feat: enforce signed target maturity evidence
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user