From 70ace5a26312978343ed697f9d15e217c4169d3a Mon Sep 17 00:00:00 2001 From: abrichr Date: Thu, 27 Aug 2026 14:12:47 -0400 Subject: [PATCH] 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(