From ec9f592e46be5a0d0168bc79692e1d323ad51399 Mon Sep 17 00:00:00 2001 From: notplants-bot Date: Mon, 17 Aug 2026 00:06:26 +0000 Subject: [PATCH] =?UTF-8?q?tools/secrets.sh=20=E2=80=94=20safe=20sops=20wr?= =?UTF-8?q?apper,=20so=20this=20cannot=20happen=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tools/secrets.sh | 140 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100755 tools/secrets.sh diff --git a/tools/secrets.sh b/tools/secrets.sh new file mode 100755 index 0000000..87dba20 --- /dev/null +++ b/tools/secrets.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# secrets.sh — safe wrapper for sops-encrypted per-host secrets in /secrets//. +# +# 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//.yaml sops-encrypted payload +# /secrets//.sops.yaml recipients +# /secrets//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 " + SOPS_AGE_KEY_FILE="$AGE_KEY" "${SOPS[@]}" -d --extract "[\"$1\"]" "$FILE" ;; + + set) # set value read from a file or stdin + [ $# -ge 2 ] || die "usage: $0 set " + 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=$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 [remote-path] — refuses to ship anything unencrypted + [ $# -ge 1 ] || die "usage: $0 deploy [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 | set | edit | verify | deploy [path]" ;; +esac