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