From b97f9f616f2f0aea41fbb6716269767aa691b023 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 12 Oct 2026 09:41:08 +0200 Subject: [PATCH] fix: separate bootstrap encryption from private root custody Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a12d13-27f2-7163-b8c0-f682af02772c --- Makefile | 4 +- canon/standards/credential-management_v0.2.md | 148 +++++----- docs/security-bootstrap-age-custody.md | 22 ++ sso-mfa/bootstrap/README.md | 12 + sso-mfa/bootstrap/creds-bootstrap-agent.sh | 137 +++++---- sso-mfa/bootstrap/creds-status.sh | 13 +- sso-mfa/bootstrap/emergency-bundle.sh | 176 +++--------- sso-mfa/bootstrap/gen-secrets.sh | 48 +--- tests/test_bootstrap_secret_output.py | 259 ++++++++++++++++++ .../security_bootstrap_console.py | 2 +- ...-bootstrap-secret-free-recovery-handoff.md | 84 ++++++ 11 files changed, 566 insertions(+), 339 deletions(-) create mode 100644 tests/test_bootstrap_secret_output.py create mode 100644 workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md diff --git a/Makefile b/Makefile index 7af282b..cc79174 100644 --- a/Makefile +++ b/Makefile @@ -171,13 +171,13 @@ creds-rotate: ## Guided rotation for one secret: make creds-rotate SECRET= ## ── Agent-driven credential lifecycle (NK-WP-0005) ────────────────────────── -creds-agent-init: ## Fully automated credential bootstrap — generates, encrypts, injects, delivers emergency bundle +creds-agent-init: ## Attended bootstrap using the public recipient and encrypted recovery handoff @bash sso-mfa/bootstrap/creds-bootstrap-agent.sh creds-agent-status: ## Show current v2 bootstrap state (agent mode) @bash sso-mfa/bootstrap/creds-status.sh --v2 -creds-emergency-reprint: ## Re-deliver emergency bundle (if lost/stolen — reprints, rotates nothing) +creds-emergency-reprint: ## Print the non-secret recovery guide; no keys or passwords are read @bash sso-mfa/bootstrap/emergency-bundle.sh --reprint openbao-init-unseal: ## SOPS-held OpenBao init/unseal (NET-WP-0020 T2; requires sops-held-automation model selected) diff --git a/canon/standards/credential-management_v0.2.md b/canon/standards/credential-management_v0.2.md index 9d7fc13..f98a0b8 100644 --- a/canon/standards/credential-management_v0.2.md +++ b/canon/standards/credential-management_v0.2.md @@ -1,5 +1,5 @@ # Credential Management Standard — net-kingdom -**Version:** 0.2 **Status:** current with OpenBao runtime refinement **Supersedes:** v0.1 (retired with NK-WP-0004) +**Version:** 0.2 **Status:** current with OpenBao runtime and secret-free handoff refinements **Supersedes:** v0.1 (retired with NK-WP-0004) --- @@ -17,7 +17,7 @@ control plane. ## 2. Trust Hierarchy ``` -age private key ─────────────► encrypts secrets.enc/ in git +age public recipient ────────► encrypts secrets.enc/ in git │ └──► ops bundle (encrypted snapshot of all secrets) └──► stored offsite; decrypt with age key @@ -49,30 +49,36 @@ authority once the platform control plane is alive. ## 3. Credential Lifecycle -### Phase 0 — Bootstrap (run once, agent-driven) +### Phase 0 — Bootstrap (attended, public-recipient encryption) -``` -make creds-agent-init -``` +`make creds-agent-init` retains its compatibility name but runs in the operator's +attended terminal. It requires an independently registered `keys/age.pub`; it +never generates a root key or searches HOME for one. The operator separately +establishes custody of the matching private key before bootstrap. -This single command runs the full bootstrap end-to-end: -1. Verifies prerequisites (age, kubectl, openssl, cluster) -2. Generates or verifies the age keypair at `~/.config/sops/age/keys.txt` -3. Generates all service secrets via `gen-secrets.sh` -4. Encrypts them to `secrets.enc/` with age and commits -5. Injects them into the cluster via `creds-apply.sh` -6. Verifies all K8s Secrets exist -7. Waits for privacyIDEA to be Ready, then runs enckey bootstrap + admin creation -8. Applies KeyCape secrets (requires pi-admin) -9. Hands off runtime secret authority to OpenBao when the Railiance - platform layer is present and verified -10. Creates the ops bundle (age-encrypted snapshot) -11. Delivers the emergency bundle to the terminal for human storage - ← **only human touchpoint** -12. Shreds all plaintext and marks `bootstrap_complete: true` +The workflow generates service files with private permissions, encrypts with the +public recipient, applies and verifies the scoped cluster setup, then creates a +fresh encrypted ops bundle. The recovery handoff displays its location and digest. +The operator stores that exact artifact off-host and verifies decryption in an +isolated recovery workspace before typing `STORED AND VERIFIED`. Only then does +bootstrap remove plaintext and mark completion. This acknowledgment is a human +statement; a separate native drill receipt is required to establish recovery. -The script resumes from where it left off if interrupted — each phase is -tracked in `creds-state.yaml`. +For an encrypted resume with no plaintext service directory, invoke bootstrap +through `sops-custody-unlock.sh` from the operator terminal. Only the explicitly +supplied temporary `NETKINGDOM_CUSTODY_AGE_KEY_FILE` is used, and it must match the +registered public recipient before any cluster call. There is no ambient key +fallback. Use the owner-approved target and change scope; the helper supplies +custody material, not authorization. + +`bash sso-mfa/bootstrap/creds-bootstrap-agent.sh --dry-run` emits a non-mutating +plan without reading keys or state, contacting the cluster, or creating files. +A terminal check prevents accidental transcript-driven execution; it is not a +credential-isolation boundary or proof of the caller's identity. + +Historical `emergency_bundle_delivered` fields remain historical. New receipts +use `recovery_handoff_confirmed`, `recovery_handoff_bundle_sha256` and a timestamp; +an old true value cannot skip acknowledgment of a newly generated bundle. ### Phase 1 — Normal operation @@ -99,60 +105,42 @@ See Section 6 for details. ### Phase 3 — Recovery Use the emergency bundle stored in your personal password manager. -See Section 4 — Emergency Bundle. +See Section 4 — Emergency Recovery Handoff. --- -## 4. Emergency Bundle +## 4. Emergency Recovery Handoff -### Contents +The custodian age private key and platform-root account/factors stay in the +independently held password safe/offline custody packet. OpenBao init/unseal +material follows its separately selected custody ceremony; the lab SOPS-held +automation branch is outside the new workflow tests and retains its own gate. Service recovery files +travel in the age-encrypted ops bundle. The bundle must not contain its own +private decryption key. Current workload authority remains in OpenBao. -| Item | Purpose | -|------|---------| -| age private key | Decrypt any `secrets.enc/*.age` file from git | -| privacyIDEA admin password | Direct access to privacyIDEA if cluster/auth is down | -| LLDAP admin password | Direct LDAP directory access | -| PostgreSQL root password | Direct database access | -| break-glass user password | Emergency login if Authelia/KeyCape is down | -| ops bundle location + decrypt command | Point-in-time snapshot of all secrets | -| OpenBao unseal/recovery material, if used | Platform-control-plane break-glass only | +`make creds-emergency-reprint` is now a **non-secret recovery guide**. It reads no +private key or service secret, writes no plaintext copy and cannot reconstruct +lost custody material. Legacy secret-export arguments are refused. Clearing a +terminal or shredding a temporary file cannot retract an existing transcript. -### Delivery +Recovery sequence: -The emergency bundle is displayed once in the terminal at the end of -`make creds-agent-init`. The operator must copy it into their personal -password manager before pressing Enter to continue. +1. Retrieve the separately held key and exact encrypted snapshot from their + independent custody locations. Check the non-secret digest/receipt. +2. Use the custody unlock helper in the approved isolated workspace. Decrypt to + private files, never stdout; verify required service files without printing + their contents. Consult `docs/security-bootstrap-age-custody.md`. +3. Execute the scoped service owner's recovery procedure with the approved + cluster/target. A historical snapshot can contain superseded credentials; + validate current authority and consumer acceptance before reuse. +4. Remove temporary keys and decrypted files, and retain only safe outcomes, + artifact identities and timestamps. Loss or suspected disclosure requires + the separate owner rotation/revocation process; guide output rotates nothing. -The confirmation gate is a **deliberate security control** — bootstrap -does not complete until the human confirms receipt. - -### Re-delivery - -If the emergency bundle is lost or stolen: - -``` -make creds-emergency-reprint -``` - -This reprints the bundle from the current age key and secrets. -It does **not** rotate any secrets — if you suspect compromise, rotate -the affected secrets separately with `make creds-rotate`. - -### Storage recommendations - -Store the emergency bundle in one or more of: -- 1Password, Bitwarden, KeePassXC, or equivalent -- Encrypted offline storage -- Printed and physically secured (air-gapped scenarios) - -The agent does not care which — it only cares that you confirm receipt. - -### Recovery procedure - -1. Restore `~/.config/sops/age/keys.txt` from the emergency bundle -2. Clone net-kingdom repo -3. Run: `make creds-apply` — re-injects all secrets from `secrets.enc/` -4. Use break-glass passwords for direct service access if needed +The 2026-10-12 source correction removes the old stdout handoff. It does not +retroactively revoke any root or password disclosed by earlier runs. Existing +founder-deferred exposure rotation remains in CUST-WP-0073-T05. Native acceptance +of the corrected workflow remains NK-WP-0049; source tests do not certify it. --- @@ -244,7 +232,9 @@ make creds-bundle Store the bundle offsite (cloud, external drive, second location). Decrypt: ``` -age -d -i ~/.config/sops/age/keys.txt ops-bundle-.tar.age | tar xf - +# In the approved isolated recovery workspace, with the custody helper: +age -d -i "$NETKINGDOM_CUSTODY_AGE_KEY_FILE" -o secrets.tar ops-bundle-.tar.age +# Inspect/extract privately under the owner procedure; never print file contents. ``` --- @@ -256,19 +246,21 @@ The following are permanently prohibited: 1. **Committing `secrets/` (plaintext) to git** — the pre-commit hook blocks this; do not bypass with `--no-verify` -2. **Storing secrets in KeePassXC as the primary credential store** — KeePassXC - is for optional personal backup of the emergency bundle only +2. **Using a personal safe as runtime workload secret authority** — runtime + workload custody belongs to OpenBao. Independently held bootstrap/root + custody and offline recovery remain distinct from runtime workload delivery. -3. **Skipping the `emergency_bundle_delivered` confirmation gate** — even in - non-interactive or automated runs, the agent MUST NOT mark bootstrap - complete until the human confirms receipt. This gate exists to ensure - break-glass access is always available. +3. **Skipping the current encrypted-recovery handoff acknowledgment** — a + historical `emergency_bundle_delivered` value cannot certify a new bundle. + Completion requires the attended acknowledgment bound to its SHA256; native + recovery readiness still requires the separate drill receipt. 4. **Hardcoding secrets in manifests or ConfigMaps** — all service secrets must flow through K8s Secrets created by `create-secrets.sh` scripts -5. **Storing the age private key in the repo** — the key lives outside the - repo at `~/.config/sops/age/keys.txt` +5. **Storing or printing the root key through automation** — the private key + belongs in independent custody. Only the explicit attended unlock supplies a + temporary key; no permanent HOME key discovery or generation in bootstrap. 6. **Using OpenBao as a policy decision point** — OpenBao stores, leases, audits, and revokes secret material; identity comes from the IAM diff --git a/docs/security-bootstrap-age-custody.md b/docs/security-bootstrap-age-custody.md index 1e9dbcc..d38bb97 100644 --- a/docs/security-bootstrap-age-custody.md +++ b/docs/security-bootstrap-age-custody.md @@ -130,3 +130,25 @@ make sops-custody-shell Run the required SOPS, recovery, or lockdown commands inside that shell. Exit the shell to shred the temporary key file. + +## Bootstrap implementation correction — 2026-10-12 + +The compatibility `creds-agent-init` command now encrypts using `keys/age.pub`. +It does not generate or discover private keys. Live bootstrap requires the +operator terminal; `--dry-run` is a non-mutating plan. For an encrypted resume, +use the explicit custody helper with the independently held matching key. The +bootstrap validates that temporary key before contacting the cluster. + +The former emergency-bundle export is a non-secret guide. The current encrypted +ops bundle is handed off by location and SHA256, and a fresh operator +acknowledgment is required before plaintext cleanup/completion. Old delivery +flags retain their historical meaning and do not acknowledge a new artifact. +Generation no longer logs value prefixes and creates private files even under a +permissive caller umask. These controls do not enforce agent runtime isolation. + +Recovery verification uses an owner-approved isolated workspace and target. With +the helper's temporary key, decrypt the exact bundle to a private file, verify +its expected contents without printing values, then remove decrypted files and +retain a non-secret receipt. Acknowledgment of a guide is not that verification. +No live root or password was read or rotated during the source correction. +NK-WP-0049 tracks remaining native acceptance and other legacy custody callers. diff --git a/sso-mfa/bootstrap/README.md b/sso-mfa/bootstrap/README.md index e73c9e4..d868ea1 100644 --- a/sso-mfa/bootstrap/README.md +++ b/sso-mfa/bootstrap/README.md @@ -116,6 +116,18 @@ See `../vault/` (created in T01 Phase 0b) for: - Vault secret path layout - Migration procedure: KeePassXC → Vault +## Current bootstrap and recovery handoff + +`make creds-agent-init` is now attended and public-recipient based. Register +`keys/age.pub` from independently established custody first. No root private key +is generated or discovered by bootstrap. `--dry-run` is a non-mutating plan. +`make creds-emergency-reprint` prints only the non-secret recovery guide. + +For a resume that needs decryption, use the custody unlock helper below. After +apply, the operator must store and independently verify the fresh encrypted ops +bundle before acknowledging its handoff. Source tests use disposable keys and a +simulated cluster; native acceptance remains NK-WP-0049. + ## Custody Age Key Unlock For SOPS-backed drills and incident work, do not copy the custodian age private diff --git a/sso-mfa/bootstrap/creds-bootstrap-agent.sh b/sso-mfa/bootstrap/creds-bootstrap-agent.sh index 817b005..9ef31c7 100755 --- a/sso-mfa/bootstrap/creds-bootstrap-agent.sh +++ b/sso-mfa/bootstrap/creds-bootstrap-agent.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash -# creds-bootstrap-agent.sh — fully automated credential bootstrap (NK-WP-0005) +# creds-bootstrap-agent.sh — attended bootstrap with public-recipient encryption # # Usage: # bash sso-mfa/bootstrap/creds-bootstrap-agent.sh [--dry-run] [--resume] # make creds-agent-init # -# Runs end-to-end without human input until the emergency bundle confirmation -# gate. Each phase updates creds-state.yaml so interrupted runs resume +# Runs in the operator terminal and ends at the encrypted recovery handoff +# acknowledgment. Each phase updates creds-state.yaml so interrupted runs resume # automatically from where they left off. # # Prerequisites: @@ -14,19 +14,36 @@ # - kubectl with a reachable cluster (KUBECONFIG set or ~/.kube/config) # - git (configured with commit access) # - openssl -# - ~/.config/sops/age/keys.txt — age private key (generated here if missing) +# - keys/age.pub — independently registered public recipient +# - temporary NETKINGDOM_CUSTODY_AGE_KEY_FILE only when resuming encrypted input +set +x set -euo pipefail +umask 077 SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" K8S_DIR="$REPO_ROOT/sso-mfa/k8s" SECRETS_DIR="$SCRIPT_DIR/secrets" STATE_FILE="$SCRIPT_DIR/creds-state.yaml" -AGE_KEY="$HOME/.config/sops/age/keys.txt" +AGE_KEY="${NETKINGDOM_CUSTODY_AGE_KEY_FILE:-}" DRY_RUN=false -for arg in "$@"; do [[ "$arg" == "--dry-run" ]] && DRY_RUN=true; done +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + --resume) ;; # Phase state already controls resume. + *) printf '%s\n' 'ERROR: unsupported bootstrap argument.' >&2; exit 2 ;; + esac +done + +# A plan must neither discover credentials nor contact a cluster or change state. +if [[ "$DRY_RUN" == true ]]; then + printf '%s\n' 'Plan only: validate registered public recipient; generate or attended-decrypt service files; encrypt; apply/verify; create encrypted ops bundle; acknowledge off-host recovery; clean plaintext.' + printf '%s\n' 'No files, credentials, cluster state or historical custody receipts were inspected or changed.' + exit 0 +fi +[[ -t 0 && -t 1 ]] || { printf '%s\n' 'ERROR: bootstrap requires an attended terminal; use --dry-run for the non-mutating plan.' >&2; exit 2; } # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -52,7 +69,11 @@ state_get_nested() { grep -E "^ $1:" "$STATE_FILE" | sed 's/^[^:]*: *//' | sed state_set() { local key="$1" value="$2" if [[ -f "$STATE_FILE" ]]; then - sed -i "s|^$key: .*|$key: $value|" "$STATE_FILE" + if grep -q "^$key:" "$STATE_FILE"; then + sed -i "s|^$key: .*|$key: $value|" "$STATE_FILE" + else + printf '%s: %s\n' "$key" "$value" >> "$STATE_FILE" + fi fi } @@ -74,32 +95,20 @@ command -v git >/dev/null 2>&1 || die "git not found" command -v openssl >/dev/null 2>&1 || die "openssl not found" ok "required tools present" -# Age key — generate if missing -if [[ ! -f "$AGE_KEY" ]]; then - log "age key not found at $AGE_KEY — generating..." - mkdir -p "$(dirname "$AGE_KEY")" - if [[ "$DRY_RUN" == false ]]; then - age-keygen -o "$AGE_KEY" 2>/dev/null - chmod 600 "$AGE_KEY" - ok "age key generated: $AGE_KEY" - log "Public key: $(grep 'public key:' "$AGE_KEY" | awk '{print $NF}')" - else - echo " [dry-run] would run: age-keygen -o $AGE_KEY" - fi -fi +# Encryption needs only the registered public recipient. Never auto-generate +# or discover a private key in HOME. Custody source is independently held. +[[ -f "$REPO_ROOT/keys/age.pub" ]] || die "registered keys/age.pub is missing" +AGE_PUBKEY=$(grep -m1 '^age1' "$REPO_ROOT/keys/age.pub" || true) +[[ -n "$AGE_PUBKEY" ]] || die "registered public age recipient is missing" +printf '' | age -r "$AGE_PUBKEY" >/dev/null 2>&1 || die "registered public age recipient is invalid" -if [[ -f "$AGE_KEY" ]]; then - AGE_PUBKEY=$(grep 'public key:' "$AGE_KEY" | awk '{print $NF}') - [[ -z "$AGE_PUBKEY" ]] && die "could not read public key from $AGE_KEY" - ok "age key ready: ${AGE_PUBKEY:0:20}…" - state_set "age_key_present" "true" -elif [[ "$DRY_RUN" == true ]]; then - # Dry-run on a machine without the age key (key generation was skipped - # above): continue with a placeholder recipient so later phases can render. - AGE_PUBKEY="age1dryrunplaceholderrecipient" - ok "age key absent — dry-run continues with placeholder recipient" -else - die "could not read public key from $AGE_KEY" +# Refuse an encrypted resume before cluster activity unless the owner explicitly +# supplied a temporary key through the custody helper. No ambient key fallback. +if [[ "$(state_get secrets_generated)" == "true" && ! -d "$SECRETS_DIR" ]]; then + [[ -n "$AGE_KEY" && -f "$AGE_KEY" ]] || die "encrypted resume requires the attended custody unlock helper" + DERIVED_RECIPIENT=$(age-keygen -y "$AGE_KEY" 2>/dev/null) || die "custody key validation failed" + [[ "$DERIVED_RECIPIENT" == "$AGE_PUBKEY" ]] || die "custody key does not match the registered recipient" + unset DERIVED_RECIPIENT fi # Cluster reachability @@ -108,6 +117,8 @@ if ! kubectl cluster-info &>/dev/null; then fi KUBE_CTX=$(kubectl config current-context 2>/dev/null || echo '(unknown)') ok "cluster reachable: $KUBE_CTX" +state_set "age_public_recipient_ready" "true" +state_set "bootstrap_complete" "false" # ── Phase 1: Generate secrets ───────────────────────────────────────────────── @@ -141,8 +152,7 @@ step "2 — Encrypt secrets to secrets.enc/ and commit" if [[ "$(state_get secrets_generated)" == "true" && ! -d "$SECRETS_DIR" ]]; then log "secrets/ absent (was shredded) — decrypting from secrets.enc/ for re-apply..." if [[ "$DRY_RUN" == false ]]; then - (cd "$SCRIPT_DIR" && bash decrypt-secrets.sh "$SECRETS_DIR" "$HOME/.config/net-kingdom/age.key" 2>/dev/null) \ - || (cd "$SCRIPT_DIR" && bash decrypt-secrets.sh "$SECRETS_DIR" "$AGE_KEY") + (cd "$SCRIPT_DIR" && bash decrypt-secrets.sh "$SECRETS_DIR" "$AGE_KEY") else echo " [dry-run] would decrypt secrets.enc/ → secrets/" fi @@ -151,7 +161,7 @@ fi # Always (re-)encrypt in case secrets were just regenerated if [[ "$DRY_RUN" == false ]]; then log "encrypting secrets → secrets.enc/ ..." - (cd "$SCRIPT_DIR" && bash encrypt-secrets.sh "$SECRETS_DIR" "$AGE_KEY" --no-shred) + (cd "$SCRIPT_DIR" && bash encrypt-secrets.sh "$SECRETS_DIR" "$AGE_PUBKEY" --no-shred) ok "secrets encrypted to secrets.enc/" # Commit the encrypted secrets @@ -328,7 +338,7 @@ if kubectl get namespace openbao &>/dev/null; then if [[ -d "$SECRETS_DIR/openbao" ]]; then log "encrypting OpenBao init material → secrets.enc/ ..." (cd "$SCRIPT_DIR" && bash encrypt-secrets.sh \ - "$SECRETS_DIR" "$AGE_KEY" --no-shred) + "$SECRETS_DIR" "$AGE_PUBKEY" --no-shred) cd "$REPO_ROOT" git add sso-mfa/bootstrap/secrets.enc/ \ sso-mfa/bootstrap/creds-state.yaml @@ -350,46 +360,25 @@ fi step "8 — Create ops bundle (age-encrypted snapshot)" -BUNDLE_NAME="ops-bundle-$(date +%Y%m%dT%H%M%S).tar.age" -BUNDLE_PATH="$REPO_ROOT/$BUNDLE_NAME" +BUNDLE_PATH=$(mktemp "$REPO_ROOT/ops-bundle-$(date +%Y%m%dT%H%M%S)-XXXXXXXX.tar.age") -if [[ "$(state_get ops_bundle_created)" == "true" ]]; then - EXISTING_LOC="$(state_get ops_bundle_location)" - ok "ops bundle already created: ${EXISTING_LOC:-} — skipping" -else - if [[ "$DRY_RUN" == false ]]; then - [[ -d "$SECRETS_DIR" ]] || die "secrets/ not found — cannot create ops bundle (re-run from phase 1)" - log "creating ops bundle → $BUNDLE_PATH" - (cd "$SCRIPT_DIR" && bash pack-bundle.sh "$SECRETS_DIR" "$AGE_PUBKEY" "$BUNDLE_PATH") - state_set "ops_bundle_created" "true" - state_set "ops_bundle_location" "\"$BUNDLE_PATH\"" - ok "ops bundle created: $BUNDLE_PATH" - else - echo " [dry-run] would run: bash pack-bundle.sh $SECRETS_DIR $AGE_PUBKEY $BUNDLE_PATH" - fi -fi +# Always snapshot the current service files after apply. An old phase boolean +# must not cause a newly generated/rotated file to be omitted from recovery. +[[ -d "$SECRETS_DIR" ]] || die "service files absent; cannot create recovery bundle" +(cd "$SCRIPT_DIR" && bash pack-bundle.sh "$SECRETS_DIR" "$AGE_PUBKEY" "$BUNDLE_PATH") +BUNDLE_DIGEST=$(sha256sum "$BUNDLE_PATH" | cut -d ' ' -f 1) +state_set "ops_bundle_created" "true" +state_set "ops_bundle_location" "\"$BUNDLE_PATH\"" +state_set "recovery_handoff_confirmed" "false" -# ── Phase 9: Emergency bundle ───────────────────────────────────────────────── - -step "9 — Emergency bundle (human confirmation required)" - -if [[ "$(state_get emergency_bundle_delivered)" == "true" ]]; then - ok "emergency bundle already delivered — skipping" -else - if [[ "$DRY_RUN" == false ]]; then - log "assembling emergency bundle..." - OPS_LOC="$(state_get ops_bundle_location | tr -d '"')" - (cd "$SCRIPT_DIR" && bash emergency-bundle.sh \ - --age-key "$AGE_KEY" \ - --secrets-dir "$SECRETS_DIR" \ - --ops-bundle "${OPS_LOC:-$BUNDLE_PATH}") - state_set "emergency_bundle_delivered" "true" - state_set "emergency_bundle_delivered_at" "\"$(date -Iseconds)\"" - ok "emergency bundle delivered and confirmed" - else - echo " [dry-run] would run: bash emergency-bundle.sh ..." - fi -fi +# ── Phase 9: Encrypted recovery handoff ────────────────────────────────────── +step "9 — Encrypted recovery handoff (operator acknowledgment required)" +printf 'Encrypted bundle: %s\nSHA256: %s\n' "$BUNDLE_PATH" "$BUNDLE_DIGEST" +bash "$SCRIPT_DIR/emergency-bundle.sh" --confirm +state_set "recovery_handoff_confirmed" "true" +state_set "recovery_handoff_bundle_sha256" "\"$BUNDLE_DIGEST\"" +state_set "recovery_handoff_confirmed_at" "\"$(date -Iseconds)\"" +# Historical emergency_bundle_delivered fields are preserved, never reinterpreted. # ── Phase 10: Cleanup + finalise ────────────────────────────────────────────── diff --git a/sso-mfa/bootstrap/creds-status.sh b/sso-mfa/bootstrap/creds-status.sh index 5dec16d..a50200a 100755 --- a/sso-mfa/bootstrap/creds-status.sh +++ b/sso-mfa/bootstrap/creds-status.sh @@ -9,7 +9,8 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -STATE_FILE="${1:-$SCRIPT_DIR/creds-state.yaml}" +STATE_FILE="$SCRIPT_DIR/creds-state.yaml" +[[ "${1:-}" == "--v2" || $# -eq 0 ]] || STATE_FILE="$1" # --v2 flag forces v2 display regardless of schema_version field FORCE_V2=false @@ -49,16 +50,22 @@ if [[ "$SCHEMA_VER" == "2" || "$FORCE_V2" == "true" ]]; then emerg_at="$(top_val emergency_bundle_delivered_at)" bootstrap_complete="$(top_val bootstrap_complete)" - printf " %-32s %s\n" "age key present:" "$(status_icon "$age_key")" + printf " %-32s %s\n" "historical age key present:" "$(status_icon "$age_key")" printf " %-32s %s\n" "secrets generated:" "$(status_icon "$secrets_gen")" printf " %-32s %s %s\n" "ops bundle created:" \ "$(status_icon "$ops_bundle")" \ "$([ "$ops_bundle" = "true" ] && echo "${ops_loc:-}" || true)" - printf " %-32s %s %s\n" "emergency bundle delivered:" \ + printf " %-32s %s %s\n" "historical emergency delivery:" \ "$(status_icon "$emerg_delivered")" \ "$([ "$emerg_delivered" = "true" ] && echo "at ${emerg_at:-}" || echo "(pending human confirmation)")" echo "" + recovery="$(top_val recovery_handoff_confirmed || true)" + digest="$(top_val recovery_handoff_bundle_sha256 || true)" + printf " %-32s %s\n" "current recovery acknowledgment:" "$(status_icon "$recovery")" + printf " %-32s %s\n" "acknowledged bundle SHA256:" "${digest:-not recorded}" + echo " Acknowledgment does not establish native recovery readiness." + echo "" echo " Secrets applied:" for component in postgres lldap authelia privacyidea keycape; do val="$(nested_val "$component")" diff --git a/sso-mfa/bootstrap/emergency-bundle.sh b/sso-mfa/bootstrap/emergency-bundle.sh index 2b22ac1..d85c6cb 100755 --- a/sso-mfa/bootstrap/emergency-bundle.sh +++ b/sso-mfa/bootstrap/emergency-bundle.sh @@ -1,147 +1,45 @@ #!/usr/bin/env bash -# emergency-bundle.sh — assemble and display the net-kingdom emergency credential bundle -# -# Usage: -# bash sso-mfa/bootstrap/emergency-bundle.sh \ -# --age-key (default: ~/.config/sops/age/keys.txt) -# --secrets-dir (default: ./secrets) -# --ops-bundle (path to the .tar.age ops bundle) -# [--reprint] (reprint a previously-delivered bundle without -# requiring all secrets to be present) -# -# Displays the emergency bundle on stdout, optionally writes a temp file -# for 60 seconds, then prompts for human confirmation. -# Sets emergency_bundle_delivered: true in creds-state.yaml after confirmation. - +# Compatibility entry point: metadata-only recovery guide; never export roots. +set +x set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -STATE_FILE="$SCRIPT_DIR/creds-state.yaml" +CONFIRM=false +case "${1:-}" in + ""|--reprint|--help) [[ $# -le 1 ]] || exit 2 ;; + --confirm) [[ $# -eq 1 ]] || exit 2; CONFIRM=true ;; + *) printf '%s\n' 'ERROR: secret-export arguments are retired; see docs/security-bootstrap-age-custody.md.' >&2; exit 2 ;; +esac -AGE_KEY="$HOME/.config/sops/age/keys.txt" -SECRETS_DIR="$SCRIPT_DIR/secrets" -OPS_BUNDLE="" -REPRINT=false - -# ── Argument parsing ────────────────────────────────────────────────────────── -while [[ $# -gt 0 ]]; do - case "$1" in - --age-key) AGE_KEY="$2"; shift 2 ;; - --secrets-dir) SECRETS_DIR="$2"; shift 2 ;; - --ops-bundle) OPS_BUNDLE="$2"; shift 2 ;; - --reprint) REPRINT=true; shift ;; - *) echo "Unknown argument: $1" >&2; exit 1 ;; - esac -done - -# ── Read helpers ────────────────────────────────────────────────────────────── - -read_env_val() { - local file="$1" key="$2" - grep -E "^${key}=" "$file" 2>/dev/null | head -1 | sed "s/^${key}=//" || echo "" -} - -read_env() { - local component="$1" key="$2" - local f="$SECRETS_DIR/$component/secrets.env" - if [[ -f "$f" ]]; then - read_env_val "$f" "$key" - else - echo "" - fi -} - -# ── Collect values ──────────────────────────────────────────────────────────── - -# Age private key -if [[ -f "$AGE_KEY" ]]; then - AGE_PRIVATE_KEY=$(cat "$AGE_KEY") - AGE_PUBKEY=$(grep 'public key:' "$AGE_KEY" | awk '{print $NF}' || echo "") -else - AGE_PRIVATE_KEY="" - AGE_PUBKEY="" +if [[ "$CONFIRM" == true && ( ! -t 0 || ! -t 1 ) ]]; then + printf '%s\n' 'ERROR: recovery acknowledgment requires an attended terminal.' >&2 + exit 2 fi -# Break-glass passwords -PI_ADMIN_PASS="$(read_env privacyidea PI_ADMIN_PASSWORD)" -LLDAP_ADMIN_PASS="$(read_env lldap LLDAP_LDAP_USER_PASS)" -PG_ROOT_PASS="$(read_env postgres PG_ROOT_PASSWORD)" -BG_PASS="$(read_env breakglass BREAKGLASS_PASSWORD)" +cat <<'GUIDE' +NetKingdom recovery guide (non-secret) -# Ops bundle info -OPS_BUNDLE_LOCATION="${OPS_BUNDLE:-}" -if [[ -n "$AGE_PUBKEY" ]]; then - DECRYPT_CMD="age -d -i ops-bundle-.tar.age" -else - DECRYPT_CMD="age -d -i ops-bundle-.tar.age" +The private age key stays in the operator password safe or offline custody. +This command never reads, generates or displays that key or any password. +The encrypted ops bundle holds service recovery material; it does not hold +its own decryption key. Preserve an off-host copy and keep the key separately. + +Before acknowledging a newly created bundle: +1. Store the exact encrypted bundle off-host. +2. In the approved isolated recovery workspace, use the custody unlock helper + to verify that the independently held key can decrypt that bundle. +3. Confirm the needed service recovery files are present without printing them. +4. Remove decrypted drill files and retain only the non-secret drill receipt. + +For an actual incident, use the scoped owner recovery procedure. An old +bootstrap snapshot may contain superseded passwords. Guide output or a human +acknowledgment is not a native restore test or evidence of current credentials. +See docs/security-bootstrap-age-custody.md and docs/security-bootstrap-king-credential-kit.md. +GUIDE + +if [[ "$CONFIRM" == true ]]; then + printf '%s' 'After completing those steps, type STORED AND VERIFIED: ' >&2 + IFS= read -r answer || exit 2 + [[ "$answer" == 'STORED AND VERIFIED' ]] || { printf '%s\n' 'ERROR: custody acknowledgment not received.' >&2; exit 2; } + unset answer + printf '%s\n' 'Recovery handoff acknowledged by the operator; retain the independent drill receipt.' fi - -GENERATED_DATE="$(date -Iseconds)" - -# ── Temp file (shredded automatically) ─────────────────────────────────────── - -TMPFILE="$(mktemp --suffix=-emergency-bundle.txt)" -cleanup_tmp() { shred -u "$TMPFILE" 2>/dev/null || rm -f "$TMPFILE"; } -trap cleanup_tmp EXIT - -# ── Assemble bundle text ────────────────────────────────────────────────────── - -BUNDLE_TEXT="$(cat < "$TMPFILE" -chmod 600 "$TMPFILE" - -# ── Display ─────────────────────────────────────────────────────────────────── - -echo "" -echo "$BUNDLE_TEXT" -echo "" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo " A copy has been written to: $TMPFILE" -echo " It will be shredded automatically when you confirm below." -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "" -echo " Store the above in your personal password manager now." -echo " (1Password, Bitwarden, KeePassXC, paper — your choice.)" -echo "" -read -rp " Press Enter when you have stored the bundle (this clears the screen and shreds the temp file): " - -# Shred temp file (trap handles it but do it explicitly here first) -shred -u "$TMPFILE" 2>/dev/null || rm -f "$TMPFILE" -trap - EXIT - -# Clear screen to remove secrets from terminal scrollback -clear 2>/dev/null || printf '\033c' -echo "" -echo " ✔ Emergency bundle confirmed. Temp file shredded." -echo "" diff --git a/sso-mfa/bootstrap/gen-secrets.sh b/sso-mfa/bootstrap/gen-secrets.sh index 64bb959..63adee7 100755 --- a/sso-mfa/bootstrap/gen-secrets.sh +++ b/sso-mfa/bootstrap/gen-secrets.sh @@ -17,7 +17,9 @@ # Extract the resulting file, store it in KeePassXC as a binary attachment on the # privacyIDEA/PI_ENCFILE entry, then create the k8s secret from it. +set +x set -euo pipefail +umask 077 OUT_DIR="${1:-./secrets}" @@ -150,45 +152,7 @@ cat > "$OUT_DIR/breakglass/secrets.env" <= 8: + prefixes.append(value[:8]) + assert len(prefixes) >= 10 + # Boolean only: test failure must not display generated material. + assert not any(prefix in transcript for prefix in prefixes) + + +def test_dry_run_does_not_read_root_contact_cluster_or_mutate_state(tmp_path, private_env): + checkout = tmp_path / 'checkout' + bootstrap = checkout / 'sso-mfa/bootstrap' + bootstrap.mkdir(parents=True) + for name in ['creds-bootstrap-agent.sh', 'creds-state.yaml']: + shutil.copyfile(BOOTSTRAP / name, bootstrap / name) + state = bootstrap / 'creds-state.yaml' + before = state.read_bytes() + bindir = tmp_path / 'bin'; bindir.mkdir() + marker = tmp_path / 'unexpected-call' + for name in ['kubectl', 'age', 'age-keygen', 'git', 'openssl']: + exe = bindir / name + exe.write_text('#!/bin/sh\nprintf called >> "$UNEXPECTED_CALL"\nexit 1\n') + exe.chmod(0o755) + env = {**private_env, 'PATH': str(bindir) + ':' + os.environ['PATH'], 'UNEXPECTED_CALL': str(marker)} + result = subprocess.run(['bash', str(bootstrap / 'creds-bootstrap-agent.sh'), '--dry-run'], + env=env, capture_output=True, text=True, timeout=5) + assert result.returncode == 0 + assert state.read_bytes() == before + assert not marker.exists() + assert not (Path(env['HOME']) / '.config/sops/age').exists() + + +def test_unattended_live_bootstrap_refuses_before_effects(tmp_path, private_env): + result = subprocess.run(['bash', str(BOOTSTRAP / 'creds-bootstrap-agent.sh')], + env=private_env, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=5) + assert result.returncode != 0 + assert 'attended terminal' in result.stderr + assert not (Path(private_env['HOME']) / '.config/sops/age').exists() + + +def run_attended(script, env, answer=b'STORED AND VERIFIED\n'): + import pty + import select + import time + master, slave = pty.openpty() + child = subprocess.Popen(['bash', str(script)], env=env, stdin=slave, stdout=slave, + stderr=slave, start_new_session=True) + os.close(slave) + transcript = bytearray() + sent = False + deadline = time.monotonic() + 15 + try: + while time.monotonic() < deadline: + if select.select([master], [], [], 0.05)[0]: + try: + chunk = os.read(master, 65536) + except OSError: + break + if not chunk: + break + transcript.extend(chunk) + if b'type STORED AND VERIFIED:' in transcript and not sent: + os.write(master, answer) + sent = True + if child.poll() is not None: + # Drain the pty on the following select until EOF. + continue + return child.wait(timeout=2), bytes(transcript) + finally: + if child.poll() is None: + child.kill() + child.wait() + os.close(master) + + +@pytest.fixture +def bootstrap_fixture(tmp_path, private_env): + checkout = tmp_path / 'checkout' + bootstrap = checkout / 'sso-mfa/bootstrap' + bootstrap.mkdir(parents=True) + for name in ['creds-bootstrap-agent.sh', 'gen-secrets.sh', 'encrypt-secrets.sh', + 'pack-bundle.sh', 'emergency-bundle.sh', 'decrypt-secrets.sh']: + shutil.copyfile(BOOTSTRAP / name, bootstrap / name) + (bootstrap / 'creds-verify.sh').write_text('#!/bin/bash\nexit 0\n') + state = bootstrap / 'creds-state.yaml' + state.write_text('''schema_version: 2 +secrets_generated: false +ops_bundle_created: false +ops_bundle_location: null +emergency_bundle_delivered: true +emergency_bundle_delivered_at: "historical-test-receipt" +bootstrap_complete: true +secrets_applied: + postgres: true + lldap: true + authelia: true + privacyidea: true + keycape: true +enckey_bootstrapped: true +pi_admin_created: true +openbao_post_unseal_verified: false +''') + key = tmp_path / 'disposable-custody.key' + subprocess.run(['age-keygen', '-o', str(key)], check=True, capture_output=True) + recipient = subprocess.check_output(['age-keygen', '-y', str(key)], text=True).strip() + (checkout / 'keys').mkdir() + (checkout / 'keys/age.pub').write_text(recipient + '\n') + bindir = tmp_path / 'bin'; bindir.mkdir() + (bindir / 'kubectl').write_text('''#!/bin/bash +printf call >> "$CLUSTER_CALLS" +if [[ "$*" == 'get namespace openbao' ]]; then exit 1; fi +if [[ "$*" == 'config current-context' ]]; then echo disposable-test; fi +if [[ "$1 $2" == 'get secret' ]]; then echo secret/disposable-test; fi +exit 0 +''') + (bindir / 'git').write_text('#!/bin/bash\nexit 0\n') + for path in bindir.iterdir(): path.chmod(0o755) + calls = tmp_path / 'cluster-calls' + env = {**private_env, 'PATH': str(bindir) + ':' + os.environ['PATH'], 'CLUSTER_CALLS': str(calls)} + return bootstrap, state, key, env, calls + + +def test_public_only_bootstrap_encrypts_recovery_without_exporting_root(bootstrap_fixture): + import io + import tarfile + bootstrap, state, key, env, calls = bootstrap_fixture + code, transcript = run_attended(bootstrap / 'creds-bootstrap-agent.sh', env) + assert code == 0 + assert calls.exists() + assert not (Path(env['HOME']) / '.config/sops/age').exists() + assert not (bootstrap / 'secrets').exists() + bundles = list(bootstrap.parents[1].glob('ops-bundle-*.tar.age')) + assert len(bundles) == 1 + clear = subprocess.check_output(['age', '-d', '-i', str(key), str(bundles[0])]) + with tarfile.open(fileobj=io.BytesIO(clear)) as archive: + files = [m for m in archive.getmembers() if m.isfile()] + assert len(files) == 6 + for member in files: + body = archive.extractfile(member).read() + for line in body.splitlines(): + if line and not line.startswith(b'#') and b'=' in line: + value = line.split(b'=', 1)[1] + if len(value) >= 8: + assert bool(value[:8] in transcript) is False + private_line = next(line for line in key.read_bytes().splitlines() if line.startswith(b'AGE-SECRET-KEY-')) + assert bool(private_line in clear) is False + recorded = state.read_text() + assert 'recovery_handoff_confirmed: true' in recorded + assert 'recovery_handoff_bundle_sha256:' in recorded + assert 'bootstrap_complete: true' in recorded + assert 'historical-test-receipt' in recorded + + +def test_missing_custody_resume_refuses_before_cluster_call(bootstrap_fixture): + bootstrap, state, _, env, calls = bootstrap_fixture + state.write_text(state.read_text().replace('secrets_generated: false', 'secrets_generated: true')) + before = state.read_bytes() + code, transcript = run_attended(bootstrap / 'creds-bootstrap-agent.sh', env) + assert code != 0 + assert b'attended custody unlock helper' in transcript + assert not calls.exists() + assert state.read_bytes() == before + + +def test_declined_handoff_is_not_marked_complete_or_cleaned(bootstrap_fixture): + bootstrap, state, _, env, _ = bootstrap_fixture + code, _ = run_attended(bootstrap / 'creds-bootstrap-agent.sh', env, answer=b'NO\n') + assert code != 0 + assert 'recovery_handoff_confirmed: false' in state.read_text() + assert 'bootstrap_complete: false' in state.read_text() + assert (bootstrap / 'secrets').is_dir() + + +def test_explicit_matching_custody_resume_preserves_prior_bundle(bootstrap_fixture): + bootstrap, state, key, env, _ = bootstrap_fixture + code, _ = run_attended(bootstrap / 'creds-bootstrap-agent.sh', env) + assert code == 0 + first = next(bootstrap.parents[1].glob('ops-bundle-*.tar.age')) + before = first.read_bytes() + env['NETKINGDOM_CUSTODY_AGE_KEY_FILE'] = str(key) + code, transcript = run_attended(bootstrap / 'creds-bootstrap-agent.sh', env) + assert code == 0 + assert first.read_bytes() == before + assert len(list(bootstrap.parents[1].glob('ops-bundle-*.tar.age'))) == 2 + private_line = next(line for line in key.read_bytes().splitlines() if line.startswith(b'AGE-SECRET-KEY-')) + assert bool(private_line in transcript) is False + assert not (bootstrap / 'secrets').exists() + + +def test_mismatched_explicit_custody_key_refuses_before_cluster(bootstrap_fixture, tmp_path): + bootstrap, state, _, env, calls = bootstrap_fixture + wrong = tmp_path / 'other-disposable.key' + subprocess.run(['age-keygen', '-o', str(wrong)], check=True, capture_output=True) + state.write_text(state.read_text().replace('secrets_generated: false', 'secrets_generated: true')) + env['NETKINGDOM_CUSTODY_AGE_KEY_FILE'] = str(wrong) + code, transcript = run_attended(bootstrap / 'creds-bootstrap-agent.sh', env) + assert code != 0 + assert b'does not match' in transcript + assert not calls.exists() + + +def test_status_keeps_historical_receipt_distinct_from_current_handoff(): + result = subprocess.run(['bash', str(BOOTSTRAP / 'creds-status.sh'), '--v2'], + capture_output=True, text=True, timeout=5) + assert result.returncode == 0 + assert 'historical emergency delivery' in result.stdout + assert 'current recovery acknowledgment' in result.stdout + assert 'does not establish native recovery readiness' in result.stdout diff --git a/tools/security-bootstrap-console/security_bootstrap_console.py b/tools/security-bootstrap-console/security_bootstrap_console.py index 2115683..8ab61e3 100755 --- a/tools/security-bootstrap-console/security_bootstrap_console.py +++ b/tools/security-bootstrap-console/security_bootstrap_console.py @@ -57,7 +57,7 @@ OPENBAO_UNSEAL_CUSTODY_MODEL_SPECS: dict[str, dict[str, str]] = { "implementation": "implemented", "summary": ( "Init/unseal material in SOPS/age bundle; creds-bootstrap-agent applies " - "for fast unattended rebuild test cycles." + "during an attended rebuild with explicit temporary custody input." ), "automation_entry": "sso-mfa/bootstrap/creds-bootstrap-agent.sh", "custody_strength": "lab-fast-iteration", diff --git a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md new file mode 100644 index 0000000..3def57c --- /dev/null +++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md @@ -0,0 +1,84 @@ +--- +id: NK-WP-0049 +type: workplan +title: "Remove bootstrap secret output and verify separated recovery custody" +domain: infotech +repo: net-kingdom +status: active +owner: codex +topic_slug: netkingdom +created: "2026-10-12" +updated: "2026-10-12" +flavor: implementation +depends_on: [] +state_hub_workstream_id: "17d315f0-829e-57c7-85cc-3a7914152804" +--- + +Native owner work for CUST-WP-0080 G08. NK-WP-0045 proves recovery of the +non-secret root-grant record, not custody of the bootstrap private key or +secret-bearing emergency bundle. Existing founder-deferred exposure rotation +remains CUST-WP-0073-T05; this work does not override it or rotate any root. + +## Remove secret-bearing output and ambient root discovery + +```task +id: NK-WP-0049-T01 +status: done +priority: high +state_hub_task_id: "d7ca8d5f-c5ff-5cba-bd25-038cb53ffe4e" +``` + +2026-10-12: regression reproduced emergency/reprint root export, generation +prefix disclosure, permissive file modes, dry-run side effects and unattended +entry. Replaced emergency export with a non-secret guide (legacy secret-input +arguments refused), suppressed generation prefixes/tracing and set private +permissions. Bootstrap requires the registered public recipient and an attended +terminal; never generates/discovers a permanent private key. Encrypted resume +requires an explicit matching temporary custody key before cluster activity. +Dry-run is a non-mutating plan. The live cluster/apply path was not executed. + +A fresh uniquely named encrypted ops bundle follows apply; old delivery flags +cannot skip current acknowledgment. New acknowledgment state binds its SHA256 +and timestamp. Declining stops before cleanup/completion. This is an operator +statement, not an independent restore receipt. Historical fields remain unchanged. +Make/help/status/console and current credential guidance reflect that distinction. +Evidence and exact source manifest: Custodian +`docs/evidence/2026-10-12-netkingdom-security/bootstrap-handoff.json`. + +## Reconcile other legacy custody callers and root-output boundaries + +```task +id: NK-WP-0049-T02 +status: todo +priority: high +state_hub_task_id: "479d6970-6516-50f5-9436-bb5b56d58aad" +``` + +Inspect remaining direct encrypt/decrypt defaults, permanent-key setup commands, +custody helper tracing/error handling and delegated apply/init helpers against +the accepted single-founder attended custody decision. Public-recipient-only +encryption and explicit temporary unlock must hold at their actual call sites. +Do not treat a tty check as identity, authority or agent isolation. Reuse +CUST-WP-0073/GLAS-WP-0012 for runtime isolation and Platform for root custody; +record any further exact source defects and repair them before native acceptance. + +## Verify owner-approved recovery and current native callers + +```task +id: NK-WP-0049-T03 +status: wait +priority: high +depends_on: [NK-WP-0049-T02] +blocking_reason: "Source caller reconciliation and exact owner-scoped isolated recovery ceremony are required; no live bootstrap or root retrieval is authorized by this source receipt." +state_hub_task_id: "d68cd4d3-1ba3-530e-88f9-26ebb8dd6eac" +``` + +Review the final scoped procedure, current consumer/caller usage and independent +key/snapshot availability. Under an exact approved ceremony, verify off-host +ciphertext retrieval, matching independently held key, isolated decryption and +required current service-file coverage, failed-key refusal and cleanup. Record +only safe identities/digests/statuses. Do not run the whole live bootstrap merely +to test this patch, or infer current credential validity from a historical bundle. +Historical disclosure is separate incident/rotation work; terminal clearing and +source repair do not revoke any earlier exposed value. G08 stays open until +caller coverage, owner review and native recovery evidence are adequate.