Files
net-kingdom/sso-mfa/bootstrap/sops-custody-unlock.sh
2026-06-14 19:51:05 +02:00

188 lines
5.7 KiB
Bash
Executable File

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