diff --git a/Makefile b/Makefile index a86cf95..2e6009e 100644 --- a/Makefile +++ b/Makefile @@ -49,7 +49,7 @@ hooks-test: ## Test that the pre-commit hook blocks plaintext secrets @rm -rf sso-mfa/bootstrap/secrets/_hooktest # ── SOPS / age ──────────────────────────────────────────────────────────────── -sops-setup: ## Copy age key to SOPS default path (~/.config/sops/age/keys.txt) +sops-setup: ## Legacy dev-local setup: copy age key to SOPS default path @mkdir -p ~/.config/sops/age @if [[ -f ~/.config/age/key.txt ]]; then \ cp -n ~/.config/age/key.txt ~/.config/sops/age/keys.txt || true; \ @@ -81,6 +81,21 @@ sops-rotate: ## Rotate SOPS recipients after updating .sops.yaml: make sops-rota sops --rotate --in-place $(FILE) @echo "✔ Recipients rotated for $(FILE)" +sops-custody-check: ## Validate a custody age key against keys/age.pub without keeping it on disk + @bash sso-mfa/bootstrap/sops-custody-unlock.sh \ + --expected-recipient "$(OPERATOR_AGE_PUBKEY)" \ + --check-only + +sops-custody-shell: ## Open a shell with a temporary SOPS_AGE_KEY_FILE from custody material + @bash sso-mfa/bootstrap/sops-custody-unlock.sh \ + --expected-recipient "$(OPERATOR_AGE_PUBKEY)" + +sops-custody-run: ## Run COMMAND with a temporary custody age key: make sops-custody-run COMMAND='make -C ../inter-hub recovery-drill' + @[[ -n "$(COMMAND)" ]] || (echo "Usage: make sops-custody-run COMMAND='make -C ../inter-hub recovery-drill'"; exit 1) + @COMMAND='$(COMMAND)' bash sso-mfa/bootstrap/sops-custody-unlock.sh \ + --expected-recipient "$(OPERATOR_AGE_PUBKEY)" \ + -- bash -lc "$$COMMAND" + check-secrets: ## Fail if any file under secrets/ is not SOPS-encrypted @echo "Checking for unencrypted files under secrets/..." @bad=0; \ diff --git a/docs/security-bootstrap-age-custody.md b/docs/security-bootstrap-age-custody.md index 9155397..1e9dbcc 100644 --- a/docs/security-bootstrap-age-custody.md +++ b/docs/security-bootstrap-age-custody.md @@ -70,3 +70,63 @@ directly. Encryption does not require the private key. `sso-mfa/bootstrap/decrypt-secrets.sh` requires the private key path and should be used only in an explicit unlock/apply ceremony. After apply, plaintext files must be shredded. + +## Custody Unlock Helper + +Use `sso-mfa/bootstrap/sops-custody-unlock.sh` when an operator needs to run a +SOPS-backed drill, recovery, or lockdown command but the private key is only in +the password safe or offline custody packet. + +The helper: + +- reads the private age key from a hidden prompt, stdin, or a source file; +- derives the public age recipient with `age-keygen -y`; +- refuses to continue unless it matches the expected recipient; +- writes a `0600` temporary `SOPS_AGE_KEY_FILE`; +- runs the requested command or opens a temporary custody shell; and +- shreds/removes the temporary key file when the command or shell exits. + +It must not be used to install the private key permanently on a workstation or +server. The source of truth remains the password safe/offline custody packet. + +### Validate Custody Material + +From the NetKingdom repo, validate the supplied private key against `keys/age.pub` +without keeping it on disk: + +```bash +make sops-custody-check +``` + +### Run A One-Shot Recovery Drill + +For inter-hub: + +```bash +make sops-custody-run COMMAND='make -C /home/worsch/inter-hub recovery-drill' +``` + +The helper prompts for the `AGE-SECRET-KEY-1...` line from the password safe or +offline custody packet, validates it against the registered public recipient, +sets `SOPS_AGE_KEY_FILE` for the command, and removes the temporary key after +the drill exits. + +If using a password-manager CLI, pipe only the private key field: + +```bash +op read 'op://Platform/NetKingdom custodian age key/private-key' \ + | sso-mfa/bootstrap/sops-custody-unlock.sh \ + --from-stdin \ + -- make -C /home/worsch/inter-hub recovery-drill +``` + +### Open An Incident Shell + +For multi-step recovery or lockdown work: + +```bash +make sops-custody-shell +``` + +Run the required SOPS, recovery, or lockdown commands inside that shell. Exit +the shell to shred the temporary key file. diff --git a/sso-mfa/bootstrap/README.md b/sso-mfa/bootstrap/README.md index ed2e9e2..e73c9e4 100644 --- a/sso-mfa/bootstrap/README.md +++ b/sso-mfa/bootstrap/README.md @@ -115,3 +115,24 @@ See `../vault/` (created in T01 Phase 0b) for: - ESO (External Secrets Operator) configuration - Vault secret path layout - Migration procedure: KeePassXC → Vault + +## Custody Age Key Unlock + +For SOPS-backed drills and incident work, do not copy the custodian age private +key into a permanent workstation path. Use the custody helper from the repo root: + +```bash +make sops-custody-run COMMAND='make -C /home/worsch/inter-hub recovery-drill' +``` + +The helper prompts for the `AGE-SECRET-KEY-1...` line from the password safe or +offline custody packet, validates it against `keys/age.pub`, writes a temporary +`SOPS_AGE_KEY_FILE`, runs the command, and removes the temporary file on exit. + +For multi-step recovery work: + +```bash +make sops-custody-shell +``` + +Exit the shell when finished so the temporary key file is removed. diff --git a/sso-mfa/bootstrap/sops-custody-unlock.sh b/sso-mfa/bootstrap/sops-custody-unlock.sh new file mode 100755 index 0000000..8e622e3 --- /dev/null +++ b/sso-mfa/bootstrap/sops-custody-unlock.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# sops-custody-unlock.sh -- run SOPS/age operations with a temporary custody key. +# +# The private age key is supplied only for this invocation, validated against an +# expected public recipient, written to a 0600 temp file, and removed on exit. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +EXPECTED_RECIPIENT="${EXPECTED_AGE_RECIPIENT:-}" +SOURCE_FILE="" +INPUT_MODE="" +CHECK_ONLY=false +COMMAND=() + +usage() { + cat <<'EOF' +Usage: + sops-custody-unlock.sh [options] [-- command ...] + +Options: + --expected-recipient age1... Required recipient. Defaults to keys/age.pub. + --from-file PATH Read custody age key material from PATH. + --from-stdin Read custody age key material from stdin. + --prompt Prompt for one AGE-SECRET-KEY-1... line. + --check-only Validate the supplied key and exit. + -h, --help Show this help. + +Examples: + # Run an inter-hub recovery drill after pasting the private key line. + sso-mfa/bootstrap/sops-custody-unlock.sh \ + --expected-recipient age1aq8twfd78wvpra0had8cezcnj96tj4q0068edrz5jez8d6xwmflqdepsh4 \ + -- make -C /home/worsch/inter-hub recovery-drill + + # Pipe a password-manager field into the helper without printing it. + op read 'op://Platform/NetKingdom custodian age key/private-key' \ + | sso-mfa/bootstrap/sops-custody-unlock.sh --from-stdin -- make recovery-drill + +If no command is supplied, an interactive shell is opened with SOPS_AGE_KEY_FILE +set. Exit that shell to remove the temporary key file. +EOF +} + +fail() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --expected-recipient) + [[ $# -ge 2 ]] || fail "--expected-recipient requires a value" + EXPECTED_RECIPIENT="$2" + shift 2 + ;; + --expected-recipient=*) + EXPECTED_RECIPIENT="${1#*=}" + shift + ;; + --from-file) + [[ $# -ge 2 ]] || fail "--from-file requires a path" + SOURCE_FILE="$2" + INPUT_MODE="file" + shift 2 + ;; + --from-file=*) + SOURCE_FILE="${1#*=}" + INPUT_MODE="file" + shift + ;; + --from-stdin) + INPUT_MODE="stdin" + shift + ;; + --prompt) + INPUT_MODE="prompt" + shift + ;; + --check-only) + CHECK_ONLY=true + shift + ;; + -h|--help) + usage + exit 0 + ;; + --) + shift + COMMAND=("$@") + break + ;; + *) + fail "unknown argument: $1" + ;; + esac +done + +if [[ -z "$EXPECTED_RECIPIENT" && -f "$REPO_ROOT/keys/age.pub" ]]; then + EXPECTED_RECIPIENT="$(grep -m1 '^age1' "$REPO_ROOT/keys/age.pub" || true)" +fi +[[ "$EXPECTED_RECIPIENT" == age1* ]] || fail "expected age recipient not set; pass --expected-recipient age1..." + +command -v age-keygen >/dev/null 2>&1 || fail "age-keygen not found; install age before running custody unlock" + +if [[ -z "$INPUT_MODE" ]]; then + if [[ -t 0 ]]; then + INPUT_MODE="prompt" + else + INPUT_MODE="stdin" + fi +fi + +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/netkingdom-sops-age.XXXXXX")" +KEY_FILE="$TMP_DIR/keys.txt" +cleanup() { + if [[ -f "$KEY_FILE" ]]; then + if command -v shred >/dev/null 2>&1; then + shred -u "$KEY_FILE" 2>/dev/null || rm -f "$KEY_FILE" + else + rm -f "$KEY_FILE" + fi + fi + rmdir "$TMP_DIR" 2>/dev/null || true +} +trap cleanup EXIT HUP INT TERM + +umask 077 +case "$INPUT_MODE" in + file) + [[ -f "$SOURCE_FILE" ]] || fail "custody age key file not found: $SOURCE_FILE" + cp "$SOURCE_FILE" "$KEY_FILE" + chmod 600 "$KEY_FILE" + ;; + stdin) + cat > "$KEY_FILE" + chmod 600 "$KEY_FILE" + ;; + prompt) + [[ -t 0 ]] || fail "cannot prompt for key because stdin is not a terminal; use --from-stdin or --from-file" + printf 'Paste custody age private key line (input hidden): ' >&2 + IFS= read -r -s key_line + printf '\n' >&2 + [[ -n "$key_line" ]] || fail "no key material supplied" + printf '%s\n' "$key_line" > "$KEY_FILE" + unset key_line + chmod 600 "$KEY_FILE" + ;; + *) + fail "unsupported input mode: $INPUT_MODE" + ;; +esac + +[[ -s "$KEY_FILE" ]] || fail "no key material supplied" +if ! grep -q 'AGE-SECRET-KEY-1' "$KEY_FILE"; then + fail "supplied material does not contain an AGE-SECRET-KEY-1 private key" +fi + +DERIVED_RECIPIENT="$(age-keygen -y "$KEY_FILE" 2>/dev/null || true)" +[[ "$DERIVED_RECIPIENT" == age1* ]] || fail "could not derive an age public recipient from supplied key" + +if [[ "$DERIVED_RECIPIENT" != "$EXPECTED_RECIPIENT" ]]; then + printf 'ERROR: custody key does not match expected recipient\n' >&2 + printf ' expected: %s\n' "$EXPECTED_RECIPIENT" >&2 + printf ' derived : %s\n' "$DERIVED_RECIPIENT" >&2 + exit 1 +fi + +printf 'OK: custody age key matches expected recipient %s\n' "$EXPECTED_RECIPIENT" >&2 + +if [[ "$CHECK_ONLY" == true ]]; then + printf 'OK: check complete; temporary key file removed on exit\n' >&2 + exit 0 +fi + +export SOPS_AGE_KEY_FILE="$KEY_FILE" +export NETKINGDOM_CUSTODY_AGE_KEY_FILE="$KEY_FILE" + +if [[ ${#COMMAND[@]} -gt 0 ]]; then + printf 'Running command with temporary SOPS_AGE_KEY_FILE. Key file will be removed after command exit.\n' >&2 + "${COMMAND[@]}" +else + printf 'Opening custody shell with temporary SOPS_AGE_KEY_FILE=%s\n' "$SOPS_AGE_KEY_FILE" >&2 + printf 'Run recovery or lockdown commands, then exit the shell to remove the temp key.\n' >&2 + bash -i +fi diff --git a/workplans/ADHOC-2026-06-14.md b/workplans/ADHOC-2026-06-14.md new file mode 100644 index 0000000..491484d --- /dev/null +++ b/workplans/ADHOC-2026-06-14.md @@ -0,0 +1,36 @@ +--- +id: ADHOC-2026-06-14 +type: workplan +title: "Ad hoc NetKingdom operator usability fixes" +domain: netkingdom +repo: net-kingdom +status: finished +owner: codex +topic_slug: netkingdom +created: "2026-06-14" +updated: "2026-06-14" +--- + +# Ad hoc NetKingdom operator usability fixes + +## SOPS Custody Unlock Helper + +```task +id: ADHOC-2026-06-14-T01 +status: done +priority: medium +``` + +Added a custody unlock helper for SOPS/age operations so drills and incident +commands can use the password-safe/offline custody age private key without +installing it permanently on a workstation. + +The helper validates the supplied private key against the expected public age +recipient, writes a temporary `0600` `SOPS_AGE_KEY_FILE`, runs the requested +command or opens an incident shell, and removes the temporary key on exit. + +Documented the inter-hub recovery-drill path: + +```bash +make sops-custody-run COMMAND='make -C /home/worsch/inter-hub recovery-drill' +```