fix: separate bootstrap encryption from private root custody
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a12d13-27f2-7163-b8c0-f682af02772c
This commit is contained in:
tegwick 2026-10-12 09:41:08 +02:00
parent d552bf253c
commit b97f9f616f
11 changed files with 566 additions and 339 deletions

View file

@ -171,13 +171,13 @@ creds-rotate: ## Guided rotation for one secret: make creds-rotate SECRET=<name>
## ── 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)

View file

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

View file

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

View file

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

View file

@ -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:-<path unknown>} — 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 ──────────────────────────────────────────────

View file

@ -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:-<unknown>}" || 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")"

View file

@ -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 <path> (default: ~/.config/sops/age/keys.txt)
# --secrets-dir <path> (default: ./secrets)
# --ops-bundle <path> (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 "<not found>"
}
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 "<secrets not available>"
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 key not found at $AGE_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:-<not created yet>}"
if [[ -n "$AGE_PUBKEY" ]]; then
DECRYPT_CMD="age -d -i <age-key-path> ops-bundle-<date>.tar.age"
else
DECRYPT_CMD="age -d -i <age-key-path> ops-bundle-<date>.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 <<BUNDLE
╔══════════════════════════════════════════════════════════════════╗
║ NET-KINGDOM EMERGENCY CREDENTIAL BUNDLE ║
║ Generated: ${GENERATED_DATE} ║
║ Store this. Nothing else ever needs human storage. ║
╠══════════════════════════════════════════════════════════════════╣
║ AGE PRIVATE KEY (decrypt all SOPS/age secrets from git) ║
║ ────────────────────────────────────────────────────────────── ║
${AGE_PRIVATE_KEY}
╠══════════════════════════════════════════════════════════════════╣
║ BREAK-GLASS PASSWORDS (direct service access, cluster-bypass) ║
║ ────────────────────────────────────────────────────────────── ║
║ privacyIDEA admin : ${PI_ADMIN_PASS}
║ LLDAP admin : ${LLDAP_ADMIN_PASS}
║ PostgreSQL root : ${PG_ROOT_PASS}
║ break-glass user : ${BG_PASS}
╠══════════════════════════════════════════════════════════════════╣
║ OPS BUNDLE (age-encrypted point-in-time secret snapshot) ║
║ ────────────────────────────────────────────────────────────── ║
║ Location : ${OPS_BUNDLE_LOCATION}
║ Decrypt : ${DECRYPT_CMD}
╠══════════════════════════════════════════════════════════════════╣
║ RECOVERY INSTRUCTIONS ║
║ ────────────────────────────────────────────────────────────── ║
║ 1. Restore age key to ~/.config/sops/age/keys.txt ║
║ 2. Clone net-kingdom repo + run: make creds-apply ║
║ 3. Use break-glass passwords for direct service access if needed ║
╚══════════════════════════════════════════════════════════════════╝
BUNDLE
)"
# Write to temp file for 60s window
echo "$BUNDLE_TEXT" > "$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 ""

View file

