diff --git a/.kiro/specs/access-control-prover/tasks.md b/.kiro/specs/access-control-prover/tasks.md index e6076f9..4dfd5de 100644 --- a/.kiro/specs/access-control-prover/tasks.md +++ b/.kiro/specs/access-control-prover/tasks.md @@ -352,7 +352,7 @@ its final resolution, dominance, fact-acquisition and measurement semantics with - _Requirements: 13.2, 13.3_ - _Boundary: Installed Fact Authority Wire_ - _Depends: 14.2_ -- [ ] 14.4 Record the approved repo-local review ceremony +- [x] 14.4 Record the approved repo-local review ceremony - Write the three closed canonical identity manifests with the exact schema, role, name and approval-basis values declared by the design. - Bind bootstrap reviewer admission, custody policy, authority review and pack evidence identities to those manifest digests while keeping all trust/store admission outside `authority.json`. - Done when the approved manifests and bootstrap load through Task 14.3, all identity bindings validate, and changing any field or approval basis invalidates authentication. diff --git a/src/openultrasast/mapping/authority_ceremony.py b/src/openultrasast/mapping/authority_ceremony.py new file mode 100644 index 0000000..a688ab3 --- /dev/null +++ b/src/openultrasast/mapping/authority_ceremony.py @@ -0,0 +1,201 @@ +"""Approved repo-local review ceremony for installed entry authority.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from openultrasast.core import canonical +from openultrasast.mapping._installed_fact_authority_codec import parse_record, text +from openultrasast.mapping.fact_authority import ( + EntryAuthorityBootstrapPolicy, + EntryFactAuthorityBundle, + derive_entry_authority_bootstrap_policy_digest, +) +from openultrasast.mapping.installed_fact_authority import decode_bootstrap_bytes + +AUTHORITY_IDENTITY_SCHEMA = "openultrasast.access-control.authority-identity.v1" +INSTALLED_ARTIFACT_STORE_IDENTITY = "openultrasast-installed-entry-artifacts-v1" +_IDENTITY_FIELDS = ("schema_version", "role", "name", "approval_basis") +_MAXIMUM_BUNDLE_BYTES = 1_048_576 + + +class AuthorityCeremonyError(ValueError): + """Installed authority identities do not match the approved ceremony.""" + + +@dataclass(frozen=True) +class AuthorityIdentityManifest: + """Closed canonical identity whose digest is used at authority boundaries.""" + + schema_version: str + role: str + name: str + approval_basis: str + + @property + def identity_digest(self) -> str: + return canonical.bytes_digest(identity_manifest_bytes(self)) + + +APPROVED_IDENTITY_MANIFESTS = ( + AuthorityIdentityManifest( + AUTHORITY_IDENTITY_SCHEMA, + "producer", + "openultrasast-entry-pack-generator-v1", + "generated-from-reviewed-entry-data", + ), + AuthorityIdentityManifest( + AUTHORITY_IDENTITY_SCHEMA, + "reviewer", + "openultrasast-maintainer", + "maintainer-session-approval:2026-11-01", + ), + AuthorityIdentityManifest( + AUTHORITY_IDENTITY_SCHEMA, + "custodian", + "openultrasast-installed-entry-custodian-v1", + "checked-in-artifact-custody", + ), +) + + +@dataclass(frozen=True) +class InstalledAuthorityCeremony: + """Approved identities and the external host bootstrap policy.""" + + producer: AuthorityIdentityManifest + reviewer: AuthorityIdentityManifest + custodian: AuthorityIdentityManifest + bootstrap: EntryAuthorityBootstrapPolicy + + +def identity_manifest_bytes(value: AuthorityIdentityManifest) -> bytes: + """Encode an identity manifest using the closed canonical schema.""" + if not isinstance(value, AuthorityIdentityManifest): + raise AuthorityCeremonyError("identity manifest must use the typed contract") + record: dict[str, canonical.JsonValue] = { + "schema_version": value.schema_version, + "role": value.role, + "name": value.name, + "approval_basis": value.approval_basis, + } + try: + return canonical.canonical_record_bytes(record, known_schema_versions=(AUTHORITY_IDENTITY_SCHEMA,)) + except (TypeError, ValueError) as exc: + raise AuthorityCeremonyError(f"invalid identity manifest: {exc}") from exc + + +def decode_identity_manifest_bytes(content: bytes) -> AuthorityIdentityManifest: + """Decode only canonical bytes with the exact identity field set.""" + try: + row = parse_record(content, AUTHORITY_IDENTITY_SCHEMA, _IDENTITY_FIELDS, "authority identity") + value = AuthorityIdentityManifest( + text(row["schema_version"], "schema_version"), + text(row["role"], "role"), + text(row["name"], "name"), + text(row["approval_basis"], "approval_basis"), + ) + if identity_manifest_bytes(value) != content: + raise AuthorityCeremonyError("identity manifest does not round-trip canonically") + return value + except AuthorityCeremonyError: + raise + except (KeyError, TypeError, ValueError) as exc: + raise AuthorityCeremonyError(f"invalid authority identity: {exc}") from exc + + +def _approved_bootstrap(reviewer_digest: str) -> EntryAuthorityBootstrapPolicy: + reviewers = (reviewer_digest,) + forbidden: tuple[tuple[str, str], ...] = () + stores = (INSTALLED_ARTIFACT_STORE_IDENTITY,) + digest = derive_entry_authority_bootstrap_policy_digest( + reviewers, + forbidden, + stores, + _MAXIMUM_BUNDLE_BYTES, + ) + return EntryAuthorityBootstrapPolicy(reviewers, forbidden, stores, _MAXIMUM_BUNDLE_BYTES, digest) + + +def load_installed_authority_ceremony(entries_root: Path) -> InstalledAuthorityCeremony: + """Load and pin the checked-in manifests plus external bootstrap policy.""" + identity_root = entries_root / "identities" + filenames = ("generator.json", "maintainer-reviewer.json", "custodian.json") + try: + manifests = tuple(decode_identity_manifest_bytes((identity_root / name).read_bytes()) for name in filenames) + bootstrap = decode_bootstrap_bytes((entries_root / "bootstrap.json").read_bytes()) + except OSError as exc: + raise AuthorityCeremonyError("installed ceremony record is unavailable") from exc + except ValueError as exc: + raise AuthorityCeremonyError(f"installed ceremony record is invalid: {exc}") from exc + if manifests != APPROVED_IDENTITY_MANIFESTS: + raise AuthorityCeremonyError("installed identity fields differ from the approved ceremony") + expected_bootstrap = _approved_bootstrap(manifests[1].identity_digest) + if bootstrap != expected_bootstrap: + raise AuthorityCeremonyError("installed bootstrap differs from the approved ceremony") + return InstalledAuthorityCeremony(*manifests, bootstrap) + + +def _require_binding(condition: bool, label: str) -> None: + if not condition: + raise AuthorityCeremonyError(f"{label} identity binding does not match the approved ceremony") + + +def _validate_pack_bindings( + bundle: EntryFactAuthorityBundle, + producer_digest: str, + reviewer_digest: str, +) -> None: + for pack in bundle.packs: + _require_binding(pack.review_identity == reviewer_digest, "pack review") + for evidence in pack.review_evidence: + _require_binding(evidence.producer_identity == producer_digest, "pack producer") + _require_binding(evidence.reviewer_identity == reviewer_digest, "pack reviewer") + _require_binding(evidence.independence.producer_identity == producer_digest, "pack attestation producer") + _require_binding(evidence.independence.reviewer_identity == reviewer_digest, "pack attestation reviewer") + + +def validate_authority_ceremony_bindings( + ceremony: InstalledAuthorityCeremony, + bundle: EntryFactAuthorityBundle, +) -> None: + """Require every authority and pack identity to use the manifest digests.""" + producer = ceremony.producer.identity_digest + reviewer = ceremony.reviewer.identity_digest + custodian = ceremony.custodian.identity_digest + bootstrap = ceremony.bootstrap + _require_binding(bootstrap.reviewed_bundle_reviewer_digests == (reviewer,), "bootstrap reviewer admission") + _require_binding(bootstrap.trusted_store_identities == (INSTALLED_ARTIFACT_STORE_IDENTITY,), "bootstrap store admission") + expected_policy = derive_entry_authority_bootstrap_policy_digest( + bootstrap.reviewed_bundle_reviewer_digests, + bootstrap.forbidden_identity_pairs, + bootstrap.trusted_store_identities, + bootstrap.maximum_bundle_bytes, + ) + _require_binding(bootstrap.policy_digest == expected_policy, "bootstrap policy") + _require_binding(producer != reviewer, "producer/reviewer separation") + _require_binding((producer, reviewer) not in bootstrap.forbidden_identity_pairs, "forbidden pair") + _require_binding(bundle.review_policy.reviewed_reviewer_digests == (reviewer,), "review policy reviewer admission") + _require_binding( + bundle.review_policy.trusted_store_identities == bootstrap.trusted_store_identities, + "review policy store admission", + ) + _require_binding(bundle.custody_policy.reviewed_custodian_digests == (custodian,), "custody") + _require_binding(bundle.authority_review.producer_identity == producer, "authority producer") + _require_binding(bundle.authority_review.reviewer_identity == reviewer, "authority reviewer") + _validate_pack_bindings(bundle, producer, reviewer) + + +__all__ = [ + "APPROVED_IDENTITY_MANIFESTS", + "AUTHORITY_IDENTITY_SCHEMA", + "INSTALLED_ARTIFACT_STORE_IDENTITY", + "AuthorityCeremonyError", + "AuthorityIdentityManifest", + "InstalledAuthorityCeremony", + "decode_identity_manifest_bytes", + "identity_manifest_bytes", + "load_installed_authority_ceremony", + "validate_authority_ceremony_bindings", +] diff --git a/src/openultrasast/ruleset/entries/bootstrap.json b/src/openultrasast/ruleset/entries/bootstrap.json new file mode 100644 index 0000000..46acf2d --- /dev/null +++ b/src/openultrasast/ruleset/entries/bootstrap.json @@ -0,0 +1 @@ +{"forbidden_identity_pairs":[],"maximum_bundle_bytes":1048576,"policy_digest":"61045d1142ded9756329bc2a0fad3d656bc13b971f4c7c615017eecd86da128b","reviewed_bundle_reviewer_digests":["f12a7e897cbaf3059619517e1f8a725b90049a0edf3af08efa12fb1ef7fdee43"],"schema_version":"openultrasast.access-control.entry-authority-bootstrap.v2","trusted_store_identities":["openultrasast-installed-entry-artifacts-v1"]} \ No newline at end of file diff --git a/src/openultrasast/ruleset/entries/identities/custodian.json b/src/openultrasast/ruleset/entries/identities/custodian.json new file mode 100644 index 0000000..37fa188 --- /dev/null +++ b/src/openultrasast/ruleset/entries/identities/custodian.json @@ -0,0 +1 @@ +{"approval_basis":"checked-in-artifact-custody","name":"openultrasast-installed-entry-custodian-v1","role":"custodian","schema_version":"openultrasast.access-control.authority-identity.v1"} \ No newline at end of file diff --git a/src/openultrasast/ruleset/entries/identities/generator.json b/src/openultrasast/ruleset/entries/identities/generator.json new file mode 100644 index 0000000..2ce7dba --- /dev/null +++ b/src/openultrasast/ruleset/entries/identities/generator.json @@ -0,0 +1 @@ +{"approval_basis":"generated-from-reviewed-entry-data","name":"openultrasast-entry-pack-generator-v1","role":"producer","schema_version":"openultrasast.access-control.authority-identity.v1"} \ No newline at end of file diff --git a/src/openultrasast/ruleset/entries/identities/maintainer-reviewer.json b/src/openultrasast/ruleset/entries/identities/maintainer-reviewer.json new file mode 100644 index 0000000..b6ad351 --- /dev/null +++ b/src/openultrasast/ruleset/entries/identities/maintainer-reviewer.json @@ -0,0 +1 @@ +{"approval_basis":"maintainer-session-approval:2026-11-01","name":"openultrasast-maintainer","role":"reviewer","schema_version":"openultrasast.access-control.authority-identity.v1"} \ No newline at end of file diff --git a/tests/test_authority_ceremony.py b/tests/test_authority_ceremony.py new file mode 100644 index 0000000..2d69c19 --- /dev/null +++ b/tests/test_authority_ceremony.py @@ -0,0 +1,212 @@ +"""Approved repo-local identities for installed entry-fact authority.""" + +from __future__ import annotations + +import json +from dataclasses import replace +from pathlib import Path + +import pytest +from test_fact_authority import _case + +from openultrasast.core import canonical, provenance +from openultrasast.core.factpack import ( + derive_review_evidence_digest, + derive_review_independence_policy_digest, +) +from openultrasast.mapping.authority_ceremony import ( + APPROVED_IDENTITY_MANIFESTS, + INSTALLED_ARTIFACT_STORE_IDENTITY, + AuthorityCeremonyError, + InstalledAuthorityCeremony, + decode_identity_manifest_bytes, + identity_manifest_bytes, + load_installed_authority_ceremony, + validate_authority_ceremony_bindings, +) +from openultrasast.mapping.fact_authority import EntryFactAuthorityBundle +from openultrasast.mapping.installed_fact_authority import decode_bootstrap_bytes + +_ENTRIES = Path(__file__).parents[1] / "src" / "openultrasast" / "ruleset" / "entries" + + +def _canonical_json(value: object) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + + +def _bound_bundle(ceremony: InstalledAuthorityCeremony) -> EntryFactAuthorityBundle: + bundle = _case().bundle + producer = ceremony.producer.identity_digest + reviewer = ceremony.reviewer.identity_digest + custodian = ceremony.custodian.identity_digest + packs = [] + for pack in bundle.packs: + evidence = tuple( + replace( + item, + producer_identity=producer, + reviewer_identity=reviewer, + independence=replace( + item.independence, + producer_identity=producer, + reviewer_identity=reviewer, + ), + ) + for item in pack.review_evidence + ) + packs.append( + replace( + pack, + review_identity=reviewer, + review_evidence=evidence, + review_evidence_digest=derive_review_evidence_digest(evidence), + ) + ) + custody = provenance.PopulationCustodyPolicy( + (custodian,), + provenance.derive_population_custody_policy_digest((custodian,)), + ) + stores = ceremony.bootstrap.trusted_store_identities + policy = replace( + bundle.review_policy, + reviewed_reviewer_digests=(reviewer,), + trusted_store_identities=stores, + ) + policy = replace( + policy, + policy_digest=derive_review_independence_policy_digest( + policy.reviewed_reviewer_digests, + policy.forbidden_identity_pairs, + policy.trusted_store_identities, + policy.maximum_evidence_bytes, + ), + ) + packs = [replace(pack, review_policy_digest=policy.policy_digest) for pack in packs] + return replace( + bundle, + packs=tuple(packs), + custody_policy=custody, + review_policy=policy, + authority_review=replace( + bundle.authority_review, + producer_identity=producer, + reviewer_identity=reviewer, + ), + ) + + +def test_installed_ceremony_loads_canonical_nonempty_records() -> None: + paths = ( + _ENTRIES / "identities" / "generator.json", + _ENTRIES / "identities" / "maintainer-reviewer.json", + _ENTRIES / "identities" / "custodian.json", + _ENTRIES / "bootstrap.json", + ) + assert all(path.stat().st_size > 0 for path in paths) + + ceremony = load_installed_authority_ceremony(_ENTRIES) + + assert (ceremony.producer, ceremony.reviewer, ceremony.custodian) == APPROVED_IDENTITY_MANIFESTS + assert ceremony.bootstrap == decode_bootstrap_bytes((_ENTRIES / "bootstrap.json").read_bytes()) + assert ceremony.bootstrap.reviewed_bundle_reviewer_digests == (ceremony.reviewer.identity_digest,) + assert ceremony.bootstrap.trusted_store_identities == (INSTALLED_ARTIFACT_STORE_IDENTITY,) + + +@pytest.mark.parametrize("mutation", ("unknown", "missing", "whitespace")) +def test_identity_decoder_rejects_open_or_noncanonical_records(mutation: str) -> None: + raw = json.loads(identity_manifest_bytes(APPROVED_IDENTITY_MANIFESTS[0])) + if mutation == "unknown": + raw["extra"] = "not-approved" + content = _canonical_json(raw) + elif mutation == "missing": + del raw["approval_basis"] + content = _canonical_json(raw) + else: + content = json.dumps(raw, indent=2).encode() + + with pytest.raises(AuthorityCeremonyError): + decode_identity_manifest_bytes(content) + + +def test_identity_decoder_rejects_changed_schema_field() -> None: + raw = json.loads(identity_manifest_bytes(APPROVED_IDENTITY_MANIFESTS[0])) + raw["schema_version"] = "openultrasast.access-control.authority-identity.changed" + + with pytest.raises(AuthorityCeremonyError): + decode_identity_manifest_bytes(_canonical_json(raw)) + + +def test_synthetic_authority_and_pack_identities_bind_to_ceremony() -> None: + ceremony = load_installed_authority_ceremony(_ENTRIES) + + validate_authority_ceremony_bindings(ceremony, _bound_bundle(ceremony)) + + +@pytest.mark.parametrize( + ("part", "field"), + ( + ("producer", "name"), + ("producer", "role"), + ("producer", "approval_basis"), + ("reviewer", "name"), + ("reviewer", "role"), + ("reviewer", "approval_basis"), + ("custodian", "name"), + ("custodian", "role"), + ("custodian", "approval_basis"), + ), +) +def test_changed_identity_field_changes_digest_and_invalidates_binding(part: str, field: str) -> None: + ceremony = load_installed_authority_ceremony(_ENTRIES) + bundle = _bound_bundle(ceremony) + manifest = getattr(ceremony, part) + changed = replace(manifest, **{field: f"changed-{getattr(manifest, field)}"}) + decoded = decode_identity_manifest_bytes(identity_manifest_bytes(changed)) + changed_ceremony = replace(ceremony, **{part: decoded}) + + assert decoded.identity_digest != manifest.identity_digest + with pytest.raises(AuthorityCeremonyError, match="identity binding"): + validate_authority_ceremony_bindings(changed_ceremony, bundle) + + +@pytest.mark.parametrize( + "binding", + ( + "bootstrap", + "bootstrap-store", + "review-policy-reviewer", + "review-policy-store", + "custody", + "authority-producer", + "authority-reviewer", + "pack-producer", + "pack-reviewer", + ), +) +def test_every_ceremony_binding_is_required(binding: str) -> None: + ceremony = load_installed_authority_ceremony(_ENTRIES) + bundle = _bound_bundle(ceremony) + other = canonical.content_digest(f"other-{binding}") + if binding == "bootstrap": + changed = replace(ceremony.bootstrap, reviewed_bundle_reviewer_digests=(other,)) + ceremony = replace(ceremony, bootstrap=changed) + elif binding == "bootstrap-store": + changed = replace(ceremony.bootstrap, trusted_store_identities=("other-store",)) + ceremony = replace(ceremony, bootstrap=changed) + elif binding == "review-policy-reviewer": + bundle = replace(bundle, review_policy=replace(bundle.review_policy, reviewed_reviewer_digests=(other,))) + elif binding == "review-policy-store": + bundle = replace(bundle, review_policy=replace(bundle.review_policy, trusted_store_identities=("other-store",))) + elif binding == "custody": + bundle = replace(bundle, custody_policy=replace(bundle.custody_policy, reviewed_custodian_digests=(other,))) + elif binding.startswith("authority-"): + field = binding.removeprefix("authority-") + "_identity" + bundle = replace(bundle, authority_review=replace(bundle.authority_review, **{field: other})) + else: + field = binding.removeprefix("pack-") + "_identity" + pack = bundle.packs[0] + evidence = replace(pack.review_evidence[0], **{field: other}) + bundle = replace(bundle, packs=(replace(pack, review_evidence=(evidence,)), *bundle.packs[1:])) + + with pytest.raises(AuthorityCeremonyError, match="identity binding"): + validate_authority_ceremony_bindings(ceremony, bundle)