← back to Secrets Manager

backup-env.sh

90 lines

#!/bin/bash
# backup-env.sh — automated, versioned, ENCRYPTED, OFF-BOX backup of the secrets master.
# Born 2026-06-16 (Officer Council #1 leftover): secrets-manager/.env had ZERO automated
# backup — one disk loss = total secret loss (memory: dw-unified-backup-broken).
#
# What it does, every run:
#   1. GPG-symmetric (AES256) encrypts .env -> timestamped snapshot in ~/.secrets-backups
#   2. ROUND-TRIP VERIFIES (decrypt the fresh snapshot, diff vs source) — proves restorable
#   3. Mirrors the latest encrypted blob to iCloud Drive (off-box: survives Mac2 disk loss)
#   4. Retains last 30 local snapshots; prunes older
# Passphrase lives in the macOS login Keychain (machine-bound). On FIRST run it is generated
# and an ESCROW handoff file is written for Steve to move into his password manager — because
# if Mac2 dies, the iCloud ciphertext is only decryptable with that passphrase.
#
# READ + ENCRYPT only. Never writes/rotates the actual secrets. Never commits .env.
set -uo pipefail
SRC="$HOME/Projects/secrets-manager/.env"
DST="$HOME/.secrets-backups"; mkdir -p "$DST"; chmod 700 "$DST"
LOG="$DST/backup.log"
ICLOUD="$HOME/Library/Mobile Documents/com~apple~CloudDocs/secrets-backups"
KC_SERVICE="secrets-env-backup-key"
KEEP=30
log(){ echo "[$(date -Iseconds)] $1" | tee -a "$LOG"; }

[ -f "$SRC" ] || { log "FATAL: source $SRC missing"; exit 1; }

# --- passphrase: load from Keychain, generate + escrow on first run ---
PASS="$(security find-generic-password -a "$USER" -s "$KC_SERVICE" -w 2>/dev/null || true)"
if [ -z "$PASS" ]; then
  PASS="$(openssl rand -base64 48 | tr -d '\n')"
  security add-generic-password -a "$USER" -s "$KC_SERVICE" -w "$PASS" -U 2>/dev/null \
    && log "generated new backup passphrase, stored in login Keychain ($KC_SERVICE)" \
    || { log "FATAL: could not store passphrase in Keychain"; exit 1; }
  ESC="$DST/KEY-ESCROW-$(date +%Y%m%d-%H%M%S).txt"
  { echo "SECRETS BACKUP PASSPHRASE — ESCROW THIS, THEN DELETE THIS FILE.";
    echo "Move into your password manager. Without it, the iCloud encrypted backups are";
    echo "UNRECOVERABLE if this Mac is lost (the key only lives in this Mac's Keychain).";
    echo "service=$KC_SERVICE account=$USER";
    echo "passphrase=$PASS"; } > "$ESC"
  chmod 600 "$ESC"
  log "ACTION REQUIRED: escrow file written $ESC (chmod 600) — Steve must save the passphrase + delete the file"
fi

# --- encrypt ---
TS="$(date +%Y%m%d-%H%M%S)"
OUT="$DST/secrets-env-$TS.env.gpg"
if ! gpg --batch --yes --passphrase "$PASS" -c --cipher-algo AES256 -o "$OUT" "$SRC" 2>>"$LOG"; then
  log "FATAL: gpg encrypt failed"; exit 1
fi
chmod 600 "$OUT"

# --- round-trip verify (the whole point: prove it's restorable) ---
if gpg --batch --yes --passphrase "$PASS" -d "$OUT" 2>/dev/null | diff -q - "$SRC" >/dev/null; then
  log "VERIFIED restorable: $OUT ($(wc -c <"$OUT" | tr -d ' ') bytes, decrypt==source)"
else
  log "FATAL: round-trip verify FAILED for $OUT — removing bad snapshot"; rm -f "$OUT"; exit 1
fi

# --- off-box mirror (iCloud) ---
# The TIMESTAMPED copy is the real off-box backup; the 'latest' pointer is convenience.
# Overwriting an already-synced iCloud file from a launchd context can hit "Operation not
# permitted" (TCC), so the pointer is best-effort and never fails the run.
if [ -d "$(dirname "$ICLOUD")" ]; then
  mkdir -p "$ICLOUD"
  if cp "$OUT" "$ICLOUD/"; then
    log "off-box mirror -> iCloud Drive ($ICLOUD/$(basename "$OUT"))"
    # best-effort 'latest' pointer (rm+cp; ignore TCC failure on overwrite)
    { rm -f "$ICLOUD/secrets-env-latest.env.gpg" 2>/dev/null; cp "$OUT" "$ICLOUD/secrets-env-latest.env.gpg" 2>/dev/null; } || true
  else
    log "WARN: iCloud off-box copy failed (Operation not permitted? grant the launchd job Full Disk Access) — local encrypted snapshots still good"
  fi
else
  log "WARN: iCloud Drive not present — local snapshots only (no off-box copy this run)"
fi

# --- retention ---
COUNT=$(ls -1 "$DST"/secrets-env-*.env.gpg 2>/dev/null | wc -l | tr -d ' ')
if [ "$COUNT" -gt "$KEEP" ]; then
  ls -1t "$DST"/secrets-env-*.env.gpg | tail -n +$((KEEP+1)) | while read -r f; do rm -f "$f"; done
  log "retention: pruned to last $KEEP (was $COUNT)"
fi
# Mirror retention on iCloud too
if [ -d "$ICLOUD" ]; then
  IC=$(ls -1 "$ICLOUD"/secrets-env-2*.env.gpg 2>/dev/null | wc -l | tr -d ' ')
  [ "$IC" -gt "$KEEP" ] && ls -1t "$ICLOUD"/secrets-env-2*.env.gpg | tail -n +$((KEEP+1)) | while read -r f; do rm -f "$f"; done
fi

log "OK: backed up secrets master ($(wc -c <"$SRC" | tr -d ' ') bytes) -> $COUNT local snapshot(s) + iCloud"
exit 0