diff --git a/docs/credential-lane-designs/coulomb-recovery-api.json b/docs/credential-lane-designs/coulomb-recovery-api.json new file mode 100644 index 0000000..338c333 --- /dev/null +++ b/docs/credential-lane-designs/coulomb-recovery-api.json @@ -0,0 +1,45 @@ +{ + "schema": "platform.coulomb-recovery-api.v1", + "project_id": "06d107bc-9d84-4672-b2a0-95701cc0c07f", + "region": "nl-ams", + "custody_owner": "railiance-platform/platform-root", + "public_recipient": "age1qm30jk8q0cezey652dk2dhcr3d9vs528v6lg8mavl6d4q3e3vgnqmrmyy3", + "materials": { + "age": { + "id": "634e4b9f-0b61-4329-a83c-1ed0e76c4636", + "revision": 1, + "name": "coulomb-social-age-recovery" + }, + "reader-pair": { + "id": "241742f7-d3b5-4163-8d87-3a3d9585778d", + "revision": 1, + "name": "coulomb-social-recovery-reader-api" + }, + "custodian-pair": { + "id": "cf44e9bd-9edc-450b-9f10-564ee3008305", + "revision": 1, + "name": "coulomb-social-recovery-custodian-api" + } + }, + "applications": { + "custodian": "f80037a2-87c9-44d2-9163-3e6907af3ced", + "reader": "7a4055fc-a3f5-40bf-8013-cd0eec9028de" + }, + "routine_key_expirations": { + "reader": "2027-10-28T19:24:59.647270Z", + "custodian": "2027-10-28T19:25:00.458446Z" + }, + "buckets": { + "kits": { + "name": "railiance-coulomb-social-recovery", + "prefix": "kits/", + "automatic_expiry_enabled": false + }, + "archives": { + "name": "railiance-coulomb-social-archives", + "prefix": "archives/", + "automatic_expiry_enabled": false + } + }, + "native_application_recovery_verified": false +} diff --git a/docs/credential-lane-designs/coulomb-recovery-api.md b/docs/credential-lane-designs/coulomb-recovery-api.md new file mode 100644 index 0000000..437e02d --- /dev/null +++ b/docs/credential-lane-designs/coulomb-recovery-api.md @@ -0,0 +1,53 @@ +# Coulomb recovery API custody + +Owner: railiance-platform / platform-root. Admission decisions +27bbc1ac-e275-42be-ba34-6cf16eae4e78 and 6139e02b-3fb7-4eff-be8a-efe127c45ce5. +Non-secret coordinates: coulomb-recovery-api.json. Native evidence: +../evidence/2026-10-29-coulomb-recovery-api-bootstrap.json. + +Routine access uses separate reader/custodian API pairs under an owner-private +directory outside Git, normally ~/.config/railiance-platform/coulomb-social-recovery/. +The worker holds only its original publisher pair and the dedicated public age +recipient. Never deliver the custodian pair, reader pair or private age identity +to that worker. No sibling credentials or private keys are reused. + +Check independent retrieval without writing a private identity: + +```sh +python3 scripts/application_recovery_access.py --execute --material age +``` + +For an isolated attended restore, add --output with a fresh absolute filename +under an existing owner-private directory outside Git. The helper validates exact +project/name/protection/revision/nonexpiry and public recipient before writing +mode0600. Materials reader-pair and custodian-pair use the same front door; +omit --output to verify retrieval without creating another secret copy. +No value goes to stdout, Git, State Hub, shell arguments or environment. + +The reader performs only prefix/version reads and listing in the isolated project. +The custodian can also publish new kit versions and read the dedicated escrow; +neither can delete retained versions or administer buckets/IAM. Scaleway's current +2023-04-17 bucket policies intersect IAM with an explicit allowlist. Deletion is +absent while publisher multipart abort is included; native checks prove both. +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 routine pairs and age identity each have a separate protected, enabled, +nonexpiring provider secret version. They survive node/workstation loss. If all +local routine credentials are lost, platform-root uses its existing independent +account/MFA recovery to retrieve only the custodian escrow coordinate in the +public contract, then returns to the API front door. Emergency owner recovery is +separate from routine drill access; preserve the platform-root recovery mechanism. + +Routine API keys expire on 2027-10-28; exact timestamps are in the JSON contract. +Platform-root owns renewal and independently read-back escrow before those +deadlines, using a bounded attended construction window. No standing organization +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. diff --git a/docs/evidence/2026-10-29-coulomb-recovery-api-bootstrap.json b/docs/evidence/2026-10-29-coulomb-recovery-api-bootstrap.json new file mode 100644 index 0000000..16fe786 --- /dev/null +++ b/docs/evidence/2026-10-29-coulomb-recovery-api-bootstrap.json @@ -0,0 +1,445 @@ +{ + "programmatic_direction_reference": "6139e02b-3fb7-4eff-be8a-efe127c45ce5", + "applications": { + "bootstrap": "2a355842-e03b-42a3-8af7-6cd8f8b98bcd", + "custodian": "f80037a2-87c9-44d2-9163-3e6907af3ced", + "reader": "7a4055fc-a3f5-40bf-8013-cd0eec9028de" + }, + "automatic_expiry_enabled": false, + "bootstrap_key_revoked_verified": true, + "buckets": { + "railiance-coulomb-social-archives": { + "created": true, + "lifecycle_rules": [], + "policy": { + "Id": "coulomb-independent-recovery", + "Statement": [ + { + "Action": "s3:*", + "Effect": "Allow", + "Principal": { + "SCW": "user_id:3d029efb-4f70-4f82-a3bb-cefbe255232b" + }, + "Resource": [ + "railiance-coulomb-social-archives", + "railiance-coulomb-social-archives/*" + ], + "Sid": "PlatformOwner" + }, + { + "Action": "s3:*", + "Effect": "Allow", + "Principal": { + "SCW": "application_id:2a355842-e03b-42a3-8af7-6cd8f8b98bcd" + }, + "Resource": [ + "railiance-coulomb-social-archives", + "railiance-coulomb-social-archives/*" + ], + "Sid": "TemporaryConstruction" + }, + { + "Action": [ + "s3:GetObject", + "s3:GetObjectVersion" + ], + "Effect": "Allow", + "Principal": { + "SCW": "application_id:7a4055fc-a3f5-40bf-8013-cd0eec9028de" + }, + "Resource": "railiance-coulomb-social-archives/archives/*", + "Sid": "readerObjects" + }, + { + "Action": [ + "s3:ListBucket", + "s3:ListBucketVersions" + ], + "Condition": { + "StringLike": { + "s3:prefix": [ + "archives/", + "archives/*" + ] + } + }, + "Effect": "Allow", + "Principal": { + "SCW": "application_id:7a4055fc-a3f5-40bf-8013-cd0eec9028de" + }, + "Resource": "railiance-coulomb-social-archives", + "Sid": "readerList" + }, + { + "Action": [ + "s3:GetObject", + "s3:GetObjectVersion", + "s3:PutObject" + ], + "Effect": "Allow", + "Principal": { + "SCW": "application_id:f80037a2-87c9-44d2-9163-3e6907af3ced" + }, + "Resource": "railiance-coulomb-social-archives/archives/*", + "Sid": "custodianObjects" + }, + { + "Action": [ + "s3:ListBucket", + "s3:ListBucketVersions" + ], + "Condition": { + "StringLike": { + "s3:prefix": [ + "archives/", + "archives/*" + ] + } + }, + "Effect": "Allow", + "Principal": { + "SCW": "application_id:f80037a2-87c9-44d2-9163-3e6907af3ced" + }, + "Resource": "railiance-coulomb-social-archives", + "Sid": "custodianList" + }, + { + "Action": [ + "s3:GetObject", + "s3:GetObjectVersion", + "s3:PutObject", + "s3:AbortMultipartUpload", + "s3:ListMultipartUploadParts" + ], + "Effect": "Allow", + "Principal": { + "SCW": "application_id:681d00bf-b965-4323-b657-97bfde423511" + }, + "Resource": "railiance-coulomb-social-archives/archives/*", + "Sid": "publisherObjects" + }, + { + "Action": [ + "s3:ListBucket", + "s3:ListBucketVersions" + ], + "Condition": { + "StringLike": { + "s3:prefix": [ + "archives/", + "archives/*" + ] + } + }, + "Effect": "Allow", + "Principal": { + "SCW": "application_id:681d00bf-b965-4323-b657-97bfde423511" + }, + "Resource": "railiance-coulomb-social-archives", + "Sid": "publisherList" + }, + { + "Action": [ + "s3:GetBucketLocation", + "s3:GetBucketVersioning", + "s3:GetLifecycleConfiguration" + ], + "Effect": "Allow", + "Principal": { + "SCW": [ + "application_id:7a4055fc-a3f5-40bf-8013-cd0eec9028de", + "application_id:f80037a2-87c9-44d2-9163-3e6907af3ced", + "application_id:681d00bf-b965-4323-b657-97bfde423511" + ] + }, + "Resource": "railiance-coulomb-social-archives", + "Sid": "ArchiveMetadata" + } + ], + "Version": "2023-04-17" + }, + "public_acl_absent": true, + "versioning": "Enabled" + }, + "railiance-coulomb-social-recovery": { + "created": true, + "lifecycle_rules": [], + "policy": { + "Id": "coulomb-independent-recovery", + "Statement": [ + { + "Action": "s3:*", + "Effect": "Allow", + "Principal": { + "SCW": "user_id:3d029efb-4f70-4f82-a3bb-cefbe255232b" + }, + "Resource": [ + "railiance-coulomb-social-recovery", + "railiance-coulomb-social-recovery/*" + ], + "Sid": "PlatformOwner" + }, + { + "Action": "s3:*", + "Effect": "Allow", + "Principal": { + "SCW": "application_id:2a355842-e03b-42a3-8af7-6cd8f8b98bcd" + }, + "Resource": [ + "railiance-coulomb-social-recovery", + "railiance-coulomb-social-recovery/*" + ], + "Sid": "TemporaryConstruction" + }, + { + "Action": [ + "s3:GetObject", + "s3:GetObjectVersion" + ], + "Effect": "Allow", + "Principal": { + "SCW": "application_id:7a4055fc-a3f5-40bf-8013-cd0eec9028de" + }, + "Resource": "railiance-coulomb-social-recovery/kits/*", + "Sid": "readerObjects" + }, + { + "Action": [ + "s3:ListBucket", + "s3:ListBucketVersions" + ], + "Condition": { + "StringLike": { + "s3:prefix": [ + "kits/", + "kits/*" + ] + } + }, + "Effect": "Allow", + "Principal": { + "SCW": "application_id:7a4055fc-a3f5-40bf-8013-cd0eec9028de" + }, + "Resource": "railiance-coulomb-social-recovery", + "Sid": "readerList" + }, + { + "Action": [ + "s3:GetBucketLocation", + "s3:GetBucketVersioning", + "s3:GetLifecycleConfiguration" + ], + "Effect": "Allow", + "Principal": { + "SCW": "application_id:7a4055fc-a3f5-40bf-8013-cd0eec9028de" + }, + "Resource": "railiance-coulomb-social-recovery", + "Sid": "readerMetadata" + }, + { + "Action": [ + "s3:GetObject", + "s3:GetObjectVersion", + "s3:PutObject" + ], + "Effect": "Allow", + "Principal": { + "SCW": "application_id:f80037a2-87c9-44d2-9163-3e6907af3ced" + }, + "Resource": "railiance-coulomb-social-recovery/kits/*", + "Sid": "custodianObjects" + }, + { + "Action": [ + "s3:ListBucket", + "s3:ListBucketVersions" + ], + "Condition": { + "StringLike": { + "s3:prefix": [ + "kits/", + "kits/*" + ] + } + }, + "Effect": "Allow", + "Principal": { + "SCW": "application_id:f80037a2-87c9-44d2-9163-3e6907af3ced" + }, + "Resource": "railiance-coulomb-social-recovery", + "Sid": "custodianList" + }, + { + "Action": [ + "s3:GetBucketLocation", + "s3:GetBucketVersioning", + "s3:GetLifecycleConfiguration" + ], + "Effect": "Allow", + "Principal": { + "SCW": "application_id:f80037a2-87c9-44d2-9163-3e6907af3ced" + }, + "Resource": "railiance-coulomb-social-recovery", + "Sid": "custodianMetadata" + } + ], + "Version": "2023-04-17" + }, + "public_acl_absent": true, + "versioning": "Enabled" + } + }, + "completed_at": "2026-10-29T20:59:12.852425+00:00", + "decision": "27bbc1ac-e275-42be-ba34-6cf16eae4e78", + "native_application_recovery_verified": false, + "native_checks": { + "archives_custodian_delete": { + "denied": true, + "http_status": 403 + }, + "archives_custodian_outside_prefix": { + "denied": true, + "http_status": 403 + }, + "archives_custodian_policy_write": { + "denied": true, + "http_status": 403 + }, + "archives_publisher_delete": { + "denied": true, + "http_status": 403 + }, + "archives_publisher_outside_prefix": { + "denied": true, + "http_status": 403 + }, + "archives_publisher_policy_write": { + "denied": true, + "http_status": 403 + }, + "archives_reader_delete": { + "denied": true, + "http_status": 403 + }, + "archives_reader_outside_prefix": { + "denied": true, + "http_status": 403 + }, + "archives_reader_policy_write": { + "denied": true, + "http_status": 403 + }, + "archives_reader_put": { + "denied": true, + "http_status": 403 + }, + "archives_reader_version_get_hash": { + "verified": true, + "version_id": "1793307392673579" + }, + "custodian_iam_admin": { + "denied": true, + "http_status": 403 + }, + "kits_custodian_delete": { + "denied": true, + "http_status": 403 + }, + "kits_custodian_outside_prefix": { + "denied": true, + "http_status": 403 + }, + "kits_custodian_policy_write": { + "denied": true, + "http_status": 403 + }, + "kits_publisher_delete": { + "denied": true, + "http_status": 403 + }, + "kits_publisher_outside_prefix": { + "denied": true, + "http_status": 403 + }, + "kits_publisher_policy_write": { + "denied": true, + "http_status": 403 + }, + "kits_reader_delete": { + "denied": true, + "http_status": 403 + }, + "kits_reader_outside_prefix": { + "denied": true, + "http_status": 403 + }, + "kits_reader_policy_write": { + "denied": true, + "http_status": 403 + }, + "kits_reader_put": { + "denied": true, + "http_status": 403 + }, + "kits_reader_version_get_hash": { + "verified": true, + "version_id": "1793307384956710" + }, + "publisher_create_readback": { + "verified": true + }, + "publisher_escrow": { + "denied": true, + "http_status": 403 + }, + "publisher_iam_admin": { + "denied": true, + "http_status": 403 + }, + "publisher_own_multipart_abort": { + "verified": true + }, + "publisher_protected_kit_get": { + "denied": true, + "http_status": 403 + }, + "reader_escrow": { + "denied": true, + "http_status": 403 + }, + "reader_iam_admin": { + "denied": true, + "http_status": 403 + } + }, + "organization_wide_grants": false, + "personal_browser_context_closed": true, + "personal_state_saved": false, + "private_values_printed": false, + "project_id": "06d107bc-9d84-4672-b2a0-95701cc0c07f", + "project_only_policies_verified": 4, + "protected_secret_references": { + "coulomb-social-age-recovery": { + "id": "634e4b9f-0b61-4329-a83c-1ed0e76c4636", + "revision": 1 + }, + "coulomb-social-recovery-access-canary": { + "id": "db825503-cf12-4bfd-8cc5-f16d09edd8b5", + "revision": 1 + }, + "coulomb-social-recovery-custodian-api": { + "id": "cf44e9bd-9edc-450b-9f10-564ee3008305", + "revision": 1 + }, + "coulomb-social-recovery-reader-api": { + "id": "241742f7-d3b5-4163-8d87-3a3d9585778d", + "revision": 1 + } + }, + "public_recipient": "age1qm30jk8q0cezey652dk2dhcr3d9vs528v6lg8mavl6d4q3e3vgnqmrmyy3", + "publisher_pair_replaced": false, + "routine_age_and_both_pairs_retrieved_without_personal_session": true, + "schema": "platform.coulomb-recovery-bootstrap.v1", + "temporary_pair_removed": true, + "timers_activated": false +} diff --git a/scripts/application_recovery_access.py b/scripts/application_recovery_access.py new file mode 100644 index 0000000..4130540 --- /dev/null +++ b/scripts/application_recovery_access.py @@ -0,0 +1,89 @@ +"""Routine Coulomb recovery access; scoped custodian API, no Console session.""" +from __future__ import annotations + +import argparse +import base64 +import json +import os +import sys +from pathlib import Path + +from application_recovery_identities import Refused, private_directory, private_json +from application_recovery_resources import API, SECRET_BASE, public_recipient, status + +CONTRACT = Path(__file__).resolve().parents[1] / "docs/credential-lane-designs/coulomb-recovery-api.json" +PROJECT = "06d107bc-9d84-4672-b2a0-95701cc0c07f" + + +def retrieve(api, contract, material): + if (contract.get("schema") != "platform.coulomb-recovery-api.v1" + or contract.get("project_id") != PROJECT): + raise Refused("exact_coulomb_contract_required") + reference = contract["materials"][material] + path = SECRET_BASE + "/" + reference["id"] + metadata = api("GET", path) + if (metadata.get("id") != reference["id"] or metadata.get("name") != reference["name"] + or metadata.get("project_id") != PROJECT or metadata.get("protected") is not True + or metadata.get("type") != "opaque" or metadata.get("ephemeral_policy") is not None + or metadata.get("deletion_requested_at") is not None): + raise Refused("protected_exact_escrow_required") + version = api("GET", path + "/versions/" + str(reference["revision"])) + properties = version.get("ephemeral_properties") or {} + if (version.get("status") != "enabled" or properties.get("expires_at") + or properties.get("expires_once_accessed")): + raise Refused("enabled_nonexpiring_exact_version_required") + response = api("GET", path + "/versions/" + str(reference["revision"]) + "/access") + if response.get("secret_id") != reference["id"] or response.get("revision") != reference["revision"]: + raise Refused("escrow_response_binding_refused") + payload = base64.b64decode(response["data"], validate=True) + if material == "age": + if public_recipient(payload) != contract["public_recipient"]: + raise Refused("public_recipient_differs") + else: + pair = json.loads(payload) + if set(pair) != {"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"} or any( + not isinstance(v, str) or not v or "\n" in v for v in pair.values() + ): + raise Refused("exact_api_pair_required") + return payload + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--execute", action="store_true") + parser.add_argument("--directory", type=Path, default=Path.home() / ".config/railiance-platform/coulomb-social-recovery") + parser.add_argument("--material", choices=("age", "reader-pair", "custodian-pair"), default="age") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + if not args.execute: + print(json.dumps({"status": "preview", "material": args.material, "provider_contacted": False, + "personal_login_required": False})) + return + private_directory(args.directory) + if args.output: + if not args.output.is_absolute(): + raise Refused("absolute_private_output_required") + private_directory(args.output.parent) + if args.output.exists() or args.output.is_symlink(): + raise Refused("fresh_private_output_required") + contract = json.loads(CONTRACT.read_text()) + pair = private_json(args.directory / "custodian-pair.json") + payload = retrieve(API(pair), contract, args.material) + if args.output: + fd = os.open(args.output, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "wb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + print(json.dumps({"status": "scoped_api_retrieval_verified", "material": args.material, + "private_output_created": bool(args.output), "credential_values_printed": False, + "personal_session_used": False})) + + +if __name__ == "__main__": + try: + main() + except Exception as error: # noqa: BLE001 -- suppress secret-bearing provider diagnostics + print(json.dumps({"status": "refused", "failure_type": type(error).__name__, + "native_status": status(error)})) + sys.exit(1) diff --git a/scripts/application_recovery_resources.py b/scripts/application_recovery_resources.py index 78b6a33..90e444e 100644 --- a/scripts/application_recovery_resources.py +++ b/scripts/application_recovery_resources.py @@ -7,6 +7,7 @@ from __future__ import annotations import argparse import base64 +import fcntl import hashlib import hmac import json @@ -180,6 +181,16 @@ class Resources: def save(self): checkpoint(self.path, self.state) + def deny_s3(self, role, operation, **arguments): + # Some denied PUTs leave a provider keep-alive connection unusable for + # the next request (observed HTTP400 with an empty error). Isolate each + # negative probe so it cannot corrupt subsequent positive read proof. + client = s3(self.pairs[role], self.project) + try: + return denied(lambda: getattr(client, operation)(**arguments)) + finally: + client.close() + def mutate(self, label, call): self.state["pending"] = label self.save() @@ -360,21 +371,20 @@ class Resources: raise Refused("independent_version_listing_required") proofs[prefix + "_reader_version_get_hash"] = {"verified": True, "version_id": version} for role in ("reader", "custodian", "publisher"): - client = self.clients[role] # All deletion targets are newly made synthetic canaries. No # actual retained backup is ever a destructive negative probe. - proofs[prefix + "_" + role + "_delete"] = denied(lambda client=client, bucket=bucket, key=key, version=version: client.delete_object( - Bucket=bucket, Key=key, VersionId=version)) + proofs[prefix + "_" + role + "_delete"] = self.deny_s3(role, "delete_object", + Bucket=bucket, Key=key, VersionId=version) intended = self.state["buckets"][bucket]["policy"] - proofs[prefix + "_" + role + "_policy_write"] = denied(lambda client=client, bucket=bucket, intended=intended: client.put_bucket_policy( - Bucket=bucket, Policy=json.dumps(intended))) - proofs[prefix + "_" + role + "_outside_prefix"] = denied(lambda client=client, bucket=bucket: client.get_object( - Bucket=bucket, Key="outside-approved-prefix/synthetic-absent")) - proofs[prefix + "_reader_put"] = denied(lambda reader=reader, bucket=bucket, prefix=prefix: reader.put_object( - Bucket=bucket, Key=prefix + "/reader-write-denial.txt", Body=b"synthetic-only")) + proofs[prefix + "_" + role + "_policy_write"] = self.deny_s3(role, "put_bucket_policy", + Bucket=bucket, Policy=json.dumps(intended)) + proofs[prefix + "_" + role + "_outside_prefix"] = self.deny_s3(role, "get_object", + Bucket=bucket, Key="outside-approved-prefix/synthetic-absent") + proofs[prefix + "_reader_put"] = self.deny_s3("reader", "put_object", + Bucket=bucket, Key=prefix + "/reader-write-denial.txt", Body=b"synthetic-only") kit = self.state["fixtures"]["kits"] - proofs["publisher_protected_kit_get"] = denied(lambda: self.clients["publisher"].get_object( - Bucket=kit["bucket"], Key=kit["key"], VersionId=kit["version_id"])) + proofs["publisher_protected_kit_get"] = self.deny_s3("publisher", "get_object", + Bucket=kit["bucket"], Key=kit["key"], VersionId=kit["version_id"]) publisher = self.clients["publisher"] # Verify ordinary worker upload/readback in addition to its multipart # abort. No content is captured from the application by this helper. @@ -425,7 +435,11 @@ def main(): return import uuid uuid.UUID(args.owner_user_id) - print(json.dumps(Resources(args.directory, args.owner_user_id).run())) + private_directory(args.directory) + fd = os.open(args.directory / "resource-bootstrap.lock", os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + print(json.dumps(Resources(args.directory, args.owner_user_id).run())) if __name__ == "__main__": diff --git a/tests/test_application_recovery_access.py b/tests/test_application_recovery_access.py new file mode 100644 index 0000000..732f0f1 --- /dev/null +++ b/tests/test_application_recovery_access.py @@ -0,0 +1,67 @@ +"""Synthetic access refusals; no live API or private custody values.""" +import base64 +import copy +import importlib.util +import sys +import unittest +from pathlib import Path + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +sys.path.insert(0, str(SCRIPTS)) +spec = importlib.util.spec_from_file_location("access", SCRIPTS / "application_recovery_access.py") +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + + +class AccessTests(unittest.TestCase): + def setUp(self): + self.contract = {"schema": "platform.coulomb-recovery-api.v1", "project_id": m.PROJECT, + "materials": {"reader-pair": {"id": "synthetic-id", "name": "synthetic-name", "revision": 1}}} + self.metadata = {"id": "synthetic-id", "name": "synthetic-name", "project_id": m.PROJECT, + "protected": True, "type": "opaque", "ephemeral_policy": None} + self.version = {"status": "enabled", "ephemeral_properties": {}} + self.accesses = 0 + + def api(self, method, path): + self.assertEqual(method, "GET") + if path.endswith("/access"): + self.accesses += 1 + return {"secret_id": "synthetic-id", "revision": 1, "data": base64.b64encode( + b'{"AWS_ACCESS_KEY_ID":"SYNTHETIC_ID","AWS_SECRET_ACCESS_KEY":"SYNTHETIC_VALUE"}').decode()} + if "/versions/" in path: + return self.version + return self.metadata + + def test_routine_retrieval_uses_only_protected_bound_scoped_secret(self): + payload = m.retrieve(self.api, self.contract, "reader-pair") + self.assertIn(b"SYNTHETIC_ID", payload) + self.assertEqual(self.accesses, 1) + + def test_project_name_protection_type_and_expiry_drift_refuse_before_data(self): + baseline = copy.deepcopy(self.metadata) + for key, value in (("project_id", "foreign"), ("name", "foreign"), ("protected", False), + ("type", "certificate"), ("ephemeral_policy", {"time_to_live": "60s"}), + ("deletion_requested_at", "synthetic-time")): + self.metadata = dict(baseline, **{key: value}) + with self.assertRaises(m.Refused): + m.retrieve(self.api, self.contract, "reader-pair") + self.assertEqual(self.accesses, 0) + + def test_disabled_or_expiring_version_refuses_before_data(self): + for version in ({"status": "disabled"}, {"status": "enabled", "ephemeral_properties": {"expires_at": "soon"}}, + {"status": "enabled", "ephemeral_properties": {"expires_once_accessed": True}}): + self.version = version + with self.assertRaises(m.Refused): + m.retrieve(self.api, self.contract, "reader-pair") + self.assertEqual(self.accesses, 0) + + def test_wrong_response_revision_refused(self): + def api(method, path): + result = self.api(method, path) + return dict(result, revision=2) if path.endswith("/access") else result + with self.assertRaises(m.Refused): + m.retrieve(api, self.contract, "reader-pair") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_application_recovery_resources.py b/tests/test_application_recovery_resources.py index 381e00c..657de7f 100644 --- a/tests/test_application_recovery_resources.py +++ b/tests/test_application_recovery_resources.py @@ -3,6 +3,7 @@ import importlib.util import sys import unittest from pathlib import Path +from unittest.mock import Mock, patch SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" sys.path.insert(0, str(SCRIPTS)) @@ -81,6 +82,16 @@ class ResourcesTests(unittest.TestCase): returned["Statement"][0]["Action"].append("s3:DeleteObject") self.assertNotEqual(m.canonical_policy(original), m.canonical_policy(returned)) + def test_negative_probe_uses_and_closes_its_own_transport(self): + resource = object.__new__(m.Resources) + resource.pairs, resource.project = {"reader": {}}, "synthetic-project" + client = Mock() + client.put_object.side_effect = m.NativeError(403) + with patch.object(m, "s3", return_value=client) as factory: + self.assertTrue(resource.deny_s3("reader", "put_object", Bucket="synthetic")["denied"]) + factory.assert_called_once_with({}, "synthetic-project") + client.close.assert_called_once() + if __name__ == "__main__": unittest.main() diff --git a/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md b/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md index 9285a8d..f5c1b4d 100644 --- a/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md +++ b/workplans/RPF-WP-0053-coulomb-paired-backup-admission.md @@ -8,7 +8,7 @@ status: active owner: codex topic_slug: coulomb-social created: "2026-10-07" -updated: "2026-10-28" +updated: "2026-10-29" state_hub_workstream_id: "cb55e474-c7a5-5c6f-bd72-ce65f853e3e3" --- @@ -354,3 +354,50 @@ retires construction authority and logs out on finish. It saves no personal browser state. Run --preflight-only first; routine access uses the scoped API pairs, never that personal session. The expired earlier capture is not a credential lane. Existing independent recovery and service gates stay open. + +## Native scoped API custody established — 2026-10-29 + +The attended bootstrap created isolated project +06d107bc-9d84-4672-b2a0-95701cc0c07f, three dedicated applications and four +verified project-only policies. No organization-wide grant or publisher-pair +replacement. The two-hour construction key expired during an approval pause; +the refreshed existing browser revoked that exact expired key and issued only +its bounded replacement. Reader/custodian pairs were preserved. + +Two private versioned buckets now have owner-preserving allowlists. No lifecycle +expiry is enabled. Four protected nonexpiring secrets contain a synthetic access +canary, the dedicated age identity, reader pair and custodian pair. Native +custodian API readback equals all three real values; publisher/reader canary +access denies403. Thirty native checks include exact-version/hash/list reads, +publisher create/readback/own multipart abort, denied retained-version deletion, +policy administration, outside-prefix reads, protected-kit access by publisher, +reader writes and IAM administration. Only newly created synthetic objects were +used as destructive denial targets. No production capture, restore or timer. + +The construction key was revoked with native GET404 verification and its own +temporary pair removed. Personal context closed; no browser state saved. Routine +scripts/application_recovery_access.py independently retrieves/validates age and +both API pairs without personal login. Public contract and recovery procedure: +docs/credential-lane-designs/coulomb-recovery-api.{json,md}; receipt: +docs/evidence/2026-10-29-coulomb-recovery-api-bootstrap.json. Twenty-six synthetic +tests and targeted Ruff pass. Future routine keys expire2027-10-28; platform-root +owns renewal/read-back escrow before those recorded deadlines. + +Native corrections were narrow: respect the provider's ten-statement policy +limit, compare every policy field independent of array order, and isolate each +negative PUT transport from positive reads. No permission was broadened to make +a denial pass. T02 remains progress for full kit/last recovered app point, +retention/capacity and historical-copy disposition; T03/T04 remain open for first +application recovery and monitored scheduling. Earlier unperformed statements +above describe their dated preparation stage. + +Consumer delivered only the verified public recipient to the admitted worker's +root-owned0600 recipient.env with cluster UID/installed guard hash/readback checks. +Private age identity and recovery pairs remain outside the worker. Consumer +receipt: docs/release/backup-public-recipient-delivery-2026-10-29.json. + +Publication preflight classified the receipt's access-direction decision UUID +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.