feat: adjudicate co-signature evidence in single-file bundles - #68
Merged
Conversation
`keel-verify export <file>` accepted a self-attesting bundle, verified the envelope, and returned VERIFIED without ever looking at co-signature evidence in the body. Co-signature adjudication existed only on the legacy split-file path, so the single-file form silently reported a green for an unexamined WebAuthn ceremony. Adds `keel.permit_co_signature/v1`: the single-file path now runs the signed Permit decision, the target-bound `permit.co_signature.v2` member claim, and the quorum claim over the bundle body, and fails closed when any of them does not resolve to supported. Closes the false-green hole three ways: * A body carrying co_signature_evidence with no adjudicable profile is refused (CO_SIGNATURE_PROFILE_UNDECLARED) rather than reported on its envelope alone. * `keel.evidence_bundle/v2` is accepted alongside v1. v2 is identical in shape and differs only in obligation — the reader must adjudicate body.profile or fail. Producers emit co-signature bundles as v2 so a verifier predating this release refuses the file instead of returning a bare green. Every existing v1 bundle keeps verifying unchanged. * A v2 bundle whose profile is never adjudicated fails closed (EVIDENCE_BUNDLE_V2_PROFILE_UNADJUDICATED). Two hardening fixes, both scoped to the new profile so existing v1/v2 verdict semantics are untouched: * The envelope key is not pinned to the Keel trust root, so a body-supplied `require_user_verification: false` could weaken the ceremony. Under this profile that downgrade is rejected outright. * Quorum is honoured only when the decision binding is v6+, the versions that hash resource_attributes_json into the signed payload. Below that a requirement read from resource attributes is attacker-authored, not signed. Reports role as established only when a quorum claim binds it: the WebAuthn assertion covers the Permit decision hash, not the role label. Negative corpus mints real ES256 assertions and Ed25519 bindings end to end, covering permit id, decision hash, claim, allowed origins (including the load-bearing widened-origin case), assertion bytes, key manifest presence and contents, revoked keys, validity windows, wrong project, wrong requirement, unsatisfied and stripped quorum, role substitution, cross-Permit replay, unsigned legacy PermitAuditBundle, unknown profile, and unknown container version. Non-vacuity was checked by disabling four separate integrity checks and confirming the matching test fails each time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Left over from an earlier draft of the fixture builder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The capability manifest is how consumers learn what this verifier handles. Shipping a new container version without declaring it leaves them guessing, so record evidence_bundle v1/v2 and the keel.permit_co_signature/v1 profile — including the v2 fail-closed obligation and the v6+ binding requirement that bounds quorum. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A single-file
keel.evidence_bundle/v1was never adjudicated forbody.co_signature_evidence— onlykeel.permit_exact/*profiles dispatched.So wrapping a Permit audit bundle in
build_self_attesting_bundleand runningkeel-verify export <file>returned:with no co-signature claim adjudicated at all. Reproduced against released
v3.13.1 using the real production co-signature: the sole claim emitted was
evidence_bundle.self_attesting.v1. A throwaway local key signs a blob and theverifier reports success. That is a false green, and it is worse than the
fail-closed error operators see today.
What
Adds
keel.permit_co_signature/v1dispatch to the single-file path, reusingthe existing (already trust-rooted) adjudicators:
permit.decision.v1,permit.co_signature.v2, and the quorum claim.Three hardening decisions, each with a negative test:
keel.evidence_bundle/v2. Identical shape to v1; differs only in readerobligation — adjudicate
body.profileor fail closed. It exists so averifier predating co-signature support refuses the artifact instead of
green-lighting the envelope alone. Evidence outlives CLI versions.
Keel root, so a body-supplied
require_user_verification: falsemust notweaken the ceremony. Rejected explicitly rather than silently re-verified.
resource_attributes_jsonisnot hashed into the signed canonical payload, so a co-signature requirement
read from it is attacker-authored, not signed.
Nothing here rests on the outer envelope. Every load-bearing fact is
re-derived from independently pinned material: the decision binding, the
Keel-signed key status manifest, and the WebAuthn assertion.
Tests
31 new vectors, minted end to end — real ES256 passkey, real WebAuthn
assertion, real Ed25519 binding. Covers modified permit id / decision hash /
claim / origin / assertion, missing and modified key manifest, revoked key,
key outside its validity interval, wrong project, wrong requirement,
unsatisfied quorum, role substitution, unsigned legacy
PermitAuditBundle,a valid co-signature bound to a different Permit, unknown profile, and a
generic Permit with no exact semantic binding.
Non-vacuity proven by breaking four checks one at a time and confirming the
matching test fails. That sweep caught a real defect in this PR's own corpus:
the origin test was passing for the wrong reason — narrowing the origin list
is caught by the ceremony itself, so the test never exercised the cross-check
against the signed key record. The load-bearing case is a widened list
plus a genuine assertion from the attacker's origin; that test is now present
and is the one that fails when the cross-check is removed.
Full suite: 754 passed, 0 failed. Exact v1/v2/v3, Work evidence, compliance
export, and co-signature v1/v2 verdict semantics all unchanged.
Does not establish
Legal identity, correctness of the approver's judgement, lawfulness of the
underlying action, or any provider outcome.
Dependency
Root of the chain. keelapi/keel-api#TBD pins the release cut from this branch;
its two offline-verify tests skip until that pin lands.
🤖 Generated with Claude Code