feat: add verified state backup and restore drill
This commit is contained in:
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user