|
159 | 159 | _legacy_artifact_ref_warning_printed = False |
160 | 160 | _legacy_vanta_schema_warning_printed = False |
161 | 161 | SELF_ATTESTING_BUNDLE_SCHEMA_VERSION = "keel.evidence_bundle/v1" |
| 162 | +# v2 is byte-identical in shape to v1 and differs only in what it obliges a |
| 163 | +# verifier to do: the body declares a profile that MUST be adjudicated, and a |
| 164 | +# verifier that does not recognise it must fail closed rather than report the |
| 165 | +# envelope alone as verified. Emitting co-signature evidence under v2 is what |
| 166 | +# makes an older CLI refuse the file instead of returning a bare green. |
| 167 | +SELF_ATTESTING_BUNDLE_SCHEMA_VERSION_V2 = "keel.evidence_bundle/v2" |
| 168 | +SUPPORTED_SELF_ATTESTING_BUNDLE_SCHEMA_VERSIONS = frozenset( |
| 169 | + { |
| 170 | + SELF_ATTESTING_BUNDLE_SCHEMA_VERSION, |
| 171 | + SELF_ATTESTING_BUNDLE_SCHEMA_VERSION_V2, |
| 172 | + } |
| 173 | +) |
162 | 174 | _LEGACY_SPLIT_EXPORT_WARNING_EMITTED = False |
163 | 175 | QUOTA_RESERVATION_LINKAGE_CLAIM_NAME = "quota.reservation_linkage.v1" |
164 | 176 | BUDGET_PARTITION_LEDGER_CLAIM_NAME = "budget.partition_ledger.v1" |
@@ -689,6 +701,8 @@ def _is_workflow_evidence_schema(schema: Any) -> bool: |
689 | 701 | PERMIT_CO_SIGNATURE_CLAIM_NAME = "permit.co_signature.v1" |
690 | 702 | PERMIT_CO_SIGNATURE_V2_CLAIM_NAME = "permit.co_signature.v2" |
691 | 703 | PERMIT_CO_SIGNATURE_QUORUM_CLAIM_NAME = "permit.co_signature.quorum.v1" |
| 704 | +PERMIT_CO_SIGNATURE_PROFILE_V1 = "keel.permit_co_signature/v1" |
| 705 | +SUPPORTED_CO_SIGNATURE_PROFILES = frozenset({PERMIT_CO_SIGNATURE_PROFILE_V1}) |
692 | 706 | PERMIT_EXACT_ACTION_CLAIM_NAME = "permit.exact_action.v1" |
693 | 707 | PERMIT_OPERATOR_APPROVAL_V2_CLAIM_NAME = "permit.operator_approval.v2" |
694 | 708 | PERMIT_COUNTER_SIGNATURE_V2_CLAIM_NAME = "permit.counter_signature.v2" |
@@ -1199,7 +1213,7 @@ def _bundle_canonical_json_bytes(value: Any) -> bytes: |
1199 | 1213 | def _is_self_attesting_bundle(value: Any) -> bool: |
1200 | 1214 | return ( |
1201 | 1215 | isinstance(value, dict) |
1202 | | - and value.get("schema_version") == SELF_ATTESTING_BUNDLE_SCHEMA_VERSION |
| 1216 | + and value.get("schema_version") in SUPPORTED_SELF_ATTESTING_BUNDLE_SCHEMA_VERSIONS |
1203 | 1217 | and isinstance(value.get("body"), dict) |
1204 | 1218 | and isinstance(value.get("signature_envelope"), dict) |
1205 | 1219 | ) |
@@ -1319,7 +1333,10 @@ def _verify_self_attesting_bundle_payload( |
1319 | 1333 | BundleTrustContext | None, |
1320 | 1334 | ]: |
1321 | 1335 | diagnostics: list[str] = [] |
1322 | | - if bundle.get("schema_version") != SELF_ATTESTING_BUNDLE_SCHEMA_VERSION: |
| 1336 | + if ( |
| 1337 | + bundle.get("schema_version") |
| 1338 | + not in SUPPORTED_SELF_ATTESTING_BUNDLE_SCHEMA_VERSIONS |
| 1339 | + ): |
1323 | 1340 | message = "unsupported evidence bundle schema_version" |
1324 | 1341 | return ( |
1325 | 1342 | False, |
@@ -11389,6 +11406,7 @@ def _co_signature_claim_from_document( |
11389 | 11406 | claim_name: str = PERMIT_CO_SIGNATURE_CLAIM_NAME, |
11390 | 11407 | decision_claim: ClaimVerdict | None = None, |
11391 | 11408 | integrity_reason: str | None = None, |
| 11409 | + enforce_user_verification: bool = False, |
11392 | 11410 | ) -> ClaimVerdict: |
11393 | 11411 | if claim_name not in { |
11394 | 11412 | PERMIT_CO_SIGNATURE_CLAIM_NAME, |
@@ -11609,6 +11627,29 @@ def _co_signature_claim_from_document( |
11609 | 11627 | ) |
11610 | 11628 | ) |
11611 | 11629 | continue |
| 11630 | + if enforce_user_verification and require_uv is not True: |
| 11631 | + # A single-file self-attesting bundle carries its ceremony |
| 11632 | + # parameters under an envelope key that is not itself pinned to the |
| 11633 | + # Keel trust root. A body-supplied user-verification downgrade must |
| 11634 | + # therefore never weaken the ceremony: reject it explicitly rather |
| 11635 | + # than silently re-verifying under the stricter setting. |
| 11636 | + subjects.append( |
| 11637 | + _subject( |
| 11638 | + subject_type="permit_co_signature", |
| 11639 | + subject_id=subject_id, |
| 11640 | + verdict="disproved", |
| 11641 | + reason_code="CO_SIGNATURE_USER_VERIFICATION_DOWNGRADED", |
| 11642 | + message=( |
| 11643 | + "self-attesting co-signature evidence must require " |
| 11644 | + "WebAuthn user verification" |
| 11645 | + ), |
| 11646 | + evidence=[ |
| 11647 | + f"export.co_signature_evidence[{evidence_index}]." |
| 11648 | + "require_user_verification" |
| 11649 | + ], |
| 11650 | + ) |
| 11651 | + ) |
| 11652 | + continue |
11612 | 11653 | if allowed_origins != key_entry.get("allowed_origins"): |
11613 | 11654 | subjects.append( |
11614 | 11655 | _subject( |
@@ -13819,6 +13860,145 @@ def _co_signature_pack_integrity_claims( |
13819 | 13860 | ] |
13820 | 13861 |
|
13821 | 13862 |
|
| 13863 | +def _adjudicate_permit_co_signature_bundle_v1( |
| 13864 | + *, |
| 13865 | + body: Mapping[str, Any], |
| 13866 | + key_manifest_source: str | None, |
| 13867 | +) -> tuple[list[ClaimVerdict], bool, str | None, dict[str, Any] | None]: |
| 13868 | + """Adjudicate a single-file ``keel.permit_co_signature/v1`` bundle body. |
| 13869 | + |
| 13870 | + The outer evidence-bundle envelope binds these parts together but its key |
| 13871 | + is not pinned to the Keel trust root, so nothing here may rest on it. Every |
| 13872 | + load-bearing fact is re-derived from independently pinned material: the |
| 13873 | + signed Permit decision binding, the Keel-signed co-signer key status |
| 13874 | + manifest, and the WebAuthn assertion itself. |
| 13875 | + """ |
| 13876 | + |
| 13877 | + claims: list[ClaimVerdict] = [] |
| 13878 | + decision_claim = _adjudicate_permit_decision_v1( |
| 13879 | + export_document=body, |
| 13880 | + key_manifest_source=key_manifest_source, |
| 13881 | + ) |
| 13882 | + claims.append(decision_claim) |
| 13883 | + member_claim = _co_signature_claim_from_document( |
| 13884 | + export_document=body, |
| 13885 | + pack_integrity_verified=True, |
| 13886 | + pinned_key_manifest_source=key_manifest_source, |
| 13887 | + claim_name=PERMIT_CO_SIGNATURE_V2_CLAIM_NAME, |
| 13888 | + decision_claim=decision_claim, |
| 13889 | + enforce_user_verification=True, |
| 13890 | + ) |
| 13891 | + claims.append(member_claim) |
| 13892 | + |
| 13893 | + # A signed decision that declares a co-signature requirement must be |
| 13894 | + # accompanied by quorum evidence. Otherwise stripping the quorum block |
| 13895 | + # would convert an unsatisfied requirement into a silently narrower — |
| 13896 | + # but still green — report. |
| 13897 | + permit_decision, _ = _find_permit_decision_evidence(body) |
| 13898 | + signed_requirement: Any = None |
| 13899 | + attributes_bound = False |
| 13900 | + if isinstance(permit_decision, Mapping): |
| 13901 | + canonical_payload = permit_decision.get("canonical_payload") |
| 13902 | + # Only v6+ bindings hash resource_attributes_json into the signed |
| 13903 | + # canonical payload. Below that the attributes ride along unsigned, so |
| 13904 | + # a requirement read from them would be attacker-authored, not signed. |
| 13905 | + attributes_bound = ( |
| 13906 | + isinstance(canonical_payload, Mapping) |
| 13907 | + and canonical_payload.get("binding_version") in {"v6", "v7"} |
| 13908 | + ) |
| 13909 | + decision_attributes, attributes_failure = _permit_decision_resource_attributes( |
| 13910 | + dict(permit_decision) |
| 13911 | + ) |
| 13912 | + if ( |
| 13913 | + attributes_bound |
| 13914 | + and attributes_failure is None |
| 13915 | + and isinstance(decision_attributes, Mapping) |
| 13916 | + ): |
| 13917 | + signed_requirement = decision_attributes.get( |
| 13918 | + "permit_co_signature_requirement_v1" |
| 13919 | + ) |
| 13920 | + quorum_evidence = body.get("co_signature_quorum_evidence") |
| 13921 | + if isinstance(quorum_evidence, Mapping) and not attributes_bound: |
| 13922 | + claims.append( |
| 13923 | + _permit_claim( |
| 13924 | + PERMIT_CO_SIGNATURE_QUORUM_CLAIM_NAME, |
| 13925 | + subject_type="permit_co_signature_quorum", |
| 13926 | + subject_id=str(body.get("permit_id") or "") or None, |
| 13927 | + verdict="unverifiable_scope", |
| 13928 | + reason_code="CO_SIGNATURE_QUORUM_ATTRIBUTES_UNBOUND", |
| 13929 | + message=( |
| 13930 | + "quorum requires a v6+ Permit decision binding that hashes " |
| 13931 | + "resource attributes into the signed payload" |
| 13932 | + ), |
| 13933 | + evidence=[ |
| 13934 | + "export.permit_decision.canonical_payload.binding_version", |
| 13935 | + "export.permit_decision.resource_attributes_json", |
| 13936 | + ], |
| 13937 | + ) |
| 13938 | + ) |
| 13939 | + elif isinstance(quorum_evidence, Mapping) or isinstance(signed_requirement, Mapping): |
| 13940 | + claims.append( |
| 13941 | + _adjudicate_permit_co_signature_quorum_v1( |
| 13942 | + export_document=body, |
| 13943 | + decision_claim=decision_claim, |
| 13944 | + member_claim=member_claim, |
| 13945 | + ) |
| 13946 | + ) |
| 13947 | + |
| 13948 | + failed = next( |
| 13949 | + ( |
| 13950 | + claim |
| 13951 | + for claim in claims |
| 13952 | + if claim.aggregate_verdict != verdict_value("supported") |
| 13953 | + ), |
| 13954 | + None, |
| 13955 | + ) |
| 13956 | + if failed is not None: |
| 13957 | + return ( |
| 13958 | + claims, |
| 13959 | + False, |
| 13960 | + ( |
| 13961 | + failed.message |
| 13962 | + or failed.reason_code |
| 13963 | + or "Permit co-signature verification did not complete." |
| 13964 | + ), |
| 13965 | + None, |
| 13966 | + ) |
| 13967 | + |
| 13968 | + entries = _co_signature_evidence_entries( |
| 13969 | + body, |
| 13970 | + payload_type=PERMIT_CO_SIGNATURE_V2_CLAIM_NAME, |
| 13971 | + ) |
| 13972 | + quorum_established = any( |
| 13973 | + claim.name == PERMIT_CO_SIGNATURE_QUORUM_CLAIM_NAME for claim in claims |
| 13974 | + ) |
| 13975 | + summary: dict[str, Any] = { |
| 13976 | + "permit_id": body.get("permit_id"), |
| 13977 | + "project_id": body.get("project_id"), |
| 13978 | + "co_signatures": [ |
| 13979 | + { |
| 13980 | + # The WebAuthn assertion binds the Permit decision hash, not the |
| 13981 | + # role label. Role is adjudicated only against a signed |
| 13982 | + # requirement via the quorum claim, so it is reported as |
| 13983 | + # established only when that claim is present. |
| 13984 | + "role": entry.get("claim", {}).get("role"), |
| 13985 | + "role_established": quorum_established, |
| 13986 | + "custody_tier": entry.get("claim", {}).get("custody_tier"), |
| 13987 | + "signed_at": entry.get("claim", {}).get("signed_at"), |
| 13988 | + } |
| 13989 | + for entry in entries |
| 13990 | + if isinstance(entry.get("claim"), Mapping) |
| 13991 | + ], |
| 13992 | + "quorum_established": quorum_established, |
| 13993 | + } |
| 13994 | + if not quorum_established: |
| 13995 | + summary["does_not_establish"] = [ |
| 13996 | + "that policy required this co-signature", |
| 13997 | + "the approver/witness role recorded for this co-signature", |
| 13998 | + ] |
| 13999 | + return claims, True, None, summary |
| 14000 | + |
| 14001 | + |
13822 | 14002 | def _adjudicate_permit_exact_action_v1( |
13823 | 14003 | *, |
13824 | 14004 | body: dict[str, Any], |
@@ -14186,6 +14366,96 @@ def verify_export_structured(args: argparse.Namespace) -> VerificationReport: |
14186 | 14366 | or transition_claim.reason_code |
14187 | 14367 | or "Human-review transition verification did not complete." |
14188 | 14368 | ) |
| 14369 | + if ( |
| 14370 | + ok |
| 14371 | + and isinstance(body, dict) |
| 14372 | + and exact_profile is not None |
| 14373 | + and exact_profile.startswith("keel.permit_co_signature/") |
| 14374 | + and exact_profile not in SUPPORTED_CO_SIGNATURE_PROFILES |
| 14375 | + ): |
| 14376 | + ok = False |
| 14377 | + error = ( |
| 14378 | + "PERMIT_CO_SIGNATURE_PROFILE_UNSUPPORTED: " |
| 14379 | + f"this verifier does not adjudicate {exact_profile}" |
| 14380 | + ) |
| 14381 | + diagnostics.append(error) |
| 14382 | + artifact.update( |
| 14383 | + { |
| 14384 | + "kind": "permit_co_signature", |
| 14385 | + "profile": exact_profile, |
| 14386 | + "unsupported_profile": True, |
| 14387 | + } |
| 14388 | + ) |
| 14389 | + elif ( |
| 14390 | + ok |
| 14391 | + and isinstance(body, dict) |
| 14392 | + and exact_profile in SUPPORTED_CO_SIGNATURE_PROFILES |
| 14393 | + ): |
| 14394 | + ( |
| 14395 | + co_signature_claims, |
| 14396 | + co_signature_ok, |
| 14397 | + co_signature_error, |
| 14398 | + co_signature_summary, |
| 14399 | + ) = _adjudicate_permit_co_signature_bundle_v1( |
| 14400 | + body=body, |
| 14401 | + key_manifest_source=_key_manifest_source_for_args(args), |
| 14402 | + ) |
| 14403 | + claims.extend(co_signature_claims) |
| 14404 | + if not co_signature_ok: |
| 14405 | + ok = False |
| 14406 | + error = co_signature_error |
| 14407 | + diagnostics.append( |
| 14408 | + co_signature_error |
| 14409 | + or "Permit co-signature verification did not complete." |
| 14410 | + ) |
| 14411 | + else: |
| 14412 | + artifact.update( |
| 14413 | + { |
| 14414 | + "kind": "permit_co_signature", |
| 14415 | + "profile": exact_profile, |
| 14416 | + "permit": co_signature_summary, |
| 14417 | + } |
| 14418 | + ) |
| 14419 | + elif ( |
| 14420 | + ok |
| 14421 | + and isinstance(body, dict) |
| 14422 | + and _co_signature_evidence_entries(body) |
| 14423 | + ): |
| 14424 | + # Co-signature evidence rode along inside a bundle body that |
| 14425 | + # declares no profile this verifier adjudicates. Reporting the |
| 14426 | + # envelope as verified would present an unexamined ceremony as a |
| 14427 | + # checked one, so refuse instead of narrowing silently. |
| 14428 | + ok = False |
| 14429 | + error = ( |
| 14430 | + "CO_SIGNATURE_PROFILE_UNDECLARED: bundle body carries " |
| 14431 | + "co-signature evidence but declares no adjudicable " |
| 14432 | + "co-signature profile" |
| 14433 | + ) |
| 14434 | + diagnostics.append(error) |
| 14435 | + artifact.update( |
| 14436 | + { |
| 14437 | + "kind": "permit_co_signature", |
| 14438 | + "profile": exact_profile, |
| 14439 | + "unsupported_profile": True, |
| 14440 | + } |
| 14441 | + ) |
| 14442 | + if ( |
| 14443 | + ok |
| 14444 | + and bundle.get("schema_version") |
| 14445 | + == SELF_ATTESTING_BUNDLE_SCHEMA_VERSION_V2 |
| 14446 | + and exact_profile |
| 14447 | + not in (SUPPORTED_CO_SIGNATURE_PROFILES | supported_exact_profiles) |
| 14448 | + ): |
| 14449 | + # v2 exists precisely so that a body profile cannot be ignored. |
| 14450 | + # Reaching here means the profile was never adjudicated, so the |
| 14451 | + # envelope claim alone must not be reported as a verification. |
| 14452 | + ok = False |
| 14453 | + error = ( |
| 14454 | + "EVIDENCE_BUNDLE_V2_PROFILE_UNADJUDICATED: " |
| 14455 | + f"this verifier does not adjudicate body profile {exact_profile!r}" |
| 14456 | + ) |
| 14457 | + diagnostics.append(error) |
| 14458 | + artifact["unsupported_profile"] = True |
14189 | 14459 | if bundle_context is not None: |
14190 | 14460 | claims = [ |
14191 | 14461 | *claims, |
|
0 commit comments