fix: separate bootstrap encryption from private root custody
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a12d13-27f2-7163-b8c0-f682af02772c
This commit is contained in:
parent
d552bf253c
commit
b97f9f616f
11 changed files with 566 additions and 339 deletions
4
Makefile
4
Makefile
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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")"
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
|
|
|
|||
259
tests/test_bootstrap_secret_output.py
Normal file
259
tests/test_bootstrap_secret_output.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue