Skip to content

Commit eb8249c

Browse files
authored
Merge pull request #68 from keelapi/cosign-bundle-offline-verify
feat: adjudicate co-signature evidence in single-file bundles
2 parents 0a20846 + dcd9f79 commit eb8249c

4 files changed

Lines changed: 1238 additions & 2 deletions

File tree

keel_verifier/capability/v1.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,24 @@
10911091
"permit_spec": null,
10921092
"note": "Workflow evidence artifact emitted as a sibling of audit-export bundles."
10931093
},
1094+
{
1095+
"name": "evidence_bundle_v1",
1096+
"status": "supported",
1097+
"permit_spec": null,
1098+
"note": "Self-attesting single-file container. A body profile this verifier does not adjudicate is reported as an envelope-only result."
1099+
},
1100+
{
1101+
"name": "evidence_bundle_v2",
1102+
"status": "supported",
1103+
"permit_spec": null,
1104+
"note": "Same shape as v1, stricter reader obligation: the body profile must be adjudicated or the artifact fails closed. Emitted for co-signature evidence so a verifier lacking keel.permit_co_signature/v1 refuses the file rather than reporting the envelope alone as verified."
1105+
},
1106+
{
1107+
"name": "permit_co_signature_v1",
1108+
"status": "supported",
1109+
"permit_spec": null,
1110+
"note": "Body profile keel.permit_co_signature/v1: signed Permit decision, WebAuthn co-signature evidence, and Keel-signed co-signer key status manifest in one file. Quorum is adjudicated only when the requirement is bound into a v6+ decision binding."
1111+
},
10941112
{
10951113
"name": "checkpoint_scope_state_v1",
10961114
"status": "supported",

keel_verifier/report_render.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@
7777
"permit.counter_signed.v1",
7878
),
7979
),
80+
(
81+
"Human co-signature",
82+
("permit.co_signature.v1", "permit.co_signature.v2"),
83+
),
84+
("Co-signature quorum", ("permit.co_signature.quorum.v1",)),
8085
("Dispatch", ("closure.dispatch_binding.v1",)),
8186
("Closure", ("closure.signature.v1",)),
8287
("Revocation", ("permit.revoked.v1",)),

keel_verifier/verifier.py

Lines changed: 272 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,18 @@
159159
_legacy_artifact_ref_warning_printed = False
160160
_legacy_vanta_schema_warning_printed = False
161161
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+
)
162174
_LEGACY_SPLIT_EXPORT_WARNING_EMITTED = False
163175
QUOTA_RESERVATION_LINKAGE_CLAIM_NAME = "quota.reservation_linkage.v1"
164176
BUDGET_PARTITION_LEDGER_CLAIM_NAME = "budget.partition_ledger.v1"
@@ -689,6 +701,8 @@ def _is_workflow_evidence_schema(schema: Any) -> bool:
689701
PERMIT_CO_SIGNATURE_CLAIM_NAME = "permit.co_signature.v1"
690702
PERMIT_CO_SIGNATURE_V2_CLAIM_NAME = "permit.co_signature.v2"
691703
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})
692706
PERMIT_EXACT_ACTION_CLAIM_NAME = "permit.exact_action.v1"
693707
PERMIT_OPERATOR_APPROVAL_V2_CLAIM_NAME = "permit.operator_approval.v2"
694708
PERMIT_COUNTER_SIGNATURE_V2_CLAIM_NAME = "permit.counter_signature.v2"
@@ -1199,7 +1213,7 @@ def _bundle_canonical_json_bytes(value: Any) -> bytes:
11991213
def _is_self_attesting_bundle(value: Any) -> bool:
12001214
return (
12011215
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
12031217
and isinstance(value.get("body"), dict)
12041218
and isinstance(value.get("signature_envelope"), dict)
12051219
)
@@ -1319,7 +1333,10 @@ def _verify_self_attesting_bundle_payload(
13191333
BundleTrustContext | None,
13201334
]:
13211335
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+
):
13231340
message = "unsupported evidence bundle schema_version"
13241341
return (
13251342
False,
@@ -11389,6 +11406,7 @@ def _co_signature_claim_from_document(
1138911406
claim_name: str = PERMIT_CO_SIGNATURE_CLAIM_NAME,
1139011407
decision_claim: ClaimVerdict | None = None,
1139111408
integrity_reason: str | None = None,
11409+
enforce_user_verification: bool = False,
1139211410
) -> ClaimVerdict:
1139311411
if claim_name not in {
1139411412
PERMIT_CO_SIGNATURE_CLAIM_NAME,
@@ -11609,6 +11627,29 @@ def _co_signature_claim_from_document(
1160911627
)
1161011628
)
1161111629
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
1161211653
if allowed_origins != key_entry.get("allowed_origins"):
1161311654
subjects.append(
1161411655
_subject(
@@ -13819,6 +13860,145 @@ def _co_signature_pack_integrity_claims(
1381913860
]
1382013861

1382113862

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+
1382214002
def _adjudicate_permit_exact_action_v1(
1382314003
*,
1382414004
body: dict[str, Any],
@@ -14186,6 +14366,96 @@ def verify_export_structured(args: argparse.Namespace) -> VerificationReport:
1418614366
or transition_claim.reason_code
1418714367
or "Human-review transition verification did not complete."
1418814368
)
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
1418914459
if bundle_context is not None:
1419014460
claims = [
1419114461
*claims,

0 commit comments

Comments
 (0)