diff --git a/policies/approle-rotate.hcl b/policies/approle-rotate.hcl new file mode 100644 index 0000000..56c7a89 --- /dev/null +++ b/policies/approle-rotate.hcl @@ -0,0 +1,31 @@ +# approle-rotate — lets the rotation timer mint and prune SecretIDs for the +# demo-app AppRole, and nothing else. Consumed via a periodic token at +# /etc/openbao-approle-rotate.token by scripts/rotate-approle-secret-id.sh. +# +# Deliberately scoped to the single role: no access to the RoleID, no ability +# to read any secret, and no reach into other AppRoles. A leak of this token +# lets an attacker mint credentials for demo-app only -- which is why the role +# itself stays limited to secret/demo-app/*. + +# Read the role config -- only for secret_id_num_uses, so the rotator knows +# whether a verification login would burn a limited-use SecretID. This exposes +# role settings (TTLs, bound policies) but NOT the RoleID, which lives at the +# separate .../role-id path and stays denied. +path "auth/approle/role/demo-app" { + capabilities = ["read"] +} + +# Generate a new SecretID, and list existing accessors for pruning. +path "auth/approle/role/demo-app/secret-id" { + capabilities = ["update", "list"] +} + +# Inspect an accessor (creation_time drives which ones are safe to prune). +path "auth/approle/role/demo-app/secret-id-accessor/lookup" { + capabilities = ["update"] +} + +# Destroy a superseded SecretID by accessor. +path "auth/approle/role/demo-app/secret-id-accessor/destroy" { + capabilities = ["update"] +} diff --git a/scripts/rotate-approle-secret-id.sh b/scripts/rotate-approle-secret-id.sh new file mode 100755 index 0000000..9c4a371 --- /dev/null +++ b/scripts/rotate-approle-secret-id.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Rotate the demo-app AppRole SecretID before its 30d TTL expires. +# +# WHY THIS EXISTS: unlike tokens, a SecretID cannot be renewed -- it must be +# re-issued by a privileged caller. So renew-app-tokens.sh structurally cannot +# cover it, and the original SecretID silently expired on ~2026-07-28, leaving +# scripts/app-get-secret.sh broken until it was noticed on 2026-08-22. +# +# ORDER OF OPERATIONS IS THE SAFETY PROPERTY. We mint, then PROVE the new +# SecretID actually authenticates, and only then overwrite the file and prune +# old accessors. A failure at any step leaves the previous working credential +# untouched and exits non-zero, so OnFailure= mails the alert. +# +# Old accessors are kept one cycle deep (KEEP=2: current + previous) so a +# consumer that read the file microseconds before rotation can still log in. +# +# Installed as openbao-approle-rotate.timer (weekly -- 4x margin on a 30d TTL, +# so three consecutive failures can occur before anything actually breaks). +set -uo pipefail + +ADDR="${BAO_ADDR:-http://127.0.0.1:8200}" +ROLE="${ROLE:-demo-app}" +TOKEN_FILE="/etc/openbao-approle-rotate.token" +OUT="${OUT:-/home/lutz/.config/openbao/approle/secret_id}" +OWNER="lutz:lutz" +KEEP="${KEEP:-2}" # accessors to retain: current + previous + +log() { printf '%s [approle-rotate] %s\n' "$(date '+%F %T')" "$*"; } +die() { log "ERROR: $*"; exit 1; } + +[ -r "$TOKEN_FILE" ] || die "token file $TOKEN_FILE not readable (run as root?)" +TOK="$(cat "$TOKEN_FILE")" +[ -n "$TOK" ] || die "token file $TOKEN_FILE is empty" +command -v jq >/dev/null || die "jq not found" + +api() { # [data] + local m="$1" p="$2" d="${3:-}" + if [ -n "$d" ]; then + curl -sS --max-time 15 -H "X-Vault-Token: $TOK" -X "$m" -d "$d" "$ADDR/v1/$p" + else + curl -sS --max-time 15 -H "X-Vault-Token: $TOK" -X "$m" "$ADDR/v1/$p" + fi +} + +# --- 0. sanity: a test login must not burn a limited-use SecretID ----------- +NUM_USES="$(api GET "auth/approle/role/$ROLE" | jq -r '.data.secret_id_num_uses // empty')" +[ -n "$NUM_USES" ] || die "cannot read role $ROLE (token scoped wrong, or role gone)" +VERIFY=1 +if [ "$NUM_USES" != "0" ] && [ "$NUM_USES" -le 1 ] 2>/dev/null; then + VERIFY=0 + log "NOTE secret_id_num_uses=$NUM_USES -- skipping verification login so it is not consumed" +fi + +# --- 1. mint --------------------------------------------------------------- +RESP="$(api POST "auth/approle/role/$ROLE/secret-id" \ + "{\"metadata\":\"{\\\"rotated_at\\\":\\\"$(date -u '+%FT%TZ')\\\",\\\"by\\\":\\\"rotate-approle-secret-id.sh\\\"}\"}")" +NEW_SID="$(printf '%s' "$RESP" | jq -r '.data.secret_id // empty')" +NEW_ACC="$(printf '%s' "$RESP" | jq -r '.data.secret_id_accessor // empty')" +[ -n "$NEW_SID" ] && [ -n "$NEW_ACC" ] \ + || die "mint failed: $(printf '%s' "$RESP" | jq -c '.errors // .')" +log "minted new SecretID (accessor ${NEW_ACC:0:8}...)" + +# --- 2. PROVE it works before touching anything ---------------------------- +if [ "$VERIFY" = "1" ]; then + RID="$(cat "$(dirname "$OUT")/role_id" 2>/dev/null)" + [ -n "$RID" ] || die "role_id file missing next to $OUT; refusing to rotate blind" + LOGIN="$(curl -sS --max-time 15 --data "$(RID="$RID" SID="$NEW_SID" python3 -c \ + 'import json,os;print(json.dumps({"role_id":os.environ["RID"],"secret_id":os.environ["SID"]}))')" \ + "$ADDR/v1/auth/approle/login" 2>/dev/null)" + TESTTOK="$(printf '%s' "$LOGIN" | jq -r '.auth.client_token // empty')" + if [ -z "$TESTTOK" ]; then + api POST "auth/approle/role/$ROLE/secret-id-accessor/destroy" \ + "{\"secret_id_accessor\":\"$NEW_ACC\"}" >/dev/null 2>&1 + die "new SecretID failed verification login; destroyed it, left existing credential in place: $(printf '%s' "$LOGIN" | jq -c '.errors // .')" + fi + # don't leave a live token lying around just because we tested + curl -sS --max-time 10 -H "X-Vault-Token: $TESTTOK" -X POST \ + "$ADDR/v1/auth/token/revoke-self" >/dev/null 2>&1 || true + log "verification login OK (test token revoked)" +fi + +# --- 3. install atomically ------------------------------------------------- +TMP="${OUT}.tmp.$$" +( umask 077; printf '%s' "$NEW_SID" > "$TMP" ) || die "cannot write $TMP" +chmod 0600 "$TMP" || die "chmod failed on $TMP" +chown "$OWNER" "$TMP" 2>/dev/null || log "WARN could not chown $TMP to $OWNER" +mv -f "$TMP" "$OUT" || die "atomic replace of $OUT failed" +log "installed new SecretID at $OUT" + +# --- 4. prune superseded accessors (keep current + previous) --------------- +mapfile -t ACCS < <(api LIST "auth/approle/role/$ROLE/secret-id" | jq -r '.data.keys[]? // empty') +if [ "${#ACCS[@]}" -gt "$KEEP" ]; then + # order by creation_time, newest first + ORDERED="$(for a in "${ACCS[@]}"; do + ct="$(api POST "auth/approle/role/$ROLE/secret-id-accessor/lookup" \ + "{\"secret_id_accessor\":\"$a\"}" | jq -r '.data.creation_time // empty')" + [ -n "$ct" ] && printf '%s\t%s\n' "$ct" "$a" + done | sort -r)" + n=0 + while IFS=$'\t' read -r ct a; do + [ -n "$a" ] || continue + n=$((n+1)) + [ "$n" -le "$KEEP" ] && continue + if api POST "auth/approle/role/$ROLE/secret-id-accessor/destroy" \ + "{\"secret_id_accessor\":\"$a\"}" >/dev/null 2>&1; then + log "pruned superseded accessor ${a:0:8}... (created $ct)" + else + log "WARN could not prune accessor ${a:0:8}..." + fi + done <<< "$ORDERED" +fi + +REMAIN="$(api LIST "auth/approle/role/$ROLE/secret-id" | jq -r '.data.keys | length // 0')" +log "done; $REMAIN SecretID accessor(s) live for role $ROLE" diff --git a/systemd/openbao-approle-rotate.service b/systemd/openbao-approle-rotate.service new file mode 100644 index 0000000..b391279 --- /dev/null +++ b/systemd/openbao-approle-rotate.service @@ -0,0 +1,12 @@ +[Unit] +Description=Rotate the demo-app AppRole SecretID before its 30d TTL expires +After=network-online.target docker.service +Wants=network-online.target +Requires=docker.service +OnFailure=openbao-alert@%n.service + +[Service] +Type=oneshot +# Runs as root: reads /etc/openbao-approle-rotate.token and writes the +# SecretID back as lutz:lutz 0600. +ExecStart=/home/lutz/Projects/OpenBAO/scripts/rotate-approle-secret-id.sh diff --git a/systemd/openbao-approle-rotate.timer b/systemd/openbao-approle-rotate.timer new file mode 100644 index 0000000..77abe05 --- /dev/null +++ b/systemd/openbao-approle-rotate.timer @@ -0,0 +1,13 @@ +[Unit] +Description=Weekly rotation of the demo-app AppRole SecretID + +[Timer] +# Weekly against a 30d secret_id_ttl: 4x margin, so three consecutive failed +# rotations can happen before the credential actually expires -- and the first +# failure already mails an alert via OnFailure=. +OnCalendar=Sun *-*-* 03:10:00 +Persistent=true +RandomizedDelaySec=15m + +[Install] +WantedBy=timers.target