igneum/tools/keys/backup.sh
igneum-labs ebab129e04 Key custody: inventory, encrypted backup and restore, no-secrets CI check
docs/security/keys.md: every key the project depends on (the folder, the gh
keyring, the Vercel env of three projects, the GitHub secrets) with where it
lives, what it unlocks, the blast radius lost and leaked, who rotates it and
the rotation status, written from the files and the scripts that read them.
No value, no private fingerprint. Section 4: the second OTA signing key kept
offline, the app change (a key list plus revocation in the manifest), 0.3.9
as the carrier, and the emergency path if the one key leaks today (a manifest
signed with a new key is useless to 0.3.x apps; the mitigation in order).

tools/keys/backup.sh: ~/Desktop/igneum-keys-<date>.dmg, AES-256, hdiutil's
own prompt (never argv, history or a file), the folder minus build-slots,
dlsite-dir and pytools/, plus a README; attached read-only, every file
compared by sha256, listed, detached. --dry-run lists. restore.sh: --check
compares the image against the live folder without printing values, --to
copies back with 0600/0644 and 0700. test-backup.sh: the end-to-end test on a
scratch folder with a throwaway passphrase, 8 steps, passed.

tools/ci/no-secrets-check.sh, in ci.yml: no tracked file named like a key of
~/.config/igneum, no 64-hex value assigned to a token/key/secret name outside
tests and the allowlist (the OTA public key, the published Hardhat and Anvil
accounts); a --self-test fires on a known-bad tree first. 776 files, 0 hits.

Also: ~/.config/igneum, vercel/ and txgen/ are 0700 now (were 0755).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 17:01:01 +00:00

190 lines
9.4 KiB
Bash
Executable file

