From 09f4fd33cbeefecb833998246cf53848bb1e5d8d Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 29 Oct 2026 22:49:33 +0100 Subject: [PATCH] Retain verified recovery proof independently and bind the protected point Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a17f16-d99c-7ea3-95f0-8212897fba6b --- .../coulomb-recovery-api.json | 30 ++++- .../coulomb-recovery-api.md | 24 +++- ...6-10-29-coulomb-provider-kit-recovery.json | 117 ++++++++++++++++++ scripts/application_recovery_drill.py | 10 +- scripts/application_recovery_kit.py | 37 +++++- tests/test_application_recovery_kit.py | 16 ++- ...WP-0053-coulomb-paired-backup-admission.md | 34 ++++- 7 files changed, 257 insertions(+), 11 deletions(-) create mode 100644 docs/evidence/2026-10-29-coulomb-provider-kit-recovery.json diff --git a/docs/credential-lane-designs/coulomb-recovery-api.json b/docs/credential-lane-designs/coulomb-recovery-api.json index 338c333..61fafa3 100644 --- a/docs/credential-lane-designs/coulomb-recovery-api.json +++ b/docs/credential-lane-designs/coulomb-recovery-api.json @@ -41,5 +41,33 @@ "automatic_expiry_enabled": false } }, - "native_application_recovery_verified": false + "native_application_recovery_verified": true, + "protected_recovered_point": { + "captured_at": "2026-10-29T21:22:46Z", + "primary_object_key": "platform-pg/application-archives/coulomb-social/2026/10/29/20261029T212246Z-fd128164c7bb22a3a6c42acf.tar.age", + "primary_version_id": "1793309020705518", + "ciphertext_sha256": "acbf92eda5074761bb6d00170e6e459e787359afcd377f2f18324ba17496510a", + "protected_kit": { + "automatic_expiry": false, + "bucket": "railiance-coulomb-social-recovery", + "bytes": 277585920, + "object_key": "kits/db05c8f3b5587dc1fc09a5352c317dd3357527dbe72366ab7ca1041db578c834/recovery-kit.tar", + "project_id": "06d107bc-9d84-4672-b2a0-95701cc0c07f", + "schema": "platform.coulomb-protected-recovery-kit.v1", + "sha256": "db05c8f3b5587dc1fc09a5352c317dd3357527dbe72366ab7ca1041db578c834", + "version_id": "1793309971213507" + }, + "recovery_seconds": 30.232, + "proof": "docs/evidence/2026-10-29-coulomb-provider-kit-recovery.json", + "protected_recovery_proof": { + "project_id": "06d107bc-9d84-4672-b2a0-95701cc0c07f", + "bucket": "railiance-coulomb-social-recovery", + "object_key": "kits/receipts/a77477cac53086b83ad2cb512973ae0d1dfecba028a29a92e06a1bcfe5287f29/recovery-proof.json", + "version_id": "1793310476676486", + "sha256": "a77477cac53086b83ad2cb512973ae0d1dfecba028a29a92e06a1bcfe5287f29", + "bytes": 4237, + "independent_readback_verified": true, + "automatic_expiry": false + } + } } diff --git a/docs/credential-lane-designs/coulomb-recovery-api.md b/docs/credential-lane-designs/coulomb-recovery-api.md index 437e02d..81255d1 100644 --- a/docs/credential-lane-designs/coulomb-recovery-api.md +++ b/docs/credential-lane-designs/coulomb-recovery-api.md @@ -33,10 +33,11 @@ Negative PUT probes use separate transports because a denied upload can leave the provider connection unusable for its next request. Both buckets are private and versioned; no automatic expiry has been enabled. -The protected bucket never participates in rolling expiry. Rolling retention, -capacity, first coherent app capture, independent exact-version decryption and -isolated application recovery, monitoring and timers remain WP-0053/CSOC-WP-0012 -acceptance work. Synthetic fixtures are boundary evidence, not app backups. +The protected bucket never participates in rolling expiry. First coherent app +capture, independent exact-version decryption and isolated application recovery +are now verified below. Rolling retention, capacity, copy disposition, monitoring +and timers remain WP-0053/CSOC-WP-0012 acceptance work. The earlier synthetic +fixtures remain boundary evidence, distinct from this real application backup. The routine pairs and age identity each have a separate protected, enabled, nonexpiring provider secret version. They survive node/workstation loss. If all @@ -51,3 +52,18 @@ deadlines, using a bounded attended construction window. No standing organizatio administration key or publisher IAM administration is admitted. The construction key was revoked, its temporary pair removed and the personal browser context closed without saving state. Routine drills do not rerun identity bootstrap. + +The first provider-only kit recovery is now verified in30.232s monotonic. The +public JSON contract records the complete protected kit, exact primary recovery +point and separately protected recovery-proof coordinates. Native receipt: +../evidence/2026-10-29-coulomb-provider-kit-recovery.json. All2098kit members, +13tables/112rows/sequences,82files and39rendered pages/access denials pass. +Images/tools/data came from the kit; the age identity came from separate escrow. +Held API callers and the existing Docker daemon were used. Account-recovery +latency, a fresh daemon and real-browser identity journeys are excluded. + +application_recovery_drill.py previews without effects and executes only with +--execute, --kit-reference and a fresh --destination under an owner-private +directory outside Git. It never restores production. Retention/copy disposition, +monitoring and scheduled activation remain open; this receipt closes only the +consumer's first-paired-recovery task. diff --git a/docs/evidence/2026-10-29-coulomb-provider-kit-recovery.json b/docs/evidence/2026-10-29-coulomb-provider-kit-recovery.json new file mode 100644 index 0000000..bbdaab3 --- /dev/null +++ b/docs/evidence/2026-10-29-coulomb-provider-kit-recovery.json @@ -0,0 +1,117 @@ +{ + "age_identity_from_separate_provider_escrow": true, + "application": "coulomb-social", + "application_checks": { + "actual_pages_rendered_through_view": 39, + "content_files_unchanged": 82, + "database_restore": { + "content_files": 82, + "content_hashes_match": true, + "content_was_empty": false, + "database_container": "csoc-recovery-20261029214329", + "database_rows": 112, + "database_rows_and_sequences_match": true, + "database_tables": 13, + "elapsed_seconds": 4.43, + "network": "csoc-recovery-20261029214329-net", + "network_internal": true, + "postgres_image_id": "sha256:ff23cdce56cac62ada6f66013e1a50864c0abbe79d132d40b6e05bd80f378a70", + "published_ports": 0, + "restored_directory": "/tmp/csoc-recovery-20261029214329-0rvudi86", + "status": "restored_verified" + }, + "external_identity_provider_used": false, + "fixture_transaction_rolled_back": true, + "packaged_application_image": "sha256:9a646d8e01a61ba5316d3c2eb0273064e17f2d0300ad07af766ccedda65ecb18", + "production_access": false, + "real_browser_journey_verified": false, + "recovered_spaces_with_content": 1, + "restored_owner_access_verified": true, + "same_tenant_ungranted_page_denials": 39, + "status": "isolated_application_recovery_verified", + "synthetic_viewer_mutation_denials": 1, + "synthetic_viewer_reads": 1 + }, + "application_restore_verified": true, + "captured_at": "2026-10-29T21:22:46Z", + "completed_at": "2026-10-29T21:43:40.590019+00:00", + "fresh_docker_daemon": false, + "kit_members_verified": 2098, + "operator_account_recovery_time_included": false, + "original_node_or_plaintext_used": false, + "personal_session_used": false, + "protected_kit": { + "automatic_expiry": false, + "bucket": "railiance-coulomb-social-recovery", + "bytes": 277585920, + "object_key": "kits/db05c8f3b5587dc1fc09a5352c317dd3357527dbe72366ab7ca1041db578c834/recovery-kit.tar", + "project_id": "06d107bc-9d84-4672-b2a0-95701cc0c07f", + "schema": "platform.coulomb-protected-recovery-kit.v1", + "sha256": "db05c8f3b5587dc1fc09a5352c317dd3357527dbe72366ab7ca1041db578c834", + "version_id": "1793309971213507" + }, + "real_browser_journey_verified": false, + "recovery_seconds": 30.232, + "registry_pulls_performed": false, + "restore": { + "content_files": 82, + "content_hashes_match": true, + "content_was_empty": false, + "database_container": "csoc-recovery-20261029214329", + "database_rows": 112, + "database_rows_and_sequences_match": true, + "database_tables": 13, + "elapsed_seconds": 4.43, + "network": "csoc-recovery-20261029214329-net", + "network_internal": true, + "postgres_image_id": "sha256:ff23cdce56cac62ada6f66013e1a50864c0abbe79d132d40b6e05bd80f378a70", + "published_ports": 0, + "restored_directory": "/tmp/csoc-recovery-20261029214329-0rvudi86", + "status": "restored_verified" + }, + "schema": "platform.coulomb-provider-kit-recovery.v1", + "stages_seconds": { + "all_kit_member_hashes_and_extraction": 2.501, + "application_image_load": 1.229, + "decrypt_and_validate_pair": 0.121, + "isolated_database_content_restore": 4.552, + "packaged_app_views_and_permissions": 6.555, + "postgres_image_load": 1.754, + "provider_age_escrow_retrieval": 0.503, + "provider_kit_retrieval": 12.373 + }, + "started_at": "2026-10-29T21:43:07.435367+00:00", + "status": "application_restored_verified", + "timers_activated": false, + "unpack": { + "application_restore_verified": false, + "captured_at": "2026-10-29T21:22:46Z", + "content_sha256": "2e81fa5fe2e1a82dfdec4df855a8c5643f59573cc0418a4a26f908d87727b125", + "database_sha256": "93f8f01edc9b9a2cd9580d0bab2048d1f5a8e2394e7fa266d419636f502e15f1", + "encrypted_sha256": "acbf92eda5074761bb6d00170e6e459e787359afcd377f2f18324ba17496510a", + "status": "decrypted_pair_verified", + "version_id": "1793309020705518" + }, + "object_key": "platform-pg/application-archives/coulomb-social/2026/10/29/20261029T212246Z-fd128164c7bb22a3a6c42acf.tar.age", + "version_id": "1793309020705518", + "ciphertext_sha256": "acbf92eda5074761bb6d00170e6e459e787359afcd377f2f18324ba17496510a", + "ciphertext_bytes": 2120392, + "image": "forgejo.coulomb.social/coulomb/coulomb-social@sha256:9a646d8e01a61ba5316d3c2eb0273064e17f2d0300ad07af766ccedda65ecb18", + "age_recipient": "age1qm30jk8q0cezey652dk2dhcr3d9vs528v6lg8mavl6d4q3e3vgnqmrmyy3", + "database_sha256": "93f8f01edc9b9a2cd9580d0bab2048d1f5a8e2394e7fa266d419636f502e15f1", + "content_sha256": "2e81fa5fe2e1a82dfdec4df855a8c5643f59573cc0418a4a26f908d87727b125", + "application_data_images_and_recovery_tools_from_provider_kit": true, + "bootstrap_api_client_preinstalled": true, + "executed_drill_source": "e1918b3", + "metadata_enriched_from_hash_verified_kit": true, + "protected_recovery_proof": { + "project_id": "06d107bc-9d84-4672-b2a0-95701cc0c07f", + "bucket": "railiance-coulomb-social-recovery", + "object_key": "kits/receipts/a77477cac53086b83ad2cb512973ae0d1dfecba028a29a92e06a1bcfe5287f29/recovery-proof.json", + "version_id": "1793310476676486", + "sha256": "a77477cac53086b83ad2cb512973ae0d1dfecba028a29a92e06a1bcfe5287f29", + "bytes": 4237, + "independent_readback_verified": true, + "automatic_expiry": false + } +} diff --git a/scripts/application_recovery_drill.py b/scripts/application_recovery_drill.py index f4de1b8..f4bc0c2 100644 --- a/scripts/application_recovery_drill.py +++ b/scripts/application_recovery_drill.py @@ -20,7 +20,7 @@ from application_recovery_identities import ( private_directory, private_json, ) -from application_recovery_kit import PROJECT, download +from application_recovery_kit import PROJECT, download, protect_receipt from application_recovery_resources import API, s3, status @@ -133,6 +133,7 @@ class Drill: "--output", str(self.root / "application-receipt.json")])) app = json.loads(app) baseline = json.loads((contents / "application-recovery.json").read_text()) + point = json.loads((contents / "primary-receipt.json").read_text()) for key in ("actual_pages_rendered_through_view", "same_tenant_ungranted_page_denials", "content_files_unchanged"): if app.get(key) != baseline.get(key): raise Refused("kit_application_baseline_differs") @@ -143,11 +144,16 @@ class Drill: "recovery_seconds": round(time.monotonic() - began, 3), "protected_kit": self.reference, "kit_members_verified": len(manifest["files"]) + 1, "application": "coulomb-social", "captured_at": manifest["captured_at"], "application_restore_verified": True, - "all_data_images_and_tools_from_independent_provider_kit": True, + "application_data_images_and_recovery_tools_from_provider_kit": True, + "bootstrap_api_client_preinstalled": True, "age_identity_from_separate_provider_escrow": True, "original_node_or_plaintext_used": False, "personal_session_used": False, "registry_pulls_performed": False, "fresh_docker_daemon": False, "operator_account_recovery_time_included": False, "real_browser_journey_verified": False, "unpack": unpacked, "restore": restored, "application_checks": app, "timers_activated": False} + result.update({k: point[k] for k in ("object_key", "version_id", "ciphertext_sha256", "ciphertext_bytes", + "image", "age_recipient", "database_sha256", "content_sha256")}) + result["protected_recovery_proof"] = protect_receipt( + s3(private_json(self.directory / "custodian-pair.json"), PROJECT), reader, result) checkpoint(self.root / "recovery-receipt.json", result) return result diff --git a/scripts/application_recovery_kit.py b/scripts/application_recovery_kit.py index 8139b2d..5a33487 100644 --- a/scripts/application_recovery_kit.py +++ b/scripts/application_recovery_kit.py @@ -49,8 +49,7 @@ def download(client, ref, output): return dict(ref, independently_retrieved_hash_verified=True, automatic_expiry=False) -def publish(client, source): - archive.regular_file(source) +def require_protected_bucket(client): if client.get_bucket_versioning(Bucket=BUCKET).get("Status") != "Enabled": raise Refused("protected_bucket_versioning_required") try: @@ -61,6 +60,40 @@ def publish(client, source): lifecycle = [] if lifecycle: raise Refused("protected_bucket_must_have_no_lifecycle_expiry") + + +def protect_receipt(writer, reader, receipt): + """Retain the recovery proof independently, without a self-hash cycle.""" + require_protected_bucket(writer) + payload = json.dumps(receipt, sort_keys=True, separators=(",", ":")).encode() + if len(payload) > 1024**2 or receipt.get("status") != "application_restored_verified": + raise Refused("bounded_successful_recovery_proof_required") + expected = hashlib.sha256(payload).hexdigest() + key = "kits/receipts/" + expected + "/recovery-proof.json" + try: + head = writer.head_object(Bucket=BUCKET, Key=key) + except Exception as error: + if status(error) != 404: + raise + response = writer.put_object(Bucket=BUCKET, Key=key, Body=payload, + IfNoneMatch="*", Metadata={"sha256": expected}, ContentType="application/json") + version = archive.require_version(response.get("VersionId")) + else: + if head["ContentLength"] != len(payload) or head.get("Metadata", {}).get("sha256") != expected: + raise Refused("existing_proof_binding_differs") + version = archive.require_version(head.get("VersionId")) + response = reader.get_object(Bucket=BUCKET, Key=key, VersionId=version) + with response["Body"] as stream: + if response.get("VersionId") != version or stream.read(len(payload) + 1) != payload: + raise Refused("independent_proof_readback_differs") + return {"project_id": PROJECT, "bucket": BUCKET, "object_key": key, "version_id": version, + "sha256": expected, "bytes": len(payload), "independent_readback_verified": True, + "automatic_expiry": False} + + +def publish(client, source): + archive.regular_file(source) + require_protected_bucket(client) size = source.stat().st_size if not 0 < size <= MAX_BYTES: raise Refused("bounded_kit_required") diff --git a/tests/test_application_recovery_kit.py b/tests/test_application_recovery_kit.py index 43842df..fc4c021 100644 --- a/tests/test_application_recovery_kit.py +++ b/tests/test_application_recovery_kit.py @@ -37,7 +37,8 @@ class Provider: def put_object(self, **args): self.calls.append("put") assert args["Bucket"] == kit.BUCKET and args["IfNoneMatch"] == "*" - self.data = args["Body"].read() + body = args["Body"] + self.data = body.read() if hasattr(body, "read") else body return {"VersionId": self.version} def get_object(self, **args): @@ -104,3 +105,16 @@ def test_protected_bucket_expiry_refuses_before_upload(fixture): with pytest.raises(kit.Refused, match="no_lifecycle_expiry"): kit.publish(client, source) assert client.calls == [] + + +def test_recovery_proof_is_conditionally_retained_and_independently_verified(): + client = Provider() + receipt = {"status": "application_restored_verified", "application": "coulomb-social"} + ref = kit.protect_receipt(client, client, receipt) + assert ref["independent_readback_verified"] is True + assert ref["object_key"] == "kits/receipts/" + ref["sha256"] + "/recovery-proof.json" + assert kit.protect_receipt(client, client, receipt) == ref + assert client.calls.count("put") == 1 + client.corrupt = True + with pytest.raises(kit.Refused, match="proof_readback_differs"): + kit.protect_receipt(client, client, receipt) diff --git a/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md b/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md index 65b58d6..c3398bb 100644 --- a/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md +++ b/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md @@ -47,7 +47,7 @@ Do not mark the warden offsite workload identity resolved without a declaration. id: RPF-WP-0053-T02 status: progress priority: high -intervention_note: "Publisher delivery and independent scoped API custody are approved and native-verified. Retention/capacity, protected app kit/last recovered point and historical-copy disposition remain open; no repeat credential or schedule approval is needed." +intervention_note: "Publisher delivery, independent scoped API custody and protected app kit/last recovered point are native-verified. Retention/capacity and historical-copy disposition remain open; no repeat credential or schedule approval is needed." state_hub_task_id: "b06f1a44-1a55-588c-9c6c-3c1e290544df" ``` @@ -401,3 +401,35 @@ as generic-api-key because of its metadata field name. The exact finding was line2, containing only the already public approval reference. Renamed that field to programmatic_direction_reference; no scanner rule, ignore or bypass changed. Only unpublished commits are amended; published history is preserved. + +## First paired and protected provider-only recovery — 2026-10-29 + +Admitted worker capture resumed the app before encryption/upload;54.3s pause/capture, +2,120,392byte ciphertext, immutable primary exact-version/hash readback. The new +application_recovery_mirror.py reuses the existing multipart/conditional-completion +engine through an exact Coulomb-only bucket/key adapter. Its independent reader +fetched the mirror; custodian escrow supplied only the dedicated age identity. +SQL restore matches13tables/112rows/sequences and82files. Actual packaged app views +render39pages; restored owner, viewer read/edit403 and ungranted404 checks pass. + +application_recovery_kit.py publishes only new hash-addressed kits and checks live +versioning/no lifecycle before publication. The full277,585,920byte kit includes +encrypted data, exact images/tools/SDK/receipts and no private identity/API pair. +application_recovery_drill.py independently downloads/verifies all2098members, +retrieves the separate key, loads the retained images without registry pulls, +decrypts/restores and repeats all actual app checks in30.232s monotonic. Original +node/plaintext not used. Existing Docker daemon and held API callers were used; +operator account recovery time/fresh-daemon proof and real-browser identity +journeys are excluded. No production restore/expiry/timer activation. + +The successful proof is itself hash-addressed/conditionally created under the +protected kits/receipts/ prefix and independently version/hash read back. The +API contract records that proof coordinate as well as the kit and recovered +primary point. Later automated drills retain the same independent proof. + +Exact protected point and kit coordinates are in the public API contract; receipt: +docs/evidence/2026-10-29-coulomb-provider-kit-recovery.json. Consumer CSOC-WP-0012-T02 +is done. Owner T02 retention/capacity/copy disposition and T03/T04 scheduled and +monitored operation remain open. Preserve isolated recovery fixtures for the +pending actual-data storage-migration rehearsal; do not treat their copies as +another independent offsite location or delete historical plaintext pairs.