feat: add verified state backup and restore drill

This commit is contained in:
thanhnv
2026-07-18 00:14:22 +07:00
parent 881ee01691
commit 00963119f4
5 changed files with 168 additions and 0 deletions
@@ -137,6 +137,7 @@ run "phase-sec23-registry-crypt" bash "$TESTS/phase-sec23-registry-crypt-tests.s
run "phase-sec24-supplychain" bash "$TESTS/phase-sec24-tests.sh"
run "phase-sec25-attestation" bash "$TESTS/phase-sec25-tests.sh"
run "phase-production-handoff" bash "$TESTS/phase-production-preflight-tests.sh"
run "phase-state-backup" bash "$TESTS/phase-state-backup-tests.sh"
run "phase-release-provenance" bash "$TESTS/phase-release-provenance-tests.sh"
run "phase-service-desk-onboard" bash "$TESTS/phase-service-desk-onboard-tests.sh"
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
set -euo pipefail
# CASAN state backup / restore. Backups are self-verifying and restore only
# into an explicit empty directory; production requires encryption at rest.
#
# state-backup.sh create <backup-id> [--out DIR]
# state-backup.sh verify <backup-id> [--out DIR]
# state-backup.sh restore <backup-id> --out DIR --to EMPTY_DIR
# state-backup.sh drill <backup-id> [--out DIR]
#
# Env: CASAN_BACKUP_PASSPHRASE_FILE is required in prod and optional elsewhere.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/casan-paths.sh"
MODE="${1:-}"; ID="${2:-}"
shift 2 2>/dev/null || true
OUT_ROOT="${CASAN_BACKUP_ROOT:-$CASAN_APP_ROOT/backups/casan-state}"
TO=""
while [[ "$#" -gt 0 ]]; do
case "$1" in
--out) OUT_ROOT="${2:-}"; shift 2 ;;
--to) TO="${2:-}"; shift 2 ;;
*) echo "STATE_BACKUP_FAIL reason=unknown_option option=$1" >&2; exit 64 ;;
esac
done
fail() { echo "STATE_BACKUP_FAIL $*" >&2; exit 1; }
[[ "$ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,80}$ ]] || { echo "STATE_BACKUP_FAIL reason=invalid_backup_id" >&2; exit 64; }
OUT_ROOT="$(cd "$(dirname "$OUT_ROOT")" 2>/dev/null && pwd)/$(basename "$OUT_ROOT")"
PACK_DIR="$OUT_ROOT/$ID"
ARCHIVE="$PACK_DIR/state.tar.gz"
ENCRYPTED="$PACK_DIR/state.tar.gz.enc"
MANIFEST="$PACK_DIR/manifest.json"
passphrase_file() {
local key="${CASAN_BACKUP_PASSPHRASE_FILE:-}"
if [[ "${CASAN_PROFILE:-}" == "prod" && ( -z "$key" || ! -s "$key" ) ]]; then
fail "reason=prod_encryption_key_required"
fi
[[ -z "$key" || -s "$key" ]] || fail "reason=backup_passphrase_file_missing"
printf '%s' "$key"
}
write_manifest() {
local payload="$1" encrypted="$2" files="$3"
python3 - "$MANIFEST" "$ID" "$payload" "$encrypted" "$files" <<'PY'
import hashlib, json, os, sys
out, backup_id, payload, encrypted, files = sys.argv[1:]
with open(payload, 'rb') as f: digest = hashlib.sha256(f.read()).hexdigest()
data = {"schema_version":"1.0", "backup_id":backup_id, "payload":os.path.basename(payload),
"encrypted":encrypted == "true", "sha256":digest, "state_files":int(files)}
with open(out, 'w', encoding='utf8') as f: json.dump(data, f, sort_keys=True, indent=2); f.write('\n')
PY
}
verify() {
[[ -f "$MANIFEST" ]] || fail "reason=manifest_missing"
python3 - "$MANIFEST" "$PACK_DIR" <<'PY'
import hashlib, json, os, sys
manifest, root = sys.argv[1:]
data = json.load(open(manifest, encoding='utf8'))
payload = data.get('payload', '')
if not payload or os.path.basename(payload) != payload: raise SystemExit('payload_invalid')
path = os.path.join(root, payload)
if not os.path.isfile(path): raise SystemExit('payload_missing')
with open(path, 'rb') as f: actual = hashlib.sha256(f.read()).hexdigest()
if actual != data.get('sha256'): raise SystemExit('checksum_mismatch')
print(json.dumps(data, sort_keys=True))
PY
}
case "$MODE" in
create)
[[ -d "$CASAN_STATE_ROOT" ]] || fail "reason=state_root_missing"
[[ ! -e "$PACK_DIR" ]] || fail "reason=backup_already_exists"
mkdir -p "$PACK_DIR" || fail "reason=backup_dir_create_failed"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
# State is copied by relative path only. The archive cannot contain an
# arbitrary absolute filesystem path even when CASAN_STATE_ROOT is overridden.
tar -czf "$TMP/state.tar.gz" -C "$(dirname "$CASAN_STATE_ROOT")" "$(basename "$CASAN_STATE_ROOT")"
COUNT="$(tar -tzf "$TMP/state.tar.gz" | wc -l | tr -d '[:space:]')"
KEY="$(passphrase_file)"
if [[ -n "$KEY" ]]; then
command -v openssl >/dev/null 2>&1 || fail "reason=openssl_required_for_encryption"
openssl enc -aes-256-cbc -pbkdf2 -salt -in "$TMP/state.tar.gz" -out "$ENCRYPTED" -pass "file:$KEY"
write_manifest "$ENCRYPTED" true "$COUNT"
rm -f "$TMP/state.tar.gz"
else
mv "$TMP/state.tar.gz" "$ARCHIVE"
write_manifest "$ARCHIVE" false "$COUNT"
fi
chmod 700 "$PACK_DIR"; chmod 600 "$MANIFEST" "$PACK_DIR"/state.tar.gz* 2>/dev/null || true
echo "STATE_BACKUP_CREATED id=$ID files=$COUNT encrypted=$([[ -n "$KEY" ]] && echo true || echo false) dir=$PACK_DIR"
;;
verify)
RESULT="$(verify)" || fail "reason=manifest_verification_failed"
echo "STATE_BACKUP_VALID id=$ID $RESULT"
;;
restore)
[[ -n "$TO" ]] || { echo "STATE_BACKUP_FAIL reason=restore_target_required" >&2; exit 64; }
RESULT="$(verify)" || fail "reason=manifest_verification_failed"
[[ ! -e "$TO" || ( -d "$TO" && -z "$(find "$TO" -mindepth 1 -maxdepth 1 -print -quit)" ) ]] || fail "reason=restore_target_not_empty"
mkdir -p "$TO"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
ENCRYPTED_FLAG="$(python3 -c 'import json,sys; print(str(json.loads(sys.argv[1])["encrypted"]).lower())' "$RESULT")"
if [[ "$ENCRYPTED_FLAG" == true ]]; then
KEY="$(passphrase_file)"; [[ -n "$KEY" ]] || fail "reason=decrypt_key_required"
openssl enc -d -aes-256-cbc -pbkdf2 -in "$ENCRYPTED" -out "$TMP/state.tar.gz" -pass "file:$KEY" || fail "reason=decrypt_failed"
else
cp "$ARCHIVE" "$TMP/state.tar.gz"
fi
tar -xzf "$TMP/state.tar.gz" -C "$TO" --no-same-owner
echo "STATE_BACKUP_RESTORED id=$ID target=$TO"
;;
drill)
"$0" create "$ID" --out "$OUT_ROOT"
"$0" verify "$ID" --out "$OUT_ROOT"
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
"$0" restore "$ID" --out "$OUT_ROOT" --to "$TMP/restore"
ORIGINAL="$(tar -tzf "${ARCHIVE:-$PACK_DIR/state.tar.gz}" 2>/dev/null || true)"
[[ -n "$ORIGINAL" || -f "$ENCRYPTED" ]] || fail "reason=drill_archive_missing"
[[ -d "$TMP/restore/$(basename "$CASAN_STATE_ROOT")" ]] || fail "reason=drill_restore_shape_invalid"
echo "STATE_BACKUP_DRILL_PASS id=$ID restored=true"
;;
*) echo "Usage: state-backup.sh {create|verify|restore|drill} <backup-id> [--out DIR] [--to EMPTY_DIR]" >&2; exit 64 ;;
esac