#!/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-.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 --check (compares against the live folder, prints no values)" echo " tools/keys/restore.sh --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 < --check compares the image to the live folder without printing values. EOF