diff --git a/README.md b/README.md index 145e1fb..be25adc 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,9 @@ The first Campaign-centric capability and infrastructure fit assessment is in `docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md`. Its rerun tooling can collect and verify a bounded installed composition; target, provider and production claims remain separate, expiring attestations signed by independently scoped proof -authorities. +authorities. The operational issuance, target-run, recovery-measurement, key +custody, and promotion-gate procedure is in +[Target Maturity Evidence Runbook](docs/TARGET_MATURITY_EVIDENCE_RUNBOOK.md). # GovOPlaN Docker diff --git a/docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md b/docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md index 0d03645..7f35f95 100644 --- a/docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md +++ b/docs/CAPABILITY_AND_INFRASTRUCTURE_FIT.md @@ -503,6 +503,20 @@ Signatures cover UTF-8 JSON with the `signatures` member omitted, object keys sorted, compact `,`/`:` separators and non-ASCII characters escaped, matching the tool's deterministic canonicalization. +`tools/assessments/boundary-evidence.py` is the bounded issuance path. It +accepts a private target-run manifest conforming to +[`capability-fit-boundary-run.schema.json`](capability-fit-boundary-run.schema.json), +hashes each retained result file without following a final-component symlink, +and excludes all paths and raw results from the signed receipt. Issuance is +refused unless an independently trusted catalog, exact installed payload, +signed installer receipt, and role-scoped installer authority already pass. +Every claim must be covered by a supplied Ed25519 private key whose public key +is authorized for the full proof interval; catalog and installer key reuse is +rejected. The command immediately verifies its own result and atomically writes +both the proof and a sanitized review. The complete operator procedure and +recovery measurement definition are in +[`TARGET_MATURITY_EVIDENCE_RUNBOOK.md`](TARGET_MATURITY_EVIDENCE_RUNBOOK.md). + ```bash ./.venv/bin/python tools/assessments/capability-fit.py \ --public \ @@ -513,6 +527,14 @@ the tool's deterministic canonicalization. --expected-external-provider-subject provider-production ``` +Promotion automation must opt into its required boundaries. Add +`--require-reference-readiness` to require all six target scopes, +`--require-external-provider-proof` when the product depends on a provider, and +`--require-production-approval` for production admission. These switches turn +missing, expired, revoked, mismatched, negative, or otherwise unchecked claims +into a blocking exit status rather than merely reporting them as an unproven +boundary. + With `--installed-evidence`, this command performs comparison and proof-binding diagnostics. Without an installer receipt, the imported document remains unsigned, so neither it nor the boundary claim becomes accepted proof. Direct diff --git a/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md b/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md index 78cc3d7..534a642 100644 --- a/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md +++ b/docs/INSTITUTIONAL_GOVERNANCE_TARGET_ARCHITECTURE.md @@ -61,7 +61,7 @@ accepted as the current baseline and must not be reopened as greenfield work. | Acting identity, function assignment, mandate, and ownership recovery | Implemented foundations span Identity, Organizations, IDM, Access, Mandates, generic ownership transfer/recovery, and audit provenance. Effective competence now resolves through a tenant-bound Mandate capability. | | Governed data foundations | Connectors, Datasources, Dataflow, Reporting, and Search now exist. Datasources already provides live/cached/static modes, staging, immutable materializations, and publication contracts. | | Task-focused projections and configured documentation | Views, view-surface declarations, configurable dashboards, and manifest-driven user/admin documentation exist. Rollout and content depth remain incremental. | -| Encryption as an optional capability | Core defines provider-neutral contracts; `govoplan-identity-trust` persists public device keys, key epochs and assurance evidence; and `govoplan-encryption` persists opaque vault/key lifecycle, versioned protection envelopes, quorum recovery authorization, outcome-unknown reconciliation, and disable preflight. No concrete cipher/provider or E2EE claim is bundled. | +| Encryption as an optional capability | Core defines provider-neutral contracts; `govoplan-identity-trust` persists public device keys, key epochs and assurance evidence; and `govoplan-encryption` persists opaque vault/key lifecycle, versioned protection envelopes, quorum recovery authorization, outcome-unknown reconciliation, and disable preflight. A bundled local AES-256-GCM server-envelope provider now stores wrapped key material and supports Files/Postbox protection, rotation, revocation, destruction, rewrap, tamper detection, and fail-closed restore behavior. It is explicitly neither E2EE nor a certified KMS/HSM. | | Search without mandatory OpenSearch | PostgreSQL-backed, permission-aware search and module provider contracts exist; OpenSearch remains an optional adapter. | | Scale-out and recovery architecture | Stateless API/worker, shared database/object storage, event delivery, deployment, and recovery contracts are documented and partly exercised. Production profiles and drills remain active work. | @@ -83,10 +83,10 @@ were proven now have independent persistent owners: | External providers | Core validates provider objects/field groups, operations, integration maturity, source authority, bounded reads, freshness/health, idempotency, conflicts, outcome-unknown handling, evidence, correction, reconciliation, outage, classification, purpose, retention, and secret handling. Addresses/CardDAV, Files remote storage, Mail SMTP/IMAP, Calendar CalDAV/ICS/Graph/EWS, and Connectors tabular/sanctions providers declare the contract and tenant-bounded secret-free runtime state. | Registry validation rejects any declared external provider without a sanitized state provider. Future adapters must cross the same gate before activation. | | Institutional context | Core provides versioned temporal, actor/representation, institution/unit/function/task/mandate/jurisdiction/service/case/party/work-item/workflow/approval/decision/record, legal-basis, evidence, information-governance, external-source, presentation, and geographic references. Events, automation actions, audit records, and the transactional Audit outbox preserve the envelope. | Owning modules must progressively require the relevant subset for consequential operations. | | Semantic provider contracts | Provider-neutral DTOs and protocols cover Mandate resolution, versioned Service definitions, procedure Parties/representation, and formal Decisions. `govoplan-mandates`, `govoplan-services`, `govoplan-parties`, and `govoplan-decisions` now persist immutable revisions behind those contracts with tenant isolation, bounded reads, replay safety, OCC, migrations, uninstall guards, permissions, APIs, capability documentation, and recovery documentation. | The owners are deliberately headless. Procedure-specific UI remains with consuming modules. | -| Formal-outcome proof | Committee persists bodies, meetings, agenda items, minutes, lifecycle events, and an optional protected local Decision projection. Voting separately owns immutable ballot definitions, frozen electorates, recorded casting/replacement, deterministic tally, certification, challenge, annulment, and provider-backed assurance profiles. Committee consumes `voting.ballots` and retains only deliberation linkage and verified aggregate outcome. | Native recorded ballots are reconstructable, not secret. A concrete confidential/secret/certified provider and deployment assurance still require protocol/custody/legal decisions and target evidence. | +| Formal-outcome proof | Committee persists bodies, meetings, agenda items, minutes, lifecycle events, and an optional protected local Decision projection. Voting separately owns immutable ballot definitions, frozen electorates, recorded casting/replacement, deterministic tally, certification, challenge, annulment, and provider-backed assurance profiles. Committee consumes `voting.ballots` and retains only deliberation linkage and verified aggregate outcome. A bundled `local_confidential` reference provider encrypts server-readable casts outside native ballot rows and exposes only receipts plus aggregate evidence to Committee. | Native recorded ballots remain reconstructable, and the reference confidential provider is neither secret nor certified: its server can decrypt casts while tallying. Secret/electronic-ballot protocol selection, custody, legal acceptance, independent review, and target evidence remain explicit product decisions. | | Service-to-case proof | Services owns the persistent exact definitions consumed by Portal discovery and Cases intake. Forms owns immutable multi-page/conditional/localized schemas, accessibility assessment and package fragments. Forms Runtime owns definition-aware drafts, validation, submission receipts, status/evidence history, and durable native Case/Workflow handoffs with intent-before-effect and outcome-unknown reconciliation. Portal delegates URL, Case, Form, or Workflow launch to an installed owner while retaining exact Service/Form provenance. | Anonymous intake and concrete attachment/signature providers remain product depth. Portal and Runtime fail closed and explain any absent launcher, target capability, or provider prerequisite. | | Generic approvals and process execution | Approvals persists exact-subject chains, delegation, separation of duties, quorum, signatures-as-evidence, escalation, OCC, and replay-safe decisions. Campaign proves an exact-version delivery gate. Workflow Engine owns immutable definitions/instances plus API, schedule, event and parent triggers, durable timer/event waits, scale-out claims, current-authority rechecks, and idempotent starts independently of the optional editor. | Policy-authored Approval template selection, concrete signature providers, cron adapters, and broader BPMN execution profiles are product/provider depth on explicit contracts. | -| Device trust and content protection | Identity Trust separates public device keys, epochs, assurance and key-access decisions from login and Access. Encryption separates resource ownership from opaque provider key custody, versioned envelopes, migration evidence, quorum recovery authorization and uninstall proof. Files/Postbox fixtures prove the optional boundary. | Concrete reviewed KMS/HSM/client providers, feature adapters, backup/restore/key-loss drills, and E2EE interoperability/certification remain required before a deployment protection claim. | +| Device trust and content protection | Identity Trust separates public device keys, epochs, assurance and key-access decisions from login and Access. Encryption separates resource ownership from opaque provider key custody, versioned envelopes, migration evidence, quorum recovery authorization and uninstall proof. Its local server-envelope provider and Files/Postbox adapters prove ciphertext persistence, integrity, rotation/rewrap and fail-closed key loss without leaking plaintext keys across the capability boundary. | Reviewed KMS/HSM/client providers, more owner adapters, target backup/restore/key-loss drills, and E2EE interoperability/certification remain required before stronger deployment claims. | | Procedure-party proof | Parties persists effective procedure roles, frozen contact snapshots, and representation powers. Existing powers cannot disappear or be silently rewritten; explicit OCC-guarded revocation is required. Cases resolves the provider capability and excludes expired/revoked authority from downstream delivery. | Procedure modules still decide which contextual fields and actions to present. | | Integrated institutional journey | The executable `product.service-to-decision` fixture uses real SQL-backed Services, Cases, Parties, Mandates, Committee, and Decisions providers. It carries one exact Service version through persisted Case intake, representation and frozen delivery authority, effective Mandate resolution, a body/meeting/agendum/vote/minute sequence, a persisted formal Decision, confirmed Postbox effect, Audit/record evidence, remedy/review, and protected reconstruction. A second executable path proves Portal to exact Form revision, persisted submission, and idempotent replay. | This is architecture and composition evidence. Signed, release-bound target accessibility, privacy, security, operator, delivery-provider, and recovery-drill evidence is still required before the package may claim `reference_ready`. | | Governed data catalogue | Datasources stores typed governance metadata, exposes bounded tenant-scoped filters and update APIs/UI, carries governance through staging, and snapshots it into immutable materializations. Reporting now persists immutable dataset, semantic-model, report, quality-plan, saved-view, and schedule revisions; executes typed semantic queries with quality gates, access checks, replay, pivoting, export/import assessment, and provenance; and exposes the governed analytical WebUI. | Rich dependency/impact traversal, additional expression functions, and policy-specific field visibility can grow on the established contracts without moving connector, transformation, or source ownership. | @@ -464,7 +464,10 @@ truthfully completed by adding generic platform code: must produce signed, expiring accessibility, privacy, security, operator, provider, backup/restore, rollback, and recovery-drill evidence. The verifier and schemas are implemented; the actual claims require those real runs. - The pinned-release evidence run is tracked in + A bounded issuer now hashes retained reports, checks role-scoped signing + authority and exact installed-release origin, emits sanitized signed + receipts, verifies them immediately, and exposes admission-enforcing CLI + gates. The real pinned-release evidence run is tracked in [GovOPlaN #37](https://git.add-ideas.de/GovOPlaN/govoplan/issues/37). Forms and Forms Runtime no longer constitute an architecture gap. Conditional @@ -533,8 +536,9 @@ Its implementation work packages and resulting owners are: - `govoplan-voting`: governed recorded ballots plus fail-closed provider-backed assurance profiles consumed by Committee; and - Identity Trust #1 and Encryption #1-#3: public device trust, provider-neutral - key/protection lifecycle, recovery authorization and disable proof, while - concrete provider conformance remains separately gated. + key/protection lifecycle, recovery authorization and disable proof, plus a + bounded local server-envelope provider and Files/Postbox fixtures; external + KMS/HSM/client-provider conformance remains separately gated. Existing Projects #1, Reporting #4, Portal #1, Cases #1, Datasources #1, Risk Compliance #2, GovOPlaN #14, and GovOPlaN #19 carry product-depth and diff --git a/docs/TARGET_MATURITY_EVIDENCE_RUNBOOK.md b/docs/TARGET_MATURITY_EVIDENCE_RUNBOOK.md new file mode 100644 index 0000000..c618a61 --- /dev/null +++ b/docs/TARGET_MATURITY_EVIDENCE_RUNBOOK.md @@ -0,0 +1,153 @@ +# Target Maturity Evidence Runbook + +This runbook turns retained target-environment results into a sanitized, +signed GovOPlaN capability-fit proof. It does not make a deployment suitable, +certified, supported, or production-approved by itself. The proof records what +independent authorities assessed against one exact installed release. + +## Roles and custody + +Use separate trust domains for release signing, installation receipts, +boundary assessment, and production approval. A private proof key must be +provisioned outside the assessed application and its matching public key must +already exist in a separately managed +`capability-fit-proof-authority-keyring.schema.json` document. Do not store +private keys, raw reports, credentials, personal data, backup material, or +target endpoints in Git. + +Each authority key lists only the scopes that role may attest. At least one +supplied signing key must cover every claim, and the issuer rejects a key that: + +- is absent, inactive, expired, or revoked in the authority keyring; +- expires before the proof; +- does not match its independently provisioned public key; +- reuses release-catalog or installer-authority key material. + +## Target run + +Install one pinned catalog release and issue its installed-composition receipt +with `tools/assessments/installer-receipt.py`. Exercise the actual target +topology, including: + +- PostgreSQL and Redis as shared state services; +- shared S3-compatible object storage; +- at least two stateless API replicas and the intended worker topology; +- fenced singleton work, ingress, certificates, proxy headers, and the real + network/trust boundary; +- provider health and freshness for every provider required by the product; +- monitoring, alerting, failure response, accessibility, privacy, and security + controls; +- backup, isolated restore, failed-deployment rollback, and forward recovery. + +For the recovery claim, retain the observed recovery point, measured RPO and +RTO, database/object-store consistency result, and semantic reconstruction of +the institutional and Service/Form reference journeys. Measure RPO from the +last acknowledged durable effect that survives recovery and RTO until service +health plus semantic reconstruction pass. Failed runs are evidence too and +must use a negative result. + +The private reports stay in the approved evidence store. Give each report an +opaque artifact ID and each evaluated control a versioned opaque control ID. + +## Private claim manifest + +Create a private manifest conforming to +`capability-fit-boundary-run.schema.json`. Relative artifact paths resolve from +the manifest directory. Paths are read and hashed by the issuer and are never +copied into the signed output. + +```json +{ + "$schema": "./capability-fit-boundary-run.schema.json", + "schema_version": "0.1.0", + "evidence_kind": "govoplan.capability-fit-boundary-run", + "proof_id": "target:production:20260802", + "expires_at": "2026-09-01T00:00:00Z", + "claims": [ + { + "scope": "target_environment", + "result": "passed", + "control_ids": ["topology:shared-state-v1"], + "artifacts": [ + {"artifact_id": "target:run-20260802", "path": "private/target.json"} + ] + }, + { + "scope": "recovery", + "result": "passed", + "control_ids": ["recovery:restore-rollback-v1"], + "artifacts": [ + {"artifact_id": "recovery:run-20260802", "path": "private/recovery.json"} + ] + } + ] +} +``` + +Reference readiness needs positive `target_environment`, `accessibility`, +`privacy`, `security`, `operations`, and `recovery` claims. Provider acceptance +and production approval are separate scopes. A production-approval authority +must not approve its own unreviewed target run. + +## Issue and verify + +Issue only while the signed installer observation is current. Repeat +`--signing-key` when multiple independent roles are needed. The command first +verifies the catalog, independent catalog trust root, exact installed payload, +installer receipt, and installer authority. It then hashes artifacts, signs the +sanitized proof, verifies it immediately, and writes both proof and review with +atomic private-file permissions. + +```bash +./.venv/bin/python tools/assessments/boundary-evidence.py \ + --assessment /srv/govoplan/assessment.json \ + --catalog /srv/govoplan/catalogs/stable.json \ + --keyring /srv/govoplan/catalogs/keyring.json \ + --trusted-keyring /srv/govoplan/trust/catalog-keyring.json \ + --installed-evidence /srv/govoplan/evidence/installed.json \ + --installer-receipt /srv/govoplan/evidence/installer-receipt.json \ + --installer-authority-keyring /srv/govoplan/trust/installer-authorities.json \ + --claims /srv/govoplan/evidence/private/target-run.json \ + --authority-keyring /srv/govoplan/trust/proof-authorities.json \ + --signing-key authority-target=/run/secrets/target-proof-ed25519.pem \ + --output /srv/govoplan/evidence/target-proof.json \ + --review-output /srv/govoplan/evidence/target-proof-review.json +``` + +Add `--expected-external-provider-subject provider-production` when the claim +manifest contains `external_providers`. This value is an opaque deployment ID, +not a URL or credential. + +## Promotion gate + +The general verifier can now be made admission-enforcing. These switches return +a blocking exit status when a required claim is absent, expired, negative, +revoked, or bound to another assessment, release, installation, or subject: + +```bash +./.venv/bin/python tools/assessments/capability-fit.py \ + --catalog /srv/govoplan/catalogs/stable.json \ + --keyring /srv/govoplan/catalogs/keyring.json \ + --trusted-keyring /srv/govoplan/trust/catalog-keyring.json \ + --installed-evidence /srv/govoplan/evidence/installed.json \ + --installer-receipt /srv/govoplan/evidence/installer-receipt.json \ + --installer-authority-keyring /srv/govoplan/trust/installer-authorities.json \ + --boundary-evidence /srv/govoplan/evidence/target-proof.json \ + --boundary-authority-keyring /srv/govoplan/trust/proof-authorities.json \ + --require-reference-readiness \ + --require-production-approval \ + --output /srv/govoplan/evidence/admission-review.json +``` + +Use `--require-external-provider-proof` as well when the promoted product +requires an external provider. Live admission must not use +`--verification-time`; that switch is only for clearly labelled historical +review. + +## Renewal and failure + +Renew evidence after release, installed composition, deployment, control, or +provider changes and before expiry. Revoke an authority key immediately after +custody loss and rerun the affected assessment with a new independent key. +Never copy a previous positive claim to a new release. Preserve negative and +superseded receipts according to the approved evidence-retention policy. diff --git a/docs/capability-fit-boundary-run.schema.json b/docs/capability-fit-boundary-run.schema.json new file mode 100644 index 0000000..ce3da25 --- /dev/null +++ b/docs/capability-fit-boundary-run.schema.json @@ -0,0 +1,103 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://git.add-ideas.de/GovOPlaN/govoplan/src/branch/main/docs/capability-fit-boundary-run.schema.json", + "title": "GovOPlaN private target-run claim manifest", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "evidence_kind", + "proof_id", + "expires_at", + "claims" + ], + "properties": { + "$schema": { + "type": "string", + "format": "uri-reference" + }, + "schema_version": { + "const": "0.1.0" + }, + "evidence_kind": { + "const": "govoplan.capability-fit-boundary-run" + }, + "proof_id": { + "$ref": "#/$defs/opaque_id" + }, + "expires_at": { + "type": "string", + "format": "date-time" + }, + "claims": { + "type": "array", + "minItems": 1, + "maxItems": 8, + "items": { + "$ref": "#/$defs/claim" + } + } + }, + "$defs": { + "opaque_id": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" + }, + "claim": { + "type": "object", + "additionalProperties": false, + "required": ["scope", "result", "control_ids", "artifacts"], + "properties": { + "scope": { + "enum": [ + "target_environment", + "external_providers", + "accessibility", + "privacy", + "security", + "operations", + "recovery", + "production_approval" + ] + }, + "result": { + "enum": ["passed", "failed", "approved", "rejected"] + }, + "control_ids": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/opaque_id" + } + }, + "artifacts": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "items": { + "$ref": "#/$defs/artifact" + } + } + } + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["artifact_id", "path"], + "properties": { + "artifact_id": { + "$ref": "#/$defs/opaque_id" + }, + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + } + } + } +} diff --git a/tests/test_boundary_evidence_issuer.py b/tests/test_boundary_evidence_issuer.py new file mode 100644 index 0000000..fd913cd --- /dev/null +++ b/tests/test_boundary_evidence_issuer.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import base64 +from datetime import UTC, datetime +import json +from pathlib import Path +import sys +import tempfile +import unittest + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + +META_ROOT = Path(__file__).resolve().parents[1] +ASSESSMENT_TOOLS_ROOT = META_ROOT / "tools" / "assessments" +RELEASE_TOOLS_ROOT = META_ROOT / "tools" / "release" +for tools_root in (ASSESSMENT_TOOLS_ROOT, RELEASE_TOOLS_ROOT): + if str(tools_root) not in sys.path: + sys.path.insert(0, str(tools_root)) + +from govoplan_assessment.boundary_evidence import ( # noqa: E402 + issue_boundary_evidence, +) +from govoplan_assessment.evidence import ( # noqa: E402 + InstalledEvidenceReview, + canonical_sha256, + review_boundary_evidence, + validate_payload, +) + + +class BoundaryEvidenceIssuerTests(unittest.TestCase): + def setUp(self) -> None: + self.assessment = { + "assessment_id": "assessment:test", + "release": {"ref": "stable-catalog-202608020001"}, + "deployment_profile": {"id": "deployment:target"}, + } + self.installed = { + "schema_version": "0.1.0", + "assessment_id": "assessment:test", + "artifacts": [], + } + self.private_key = Ed25519PrivateKey.generate() + public_key = base64.b64encode( + self.private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + ).decode("ascii") + self.authority = { + "$schema": "./capability-fit-proof-authority-keyring.schema.json", + "schema_version": "0.1.0", + "purpose": "govoplan.capability-fit-proof-authorities", + "keys": [ + { + "key_id": "authority:target", + "status": "active", + "public_key": public_key, + "allowed_scopes": ["target_environment", "recovery"], + "not_before": "2026-08-01T00:00:00Z", + "not_after": "2026-09-01T00:00:00Z", + } + ], + } + self.boundary_schema = json.loads( + ( + META_ROOT / "docs" / "capability-fit-boundary-evidence.schema.json" + ).read_text(encoding="utf-8") + ) + self.authority_schema = json.loads( + ( + META_ROOT + / "docs" + / "capability-fit-proof-authority-keyring.schema.json" + ).read_text(encoding="utf-8") + ) + + def test_issues_sanitized_hash_bound_proof_and_verifies_it(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + result_path = Path(temp_dir) / "recovery-result.json" + result_path.write_text( + '{"rto_seconds":42,"rpo_seconds":0,"reconstructed":true}\n', + encoding="utf-8", + ) + proof = self.issue( + claims=[ + { + "scope": "target_environment", + "result": "passed", + "control_ids": ["topology:shared-state-v1"], + "artifacts": [ + {"artifact_id": "target:run-1", "path": result_path} + ], + }, + { + "scope": "recovery", + "result": "passed", + "control_ids": ["recovery:restore-v1"], + "artifacts": [ + {"artifact_id": "recovery:run-1", "path": result_path} + ], + }, + ] + ) + + self.assertEqual( + (), validate_payload(payload=proof, schema=self.boundary_schema) + ) + self.assertNotIn(str(result_path), json.dumps(proof)) + self.assertEqual( + canonical_sha256(self.installed), proof["installed_evidence_sha256"] + ) + installed_review = InstalledEvidenceReview( + findings=(), + changes=(), + changed_repositories=frozenset(), + affected_module_ids=frozenset(), + proof_scope={ + "installed_artifacts": {"valid": True}, + "installed_release_origin": {"valid": True}, + }, + evidence_sha256=canonical_sha256(self.installed), + ) + review = review_boundary_evidence( + assessment=self.assessment, + installed_review=installed_review, + evidence=proof, + evidence_schema=self.boundary_schema, + authority_keyring=self.authority, + authority_keyring_schema=self.authority_schema, + verification_time=datetime(2026, 8, 2, 12, 30, tzinfo=UTC), + ) + + self.assertEqual((), review.findings) + self.assertTrue(review.proof_scope["target_environment"]["valid"]) + self.assertTrue(review.proof_scope["recovery"]["valid"]) + + def test_refuses_missing_release_origin_or_scope_authority(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + artifact = Path(temp_dir) / "result.txt" + artifact.write_text("passed\n", encoding="utf-8") + claim = { + "scope": "security", + "result": "passed", + "control_ids": ["security:sast-v1"], + "artifacts": [{"artifact_id": "security:run-1", "path": artifact}], + } + with self.assertRaisesRegex(ValueError, "release origin"): + self.issue(claims=[claim], release_origin_verified=False) + with self.assertRaisesRegex(ValueError, "authorized for scopes: security"): + self.issue(claims=[claim]) + + def test_refuses_authority_that_expires_before_the_proof(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + artifact = Path(temp_dir) / "result.txt" + artifact.write_text("passed\n", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "full proof interval"): + self.issue( + expires_at=datetime(2026, 10, 1, tzinfo=UTC), + claims=[ + { + "scope": "recovery", + "result": "passed", + "control_ids": ["recovery:restore-v1"], + "artifacts": [ + { + "artifact_id": "recovery:run-1", + "path": artifact, + } + ], + } + ], + ) + + def issue( + self, + *, + claims: list[dict[str, object]], + expires_at: datetime = datetime(2026, 8, 3, tzinfo=UTC), + release_origin_verified: bool = True, + ) -> dict[str, object]: + return issue_boundary_evidence( + assessment=self.assessment, + installed_evidence=self.installed, + proof_id="proof:target:1", + claims=claims, + authority_keyring=self.authority, + signing_keys={"authority:target": self.private_key}, + issued_at=datetime(2026, 8, 2, 12, tzinfo=UTC), + expires_at=expires_at, + release_origin_verified=release_origin_verified, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_capability_fit_review.py b/tests/test_capability_fit_review.py index cc69b61..d161902 100644 --- a/tests/test_capability_fit_review.py +++ b/tests/test_capability_fit_review.py @@ -26,6 +26,7 @@ for tools_root in (ASSESSMENT_TOOLS_ROOT, RELEASE_TOOLS_ROOT): sys.path.insert(0, str(tools_root)) from govoplan_assessment.capability_fit import ( # noqa: E402 + enforce_required_boundary_scopes, local_tag_provenance, render_review, review_capability_fit, @@ -74,6 +75,34 @@ class CapabilityFitReviewTests(unittest.TestCase): json.dumps(report, sort_keys=True), json.dumps(repeated, sort_keys=True) ) + def test_required_boundary_scope_blocks_admission_until_positive(self) -> None: + catalog, keyring = signed_catalog(self.assessment) + report = review_capability_fit( + assessment=deepcopy(self.assessment), + schema=self.schema, + catalog=catalog, + published_keyring=keyring, + trusted_keyring=keyring, + ) + + enforce_required_boundary_scopes( + report, required_scopes=("security", "recovery") + ) + + self.assertEqual("blocked", report["status"]) + self.assertFalse(report["proof_scope"]["admission"]["valid"]) + self.assertEqual( + ["recovery", "security"], + report["proof_scope"]["admission"]["failed_scopes"], + ) + self.assertEqual( + 2, + sum( + item["code"] == "required_boundary_scope_unsatisfied" + for item in report["findings"] + ), + ) + def test_release_drift_identifies_affected_conclusions(self) -> None: catalog, keyring = signed_catalog( self.assessment, versions={"campaigns": "0.1.11"}, sequence=202607230001 @@ -360,7 +389,7 @@ class CapabilityFitReviewTests(unittest.TestCase): self.assertIn("Capability fit rerun: current", rendered) self.assertIn( - "No installed-composition, installed-release-origin, target-environment, external-provider, production-approval proof", + "No installed-composition, installed-release-origin, target-environment, external-providers, accessibility, privacy, security, operations, recovery, production-approval proof", rendered, ) diff --git a/tools/assessments/boundary-evidence.py b/tools/assessments/boundary-evidence.py new file mode 100644 index 0000000..13501b2 --- /dev/null +++ b/tools/assessments/boundary-evidence.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +"""Issue and immediately verify a sanitized target-boundary proof.""" + +from __future__ import annotations + +import argparse +from copy import deepcopy +from datetime import UTC, datetime +import json +from pathlib import Path +import sys + + +META_ROOT = Path(__file__).resolve().parents[2] +for tools_root in ( + META_ROOT / "tools" / "assessments", + META_ROOT / "tools" / "release", +): + if str(tools_root) not in sys.path: + sys.path.insert(0, str(tools_root)) + +from cryptography.hazmat.primitives import serialization # noqa: E402 +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( # noqa: E402 + Ed25519PrivateKey, +) + +from govoplan_assessment.atomic_io import ( # noqa: E402 + AtomicJsonWriteError, + atomic_write_json, +) +from govoplan_assessment.boundary_evidence import ( # noqa: E402 + issue_boundary_evidence, +) +from govoplan_assessment.capability_fit import review_capability_fit # noqa: E402 +from govoplan_assessment.evidence import ( # noqa: E402 + public_key_material_from_keyrings, + validate_payload, +) + + +MAX_INPUT_BYTES = 16 * 1024 * 1024 +MAX_OUTPUT_BYTES = 4 * 1024 * 1024 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--assessment", type=Path, required=True) + parser.add_argument( + "--assessment-schema", + type=Path, + default=META_ROOT / "docs" / "capability-fit.schema.json", + ) + parser.add_argument("--catalog", type=Path, required=True) + parser.add_argument("--keyring", type=Path, required=True) + parser.add_argument("--trusted-keyring", type=Path, required=True) + parser.add_argument("--installed-evidence", type=Path, required=True) + parser.add_argument( + "--installed-evidence-schema", + type=Path, + default=META_ROOT / "docs" / "installed-composition-evidence.schema.json", + ) + parser.add_argument("--installer-receipt", type=Path, required=True) + parser.add_argument( + "--installer-receipt-schema", + type=Path, + default=META_ROOT / "docs" / "installer-receipt.schema.json", + ) + parser.add_argument("--installer-authority-keyring", type=Path, required=True) + parser.add_argument( + "--installer-authority-keyring-schema", + type=Path, + default=META_ROOT / "docs" / "installer-receipt-authority-keyring.schema.json", + ) + parser.add_argument( + "--claims", + type=Path, + required=True, + help="Private target-run claim manifest; artifact paths are not published.", + ) + parser.add_argument( + "--claims-schema", + type=Path, + default=META_ROOT / "docs" / "capability-fit-boundary-run.schema.json", + ) + parser.add_argument("--authority-keyring", type=Path, required=True) + parser.add_argument( + "--authority-keyring-schema", + type=Path, + default=META_ROOT + / "docs" + / "capability-fit-proof-authority-keyring.schema.json", + ) + parser.add_argument( + "--signing-key", + action="append", + default=[], + required=True, + metavar="KEY_ID=/PATH/PRIVATE.pem", + help="Repeat for independent authorities needed to cover all claim scopes.", + ) + parser.add_argument("--expected-external-provider-subject") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--review-output", type=Path, required=True) + parser.add_argument( + "--boundary-evidence-schema", + type=Path, + default=META_ROOT / "docs" / "capability-fit-boundary-evidence.schema.json", + ) + args = parser.parse_args(argv) + if args.output.resolve() == args.review_output.resolve(): + parser.error("proof and review output paths must differ") + + assessment = read_object(args.assessment, label="assessment") + assessment_schema = read_object(args.assessment_schema, label="assessment schema") + catalog = read_object(args.catalog, label="catalog") + published_keyring = read_object(args.keyring, label="published keyring") + trusted_keyring = read_object(args.trusted_keyring, label="trusted keyring") + installed_evidence = read_object( + args.installed_evidence, label="installed evidence" + ) + installed_schema = read_object( + args.installed_evidence_schema, label="installed evidence schema" + ) + installer_receipt = read_object(args.installer_receipt, label="installer receipt") + installer_receipt_schema = read_object( + args.installer_receipt_schema, label="installer receipt schema" + ) + installer_authorities = read_object( + args.installer_authority_keyring, label="installer authority keyring" + ) + installer_authority_schema = read_object( + args.installer_authority_keyring_schema, + label="installer authority keyring schema", + ) + claim_manifest = read_object(args.claims, label="target-run claim manifest") + claim_schema = read_object(args.claims_schema, label="target-run claim schema") + authority_keyring = read_object( + args.authority_keyring, label="proof-authority keyring" + ) + authority_schema = read_object( + args.authority_keyring_schema, label="proof-authority keyring schema" + ) + boundary_schema = read_object( + args.boundary_evidence_schema, label="boundary evidence schema" + ) + + _require_schema(claim_manifest, claim_schema, label="target-run claim manifest") + _require_schema( + authority_keyring, authority_schema, label="proof-authority keyring" + ) + base_report = review_capability_fit( + assessment=assessment, + schema=assessment_schema, + catalog=catalog, + published_keyring=published_keyring, + trusted_keyring=trusted_keyring, + workspace_root=None, + installed_evidence=installed_evidence, + installed_evidence_schema=installed_schema, + installer_receipt=installer_receipt, + installer_receipt_schema=installer_receipt_schema, + installer_authority_keyring=installer_authorities, + installer_authority_keyring_schema=installer_authority_schema, + installed_evidence_mode="imported_unsigned", + ) + _require_release_origin(base_report, parser=parser) + + claims = resolve_claim_artifact_paths( + claim_manifest.get("claims"), base_dir=args.claims.resolve().parent + ) + signing_keys = read_signing_keys(tuple(args.signing_key)) + expires_at = parse_datetime(claim_manifest.get("expires_at"), label="expires_at") + forbidden_keys = public_key_material_from_keyrings( + published_keyring, + trusted_keyring, + installer_authorities, + ) + try: + proof = issue_boundary_evidence( + assessment=assessment, + installed_evidence=installed_evidence, + proof_id=str(claim_manifest.get("proof_id") or ""), + claims=claims, + authority_keyring=authority_keyring, + signing_keys=signing_keys, + expires_at=expires_at, + expected_external_provider_subject=( + args.expected_external_provider_subject + ), + forbidden_authority_public_keys=forbidden_keys, + release_origin_verified=True, + ) + except ValueError as exc: + parser.error(str(exc)) + _require_schema(proof, boundary_schema, label="issued boundary evidence") + + review = review_capability_fit( + assessment=assessment, + schema=assessment_schema, + catalog=catalog, + published_keyring=published_keyring, + trusted_keyring=trusted_keyring, + workspace_root=None, + installed_evidence=installed_evidence, + installed_evidence_schema=installed_schema, + installer_receipt=installer_receipt, + installer_receipt_schema=installer_receipt_schema, + installer_authority_keyring=installer_authorities, + installer_authority_keyring_schema=installer_authority_schema, + boundary_evidence=proof, + boundary_evidence_schema=boundary_schema, + boundary_authority_keyring=authority_keyring, + boundary_authority_keyring_schema=authority_schema, + installed_evidence_mode="imported_unsigned", + expected_external_provider_subject=args.expected_external_provider_subject, + ) + supplied_scopes = {str(claim.get("scope")) for claim in proof["claims"]} + unchecked = sorted( + scope + for scope in supplied_scopes + if review.get("proof_scope", {}).get(scope, {}).get("checked") is not True + ) + if unchecked: + parser.error( + "issued proof did not pass immediate verification for scopes: " + + ", ".join(unchecked) + ) + write_object(args.output, proof, label="boundary evidence") + write_object(args.review_output, review, label="capability-fit review") + print( + json.dumps( + { + "proof_id": proof["proof_id"], + "scopes": sorted(supplied_scopes), + "reference_readiness": review.get("proof_scope", {}).get( + "reference_readiness" + ), + "output": str(args.output), + "review_output": str(args.review_output), + }, + sort_keys=True, + ) + ) + return 0 + + +def read_object(path: Path, *, label: str) -> dict[str, object]: + try: + if path.stat().st_size > MAX_INPUT_BYTES: + raise ValueError("input exceeds size limit") + payload = json.loads(path.read_bytes().decode("utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise SystemExit(f"Could not read {label}") from exc + if not isinstance(payload, dict): + raise SystemExit(f"{label.capitalize()} must be a JSON object") + return payload + + +def read_signing_keys(values: tuple[str, ...]) -> dict[str, Ed25519PrivateKey]: + result: dict[str, Ed25519PrivateKey] = {} + for value in values: + key_id, separator, path_text = value.partition("=") + if not separator or not key_id or not path_text or key_id in result: + raise SystemExit( + "--signing-key must uniquely use KEY_ID=/path/to/private.pem" + ) + try: + key = serialization.load_pem_private_key( + Path(path_text).expanduser().read_bytes(), password=None + ) + except (OSError, ValueError) as exc: + raise SystemExit("Could not read boundary signing key") from exc + if not isinstance(key, Ed25519PrivateKey): + raise SystemExit("Boundary signing keys must be Ed25519") + result[key_id] = key + return result + + +def resolve_claim_artifact_paths( + claims: object, *, base_dir: Path +) -> list[dict[str, object]]: + if not isinstance(claims, list): + raise SystemExit("Target-run claim manifest has no claim list") + resolved = deepcopy(claims) + for claim in resolved: + if not isinstance(claim, dict): + continue + artifacts = claim.get("artifacts") + if not isinstance(artifacts, list): + continue + for artifact in artifacts: + if not isinstance(artifact, dict) or not isinstance( + artifact.get("path"), str + ): + continue + path = Path(artifact["path"]).expanduser() + artifact["path"] = path if path.is_absolute() else base_dir / path + return resolved + + +def parse_datetime(value: object, *, label: str) -> datetime: + if not isinstance(value, str): + raise SystemExit(f"{label} must be an RFC 3339 date-time") + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError as exc: + raise SystemExit(f"{label} must be an RFC 3339 date-time") from exc + if parsed.tzinfo is None: + raise SystemExit(f"{label} must include a timezone") + return parsed.astimezone(UTC) + + +def _require_schema( + payload: dict[str, object], schema: dict[str, object], *, label: str +) -> None: + errors = validate_payload(payload=payload, schema=schema) + if errors: + raise SystemExit(f"{label.capitalize()} failed schema validation: {errors[0]}") + + +def _require_release_origin( + report: dict[str, object], *, parser: argparse.ArgumentParser +) -> None: + proof_scope = report.get("proof_scope") + if not isinstance(proof_scope, dict): + parser.error("capability-fit review did not return proof scope") + required = ( + "catalog_signature_and_keyring", + "catalog_signature_and_trusted_keyring", + "published_keyring_hash", + "release_metadata", + "installed_artifacts", + "installed_record_integrity", + "installed_release_origin", + ) + failed = sorted( + scope + for scope in required + if not isinstance(proof_scope.get(scope), dict) + or proof_scope[scope].get("valid") is not True + ) + observation = proof_scope.get("installed_evidence_observation") + if ( + not isinstance(observation, dict) + or observation.get("receipt_authenticated") is not True + ): + failed.append("installed_evidence_observation.receipt_authenticated") + if failed: + parser.error( + "trusted release and installer evidence must pass before target proof issuance: " + + ", ".join(failed) + ) + + +def write_object(path: Path, payload: dict[str, object], *, label: str) -> None: + try: + atomic_write_json(path, payload, max_bytes=MAX_OUTPUT_BYTES) + except AtomicJsonWriteError as exc: + raise SystemExit(f"Could not securely write {label}") from exc + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/assessments/capability-fit.py b/tools/assessments/capability-fit.py index 6673dfd..245aee2 100755 --- a/tools/assessments/capability-fit.py +++ b/tools/assessments/capability-fit.py @@ -20,9 +20,11 @@ for tools_root in (ASSESSMENT_TOOLS_ROOT, RELEASE_TOOLS_ROOT): from govoplan_assessment import ( # noqa: E402 collect_installed_composition, + enforce_required_boundary_scopes, render_review, review_capability_fit, ) +from govoplan_assessment.evidence import REFERENCE_READINESS_SCOPES # noqa: E402 from govoplan_assessment.atomic_io import ( # noqa: E402 AtomicJsonWriteError, atomic_write_json, @@ -169,6 +171,21 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace: "never supply a URL, credential, or endpoint identifier." ), ) + parser.add_argument( + "--require-reference-readiness", + action="store_true", + help="Fail unless every reference-readiness boundary scope is current and positive.", + ) + parser.add_argument( + "--require-external-provider-proof", + action="store_true", + help="Fail unless current positive external-provider evidence is present.", + ) + parser.add_argument( + "--require-production-approval", + action="store_true", + help="Fail unless a current explicit production approval is present.", + ) parser.add_argument( "--json", action="store_true", help="Print the machine-readable review report." ) @@ -377,6 +394,17 @@ def main(argv: list[str] | None = None) -> int: ), expected_external_provider_subject=args.expected_external_provider_subject, ) + required_boundary_scopes: set[str] = set() + if args.require_reference_readiness: + required_boundary_scopes.update(REFERENCE_READINESS_SCOPES) + if args.require_external_provider_proof: + required_boundary_scopes.add("external_providers") + if args.require_production_approval: + required_boundary_scopes.add("production_approval") + if required_boundary_scopes: + enforce_required_boundary_scopes( + report, required_scopes=required_boundary_scopes + ) encoded = json.dumps(report, indent=2, sort_keys=True) + "\n" if args.output is not None: write_object( diff --git a/tools/assessments/govoplan_assessment/__init__.py b/tools/assessments/govoplan_assessment/__init__.py index 445bf8e..5e7407e 100644 --- a/tools/assessments/govoplan_assessment/__init__.py +++ b/tools/assessments/govoplan_assessment/__init__.py @@ -1,10 +1,15 @@ """Repeatable GovOPlaN product-assessment tooling.""" -from .capability_fit import review_capability_fit, render_review +from .capability_fit import ( + enforce_required_boundary_scopes, + render_review, + review_capability_fit, +) from .evidence import collect_installed_composition __all__ = ( "collect_installed_composition", + "enforce_required_boundary_scopes", "render_review", "review_capability_fit", ) diff --git a/tools/assessments/govoplan_assessment/boundary_evidence.py b/tools/assessments/govoplan_assessment/boundary_evidence.py new file mode 100644 index 0000000..715e5c8 --- /dev/null +++ b/tools/assessments/govoplan_assessment/boundary_evidence.py @@ -0,0 +1,270 @@ +"""Issue sanitized, release-bound target evidence from retained result files.""" + +from __future__ import annotations + +import base64 +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any, Mapping, Sequence + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from .evidence import ( + MAX_HASHED_FILE_BYTES, + NEGATIVE_RESULTS, + OPAQUE_ID_PATTERN, + POSITIVE_RESULTS, + PROOF_REQUIREMENTS, + _active_authority_keys, + _bounded_file_sha256, + canonical_bytes, + canonical_sha256, +) + + +MAX_BOUNDARY_ARTIFACT_BYTES = MAX_HASHED_FILE_BYTES +MAX_BOUNDARY_ARTIFACT_TOTAL_BYTES = 512 * 1024 * 1024 + + +def issue_boundary_evidence( + *, + assessment: Mapping[str, Any], + installed_evidence: Mapping[str, Any], + proof_id: str, + claims: Sequence[Mapping[str, Any]], + authority_keyring: dict[str, Any], + signing_keys: Mapping[str, Ed25519PrivateKey], + expires_at: datetime, + issued_at: datetime | None = None, + expected_external_provider_subject: str | None = None, + forbidden_authority_public_keys: frozenset[bytes] = frozenset(), + release_origin_verified: bool = False, +) -> dict[str, Any]: + """Build and sign one bounded proof after its release origin was verified. + + The public CLI is responsible for deriving ``release_origin_verified`` from + the signed catalog, installed observation and installer receipt. Artifact + paths are consumed here and never copied into the resulting receipt. + """ + + if release_origin_verified is not True: + raise ValueError( + "boundary evidence requires independently verified installed release origin" + ) + _require_opaque_id(proof_id, label="proof ID") + now = issued_at or datetime.now(tz=UTC) + now = _aware_utc(now, label="issuance time") + expiry = _aware_utc(expires_at, label="expiry time") + if now >= expiry: + raise ValueError("boundary evidence expiry must be after issuance") + + assessment_id = assessment.get("assessment_id") + release = assessment.get("release") + assessment_release = release.get("ref") if isinstance(release, Mapping) else None + deployment = assessment.get("deployment_profile") + deployment_subject = ( + deployment.get("id") if isinstance(deployment, Mapping) else None + ) + _require_opaque_id(assessment_id, label="assessment ID") + _require_opaque_id(assessment_release, label="assessment release") + _require_opaque_id(deployment_subject, label="deployment subject") + if expected_external_provider_subject is not None: + _require_opaque_id( + expected_external_provider_subject, + label="external-provider subject", + ) + + normalized_claims = _normalize_claims( + claims=claims, + deployment_subject=str(deployment_subject), + expected_external_provider_subject=expected_external_provider_subject, + ) + active_at_issue, issue_findings = _active_authority_keys( + authority_keyring=authority_keyring, + verification_time=now, + forbidden_public_keys=forbidden_authority_public_keys, + ) + active_until_expiry, expiry_findings = _active_authority_keys( + authority_keyring=authority_keyring, + verification_time=expiry - timedelta(microseconds=1), + forbidden_public_keys=forbidden_authority_public_keys, + ) + if issue_findings or expiry_findings: + raise ValueError( + "proof-authority keyring is malformed, reused, or not valid for the full proof interval" + ) + if not signing_keys: + raise ValueError( + "at least one independently provisioned signing key is required" + ) + + covered_scopes: set[str] = set() + verified_signers: list[tuple[str, Ed25519PrivateKey]] = [] + for key_id, private_key in sorted(signing_keys.items()): + _require_opaque_id(key_id, label="signing key ID") + if not isinstance(private_key, Ed25519PrivateKey): + raise ValueError("boundary signing keys must be Ed25519") + authority = active_at_issue.get(key_id) + authority_at_expiry = active_until_expiry.get(key_id) + public_bytes = private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + if ( + authority is None + or authority_at_expiry is None + or authority[0] != public_bytes + or authority_at_expiry[0] != public_bytes + ): + raise ValueError( + f"signing key {key_id!r} is not independently authorized for the full proof interval" + ) + covered_scopes.update(authority[1]) + verified_signers.append((key_id, private_key)) + + claim_scopes = {str(claim["scope"]) for claim in normalized_claims} + uncovered_scopes = sorted(claim_scopes - covered_scopes) + if uncovered_scopes: + raise ValueError( + "no supplied signing key is authorized for scopes: " + + ", ".join(uncovered_scopes) + ) + + proof: dict[str, Any] = { + "$schema": "./capability-fit-boundary-evidence.schema.json", + "schema_version": "0.1.0", + "evidence_kind": "govoplan.capability-fit-boundary-proof", + "proof_id": proof_id, + "assessment_id": assessment_id, + "assessment_release": assessment_release, + "installed_evidence_sha256": canonical_sha256(installed_evidence), + "issued_at": _iso_datetime(now), + "expires_at": _iso_datetime(expiry), + "claims": normalized_claims, + } + signature_payload = canonical_bytes(proof) + proof["signatures"] = [ + { + "algorithm": "ed25519", + "key_id": key_id, + "value": base64.b64encode(private_key.sign(signature_payload)).decode( + "ascii" + ), + } + for key_id, private_key in verified_signers + ] + return proof + + +def _normalize_claims( + *, + claims: Sequence[Mapping[str, Any]], + deployment_subject: str, + expected_external_provider_subject: str | None, +) -> list[dict[str, Any]]: + if not 1 <= len(claims) <= len(PROOF_REQUIREMENTS): + raise ValueError("boundary evidence requires between one and eight claims") + normalized: list[dict[str, Any]] = [] + seen_scopes: set[str] = set() + total_bytes = 0 + for claim in claims: + scope = str(claim.get("scope") or "") + if scope not in PROOF_REQUIREMENTS or scope in seen_scopes: + raise ValueError("boundary claim scopes must be known and unique") + seen_scopes.add(scope) + result = str(claim.get("result") or "") + if result not in {POSITIVE_RESULTS[scope], NEGATIVE_RESULTS[scope]}: + raise ValueError(f"boundary claim result is invalid for {scope!r}") + controls = _opaque_id_list( + claim.get("control_ids"), + label=f"{scope} control IDs", + maximum=256, + ) + raw_artifacts = claim.get("artifacts") + if not isinstance(raw_artifacts, Sequence) or isinstance( + raw_artifacts, (str, bytes) + ): + raise ValueError(f"{scope} artifacts must be a non-empty list") + if not 1 <= len(raw_artifacts) <= 256: + raise ValueError(f"{scope} must reference between one and 256 artifacts") + artifacts: list[dict[str, str]] = [] + seen_artifacts: set[str] = set() + for artifact in raw_artifacts: + if not isinstance(artifact, Mapping): + raise ValueError(f"{scope} contains a malformed artifact") + artifact_id = artifact.get("artifact_id") + _require_opaque_id(artifact_id, label=f"{scope} artifact ID") + artifact_id = str(artifact_id) + if artifact_id in seen_artifacts: + raise ValueError(f"{scope} contains duplicate artifact IDs") + seen_artifacts.add(artifact_id) + raw_path = artifact.get("path") + if not isinstance(raw_path, (str, Path)) or not str(raw_path): + raise ValueError(f"{scope} artifact {artifact_id!r} has no file path") + try: + digest, size, changed = _bounded_file_sha256( + Path(raw_path), max_bytes=MAX_BOUNDARY_ARTIFACT_BYTES + ) + except OSError as exc: + raise ValueError( + f"could not securely hash {scope} artifact {artifact_id!r}" + ) from exc + if changed: + raise ValueError( + f"{scope} artifact {artifact_id!r} changed or exceeded the size limit while hashing" + ) + total_bytes += size + if total_bytes > MAX_BOUNDARY_ARTIFACT_TOTAL_BYTES: + raise ValueError( + "boundary evidence artifacts exceed the total size limit" + ) + artifacts.append({"artifact_id": artifact_id, "sha256": digest.hex()}) + normalized.append( + { + "scope": scope, + "result": result, + "subject_id": ( + expected_external_provider_subject + if scope == "external_providers" + else deployment_subject + ), + "control_ids": controls, + "artifacts": artifacts, + } + ) + if "external_providers" in seen_scopes and not expected_external_provider_subject: + raise ValueError( + "external-provider evidence requires its expected opaque subject ID" + ) + return sorted(normalized, key=lambda item: str(item["scope"])) + + +def _opaque_id_list(value: object, *, label: str, maximum: int) -> list[str]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): + raise ValueError(f"{label} must be a non-empty list") + result = [str(item) for item in value] + if not 1 <= len(result) <= maximum or len(result) != len(set(result)): + raise ValueError(f"{label} must contain unique bounded IDs") + for item in result: + _require_opaque_id(item, label=label) + return result + + +def _require_opaque_id(value: object, *, label: str) -> None: + if ( + not isinstance(value, str) + or len(value) > 160 + or OPAQUE_ID_PATTERN.fullmatch(value) is None + ): + raise ValueError(f"{label} must be a bounded opaque ID") + + +def _aware_utc(value: datetime, *, label: str) -> datetime: + if value.tzinfo is None: + raise ValueError(f"{label} must include a timezone") + return value.astimezone(UTC) + + +def _iso_datetime(value: datetime) -> str: + return value.astimezone(UTC).isoformat().replace("+00:00", "Z") diff --git a/tools/assessments/govoplan_assessment/capability_fit.py b/tools/assessments/govoplan_assessment/capability_fit.py index f6fb3c6..963d74b 100644 --- a/tools/assessments/govoplan_assessment/capability_fit.py +++ b/tools/assessments/govoplan_assessment/capability_fit.py @@ -930,10 +930,7 @@ def build_report( boundary_scope = ( boundary_review.proof_scope if boundary_review is not None - else { - scope: {"checked": False, "valid": None} - for scope in PROOF_REQUIREMENTS - } + else {scope: {"checked": False, "valid": None} for scope in PROOF_REQUIREMENTS} ) proof_scope = { "assessment_schema": {"checked": True, "valid": schema_valid}, @@ -1002,6 +999,63 @@ def build_report( } +def enforce_required_boundary_scopes( + report: dict[str, Any], *, required_scopes: Iterable[str] +) -> dict[str, Any]: + """Turn missing or negative external proof into an admission blocker.""" + + requested = tuple(dict.fromkeys(sorted(required_scopes))) + unknown = sorted(set(requested) - set(PROOF_REQUIREMENTS)) + if unknown: + raise ValueError("unknown boundary scopes: " + ", ".join(unknown)) + proof_scope = report.get("proof_scope") + if not isinstance(proof_scope, dict): + raise ValueError("capability-fit report has no proof scope") + failed = tuple( + scope + for scope in requested + if not isinstance(proof_scope.get(scope), dict) + or proof_scope[scope].get("checked") is not True + or proof_scope[scope].get("valid") is not True + ) + proof_scope["admission"] = { + "checked": True, + "valid": not failed, + "required_scopes": list(requested), + "failed_scopes": list(failed), + } + if not failed: + return report + findings = report.setdefault("findings", []) + existing = { + (item.get("code"), item.get("message")) + for item in findings + if isinstance(item, dict) + } + for scope in failed: + message = ( + f"Admission requires current positive boundary evidence for {scope!r}." + ) + if ("required_boundary_scope_unsatisfied", message) not in existing: + findings.append( + { + "severity": "blocker", + "code": "required_boundary_scope_unsatisfied", + "message": message, + "assessment_ids": ["assessment.external_proof"], + } + ) + findings.sort( + key=lambda item: ( + _severity_rank(str(item.get("severity") or "")), + str(item.get("code") or ""), + str(item.get("message") or ""), + ) + ) + report["status"] = "blocked" + return report + + def render_review(report: dict[str, Any]) -> str: proof_scope = report.get("proof_scope", {}) installed_scope = proof_scope.get("installed_artifacts", {}) @@ -1062,8 +1116,7 @@ def render_review(report: dict[str, Any]) -> str: unchecked_boundaries = [ label for key, label in ( - (scope, scope.replace("_", "-")) - for scope in PROOF_REQUIREMENTS + (scope, scope.replace("_", "-")) for scope in PROOF_REQUIREMENTS ) if report.get("proof_scope", {}).get(key, {}).get("checked") is not True ]