diff --git a/production-lifecycle-policy.json b/production-lifecycle-policy.json index 2d3dd86..0a878c4 100644 --- a/production-lifecycle-policy.json +++ b/production-lifecycle-policy.json @@ -1,9 +1,10 @@ { "$schema": "schemas/production-lifecycle-policy.schema.json", "schema_version": "openadapt.production-lifecycle-policy/v3", - "revision": 3, - "maximum_release_admission_days": 30, - "maximum_workflow_admission_days": 7, + "revision": 4, + "admission_validity": "until_revoked", + "maximum_release_admission_days": null, + "maximum_workflow_admission_days": null, "object_reference_schema_version": "openadapt.production-evidence-object-reference/v2", "release_admission_schema_version": "openadapt.qualification-release/v2", "workflow_admission_schema_version": "openadapt.qualification-admission/v4", diff --git a/schemas/production-current-default.schema.json b/schemas/production-current-default.schema.json index 67a37e6..295668b 100644 --- a/schemas/production-current-default.schema.json +++ b/schemas/production-current-default.schema.json @@ -27,7 +27,7 @@ }, "issued_at": {"$ref": "#/$defs/timestamp"}, "not_before": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "issuer": {"$ref": "#/$defs/issuer"} }, "$defs": { diff --git a/schemas/production-lifecycle-admissions.schema.json b/schemas/production-lifecycle-admissions.schema.json index fffe032..91f224d 100644 --- a/schemas/production-lifecycle-admissions.schema.json +++ b/schemas/production-lifecycle-admissions.schema.json @@ -125,7 +125,7 @@ }, "acceptance_evidence": {"$ref": "#/$defs/acceptanceEvidence"}, "issued_at": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "revoked_at": {"oneOf": [{"type": "null"}, {"$ref": "#/$defs/timestamp"}]} } } diff --git a/schemas/production-lifecycle-checkpoint.schema.json b/schemas/production-lifecycle-checkpoint.schema.json index 83dcd57..1f1af40 100644 --- a/schemas/production-lifecycle-checkpoint.schema.json +++ b/schemas/production-lifecycle-checkpoint.schema.json @@ -70,7 +70,7 @@ "revocation_state_sha256": {"$ref": "#/$defs/digest"}, "generated_at": {"$ref": "#/$defs/timestamp"}, "not_before": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "issuer": {"$ref": "#/$defs/issuer"} }, "$defs": { @@ -221,7 +221,7 @@ "release_admission_object_sha256": {"$ref": "#/$defs/digest"}, "default_identity_sha256": {"$ref": "#/$defs/digest"}, "not_before": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "reason_codes": {"type": "array", "uniqueItems": true, "items": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}} } }, diff --git a/schemas/production-lifecycle-evidence-manifest.schema.json b/schemas/production-lifecycle-evidence-manifest.schema.json index 557e16b..142ec35 100644 --- a/schemas/production-lifecycle-evidence-manifest.schema.json +++ b/schemas/production-lifecycle-evidence-manifest.schema.json @@ -55,7 +55,7 @@ "signer_registry_sha256": {"$ref": "#/$defs/digest"}, "issued_at": {"$ref": "#/$defs/timestamp"}, "not_before": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "issuer": {"$ref": "#/$defs/issuer"} }, "$defs": { diff --git a/schemas/production-lifecycle-evidence-summary.schema.json b/schemas/production-lifecycle-evidence-summary.schema.json index 4a00514..9feec29 100644 --- a/schemas/production-lifecycle-evidence-summary.schema.json +++ b/schemas/production-lifecycle-evidence-summary.schema.json @@ -57,7 +57,7 @@ "signer_registry_sha256": {"$ref": "#/$defs/digest"}, "issued_at": {"$ref": "#/$defs/timestamp"}, "not_before": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "issuer": {"$ref": "#/$defs/issuer"} }, "$defs": { diff --git a/schemas/production-lifecycle-feed-update.schema.json b/schemas/production-lifecycle-feed-update.schema.json index ca63f2b..a207c9a 100644 --- a/schemas/production-lifecycle-feed-update.schema.json +++ b/schemas/production-lifecycle-feed-update.schema.json @@ -39,7 +39,7 @@ "feed_sha256": {"$ref": "#/$defs/digest"}, "checkpoint_sha256": {"$ref": "#/$defs/digest"}, "registry_head_sha256": {"$ref": "#/$defs/digest"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "idempotency_key": {"type": "string", "pattern": "^lifecycle-feed-update:[0-9a-f]{64}$"} }, "$defs": { diff --git a/schemas/production-lifecycle-feed.schema.json b/schemas/production-lifecycle-feed.schema.json index 810bd80..8e89a73 100644 --- a/schemas/production-lifecycle-feed.schema.json +++ b/schemas/production-lifecycle-feed.schema.json @@ -27,7 +27,7 @@ "ref": {"const": "refs/heads/production-lifecycle-feed"}, "feed_revision": {"type": "integer", "minimum": 1}, "generated_at": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "registry_source_commit": {"$ref": "#/$defs/commit"}, "registry_revision": {"type": "integer", "minimum": 1}, "registry_head_sha256": {"$ref": "#/$defs/digest"}, diff --git a/schemas/production-lifecycle-policy.schema.json b/schemas/production-lifecycle-policy.schema.json index 7830b05..3bb1dfc 100644 --- a/schemas/production-lifecycle-policy.schema.json +++ b/schemas/production-lifecycle-policy.schema.json @@ -8,6 +8,7 @@ "$schema", "schema_version", "revision", + "admission_validity", "maximum_release_admission_days", "maximum_workflow_admission_days", "object_reference_schema_version", @@ -21,9 +22,10 @@ "properties": { "$schema": {"const": "schemas/production-lifecycle-policy.schema.json"}, "schema_version": {"const": "openadapt.production-lifecycle-policy/v3"}, - "revision": {"type": "integer", "minimum": 3}, - "maximum_release_admission_days": {"const": 30}, - "maximum_workflow_admission_days": {"const": 7}, + "revision": {"type": "integer", "minimum": 4}, + "admission_validity": {"const": "until_revoked"}, + "maximum_release_admission_days": {"type": "null"}, + "maximum_workflow_admission_days": {"type": "null"}, "object_reference_schema_version": {"const": "openadapt.production-evidence-object-reference/v2"}, "release_admission_schema_version": {"const": "openadapt.qualification-release/v2"}, "workflow_admission_schema_version": {"const": "openadapt.qualification-admission/v4"}, diff --git a/schemas/production-public-trust-signing-statement.schema.json b/schemas/production-public-trust-signing-statement.schema.json index 5962d19..ec0ce67 100644 --- a/schemas/production-public-trust-signing-statement.schema.json +++ b/schemas/production-public-trust-signing-statement.schema.json @@ -25,7 +25,7 @@ "revocation_state_sha256": {"$ref": "#/$defs/digest"}, "issued_at": {"$ref": "#/$defs/timestamp"}, "not_before": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "request_id_sha256": {"$ref": "#/$defs/digest"}, "signing_authority": { "oneOf": [ diff --git a/schemas/qualification-admission.schema.json b/schemas/qualification-admission.schema.json index a86b010..86d830e 100644 --- a/schemas/qualification-admission.schema.json +++ b/schemas/qualification-admission.schema.json @@ -72,7 +72,7 @@ "verdict": {"const": "accepted"}, "issued_at": {"$ref": "#/$defs/timestamp"}, "not_before": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "issuer": {"$ref": "#/$defs/issuer"} }, "$defs": { diff --git a/schemas/qualification-authority-state-receipt.schema.json b/schemas/qualification-authority-state-receipt.schema.json index 51caabf..956b33a 100644 --- a/schemas/qualification-authority-state-receipt.schema.json +++ b/schemas/qualification-authority-state-receipt.schema.json @@ -21,7 +21,7 @@ "evidence_authority_sha256": {"$ref": "#/$defs/digest"}, "observed_at": {"$ref": "#/$defs/timestamp"}, "not_before": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "issuer_key_id": {"type": "string", "pattern": "^qa-ed25519-[0-9a-f]{16}$"}, "algorithm": {"const": "ed25519"}, "signing_statement": { diff --git a/schemas/qualification-evidence-decision-receipt.schema.json b/schemas/qualification-evidence-decision-receipt.schema.json index 55418f0..bd8f820 100644 --- a/schemas/qualification-evidence-decision-receipt.schema.json +++ b/schemas/qualification-evidence-decision-receipt.schema.json @@ -77,7 +77,7 @@ "verdict": {"const": "ADMIT"}, "issued_at": {"$ref": "#/$defs/timestamp"}, "not_before": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "issuer_key_id": {"type": "string", "pattern": "^qa-ed25519-[0-9a-f]{16}$"}, "algorithm": {"const": "ed25519"}, "signing_statement": {"$ref": "#/$defs/signing_statement"}, diff --git a/schemas/qualification-release-verification-receipt-v2.schema.json b/schemas/qualification-release-verification-receipt-v2.schema.json index 436e94b..14f446c 100644 --- a/schemas/qualification-release-verification-receipt-v2.schema.json +++ b/schemas/qualification-release-verification-receipt-v2.schema.json @@ -80,7 +80,7 @@ "workflow_bundle_sha256": {"$ref": "#/$defs/digest"}, "admitted_runtime_sha256": {"$ref": "#/$defs/digest"}, "verified_at": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "registry_source_commit": {"$ref": "#/$defs/commit"}, "registry_revision": {"type": "integer", "minimum": 1}, "registry_head_sha256": {"$ref": "#/$defs/digest"}, diff --git a/schemas/qualification-release-verification-receipt.schema.json b/schemas/qualification-release-verification-receipt.schema.json index 42ea782..3427634 100644 --- a/schemas/qualification-release-verification-receipt.schema.json +++ b/schemas/qualification-release-verification-receipt.schema.json @@ -74,7 +74,7 @@ "workflow_bundle_sha256": {"$ref": "#/$defs/digest"}, "admitted_runtime_sha256": {"$ref": "#/$defs/digest"}, "verified_at": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "registry_source_commit": {"$ref": "#/$defs/commit"}, "registry_revision": {"type": "integer", "minimum": 1}, "registry_head_sha256": {"$ref": "#/$defs/digest"}, diff --git a/schemas/qualification-release.schema.json b/schemas/qualification-release.schema.json index 7f6a2d7..7e37430 100644 --- a/schemas/qualification-release.schema.json +++ b/schemas/qualification-release.schema.json @@ -49,7 +49,7 @@ "publication_policy_sha256": {"$ref": "#/$defs/digest"}, "issued_at": {"$ref": "#/$defs/timestamp"}, "not_before": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "issuer": {"$ref": "#/$defs/issuer"} }, "$defs": { diff --git a/schemas/qualification-revocation-state-receipt.schema.json b/schemas/qualification-revocation-state-receipt.schema.json index ffcbe3e..388d1b1 100644 --- a/schemas/qualification-revocation-state-receipt.schema.json +++ b/schemas/qualification-revocation-state-receipt.schema.json @@ -21,7 +21,7 @@ "revocations": {"type": "array", "uniqueItems": true, "items": {"$ref": "#/$defs/revocation"}}, "observed_at": {"$ref": "#/$defs/timestamp"}, "not_before": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "issuer_key_id": {"type": "string", "pattern": "^qa-ed25519-[0-9a-f]{16}$"}, "algorithm": {"const": "ed25519"}, "signing_statement": { diff --git a/schemas/qualification-signer-registry.schema.json b/schemas/qualification-signer-registry.schema.json index 6148f6b..4d3eafd 100644 --- a/schemas/qualification-signer-registry.schema.json +++ b/schemas/qualification-signer-registry.schema.json @@ -9,7 +9,7 @@ "schema_version": {"const": "openadapt.qualification-signer-registry/v2"}, "revision": {"type": "integer", "minimum": 1}, "generated_at": {"$ref": "#/$defs/timestamp"}, - "expires_at": {"$ref": "#/$defs/timestamp"}, + "expires_at": {"oneOf": [{"$ref": "#/$defs/timestamp"}, {"type": "null"}]}, "signers": { "type": "array", "minItems": 1, diff --git a/scripts/production_trust.py b/scripts/production_trust.py index 9efe098..d3bcc1a 100644 --- a/scripts/production_trust.py +++ b/scripts/production_trust.py @@ -307,6 +307,33 @@ def require_timestamp(value: Any, label: str) -> datetime: raise TrustError(f"{label} is not a calendar timestamp") from exc +def optional_timestamp(value: Any, label: str) -> datetime | None: + if value is None: + return None + return require_timestamp(value, label) + + +def expires_contained( + parent_expires: datetime | None, child_expires: datetime | None +) -> bool: + """Return True when the parent window cannot outlive the child. + + ``None`` means until revoked. An until-revoked parent is contained only by + an until-revoked child. A timestamp parent is contained by until-revoked + or by a later-or-equal timestamp. + """ + + if parent_expires is None: + return child_expires is None + if child_expires is None: + return True + return parent_expires <= child_expires + + +def window_covers(now: datetime, *, not_before: datetime, expires: datetime | None) -> bool: + return not_before <= now and (expires is None or now < expires) + + def require_positive_int(value: Any, label: str, minimum: int = 1) -> int: if not isinstance(value, int) or isinstance(value, bool) or value < minimum: raise TrustError(f"{label} must be an integer of at least {minimum}") @@ -314,14 +341,24 @@ def require_positive_int(value: Any, label: str, minimum: int = 1) -> int: def validate_window( - value: Mapping[str, Any], *, maximum: timedelta, now: datetime | None = None + value: Mapping[str, Any], + *, + maximum: timedelta | None = None, + now: datetime | None = None, ) -> None: issued = require_timestamp(value["issued_at"], "issued_at") not_before = require_timestamp(value["not_before"], "not_before") - expires = require_timestamp(value["expires_at"], "expires_at") - if not not_before <= issued < expires <= not_before + maximum: + expires = optional_timestamp(value["expires_at"], "expires_at") + if not not_before <= issued: raise TrustError("validity window is invalid") - if now is not None and not not_before <= now < expires: + if expires is not None: + if not issued < expires: + raise TrustError("validity window is invalid") + if maximum is not None and expires > not_before + maximum: + raise TrustError("validity window is invalid") + if now is not None and not window_covers( + now, not_before=not_before, expires=expires + ): raise TrustError("object is not active at the requested time") @@ -994,7 +1031,7 @@ def validate_release(value: Any, *, now: datetime | None = None) -> dict[str, An or HEX40.fullmatch(issuer["source_commit"]) is None ): raise TrustError("release issuer identity differs") - validate_window(release_admission, maximum=timedelta(days=30), now=now) + validate_window(release_admission, now=now) validate_staging_observation_age(staging, release_admission) projection = dict(release_admission) admission_id = projection.pop("admission_id_sha256") @@ -1650,7 +1687,7 @@ def _validate_receipt_structure( or HEX40.fullmatch(source_commit) is None ): raise TrustError("decision receipt issuer differs") - validate_window(receipt, maximum=timedelta(days=7), now=now) + validate_window(receipt, now=now) return receipt @@ -1747,8 +1784,8 @@ def verify_embedded_signature( raise TrustError(str(exc)) from exc requested_time = now or datetime.now(timezone.utc) generated = evidence._timestamp(registry["generated_at"], "generated_at") - expires = evidence._timestamp(registry["expires_at"], "expires_at") - if not generated <= requested_time < expires: + expires = evidence.optional_timestamp(registry["expires_at"], "expires_at") + if not window_covers(requested_time, not_before=generated, expires=expires): raise TrustError("signer registry is not active") key_id = value.get("issuer_key_id") matches = [item for item in registry["signers"] if item["key_id"] == key_id] @@ -1934,7 +1971,6 @@ def validate_authority_state( "not_before": state["not_before"], "expires_at": state["expires_at"], }, - maximum=timedelta(days=7), now=now, ) return state @@ -2040,7 +2076,6 @@ def validate_revocation_state( "not_before": state["not_before"], "expires_at": state["expires_at"], }, - maximum=timedelta(days=7), now=now, ) return state @@ -2266,7 +2301,7 @@ def validate_qualification_admission( or HEX40.fullmatch(issuer["source_commit"]) is None ): raise TrustError("qualification admission issuer differs") - validate_window(admission, maximum=timedelta(days=7), now=now) + validate_window(admission, now=now) projection = dict(admission) admission_id = projection.pop("admission_id_sha256") if admission_id != digest_bytes(ADMISSION_DOMAIN, projection): @@ -2377,20 +2412,20 @@ def _validate_receipt_admission_binding( ) receipt_not_before = require_timestamp(receipt["not_before"], "receipt not_before") receipt_issued = require_timestamp(receipt["issued_at"], "receipt issued_at") - receipt_expires = require_timestamp(receipt["expires_at"], "receipt expires_at") + receipt_expires = optional_timestamp(receipt["expires_at"], "receipt expires_at") admission_not_before = require_timestamp( admission["not_before"], "qualification admission not_before" ) admission_issued = require_timestamp( admission["issued_at"], "qualification admission issued_at" ) - admission_expires = require_timestamp( + admission_expires = optional_timestamp( admission["expires_at"], "qualification admission expires_at" ) if not ( receipt_not_before <= receipt_issued <= admission_issued and admission_not_before >= receipt_not_before - and admission_expires <= receipt_expires + and expires_contained(admission_expires, receipt_expires) ): raise TrustError("qualification admission validity exceeds the receipt") return receipt, admission @@ -2550,10 +2585,10 @@ def validate_acceptance_manifest( ): raise TrustError("production acceptance manifest evidence bindings differ") validate_campaign_summary(manifest["campaign_summary"]) - validate_window(manifest, maximum=timedelta(days=7), now=now) + validate_window(manifest, now=now) issued = require_timestamp(manifest["issued_at"], "manifest issued_at") not_before = require_timestamp(manifest["not_before"], "manifest not_before") - expires = require_timestamp(manifest["expires_at"], "manifest expires_at") + expires = optional_timestamp(manifest["expires_at"], "manifest expires_at") if ( require_timestamp( manifest["publication_staging"]["observed_at"], "staging observed_at" @@ -2565,7 +2600,9 @@ def validate_acceptance_manifest( if ( not_before < require_timestamp(child["not_before"], f"{label} not_before") or issued < require_timestamp(child["issued_at"], f"{label} issued_at") - or expires > require_timestamp(child["expires_at"], f"{label} expires_at") + or not expires_contained( + expires, optional_timestamp(child["expires_at"], f"{label} expires_at") + ) ): raise TrustError(f"manifest validity exceeds the {label}") return manifest @@ -2691,17 +2728,19 @@ def validate_acceptance_summary( raise TrustError( f"production acceptance summary {field} differs from the manifest" ) - validate_window(summary, maximum=timedelta(days=7), now=now) + validate_window(summary, now=now) summary_not_before = require_timestamp(summary["not_before"], "summary not_before") summary_issued = require_timestamp(summary["issued_at"], "summary issued_at") - summary_expires = require_timestamp(summary["expires_at"], "summary expires_at") + summary_expires = optional_timestamp(summary["expires_at"], "summary expires_at") if ( summary_not_before < require_timestamp(bound_manifest["not_before"], "manifest not_before") or summary_issued < require_timestamp(bound_manifest["issued_at"], "manifest issued_at") - or summary_expires - > require_timestamp(bound_manifest["expires_at"], "manifest expires_at") + or not expires_contained( + summary_expires, + optional_timestamp(bound_manifest["expires_at"], "manifest expires_at"), + ) ): raise TrustError("summary validity exceeds the manifest") if summary["evidence_identity_sha256"] != acceptance_summary_identity(summary): @@ -2764,14 +2803,16 @@ def validate_release_evidence_chain( raise TrustError("release publication policy differs from acceptance policy") release_not_before = require_timestamp(release["not_before"], "release not_before") release_issued = require_timestamp(release["issued_at"], "release issued_at") - release_expires = require_timestamp(release["expires_at"], "release expires_at") + release_expires = optional_timestamp(release["expires_at"], "release expires_at") if ( release_not_before < require_timestamp(bound_summary["not_before"], "summary not_before") or release_issued < require_timestamp(bound_summary["issued_at"], "summary issued_at") - or release_expires - > require_timestamp(bound_summary["expires_at"], "summary expires_at") + or not expires_contained( + release_expires, + optional_timestamp(bound_summary["expires_at"], "summary expires_at"), + ) ): raise TrustError("release validity exceeds its acceptance summary") return release @@ -3191,7 +3232,7 @@ def validate_current_default( or HEX40.fullmatch(issuer["source_commit"]) is None ): raise TrustError("current default issuer differs") - validate_window(current, maximum=timedelta(days=7), now=now) + validate_window(current, now=now) return current @@ -3239,7 +3280,7 @@ def validate_projection(value: Any) -> dict[str, Any]: ): require_digest(target[field], field) require_timestamp(target["not_before"], "target not_before") - require_timestamp(target["expires_at"], "target expires_at") + optional_timestamp(target["expires_at"], "target expires_at") reasons = target["reason_codes"] if ( not isinstance(reasons, list) @@ -3406,10 +3447,14 @@ def validate_checkpoint(value: Any, *, now: datetime | None = None) -> dict[str, raise TrustError("checkpoint lifecycle policy source differs") generated = require_timestamp(checkpoint["generated_at"], "checkpoint generated_at") not_before = require_timestamp(checkpoint["not_before"], "checkpoint not_before") - expires = require_timestamp(checkpoint["expires_at"], "checkpoint expires_at") - if not generated <= not_before < expires <= generated + timedelta(days=7): + expires = optional_timestamp(checkpoint["expires_at"], "checkpoint expires_at") + if not generated <= not_before: + raise TrustError("checkpoint scheduled validity window is invalid") + if expires is not None and not not_before < expires: raise TrustError("checkpoint scheduled validity window is invalid") - if now is not None and not not_before <= now < expires: + if now is not None and not window_covers( + now, not_before=not_before, expires=expires + ): raise TrustError("checkpoint is not active at the requested time") projection_without_id = dict(checkpoint) checkpoint_id = projection_without_id.pop("checkpoint_id_sha256") @@ -3461,7 +3506,7 @@ def validate_checkpoint_expiry_containment( checkpoint_not_before = require_timestamp( checkpoint["not_before"], "checkpoint not_before" ) - checkpoint_expires = require_timestamp( + checkpoint_expires = optional_timestamp( checkpoint["expires_at"], "checkpoint expires_at" ) checkpoint_generated = require_timestamp( @@ -3470,25 +3515,25 @@ def validate_checkpoint_expiry_containment( windows = [ ( evidence._timestamp(registry["generated_at"], "generated_at"), - evidence._timestamp(registry["expires_at"], "expires_at"), + evidence.optional_timestamp(registry["expires_at"], "expires_at"), evidence._timestamp(registry["generated_at"], "generated_at"), "signer registry", ), ( require_timestamp(current["not_before"], "current default not_before"), - require_timestamp(current["expires_at"], "current default expires_at"), + optional_timestamp(current["expires_at"], "current default expires_at"), require_timestamp(current["issued_at"], "current default issued_at"), "current default", ), ( require_timestamp(authority["not_before"], "authority not_before"), - require_timestamp(authority["expires_at"], "authority expires_at"), + optional_timestamp(authority["expires_at"], "authority expires_at"), require_timestamp(authority["observed_at"], "authority observed_at"), "authority state", ), ( require_timestamp(revocation["not_before"], "revocation not_before"), - require_timestamp(revocation["expires_at"], "revocation expires_at"), + optional_timestamp(revocation["expires_at"], "revocation expires_at"), require_timestamp(revocation["observed_at"], "revocation observed_at"), "revocation state", ), @@ -3496,7 +3541,7 @@ def validate_checkpoint_expiry_containment( windows.extend( ( require_timestamp(item["not_before"], "release not_before"), - require_timestamp(item["expires_at"], "release expires_at"), + optional_timestamp(item["expires_at"], "release expires_at"), require_timestamp(item["issued_at"], "release issued_at"), f"{item['target']} release", ) @@ -3505,7 +3550,7 @@ def validate_checkpoint_expiry_containment( windows.extend( ( require_timestamp(item["not_before"], "workflow not_before"), - require_timestamp(item["expires_at"], "workflow expires_at"), + optional_timestamp(item["expires_at"], "workflow expires_at"), require_timestamp(item["issued_at"], "workflow issued_at"), "workflow admission", ) @@ -3519,7 +3564,7 @@ def validate_checkpoint_expiry_containment( windows.extend( ( require_timestamp(item["not_before"], f"{label} not_before"), - require_timestamp(item["expires_at"], f"{label} expires_at"), + optional_timestamp(item["expires_at"], f"{label} expires_at"), require_timestamp(item["issued_at"], f"{label} issued_at"), label, ) @@ -3528,9 +3573,8 @@ def validate_checkpoint_expiry_containment( for child_not_before, child_expires, child_issued, label in windows: if child_issued > checkpoint_generated: raise TrustError(f"{label} was issued after the checkpoint") - if ( - checkpoint_not_before < child_not_before - or checkpoint_expires > child_expires + if checkpoint_not_before < child_not_before or not expires_contained( + checkpoint_expires, child_expires ): raise TrustError(f"checkpoint validity exceeds {label} validity") return checkpoint @@ -3589,10 +3633,12 @@ def validate_feed(value: Any, *, now: datetime | None = None) -> dict[str, Any]: kind="production-lifecycle-checkpoint", ) generated = require_timestamp(feed["generated_at"], "feed generated_at") - expires = require_timestamp(feed["expires_at"], "feed expires_at") - if not generated < expires <= generated + timedelta(days=7): + expires = optional_timestamp(feed["expires_at"], "feed expires_at") + if expires is not None and not generated < expires: raise TrustError("feed lifetime is invalid") - if now is not None and not generated <= now < expires: + if now is not None and not window_covers( + now, not_before=generated, expires=expires + ): raise TrustError("feed is not current") return feed @@ -3646,17 +3692,19 @@ def validate_feed_expiry_containment( raise TrustError("feed checkpoints are not one exact raw hash-chain step") generated = require_timestamp(feed["generated_at"], "feed generated_at") - expires = require_timestamp(feed["expires_at"], "feed expires_at") + expires = optional_timestamp(feed["expires_at"], "feed expires_at") registry_generated = evidence._timestamp( registry["generated_at"], "signer registry generated_at" ) - registry_expires = evidence._timestamp( + registry_expires = evidence.optional_timestamp( registry["expires_at"], "signer registry expires_at" ) - if generated < registry_generated or expires > registry_expires: + if generated < registry_generated or not expires_contained( + expires, registry_expires + ): raise TrustError("feed validity exceeds signer registry validity") - checkpoint_windows: list[tuple[datetime, datetime]] = [] + checkpoint_windows: list[tuple[datetime, datetime | None]] = [] for index, (pair, checkpoint) in enumerate( zip(feed["checkpoints"], resolved, strict=True) ): @@ -3667,7 +3715,7 @@ def validate_feed_expiry_containment( checkpoint_not_before = require_timestamp( checkpoint["not_before"], "checkpoint not_before" ) - checkpoint_expires = require_timestamp( + checkpoint_expires = optional_timestamp( checkpoint["expires_at"], "checkpoint expires_at" ) checkpoint_generated = require_timestamp( @@ -3679,11 +3727,15 @@ def validate_feed_expiry_containment( if len(checkpoint_windows) == 1: checkpoint_not_before, checkpoint_expires = checkpoint_windows[0] - if generated < checkpoint_not_before or expires > checkpoint_expires: + if generated < checkpoint_not_before or not expires_contained( + expires, checkpoint_expires + ): raise TrustError("feed validity exceeds checkpoint 0 validity") else: newer_not_before, newer_expires = checkpoint_windows[0] older_not_before, older_expires = checkpoint_windows[1] + if older_expires is None or newer_expires is None or expires is None: + raise TrustError("feed checkpoint handoff requires timestamps") if older_expires != newer_not_before: raise TrustError("feed checkpoint handoff has an overlap or gap") if not older_not_before <= generated < older_expires: @@ -3695,7 +3747,7 @@ def validate_feed_expiry_containment( active = [ index for index, (not_before, checkpoint_expires) in enumerate(checkpoint_windows) - if not_before <= now < checkpoint_expires + if window_covers(now, not_before=not_before, expires=checkpoint_expires) ] if len(active) != 1: raise TrustError("feed does not select exactly one active checkpoint") @@ -3803,7 +3855,7 @@ def validate_feed_update(value: Any) -> dict[str, Any]: raise TrustError("lifecycle feed expected old commit is invalid") for field in ("feed_sha256", "checkpoint_sha256", "registry_head_sha256"): require_digest(update[field], field) - require_timestamp(update["expires_at"], "feed update expires_at") + optional_timestamp(update["expires_at"], "feed update expires_at") projection = dict(update) idempotency_key = projection.pop("idempotency_key") expected_key = ( diff --git a/scripts/public_trust_kms.py b/scripts/public_trust_kms.py index fa33128..ca6341b 100644 --- a/scripts/public_trust_kms.py +++ b/scripts/public_trust_kms.py @@ -234,6 +234,12 @@ def _timestamp(value: Any, label: str) -> datetime: raise PublicTrustKmsError(f"{label} is not a calendar timestamp") from exc +def _optional_timestamp(value: Any, label: str) -> datetime | None: + if value is None: + return None + return _timestamp(value, label) + + def _canonical_base64(value: Any, label: str) -> bytes: if not isinstance(value, str): raise PublicTrustKmsError(f"{label} must be canonical padded base64") @@ -474,10 +480,14 @@ def validate_signing_statement( raise PublicTrustKmsError("public-trust signature profile is invalid") issued_at = _timestamp(statement["issued_at"], "statement issued_at") not_before = _timestamp(statement["not_before"], "statement not_before") - expires_at = _timestamp(statement["expires_at"], "statement expires_at") - if not not_before <= issued_at < expires_at: + expires_at = _optional_timestamp(statement["expires_at"], "statement expires_at") + if not not_before <= issued_at: raise PublicTrustKmsError("public-trust statement validity is invalid") - if now is not None and not not_before <= now < expires_at: + if expires_at is not None and not issued_at < expires_at: + raise PublicTrustKmsError("public-trust statement validity is invalid") + if now is not None and ( + now < not_before or (expires_at is not None and now >= expires_at) + ): raise PublicTrustKmsError("public-trust signing statement is not active") if signer is not None: _bind_statement_to_signer(statement, signer, authority) diff --git a/scripts/public_trust_resolver.py b/scripts/public_trust_resolver.py index 5b6df3d..fbdbc2d 100644 --- a/scripts/public_trust_resolver.py +++ b/scripts/public_trust_resolver.py @@ -101,8 +101,8 @@ def verify_registered_public_trust_pair( raise PublicTrustResolutionError("signer registry identity is not current") instant = now or datetime.now(timezone.utc) generated_at = evidence._timestamp(registry["generated_at"], "registry generated_at") - expires_at = evidence._timestamp(registry["expires_at"], "registry expires_at") - if not generated_at <= instant < expires_at: + expires_at = evidence.optional_timestamp(registry["expires_at"], "registry expires_at") + if instant < generated_at or (expires_at is not None and instant >= expires_at): raise PublicTrustResolutionError("signer registry is not active") try: statement = kms.statement_from_bundle(bundle_value) diff --git a/scripts/qualification_issuer.py b/scripts/qualification_issuer.py index 6d631d5..23029ad 100644 --- a/scripts/qualification_issuer.py +++ b/scripts/qualification_issuer.py @@ -313,6 +313,19 @@ def _timestamp(value: datetime) -> str: return value.replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%SZ") +def _optional_timestamp_text(value: datetime | None) -> str | None: + if value is None: + return None + return _timestamp(value) + + +def _earliest_expiry(*values: datetime | None) -> datetime | None: + present = [item for item in values if item is not None] + if not present: + return None + return min(present) + + def _request_digest(value: Mapping[str, Any]) -> str: return trust.digest_bytes(REQUEST_DOMAIN, value) @@ -363,7 +376,7 @@ def _active_trust_state( authority_evidence: ResolvedEvidence, revocation_evidence: ResolvedEvidence, now: datetime, -) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], datetime]: +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], datetime | None]: try: registry = evidence.validate_signer_registry(current_signer_registry) except evidence.EvidenceRegistryError as exc: @@ -419,10 +432,10 @@ def _active_trust_state( in revoked ): raise IssuerError("an active signer key is revoked") - expiry = min( - evidence._timestamp(registry["expires_at"], "signer registry expires_at"), - trust.require_timestamp(authority["expires_at"], "authority expires_at"), - trust.require_timestamp(revocation["expires_at"], "revocation expires_at"), + expiry = _earliest_expiry( + evidence.optional_timestamp(registry["expires_at"], "signer registry expires_at"), + trust.optional_timestamp(authority["expires_at"], "authority expires_at"), + trust.optional_timestamp(revocation["expires_at"], "revocation expires_at"), ) return registry, authority, revocation, expiry @@ -582,12 +595,11 @@ def issue_workflow_admission( ) issue_time = now.replace(microsecond=0) - expiry = min( - issue_time + timedelta(days=7), + expiry = _earliest_expiry( trust_expiry, - trust.require_timestamp(receipt["expires_at"], "receipt expires_at"), + trust.optional_timestamp(receipt["expires_at"], "receipt expires_at"), ) - if expiry <= issue_time: + if expiry is not None and expiry <= issue_time: raise IssuerError("workflow admission has no positive active window") admission = { "schema_version": "openadapt.qualification-admission/v4", @@ -619,7 +631,7 @@ def issue_workflow_admission( "verdict": "accepted", "issued_at": _timestamp(issue_time), "not_before": _timestamp(issue_time), - "expires_at": _timestamp(expiry), + "expires_at": _optional_timestamp_text(expiry), "issuer": { "repository": "OpenAdaptAI/.github", "repository_id": "858454062", @@ -824,12 +836,11 @@ def issue_release_admission( ) issue_time = now.replace(microsecond=0) - expiry = min( - issue_time + timedelta(days=30), + expiry = _earliest_expiry( trust_expiry, - trust.require_timestamp(summary["expires_at"], "summary expires_at"), + trust.optional_timestamp(summary["expires_at"], "summary expires_at"), ) - if expiry <= issue_time: + if expiry is not None and expiry <= issue_time: raise IssuerError("release admission has no positive active window") release = { "schema_version": "openadapt.qualification-release/v2", @@ -854,7 +865,7 @@ def issue_release_admission( "publication_policy_sha256": summary["acceptance_policy_sha256"], "issued_at": _timestamp(issue_time), "not_before": _timestamp(issue_time), - "expires_at": _timestamp(expiry), + "expires_at": _optional_timestamp_text(expiry), "issuer": { "repository": "OpenAdaptAI/.github", "repository_id": "858454062", @@ -906,8 +917,9 @@ def interface_contract() -> dict[str, Any]: "release_request_schema": "openadapt.qualification-release-issue-request/v1", "workflow_output_schema": "openadapt.qualification-admission/v4", "release_output_schema": "openadapt.qualification-release/v2", - "workflow_maximum_lifetime_seconds": 7 * 24 * 60 * 60, - "release_maximum_lifetime_seconds": 30 * 24 * 60 * 60, + "admission_validity": "until_revoked", + "workflow_maximum_lifetime_seconds": None, + "release_maximum_lifetime_seconds": None, "registry_resolution": "exact-commit-registered-adjacent-bundle-verified", "one_use_effect": "durable-atomic-result-bound-reconciliation-required", "retry_policy": "reconcile-never-retry", diff --git a/scripts/qualification_kms_ed25519.py b/scripts/qualification_kms_ed25519.py index ad9c434..5787397 100644 --- a/scripts/qualification_kms_ed25519.py +++ b/scripts/qualification_kms_ed25519.py @@ -113,7 +113,7 @@ def signer_registry_candidate( kms_public_key_projection: Mapping[str, Any], revision: int, generated_at: datetime, - expires_at: datetime, + expires_at: datetime | None = None, ) -> dict[str, Any]: """Build an inactive candidate from an exact KMS GetPublicKey projection.""" @@ -132,8 +132,8 @@ def signer_registry_candidate( ) if isinstance(revision, bool) or not isinstance(revision, int) or revision < 1: raise KmsEd25519Error("registry revision must be a positive integer") - if not generated_at < expires_at <= generated_at + timedelta(days=7): - raise KmsEd25519Error("signer registry lifetime must be at most seven days") + if expires_at is not None and not generated_at < expires_at: + raise KmsEd25519Error("signer registry expiry must be after generated_at") if ( kms_public_key_projection["KeySpec"] != KMS_KEY_SPEC or kms_public_key_projection["KeyUsage"] != "SIGN_VERIFY" @@ -176,7 +176,7 @@ def signer_registry_candidate( "schema_version": "openadapt.qualification-signer-registry/v2", "revision": revision, "generated_at": format_timestamp(generated_at), - "expires_at": format_timestamp(expires_at), + "expires_at": None if expires_at is None else format_timestamp(expires_at), "signers": [signer], } try: @@ -295,7 +295,10 @@ def main(argv: list[str] | None = None) -> int: registry_parser.add_argument("--kms-public-key-projection", required=True) registry_parser.add_argument("--revision", required=True, type=int) registry_parser.add_argument("--generated-at", required=True) - registry_parser.add_argument("--expires-at", required=True) + registry_parser.add_argument( + "--expires-at", + help="UTC timestamp, or omit / pass null for until-revoked", + ) sign_parser = subparsers.add_parser("sign-request") sign_parser.add_argument("--receipt", required=True) @@ -308,6 +311,12 @@ def main(argv: list[str] | None = None) -> int: if args.command == "interface": result = interface_contract() elif args.command == "registry-candidate": + expires_raw = args.expires_at + expires_at = None + if expires_raw not in (None, "", "null"): + expires_at = datetime.strptime( + expires_raw, "%Y-%m-%dT%H:%M:%SZ" + ).replace(tzinfo=timezone.utc) result = signer_registry_candidate( kms_public_key_projection=json.loads( Path(args.kms_public_key_projection).read_text(encoding="utf-8") @@ -316,9 +325,7 @@ def main(argv: list[str] | None = None) -> int: generated_at=datetime.strptime( args.generated_at, "%Y-%m-%dT%H:%M:%SZ" ).replace(tzinfo=timezone.utc), - expires_at=datetime.strptime( - args.expires_at, "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=timezone.utc), + expires_at=expires_at, ) else: receipt = json.loads(Path(args.receipt).read_text(encoding="utf-8")) diff --git a/scripts/qualification_software_ed25519.py b/scripts/qualification_software_ed25519.py index 6649e8e..dec13b2 100644 --- a/scripts/qualification_software_ed25519.py +++ b/scripts/qualification_software_ed25519.py @@ -11,8 +11,8 @@ ``local-candidate`` builds an unpublished, Keychain-signed registry candidate with ``activation_state=not-installed`` and ``clock=unset``. It does not write -``generated_at`` or ``expires_at``, so it cannot start the seven-day live -registry clock. Do not copy its output onto ``main``. +``generated_at`` or ``expires_at``, so it cannot start the live registry clock. +Do not copy its output onto ``main``. """ from __future__ import annotations @@ -157,20 +157,20 @@ def signer_registry_candidate( public_material_value: Mapping[str, str], revision: int, generated_at: datetime, - expires_at: datetime, + expires_at: datetime | None = None, ) -> dict[str, Any]: """Build an inactive candidate from the founder-held public half.""" if isinstance(revision, bool) or not isinstance(revision, int) or revision < 1: raise SoftwareEd25519Error("registry revision must be a positive integer") - if not generated_at < expires_at <= generated_at + timedelta(days=7): - raise SoftwareEd25519Error("signer registry lifetime must be at most seven days") + if expires_at is not None and not generated_at < expires_at: + raise SoftwareEd25519Error("signer registry expiry must be after generated_at") signer = signer_from_public_material(public_material_value) proposed_registry = { "schema_version": "openadapt.qualification-signer-registry/v2", "revision": revision, "generated_at": format_timestamp(generated_at), - "expires_at": format_timestamp(expires_at), + "expires_at": None if expires_at is None else format_timestamp(expires_at), "signers": [signer], } try: @@ -210,8 +210,9 @@ def unsigned_local_registry_candidate( """Build an unpublished candidate with no registry clock. The result is not a live signer registry. It omits ``generated_at`` and - ``expires_at`` so copying it onto ``main`` cannot start a seven-day - window. A later publish step must stamp those fields at publish time. + ``expires_at`` so copying it onto ``main`` cannot start the live + registry clock. A later publish step must stamp those fields at publish + time. """ if isinstance(revision, bool) or not isinstance(revision, int) or revision < 1: @@ -534,7 +535,10 @@ def main(argv: list[str] | None = None) -> int: registry_parser.add_argument("--public-material", required=True) registry_parser.add_argument("--revision", required=True, type=int) registry_parser.add_argument("--generated-at", required=True) - registry_parser.add_argument("--expires-at", required=True) + registry_parser.add_argument( + "--expires-at", + help="UTC timestamp, or omit / pass null for until-revoked", + ) local_parser = subparsers.add_parser("local-candidate") local_parser.add_argument("--revision", required=True, type=int) @@ -561,13 +565,17 @@ def main(argv: list[str] | None = None) -> int: if args.command == "interface": result: Any = interface_contract() elif args.command == "registry-candidate": + expires_raw = args.expires_at + expires_at = None + if expires_raw not in (None, "", "null"): + expires_at = _parse_timestamp(expires_raw) result = signer_registry_candidate( public_material_value=json.loads( Path(args.public_material).read_text(encoding="utf-8") ), revision=args.revision, generated_at=_parse_timestamp(args.generated_at), - expires_at=_parse_timestamp(args.expires_at), + expires_at=expires_at, ) elif args.command == "local-candidate": sources = [args.pem_file, args.from_keychain] diff --git a/scripts/validate_evidence_registry.py b/scripts/validate_evidence_registry.py index bafde42..51b3a7b 100644 --- a/scripts/validate_evidence_registry.py +++ b/scripts/validate_evidence_registry.py @@ -17,7 +17,7 @@ import re import sys from collections.abc import Mapping -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from pathlib import Path, PurePosixPath from typing import Any @@ -217,6 +217,22 @@ def _timestamp(value: Any, label: str) -> datetime: raise EvidenceRegistryError(f"{label} is not a calendar timestamp") from exc +def optional_timestamp(value: Any, label: str) -> datetime | None: + if value is None: + return None + return _timestamp(value, label) + + +def _revocation_in_lifetime( + generated_at: datetime, revoked_at: datetime, expires_at: datetime | None +) -> bool: + if revoked_at < generated_at: + return False + if expires_at is not None and revoked_at > expires_at: + return False + return True + + def semantic_identity_digest( *, kind: str, object_schema_version: str, object_value: Any, object_sha256: str ) -> str: @@ -286,10 +302,10 @@ def validate_signer_registry(value: Any) -> dict[str, Any]: raise EvidenceRegistryError("signer registry schema is not supported") _positive_integer(registry["revision"], "signer registry revision") generated_at = _timestamp(registry["generated_at"], "signer registry generated_at") - expires_at = _timestamp(registry["expires_at"], "signer registry expires_at") - if not generated_at < expires_at <= generated_at + timedelta(days=7): + expires_at = optional_timestamp(registry["expires_at"], "signer registry expires_at") + if expires_at is not None and not generated_at < expires_at: raise EvidenceRegistryError( - "signer registry lifetime must be positive and at most seven days" + "signer registry expiry must be after generated_at" ) signers = registry["signers"] if not isinstance(signers, list) or not signers: @@ -313,7 +329,7 @@ def validate_signer_registry(value: Any) -> dict[str, Any]: seen_ids.add(key_id) if signer["status"] == "revoked": revoked_at = _timestamp(signer["revoked_at"], f"{label} revoked_at") - if not generated_at <= revoked_at <= expires_at: + if not _revocation_in_lifetime(generated_at, revoked_at, expires_at): raise EvidenceRegistryError( f"{label} revocation time is outside the registry lifetime" ) @@ -460,7 +476,7 @@ def validate_signer_registry(value: Any) -> dict[str, Any]: ) elif signer["status"] == "revoked": revoked_at = _timestamp(signer["revoked_at"], f"{label} revoked_at") - if not generated_at <= revoked_at <= expires_at: + if not _revocation_in_lifetime(generated_at, revoked_at, expires_at): raise EvidenceRegistryError( f"{label} revocation time is outside the registry lifetime" ) diff --git a/scripts/validate_production_lifecycle.py b/scripts/validate_production_lifecycle.py index 669b5e8..69af996 100755 --- a/scripts/validate_production_lifecycle.py +++ b/scripts/validate_production_lifecycle.py @@ -12,15 +12,15 @@ admission fails closed unless the referenced summary, attestation bundle, and evidence manifest can all be fetched, hashed, and verified. -The lifecycle policy is a v2 document. It declares the schema versions and the -two admission windows that the signed checkpoint chain enforces, and it names -the protected feed ref that carries live Production state. It does not carry a -summary authority. For v2 objects the certificate identity that signs -Production acceptance evidence lives in production-evidence-policy.json, keyed -by evidence kind. The admission ledger this module reads is the retained v1 -ledger, so its records keep the exact trust root, target map and policy +The lifecycle policy is a v3 document. It declares the schema versions and +until-revoked admission validity that the signed checkpoint chain enforces, and +it names the protected feed ref that carries live Production state. It does +not carry a summary authority. For live objects the certificate identity that +signs Production acceptance evidence lives in production-evidence-policy.json, +keyed by evidence kind. The admission ledger this module reads is the retained +v1 ledger, so its records keep the exact trust root, target map and policy revision they were issued under; those are pinned here as module constants -rather than read from the v2 policy file. +rather than read from the live policy file. """ from __future__ import annotations @@ -50,7 +50,7 @@ POLICY_SCHEMA = "openadapt.production-lifecycle-policy/v3" POLICY_DOCUMENT_SCHEMA = "schemas/production-lifecycle-policy.schema.json" -POLICY_REVISION_MINIMUM = 3 +POLICY_REVISION_MINIMUM = 4 ADMISSIONS_SCHEMA = "openadapt.production-lifecycle-admissions/v1" SUMMARY_SCHEMA = "openadapt.production-lifecycle-evidence-summary/v1" RELEASE_IDENTITY_SCHEMA = "openadapt.monotonic-production-release/v1" @@ -60,18 +60,17 @@ LIFECYCLE_CHECKPOINT_SCHEMA = "openadapt.production-lifecycle-checkpoint/v2" LIFECYCLE_FEED_SCHEMA = "openadapt.production-lifecycle-feed/v2" LIFECYCLE_FEED_REF = "refs/heads/production-lifecycle-feed" -# production_trust.validate_release enforces a 30 day window on every -# openadapt.qualification-release/v2 object, and -# production_trust.validate_qualification_admission enforces a 7 day window on -# every openadapt.qualification-admission/v4 object. The policy declares both -# numbers; this module refuses a policy that declares a different one. -RELEASE_ADMISSION_MAXIMUM_DAYS = 30 -WORKFLOW_ADMISSION_MAXIMUM_DAYS = 7 +# Live admissions stay valid until revoked or replaced. The retained v1 +# admission ledger still holds timestamped release admissions issued under the +# historical 30-day window, so that bound remains only for those records. +RETAINED_RELEASE_ADMISSION_MAXIMUM_DAYS = 30 +RELEASE_ADMISSION_MAXIMUM_DAYS = None +WORKFLOW_ADMISSION_MAXIMUM_DAYS = None # The retained v1 admission ledger holds release admissions on the production -# channel only, so the release admission window governs its expiry, and every -# retained record was issued under policy revision 1. Its records and their -# acceptance summaries carry the digest of the v1 policy document that issued -# them, which is not the digest of the live v2 policy document. +# channel only, so the historical release admission window governs its expiry, +# and every retained record was issued under policy revision 1. Its records +# and their acceptance summaries carry the digest of the v1 policy document +# that issued them, which is not the digest of the live policy document. RETAINED_POLICY_REVISION = 1 RETAINED_POLICY_SHA256 = ( "sha256:e1444a08ce6b16736168cce027ce9d48abb2e0e246fc0cd79c0772fa8e423e11" @@ -379,16 +378,13 @@ def load_lifecycle( return _parse_group(text, "lifecycle"), _parse_group(text, "public_surfaces") -def _admission_days(value: object, label: str, enforced: int) -> int: - """Validate one declared admission window against the enforced window.""" +def _admission_days(value: object, label: str) -> None: + """Refuse a live admission-day maximum. Validity is until revoked.""" - if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= 30: - raise LifecycleError(f"{label} must be between 1 and 30") - if value != enforced: + if value is not None: raise LifecycleError( - f"{label} differs from the window the Production trust core enforces" + f"{label} must be null; admissions stay valid until revoked" ) - return value def _validate_summary_authority(value: object) -> dict[str, Any]: @@ -556,8 +552,8 @@ def _validate_policy(value: object) -> tuple[dict[str, Any], dict[str, Any]]: """Validate the v3 policy and pin the versioned admission contracts. The v3 policy states which schema versions the signed checkpoint chain - accepts and how long each admission kind may live. Every target it declares - must agree with the Production trust contract that + accepts and that admissions stay valid until revoked. Every target it + declares must agree with the Production trust contract that production_trust.validate_release applies to the matching openadapt.qualification-release/v2 object. """ @@ -568,6 +564,7 @@ def _validate_policy(value: object) -> tuple[dict[str, Any], dict[str, Any]]: "$schema", "schema_version", "revision", + "admission_validity", "maximum_release_admission_days", "maximum_workflow_admission_days", "object_reference_schema_version", @@ -593,21 +590,18 @@ def _validate_policy(value: object) -> tuple[dict[str, Any], dict[str, Any]]: "production lifecycle policy revision must be at least " f"{POLICY_REVISION_MINIMUM}" ) - release_days = _admission_days( + if policy["admission_validity"] != "until_revoked": + raise LifecycleError( + "production lifecycle policy admission_validity must be until_revoked" + ) + _admission_days( policy["maximum_release_admission_days"], "maximum_release_admission_days", - RELEASE_ADMISSION_MAXIMUM_DAYS, ) - workflow_days = _admission_days( + _admission_days( policy["maximum_workflow_admission_days"], "maximum_workflow_admission_days", - WORKFLOW_ADMISSION_MAXIMUM_DAYS, ) - if workflow_days > release_days: - raise LifecycleError( - "maximum_workflow_admission_days cannot exceed " - "maximum_release_admission_days" - ) for key, expected in ( ("object_reference_schema_version", OBJECT_REFERENCE_SCHEMA), ("release_admission_schema_version", RELEASE_ADMISSION_SCHEMA), @@ -628,7 +622,7 @@ def _validate_policy(value: object) -> tuple[dict[str, Any], dict[str, Any]]: targets[target_id] = target if set(targets) != set(EXPECTED_TARGETS): raise LifecycleError("production target map differs from the pinned target map") - return policy, _retained_admission_contract(release_days) + return policy, _retained_admission_contract(RETAINED_RELEASE_ADMISSION_MAXIMUM_DAYS) def _clean_https_url(value: object, label: str) -> str: diff --git a/scripts/verify_production_release_admission.py b/scripts/verify_production_release_admission.py index ad4a535..89a964c 100644 --- a/scripts/verify_production_release_admission.py +++ b/scripts/verify_production_release_admission.py @@ -370,11 +370,11 @@ def load_signer(pointer: dict[str, Any], *, require_active: bool) -> dict[str, A raise trust.TrustError("signer registry identity differs") if require_active: now = datetime.now(timezone.utc) - if not ( - evidence._timestamp(signer_value["generated_at"], "generated_at") - <= now - < evidence._timestamp(signer_value["expires_at"], "expires_at") - ): + generated = evidence._timestamp(signer_value["generated_at"], "generated_at") + expires = evidence.optional_timestamp( + signer_value["expires_at"], "expires_at" + ) + if now < generated or (expires is not None and now >= expires): raise trust.TrustError("current signer registry is not active") return signer_value diff --git a/tests/test_evidence_registry.py b/tests/test_evidence_registry.py index 8cba4d5..d60a002 100644 --- a/tests/test_evidence_registry.py +++ b/tests/test_evidence_registry.py @@ -319,6 +319,24 @@ def test_signer_registry_key_id_binds_canonical_key(self) -> None: with self.assertRaisesRegex(registry.EvidenceRegistryError, "bind"): registry.validate_signer_registry(value) + def test_signer_registry_null_expires_at_is_until_revoked(self) -> None: + value = signer_registry(ed25519_signer(1, "qualification-evidence-decision-receipt")) + value["expires_at"] = None + self.assertEqual(registry.validate_signer_registry(value), value) + + def test_signer_registry_refuses_expiry_at_or_before_generated_at(self) -> None: + value = signer_registry(ed25519_signer(1, "qualification-evidence-decision-receipt")) + value["expires_at"] = value["generated_at"] + with self.assertRaisesRegex( + registry.EvidenceRegistryError, "after generated_at" + ): + registry.validate_signer_registry(value) + + def test_signer_registry_accepts_a_timestamp_beyond_seven_days(self) -> None: + value = signer_registry(ed25519_signer(1, "qualification-evidence-decision-receipt")) + value["expires_at"] = "2027-08-27T00:00:00Z" + self.assertEqual(registry.validate_signer_registry(value), value) + def test_recovery_receipt_uses_one_distinct_active_signer(self) -> None: schema = json.loads( (ROOT / "schemas/qualification-signer-registry.schema.json").read_text() diff --git a/tests/test_production_lifecycle.py b/tests/test_production_lifecycle.py index 71f997b..03e3e1a 100644 --- a/tests/test_production_lifecycle.py +++ b/tests/test_production_lifecycle.py @@ -1118,7 +1118,8 @@ def test_published_policy_declares_the_v2_contract(self) -> None: self.assertEqual( lifecycle.POLICY_SCHEMA, "openadapt.production-lifecycle-policy/v3" ) - self.assertGreaterEqual(policy["revision"], 3) + self.assertGreaterEqual(policy["revision"], 4) + self.assertEqual(policy["admission_validity"], "until_revoked") self.assertNotIn("summary_authority", policy) self.assertNotIn("maximum_admission_days", policy) self.assertEqual( @@ -1148,12 +1149,18 @@ def test_retained_ledger_carries_the_v1_policy_digest(self) -> None: class AdmissionWindowTests(unittest.TestCase): - """The two v2 maximums must be the windows the trust core enforces.""" + """Live admissions stay valid until revoked. The retained ledger keeps 30 days.""" - def test_release_maximum_governs_the_retained_expiry(self) -> None: + def test_live_policy_is_until_revoked(self) -> None: policy = load_policy() - self.assertEqual(policy["maximum_release_admission_days"], 30) - self.assertEqual(lifecycle.RELEASE_ADMISSION_MAXIMUM_DAYS, 30) + self.assertEqual(policy["admission_validity"], "until_revoked") + self.assertIsNone(policy["maximum_release_admission_days"]) + self.assertIsNone(policy["maximum_workflow_admission_days"]) + self.assertIsNone(lifecycle.RELEASE_ADMISSION_MAXIMUM_DAYS) + self.assertIsNone(lifecycle.WORKFLOW_ADMISSION_MAXIMUM_DAYS) + + def test_retained_ledger_still_uses_the_historical_thirty_day_bound(self) -> None: + self.assertEqual(lifecycle.RETAINED_RELEASE_ADMISSION_MAXIMUM_DAYS, 30) admissions, _summary, remote = build_case( issued_at="2026-08-18T11:00:00Z", expires_at="2026-09-17T11:00:00Z", @@ -1170,50 +1177,29 @@ def test_release_maximum_governs_the_retained_expiry(self) -> None: ): validate_case(admissions, remote) - def test_policy_maximums_stay_within_the_historical_bound(self) -> None: - policy = load_policy() - for key in ( - "maximum_release_admission_days", - "maximum_workflow_admission_days", - ): - with self.subTest(key=key): - self.assertIsInstance(policy[key], int) - self.assertGreaterEqual(policy[key], 1) - self.assertLessEqual(policy[key], 30) - self.assertLessEqual( - policy["maximum_workflow_admission_days"], - policy["maximum_release_admission_days"], - ) - - def test_declared_maximums_match_the_enforced_windows(self) -> None: - # production_trust is the v2 trust core. validate_release applies the - # release window to openadapt.qualification-release/v2 and - # validate_qualification_admission applies the workflow window to - # openadapt.qualification-admission/v4. A drift between the declared - # policy numbers and those call sites must fail here. - policy = load_policy() - for function, days in ( - (trust.validate_release, policy["maximum_release_admission_days"]), - ( - trust.validate_qualification_admission, - policy["maximum_workflow_admission_days"], - ), + def test_trust_core_does_not_cap_admission_windows_by_day_count(self) -> None: + for function in ( + trust.validate_release, + trust.validate_qualification_admission, ): with self.subTest(function=function.__name__): source = inspect.getsource(function) self.assertIn("validate_window(", source) - self.assertIn(f"maximum=timedelta(days={days})", source) - - def test_policy_that_declares_another_maximum_is_refused(self) -> None: - for key in ( - "maximum_release_admission_days", - "maximum_workflow_admission_days", + self.assertNotIn("timedelta(days=7)", source) + self.assertNotIn("timedelta(days=30)", source) + + def test_policy_that_declares_an_admission_day_maximum_is_refused(self) -> None: + for key, value in ( + ("maximum_release_admission_days", 3), + ("maximum_workflow_admission_days", 7), + ("maximum_release_admission_days", 31), ): - with self.subTest(key=key): + with self.subTest(key=key, value=value): policy = load_policy() - policy[key] = 3 + policy[key] = value with self.assertRaisesRegex( - lifecycle.LifecycleError, "the Production trust core enforces" + lifecycle.LifecycleError, + "must be null; admissions stay valid until revoked", ): lifecycle.validate( policy, @@ -1223,20 +1209,6 @@ def test_policy_that_declares_another_maximum_is_refused(self) -> None: now=NOW, ) - def test_policy_maximum_outside_the_historical_bound_is_refused(self) -> None: - policy = load_policy() - policy["maximum_release_admission_days"] = 31 - with self.assertRaisesRegex( - lifecycle.LifecycleError, "must be between 1 and 30" - ): - lifecycle.validate( - policy, - empty_admissions(), - *lifecycle.load_lifecycle(), - policy_sha256=POLICY_DIGEST, - now=NOW, - ) - class CertificateIdentityBindingTests(unittest.TestCase): """The v2 policy dropped the summary authority. The binding must remain. diff --git a/tests/test_production_trust.py b/tests/test_production_trust.py index f456f5b..be906bc 100644 --- a/tests/test_production_trust.py +++ b/tests/test_production_trust.py @@ -968,5 +968,70 @@ def test_v2_reference_ids_are_strings_and_digests_are_prefixed(self) -> None: trust.validate_reference_pair(regular, bundle, kind="qualification-release") +class UntilRevokedWindowTests(unittest.TestCase): + def test_null_expires_at_stays_active(self) -> None: + trust.validate_window( + { + "issued_at": "2026-08-27T12:00:00Z", + "not_before": "2026-08-27T12:00:00Z", + "expires_at": None, + }, + now=datetime(2027, 9, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + + def test_past_expires_at_is_not_active(self) -> None: + window = { + "issued_at": "2026-08-27T12:00:00Z", + "not_before": "2026-08-27T12:00:00Z", + "expires_at": "2026-08-28T12:00:00Z", + } + trust.validate_window( + window, now=datetime(2026, 8, 28, 11, 59, 59, tzinfo=timezone.utc) + ) + with self.assertRaisesRegex(trust.TrustError, "not active"): + trust.validate_window( + window, now=datetime(2026, 8, 28, 12, 0, 0, tzinfo=timezone.utc) + ) + + def test_timestamp_beyond_thirty_days_is_accepted(self) -> None: + trust.validate_window( + { + "issued_at": "2026-08-27T12:00:00Z", + "not_before": "2026-08-27T12:00:00Z", + "expires_at": "2027-08-27T12:00:00Z", + }, + now=datetime(2026, 8, 27, 12, 0, 0, tzinfo=timezone.utc), + ) + + def test_null_expires_at_feed_stays_current(self) -> None: + checkpoint_reference, checkpoint_bundle_reference = pair( + "production-lifecycle-checkpoint", "1", "2" + ) + feed = { + "schema_version": "openadapt.production-lifecycle-feed/v2", + "repository": "OpenAdaptAI/.github", + "repository_id": "858454062", + "repository_owner_id": "132681217", + "ref": "refs/heads/production-lifecycle-feed", + "feed_revision": 1, + "generated_at": "2026-08-27T12:00:00Z", + "expires_at": None, + "registry_source_commit": "a" * 40, + "registry_revision": 3, + "registry_head_sha256": sha("8"), + "signer_registry": {}, + "checkpoints": [ + { + "checkpoint_reference": checkpoint_reference, + "checkpoint_bundle_reference": checkpoint_bundle_reference, + } + ], + } + trust.validate_feed( + feed, + now=datetime(2027, 9, 1, 12, 0, 0, tzinfo=timezone.utc), + ) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_public_trust_schemas.py b/tests/test_public_trust_schemas.py index 5e741b2..caad072 100644 --- a/tests/test_public_trust_schemas.py +++ b/tests/test_public_trust_schemas.py @@ -437,6 +437,7 @@ "$schema", "schema_version", "revision", + "admission_validity", "maximum_release_admission_days", "maximum_workflow_admission_days", "object_reference_schema_version", diff --git a/tests/test_qualification_issuer.py b/tests/test_qualification_issuer.py index 81fcf3f..6810d15 100644 --- a/tests/test_qualification_issuer.py +++ b/tests/test_qualification_issuer.py @@ -279,11 +279,16 @@ def current_trust_state( return self.authority, self.revocation -def trust_fixture(*, decision_origin: str = "aws-kms") -> dict: +def trust_fixture( + *, + decision_origin: str = "aws-kms", + expires_at: datetime | None = EXPIRES, +) -> dict: decision_key, _decision_raw, decision_spki = ed25519_material(1) authority_key, _authority_raw, _authority_spki = ed25519_material(33) revocation_key, _revocation_raw, _revocation_spki = ed25519_material(65) private_decision_key, _private_raw, _private_spki = ed25519_material(97) + expires_text = None if expires_at is None else utc(expires_at) if decision_origin == "aws-kms": decision_registry = kms.signer_registry_candidate( kms_public_key_projection={ @@ -295,7 +300,7 @@ def trust_fixture(*, decision_origin: str = "aws-kms") -> dict: }, revision=1, generated_at=NOW, - expires_at=EXPIRES, + expires_at=expires_at, ) registry = copy.deepcopy(decision_registry["proposed_registry"]) elif decision_origin == "software": @@ -303,7 +308,7 @@ def trust_fixture(*, decision_origin: str = "aws-kms") -> dict: "schema_version": "openadapt.qualification-signer-registry/v2", "revision": 1, "generated_at": utc(NOW), - "expires_at": utc(EXPIRES), + "expires_at": expires_text, "signers": [ ordinary_signer( decision_key, @@ -353,7 +358,7 @@ def trust_fixture(*, decision_origin: str = "aws-kms") -> dict: "evidence_authority_sha256": sha("synthetic-evidence-authority"), "observed_at": utc(NOW), "not_before": utc(NOW), - "expires_at": utc(EXPIRES), + "expires_at": expires_text, "issuer_key_id": registry["signers"][1]["key_id"], "algorithm": "ed25519", "signing_statement": None, @@ -393,7 +398,7 @@ def trust_fixture(*, decision_origin: str = "aws-kms") -> dict: "revocations": [], "observed_at": utc(NOW), "not_before": utc(NOW), - "expires_at": utc(EXPIRES), + "expires_at": expires_text, "issuer_key_id": registry["signers"][2]["key_id"], "algorithm": "ed25519", "signing_statement": None, @@ -460,7 +465,7 @@ def trust_fixture(*, decision_origin: str = "aws-kms") -> dict: "verdict": "ADMIT", "issued_at": utc(NOW), "not_before": utc(NOW), - "expires_at": utc(EXPIRES), + "expires_at": expires_text, "issuer_key_id": registry["signers"][0]["key_id"], "algorithm": "ed25519", "signing_statement": None, @@ -498,6 +503,7 @@ def trust_fixture(*, decision_origin: str = "aws-kms") -> dict: "receipt": receipt, "receipt_ref": receipt_ref, "receipt_bundle_ref": receipt_bundle_ref, + "expires_at": expires_text, } @@ -669,7 +675,7 @@ def flow_release_inputs( ), "issued_at": utc(NOW), "not_before": utc(NOW), - "expires_at": utc(EXPIRES), + "expires_at": fixture["expires_at"], "issuer": { "repository": "OpenAdaptAI/openadapt-evals", "repository_id": "1135998197", @@ -1045,7 +1051,11 @@ def test_release_verifier_uses_current_protected_main_state(self) -> None: def test_oidc_contract_is_exact_and_inactive(self) -> None: contract = kms.interface_contract() self.assertEqual(contract["activation_state"], "inactive") - self.assertEqual(issuer.interface_contract()["activation_state"], "inactive") + issuer_contract = issuer.interface_contract() + self.assertEqual(issuer_contract["activation_state"], "inactive") + self.assertEqual(issuer_contract["admission_validity"], "until_revoked") + self.assertIsNone(issuer_contract["workflow_maximum_lifetime_seconds"]) + self.assertIsNone(issuer_contract["release_maximum_lifetime_seconds"]) self.assertEqual(contract["aws_account_id"], "992382684924") commit = "a" * 40 claims = { @@ -1109,6 +1119,41 @@ def test_workflow_admission_derives_every_binding_and_consumes_once(self) -> Non consumer=consumer, ) + def test_null_expires_at_issues_until_revoked_admission(self) -> None: + fixture = trust_fixture(expires_at=None) + resolver = RecordingResolver(fixture) + admission = issuer.issue_workflow_admission( + workflow_request(fixture), + resolver=resolver, + issuer_source_commit="1" * 40, + now=NOW, + consumer=RecordingConsumer(), + ) + self.assertIsNone(admission["expires_at"]) + later = NOW + timedelta(days=400) + trust.validate_qualification_admission(admission, now=later) + release_request = flow_release_inputs(fixture, admission, resolver) + release = issuer.issue_release_admission( + release_request, + resolver=resolver, + issuer_source_commit="2" * 40, + now=NOW, + consumer=RecordingConsumer(), + ) + self.assertIsNone(release["expires_at"]) + trust.validate_release(release, now=later) + + def test_past_expires_at_does_not_issue(self) -> None: + fixture = trust_fixture(expires_at=NOW + timedelta(hours=1)) + with self.assertRaisesRegex(trust.TrustError, "not active"): + issuer.issue_workflow_admission( + workflow_request(fixture), + resolver=RecordingResolver(fixture), + issuer_source_commit="1" * 40, + now=NOW + timedelta(hours=2), + consumer=RecordingConsumer(), + ) + def test_workflow_root_must_be_in_the_issuer_source_commit(self) -> None: fixture = trust_fixture() fixture["receipt_ref"], fixture["receipt_bundle_ref"] = reference_pair( @@ -1283,7 +1328,6 @@ def test_release_admission_binds_flow_artifacts_and_full_chain(self) -> None: [item["kind"] for item in release["release"]["artifacts"]], ["python-sdist", "python-wheel"], ) - # The schema permits 30 days. The seven-day evidence chain contains it. self.assertEqual(release["expires_at"], utc(EXPIRES)) self.assertEqual(consumer.calls[0]["operation"], "issue-qualification-release") diff --git a/tests/test_qualification_software_ed25519.py b/tests/test_qualification_software_ed25519.py index 2dba317..31cc6cc 100644 --- a/tests/test_qualification_software_ed25519.py +++ b/tests/test_qualification_software_ed25519.py @@ -62,14 +62,38 @@ def test_registry_candidate_validates_without_kms_fields(self) -> None: ["github-environment-secret", "local-keychain-backup"], ) - def test_registry_candidate_refuses_window_over_seven_days(self) -> None: + def test_registry_candidate_accepts_null_expires_at(self) -> None: private_key = Ed25519PrivateKey.generate() - with self.assertRaisesRegex(software.SoftwareEd25519Error, "seven days"): + candidate = software.signer_registry_candidate( + public_material_value=software.public_material(private_key), + revision=1, + generated_at=NOW, + expires_at=None, + ) + registry = candidate["proposed_registry"] + self.assertIsNone(registry["expires_at"]) + evidence.validate_signer_registry(registry) + + def test_registry_candidate_accepts_window_over_seven_days(self) -> None: + private_key = Ed25519PrivateKey.generate() + candidate = software.signer_registry_candidate( + public_material_value=software.public_material(private_key), + revision=1, + generated_at=NOW, + expires_at=NOW + timedelta(days=8), + ) + evidence.validate_signer_registry(candidate["proposed_registry"]) + + def test_registry_candidate_refuses_expiry_before_generated_at(self) -> None: + private_key = Ed25519PrivateKey.generate() + with self.assertRaisesRegex( + software.SoftwareEd25519Error, "after generated_at" + ): software.signer_registry_candidate( public_material_value=software.public_material(private_key), revision=1, generated_at=NOW, - expires_at=NOW + timedelta(days=8), + expires_at=NOW, ) def test_sign_receipt_round_trip(self) -> None: