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 <noreply@anthropic.com>
This commit is contained in:
2026-06-28 20:28:32 +02:00
parent 5a111b7a20
commit 21929fa899
7 changed files with 151 additions and 0 deletions

View File

@@ -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

23
scripts/app-get-secret.sh Executable file
View File

@@ -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 <kv-path-under-secret/> 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 <kv-path under secret/>}"
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'

18
scripts/personal-get.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Scenario A: read a personal credential back.
# personal-get.sh <name> # show all fields
# personal-get.sh <name> 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 <name> [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

8
scripts/personal-list.sh Executable file
View File

@@ -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'

19
scripts/personal-put.sh Executable file
View File

@@ -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 <name> 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 <name>}"
[ -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

19
scripts/secret-decrypt.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Scenario B: decrypt a transit blob produced by secret-encrypt.sh.
# secret-decrypt.sh <encfile> [outfile] (default outfile: <encfile> 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 <encfile> [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"

20
scripts/secret-encrypt.sh Executable file
View File

@@ -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 <infile> [outfile] (default outfile: <infile>.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 <infile> [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)"