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
141 lines
6.9 KiB
Bash
Executable File
141 lines
6.9 KiB
Bash
Executable File
#!/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
|