Issue #1649. Extends the backup system described in backup-system.md. Recovery procedures that consume these backups are in disaster-recovery.md.
A backup that has never been restored is a hope, not a backup. Before this
change the daily workflow exported and encrypted contract state, but the
verification step only looked for plaintext *.json files — encrypted
*.json.enc backups (the production default) were never actually checked.
This document describes the automated pipeline that now proves every backup is
recoverable before it counts.
| Script | Role |
|---|---|
scripts/backup.sh |
Exports contract state, optionally encrypts (AES-256-CBC) and uploads to S3. Unchanged. |
scripts/verify_backup.sh |
Content checks on a plaintext backup: JSON validity, required keys, counts, required fields, age, restore dry-run. Unchanged. |
scripts/automated_backup.sh |
New. Orchestrates export → checksum → decrypt → verify → remote re-download → retention, and records the result in backups/manifest.json. |
scripts/check_backup_integrity.sh |
New. Re-validates every retained backup against the manifest, detects stale schedules and unaccounted files, optional deep restore dry-run. |
.github/workflows/backup.yml |
Runs automated_backup.sh then check_backup_integrity.sh --deep daily at 02:00 UTC per network. |
automated_backup.sh
1. Export backup.sh --network N [--encrypt] [--upload bucket]
2. Checksum sha256 → <artifact>.sha256
3. Decrypt openssl enc -d (same params as restore_from_backup.sh)
4. Content verify_backup.sh <plaintext> --dry-run-restore
5. Remote aws s3 cp back down, sha256 must match step 2;
upload <artifact>.sha256 sidecar next to the object
6. Retention prune local backups older than BACKUP_RETENTION_DAYS
→ manifest.json entry { status: "verified" | "failed", sha256, s3_uri, ... }
Any failing step aborts the run, writes a failed manifest entry with the
reason, posts to BACKUP_NOTIFY_WEBHOOK if configured, and exits 1 (which
fails the workflow). Retention runs only after the new backup has
verified, so a bad run never deletes the last good backup.
# Local, unencrypted
CONTRACT_QUORUM_PROOF=<id> ./scripts/automated_backup.sh --network testnet
# Production-equivalent
export BACKUP_ENCRYPTION_KEY=...
./scripts/automated_backup.sh --network mainnet --encrypt --upload quorumproof-backups
# Verify a backup you already have (no export)
./scripts/automated_backup.sh --skip-export backups/daily/quorumproof-2026-09-01_02-00-00.json.enc| Variable | Default | Purpose |
|---|---|---|
CONTRACT_QUORUM_PROOF |
— | Contract to export (not needed with --skip-export). |
BACKUP_ENCRYPTION_KEY |
— | Required for --encrypt and to verify .enc files. |
BACKUP_RETENTION_DAYS |
30 |
Local retention window (--retention-days overrides). |
BACKUP_NOTIFY_WEBHOOK |
— | Slack/Teams webhook for failure notifications. |
check_backup_integrity.sh is the long-term guard against bit-rot, tampering,
silent deletion, and a schedule that has quietly stopped running.
| Check | Fails when |
|---|---|
| Checksum | A verified manifest entry's file (local, or S3 with --remote) no longer hashes to the recorded SHA-256. |
| Accounting | A quorumproof-* file exists on disk with no verified manifest entry. |
| Freshness | A network's newest verified backup is older than --max-age-hours (default 26h). |
Deep (--deep) |
The newest backup no longer decrypts with the current key, or fails verify_backup.sh --dry-run-restore. |
./scripts/check_backup_integrity.sh # local set
./scripts/check_backup_integrity.sh --remote quorumproof-backups # also check S3
./scripts/check_backup_integrity.sh --deep # plus restore dry-run on newestRun --deep after any encryption key rotation: a backup encrypted with a
retired key is only recoverable while that key is still available.
backups/manifest.json is append-only:
{
"backups": [
{
"file": "quorumproof-2026-09-01_02-00-00.json.enc",
"sha256": "9f2c…",
"network": "mainnet",
"encrypted": true,
"s3_uri": "s3://quorumproof-backups/quorumproof/mainnet/quorumproof-2026-09-01_02-00-00.json.enc",
"status": "verified",
"detail": "",
"verified_at": "2026-09-01T02:03:12Z"
}
]
}It is uploaded alongside the backups as a GitHub Actions artifact.
-
Pick a candidate — the newest
verifiedentry for the network:jq -r '[.backups[] | select(.network=="mainnet" and .status=="verified")] | sort_by(.verified_at) | last' backups/manifest.json
-
Fetch it and its checksum (skip if already local):
aws s3 cp "$S3_URI" backups/daily/ aws s3 cp "$S3_URI.sha256" backups/daily/ (cd backups/daily && sha256sum --check "$(basename "$S3_URI").sha256")
-
Re-verify before touching the chain:
./scripts/automated_backup.sh --skip-export backups/daily/<file>
-
Restore — follow the pause/restore steps in disaster-recovery.md, then:
./scripts/restore_from_backup.sh --backup backups/daily/<file> --network mainnet
-
Confirm — re-export with
backup.shand comparecredential_count/slice_countwith the restored backup.
If the newest backup fails step 2 or 3, move to the next-newest verified
entry and record the failed one in the incident log.
| When | Action |
|---|---|
| Daily 02:00 UTC | backup.yml runs automated_backup.sh + check_backup_integrity.sh --deep per network. |
| Weekly | Run check_backup_integrity.sh --remote <bucket> to confirm S3 copies. |
| Quarterly | Full restore drill onto a fresh testnet deployment (see disaster-recovery.md). |
| After key rotation | check_backup_integrity.sh --deep, and keep the retired key until all backups encrypted with it have aged out. |