diff --git a/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md b/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md index 10faafa..88be08d 100644 --- a/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md +++ b/docs/eval_results/PRIVATE_EXPORT_CONTRACT.md @@ -13,19 +13,24 @@ 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 -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 +38,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 authority. **Every value is blank in this repository.** They are deployment -facts, and only the account owner can supply them. +The approval fixes six destination fields plus the uploader, the importer, and +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 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 | `OpenAdaptAI/openadapt-evidence` | +| `destination.ref` | the branch it lands on | `refs/heads/main` | +| `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** | | `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 +137,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 +153,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/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 fa318fc..2f0a80c 100644 --- a/docs/eval_results/private-export-contract.template.json +++ b/docs/eval_results/private-export-contract.template.json @@ -2,31 +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 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 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.", "", - "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." + "retention_commitment_days is a commitment this mechanism records and does", + "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", - "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": "OpenAdaptAI/openadapt-evidence", + "ref": "refs/heads/main", + "path_prefix": "production-acceptance", + "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 4cc0c95..ab4c42e 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,11 @@ "private_envelope_sha256", "store_attestation_sha256", "storage_identity_sha256", - "object_version_sha256", "private_locator_version_sha256", - "kms_key_identity_sha256", + "encryption_recipient_sha256", "uploader_identity_sha256", - "retention_mode", - "retention_until", "retained_at", - "upload_verified", - "head_verified", - "object_lock_verified", + "visibility_verified", "private_locator_recorded", "acceptance_verified_at", "provenance_attestation", @@ -826,7 +822,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, @@ -872,18 +868,16 @@ def file_sha256(path: Path) -> str: _RETENTION_DESTINATION_KEYS = { - "account_id", - "region", - "bucket", - "object_prefix", - "kms_key_arn", - "retention_mode", - "retention_days", + "repository", + "ref", + "path_prefix", + "encryption_recipient", + "retention_commitment_days", } _PRIVATE_EXPORT_CONTRACT_KEYS = { "schema_version", "destination", - "uploader_arn", + "uploader_identity", "importer_workflow_ref", "approval_authority", "approved_at", @@ -891,14 +885,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]$") -_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}$") -_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+=,.@_/-]+$") +_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}$") +# 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: @@ -936,40 +929,35 @@ 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") - bucket = _nonempty(destination["bucket"], "retention destination bucket") - prefix = _nonempty(destination["object_prefix"], "retention destination object_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") + repository = _nonempty(destination["repository"], "retention destination repository") + ref = _nonempty(destination["ref"], "retention destination ref") + prefix = _nonempty(destination["path_prefix"], "retention destination path_prefix") + 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: + 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") - 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 path prefix is invalid") + 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( - "retention destination KMS key is outside the approved account or region" + "retention destination retention_commitment_days must be an integer" ) - if destination["retention_mode"] != "COMPLIANCE": - raise AcceptanceError("retention destination mode is not Object Lock COMPLIANCE") - days = destination["retention_days"] - if not isinstance(days, int) or isinstance(days, bool): - raise AcceptanceError("retention destination retention_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) @@ -985,11 +973,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) @@ -1002,18 +991,18 @@ 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", - 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_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,24 +1033,24 @@ 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 ( "storage_identity_sha256", - "kms_key_identity_sha256", + "encryption_recipient_sha256", "uploader_identity_sha256", ): 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,9 +1927,8 @@ def _validate_certificate( "private_envelope_sha256", "store_attestation_sha256", "storage_identity_sha256", - "object_version_sha256", "private_locator_version_sha256", - "kms_key_identity_sha256", + "encryption_recipient_sha256", "uploader_identity_sha256", ): _digest(retention[key], f"certificate retention {key}") @@ -1948,12 +1936,8 @@ def _validate_certificate( 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") for key in ( - "upload_verified", - "head_verified", - "object_lock_verified", + "visibility_verified", "private_locator_recorded", ): if retention[key] is not True: @@ -1965,21 +1949,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 +3599,14 @@ 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", + "encryption_recipient_sha256", "uploader_identity_sha256", ): _digest(retention[key], f"production acceptance source retention {key}") - for key in ( - "upload_verified", - "head_verified", - "object_lock_verified", - "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 retention["retention_mode"] != "COMPLIANCE": - raise AcceptanceError("production acceptance source retention is not COMPLIANCE") if not isinstance(retention["receipt_id"], str) or re.fullmatch( r"retention:[a-f0-9]{32}", retention["receipt_id"] ) is None: @@ -3656,16 +3623,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..dbc88dc 100644 --- a/tests/fixtures/production_acceptance/live-certificate.json +++ b/tests/fixtures/production_acceptance/live-certificate.json @@ -96,22 +96,17 @@ "acceptance_verified_at": "2026-08-18T12:00:00.000Z", "candidate_sha256": "sha256:6262626262626262626262626262626262626262626262626262626262626262", "ciphertext_sha256": "sha256:6161616161616161616161616161616161616161616161616161616161616161", - "head_verified": true, - "kms_key_identity_sha256": "sha256:6868686868686868686868686868686868686868686868686868686868686868", - "object_lock_verified": true, - "object_version_sha256": "sha256:6666666666666666666666666666666666666666666666666666666666666666", + "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", "receipt_id": "retention:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "retained_at": "2026-08-18T12:01:00.000Z", - "retention_mode": "COMPLIANCE", - "retention_until": "2027-08-18T12:01:00.000Z", "storage_identity_sha256": "sha256:6565656565656565656565656565656565656565656565656565656565656565", "store_attestation_sha256": "sha256:6464646464646464646464646464646464646464646464646464646464646464", - "upload_verified": 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 5e9cbf5..bb21982 100644 --- a/tests/test_import_production_acceptance.py +++ b/tests/test_import_production_acceptance.py @@ -966,14 +966,23 @@ 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) + + + @pytest.mark.parametrize( "route", [ @@ -2465,7 +2474,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 +2951,14 @@ 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"), + ("visibility_verified", False, "visibility_verified is false"), ( "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..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", - "bucket": "openadapt-retained-evidence", - "object_prefix": "production-acceptance", - "kms_key_arn": ( - "arn:aws:kms:us-east-1:123456789012:key/1111-2222" - ), - "retention_mode": "COMPLIANCE", - "retention_days": 2555, + "repository": "OpenAdaptAI/openadapt-evidence", + "ref": "refs/heads/main", + "path_prefix": "production-acceptance", + "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", @@ -60,13 +56,13 @@ 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"] + 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", @@ -82,10 +78,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) ) @@ -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__("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__("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_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.__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["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__("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_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"), ], ) @@ -163,8 +137,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: @@ -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) @@ -230,7 +204,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 +214,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"], + "encryption_recipient_sha256": facts["encryption_recipient_sha256"], + "uploader_identity_sha256": facts["uploader_identity_sha256"], + }, + facts, + ) def test_cli_refuses_a_contract_it_is_not_authorised_to_use(