Clarifications on sops

This commit is contained in:
2026-06-14 19:51:05 +02:00
parent 443b585010
commit 3ab326b597
5 changed files with 320 additions and 1 deletions

View File

@@ -49,7 +49,7 @@ hooks-test: ## Test that the pre-commit hook blocks plaintext secrets
@rm -rf sso-mfa/bootstrap/secrets/_hooktest @rm -rf sso-mfa/bootstrap/secrets/_hooktest
# ── SOPS / age ──────────────────────────────────────────────────────────────── # ── SOPS / age ────────────────────────────────────────────────────────────────
sops-setup: ## Copy age key to SOPS default path (~/.config/sops/age/keys.txt) sops-setup: ## Legacy dev-local setup: copy age key to SOPS default path
@mkdir -p ~/.config/sops/age @mkdir -p ~/.config/sops/age
@if [[ -f ~/.config/age/key.txt ]]; then \ @if [[ -f ~/.config/age/key.txt ]]; then \
cp -n ~/.config/age/key.txt ~/.config/sops/age/keys.txt || true; \ cp -n ~/.config/age/key.txt ~/.config/sops/age/keys.txt || true; \
@@ -81,6 +81,21 @@ sops-rotate: ## Rotate SOPS recipients after updating .sops.yaml: make sops-rota
sops --rotate --in-place $(FILE) sops --rotate --in-place $(FILE)
@echo "✔ Recipients rotated for $(FILE)" @echo "✔ Recipients rotated for $(FILE)"
sops-custody-check: ## Validate a custody age key against keys/age.pub without keeping it on disk
@bash sso-mfa/bootstrap/sops-custody-unlock.sh \
--expected-recipient "$(OPERATOR_AGE_PUBKEY)" \
--check-only
sops-custody-shell: ## Open a shell with a temporary SOPS_AGE_KEY_FILE from custody material
@bash sso-mfa/bootstrap/sops-custody-unlock.sh \
--expected-recipient "$(OPERATOR_AGE_PUBKEY)"
sops-custody-run: ## Run COMMAND with a temporary custody age key: make sops-custody-run COMMAND='make -C ../inter-hub recovery-drill'
@[[ -n "$(COMMAND)" ]] || (echo "Usage: make sops-custody-run COMMAND='make -C ../inter-hub recovery-drill'"; exit 1)
@COMMAND='$(COMMAND)' bash sso-mfa/bootstrap/sops-custody-unlock.sh \
--expected-recipient "$(OPERATOR_AGE_PUBKEY)" \
-- bash -lc "$$COMMAND"
check-secrets: ## Fail if any file under secrets/ is not SOPS-encrypted check-secrets: ## Fail if any file under secrets/ is not SOPS-encrypted
@echo "Checking for unencrypted files under secrets/..." @echo "Checking for unencrypted files under secrets/..."
@bad=0; \ @bad=0; \

View File

@@ -70,3 +70,63 @@ directly. Encryption does not require the private key.
`sso-mfa/bootstrap/decrypt-secrets.sh` requires the private key path and should `sso-mfa/bootstrap/decrypt-secrets.sh` requires the private key path and should
be used only in an explicit unlock/apply ceremony. After apply, plaintext files be used only in an explicit unlock/apply ceremony. After apply, plaintext files
must be shredded. must be shredded.
## Custody Unlock Helper
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:
- reads the private age key from a 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`;
- runs the requested command or opens a temporary custody shell; and
- shreds/removes the temporary key file when the command or shell exits.
It must not be used to install the private key permanently on a workstation or
server. The source of truth remains the password safe/offline custody packet.
### Validate Custody Material
From the NetKingdom repo, validate the supplied private key against `keys/age.pub`
without keeping it on disk:
```bash
make sops-custody-check
```
### Run A One-Shot Recovery Drill
For inter-hub:
```bash
make sops-custody-run COMMAND='make -C /home/worsch/inter-hub recovery-drill'
```
The helper prompts for the `AGE-SECRET-KEY-1...` line from the password safe or
offline custody packet, validates it against the registered public recipient,
sets `SOPS_AGE_KEY_FILE` for the command, and removes the temporary key after
the drill exits.
If using a password-manager CLI, pipe only the private key field:
```bash
op read 'op://Platform/NetKingdom custodian age key/private-key' \
| sso-mfa/bootstrap/sops-custody-unlock.sh \
--from-stdin \
-- make -C /home/worsch/inter-hub recovery-drill
```
### Open An Incident Shell
For multi-step recovery or lockdown work:
```bash
make sops-custody-shell
```
Run the required SOPS, recovery, or lockdown commands inside that shell. Exit
the shell to shred the temporary key file.

View File

@@ -115,3 +115,24 @@ See `../vault/` (created in T01 Phase 0b) for:
- ESO (External Secrets Operator) configuration - ESO (External Secrets Operator) configuration
- Vault secret path layout - Vault secret path layout
- Migration procedure: KeePassXC → Vault - Migration procedure: KeePassXC → Vault
## Custody Age Key Unlock
For SOPS-backed drills and incident work, do not copy the custodian age private
key into a permanent workstation path. Use the custody helper from the repo root:
```bash
make sops-custody-run COMMAND='make -C /home/worsch/inter-hub recovery-drill'
```
The helper prompts for the `AGE-SECRET-KEY-1...` line from the password safe or
offline custody packet, validates it against `keys/age.pub`, writes a temporary
`SOPS_AGE_KEY_FILE`, runs the command, and removes the temporary file on exit.
For multi-step recovery work:
```bash
make sops-custody-shell
```
Exit the shell when finished so the temporary key file is removed.

View File

@@ -0,0 +1,187 @@
#!/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

View File

@@ -0,0 +1,36 @@
---
id: ADHOC-2026-06-14
type: workplan
title: "Ad hoc NetKingdom operator usability fixes"
domain: netkingdom
repo: net-kingdom
status: finished
owner: codex
topic_slug: netkingdom
created: "2026-06-14"
updated: "2026-06-14"
---
# Ad hoc NetKingdom operator usability fixes
## SOPS Custody Unlock Helper
```task
id: ADHOC-2026-06-14-T01
status: done
priority: medium
```
Added a custody unlock helper for SOPS/age operations so drills and incident
commands can use the password-safe/offline custody age private key without
installing it permanently on a workstation.
The helper validates the supplied private key against the expected public age
recipient, writes a temporary `0600` `SOPS_AGE_KEY_FILE`, runs the requested
command or opens an incident shell, and removes the temporary key on exit.
Documented the inter-hub recovery-drill path:
```bash
make sops-custody-run COMMAND='make -C /home/worsch/inter-hub recovery-drill'
```