@ -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" <<EOF
BREAKGLASS_PASSWORD=$BG_PASS
EOF
# ── Summary ────────────────────────────────────────────────────────────────────
echo "=== net-kingdom SSO/MFA bootstrap secrets ==="
echo "Generated: $(date -Iseconds)"
echo "Output : $OUT_DIR/"
echo ""
echo " privacyIDEA:"
echo " PI_SECRET_KEY : ${PI_SECRET_KEY:0:16}…"
echo " PI_PEPPER : ${PI_PEPPER:0:8}…"
echo " PI_DB_PASSWORD : ${PI_DB_PASS:0:8}…"
echo " PI_ADMIN_PASSWORD: ${PI_ADMIN_PASS:0:8}…"
echo " PI_ENCFILE : *** generate after container deploy (see comments) ***"
echo ""
echo " PostgreSQL:"
echo " PG_ROOT_PASSWORD : ${PG_ROOT_PASS:0:8}…"
echo " PG_PI_PASSWORD : (same as PI_DB_PASSWORD)"
echo ""
echo " LLDAP:"
echo " LLDAP_JWT_SECRET : ${LLDAP_JWT_SECRET:0:8}…"
echo " LLDAP_LDAP_USER_PASS: ${LLDAP_LDAP_USER_PASS:0:8}…"
echo ""
echo " Authelia:"
echo " AUTHELIA_JWT_SECRET : ${AUTHELIA_JWT_SECRET:0:8}…"
echo " AUTHELIA_SESSION_SECRET : ${AUTHELIA_SESSION_SECRET:0:8}…"
echo " AUTHELIA_STORAGE_ENCRYPTION_KEY: ${AUTHELIA_STORAGE_ENCRYPTION_KEY:0:8}…"
echo " AUTHELIA_OIDC_HMAC_SECRET : ${AUTHELIA_OIDC_HMAC_SECRET:0:8}…"
echo " AUTHELIA_KEYCAPE_CLIENT_SECRET : ${AUTHELIA_KEYCAPE_CLIENT_SECRET:0:8}…"
echo " AUTHELIA_OIDC_PRIVATE_KEY : *** generated by authelia/create-secrets.sh ***"
echo ""
echo " KeyCape:"
echo " key.pem : *** generated by keycape/create-secrets.sh ***"
echo " pi_admin_token : *** generated by keycape/create-pi-token.sh (after T04 bootstrap) ***"
echo ""
echo " Break-glass:"
echo " BREAKGLASS_PASSWORD: ${BG_PASS:0:8}…"
echo ""
echo "Next steps:"
echo " 1. Enter each value into KeePassXC (see comments in each secrets.env file)."
echo " 2. Run pack-bundle.sh to create an age-encrypted offsite backup."
echo " 3. Shred the generated files:"
echo " find $OUT_DIR -type f -exec shred -u {} \\;"
echo ""
echo "NEVER commit $OUT_DIR/ to git."
# Values and even prefixes must stay out of normal output and shell tracing.
printf '%s\n' 'Bootstrap service files generated with private permissions.'
printf '%s\n' 'Encrypt to the registered public age recipient, verify off-host recovery, then remove plaintext.'
printf '%s\n' 'Never paste these files into chat, logs, metadata or Git.'

View file

@ -0,0 +1,259 @@
"""No real keys, credentials, cluster or HOME are used by these regressions."""
import os
from pathlib import Path
import shutil
import subprocess
import pytest
ROOT = Path(__file__).resolve().parents[1]
BOOTSTRAP = ROOT / 'sso-mfa/bootstrap'
@pytest.fixture
def private_env(tmp_path):
home = tmp_path / 'home'
home.mkdir(mode=0o700)
return {'PATH': os.environ['PATH'], 'HOME': str(home), 'TMPDIR': str(tmp_path),
'KUBECONFIG': str(tmp_path / 'no-cluster-config')}
@pytest.mark.parametrize('flag', [[], ['--reprint']])
def test_emergency_guide_never_exports_the_default_root(private_env, flag):
key = Path(private_env['HOME']) / '.config/sops/age/keys.txt'
key.parent.mkdir(parents=True)
key.write_text('synthetic-private-custody-marker\n')
result = subprocess.run(['bash', str(BOOTSTRAP / 'emergency-bundle.sh'), *flag],
env=private_env, input='\n', capture_output=True, text=True, timeout=5)
assert result.returncode == 0
assert 'synthetic-private-custody-marker' not in result.stdout + result.stderr
assert 'non-secret' in result.stdout
assert not list(Path(private_env['TMPDIR']).glob('*emergency-bundle*'))
def test_legacy_root_export_arguments_fail_without_echo(private_env):
result = subprocess.run(['bash', str(BOOTSTRAP / 'emergency-bundle.sh'), '--age-key', 'synthetic-private-argument'],
env=private_env, input='\n', capture_output=True, text=True, timeout=5)
assert result.returncode != 0
assert 'synthetic-private-argument' not in result.stdout + result.stderr
@pytest.mark.parametrize('trace', [False, True])
def test_generation_logs_no_secret_prefixes_and_files_are_private(tmp_path, private_env, trace):
out = tmp_path / 'generated'
result = subprocess.run(['bash', *(['-x'] if trace else []), str(BOOTSTRAP / 'gen-secrets.sh'), str(out)],
env=private_env, capture_output=True, text=True, timeout=15)
assert result.returncode == 0
transcript = result.stdout + result.stderr
prefixes = []
for path in out.glob('*/secrets.env'):
assert path.stat().st_mode & 0o077 == 0
for line in path.read_text().splitlines():
if line and not line.startswith('#') and '=' in line:
value = line.split('=', 1)[1]
if len(value) >= 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

View file

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

View file

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