igneum/tools/keys/test-backup.sh
igneum-labs bbc05961b2 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

80 lines
5.4 KiB
Bash
Executable file

#!/usr/bin/env bash
# End-to-end test of backup.sh and restore.sh on a SCRATCH folder with a throwaway passphrase. Never points at
# ~/.config/igneum and never uses a real passphrase: the passphrase is generated here and piped through --stdinpass.
#
# tools/keys/test-backup.sh # exit 0 when every step passes; prints each step
#
# Steps: a scratch folder with the same file names as the real one (random contents), backup --dry-run, backup
# --stdinpass, restore --list, restore --check (must match), a changed live file (check must FAIL), restore --to a
# fresh folder (modes 600/644, check must match), a wrong passphrase (attach must fail). macOS only (hdiutil).
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
T="$(mktemp -d "${TMPDIR:-/tmp}/igneum-keys-test.XXXXXX")"; chmod 700 "$T"
trap 'rm -rf "$T"' EXIT
SRC="$T/config"; mkdir -p "$SRC/txgen" "$SRC/vercel" "$SRC/pytools"
rnd() { head -c "$1" /dev/urandom | base64 | tr -d '\n/+=' | head -c "$1"; }
for n in ota-signing-key relay-token relay-key dl-token dl-token.old-2026-10-05 log-intake-key log-intake-key.old-2026-10-05 hetzner-token desec-token env relay-token.old-2026-10-04; do
rnd 40 > "$SRC/$n"; chmod 600 "$SRC/$n"
done
printf '{"purpose":"test","private_key":"0x%s"}\n' "$(rnd 64)" > "$SRC/dev-fee-devnet.json"; chmod 600 "$SRC/dev-fee-devnet.json"
printf '{"wallets":[]}\n' > "$SRC/txgen/wallets.json"; chmod 600 "$SRC/txgen/wallets.json"
printf '{"token":"%s"}\n' "$(rnd 24)" > "$SRC/vercel/auth.json"; chmod 600 "$SRC/vercel/auth.json"
printf '{"currentTeam":"x"}\n' > "$SRC/vercel/config.json"; chmod 644 "$SRC/vercel/config.json"
printf '%s\n' "$(rnd 64 | tr -c '0-9a-f\n' 'a')" > "$SRC/ota-signing-key.pub"; chmod 644 "$SRC/ota-signing-key.pub"
printf '2\n' > "$SRC/build-slots"; chmod 644 "$SRC/build-slots"
printf '/nowhere/dlsite\n' > "$SRC/dlsite-dir"; chmod 600 "$SRC/dlsite-dir"
printf '# not a secret\n' > "$SRC/pytools/git_filter_repo.py"
PASS="test-$(rnd 24)"
OUT="$T/igneum-keys-test.dmg"
step() { printf '\n== %s\n' "$*"; }
step "1 dry run"
"$HERE/backup.sh" --dry-run --source "$SRC" --out "$OUT" | tee "$T/dry.txt"
grep -q 'files 16$' "$T/dry.txt" || { echo "FAIL: expected 16 files in the dry run"; exit 1; }
grep -q 'build-slots' "$T/dry.txt" && grep -q 'excluded' "$T/dry.txt" || true
! grep -E '^-.* (build-slots|dlsite-dir|pytools/)' "$T/dry.txt" || { echo "FAIL: an excluded file is listed"; exit 1; }
[ ! -e "$OUT" ] || { echo "FAIL: the dry run created the image"; exit 1; }
step "2 backup with the harness passphrase"
printf '%s\0' "$PASS" | "$HERE/backup.sh" --stdinpass --source "$SRC" --out "$OUT" | tee "$T/backup.txt"
grep -q '^verified 17 files' "$T/backup.txt" || { echo "FAIL: the verify line is missing"; exit 1; }
[ -f "$OUT" ] || { echo "FAIL: no image"; exit 1; }
[ "$(stat -f '%Sp' "$OUT")" = "-rw-------" ] || { echo "FAIL: the image is not 0600"; exit 1; }
if grep -q -F "$(cat "$SRC/relay-token")" "$T/backup.txt" "$T/dry.txt"; then echo "FAIL: a value was printed"; exit 1; fi
step "3 restore --list"
printf '%s\0' "$PASS" | "$HERE/restore.sh" "$OUT" --stdinpass --list | tee "$T/list.txt"
grep -q 'files 16 ' "$T/list.txt" || { echo "FAIL: expected 16 files listed"; exit 1; }
step "4 restore --check against the unchanged source (must match)"
printf '%s\0' "$PASS" | "$HERE/restore.sh" "$OUT" --stdinpass --check --source "$SRC" | tee "$T/check1.txt"
grep -q 'every file in the image matches' "$T/check1.txt" || { echo "FAIL: the check did not pass on identical files"; exit 1; }
step "5 restore --check after a live file changes (must FAIL)"
rnd 40 > "$SRC/relay-token"
if printf '%s\0' "$PASS" | "$HERE/restore.sh" "$OUT" --stdinpass --check --source "$SRC" > "$T/check2.txt" 2>&1; then
cat "$T/check2.txt"; echo "FAIL: the check passed on a changed file"; exit 1
fi
grep -q 'relay-token .*DIFFERS' "$T/check2.txt" || { cat "$T/check2.txt"; echo "FAIL: the changed file is not reported"; exit 1; }
echo "ok: the check failed on the changed file, as it must"
step "6 restore --to a fresh folder"
printf '%s\0' "$PASS" | "$HERE/restore.sh" "$OUT" --stdinpass --to "$T/restored" | tee "$T/restore.txt"
grep -q 'every restored file matches the image' "$T/restore.txt" || { echo "FAIL: the restore check"; exit 1; }
[ "$(stat -f '%Sp' "$T/restored/ota-signing-key")" = "-rw-------" ] || { echo "FAIL: restored key is not 0600"; exit 1; }
[ "$(stat -f '%Sp' "$T/restored/ota-signing-key.pub")" = "-rw-r--r--" ] || { echo "FAIL: restored .pub is not 0644"; exit 1; }
[ "$(stat -f '%Sp' "$T/restored")" = "drwx------" ] || { echo "FAIL: restored folder is not 0700"; exit 1; }
[ ! -e "$T/restored/build-slots" ] || { echo "FAIL: build-slots was restored"; exit 1; }
[ ! -e "$T/restored/README.txt" ] || { echo "FAIL: README.txt was restored as a secret"; exit 1; }
if printf '%s\0' "$PASS" | "$HERE/restore.sh" "$OUT" --stdinpass --to "$T/restored" > "$T/restore2.txt" 2>&1; then echo "FAIL: overwrote without --force"; exit 1; fi
echo "ok: a second restore without --force is refused"
step "7 a wrong passphrase must not open the image"
if printf '%s\0' "not-$PASS" | "$HERE/restore.sh" "$OUT" --stdinpass --list > "$T/wrong.txt" 2>&1; then echo "FAIL: a wrong passphrase opened the image"; exit 1; fi
echo "ok: refused"
step "8 the image never holds a plain value"
if grep -a -q -F "$(cat "$SRC/hetzner-token")" "$OUT"; then echo "FAIL: a value is readable in the image bytes"; exit 1; fi
echo "ok: the raw image bytes do not contain the test values"
printf '\nall steps passed (%s)\n' "$OUT"