From 480d1815496066d8a852934a2f841ec7d212f8f1 Mon Sep 17 00:00:00 2001 From: codex Date: Sat, 5 Sep 2026 22:21:54 +0200 Subject: [PATCH] Bind isolated restore to fetched artifact and document archive integrity gates Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883 --- docs/backup-credential-recovery.md | 26 +++++++++++++++++++++ scripts/capture_forgejo_archive.py | 5 +++- scripts/restore_forgejo_offsite_locally.py | 11 ++++++++- scripts/verify_nextcloud_offsite_restore.py | 2 +- 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/docs/backup-credential-recovery.md b/docs/backup-credential-recovery.md index 7193f8a..dd9e299 100644 --- a/docs/backup-credential-recovery.md +++ b/docs/backup-credential-recovery.md @@ -98,3 +98,29 @@ a cleanup failure after CAS can occur after credentials have already changed. Refresh `actcore-backup-offsite`, verify delivery by contained comparison, and restart its three environment consumers (`actcore-api`, `actcore-event-router`, `actcore-worker`). Record their readiness and loaded-value comparisons. + +## Real archive acceptance + +`capture_forgejo_archive.py` requires the exact producer's successful exit marker, +checks every transferred chunk's length and the complete SHA-256 against the +producer, then validates ZIP structure and member CRCs before encryption. The +2026-09-05 recovery attempt exposed a truncated September 4 encrypted archive; +successful age decryption and upload alone cannot certify backup completeness. + +`verify_nextcloud_offsite_restore.py` streams a real encrypted archive through +the current upload lane and downloads it separately through owner recovery +custody. It emits a hash-bound transfer receipt. Run this silent helper through +the attended Warden envelope, with fresh private staging and receipt paths. +`restore_forgejo_offsite_locally.py` requires both that receipt and the fetched +archive. Its disposable local Docker network is internal, exposes no host ports, +and uses an independent database password/configuration. Production app.ini is +never activated. It checks database import, application health, repository API +and clone/fsck, and package blob integrity; cleanup removes its named containers, +anonymous volumes, network and extracted staging. Remaining fetched plaintext +must be removed after the acceptance receipt is preserved. + +This bounded application-data recovery does not test replacement runners or +prove that every restored package can be installed or every image pulled. +Package-consumer acceptance and broader disaster-recovery guarantees remain +with their owning assurance tasks. Full backups share the account's 10 GiB +quota; a newly measured archive size must inform the separate retention decision. diff --git a/scripts/capture_forgejo_archive.py b/scripts/capture_forgejo_archive.py index 3d93a57..742cb51 100644 --- a/scripts/capture_forgejo_archive.py +++ b/scripts/capture_forgejo_archive.py @@ -11,6 +11,8 @@ import zipfile def validate_archive(path): with zipfile.ZipFile(path) as archive: + if sum(i.file_size for i in archive.infolist()) > 20*1024**3: + raise ValueError('archive exceeds recovery size bound') names = set(archive.namelist()) if 'forgejo-db.sql' not in names or not any(n.startswith('repos/') for n in names): raise ValueError('required archive content missing') @@ -48,7 +50,8 @@ def capture(namespace, pod, destination): piece = call(['dd','if='+remote+'.zip','bs='+str(chunk),'skip='+str(index),'count=1']) if len(piece) != min(chunk,size-index*chunk): raise ValueError('short archive chunk') out.write(piece) - actual = hashlib.file_digest(destination.open('rb'),'sha256').hexdigest() + with destination.open('rb') as source: + actual = hashlib.file_digest(source,'sha256').hexdigest() if actual != expected: raise ValueError('archive transfer mismatch') validate_archive(destination) finally: diff --git a/scripts/restore_forgejo_offsite_locally.py b/scripts/restore_forgejo_offsite_locally.py index b790815..83f46d5 100644 --- a/scripts/restore_forgejo_offsite_locally.py +++ b/scripts/restore_forgejo_offsite_locally.py @@ -154,9 +154,18 @@ LEVEL = Error def main(): p=argparse.ArgumentParser(description=__doc__) p.add_argument('--archive',required=True,type=Path);p.add_argument('--receipt',required=True) + p.add_argument('--transfer-receipt',required=True,type=Path) a=p.parse_args();receipt={'schema':'platform.forgejo-isolated-restore.v1','status':'failed','images':[FORGE,POSTGRES]} fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) - try: run(a.archive,receipt) + try: + transfer=json.loads(a.transfer_receipt.read_text()) + if (transfer.get('status') != 'offsite_fetched_pending_isolated_restore' + or transfer.get('download_http_status') != 200 + or transfer.get('plaintext_sha256') != file_digest(a.archive)): + raise ValueError('verified_offsite_provenance_required') + receipt['offsite_artifact']=transfer['artifact'] + receipt['ciphertext_sha256']=transfer['ciphertext_sha256'] + run(a.archive,receipt) except Exception: receipt['error']='isolated_restore_failed' finally: with os.fdopen(fd,'w') as f: json.dump(receipt,f,indent=2) diff --git a/scripts/verify_nextcloud_offsite_restore.py b/scripts/verify_nextcloud_offsite_restore.py index 7e2272e..0c5cc2a 100644 --- a/scripts/verify_nextcloud_offsite_restore.py +++ b/scripts/verify_nextcloud_offsite_restore.py @@ -56,7 +56,7 @@ def run(source, directory, receipt): result = subprocess.run(['age','-d','-i','/dev/stdin',str(encrypted)], input=(values['AGE_PRIVATE_KEY'].strip()+'\n').encode(), stdout=output, stderr=subprocess.PIPE, timeout=1200) require(result.returncode == 0, 'offsite_decryption_failed') - receipt.update(decrypted=True, quota_after=quota(auth), status='offsite_fetched_pending_isolated_restore') + receipt.update(decrypted=True, plaintext_sha256=sha(plain), quota_after=quota(auth), status='offsite_fetched_pending_isolated_restore') def main():