diff --git a/docs/security-bootstrap-age-custody.md b/docs/security-bootstrap-age-custody.md index d38bb97..fdd1943 100644 --- a/docs/security-bootstrap-age-custody.md +++ b/docs/security-bootstrap-age-custody.md @@ -77,9 +77,12 @@ 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 disables shell tracing before handling input and refuses conflicting input modes. +Noninteractive callers must select `--from-stdin` or `--from-file` explicitly. + The helper: -- reads the private age key from a hidden prompt, stdin, or a source file; +- reads the private age key from one explicit source: 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`; @@ -152,3 +155,27 @@ 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. + +## Direct helper correction — 2026-10-12 + +`encrypt-secrets.sh` defaults to the registered `keys/age.pub`. It accepts a public +recipient or public-recipient file and rejects private-key files instead of +loading them to derive metadata. `decrypt-secrets.sh` accepts an explicit key +argument or the custody helper's `NETKINGDOM_CUSTODY_AGE_KEY_FILE`; it no longer +searches HOME. Successful decryption publishes a new private output directory +only after every input decrypts. Failed/empty input removes partial staging; +existing files, directories and dangling symlinks are never replaced. + +The custody helper disables shell tracing, rejects repeated/mixed input modes, +does not echo unknown arguments and clears competing inline/key-command SOPS +authentication before the child starts. HUP/INT/TERM now retain failure status +and EXIT cleanup rather than running cleanup and then continuing. Bash may defer +its trap while a foreground child runs; this is not a process-group supervisor, +SIGKILL cleanup guarantee or a runtime authority boundary. Temporary key cleanup +on command return does not certify that an arbitrary child retained no copies. + +Remaining concrete caller defects stay in NK-WP-0049-T02: Make `sops-setup` / +`creds-init` still install or require permanent HOME keys; Make `sops-decrypt` +still emits plaintext; legacy rotation and privacyIDEA bootstrap helpers contain +secret-bearing output. Do not use those legacy paths as the recovery procedure +or treat these narrower helper repairs as closure of the whole custody surface. diff --git a/sso-mfa/bootstrap/decrypt-secrets.sh b/sso-mfa/bootstrap/decrypt-secrets.sh index 84774a5..a5b8ced 100755 --- a/sso-mfa/bootstrap/decrypt-secrets.sh +++ b/sso-mfa/bootstrap/decrypt-secrets.sh @@ -1,62 +1,48 @@ #!/usr/bin/env bash -# decrypt-secrets.sh — decrypt secrets.enc/ to secrets/ using age -# -# Usage: -# ./decrypt-secrets.sh [OUTPUT_DIR] [AGE_KEY_FILE] -# -# OUTPUT_DIR where to write plaintext secrets (default: ./secrets) -# AGE_KEY_FILE age private key file (default: ~/.config/net-kingdom/age.key) -# -# Decrypts all *.age files in secrets.enc/ to OUTPUT_DIR for use by -# create-secrets.sh scripts. Shred OUTPUT_DIR when done: -# find secrets/ -type f -exec shred -u {} \; && rm -rf secrets/ -# -# The age key must be present on the machine. Keep it outside the repo: -# ~/.config/net-kingdom/age.key - +# Explicit attended custody input only. Publish plaintext only after all files +# decrypt successfully into a private staging directory; never emit values. +set +x set -euo pipefail +umask 077 +fail() { printf 'ERROR: %s\n' "$1" >&2; exit 1; } OUTPUT_DIR="${1:-./secrets}" -AGE_KEY="${2:-$HOME/.config/net-kingdom/age.key}" - +AGE_KEY="${2:-${NETKINGDOM_CUSTODY_AGE_KEY_FILE:-}}" +[[ $# -le 2 ]] || fail "unsupported decrypt argument" +[[ -n "$AGE_KEY" && -f "$AGE_KEY" ]] || fail "explicit temporary custody key required" ENC_DIR="$(dirname "$OUTPUT_DIR")/secrets.enc" +[[ -d "$ENC_DIR" && ! -L "$ENC_DIR" ]] || fail "encrypted source directory unavailable" +[[ ! -e "$OUTPUT_DIR" && ! -L "$OUTPUT_DIR" ]] || fail "output already exists" +[[ -d "$(dirname "$OUTPUT_DIR")" ]] || fail "output parent unavailable" -if [[ ! -d "$ENC_DIR" ]]; then - echo "ERROR: encrypted secrets directory not found: $ENC_DIR" >&2 - echo "Expected secrets.enc/ next to the output directory." >&2 - exit 1 -fi - -if [[ ! -f "$AGE_KEY" ]]; then - echo "ERROR: age key not found: $AGE_KEY" >&2 - echo "Copy your age key to $AGE_KEY or pass the path as the second argument." >&2 - exit 1 -fi - -if [[ -e "$OUTPUT_DIR" ]]; then - echo "ERROR: $OUTPUT_DIR already exists. Remove it first or choose a different path." >&2 - exit 1 -fi - -echo "Decrypting $ENC_DIR → $OUTPUT_DIR/" -echo "" +STAGING=$(mktemp -d "$(dirname "$OUTPUT_DIR")/.netkingdom-decrypt.XXXXXXXX") +cleanup() { + if [[ -d "$STAGING" ]]; then + find "$STAGING" -type f -exec shred -u {} \; 2>/dev/null || true + rm -rf -- "$STAGING" + fi +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM count=0 -for component_dir in "$ENC_DIR"/*/; do +for component_dir in "$ENC_DIR"/*; do + [[ -e "$component_dir" || -L "$component_dir" ]] || continue + [[ -d "$component_dir" && ! -L "$component_dir" ]] || fail "unexpected encrypted component" component=$(basename "$component_dir") - mkdir -p "$OUTPUT_DIR/$component" - for f in "$component_dir"*.age; do - [[ -f "$f" ]] || continue - fname=$(basename "${f%.age}") - out="$OUTPUT_DIR/$component/$fname" - age -d -i "$AGE_KEY" -o "$out" "$f" - echo " decrypted: secrets.enc/$component/$(basename "$f") → $component/$fname" + mkdir "$STAGING/$component" + for file in "$component_dir"/*.age; do + [[ -e "$file" || -L "$file" ]] || continue + [[ -f "$file" && ! -L "$file" ]] || fail "unexpected encrypted input" + name=$(basename "${file%.age}") + age -d -i "$AGE_KEY" -o "$STAGING/$component/$name" "$file" 2>/dev/null || fail "decryption failed; partial plaintext removed" count=$((count + 1)) done done - -echo "" -echo "$count file(s) decrypted to $OUTPUT_DIR/" -echo "" -echo "Use create-secrets.sh scripts, then shred:" -echo " find $OUTPUT_DIR -type f -exec shred -u {} \\; && rm -rf $OUTPUT_DIR" +[[ "$count" -gt 0 ]] || fail "no encrypted files found" +# Never replace an output created by another process during decryption. +mv -Tn -- "$STAGING" "$OUTPUT_DIR" || fail "could not publish private output" +[[ ! -d "$STAGING" ]] || fail "output appeared during decryption" +printf '%s file(s) decrypted to private output; remove plaintext after the approved operation.\n' "$count" diff --git a/sso-mfa/bootstrap/encrypt-secrets.sh b/sso-mfa/bootstrap/encrypt-secrets.sh index 9a150c2..a9ca449 100755 --- a/sso-mfa/bootstrap/encrypt-secrets.sh +++ b/sso-mfa/bootstrap/encrypt-secrets.sh @@ -2,12 +2,11 @@ # encrypt-secrets.sh — encrypt secrets/ directory to secrets.enc/ using age # # Usage: -# ./encrypt-secrets.sh [SECRETS_DIR] [AGE_RECIPIENT_OR_KEY_FILE] +# ./encrypt-secrets.sh [SECRETS_DIR] [AGE_PUBLIC_RECIPIENT_OR_FILE] # # SECRETS_DIR plaintext secrets directory (default: ./secrets) -# AGE_RECIPIENT_OR_KEY_FILE age public key, public-key file, or private-key -# file with public-key comment -# (default: ~/.config/net-kingdom/age.key) +# AGE_PUBLIC_RECIPIENT_OR_FILE age public recipient or public-recipient file +# (default: registered keys/age.pub) # # Encrypts each *.env file (and pi.enc if present) to # secrets.enc//.age. Prefer passing a public age recipient @@ -19,15 +18,18 @@ # Run after gen-secrets.sh to store secrets safely in the repo. # Commit secrets.enc/ to git. +set +x set -euo pipefail +umask 077 SECRETS_DIR="${1:-./secrets}" -AGE_RECIPIENT_OR_KEY="${2:-$HOME/.config/net-kingdom/age.key}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AGE_RECIPIENT_OR_KEY="${2:-$SCRIPT_DIR/../../keys/age.pub}" NO_SHRED=false for arg in "$@"; do [[ "$arg" == "--no-shred" ]] && NO_SHRED=true; done if [[ ! -d "$SECRETS_DIR" ]]; then - echo "ERROR: secrets directory not found: $SECRETS_DIR" >&2 + echo "ERROR: source secrets directory is unavailable." >&2 echo "Run gen-secrets.sh first." >&2 exit 1 fi @@ -39,33 +41,27 @@ resolve_recipient() { return 0 fi if [[ ! -f "$source" ]]; then - echo "ERROR: age recipient/key file not found: $source" >&2 + echo "ERROR: public recipient file is unavailable." >&2 echo "Pass an age public recipient such as age1... or a file containing it." >&2 return 1 fi + # Never derive public metadata by loading private custody material. + if grep -q 'AGE-SECRET-KEY-' "$source"; then + echo "ERROR: encryption requires a public recipient, never a private-key file." >&2 + return 1 + fi local recipient recipient=$(grep -m1 '^age1' "$source" || true) if [[ -n "$recipient" ]]; then printf '%s\n' "$recipient" return 0 fi - recipient=$(grep -m1 'public key:' "$source" | awk '{print $NF}' || true) - if [[ -n "$recipient" ]]; then - printf '%s\n' "$recipient" - return 0 - fi - if grep -q 'AGE-SECRET-KEY-1' "$source"; then - recipient=$(age-keygen -y "$source" 2>/dev/null || true) - if [[ -n "$recipient" ]]; then - printf '%s\n' "$recipient" - return 0 - fi - fi - echo "ERROR: could not resolve an age public recipient from $source" >&2 + echo "ERROR: public recipient is missing from the supplied file." >&2 return 1 } PUBKEY=$(resolve_recipient "$AGE_RECIPIENT_OR_KEY") +printf '' | age -r "$PUBKEY" >/dev/null 2>&1 || { echo "ERROR: invalid public recipient." >&2; exit 1; } ENC_DIR="$(dirname "$SECRETS_DIR")/secrets.enc" mkdir -p "$ENC_DIR" diff --git a/sso-mfa/bootstrap/sops-custody-unlock.sh b/sso-mfa/bootstrap/sops-custody-unlock.sh index 8e622e3..9e9fa46 100755 --- a/sso-mfa/bootstrap/sops-custody-unlock.sh +++ b/sso-mfa/bootstrap/sops-custody-unlock.sh @@ -4,6 +4,7 @@ # 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 +x set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -61,20 +62,24 @@ while [[ $# -gt 0 ]]; do ;; --from-file) [[ $# -ge 2 ]] || fail "--from-file requires a path" + [[ -z "$INPUT_MODE" ]] || fail "choose exactly one custody input mode" SOURCE_FILE="$2" INPUT_MODE="file" shift 2 ;; --from-file=*) + [[ -z "$INPUT_MODE" ]] || fail "choose exactly one custody input mode" SOURCE_FILE="${1#*=}" INPUT_MODE="file" shift ;; --from-stdin) + [[ -z "$INPUT_MODE" ]] || fail "choose exactly one custody input mode" INPUT_MODE="stdin" shift ;; --prompt) + [[ -z "$INPUT_MODE" ]] || fail "choose exactly one custody input mode" INPUT_MODE="prompt" shift ;; @@ -92,7 +97,7 @@ while [[ $# -gt 0 ]]; do break ;; *) - fail "unknown argument: $1" + fail "unknown custody argument" ;; esac done @@ -108,10 +113,14 @@ if [[ -z "$INPUT_MODE" ]]; then if [[ -t 0 ]]; then INPUT_MODE="prompt" else - INPUT_MODE="stdin" + fail "noninteractive input requires explicit --from-stdin or --from-file" fi fi +if [[ "$CHECK_ONLY" == false && ${#COMMAND[@]} -eq 0 && ! -t 0 ]]; then + fail "an interactive custody shell requires a terminal; supply an explicit command" +fi + TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/netkingdom-sops-age.XXXXXX")" KEY_FILE="$TMP_DIR/keys.txt" cleanup() { @@ -124,13 +133,16 @@ cleanup() { fi rmdir "$TMP_DIR" 2>/dev/null || true } -trap cleanup EXIT HUP INT TERM +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' 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" + [[ -f "$SOURCE_FILE" ]] || fail "custody age key source is unavailable" + cp "$SOURCE_FILE" "$KEY_FILE" 2>/dev/null || fail "custody key copy failed" chmod 600 "$KEY_FILE" ;; stdin) @@ -162,8 +174,6 @@ DERIVED_RECIPIENT="$(age-keygen -y "$KEY_FILE" 2>/dev/null || true)" 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 @@ -174,6 +184,8 @@ if [[ "$CHECK_ONLY" == true ]]; then exit 0 fi +# The supplied key must not compete with an ambient inline key or key command. +unset SOPS_AGE_KEY SOPS_AGE_KEY_CMD export SOPS_AGE_KEY_FILE="$KEY_FILE" export NETKINGDOM_CUSTODY_AGE_KEY_FILE="$KEY_FILE" diff --git a/tests/test_custody_entrypoints.py b/tests/test_custody_entrypoints.py new file mode 100644 index 0000000..452c984 --- /dev/null +++ b/tests/test_custody_entrypoints.py @@ -0,0 +1,178 @@ +"""Disposable age keys only; subprocess logs are never asserted with raw values.""" +import os +from pathlib import Path +import subprocess +import pytest + +ROOT = Path(__file__).resolve().parents[1] +BOOT = ROOT / 'sso-mfa/bootstrap' + + +@pytest.fixture +def custody(tmp_path): + home = tmp_path / 'home'; home.mkdir() + key = tmp_path / 'disposable.key' + subprocess.run(['age-keygen', '-o', str(key)], capture_output=True, check=True) + public = subprocess.check_output(['age-keygen', '-y', str(key)], text=True).strip() + env = {'PATH': os.environ['PATH'], 'HOME': str(home), 'TMPDIR': str(tmp_path)} + return key, public, env + + +def unlock(custody, *args): + _, public, env = custody + return subprocess.run(['bash', str(BOOT / 'sops-custody-unlock.sh'), '--expected-recipient', public, *args], + env=env, capture_output=True, text=True, timeout=10) + + +def test_custody_unknown_arguments_are_not_echoed(custody): + result = unlock(custody, 'synthetic-secret-argument') + assert result.returncode != 0 + assert 'synthetic-secret-argument' not in result.stdout + result.stderr + + +def test_signal_preserves_failure_status_and_removes_temporary_key(custody): + key, _, env = custody + result = unlock(custody, '--from-file', str(key), '--', 'sh', '-c', 'kill -TERM "$PPID"; exit 0') + assert result.returncode == 143 + assert not list(Path(env['TMPDIR']).glob('netkingdom-sops-age.*')) + + +def test_ambiguous_custody_sources_are_refused_before_child(custody, tmp_path): + key, _, _ = custody + marker = tmp_path / 'child-called' + result = unlock(custody, '--from-file', str(key), '--from-file', str(key), '--', 'touch', str(marker)) + assert result.returncode != 0 + assert not marker.exists() + + +def test_explicit_custody_check_cleans_key(custody): + key, _, env = custody + result = unlock(custody, '--from-file', str(key), '--check-only') + assert result.returncode == 0 + assert not list(Path(env['TMPDIR']).glob('netkingdom-sops-age.*')) + + +def test_prompt_does_not_leak_when_shell_tracing_is_requested(custody): + import pty, select, time + key, public, env = custody + private_line = next(line for line in key.read_bytes().splitlines() if line.startswith(b'AGE-SECRET-KEY-')) + master, slave = pty.openpty() + child = subprocess.Popen(['bash', '-x', str(BOOT / 'sops-custody-unlock.sh'), + '--expected-recipient', public, '--prompt', '--check-only'], + stdin=slave, stdout=slave, stderr=slave, env=env) + os.close(slave) + captured = bytearray(); sent = False + deadline = time.monotonic() + 10 + 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 + captured.extend(chunk) + if b'input hidden): ' in captured and not sent: + os.write(master, private_line + b'\n'); sent = True + assert child.wait(timeout=2) == 0 + assert bool(private_line in captured) is False + assert not list(Path(env['TMPDIR']).glob('netkingdom-sops-age.*')) + finally: + if child.poll() is None: child.kill(); child.wait() + os.close(master) + + +def encrypt_fixture(tmp_path, public): + secrets = tmp_path / 'secrets'; (secrets / 'service').mkdir(parents=True) + (secrets / 'service/one.env').write_text('TEST_VALUE=synthetic-private-payload\n') + out = tmp_path / 'secrets.enc/service'; out.mkdir(parents=True) + subprocess.run(['age', '-r', public, '-o', str(out / 'one.env.age'), str(secrets / 'service/one.env')], + capture_output=True, check=True) + return secrets, out + + +def test_encryption_refuses_private_key_as_recipient(custody, tmp_path): + key, public, env = custody + secrets, _ = encrypt_fixture(tmp_path, public) + result = subprocess.run(['bash', str(BOOT / 'encrypt-secrets.sh'), str(secrets), str(key), '--no-shred'], + env=env, capture_output=True, text=True, timeout=10) + assert result.returncode != 0 + assert 'public recipient' in result.stderr + assert (secrets / 'service/one.env').exists() + + +def test_decryption_does_not_fall_back_to_home_key(custody, tmp_path): + key, public, env = custody + encrypt_fixture(tmp_path, public) + home_key = Path(env['HOME']) / '.config/net-kingdom/age.key' + home_key.parent.mkdir(parents=True); home_key.write_bytes(key.read_bytes()) + output = tmp_path / 'restored' + result = subprocess.run(['bash', str(BOOT / 'decrypt-secrets.sh'), str(output)], + env=env, capture_output=True, text=True, timeout=10) + assert result.returncode != 0 + assert not output.exists() + + +def test_decryption_failure_removes_partial_plaintext(custody, tmp_path): + key, public, env = custody + _, encrypted = encrypt_fixture(tmp_path, public) + (encrypted / 'two.env.age').write_bytes(b'not-a-valid-ciphertext') + output = tmp_path / 'restored' + result = subprocess.run(['bash', str(BOOT / 'decrypt-secrets.sh'), str(output), str(key)], + env=env, capture_output=True, text=True, timeout=10) + assert result.returncode != 0 + assert not output.exists() + assert not list(tmp_path.glob('.netkingdom-decrypt.*')) + + +def test_explicit_decryption_has_private_modes_and_no_output(custody, tmp_path): + key, public, env = custody + original, _ = encrypt_fixture(tmp_path, public) + output = tmp_path / 'restored' + result = subprocess.run(['bash', str(BOOT / 'decrypt-secrets.sh'), str(output), str(key)], + env=env, capture_output=True, text=True, timeout=10) + assert result.returncode == 0 + restored = output / 'service/one.env' + assert restored.read_bytes() == (original / 'service/one.env').read_bytes() + assert output.stat().st_mode & 0o077 == 0 + assert restored.stat().st_mode & 0o077 == 0 + assert 'synthetic-private-payload' not in result.stdout + result.stderr + + +def test_default_encryption_uses_registered_public_file(custody, tmp_path): + import shutil + _, public, env = custody + checkout = tmp_path / 'fixture-repo' + script_dir = checkout / 'sso-mfa/bootstrap'; script_dir.mkdir(parents=True) + shutil.copyfile(BOOT / 'encrypt-secrets.sh', script_dir / 'encrypt-secrets.sh') + (checkout / 'keys').mkdir(); (checkout / 'keys/age.pub').write_text(public + '\n') + secrets = tmp_path / 'plaintext'; (secrets / 'service').mkdir(parents=True) + (secrets / 'service/test.env').write_text('TEST=synthetic-private-payload\n') + result = subprocess.run(['bash', str(script_dir / 'encrypt-secrets.sh'), str(secrets)], + env=env, capture_output=True, text=True, timeout=10) + assert result.returncode == 0 + assert not secrets.exists() + assert (tmp_path / 'secrets.enc/service/test.env.age').is_file() + assert not (Path(env['HOME']) / '.config').exists() + + +def test_custody_child_does_not_inherit_competing_inline_key(custody): + key, public, env = custody + env = {**env, 'SOPS_AGE_KEY': 'synthetic-competing-key', 'SOPS_AGE_KEY_CMD': 'false'} + result = unlock((key, public, env), '--from-file', str(key), '--', 'sh', '-c', + 'test -z "${SOPS_AGE_KEY+x}" && test -z "${SOPS_AGE_KEY_CMD+x}" && test -f "$SOPS_AGE_KEY_FILE"') + assert result.returncode == 0 + + +@pytest.mark.parametrize('kind', ['file', 'directory', 'dangling-symlink']) +def test_decryption_never_overwrites_existing_output(custody, tmp_path, kind): + key, public, env = custody + encrypt_fixture(tmp_path, public) + output = tmp_path / 'restored' + if kind == 'file': output.write_bytes(b'existing-output') + elif kind == 'directory': output.mkdir(); (output / 'existing').write_bytes(b'existing-output') + else: output.symlink_to(tmp_path / 'uncreated') + result = subprocess.run(['bash', str(BOOT / 'decrypt-secrets.sh'), str(output), str(key)], + env=env, capture_output=True, text=True, timeout=10) + assert result.returncode != 0 + if kind == 'file': assert output.read_bytes() == b'existing-output' + elif kind == 'directory': assert (output / 'existing').read_bytes() == b'existing-output' + else: assert output.is_symlink(); assert not (tmp_path / 'uncreated').exists() diff --git a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md index 3def57c..22c8bd9 100644 --- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md +++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md @@ -49,7 +49,7 @@ Evidence and exact source manifest: Custodian ```task id: NK-WP-0049-T02 -status: todo +status: progress priority: high state_hub_task_id: "479d6970-6516-50f5-9436-bb5b56d58aad" ``` @@ -62,6 +62,27 @@ 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. +2026-10-12 caller audit: direct encrypt/decrypt helpers and custody unlock repaired. +Eight initial regressions failed against prior source. Encryption defaults to the +registered public file and refuses private-key inputs; decryption has no HOME +fallback and publishes only complete private output from staging, preserving +existing outputs and removing partial failures. Unlock suppresses xtrace before +hidden input, rejects ambiguous modes, removes competing inline/key-command +credentials and preserves signal failure status with EXIT cleanup. Disposable +crypto and prompt/child tests cover these boundaries. Bash foreground-child +signal deferral and arbitrary-child copy retention are explicitly not solved by +this helper. Evidence: Custodian `custody-entrypoints.json`. + +Concrete uncovered callers remain (T02 stays progress): +- `Makefile` sops-setup/creds-init copy or require permanent HOME private keys. +- `Makefile` sops-decrypt sends plaintext to stdout. +- `sso-mfa/bootstrap/creds-rotate.sh` logs newly generated values and embeds a + password in a printed SQL command. Its source can be repaired without executing + founder-deferred rotations; no such rotation has been run. +- `sso-mfa/k8s/privacyidea/bootstrap-admin.sh` prints the trigger-admin password. +- Delegated create/apply helpers still need full tracing/error/Secret-annotation + output review; transport capture and native effect evidence remain mandatory. + ## Verify owner-approved recovery and current native callers ```task