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:
Executable
+140
@@ -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
|
||||
Reference in New Issue
Block a user