#!/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"; } # NB: must return 0. As an EXIT trap its status becomes the script's status, and an # `[ -n "$WD" ] && ...` that is simply false would make every read-only command (get/list/verify) # exit 1 while printing a perfectly correct answer — a silent false failure in callers. scrub() { if [ -n "${WD:-}" ]; then find "$WD" -type f -exec shred -u {} + 2>/dev/null || true rm -rf "$WD" fi return 0 } 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