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>
97 lines
6.6 KiB
Bash
Executable file
97 lines
6.6 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# No secret in the tree, for CI (ci.yml) and for a pre-push look on the Mac.
|
|
#
|
|
# Two checks over the tracked files (git ls-files; the working tree when not in a git checkout):
|
|
# 1. file NAMES: nothing tracked may be named like a file of ~/.config/igneum (ota-signing-key, relay-token,
|
|
# relay-key, dl-token, log-intake-key, hetzner-token, desec-token, dev-fee-*.json, wallets.json, vercel auth.json,
|
|
# their .next and .old-<date> variants), nor igneum-app.json (the packaged config carries the intake key),
|
|
# igneum-log-key.txt, igneum-relay-clients.zip (the relay token and key baked in), *.env, .env*, a bare `env`.
|
|
# 2. file CONTENTS: a 64-hex string (optionally 0x-prefixed) assigned to a name ending in token, key, secret,
|
|
# password or passphrase (`KEY = "<64 hex>"`, `token: <64 hex>`, `x-igneum-key: <64 hex>`), case-insensitive,
|
|
# in non-test files. Skipped: files with `test` in the name, proving/fixtures/, infra/cloud-devnet/results/,
|
|
# *.log, vendor/, node_modules/, target/. Allowlisted by path (ALLOW below, each with its reason): the OTA
|
|
# public key in the app (public by design) and the published Hardhat/Anvil developer accounts the devnet tools
|
|
# use (public test vectors; never fund them on a real network).
|
|
#
|
|
# tools/ci/no-secrets-check.sh # exit 1 on any hit, hits printed with the hex masked
|
|
# tools/ci/no-secrets-check.sh --self-test # the name rule and the content rule must fire on a known-bad case and
|
|
# # stay quiet on a known-good one (the gate rule of CLAUDE.md)
|
|
set -euo pipefail
|
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
REPO="$(cd "$HERE/../.." && pwd)"
|
|
|
|
# 1. forbidden basenames (grep -E, anchored on the basename)
|
|
NAME_RULES='^(ota-signing-key|relay-token|relay-key|dl-token|log-intake-key|hetzner-token|desec-token)(\.next|\.old-[0-9-]+)?$|^(dev-fee-devnet|dev-fee-release|wallets|auth|igneum-app)\.json$|^igneum-log-key\.txt$|^igneum-relay-clients\.zip$|\.env$|^\.env|^env$|\.pem$|^id_(rsa|ed25519)$'
|
|
# the public counterpart is fine
|
|
NAME_ALLOW='^ota-signing-key\.pub$'
|
|
|
|
# 2. a 64-hex value assigned to a secret-looking name
|
|
HEX='(0x)?[0-9a-fA-F]{64}([^0-9a-fA-F]|$)'
|
|
CONTENT_RULE="(token|key|secret|password|passphrase)[\"']?[[:space:]]*[:=][[:space:]]*[\"']?${HEX}"
|
|
# paths that may carry such a line, with the reason
|
|
ALLOW=(
|
|
'app/igneum-app/src/manifest.rs' # OTA_PUBLIC_KEY_HEX: the public half of the signing key, compiled into every app
|
|
'site/api/faucet.test.mjs' # Anvil developer account 0, a published test vector
|
|
'tools/exec-attacks/lib/common.mjs' # Hardhat/Anvil developer accounts 1, 4, 5, 15, 16: published, devnet 4463 only
|
|
'tools/evm-smoke/smoke.mjs' # the same published accounts
|
|
)
|
|
SKIP_PATH='(^|/)(vendor|node_modules|target|tests?|proving/fixtures|infra/cloud-devnet/results)(/|$)|\.log$'
|
|
is_test_name() { # a basename with "test" in it (test-publish-jobs.sh, faucet.test.mjs, notices.test.mjs), not "testnet"
|
|
local b="${1##*/}"; b="${b//testnet/}"; case "$b" in *test*) return 0 ;; *) return 1 ;; esac
|
|
}
|
|
|
|
list_files() {
|
|
if git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then git -C "$REPO" ls-files; else (cd "$REPO" && find . -type f | sed 's#^\./##'); fi
|
|
}
|
|
|
|
run_checks() { # $1 = root, reads the file list on stdin; prints hits, returns 1 on any
|
|
local root="$1" bad=0 f base
|
|
local -a content_files=()
|
|
while IFS= read -r f; do
|
|
[ -n "$f" ] || continue
|
|
base="${f##*/}"
|
|
if printf '%s' "$base" | grep -qE "$NAME_RULES" && ! printf '%s' "$base" | grep -qE "$NAME_ALLOW"; then
|
|
echo "secret file name tracked: $f"; bad=1
|
|
fi
|
|
if ! printf '%s' "$f" | grep -qE "$SKIP_PATH" && ! is_test_name "$f"; then
|
|
local allowed=0 a; for a in "${ALLOW[@]}"; do [ "$f" = "$a" ] && allowed=1; done
|
|
[ $allowed -eq 0 ] && [ -f "$root/$f" ] && content_files+=("$f")
|
|
fi
|
|
done
|
|
if [ ${#content_files[@]} -gt 0 ]; then
|
|
local hits
|
|
hits="$(cd "$root" && printf '%s\n' "${content_files[@]}" | tr '\n' '\0' | xargs -0 grep -nIiE "$CONTENT_RULE" 2>/dev/null | sed -E 's/(0x)?[0-9a-fA-F]{64}/<64-hex>/g' | cut -c1-160 || true)"
|
|
if [ -n "$hits" ]; then echo "a 64-hex value next to token/key/secret:"; printf '%s\n' "$hits" | sed 's/^/ /'; bad=1; fi
|
|
fi
|
|
echo "checked ${#content_files[@]} files for contents"
|
|
return $bad
|
|
}
|
|
|
|
if [ "${1:-}" = "--self-test" ]; then
|
|
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
|
|
mkdir -p "$T/good/src" "$T/bad/src" "$T/bad/cfg"
|
|
# a good tree: a hash next to an unrelated word, a 32-hex id, the public key in the allowlisted path, a test file
|
|
printf 'sha256 = "%s"\nlet id = "%s";\n' "$(printf 'a%.0s' $(seq 64))" "$(printf 'b%.0s' $(seq 32))" > "$T/good/src/ok.rs"
|
|
mkdir -p "$T/good/app/igneum-app/src"; printf 'pub const OTA_PUBLIC_KEY_HEX: &str = "%s";\n' "$(printf 'c%.0s' $(seq 64))" > "$T/good/app/igneum-app/src/manifest.rs"
|
|
printf 'const KEY = "0x%s";\n' "$(printf 'd%.0s' $(seq 64))" > "$T/good/src/vectors.test.mjs"
|
|
printf 'x\n' > "$T/good/src/ota-signing-key.pub"
|
|
# a bad tree: a tracked key file, and three content shapes
|
|
printf 'x\n' > "$T/bad/cfg/ota-signing-key"; printf 'x\n' > "$T/bad/cfg/dl-token.old-2026-10-05"; printf 'x\n' > "$T/bad/cfg/igneum-app.json"
|
|
printf 'FAUCET_KEY=0x%s\n' "$(printf 'e%.0s' $(seq 64))" > "$T/bad/src/a.sh"
|
|
printf 'const signingKey = "%s";\n' "$(printf 'f%.0s' $(seq 64))" > "$T/bad/src/b.mjs"
|
|
printf 'curl -H "x-igneum-key: %s"\n' "$(printf '0%.0s' $(seq 64))" > "$T/bad/src/c.md"
|
|
good_out="$( (cd "$T/good" && find . -type f | sed 's#^\./##') | run_checks "$T/good" 2>&1)" && good_rc=0 || good_rc=$?
|
|
bad_out="$( (cd "$T/bad" && find . -type f | sed 's#^\./##') | run_checks "$T/bad" 2>&1)" && bad_rc=0 || bad_rc=$?
|
|
echo "self-test good tree: rc $good_rc"; printf '%s\n' "$good_out" | sed 's/^/ /'
|
|
echo "self-test bad tree: rc $bad_rc"; printf '%s\n' "$bad_out" | sed 's/^/ /'
|
|
[ $good_rc -eq 0 ] || { echo "SELF-TEST FAILED: the good tree was flagged"; exit 1; }
|
|
[ $bad_rc -ne 0 ] || { echo "SELF-TEST FAILED: the bad tree passed"; exit 1; }
|
|
for want in 'cfg/ota-signing-key' 'cfg/dl-token.old-2026-10-05' 'cfg/igneum-app.json' 'src/a.sh' 'src/b.mjs' 'src/c.md'; do
|
|
printf '%s' "$bad_out" | grep -q "$want" || { echo "SELF-TEST FAILED: $want not reported"; exit 1; }
|
|
done
|
|
printf '%s' "$bad_out" | grep -qE '[0-9a-f]{64}' && { echo "SELF-TEST FAILED: a hex value was printed"; exit 1; }
|
|
echo "self-test passed: the name rule and the content rule fire on the bad tree and not on the good one"
|
|
exit 0
|
|
fi
|
|
|
|
if list_files | run_checks "$REPO"; then echo "no-secrets: 0 hits"; else echo "no-secrets: HITS (above)"; exit 1; fi
|