net-kingdom/sso-mfa/bootstrap/decrypt-secrets.sh
tegwick d9557ba90e
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Harden remaining bootstrap custody callers and prepare isolated recovery
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a13eab-1e6a-7b33-9c37-372e90ada3a2
2026-10-15 22:57:14 +02:00

65 lines
3 KiB
Bash
Executable file

#!/usr/bin/env bash
# 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:-${NETKINGDOM_CUSTODY_AGE_KEY_FILE:-}}"
[[ $# -le 2 ]] || fail "unsupported decrypt argument"
[[ -n "$AGE_KEY" && -f "$AGE_KEY" ]] || fail "explicit temporary custody key required"
# Validate an owned, private, non-linked age identity before passing it to age.
# No HOME/plugin/SSH identity discovery is admitted by this recovery path.
python3 - "$AGE_KEY" 2>/dev/null <<'PYKEY' || fail "custody key must be an owned private regular age identity"
import os, pathlib, stat, sys
p = pathlib.Path(os.path.abspath(sys.argv[1]))
if any(x.is_symlink() for x in (p, *p.parents)): raise ValueError()
fd = os.open(p, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
try:
st = os.fstat(fd)
if not stat.S_ISREG(st.st_mode) or st.st_uid != os.getuid() or st.st_mode & 0o077 or st.st_nlink != 1 or st.st_size > 16384:
raise ValueError()
data = os.read(fd, 16385).decode('ascii')
values = [x for x in data.splitlines() if x and not x.startswith('#')]
if len(values) != 1 or not values[0].startswith('AGE-SECRET-KEY-1'): raise ValueError()
finally: os.close(fd)
PYKEY
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"
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
[[ -e "$component_dir" || -L "$component_dir" ]] || continue
[[ -d "$component_dir" && ! -L "$component_dir" ]] || fail "unexpected encrypted component"
component=$(basename "$component_dir")
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
[[ "$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"