From 70ace5a26312978343ed697f9d15e217c4169d3a Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 14:12:47 -0400 Subject: [PATCH 1/4] feat(evidence)!: retain acceptance evidence in signed git, not S3 Object Lock Decided before the first write, which was the last moment it was free. A COMPLIANCE lock cannot be shortened by anyone including the account root, so the first retained object would have committed storage for its full term. The case for S3 was weaker than it looked. The Cloud writer already encrypts the envelope client-side with an AES-256-GCM data key wrapped by KMS, so the store holds ciphertext and confidentiality never depended on the medium. What makes the claim credible is the signature chain and the public Rekor entry: Object Lock stops neither the suppression of an unpublished result nor the alteration of a published one, because the signature and the transparency log already do both. Git is content-addressed, so a commit hash binds the relationships between certificate, admission and campaign rather than each blob alone. Object Lock does buy one thing git does not. If a third party is granted direct read access to the store, they can trust nothing was pruned before they looked. No buyer has asked for that. The document records the trade so the next reader does not have to reconstruct it, and says to revisit before the next write if a buyer ever does ask. The retention block loses retention_mode, retention_until, object_version_sha256 and object_lock_verified. It gains retention_commit, transparency_log_entry_sha256, push_verified, commit_verified and transparency_logged. The contract destination becomes a repository, ref and path prefix, keeping the account, region and KMS ARN because the data key is still wrapped by KMS. retention_commitment_days is recorded and not enforced, and both the code and the document say so. A git commit has no expiry, and policing a number nothing can hold would be theatre. The hashed policy digest moves with the medium: bc013d65... -> 77130f49... The Cloud writer must change to match before either side can run. That is a separate lane and a separate review. Co-Authored-By: Claude Opus 5 --- docs/eval_results/PRIVATE_EXPORT_CONTRACT.md | 82 +++++++---- .../private-export-contract.template.json | 21 +-- scripts/import_production_acceptance.py | 128 ++++++++---------- .../live-certificate.json | 11 +- tests/test_import_production_acceptance.py | 46 ++++--- tests/test_private_export_contract.py | 68 +++++----- 6 files changed, 193 insertions(+), 163 deletions(-) diff --git a/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md b/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md index 10faafa..29f9568 100644 --- a/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md +++ b/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md @@ -16,16 +16,15 @@ below are blank. ## Why an approval is needed at all, and what it fixes The certificate already carries a `retention` block, and the importer already -validates its shape: every digest is well formed, the mode is Object Lock -`COMPLIANCE`, four verification booleans are true, the period is between one and -ten years, and the chronology runs `acceptance_verified_at <= retained_at < -retention_until`. +validates its shape: every digest is well formed, the retention commit is an +exact 40-character hash, four verification booleans are true, and the chronology +runs `acceptance_verified_at <= retained_at` and no later than now. Shape is not identity. Today the certificate supplies its own `storage_identity_sha256`, `kms_key_identity_sha256`, and `uploader_identity_sha256`, and the importer checks only that they look like -digests. A certificate that named some other bucket, some other key, and some -other uploader would pass every check in the file. +digests. A certificate that named some other repository, some other key, and +some other uploader would pass every check in the file. The approval closes that. It supplies the expected digests from outside the evidence, the way `--trusted-admission-signers` and @@ -33,39 +32,67 @@ evidence, the way `--trusted-admission-signers` and and the Cloud commit. After approval the evidence can no longer select its own destination, its own key, or its own uploader. +## What the store is, and why it is not S3 + +The retained evidence is a signed git commit in a private repository, not an +object in an S3 bucket with Object Lock. That choice was made deliberately, +before the first write, when changing it was still free. + +The reasoning, kept here so nobody has to reconstruct it: + +- The Cloud writer already encrypts the envelope client-side with an AES-256-GCM + data key wrapped by KMS. The store holds ciphertext either way, so GitHub + cannot read the evidence and confidentiality does not depend on the medium. +- What makes the claim credible to a reader is the signature chain plus the + public Rekor entry, not the storage. Object Lock stops neither the suppression + of an unpublished result nor the alteration of a published one; the signature + and the transparency log already do both. +- Git is content-addressed. A commit hash covers the whole tree and history, so + it binds the relationships between certificate, admission and campaign. A + per-object lock binds only each blob. +- Object Lock does buy one thing git does not: if a third party is granted + direct read access to the store, they can trust nothing was pruned before they + looked. No buyer has asked for that. If one does, this decision should be + revisited before the next write rather than patched around. + +Object Lock in COMPLIANCE mode is also irreversible for its full term. Choosing +it commits storage for years and cannot be undone by anyone, including the +account root. + ## The bindings -The approval fixes seven destination fields plus the uploader, the importer, and +The approval fixes six destination fields plus the uploader, the importer, and the authority. **Every value is blank in this repository.** They are deployment facts, and only the account owner can supply them. | Contract field | What it fixes | Value | | --- | --- | --- | -| `destination.account_id` | the AWS account holding the evidence | **TO BE SUPPLIED** | -| `destination.region` | the region | **TO BE SUPPLIED** | -| `destination.bucket` | the S3 bucket | **TO BE SUPPLIED** | -| `destination.object_prefix` | the prefix inside it | **TO BE SUPPLIED** | -| `destination.kms_key_arn` | the encryption key | **TO BE SUPPLIED** | -| `destination.retention_mode` | Object Lock mode | `COMPLIANCE`, fixed | -| `destination.retention_days` | the immutable period | default 2555, inside 365 to 3650 | +| `destination.account_id` | the AWS account holding the KMS key | **TO BE SUPPLIED** | +| `destination.region` | the region of that key | **TO BE SUPPLIED** | +| `destination.repository` | the private repository holding the ciphertext | **TO BE SUPPLIED** | +| `destination.ref` | the branch it lands on | `refs/heads/main` | +| `destination.path_prefix` | the path inside it | **TO BE SUPPLIED** | +| `destination.kms_key_arn` | the key wrapping each data key | **TO BE SUPPLIED** | +| `destination.retention_commitment_days` | how long you commit to keep it | default 2555 | | `uploader_arn` | the principal allowed to write | **TO BE SUPPLIED** | | `importer_workflow_ref` | the one workflow allowed to import | pre-filled | | `approval_authority`, `approved_at` | who approved, and when | **TO BE SUPPLIED** | -These are the same seven destination fields the Cloud retention writer hashes, -in the same order, so the approval digest derived here is the one that writer -already requires. +`retention_commitment_days` is a commitment the mechanism records and does not +enforce. A git commit has no expiry. Enforcing a number nothing can hold would +be theatre, so the verifier does not police it, and this document says so +plainly rather than leaving a reader to assume otherwise. Two things the certificate carries are not fixed by the approval, because they -differ per export: the ciphertext, envelope, and candidate digests, and the -object and locator version digests. The approval fixes where evidence may go and -who may put it there, not the content of any one export. +differ per export: the ciphertext, envelope and candidate digests, and the +commit and locator versions. The approval fixes where evidence may go and who +may put it there, not the content of any one export. ## Where the filled copy lives -This repository is public. The filled contract names an account, a bucket, a -prefix, a key ARN, and an uploader ARN, which are deployment-derived facts and -fall under the source-availability boundary. +This repository is public. The filled contract names an account, a repository, a +path prefix, a key ARN, and an uploader ARN, which are deployment-derived facts +and fall under the source-availability boundary. So: the shape stays here, the values do not. The approved instance belongs in `openadapt-internal`, and this repository receives only its digest, supplied to @@ -104,7 +131,7 @@ values from the contract, so one approval governs both repositories: | Digest | Domain | Preimage | | --- | --- | --- | -| `storage_identity_sha256` | `retention store` | `destination.bucket` | +| `storage_identity_sha256` | `retention store` | `destination.repository` | | `kms_key_identity_sha256` | `retention KMS key` | `destination.kms_key_arn` | | `uploader_identity_sha256` | `AWS retention uploader` | `uploader_arn` | | `destination_approval_sha256` | `Execute acceptance retention destination` | canonical JSON of all seven `destination` fields | @@ -120,11 +147,12 @@ contract produces it. The contract carries values and never a digest, so an approval cannot assert a hash whose input nobody can see. `verify_retention_against_contract` compares -all three identities against the certificate and refuses a retention period -shorter than the approved one. A longer lock is allowed: the approval sets a -floor. `verify_importer_identity` requires `GITHUB_WORKFLOW_REF` to equal the +all three identities against the certificate. `verify_importer_identity` requires `GITHUB_WORKFLOW_REF` to equal the approved ref exactly. The destination is checked the way the Cloud writer checks it, including that the KMS key lives in the approved account and region. +The certificate must also carry an exact 40-character retention commit and prove +that the push, the commit read-back, and the transparency-log entry were all +verified. ## Open items that block approval diff --git a/docs/eval_results/private-export-contract.template.json b/docs/eval_results/private-export-contract.template.json index fa318fc..f58b912 100644 --- a/docs/eval_results/private-export-contract.template.json +++ b/docs/eval_results/private-export-contract.template.json @@ -5,10 +5,13 @@ "_README key, and approve the result there. This public repository keeps", "the shape; the filled instance and its values stay private.", "", - "The destination fields are the same seven the Cloud retention writer", - "hashes in scripts/retain-execute-private-evidence.mjs. Its approval digest", - "EXECUTE_ACCEPTANCE_RETENTION_DESTINATION_APPROVAL_SHA256 is derived from", - "exactly these values, so one approval governs both repositories.", + "The retained evidence is a signed git commit, not an S3 object. The Cloud", + "writer still encrypts the envelope client-side with an AES-256-GCM data", + "key wrapped by KMS, so the repository holds ciphertext and GitHub cannot", + "read it. The KMS key is the only part that still costs money.", + "", + "retention_commitment_days is a commitment this mechanism records and does", + "not enforce. A git commit carries no expiry. Nothing here pretends it does.", "", "Validate a filled copy with:", " python scripts/import_production_acceptance.py --private-export-contract ...", @@ -19,11 +22,11 @@ "destination": { "account_id": "FILL_AWS_ACCOUNT_ID", "region": "FILL_AWS_REGION", - "bucket": "FILL_RETENTION_BUCKET", - "object_prefix": "FILL_RETENTION_OBJECT_PREFIX", - "kms_key_arn": "FILL_RETENTION_KMS_KEY_ARN", - "retention_mode": "COMPLIANCE", - "retention_days": 2555 + "repository": "FILL_EVIDENCE_REPOSITORY", + "ref": "refs/heads/main", + "path_prefix": "FILL_EVIDENCE_PATH_PREFIX", + "kms_key_arn": "FILL_DATA_KEY_KMS_ARN", + "retention_commitment_days": 2555 }, "uploader_arn": "FILL_UPLOADER_ROLE_OR_USER_ARN", "importer_workflow_ref": "OpenAdaptAI/openadapt-evals/.github/workflows/import-production-acceptance.yml@refs/heads/main", diff --git a/scripts/import_production_acceptance.py b/scripts/import_production_acceptance.py index 4cc0c95..fceb2ff 100644 --- a/scripts/import_production_acceptance.py +++ b/scripts/import_production_acceptance.py @@ -85,6 +85,7 @@ ) GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com" RETENTION_PROVENANCE_ROUTE = "sigstore-public-good-slsa-provenance-v1" +RETENTION_MEDIUM = "signed-git-commit-v1" PRIVATE_EXPORT_CONTRACT_SCHEMA = "openadapt.private-export-contract/v1" GITHUB_HOSTNAME = "github.com" PUBLIC_TRANSPARENCY_LOG = "https://rekor.sigstore.dev" @@ -234,16 +235,15 @@ "private_envelope_sha256", "store_attestation_sha256", "storage_identity_sha256", - "object_version_sha256", + "retention_commit", "private_locator_version_sha256", "kms_key_identity_sha256", "uploader_identity_sha256", - "retention_mode", - "retention_until", + "transparency_log_entry_sha256", "retained_at", - "upload_verified", - "head_verified", - "object_lock_verified", + "push_verified", + "commit_verified", + "transparency_logged", "private_locator_recorded", "acceptance_verified_at", "provenance_attestation", @@ -826,7 +826,7 @@ def production_acceptance_policy() -> dict[str, Any]: "minimum_trials_per_condition": 3, "excluded_trial_count": 0, "zero_failure_counts_required": sorted(_PRODUCTION_FAILURES), - "required_retention_mode": "COMPLIANCE", + "required_retention_medium": RETENTION_MEDIUM, "required_retention_provenance": RETENTION_PROVENANCE_ROUTE, "minimum_retention_days": 365, "maximum_retention_days": 3650, @@ -874,11 +874,11 @@ def file_sha256(path: Path) -> str: _RETENTION_DESTINATION_KEYS = { "account_id", "region", - "bucket", - "object_prefix", + "repository", + "ref", + "path_prefix", "kms_key_arn", - "retention_mode", - "retention_days", + "retention_commitment_days", } _PRIVATE_EXPORT_CONTRACT_KEYS = { "schema_version", @@ -893,8 +893,9 @@ def file_sha256(path: Path) -> str: ) _AWS_ACCOUNT_ID = re.compile(r"^[0-9]{12}$") _AWS_REGION = re.compile(r"^[a-z]{2}(-gov)?-[a-z]+-[0-9]$") -_S3_BUCKET = re.compile(r"^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$") -_S3_PREFIX = re.compile(r"^[A-Za-z0-9._/-]{1,256}$") +_REPOSITORY = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") +_GIT_REF = re.compile(r"^refs/heads/[A-Za-z0-9._/-]+$") +_PATH_PREFIX = re.compile(r"^[A-Za-z0-9._/-]{1,256}$") _KMS_KEY_ARN = re.compile( r"^arn:aws:kms:([a-z0-9-]+):([0-9]{12}):key/[A-Za-z0-9-]+$" ) @@ -938,23 +939,26 @@ def _validate_retention_destination(value: Any) -> dict[str, Any]: destination = _closed(value, _RETENTION_DESTINATION_KEYS, "retention destination") account_id = _nonempty(destination["account_id"], "retention destination account_id") region = _nonempty(destination["region"], "retention destination region") - bucket = _nonempty(destination["bucket"], "retention destination bucket") - prefix = _nonempty(destination["object_prefix"], "retention destination object_prefix") + repository = _nonempty(destination["repository"], "retention destination repository") + ref = _nonempty(destination["ref"], "retention destination ref") + prefix = _nonempty(destination["path_prefix"], "retention destination path_prefix") key_arn = _nonempty(destination["kms_key_arn"], "retention destination kms_key_arn") if _AWS_ACCOUNT_ID.fullmatch(account_id) is None: raise AcceptanceError("retention destination account is not an AWS account ID") if _AWS_REGION.fullmatch(region) is None: raise AcceptanceError("retention destination region is invalid") - if _S3_BUCKET.fullmatch(bucket) is None: - raise AcceptanceError("retention destination bucket is invalid") + if _REPOSITORY.fullmatch(repository) is None: + raise AcceptanceError("retention destination repository is invalid") + if _GIT_REF.fullmatch(ref) is None: + raise AcceptanceError("retention destination ref is invalid") if ( - _S3_PREFIX.fullmatch(prefix) is None + _PATH_PREFIX.fullmatch(prefix) is None or prefix.startswith("/") or prefix.endswith("/") or ".." in prefix or "//" in prefix ): - raise AcceptanceError("retention destination object prefix is invalid") + raise AcceptanceError("retention destination path prefix is invalid") key_match = _KMS_KEY_ARN.fullmatch(key_arn) if key_match is None: raise AcceptanceError("retention destination KMS key ARN is invalid") @@ -962,14 +966,14 @@ def _validate_retention_destination(value: Any) -> dict[str, Any]: raise AcceptanceError( "retention destination KMS key is outside the approved account or region" ) - if destination["retention_mode"] != "COMPLIANCE": - raise AcceptanceError("retention destination mode is not Object Lock COMPLIANCE") - days = destination["retention_days"] + days = destination["retention_commitment_days"] if not isinstance(days, int) or isinstance(days, bool): - raise AcceptanceError("retention destination retention_days must be an integer") + raise AcceptanceError( + "retention destination retention_commitment_days must be an integer" + ) policy = production_acceptance_policy() if not policy["minimum_retention_days"] <= days <= policy["maximum_retention_days"]: - raise AcceptanceError("retention destination retention period is outside policy") + raise AcceptanceError("retention destination retention commitment is outside policy") return dict(destination) @@ -1002,7 +1006,7 @@ def validate_private_export_contract(contract: Any) -> dict[str, Any]: "destination_approval_sha256": retention_destination_approval_sha256(destination), "storage_identity_sha256": retention_binding_sha256( "retention store", - destination["bucket"], + destination["repository"], ), "kms_key_identity_sha256": retention_binding_sha256( "retention KMS key", @@ -1013,7 +1017,7 @@ def validate_private_export_contract(contract: Any) -> dict[str, Any]: uploader_arn, ), "importer_workflow_ref": workflow_ref, - "retention_days": destination["retention_days"], + "retention_commitment_days": destination["retention_commitment_days"], "approval_authority": document["approval_authority"], "approved_at": approved_at, "contract_sha256": canonical_sha256(document), @@ -1044,8 +1048,13 @@ def verify_retention_against_contract( ) -> None: """Refuse unless the retained evidence went where the approval says. - Without this the certificate names its own destination, its own key, and its + Without this the certificate names its own repository, its own key, and its own uploader, and the importer only checks that those look like digests. + + There is no period check here, and that is deliberate rather than an + omission. A git commit carries no expiry, so retention_commitment_days is a + commitment this mechanism records and does not enforce. Enforcing a number + nothing can hold would be theatre. """ for key in ( @@ -1055,13 +1064,8 @@ def verify_retention_against_contract( ): if retention.get(key) != contract[key]: raise AcceptanceError(f"retained evidence {key} is not the approved identity") - retained_at = _timestamp(retention["retained_at"], "certificate retention retained_at") - expires_at = _timestamp( - retention["retention_until"], - "certificate retention retention_until", - ) - if expires_at - retained_at < timedelta(days=contract["retention_days"]): - raise AcceptanceError("retained evidence period is shorter than the approved contract") + + def opaque_binding_sha256(domain: str, value: str) -> str: payload = f"OpenAdapt acceptance {domain} v1\0".encode("utf-8") + value.encode( "utf-8" @@ -1938,22 +1942,25 @@ def _validate_certificate( "private_envelope_sha256", "store_attestation_sha256", "storage_identity_sha256", - "object_version_sha256", "private_locator_version_sha256", "kms_key_identity_sha256", "uploader_identity_sha256", + "transparency_log_entry_sha256", ): _digest(retention[key], f"certificate retention {key}") if not isinstance(retention["receipt_id"], str) or re.fullmatch( r"retention:[a-f0-9]{32}", retention["receipt_id"] ) is None: raise AcceptanceError("certificate retention receipt ID is invalid") - if retention["retention_mode"] != "COMPLIANCE": - raise AcceptanceError("certificate retention mode is not Object Lock COMPLIANCE") + if ( + not isinstance(retention["retention_commit"], str) + or _HEX_40.fullmatch(retention["retention_commit"]) is None + ): + raise AcceptanceError("certificate retention commit is not exact") for key in ( - "upload_verified", - "head_verified", - "object_lock_verified", + "push_verified", + "commit_verified", + "transparency_logged", "private_locator_recorded", ): if retention[key] is not True: @@ -1965,21 +1972,12 @@ def _validate_certificate( "certificate retention acceptance_verified_at", ) retained_at = _timestamp(retention["retained_at"], "certificate retention retained_at") - expires_at = _timestamp( - retention["retention_until"], - "certificate retention retention_until", - ) - retention_period = expires_at - retained_at - if not timedelta(days=365) <= retention_period <= timedelta(days=3650): - raise AcceptanceError("certificate Object Lock retention period is outside policy") - if not acceptance_verified_at <= retained_at < expires_at: + if not acceptance_verified_at <= retained_at: raise AcceptanceError("certificate retention chronology is invalid") if now.tzinfo is None: raise AcceptanceError("import time must include a timezone") - if now.astimezone(timezone.utc) >= expires_at: - raise AcceptanceError( - "certificate Object Lock retention has expired" - ) + if retained_at > now.astimezone(timezone.utc): + raise AcceptanceError("certificate retention is dated in the future") return { "product": {"cloud": dict(cloud), "flow": dict(flow), "managed_runtime": dict(runtime)}, @@ -3624,22 +3622,25 @@ def _validated_manifest_source(value: Mapping[str, Any]) -> dict[str, Any]: "private_envelope_sha256", "store_attestation_sha256", "storage_identity_sha256", - "object_version_sha256", "private_locator_version_sha256", "kms_key_identity_sha256", "uploader_identity_sha256", + "transparency_log_entry_sha256", ): _digest(retention[key], f"production acceptance source retention {key}") for key in ( - "upload_verified", - "head_verified", - "object_lock_verified", + "push_verified", + "commit_verified", + "transparency_logged", "private_locator_recorded", ): if retention[key] is not True: raise AcceptanceError(f"production acceptance source retention {key} is false") - if retention["retention_mode"] != "COMPLIANCE": - raise AcceptanceError("production acceptance source retention is not COMPLIANCE") + if ( + not isinstance(retention["retention_commit"], str) + or _HEX_40.fullmatch(retention["retention_commit"]) is None + ): + raise AcceptanceError("production acceptance source retention commit is not exact") if not isinstance(retention["receipt_id"], str) or re.fullmatch( r"retention:[a-f0-9]{32}", retention["receipt_id"] ) is None: @@ -3656,16 +3657,7 @@ def _validated_manifest_source(value: Mapping[str, Any]) -> dict[str, Any]: retention["retained_at"], "production acceptance source retention retained_at", ) - retention_until = _timestamp( - retention["retention_until"], - "production acceptance source retention retention_until", - ) - retention_period = retention_until - retained_at - if not timedelta(days=365) <= retention_period <= timedelta(days=3650): - raise AcceptanceError( - "production acceptance source retention period is outside policy" - ) - if not acceptance_verified_at <= retained_at < retention_until: + if not acceptance_verified_at <= retained_at: raise AcceptanceError("production acceptance source retention chronology is invalid") return source diff --git a/tests/fixtures/production_acceptance/live-certificate.json b/tests/fixtures/production_acceptance/live-certificate.json index d668f19..c29a00e 100644 --- a/tests/fixtures/production_acceptance/live-certificate.json +++ b/tests/fixtures/production_acceptance/live-certificate.json @@ -96,21 +96,20 @@ "acceptance_verified_at": "2026-08-18T12:00:00.000Z", "candidate_sha256": "sha256:6262626262626262626262626262626262626262626262626262626262626262", "ciphertext_sha256": "sha256:6161616161616161616161616161616161616161616161616161616161616161", - "head_verified": true, + "commit_verified": true, "kms_key_identity_sha256": "sha256:6868686868686868686868686868686868686868686868686868686868686868", - "object_lock_verified": true, - "object_version_sha256": "sha256:6666666666666666666666666666666666666666666666666666666666666666", "private_envelope_sha256": "sha256:6363636363636363636363636363636363636363636363636363636363636363", "private_locator_recorded": true, "private_locator_version_sha256": "sha256:6767676767676767676767676767676767676767676767676767676767676767", "provenance_attestation": "sigstore-public-good-slsa-provenance-v1", + "push_verified": true, "receipt_id": "retention:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "retained_at": "2026-08-18T12:01:00.000Z", - "retention_mode": "COMPLIANCE", - "retention_until": "2027-08-18T12:01:00.000Z", + "retention_commit": "ffffffffffffffffffffffffffffffffffffffff", "storage_identity_sha256": "sha256:6565656565656565656565656565656565656565656565656565656565656565", "store_attestation_sha256": "sha256:6464646464646464646464646464646464646464646464646464646464646464", - "upload_verified": true, + "transparency_log_entry_sha256": "sha256:6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a", + "transparency_logged": true, "uploader_identity_sha256": "sha256:6969696969696969696969696969696969696969696969696969696969696969" }, "schema_version": "openadapt.execute-live-acceptance-record/v2", diff --git a/tests/test_import_production_acceptance.py b/tests/test_import_production_acceptance.py index 5e9cbf5..ef635e4 100644 --- a/tests/test_import_production_acceptance.py +++ b/tests/test_import_production_acceptance.py @@ -966,11 +966,27 @@ def test_rejects_expired_object_lock_retention() -> None: _derive(now=expired) -def test_rejects_noncanonical_or_inconsistent_retention() -> None: +def test_rejects_retention_dated_before_acceptance() -> None: certificate = _certificate() - certificate["retention"]["retention_until"] = "2026-08-19T12:01:00.000Z" + certificate["retention"]["retained_at"] = "2026-08-18T11:59:59.000Z" - with pytest.raises(MODULE.AcceptanceError, match="outside policy"): + with pytest.raises(MODULE.AcceptanceError, match="chronology is invalid"): + _derive(certificate) + + +def test_rejects_retention_dated_in_the_future() -> None: + certificate = _certificate() + certificate["retention"]["retained_at"] = "2099-01-01T00:00:00.000Z" + + with pytest.raises(MODULE.AcceptanceError, match="dated in the future"): + _derive(certificate) + + +def test_rejects_an_inexact_retention_commit() -> None: + certificate = _certificate() + certificate["retention"]["retention_commit"] = "f" * 39 + + with pytest.raises(MODULE.AcceptanceError, match="retention commit is not exact"): _derive(certificate) @@ -2465,7 +2481,7 @@ def test_production_acceptance_target_scope_map_is_closed() -> None: "openadapt": "qualified_workflow_launcher_release", } assert MODULE.production_acceptance_policy_sha256() == ( - "sha256:bc013d6533b13288ae996b413519ac4b185da94ee0b1cc9727b662e04b989b31" + "sha256:77130f494453c2443ed824a863069bd055a831da483880386e95b092eabaf5bb" ) @@ -2942,29 +2958,17 @@ def test_private_source_reliability_schema_is_closed() -> None: "field,replacement,expected", [ ("receipt_id", "retention:not-a-receipt", "receipt ID"), - ("retention_mode", "GOVERNANCE", "not COMPLIANCE"), ("provenance_attestation", "unreviewed-v1", "provenance"), - ("head_verified", False, "head_verified is false"), + ("commit_verified", False, "commit_verified is false"), + ("push_verified", False, "push_verified is false"), + ("transparency_logged", False, "transparency_logged is false"), + ("retention_commit", "not-a-commit", "retention commit is not exact"), ( "acceptance_verified_at", "2026-08-18T12:00:00Z", "canonical millisecond UTC form", ), - ( - "retained_at", - "2026-08-18T11:59:59.000Z", - "chronology", - ), - ( - "retention_until", - "2026-08-19T12:01:00.000Z", - "period is outside policy", - ), - ( - "retention_until", - "2037-08-18T12:01:00.000Z", - "period is outside policy", - ), + ("retained_at", "2026-08-18T11:59:59.000Z", "chronology"), ], ) def test_private_source_retention_mutation_refuses( diff --git a/tests/test_private_export_contract.py b/tests/test_private_export_contract.py index 261942d..e967ffb 100644 --- a/tests/test_private_export_contract.py +++ b/tests/test_private_export_contract.py @@ -28,13 +28,13 @@ def _contract() -> dict[str, Any]: "destination": { "account_id": "123456789012", "region": "us-east-1", - "bucket": "openadapt-retained-evidence", - "object_prefix": "production-acceptance", + "repository": "OpenAdaptAI/openadapt-retained-evidence", + "ref": "refs/heads/main", + "path_prefix": "production-acceptance", "kms_key_arn": ( "arn:aws:kms:us-east-1:123456789012:key/1111-2222" ), - "retention_mode": "COMPLIANCE", - "retention_days": 2555, + "retention_commitment_days": 2555, }, "uploader_arn": "arn:aws:iam::123456789012:role/openadapt-retention-writer", "importer_workflow_ref": WORKFLOW_REF, @@ -60,7 +60,7 @@ def cloud(domain: str, value: str) -> str: return "sha256:" + hashlib.sha256(payload).hexdigest() assert facts["storage_identity_sha256"] == cloud( - "retention store", destination["bucket"] + "retention store", destination["repository"] ) assert facts["kms_key_identity_sha256"] == cloud( "retention KMS key", destination["kms_key_arn"] @@ -82,10 +82,10 @@ def test_the_retention_separator_is_not_the_acceptance_separator() -> None: exactly the defect this file exists to prevent recurring. """ - bucket = _contract()["destination"]["bucket"] + repository = _contract()["destination"]["repository"] - assert MODULE.retention_binding_sha256("retention store", bucket) != ( - MODULE.opaque_binding_sha256("retention store", bucket) + assert MODULE.retention_binding_sha256("retention store", repository) != ( + MODULE.opaque_binding_sha256("retention store", repository) ) @@ -118,10 +118,11 @@ def test_the_contract_carries_values_not_digests() -> None: (lambda c: c["destination"].pop("region"), "retention destination keys differ"), (lambda c: c["destination"].__setitem__("account_id", "12345"), "AWS account ID"), (lambda c: c["destination"].__setitem__("region", "nowhere"), "region is invalid"), - (lambda c: c["destination"].__setitem__("bucket", "Bad_Bucket"), "bucket is invalid"), - (lambda c: c["destination"].__setitem__("object_prefix", "/x"), "object prefix"), - (lambda c: c["destination"].__setitem__("object_prefix", "a//b"), "object prefix"), - (lambda c: c["destination"].__setitem__("object_prefix", "a/../b"), "object prefix"), + (lambda c: c["destination"].__setitem__("repository", "nope"), "repository is invalid"), + (lambda c: c["destination"].__setitem__("ref", "refs/tags/v1"), "ref is invalid"), + (lambda c: c["destination"].__setitem__("path_prefix", "/x"), "path prefix"), + (lambda c: c["destination"].__setitem__("path_prefix", "a//b"), "path prefix"), + (lambda c: c["destination"].__setitem__("path_prefix", "a/../b"), "path prefix"), (lambda c: c["destination"].__setitem__("kms_key_arn", "arn:aws:kms:x"), "KMS key ARN"), # The key must live in the approved account and region, the same rule # the Cloud writer enforces before it retains anything. @@ -137,10 +138,9 @@ def test_the_contract_carries_values_not_digests() -> None: ), "outside the approved account or region", ), - (lambda c: c["destination"].__setitem__("retention_mode", "GOVERNANCE"), "COMPLIANCE"), - (lambda c: c["destination"].__setitem__("retention_days", 30), "outside policy"), - (lambda c: c["destination"].__setitem__("retention_days", 4000), "outside policy"), - (lambda c: c["destination"].__setitem__("retention_days", "2555"), "must be an integer"), + (lambda c: c["destination"].__setitem__("retention_commitment_days", 30), "outside policy"), + (lambda c: c["destination"].__setitem__("retention_commitment_days", 4000), "outside policy"), + (lambda c: c["destination"].__setitem__("retention_commitment_days", "2555"), "must be an integer"), (lambda c: c.__setitem__("uploader_arn", "not-an-arn"), "uploader ARN is invalid"), ( lambda c: c.__setitem__( @@ -163,8 +163,8 @@ def test_retention_days_must_sit_inside_the_fixed_policy() -> None: policy = MODULE.production_acceptance_policy() facts = MODULE.validate_private_export_contract(_contract()) - assert policy["minimum_retention_days"] <= facts["retention_days"] - assert facts["retention_days"] <= policy["maximum_retention_days"] + assert policy["minimum_retention_days"] <= facts["retention_commitment_days"] + assert facts["retention_commitment_days"] <= policy["maximum_retention_days"] def test_importer_identity_must_be_the_approved_workflow_and_ref() -> None: @@ -230,7 +230,7 @@ def test_retention_must_match_the_approved_destination_key_and_uploader() -> Non def test_a_certificate_cannot_choose_its_own_destination() -> None: facts = MODULE.validate_private_export_contract(_contract()) elsewhere = copy.deepcopy(_contract()) - elsewhere["destination"]["bucket"] = "attacker-bucket" + elsewhere["destination"]["repository"] = "attacker/evidence" forged = MODULE.validate_private_export_contract(elsewhere) retention = _retention(facts) @@ -240,23 +240,27 @@ def test_a_certificate_cannot_choose_its_own_destination() -> None: MODULE.verify_retention_against_contract(retention, facts) -def test_a_shorter_retention_period_than_approved_is_refused() -> None: - facts = MODULE.validate_private_export_contract(_contract()) - retention = _retention(facts) - retention["retention_until"] = "2026-09-18T12:00:00.000Z" - - with pytest.raises(MODULE.AcceptanceError, match="shorter than the approved"): - MODULE.verify_retention_against_contract(retention, facts) - +def test_the_commitment_is_recorded_and_not_enforced() -> None: + """A git commit has no expiry, so the period is a commitment, not a lock. -def test_a_longer_retention_period_than_approved_is_allowed() -> None: - """An approval sets a floor. Locking evidence for longer is never a fault.""" + Enforcing a number nothing can hold would be theatre. The contract records + retention_commitment_days and the verifier does not police it; what it does + police is that the evidence went to the approved repository, key and + uploader. + """ facts = MODULE.validate_private_export_contract(_contract()) - retention = _retention(facts) - retention["retention_until"] = "2044-08-18T12:00:00.000Z" - MODULE.verify_retention_against_contract(retention, facts) + assert facts["retention_commitment_days"] == 2555 + # No period is compared, so no retained_at or retention_until is needed. + MODULE.verify_retention_against_contract( + { + "storage_identity_sha256": facts["storage_identity_sha256"], + "kms_key_identity_sha256": facts["kms_key_identity_sha256"], + "uploader_identity_sha256": facts["uploader_identity_sha256"], + }, + facts, + ) def test_cli_refuses_a_contract_it_is_not_authorised_to_use( From 5fec7e82b91bda6a5f07876b49d75e76419171a1 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 15:12:31 -0400 Subject: [PATCH 2/4] docs(evidence): name the evidence repository and retire the S3 language openadapt-evidence exists and is empty by design, so the repository and path prefix are no longer blanks. The readiness bar, the four evidence inputs, and the retention description still described an S3 bucket with Object Lock and a GitHub artifact attestation; both were replaced this cycle. Co-Authored-By: Claude Opus 5 --- docs/eval_results/PRIVATE_EXPORT_CONTRACT.md | 14 ++++++++++---- docs/eval_results/PRODUCTION_READINESS.md | 14 ++++++++------ .../private-export-contract.template.json | 4 ++-- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md b/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md index 29f9568..88be08d 100644 --- a/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md +++ b/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md @@ -13,6 +13,12 @@ below are blank. > workflow and ref, and the approval authority. A broad enable flag or approval > of only the admission and campaign digests is not sufficient. +That bar was written when the store was an S3 bucket with Object Lock. The store +is now a signed git commit, so "container and prefix" reads as repository and +path prefix, and "immutable retention mode and period" reads as the transparency +log entry plus a recorded commitment. Every other clause holds unchanged. The +next section explains why the medium changed. + ## Why an approval is needed at all, and what it fixes The certificate already carries a `retention` block, and the importer already @@ -62,16 +68,16 @@ account root. ## The bindings The approval fixes six destination fields plus the uploader, the importer, and -the authority. **Every value is blank in this repository.** They are deployment -facts, and only the account owner can supply them. +the authority. The store and its path are settled: `openadapt-evidence` exists +and is empty by design. The remaining blanks are AWS facts and your signature. | Contract field | What it fixes | Value | | --- | --- | --- | | `destination.account_id` | the AWS account holding the KMS key | **TO BE SUPPLIED** | | `destination.region` | the region of that key | **TO BE SUPPLIED** | -| `destination.repository` | the private repository holding the ciphertext | **TO BE SUPPLIED** | +| `destination.repository` | the private repository holding the ciphertext | `OpenAdaptAI/openadapt-evidence` | | `destination.ref` | the branch it lands on | `refs/heads/main` | -| `destination.path_prefix` | the path inside it | **TO BE SUPPLIED** | +| `destination.path_prefix` | the path inside it | `production-acceptance` | | `destination.kms_key_arn` | the key wrapping each data key | **TO BE SUPPLIED** | | `destination.retention_commitment_days` | how long you commit to keep it | default 2555 | | `uploader_arn` | the principal allowed to write | **TO BE SUPPLIED** | diff --git a/docs/eval_results/PRODUCTION_READINESS.md b/docs/eval_results/PRODUCTION_READINESS.md index 4d43bdf..c9f3351 100644 --- a/docs/eval_results/PRODUCTION_READINESS.md +++ b/docs/eval_results/PRODUCTION_READINESS.md @@ -25,9 +25,9 @@ repository. Do not set `production_acceptance: true` while this gate is closed. The checked-in fixtures are synthetic test vectors only. The future approval must bind the complete payload schema and digest, the -destination account, service, container, and prefix, the encryption-key -identity, the immutable retention mode and period, the authorized importer -workflow and ref, and the approval authority. A broad enable flag or approval +destination account, repository, ref, and path prefix, the encryption-key +identity, the recorded retention commitment, the authorized importer workflow +and ref, and the approval authority. A broad enable flag or approval of only the admission and campaign digests is not sufficient. After approval, the importer will compose four evidence inputs: @@ -39,7 +39,8 @@ After approval, the importer will compose four evidence inputs: 3. The full `openadapt.qualification-campaign/v2` artifact with every retained trial row and normalized evidence receipt for the exact qualification contract. -4. The GitHub artifact-attestation bundle for the certificate bytes. +4. The Sigstore bundle signing the certificate bytes on the public-good + instance. The future importer also needs external control inputs: the approved Cloud source commit, the approved qualification signer registry, the admission and @@ -219,8 +220,9 @@ that output. Until then, the importer refuses. A bare registry boolean or campaign label fails the check. Cloud does not issue a complete acceptance record before it verifies durable -retention. The private evidence envelope uses encrypted immutable storage with -Object Lock and KMS. The public record retains only opaque digests and the +retention. The private evidence envelope is encrypted with an AES-256-GCM data +key wrapped by KMS, then committed to a private repository and signed on the +Sigstore public-good instance. The public record retains only opaque digests and the verified retention facts. The importer verifies the public retention receipt and its exact binding to the candidate and retained envelope. GitHub artifact retention alone does not satisfy this contract. diff --git a/docs/eval_results/private-export-contract.template.json b/docs/eval_results/private-export-contract.template.json index f58b912..299edf0 100644 --- a/docs/eval_results/private-export-contract.template.json +++ b/docs/eval_results/private-export-contract.template.json @@ -22,9 +22,9 @@ "destination": { "account_id": "FILL_AWS_ACCOUNT_ID", "region": "FILL_AWS_REGION", - "repository": "FILL_EVIDENCE_REPOSITORY", + "repository": "OpenAdaptAI/openadapt-evidence", "ref": "refs/heads/main", - "path_prefix": "FILL_EVIDENCE_PATH_PREFIX", + "path_prefix": "production-acceptance", "kms_key_arn": "FILL_DATA_KEY_KMS_ARN", "retention_commitment_days": 2555 }, From 9867ddfe4d54b9509552596887a63cdd9bedf586 Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 15:56:38 -0400 Subject: [PATCH 3/4] feat(evidence)!: encrypt to an age recipient, drop AWS from the contract AWS was in this design for one reason: a KMS key to wrap the data key. That reason is gone. Identity Center on 992382684924 has no user for the founder; its five members are an external vendor, and the only IAM users are claude-ops and a vendor CLI. There was no admin identity to provision a key with. The contract destination becomes repository, ref, path_prefix, encryption_recipient and retention_commitment_days. account_id, region and kms_key_arn are gone, along with the ARN validators. kms_key_identity_sha256 becomes encryption_recipient_sha256 under the domain 'retention encryption recipient'. uploader_arn becomes uploader_identity under 'retention uploader'. The Cloud writer changed in the same commit range. The recipient is public by design and is filled into the template. The private half is in the founder's login keychain and never reaches CI, so the writer can encrypt and nothing in any workflow can decrypt. The hashed policy digest is unchanged; the retention medium did not move. 324 tests pass. ruff clean. Co-Authored-By: Claude Opus 5 --- .../private-export-contract.template.json | 26 +++----- scripts/import_production_acceptance.py | 65 ++++++++----------- .../live-certificate.json | 2 +- tests/test_private_export_contract.py | 50 ++++---------- 4 files changed, 49 insertions(+), 94 deletions(-) diff --git a/docs/eval_results/private-export-contract.template.json b/docs/eval_results/private-export-contract.template.json index 299edf0..2f0a80c 100644 --- a/docs/eval_results/private-export-contract.template.json +++ b/docs/eval_results/private-export-contract.template.json @@ -2,34 +2,26 @@ "_README": [ "TEMPLATE. This file is not an approval and the importer will refuse it.", "Copy it into openadapt-internal, replace every FILL_ value, delete this", - "_README key, and approve the result there. This public repository keeps", - "the shape; the filled instance and its values stay private.", + "_README key, and approve the result there.", "", - "The retained evidence is a signed git commit, not an S3 object. The Cloud", - "writer still encrypts the envelope client-side with an AES-256-GCM data", - "key wrapped by KMS, so the repository holds ciphertext and GitHub cannot", - "read it. The KMS key is the only part that still costs money.", + "The evidence is encrypted to an age recipient before it is committed, so", + "the repository holds ciphertext. The private half lives in the founder's", + "keychain and never reaches CI: the writer only encrypts, so nothing in any", + "workflow can read the evidence back.", "", "retention_commitment_days is a commitment this mechanism records and does", - "not enforce. A git commit carries no expiry. Nothing here pretends it does.", - "", - "Validate a filled copy with:", - " python scripts/import_production_acceptance.py --private-export-contract ...", - "It derives every digest from the values below, so a reviewer recomputes", - "each one rather than trusting an asserted hash." + "not enforce. A git commit carries no expiry." ], "schema_version": "openadapt.private-export-contract/v1", "destination": { - "account_id": "FILL_AWS_ACCOUNT_ID", - "region": "FILL_AWS_REGION", "repository": "OpenAdaptAI/openadapt-evidence", "ref": "refs/heads/main", "path_prefix": "production-acceptance", - "kms_key_arn": "FILL_DATA_KEY_KMS_ARN", + "encryption_recipient": "age19gxzh0n7d6r8hdwpdfvuwxgc46gtcq0fd298nq2v8puqprtphetsg7sdh2", "retention_commitment_days": 2555 }, - "uploader_arn": "FILL_UPLOADER_ROLE_OR_USER_ARN", "importer_workflow_ref": "OpenAdaptAI/openadapt-evals/.github/workflows/import-production-acceptance.yml@refs/heads/main", "approval_authority": "FILL_APPROVAL_AUTHORITY", - "approved_at": "FILL_APPROVED_AT" + "approved_at": "FILL_APPROVED_AT", + "uploader_identity": "FILL_PUSHING_IDENTITY" } diff --git a/scripts/import_production_acceptance.py b/scripts/import_production_acceptance.py index fceb2ff..0947208 100644 --- a/scripts/import_production_acceptance.py +++ b/scripts/import_production_acceptance.py @@ -237,7 +237,7 @@ "storage_identity_sha256", "retention_commit", "private_locator_version_sha256", - "kms_key_identity_sha256", + "encryption_recipient_sha256", "uploader_identity_sha256", "transparency_log_entry_sha256", "retained_at", @@ -872,18 +872,16 @@ def file_sha256(path: Path) -> str: _RETENTION_DESTINATION_KEYS = { - "account_id", - "region", "repository", "ref", "path_prefix", - "kms_key_arn", + "encryption_recipient", "retention_commitment_days", } _PRIVATE_EXPORT_CONTRACT_KEYS = { "schema_version", "destination", - "uploader_arn", + "uploader_identity", "importer_workflow_ref", "approval_authority", "approved_at", @@ -891,15 +889,13 @@ def file_sha256(path: Path) -> str: _IMPORTER_WORKFLOW_REF = re.compile( r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+/\.github/workflows/[A-Za-z0-9._-]+\.ya?ml@refs/heads/[A-Za-z0-9._/-]+$" ) -_AWS_ACCOUNT_ID = re.compile(r"^[0-9]{12}$") -_AWS_REGION = re.compile(r"^[a-z]{2}(-gov)?-[a-z]+-[0-9]$") _REPOSITORY = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") _GIT_REF = re.compile(r"^refs/heads/[A-Za-z0-9._/-]+$") _PATH_PREFIX = re.compile(r"^[A-Za-z0-9._/-]{1,256}$") -_KMS_KEY_ARN = re.compile( - r"^arn:aws:kms:([a-z0-9-]+):([0-9]{12}):key/[A-Za-z0-9-]+$" -) -_UPLOADER_ARN = re.compile(r"^arn:aws:(iam|sts)::[0-9]{12}:[A-Za-z0-9+=,.@_/-]+$") +# An age X25519 recipient. The private half never reaches CI: the writer only +# ever encrypts, so nothing in any workflow can read the evidence back. +_AGE_RECIPIENT = re.compile(r"^age1[0-9a-z]{58}$") +_UPLOADER_IDENTITY = re.compile(r"^[A-Za-z0-9._/\[\]-]{1,128}$") def retention_binding_sha256(domain: str, value: str) -> str: @@ -937,16 +933,13 @@ def retention_destination_approval_sha256(destination: Mapping[str, Any]) -> str def _validate_retention_destination(value: Any) -> dict[str, Any]: destination = _closed(value, _RETENTION_DESTINATION_KEYS, "retention destination") - account_id = _nonempty(destination["account_id"], "retention destination account_id") - region = _nonempty(destination["region"], "retention destination region") repository = _nonempty(destination["repository"], "retention destination repository") ref = _nonempty(destination["ref"], "retention destination ref") prefix = _nonempty(destination["path_prefix"], "retention destination path_prefix") - key_arn = _nonempty(destination["kms_key_arn"], "retention destination kms_key_arn") - if _AWS_ACCOUNT_ID.fullmatch(account_id) is None: - raise AcceptanceError("retention destination account is not an AWS account ID") - if _AWS_REGION.fullmatch(region) is None: - raise AcceptanceError("retention destination region is invalid") + recipient = _nonempty( + destination["encryption_recipient"], + "retention destination encryption_recipient", + ) if _REPOSITORY.fullmatch(repository) is None: raise AcceptanceError("retention destination repository is invalid") if _GIT_REF.fullmatch(ref) is None: @@ -959,13 +952,8 @@ def _validate_retention_destination(value: Any) -> dict[str, Any]: or "//" in prefix ): raise AcceptanceError("retention destination path prefix is invalid") - key_match = _KMS_KEY_ARN.fullmatch(key_arn) - if key_match is None: - raise AcceptanceError("retention destination KMS key ARN is invalid") - if key_match.group(1) != region or key_match.group(2) != account_id: - raise AcceptanceError( - "retention destination KMS key is outside the approved account or region" - ) + if _AGE_RECIPIENT.fullmatch(recipient) is None: + raise AcceptanceError("retention destination encryption recipient is invalid") days = destination["retention_commitment_days"] if not isinstance(days, int) or isinstance(days, bool): raise AcceptanceError( @@ -989,11 +977,12 @@ def validate_private_export_contract(contract: Any) -> dict[str, Any]: if document["schema_version"] != PRIVATE_EXPORT_CONTRACT_SCHEMA: raise AcceptanceError("private export contract schema is not supported") destination = _validate_retention_destination(document["destination"]) - uploader_arn = _nonempty(document["uploader_arn"], "private export contract uploader_arn") - if _UPLOADER_ARN.fullmatch(uploader_arn) is None: - raise AcceptanceError("private export contract uploader ARN is invalid") - if uploader_arn.split(":")[4] != destination["account_id"]: - raise AcceptanceError("private export contract uploader is outside the approved account") + uploader_identity = _nonempty( + document["uploader_identity"], + "private export contract uploader_identity", + ) + if _UPLOADER_IDENTITY.fullmatch(uploader_identity) is None: + raise AcceptanceError("private export contract uploader identity is invalid") workflow_ref = document["importer_workflow_ref"] if ( not isinstance(workflow_ref, str) @@ -1008,13 +997,13 @@ def validate_private_export_contract(contract: Any) -> dict[str, Any]: "retention store", destination["repository"], ), - "kms_key_identity_sha256": retention_binding_sha256( - "retention KMS key", - destination["kms_key_arn"], + "encryption_recipient_sha256": retention_binding_sha256( + "retention encryption recipient", + destination["encryption_recipient"], ), "uploader_identity_sha256": retention_binding_sha256( - "AWS retention uploader", - uploader_arn, + "retention uploader", + uploader_identity, ), "importer_workflow_ref": workflow_ref, "retention_commitment_days": destination["retention_commitment_days"], @@ -1059,7 +1048,7 @@ def verify_retention_against_contract( for key in ( "storage_identity_sha256", - "kms_key_identity_sha256", + "encryption_recipient_sha256", "uploader_identity_sha256", ): if retention.get(key) != contract[key]: @@ -1943,7 +1932,7 @@ def _validate_certificate( "store_attestation_sha256", "storage_identity_sha256", "private_locator_version_sha256", - "kms_key_identity_sha256", + "encryption_recipient_sha256", "uploader_identity_sha256", "transparency_log_entry_sha256", ): @@ -3623,7 +3612,7 @@ def _validated_manifest_source(value: Mapping[str, Any]) -> dict[str, Any]: "store_attestation_sha256", "storage_identity_sha256", "private_locator_version_sha256", - "kms_key_identity_sha256", + "encryption_recipient_sha256", "uploader_identity_sha256", "transparency_log_entry_sha256", ): diff --git a/tests/fixtures/production_acceptance/live-certificate.json b/tests/fixtures/production_acceptance/live-certificate.json index c29a00e..36a2580 100644 --- a/tests/fixtures/production_acceptance/live-certificate.json +++ b/tests/fixtures/production_acceptance/live-certificate.json @@ -97,7 +97,7 @@ "candidate_sha256": "sha256:6262626262626262626262626262626262626262626262626262626262626262", "ciphertext_sha256": "sha256:6161616161616161616161616161616161616161616161616161616161616161", "commit_verified": true, - "kms_key_identity_sha256": "sha256:6868686868686868686868686868686868686868686868686868686868686868", + "encryption_recipient_sha256": "sha256:6868686868686868686868686868686868686868686868686868686868686868", "private_envelope_sha256": "sha256:6363636363636363636363636363636363636363636363636363636363636363", "private_locator_recorded": true, "private_locator_version_sha256": "sha256:6767676767676767676767676767676767676767676767676767676767676767", diff --git a/tests/test_private_export_contract.py b/tests/test_private_export_contract.py index e967ffb..0893443 100644 --- a/tests/test_private_export_contract.py +++ b/tests/test_private_export_contract.py @@ -26,17 +26,13 @@ def _contract() -> dict[str, Any]: return { "schema_version": MODULE.PRIVATE_EXPORT_CONTRACT_SCHEMA, "destination": { - "account_id": "123456789012", - "region": "us-east-1", - "repository": "OpenAdaptAI/openadapt-retained-evidence", + "repository": "OpenAdaptAI/openadapt-evidence", "ref": "refs/heads/main", "path_prefix": "production-acceptance", - "kms_key_arn": ( - "arn:aws:kms:us-east-1:123456789012:key/1111-2222" - ), + "encryption_recipient": "age19gxzh0n7d6r8hdwpdfvuwxgc46gtcq0fd298nq2v8puqprtphetsg7sdh2", "retention_commitment_days": 2555, }, - "uploader_arn": "arn:aws:iam::123456789012:role/openadapt-retention-writer", + "uploader_identity": "openadapt-release[bot]", "importer_workflow_ref": WORKFLOW_REF, "approval_authority": "OpenAdapt", "approved_at": "2026-08-26T12:00:00.000Z", @@ -62,11 +58,11 @@ def cloud(domain: str, value: str) -> str: assert facts["storage_identity_sha256"] == cloud( "retention store", destination["repository"] ) - assert facts["kms_key_identity_sha256"] == cloud( - "retention KMS key", destination["kms_key_arn"] + assert facts["encryption_recipient_sha256"] == cloud( + "retention encryption recipient", destination["encryption_recipient"] ) assert facts["uploader_identity_sha256"] == cloud( - "AWS retention uploader", contract["uploader_arn"] + "retention uploader", contract["uploader_identity"] ) assert facts["destination_approval_sha256"] == cloud( "Execute acceptance retention destination", @@ -115,39 +111,17 @@ def test_the_contract_carries_values_not_digests() -> None: ), "workflow ref is invalid", ), - (lambda c: c["destination"].pop("region"), "retention destination keys differ"), - (lambda c: c["destination"].__setitem__("account_id", "12345"), "AWS account ID"), - (lambda c: c["destination"].__setitem__("region", "nowhere"), "region is invalid"), (lambda c: c["destination"].__setitem__("repository", "nope"), "repository is invalid"), (lambda c: c["destination"].__setitem__("ref", "refs/tags/v1"), "ref is invalid"), (lambda c: c["destination"].__setitem__("path_prefix", "/x"), "path prefix"), (lambda c: c["destination"].__setitem__("path_prefix", "a//b"), "path prefix"), (lambda c: c["destination"].__setitem__("path_prefix", "a/../b"), "path prefix"), - (lambda c: c["destination"].__setitem__("kms_key_arn", "arn:aws:kms:x"), "KMS key ARN"), - # The key must live in the approved account and region, the same rule - # the Cloud writer enforces before it retains anything. - ( - lambda c: c["destination"].__setitem__( - "kms_key_arn", "arn:aws:kms:eu-west-1:123456789012:key/1111" - ), - "outside the approved account or region", - ), - ( - lambda c: c["destination"].__setitem__( - "kms_key_arn", "arn:aws:kms:us-east-1:999999999999:key/1111" - ), - "outside the approved account or region", - ), (lambda c: c["destination"].__setitem__("retention_commitment_days", 30), "outside policy"), (lambda c: c["destination"].__setitem__("retention_commitment_days", 4000), "outside policy"), (lambda c: c["destination"].__setitem__("retention_commitment_days", "2555"), "must be an integer"), - (lambda c: c.__setitem__("uploader_arn", "not-an-arn"), "uploader ARN is invalid"), - ( - lambda c: c.__setitem__( - "uploader_arn", "arn:aws:iam::999999999999:role/other" - ), - "uploader is outside the approved account", - ), + (lambda c: c.__setitem__("uploader_identity", "bad identity!"), "uploader identity is invalid"), + (lambda c: c["destination"].__setitem__("encryption_recipient", "age1short"), "encryption recipient is invalid"), + (lambda c: c["destination"].pop("ref"), "retention destination keys differ"), (lambda c: c.__setitem__("approved_at", "2026-08-26T12:00:00Z"), "canonical"), ], ) @@ -199,7 +173,7 @@ def test_importer_identity_must_be_the_approved_workflow_and_ref() -> None: def _retention(facts: dict[str, Any]) -> dict[str, Any]: return { "storage_identity_sha256": facts["storage_identity_sha256"], - "kms_key_identity_sha256": facts["kms_key_identity_sha256"], + "encryption_recipient_sha256": facts["encryption_recipient_sha256"], "uploader_identity_sha256": facts["uploader_identity_sha256"], "retained_at": "2026-08-18T12:00:00.000Z", "retention_until": "2034-08-18T12:00:00.000Z", @@ -213,7 +187,7 @@ def test_retention_must_match_the_approved_destination_key_and_uploader() -> Non for key in ( "storage_identity_sha256", - "kms_key_identity_sha256", + "encryption_recipient_sha256", "uploader_identity_sha256", ): retention = _retention(facts) @@ -256,7 +230,7 @@ def test_the_commitment_is_recorded_and_not_enforced() -> None: MODULE.verify_retention_against_contract( { "storage_identity_sha256": facts["storage_identity_sha256"], - "kms_key_identity_sha256": facts["kms_key_identity_sha256"], + "encryption_recipient_sha256": facts["encryption_recipient_sha256"], "uploader_identity_sha256": facts["uploader_identity_sha256"], }, facts, From 489b2626870224cbbcaa1de1448b7082863d0f4c Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 16:45:33 -0400 Subject: [PATCH 4/4] fix(evidence): follow the verified-certificate retention receipt Matches the Cloud writer after the three defects from the #330 review were fixed. The receipt drops retention_commit, transparency_log_entry_sha256 and the three assertion booleans, and gains visibility_verified. retention_commit went because it forced a circularity: a certificate cannot name the commit it is not yet inside, which is what pushed it into a second unsigned commit. The certificate is now signed and verified before it is committed, so the commit is a delivery detail rather than something the receipt must name. The booleans went because a verified Sigstore bundle is the claim. A field asserting transparency_logged: true proves nothing the writer could not have written anyway. 320 tests pass. ruff clean. The hashed policy digest is unchanged. Co-Authored-By: Claude Opus 5 --- scripts/import_production_acceptance.py | 29 ++----------------- .../live-certificate.json | 8 ++--- tests/test_import_production_acceptance.py | 12 +------- 3 files changed, 6 insertions(+), 43 deletions(-) diff --git a/scripts/import_production_acceptance.py b/scripts/import_production_acceptance.py index 0947208..ab4c42e 100644 --- a/scripts/import_production_acceptance.py +++ b/scripts/import_production_acceptance.py @@ -235,15 +235,11 @@ "private_envelope_sha256", "store_attestation_sha256", "storage_identity_sha256", - "retention_commit", "private_locator_version_sha256", "encryption_recipient_sha256", "uploader_identity_sha256", - "transparency_log_entry_sha256", "retained_at", - "push_verified", - "commit_verified", - "transparency_logged", + "visibility_verified", "private_locator_recorded", "acceptance_verified_at", "provenance_attestation", @@ -1934,22 +1930,14 @@ def _validate_certificate( "private_locator_version_sha256", "encryption_recipient_sha256", "uploader_identity_sha256", - "transparency_log_entry_sha256", ): _digest(retention[key], f"certificate retention {key}") if not isinstance(retention["receipt_id"], str) or re.fullmatch( r"retention:[a-f0-9]{32}", retention["receipt_id"] ) is None: raise AcceptanceError("certificate retention receipt ID is invalid") - if ( - not isinstance(retention["retention_commit"], str) - or _HEX_40.fullmatch(retention["retention_commit"]) is None - ): - raise AcceptanceError("certificate retention commit is not exact") for key in ( - "push_verified", - "commit_verified", - "transparency_logged", + "visibility_verified", "private_locator_recorded", ): if retention[key] is not True: @@ -3614,22 +3602,11 @@ def _validated_manifest_source(value: Mapping[str, Any]) -> dict[str, Any]: "private_locator_version_sha256", "encryption_recipient_sha256", "uploader_identity_sha256", - "transparency_log_entry_sha256", ): _digest(retention[key], f"production acceptance source retention {key}") - for key in ( - "push_verified", - "commit_verified", - "transparency_logged", - "private_locator_recorded", - ): + for key in ("visibility_verified", "private_locator_recorded"): if retention[key] is not True: raise AcceptanceError(f"production acceptance source retention {key} is false") - if ( - not isinstance(retention["retention_commit"], str) - or _HEX_40.fullmatch(retention["retention_commit"]) is None - ): - raise AcceptanceError("production acceptance source retention commit is not exact") if not isinstance(retention["receipt_id"], str) or re.fullmatch( r"retention:[a-f0-9]{32}", retention["receipt_id"] ) is None: diff --git a/tests/fixtures/production_acceptance/live-certificate.json b/tests/fixtures/production_acceptance/live-certificate.json index 36a2580..dbc88dc 100644 --- a/tests/fixtures/production_acceptance/live-certificate.json +++ b/tests/fixtures/production_acceptance/live-certificate.json @@ -96,21 +96,17 @@ "acceptance_verified_at": "2026-08-18T12:00:00.000Z", "candidate_sha256": "sha256:6262626262626262626262626262626262626262626262626262626262626262", "ciphertext_sha256": "sha256:6161616161616161616161616161616161616161616161616161616161616161", - "commit_verified": true, "encryption_recipient_sha256": "sha256:6868686868686868686868686868686868686868686868686868686868686868", "private_envelope_sha256": "sha256:6363636363636363636363636363636363636363636363636363636363636363", "private_locator_recorded": true, "private_locator_version_sha256": "sha256:6767676767676767676767676767676767676767676767676767676767676767", "provenance_attestation": "sigstore-public-good-slsa-provenance-v1", - "push_verified": true, "receipt_id": "retention:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "retained_at": "2026-08-18T12:01:00.000Z", - "retention_commit": "ffffffffffffffffffffffffffffffffffffffff", "storage_identity_sha256": "sha256:6565656565656565656565656565656565656565656565656565656565656565", "store_attestation_sha256": "sha256:6464646464646464646464646464646464646464646464646464646464646464", - "transparency_log_entry_sha256": "sha256:6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a", - "transparency_logged": true, - "uploader_identity_sha256": "sha256:6969696969696969696969696969696969696969696969696969696969696969" + "uploader_identity_sha256": "sha256:6969696969696969696969696969696969696969696969696969696969696969", + "visibility_verified": true }, "schema_version": "openadapt.execute-live-acceptance-record/v2", "transaction": { diff --git a/tests/test_import_production_acceptance.py b/tests/test_import_production_acceptance.py index ef635e4..bb21982 100644 --- a/tests/test_import_production_acceptance.py +++ b/tests/test_import_production_acceptance.py @@ -982,13 +982,6 @@ def test_rejects_retention_dated_in_the_future() -> None: _derive(certificate) -def test_rejects_an_inexact_retention_commit() -> None: - certificate = _certificate() - certificate["retention"]["retention_commit"] = "f" * 39 - - with pytest.raises(MODULE.AcceptanceError, match="retention commit is not exact"): - _derive(certificate) - @pytest.mark.parametrize( "route", @@ -2959,10 +2952,7 @@ def test_private_source_reliability_schema_is_closed() -> None: [ ("receipt_id", "retention:not-a-receipt", "receipt ID"), ("provenance_attestation", "unreviewed-v1", "provenance"), - ("commit_verified", False, "commit_verified is false"), - ("push_verified", False, "push_verified is false"), - ("transparency_logged", False, "transparency_logged is false"), - ("retention_commit", "not-a-commit", "retention commit is not exact"), + ("visibility_verified", False, "visibility_verified is false"), ( "acceptance_verified_at", "2026-08-18T12:00:00Z",