From 21929fa899f4932b0b832d28ca801c7f4db8527e Mon Sep 17 00:00:00 2001 From: Lutz Finsterle Date: Sun, 28 Jun 2026 20:28:32 +0200 Subject: [PATCH] Add personal-backup playground (KV + transit) and AppRole demo Two isolated personal-credential backup options to evaluate, both excluded from the admin policy (root-only break-glass): - Scenario A: personal/ KV v2 (versioned) + personal-{put,get,list}.sh - Scenario B: transit-personal/ key personal-backup + secret-{encrypt,decrypt}.sh Plus an AppRole example of how a system should consume a secret: - demo-app role/policy (read-only secret/demo-app/*), short-lived tokens - scripts/app-get-secret.sh: login (role_id+secret_id) -> token -> read All tokens/credentials live under ~/.config/openbao (outside the repo). Co-Authored-By: Claude Opus 4.8 --- README.md | 44 +++++++++++++++++++++++++++++++++++++++ scripts/app-get-secret.sh | 23 ++++++++++++++++++++ scripts/personal-get.sh | 18 ++++++++++++++++ scripts/personal-list.sh | 8 +++++++ scripts/personal-put.sh | 19 +++++++++++++++++ scripts/secret-decrypt.sh | 19 +++++++++++++++++ scripts/secret-encrypt.sh | 20 ++++++++++++++++++ 7 files changed, 151 insertions(+) create mode 100755 scripts/app-get-secret.sh create mode 100755 scripts/personal-get.sh create mode 100755 scripts/personal-list.sh create mode 100755 scripts/personal-put.sh create mode 100755 scripts/secret-decrypt.sh create mode 100755 scripts/secret-encrypt.sh diff --git a/README.md b/README.md index 086140a..7f317f6 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,50 @@ The push credential for the gitea remote is stored in OpenBAO KV Rotate the PAT by re-running `store-gitea-cred.sh`. The helper token is a periodic, read-only token scoped to just that one KV path. +## Personal credential backup — two playground scenarios + +Two isolated setups to evaluate using OpenBAO as a *backup* for personal +passwords. **Both are deliberately excluded from the `admin` policy** (only +root is break-glass), so your day-to-day admin token cannot read them. + +**Scenario A — structured KV** (`personal/` KV v2, versioned): + +```bash +scripts/personal-put.sh github # prompts user/password(hidden)/url +scripts/personal-list.sh +scripts/personal-get.sh github # all fields +scripts/personal-get.sh github password # one field +``` + +**Scenario B — transit-encrypted blob** (`transit-personal/` key +`personal-backup`; OpenBAO holds only the key, the ciphertext is portable): + +```bash +scripts/secret-encrypt.sh passwords-export.csv # -> passwords-export.csv.vaultenc +scripts/secret-decrypt.sh passwords-export.csv.vaultenc out.csv +``` + +Scoped tokens live in `~/.config/openbao/{personal-kv,personal-transit}.token`. +Trade-offs discussed inline; A = granular/versioned but OpenBAO holds plaintext, +B = OpenBAO holds only ciphertext (store the blob anywhere offsite). + +## Consuming a secret from a system (AppRole) + +How a *service* should authenticate (vs. a static token on disk): it holds a +**RoleID** (non-secret) + **SecretID** (secret), logs in for a **short-lived** +token, then reads. Demo role `demo-app` can read only `secret/demo-app/*`. + +```bash +scripts/app-get-secret.sh demo-app/config +# authenticated: token ttl=1200s policies=["default","demo-app"] +# { "api_key": "...", "db_url": "..." } +``` + +RoleID/SecretID in `~/.config/openbao/approle/`. The minted token is 20m TTL and +scoped to `demo-app` only — proven denied on other paths. This is the +recommended pattern for real services; the static-token helpers elsewhere in +this repo are the simpler home-lab shortcut. + ## Backups (Raft snapshots) — automated `scripts/backup-raft-snapshots.sh` snapshots **both** instances and prunes to diff --git a/scripts/app-get-secret.sh b/scripts/app-get-secret.sh new file mode 100755 index 0000000..1e1f31c --- /dev/null +++ b/scripts/app-get-secret.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# AppRole demo: how a *system* consumes a secret. +# It holds a RoleID (non-secret) + SecretID (secret), logs in to get a +# SHORT-LIVED token, then reads a secret with it. Contrast with the static-token +# helpers (git-credential-openbao.sh etc.) that keep a long-lived token on disk. +# +# app-get-secret.sh e.g. app-get-secret.sh demo-app/config +set -euo pipefail +ADDR="${BAO_ADDR:-http://127.0.0.1:8200}" +DIR="${OPENBAO_APPROLE_DIR:-$HOME/.config/openbao/approle}" +path="${1:?usage: app-get-secret.sh }" +RID="$(cat "$DIR/role_id")"; SID="$(cat "$DIR/secret_id")" + +# 1) authenticate -> short-lived token +login="$(curl -sS --data "$(RID="$RID" SID="$SID" python3 -c \ + 'import json,os;print(json.dumps({"role_id":os.environ["RID"],"secret_id":os.environ["SID"]}))')" \ + "$ADDR/v1/auth/approle/login")" +tok="$(printf '%s' "$login" | jq -r '.auth.client_token // empty')" +[ -n "$tok" ] || { echo "login failed: $(printf '%s' "$login" | jq -c '.errors // .')" >&2; exit 1; } +echo "authenticated: token ttl=$(printf '%s' "$login" | jq -r '.auth.lease_duration')s policies=$(printf '%s' "$login" | jq -c '.auth.token_policies')" >&2 + +# 2) read the secret with that token +curl -sS -H "X-Vault-Token: $tok" "$ADDR/v1/secret/data/$path" | jq '.data.data' diff --git a/scripts/personal-get.sh b/scripts/personal-get.sh new file mode 100755 index 0000000..c923875 --- /dev/null +++ b/scripts/personal-get.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Scenario A: read a personal credential back. +# personal-get.sh # show all fields +# personal-get.sh password # print one field (for piping) +set -euo pipefail +ADDR="${BAO_ADDR:-http://127.0.0.1:8200}" +TOKF="$HOME/.config/openbao/personal-kv.token" +name="${1:?usage: personal-get.sh [field]}"; field="${2:-}" +[ -r "$TOKF" ] || { echo "no token at $TOKF" >&2; exit 1; } + +resp="$(curl -sS -H "X-Vault-Token: $(cat "$TOKF")" "$ADDR/v1/personal/data/$name")" +if printf '%s' "$resp" | jq -e '.errors and (.errors|length>0)' >/dev/null 2>&1; then + echo "not found or no access: $name" >&2; exit 1; fi +if [ -n "$field" ]; then + printf '%s' "$resp" | jq -r ".data.data.$field // empty" +else + printf '%s' "$resp" | jq -r '.data.data | to_entries[] | "\(.key): \(.value)"' +fi diff --git a/scripts/personal-list.sh b/scripts/personal-list.sh new file mode 100755 index 0000000..9c2d5cf --- /dev/null +++ b/scripts/personal-list.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Scenario A: list the names stored in the personal/ KV engine. +set -euo pipefail +ADDR="${BAO_ADDR:-http://127.0.0.1:8200}" +TOKF="$HOME/.config/openbao/personal-kv.token" +[ -r "$TOKF" ] || { echo "no token at $TOKF" >&2; exit 1; } +curl -sS -H "X-Vault-Token: $(cat "$TOKF")" "$ADDR/v1/personal/metadata?list=true" \ + | jq -r '.data.keys[]? // empty' diff --git a/scripts/personal-put.sh b/scripts/personal-put.sh new file mode 100755 index 0000000..9954b04 --- /dev/null +++ b/scripts/personal-put.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Scenario A (structured KV): store a personal credential in the isolated +# `personal/` KV v2 engine (NOT readable by the day-to-day admin policy). +# personal-put.sh e.g. personal-put.sh github +set -euo pipefail +ADDR="${BAO_ADDR:-http://127.0.0.1:8200}" +TOKF="$HOME/.config/openbao/personal-kv.token" +name="${1:?usage: personal-put.sh }" +[ -r "$TOKF" ] || { echo "no token at $TOKF" >&2; exit 1; } + +read -rp "username: " u +read -rsp "password (hidden): " p; echo +read -rp "url/notes (optional): " n + +payload="$(U="$u" P="$p" N="$n" python3 -c \ + 'import json,os;print(json.dumps({"data":{"username":os.environ["U"],"password":os.environ["P"],"url":os.environ["N"]}}))')" +code="$(printf '%s' "$payload" | curl -sS -o /dev/null -w '%{http_code}' \ + -H "X-Vault-Token: $(cat "$TOKF")" --data @- "$ADDR/v1/personal/data/$name")" +case "$code" in 200|204) echo "stored personal/$name (versioned)";; *) echo "FAILED (HTTP $code)"; exit 1;; esac diff --git a/scripts/secret-decrypt.sh b/scripts/secret-decrypt.sh new file mode 100755 index 0000000..0992d43 --- /dev/null +++ b/scripts/secret-decrypt.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Scenario B: decrypt a transit blob produced by secret-encrypt.sh. +# secret-decrypt.sh [outfile] (default outfile: w/o .vaultenc, +.dec) +set -euo pipefail +ADDR="${BAO_ADDR:-http://127.0.0.1:8200}" +TOKF="$HOME/.config/openbao/personal-transit.token" +KEY="personal-backup" +in="${1:?usage: secret-decrypt.sh [outfile]}" +out="${2:-${in%.vaultenc}.dec}" +[ -r "$TOKF" ] || { echo "no token at $TOKF" >&2; exit 1; } +[ -r "$in" ] || { echo "no input file: $in" >&2; exit 1; } + +b64="$(cat "$in" \ + | python3 -c 'import json,sys;print(json.dumps({"ciphertext":sys.stdin.read().strip()}))' \ + | curl -sS -H "X-Vault-Token: $(cat "$TOKF")" --data @- "$ADDR/v1/transit-personal/decrypt/$KEY" \ + | jq -r '.data.plaintext // empty')" +[ -n "$b64" ] || { echo "decrypt failed (wrong key/blob?)" >&2; exit 1; } +printf '%s' "$b64" | base64 -d > "$out" +echo "decrypted $in -> $out" diff --git a/scripts/secret-encrypt.sh b/scripts/secret-encrypt.sh new file mode 100755 index 0000000..3df984c --- /dev/null +++ b/scripts/secret-encrypt.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Scenario B (transit blob): encrypt a file with OpenBAO's transit key +# `personal-backup`. OpenBAO holds only the KEY; the ciphertext is a portable +# blob you can store anywhere (Synology, git, offsite). Output is ASCII. +# secret-encrypt.sh [outfile] (default outfile: .vaultenc) +set -euo pipefail +ADDR="${BAO_ADDR:-http://127.0.0.1:8200}" +TOKF="$HOME/.config/openbao/personal-transit.token" +KEY="personal-backup" +in="${1:?usage: secret-encrypt.sh [outfile]}"; out="${2:-$in.vaultenc}" +[ -r "$TOKF" ] || { echo "no token at $TOKF" >&2; exit 1; } +[ -r "$in" ] || { echo "no input file: $in" >&2; exit 1; } + +ct="$(base64 -w0 "$in" \ + | python3 -c 'import json,sys;print(json.dumps({"plaintext":sys.stdin.read()}))' \ + | curl -sS -H "X-Vault-Token: $(cat "$TOKF")" --data @- "$ADDR/v1/transit-personal/encrypt/$KEY" \ + | jq -r '.data.ciphertext // empty')" +[ -n "$ct" ] || { echo "encrypt failed" >&2; exit 1; } +printf '%s\n' "$ct" > "$out" +echo "encrypted $in -> $out ($(wc -c < "$out") bytes ciphertext)"