From 471692c283a03f97d14bdaa3688cf655bd7ddaf4 Mon Sep 17 00:00:00 2001 From: Lutz Finsterle Date: Sat, 22 Aug 2026 09:33:16 +0200 Subject: [PATCH] Automate demo-app AppRole SecretID rotation on a weekly timer A SecretID cannot be renewed the way a token can -- it must be re-issued by a privileged caller -- so renew-app-tokens.sh structurally could not cover it. The original expired on ~2026-07-28 and broke app-get-secret.sh silently until it was noticed on 2026-08-22. This closes the last credential in the stack that had no automated lifecycle. Weekly against a 30d secret_id_ttl gives 4x margin: three consecutive failed rotations can occur before anything actually breaks, and the first failure already mails an alert via OnFailure=openbao-alert@%n.service. Order of operations is the safety property: mint -> verify the new SecretID actually authenticates -> only then overwrite the file -> only then prune old accessors. Any failure leaves the previous working credential in place and exits non-zero. Verified by pointing BAO_ADDR at a dead port: exit 1, file byte-identical, consumer unaffected. A SecretID that fails its verification login is destroyed rather than installed. Accessors are kept one cycle deep (KEEP=2: current + previous) so a consumer that read the file just before rotation can still log in. Verified across three consecutive runs: steady state stays at 2, oldest pruned each cycle. The rotator skips its verification login when secret_id_num_uses would be consumed by it, which is why the policy grants read on the role config. That read exposes TTLs and bound policies but NOT the RoleID, which lives at the separate .../role-id path and stays denied -- verified 403, along with 403 on the demo secret itself and on other AppRoles. Token at /etc/openbao-approle-rotate.token matches the /etc/openbao-*.token glob, so the renew loop picks it up automatically (verified: renewed=10 failed=0) and it cannot lapse the way the backup tokens did. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NFtVLA7VVqXL5G2S18c4Jk --- policies/approle-rotate.hcl | 31 +++++++ scripts/rotate-approle-secret-id.sh | 114 +++++++++++++++++++++++++ systemd/openbao-approle-rotate.service | 12 +++ systemd/openbao-approle-rotate.timer | 13 +++ 4 files changed, 170 insertions(+) create mode 100644 policies/approle-rotate.hcl create mode 100755 scripts/rotate-approle-secret-id.sh create mode 100644 systemd/openbao-approle-rotate.service create mode 100644 systemd/openbao-approle-rotate.timer 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