tools/secrets.sh — safe sops wrapper, so this cannot happen again

On 2026-08-16 a hand-rolled sops pipeline wrote plaintext to the real secrets path and only
THEN tried to encrypt it. The encrypt failed (creation_rules keyed on a filename the temp path
did not match), sops exited non-zero after the clobber had already happened, and the plaintext
file was copied to the host world-readable before anyone noticed.

The invariant here is that plaintext never exists at the destination path: every mutation
happens on a temp file in a 0700 dir, and install_encrypted() refuses to move anything into
place that is not verified ciphertext AND does not round-trip through a decrypt. A failure at
any step leaves the original untouched — verified by reproducing the incident (break the
creation rule, attempt a set, confirm the file's hash is unchanged).

Two other traps are handled because they already bit us: --config is passed explicitly, since
sops discovers .sops.yaml from the CWD and the manual runs only worked by accident of being in
the right directory; and PATH is hardened, because a tool 'not found' merely because
/run/current-system/sw/bin was absent reads as 'decryption failed', which is the wrong
conclusion entirely.

  SECRETS_HOST=b1 engine/tools/secrets.sh verify|list|get|set|unset|edit|deploy
This commit is contained in:
notplants-bot
2026-08-17 00:06:26 +00:00
parent 6ee9197fce
commit ec9f592e46
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# secrets.sh — safe wrapper for sops-encrypted per-host secrets in /secrets/<host>/.
#
# Shared agent tooling: any agent on any orchestrator can use this. Pick the host with
# SECRETS_HOST (default b1), e.g. SECRETS_HOST=b1 engine/tools/secrets.sh verify
#
# Layout it expects:
# /secrets/<host>/<host>.yaml sops-encrypted payload
# /secrets/<host>/.sops.yaml recipients
# /secrets/<host>/admin-age.key the admin private key (0600)
#
# WHY THIS EXISTS. On 2026-08-16 a hand-rolled sops pipeline did this:
#
# sops -d b1.yaml > /tmp/p.yaml # decrypt
# echo "new_key: value" >> /tmp/p.yaml
# cp /tmp/p.yaml b1.yaml # <-- plaintext written to the REAL path
# sops -e -i b1.yaml # <-- FAILED (creation_rules path mismatch)
#
# sops exited non-zero, the `cp` had already happened, and the plaintext file was then copied
# to the host — world-readable — before anyone noticed. Every secret in it was exposed.
#
# The invariant here: **plaintext never exists at the destination path.** All mutation happens
# on a temp file inside a 0700 directory; the result is encrypted, verified to be ciphertext,
# round-tripped through a decrypt, and only then moved into place atomically. Any failure at
# any step leaves the original file untouched.
set -euo pipefail
# PATH hardening. This script has already been bitten by a tool "not existing" merely because
# it was not on PATH — on NixOS, sops/nix live in /run/current-system/sw/bin and setuid wrappers
# in /run/wrappers/bin, neither of which is guaranteed in a non-login shell. A false "command not
# found" here reads as "decryption failed", which is exactly the wrong conclusion to draw.
export PATH="/run/wrappers/bin:/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin:$PATH"
HOST="${SECRETS_HOST:-b1}"
DIR="/secrets/$HOST"
FILE="$DIR/$HOST.yaml"
AGE_KEY="$DIR/admin-age.key"
die() { echo "error: $*" >&2; exit 1; }
have_sops() {
if command -v sops >/dev/null 2>&1; then SOPS=(sops)
elif command -v nix >/dev/null 2>&1; then
SOPS=(nix --extra-experimental-features "nix-command flakes" shell nixpkgs#sops -c sops)
else
die "neither sops nor nix found on PATH ($PATH)"
fi
}
is_encrypted() { grep -qE 'ENC\[AES256_GCM' "$1" 2>/dev/null; }
# Every mutation goes through here. It refuses to install anything that is not verified ciphertext.
install_encrypted() {
local tmp=$1
is_encrypted "$tmp" || die "refusing to install: result is NOT encrypted (this is the bug this script exists to prevent)"
SOPS_AGE_KEY_FILE="$AGE_KEY" "${SOPS[@]}" -d "$tmp" >/dev/null 2>&1 \
|| die "refusing to install: encrypted file does not decrypt with $AGE_KEY"
chmod 600 "$tmp"
mv -f "$tmp" "$FILE" # atomic within the same filesystem
echo "ok: $FILE updated ($(grep -c 'recipient:' "$FILE") recipients)"
}
workdir() { local d; d=$(mktemp -d "$DIR/.work.XXXXXX"); chmod 700 "$d"; echo "$d"; }
scrub() { [ -n "${WD:-}" ] && { find "$WD" -type f -exec shred -u {} + 2>/dev/null || true; rm -rf "$WD"; }; }
trap scrub EXIT
have_sops
[ -r "$DIR/.sops.yaml" ] || die "no $DIR/.sops.yaml — cannot know who may decrypt"
[ -d "$DIR" ] || die "no such secrets dir: $DIR"
[ -f "$FILE" ] || die "no such secrets file: $FILE"
cmd="${1:-help}"; shift || true
case "$cmd" in
list) # key names only, never values
SOPS_AGE_KEY_FILE="$AGE_KEY" "${SOPS[@]}" -d "$FILE" | grep -oE '^[a-zA-Z0-9_]+:' | tr -d ':' ;;
get) # print ONE value to stdout, for piping. Nothing is written to disk.
[ $# -ge 1 ] || die "usage: $0 get <key>"
SOPS_AGE_KEY_FILE="$AGE_KEY" "${SOPS[@]}" -d --extract "[\"$1\"]" "$FILE" ;;
set) # set <key> <file|-> value read from a file or stdin
[ $# -ge 2 ] || die "usage: $0 set <key> <file|->"
key=$1; src=$2
WD=$(workdir); p="$WD/plain.yaml"; e="$WD/enc.yaml"
SOPS_AGE_KEY_FILE="$AGE_KEY" "${SOPS[@]}" -d "$FILE" > "$p"
# drop any existing definition of this key (scalar or block)
awk -v k="$key" 'BEGIN{skip=0}
$0 ~ "^"k":" {skip=1; next}
skip==1 && /^[[:space:]]/ {next}
{skip=0; print}' "$p" > "$p.new" && mv "$p.new" "$p"
if [ "$src" = "-" ]; then val=$(cat); else [ -r "$src" ] || die "cannot read $src"; val=$(cat "$src"); fi
if [ "$(printf '%s' "$val" | wc -l)" -gt 0 ]; then
{ echo "$key: |"; printf '%s\n' "$val" | sed 's/^/ /'; } >> "$p" # multi-line block
else
printf '%s: %s\n' "$key" "$val" >> "$p"
fi
cp "$p" "$e"
# --filename-override makes creation_rules match regardless of the temp path. This is the
# exact failure that caused the incident: the rule keyed on the real filename, the temp file
# did not match, and encryption silently refused.
"${SOPS[@]}" --config "$DIR/.sops.yaml" -e -i --filename-override "$FILE" "$e"
install_encrypted "$e" ;;
unset) # remove a key entirely
[ $# -ge 1 ] || die "usage: $0 unset <key>"
key=$1
WD=$(workdir); p="$WD/plain.yaml"; e="$WD/enc.yaml"
SOPS_AGE_KEY_FILE="$AGE_KEY" "${SOPS[@]}" -d "$FILE" > "$p"
awk -v k="$key" 'BEGIN{skip=0}
$0 ~ "^"k":" {skip=1; next}
skip==1 && /^[[:space:]]/ {next}
{skip=0; print}' "$p" > "$e"
"${SOPS[@]}" --config "$DIR/.sops.yaml" -e -i --filename-override "$FILE" "$e"
install_encrypted "$e" ;;
edit)
SOPS_AGE_KEY_FILE="$AGE_KEY" "${SOPS[@]}" "$FILE"
is_encrypted "$FILE" || die "file is not encrypted after edit — restore from git/backup NOW" ;;
verify)
is_encrypted "$FILE" && echo " encrypted: yes" || die "NOT ENCRYPTED: $FILE"
echo " recipients: $(grep -c 'recipient:' "$FILE")"
echo " decrypts: $(SOPS_AGE_KEY_FILE="$AGE_KEY" "${SOPS[@]}" -d "$FILE" >/dev/null 2>&1 && echo yes || echo NO)"
echo " keys: $(SOPS_AGE_KEY_FILE="$AGE_KEY" "${SOPS[@]}" -d "$FILE" | grep -oE '^[a-zA-Z0-9_]+:' | tr -d ':' | tr '\n' ' ')"
n=$(grep -cE 'tskey-auth-[A-Za-z0-9]{5}|BEGIN OPENSSH PRIVATE KEY' "$FILE" || true)
[ "$n" -eq 0 ] && echo " plaintext leaks: none" || die "PLAINTEXT SECRETS PRESENT ($n)" ;;
deploy) # deploy <user@host> [remote-path] — refuses to ship anything unencrypted
[ $# -ge 1 ] || die "usage: $0 deploy <user@host> [remote-path]"
target=$1; rpath=${2:-/etc/nixos/secrets/$HOST.yaml}
is_encrypted "$FILE" || die "refusing to deploy: local file is not encrypted"
# SSH_OPTS lets the caller pass -i/-o without this script guessing at key locations.
# shellcheck disable=SC2086
scp -q ${SSH_OPTS:-} "$FILE" "$target:$rpath" || die "scp failed"
ssh ${SSH_OPTS:-} "$target" "chmod 600 '$rpath'" || die "chmod failed"
ssh ${SSH_OPTS:-} "$target" "grep -qE 'ENC\[AES256_GCM' '$rpath'" \
&& echo "ok: deployed and verified encrypted at $target:$rpath" \
|| die "remote file is not encrypted after deploy" ;;
*) sed -n '2,30p' "$0"; echo; echo "commands: list | get <key> | set <key> <file|-> | edit | verify | deploy <user@host> [path]" ;;
esac