fix: make custody input explicit and remove partial decryptions
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a12d13-27f2-7163-b8c0-f682af02772c
This commit is contained in:
parent
b97f9f616f
commit
4cf26a9bc8
6 changed files with 299 additions and 79 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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/<component>/<filename>.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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
178
tests/test_custody_entrypoints.py
Normal file
178
tests/test_custody_entrypoints.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue