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>
116 lines
5.9 KiB
Bash
Executable file
116 lines
5.9 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# The reverse of backup.sh: open an igneum-keys-<date>.dmg and either check it against the live folder or copy its
|
|
# files back.
|
|
#
|
|
# tools/keys/restore.sh <image.dmg> --check # every file in the image against ~/.config/igneum, by sha256;
|
|
# # prints match / DIFFERS / missing, never a value; exit 1 on any difference
|
|
# tools/keys/restore.sh <image.dmg> --to <folder> # copies the files into <folder> (0700 dirs, 0600 files; .pub 0644),
|
|
# # refuses to overwrite an existing file unless --force, then runs the check
|
|
# tools/keys/restore.sh <image.dmg> --list # names, sizes and modes inside the image
|
|
#
|
|
# Options: --source <folder> (the live folder for --check, default ~/.config/igneum), --agent (passphrase through the
|
|
# macOS dialog), --stdinpass (test harness only: a NUL-terminated passphrase on standard input). The image is attached
|
|
# read-only at a private mount point and detached at exit, also on failure. README.txt and keys.md inside the image are
|
|
# documentation and are not copied or compared.
|
|
set -euo pipefail
|
|
|
|
IMG=""; ACTION=""; DEST=""; LIVE="$HOME/.config/igneum"; MODE="tty"; FORCE=0
|
|
die() { printf 'restore: %s\n' "$*" >&2; exit 1; }
|
|
say() { printf '%s\n' "$*"; }
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--check) ACTION="check" ;;
|
|
--list) ACTION="list" ;;
|
|
--to) ACTION="restore"; DEST="${2:?--to needs a folder}"; shift ;;
|
|
--source) LIVE="${2:?--source needs a folder}"; shift ;;
|
|
--force) FORCE=1 ;;
|
|
--agent) MODE="agent" ;;
|
|
--stdinpass) MODE="stdin" ;;
|
|
-h|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
|
|
-*) die "unknown argument $1" ;;
|
|
*) [ -z "$IMG" ] && IMG="$1" || die "one image only" ;;
|
|
esac
|
|
shift
|
|
done
|
|
[ -n "$IMG" ] || die "which image? tools/keys/restore.sh <image.dmg> --check|--list|--to <folder>"
|
|
[ -f "$IMG" ] || die "no file at $IMG"
|
|
[ -n "$ACTION" ] || die "pick --check, --list or --to <folder>"
|
|
command -v hdiutil >/dev/null || die "hdiutil is not available (macOS only)"
|
|
if [ "$MODE" = "tty" ] && [ ! -t 0 ]; then die "no terminal for the passphrase prompt; run from a terminal, or --agent"; fi
|
|
|
|
PASS=""
|
|
if [ "$MODE" = "stdin" ]; then IFS= read -r -d '' PASS || true; [ -n "$PASS" ] || die "--stdinpass: no passphrase on standard input"; fi
|
|
|
|
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 || rmdir "$MNT" 2>/dev/null || true
|
|
PASS=""
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
case "$MODE" in
|
|
tty) hdiutil attach "$IMG" -readonly -nobrowse -noautoopen -mountpoint "$MNT" -quiet ;;
|
|
agent) hdiutil attach "$IMG" -readonly -nobrowse -noautoopen -agentpass -mountpoint "$MNT" -quiet ;;
|
|
stdin) printf '%s\0' "$PASS" | hdiutil attach "$IMG" -readonly -nobrowse -noautoopen -stdinpass -mountpoint "$MNT" -quiet ;;
|
|
esac
|
|
PASS=""
|
|
attached || die "the image did not attach at $MNT (wrong passphrase, or the image is already attached: hdiutil info)"
|
|
|
|
# The secret files inside the image: everything except the documentation.
|
|
FILES="$(cd "$MNT" && find . -type f ! -name .DS_Store ! -name README.txt ! -name keys.md -print | sed 's#^\./##' | LC_ALL=C sort)"
|
|
COUNT="$(printf '%s\n' "$FILES" | grep -c . || true)"
|
|
[ "$COUNT" -gt 0 ] || die "the image holds no files"
|
|
say "image $IMG"
|
|
say "files $COUNT (plus README.txt)"
|
|
|
|
sha() { shasum -a 256 "$1" | cut -c1-64; }
|
|
|
|
check_against() {
|
|
local live="$1" bad=0 rel
|
|
while IFS= read -r rel; do
|
|
if [ ! -f "$live/$rel" ]; then
|
|
printf ' %-40s %8s bytes MISSING in %s\n' "$rel" "$(stat -f '%z' "$MNT/$rel")" "$live"; bad=1
|
|
elif [ "$(sha "$MNT/$rel")" = "$(sha "$live/$rel")" ]; then
|
|
printf ' %-40s %8s bytes match\n' "$rel" "$(stat -f '%z' "$MNT/$rel")"
|
|
else
|
|
printf ' %-40s %8s bytes DIFFERS (live %s bytes, mtime %s)\n' "$rel" "$(stat -f '%z' "$MNT/$rel")" "$(stat -f '%z' "$live/$rel")" "$(date -u -r "$(stat -f '%m' "$live/$rel")" +%Y-%m-%dT%H:%MZ)"; bad=1
|
|
fi
|
|
done <<< "$FILES"
|
|
# live files the image does not carry (the two settings files and the pip folder are expected)
|
|
local extra
|
|
extra="$(cd "$live" && find . -type f ! -name .DS_Store -print | sed 's#^\./##' | LC_ALL=C sort | grep -v -x -F -f <(printf '%s\n' "$FILES") | grep -v -E '^(build-slots|dlsite-dir|pytools/.*)$' || true)"
|
|
if [ -n "$extra" ]; then
|
|
say " live files not in the image (a newer key? back up again):"
|
|
printf '%s\n' "$extra" | sed 's/^/ /'
|
|
bad=1
|
|
fi
|
|
return $bad
|
|
}
|
|
|
|
case "$ACTION" in
|
|
list)
|
|
while IFS= read -r rel; do printf ' %s %8s bytes %s\n' "$(stat -f '%Sp' "$MNT/$rel")" "$(stat -f '%z' "$MNT/$rel")" "$rel"; done <<< "$FILES"
|
|
;;
|
|
check)
|
|
[ -d "$LIVE" ] || die "no live folder at $LIVE"
|
|
say "against $LIVE"
|
|
if check_against "$LIVE"; then say "check every file in the image matches the live folder"; else say "check DIFFERENCES found (see above)"; exit 1; fi
|
|
;;
|
|
restore)
|
|
mkdir -p "$DEST"; chmod 700 "$DEST"
|
|
if [ $FORCE -eq 0 ]; then
|
|
while IFS= read -r rel; do [ -e "$DEST/$rel" ] && die "$DEST/$rel exists; --force to overwrite (it is replaced by the image's copy)"; done <<< "$FILES"
|
|
fi
|
|
while IFS= read -r rel; do
|
|
mkdir -p "$DEST/$(dirname "$rel")"; chmod 700 "$DEST/$(dirname "$rel")"
|
|
cp -p "$MNT/$rel" "$DEST/$rel"
|
|
case "$rel" in *.pub) chmod 644 "$DEST/$rel" ;; *) chmod 600 "$DEST/$rel" ;; esac
|
|
done <<< "$FILES"
|
|
say "restored $COUNT files into $DEST"
|
|
if check_against "$DEST"; then say "check every restored file matches the image"; else die "the restored files do not match the image"; fi
|
|
;;
|
|
esac
|