Skip to content

feat: adjudicate co-signature evidence in single-file bundles - #68

Merged
sftimeless merged 3 commits into
mainfrom
cosign-bundle-offline-verify
Aug 5, 2026
Merged

feat: adjudicate co-signature evidence in single-file bundles#68
sftimeless merged 3 commits into
mainfrom
cosign-bundle-offline-verify

Conversation

@sftimeless

Copy link
Copy Markdown
Member

Why

A single-file keel.evidence_bundle/v1 was never adjudicated for
body.co_signature_evidence — only keel.permit_exact/* profiles dispatched.
So wrapping a Permit audit bundle in build_self_attesting_bundle and running
keel-verify export <file> returned:

Evidence: VERIFIED
  ✓ Evidence bundle structure and signature valid

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 the
verifier 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/v1 dispatch to the single-file path, reusing
the 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 reader
    obligation — adjudicate body.profile or fail closed. It exists so a
    verifier predating co-signature support refuses the artifact instead of
    green-lighting the envelope alone. Evidence outlives CLI versions.
  • No user-verification downgrade. The envelope key is not pinned to the
    Keel root, so a body-supplied require_user_verification: false must not
    weaken the ceremony. Rejected explicitly rather than silently re-verified.
  • Quorum requires a v6+ binding. Below v6, resource_attributes_json is
    not 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

sftimeless and others added 3 commits August 4, 2026 19:22
`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>
@sftimeless
sftimeless merged commit eb8249c into main Aug 5, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant