#!/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"