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>
80 lines
5.4 KiB
Bash
Executable file
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"
|