#!/usr/bin/env bash
# Encrypted backup of ~/.config/igneum: one AES-256 disk image on the Desktop, verified, then unmounted.
#
# tools/keys/backup.sh # prompts for a passphrase on the terminal (hdiutil's own prompt, twice:
# # once to create, once to verify); nothing passes through argv, history or a file
# tools/keys/backup.sh --dry-run # lists what would go in, creates nothing
# tools/keys/backup.sh --agent # the passphrase through the macOS Security Agent dialog instead of the terminal
#
# What goes in: every file under ~/.config/igneum except build-slots, dlsite-dir (settings, not secrets) and pytools/
# (a pip copy of git-filter-repo), with its mode and mtime, plus README.txt (the listing, no values) and the inventory
# docs/security/keys.md when this script runs from the repository. Output: ~/Desktop/igneum-keys-<YYYY-MM-DD>.dmg,
# read-only, compressed, AES-256 (hdiutil create -encryption AES-256 -format UDZO). An existing output is never
# overwritten. After the create the image is attached read-only at a private mount point, every file is compared by
# sha256 against the staged copy (counts and a match line, never a value), then detached. The staging folder is a
# 0700 mktemp directory, removed at exit.
#
# Test harness only (tools/keys/test-backup.sh): --stdinpass reads a NUL-terminated passphrase from standard input
# once and feeds it to both hdiutil calls. Never type a real passphrase through it. --source and --out point the
# script at a scratch folder and a scratch output.
#
# Values are never printed: this script prints names, sizes, modes and counts.
set -euo pipefail
SRC="$HOME/.config/igneum"
OUT=""
DRY=0; MODE="tty"
EXCLUDE_NAMES=(build-slots dlsite-dir) # settings, not secrets
EXCLUDE_DIRS=(pytools) # a pip library (git-filter-repo), 210 KB, not a secret
HERE="$(cd "$(dirname "$0")" && pwd)"
REPO="$(cd "$HERE/../.." && pwd)"
INVENTORY="$REPO/docs/security/keys.md"
usage() { sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 2; }
say() { printf '%s\n' "$*"; }
die() { printf 'backup: %s\n' "$*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY=1 ;;
--agent) MODE="agent" ;;
--stdinpass) MODE="stdin" ;;
--source) SRC="${2:?--source needs a folder}"; shift ;;
--out) OUT="${2:?--out needs a file}"; shift ;;
-h|--help) usage ;;
*) die "unknown argument $1" ;;
esac
shift
done
[ -d "$SRC" ] || die "no folder at $SRC"
DATE="$(date -u +%Y-%m-%d)"
[ -n "$OUT" ] || OUT="$HOME/Desktop/igneum-keys-$DATE.dmg"
case "$OUT" in *.dmg) ;; *) die "the output must end in .dmg" ;; esac
command -v hdiutil >/dev/null || die "hdiutil is not available (macOS only)"
command -v shasum >/dev/null || die "shasum is not available"
# The file list: relative paths, sorted, excluding the non-secrets. Only regular files travel.
list_files() {
(cd "$SRC" && find . -type f -print | sed 's#^\./##' | LC_ALL=C sort) | while IFS= read -r rel; do
base="${rel##*/}"; top="${rel%%/*}"
skip=0
for n in "${EXCLUDE_NAMES[@]}"; do [ "$rel" = "$n" ] && skip=1; done
for d in "${EXCLUDE_DIRS[@]}"; do [ "$top" = "$d" ] && [ "$top" != "$rel" ] && skip=1; done
[ "$base" = ".DS_Store" ] && skip=1
[ $skip -eq 0 ] && printf '%s\n' "$rel"
done
}
# One line per file: mode, size, mtime (UTC), name. No contents.
describe() {
local rel="$1" f="$SRC/$1"
printf '%s %8s bytes %s %s\n' "$(stat -f '%Sp' "$f")" "$(stat -f '%z' "$f")" "$(date -u -r "$(stat -f '%m' "$f")" +%Y-%m-%dT%H:%M:%SZ)" "$rel"
}
FILES="$(list_files)"
COUNT="$(printf '%s\n' "$FILES" | grep -c . || true)"
[ "$COUNT" -gt 0 ] || die "nothing to back up under $SRC"
say "source $SRC"
say "output $OUT"
say "excluded ${EXCLUDE_NAMES[*]} ${EXCLUDE_DIRS[*]}/ (not secrets)"
say "files $COUNT"
while IFS= read -r rel; do describe "$rel"; done <<< "$FILES"
WORLD="$(while IFS= read -r rel; do [[ "$(stat -f '%Sp' "$SRC/$rel")" == ???????r* ]] && printf '%s\n' "$rel"; done <<< "$FILES" | grep -v '\.pub$' || true)"
[ -z "$WORLD" ] || say "note world-readable (fine only for public files): $(printf '%s ' $WORLD)"
if [ $DRY -eq 1 ]; then
say "dry run: nothing created. README.txt and $( [ -f "$INVENTORY" ] && echo "docs/security/keys.md" || echo "(no keys.md found)" ) would be added."
exit 0
fi
[ -e "$OUT" ] && die "$OUT exists; not overwriting a backup (move it or pick --out)"
mkdir -p "$(dirname "$OUT")"
if [ "$MODE" = "tty" ] && [ ! -t 0 ]; then
die "no terminal for the passphrase prompt; run from a terminal, or --agent for the macOS dialog"
fi
# The passphrase, test harness only: read once from standard input, NUL-terminated, kept in this process only.
PASS=""
if [ "$MODE" = "stdin" ]; then
IFS= read -r -d '' PASS || true
[ -n "$PASS" ] || die "--stdinpass: no passphrase on standard input"
fi
STAGE="$(mktemp -d "${TMPDIR:-/tmp}/igneum-keys-stage.XXXXXX")"
chmod 700 "$STAGE"
MNT="$(mktemp -d "${TMPDIR:-/tmp}/igneum-keys-mnt.XXXXXX")"
MNT="$(cd "$MNT" && pwd -P)" # the physical path: $TMPDIR is a symlink on macOS and `mount` prints the real one
attached() { mount | grep -qF " on $MNT "; }
cleanup() {
if attached; then hdiutil detach "$MNT" -quiet >/dev/null 2>&1 || hdiutil detach "$MNT" -force -quiet >/dev/null 2>&1 || true; fi
attached || rm -rf "$MNT"
rm -rf "$STAGE"
PASS=""
}
trap cleanup EXIT
# Stage: the files with their modes and mtimes.
while IFS= read -r rel; do
mkdir -p "$STAGE/$(dirname "$rel")"
cp -p "$SRC/$rel" "$STAGE/$rel"
done <<< "$FILES"
chmod -R u+rwX,go-rwx "$STAGE"
# README.txt: the listing and the inventory, no values.
{
echo "Igneum key backup, $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "Source: $SRC on $(hostname -s)"
echo "Files ($COUNT), mode, size, mtime, name:"
while IFS= read -r rel; do describe "$rel"; done <<< "$FILES"
echo
echo "Restore: tools/keys/restore.sh <this image> --check (compares against the live folder, prints no values)"
echo " tools/keys/restore.sh <this image> --to ~/.config/igneum"
echo "Excluded on purpose: ${EXCLUDE_NAMES[*]} (settings) and ${EXCLUDE_DIRS[*]}/ (a pip library)."
if [ -f "$SRC/ota-signing-key.pub" ]; then
echo "OTA public key fingerprint (sha256 of the 32 raw bytes): $(python3 -c 'import hashlib,sys;print(hashlib.sha256(bytes.fromhex(open(sys.argv[1]).read().strip())).hexdigest())' "$SRC/ota-signing-key.pub" 2>/dev/null || echo unknown)"
fi
if [ -f "$INVENTORY" ]; then
echo; echo "----- docs/security/keys.md at $(git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo unknown) -----"; echo
cat "$INVENTORY"
fi
} > "$STAGE/README.txt"
chmod 600 "$STAGE/README.txt"
# Create. The passphrase: hdiutil's own prompt (tty), the Security Agent (--agent), or the harness pipe (--stdinpass).
say "creating $OUT (AES-256, read-only, compressed)"
case "$MODE" in
tty) hdiutil create -srcfolder "$STAGE" -volname "igneum-keys-$DATE" -encryption AES-256 -format UDZO -quiet "$OUT" ;;
agent) hdiutil create -srcfolder "$STAGE" -volname "igneum-keys-$DATE" -encryption AES-256 -agentpass -format UDZO -quiet "$OUT" ;;
stdin) printf '%s\0' "$PASS" | hdiutil create -srcfolder "$STAGE" -volname "igneum-keys-$DATE" -encryption AES-256 -stdinpass -format UDZO -quiet "$OUT" ;;
esac
chmod 600 "$OUT"
# Verify: attach read-only at a private mount point, compare every file by sha256 against the stage, detach.
say "verifying attaching read-only (the passphrase again)"
case "$MODE" in
tty) hdiutil attach "$OUT" -readonly -nobrowse -noautoopen -mountpoint "$MNT" -quiet ;;
agent) hdiutil attach "$OUT" -readonly -nobrowse -noautoopen -agentpass -mountpoint "$MNT" -quiet ;;
stdin) printf '%s\0' "$PASS" | hdiutil attach "$OUT" -readonly -nobrowse -noautoopen -stdinpass -mountpoint "$MNT" -quiet ;;
esac
PASS=""
attached || die "the image did not attach at $MNT; do not trust $OUT"
MOUNTED="$(cd "$MNT" && find . -type f ! -name .DS_Store -print | sed 's#^\./##' | LC_ALL=C sort)"
MCOUNT="$(printf '%s\n' "$MOUNTED" | grep -c . || true)"
say "mounted $MCOUNT files:"
while IFS= read -r rel; do printf ' %8s bytes %s\n' "$(stat -f '%z' "$MNT/$rel")" "$rel"; done <<< "$MOUNTED"
A="$(cd "$STAGE" && find . -type f -print | sed 's#^\./##' | LC_ALL=C sort | while IFS= read -r r; do shasum -a 256 "$r"; done)"
B="$(cd "$MNT" && find . -type f ! -name .DS_Store -print | sed 's#^\./##' | LC_ALL=C sort | while IFS= read -r r; do shasum -a 256 "$r"; done)"
if [ "$A" = "$B" ] && [ "$MCOUNT" -eq $((COUNT + 1)) ]; then
say "verified $MCOUNT files in the image are byte-identical to the staged copies ($COUNT secrets + README.txt)"
else
die "VERIFY FAILED: the image does not match the staged files (image $MCOUNT, expected $((COUNT + 1))); do not trust $OUT"
fi
hdiutil detach "$MNT" -quiet
attached && die "the image is still attached at $MNT; detach it by hand (hdiutil detach)"
say "detached"
cat <<EOF
done $OUT ($(stat -f '%z' "$OUT") bytes, mode 600)
What to do with it now:
1. Copy it to TWO media that are not this Mac: a USB stick kept at home and a second stick or an encrypted
cloud folder you already trust. Check each copy's size matches. Then delete the Desktop copy.
2. Write the passphrase on paper. Keep the paper away from both media. No passphrase, no keys: the image is
AES-256 and nobody can open it without it.
3. After every rotation (a new key, a new token) run this again and replace both copies; keep one old image.
4. Test it: tools/keys/restore.sh <image> --check compares the image to the live folder without printing values.
EOF