Skip to content

feat(openid4vci): verify a wallet key proof (OpenID4VCI 1.0 App. F.1)#144

Merged
luisgf merged 1 commit into
mainfrom
feat/141-oid4vci-key-proof
Jul 24, 2026
Merged

feat(openid4vci): verify a wallet key proof (OpenID4VCI 1.0 App. F.1)#144
luisgf merged 1 commit into
mainfrom
feat/141-oid4vci-key-proof

Conversation

@luisgf

@luisgf luisgf commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Closes #141. Depends on #143 (RFC 7638 thumbprint) — this branch is stacked on it.

The security-critical half of OpenID4VCI, and the piece a downstream issuer cannot get
right alone. Given the Credential Request a wallet POSTed, openvc.openid4vci verifies
every openid4vci-proof+jwt in it and returns the public key each demonstrated
possession of — exactly what SdJwtVcProofSuite.issue binds a credential to via cnf.
The last mile was already one line; this supplies the first.

Check order

Structure and allow-lists before any crypto. Any failure rejects the whole request —
there is no partial issuance.

typ pin → alg allow-list → critexactly one key parameter → key material rules
→ signature → audiat freshnessexp/nbfiss(batch) one shared
nonce, consumed once, no duplicate keys.

Three carry the weight

  • The typ pin stops type confusion: without it a KB-JWT, VP-JWT, ID token or
    status-list token could be replayed as a key proof. Both media-type spellings are
    accepted per RFC 7515 §4.1.9 — an equivalence, not a widening.
  • Exactly one of jwk/kid/x5c/trust_chain, counted. Two present lets an
    attacker pair a kid naming an honest key with a jwk they control, and anything
    that silently prefers one accepts it. Structurally the defect feat: parse EUDI relying-party registration certificate (WRPRC) — JWT/CWT entitlements (ETSI TS 119 475) #89's adversarial review
    found in claim/claims precedence — so it is pinned by test, not by care.
  • iat freshness is new logic. check_jwt_temporal reads exp/nbf and never
    iat, but freshness is the property a key proof exists to carry and wallets need not
    set exp. Both directions are enforced; the future-dated one is what implementations
    forget — without it a wallet signs once with iat = now + 10y and holds a proof that
    never goes stale. NaN is rejected explicitly, for the reason check_jwt_temporal
    already documents: every comparison against it is False, i.e. never stale.

The state seam

Nonce single-use is part of proof verification, but a nonce store has a lifetime and by
ADR-0007
does not belong here. So the obligation lives in the type signature: ConsumeNonce
is a required injected callable and the verifier raises without one.

A plain expected_nonce: str was rejected as a design — equality cannot express
"consume once, atomically", and offering it would make the fail-open path the ergonomic
one. It fires exactly once per request, and only after every signature has
verified, so an unauthenticated attacker cannot burn nonces by spraying garbage.

That is also why there is no public singular verifier: the batch invariants exist
only in the plural, and a public singular would invite the caller loop that breaks all
three.

ProofReplayed is deliberately not a ClaimsInvalid, so an endpoint can answer
invalid_nonce and hand out a fresh one rather than rejecting the wallet.

Fail-closed defaults

No trust_anchorsx5c rejected (an unanchored chain is decoration, not trust).
No resolve_proof_keykid rejected. trust_chain ⇒ typed UnsupportedProofType,
never silently ignored. batch_size defaults to 1, so an issuer that never
advertised batch issuance cannot be made to mint one credential per proof off one grant.

Two findings from the adversarial pass

  1. The x5c chain was path-validated against the wall clock while everything else
    honoured the injected now — so a frozen-clock verification checked the certificate
    window against real time. now is now threaded through and pinned by test.
  2. Writing the example surfaced a fail-open nonce store in my own code: a set-based
    consume_nonce using discard() always returns True — the exact read-then-write
    shape the guide warns about. Both the example and the wiki now show remove() and
    name the wrong version explicitly.

Reuse, not restatement

parse_compact/verify_compact for the signature, reject_unknown_crit,
check_jwt_temporal for exp/nbf, x5c's path-validation core (deliberately not
resolve_x5c_key, whose iss→SAN and P-256-leaf rules are issuer-certificate rules),
and the shared proof error leaves. One area root plus three leaves that earn their own
class.

Out of scope, and staying out

The AS, HTTP, state stores, response/offer/metadata builders, JWE encrypt, di_vp,
DPoP, and key-attestation verification — the attestation header is captured verbatim
and unverified under the peek_* doctrine. The claim this supports is "OpenID4VCI
1.0 key-proof verification" — not "issuance", and not HAIP.

Verification

flake8 clean · mypy clean (61 source files) · pytest 1553 passed, 25 skipped
(+130) · gitlint clean. No new runtime dependency.

129 tests covering the full check order and the adversarial corpus (typ confusion,
alg:none, two key parameters, d in a jwk, alg↔crv mismatch, unanchored and expired
x5c, future/NaN/stale/missing iat, nonce replay, cross-issuer and multi-valued
aud, divergent batch nonces, duplicate thumbprints, unknown crit, hostile-depth
JSON, batch overflow), plus examples/12 running the flow end to end offline and then
replaying the same proof to show it rejected.

Closes #141.

The security-critical half of OpenID4VCI, and the piece a downstream issuer cannot
get right alone. Given the Credential Request a wallet POSTed, openvc.openid4vci
verifies every openid4vci-proof+jwt in it and returns the public key each one
demonstrated possession of — which is exactly what SdJwtVcProofSuite.issue binds a
credential to via cnf. The last mile was already one line; this supplies the first.

Checks run in a fixed order, structure and allow-lists before any crypto, and any
failure rejects the whole request — there is no partial issuance:

  typ pin -> alg allow-list -> crit -> exactly one key parameter -> key material
  rules -> signature -> aud -> iat freshness -> exp/nbf -> iss

Three of those carry the weight.

The typ pin is what stops type confusion: without it a KB-JWT, a VP-JWT, an ID token
or a status-list token could be replayed as a key proof. Both media-type spellings
are accepted per RFC 7515 §4.1.9, which is an equivalence and not a widening.

Exactly one of jwk/kid/x5c/trust_chain, counted. Two present lets an attacker pair a
kid naming an honest key with a jwk they control, and any implementation that
silently prefers one accepts it. This is structurally the defect #89's adversarial
review found in claim/claims precedence, so it is pinned by test rather than by care.

iat freshness is new logic: check_jwt_temporal reads exp/nbf and never looks at iat,
but freshness is the property a key proof exists to carry and wallets need not set
exp. Both directions are enforced. The future-dated one is what implementations
forget — without it a wallet signs once with iat = now + 10y and holds a proof that
never goes stale. NaN is rejected explicitly for the reason check_jwt_temporal
already documents: every comparison against it is False, i.e. never stale.

Nonce single-use is part of proof verification, but a nonce store has a lifetime and
by ADR-0007 does not belong here. So the obligation lives in the type signature:
ConsumeNonce is a required injected callable and the verifier raises without one. A
plain expected_nonce string was rejected as a design — equality cannot express
consume-once-atomically, and offering it would make the fail-open path the ergonomic
one. It fires exactly once per request and only after every signature has verified,
so an unauthenticated attacker cannot burn nonces by spraying garbage; a per-proof
loop would instead fail the second proof of a batch. That is also why there is no
public singular verifier: the batch invariants — one shared nonce, consume once, no
two proofs on the same key — exist only in the plural, and a public singular would
invite the caller loop that breaks all three.

Replay surfaces as a distinct ProofReplayed, not a ClaimsInvalid, so an endpoint can
answer invalid_nonce and hand out a fresh one rather than rejecting the wallet.

Fail-closed defaults throughout: no trust_anchors means x5c proofs are rejected, an
unanchored chain being decoration rather than trust; no resolve_proof_key means kid
proofs are rejected; trust_chain is a typed UnsupportedProofType rather than silently
ignored; batch_size defaults to 1 so an issuer that never advertised batch issuance
cannot be made to mint one credential per proof off a single grant.

Two findings from the adversarial pass are folded in. The x5c chain was being
path-validated against the wall clock while everything else honoured the injected
now, so a frozen-clock verification checked the certificate window against real time;
now is threaded through and pinned by test. And writing the example surfaced that a
set-based nonce store using discard() always returns True — the exact read-then-write
shape the guide warns about — so both the example and the wiki now show remove() and
name the wrong version explicitly.

Reuses the existing JOSE layer rather than restating it: parse_compact/verify_compact
for the signature, reject_unknown_crit, check_jwt_temporal for exp/nbf, and the shared
proof error leaves. One area root plus three leaves that earn their own class.

Out of scope and staying out (ADR-0007 D9): the AS, HTTP, state stores, response and
offer/metadata builders, JWE encrypt, di_vp, DPoP, and key-attestation verification —
the attestation header is captured verbatim and unverified under the peek doctrine.
The claim this supports is OpenID4VCI 1.0 key-proof verification, not issuance and
not HAIP. No new runtime dependency.

129 tests, plus examples/12 running the flow end to end offline and then replaying the
same proof to show it rejected.
@luisgf
luisgf force-pushed the feat/141-oid4vci-key-proof branch from d16b3a9 to 2c09fbc Compare July 24, 2026 20:46
@luisgf
luisgf merged commit ef5d6c5 into main Jul 24, 2026
13 of 14 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.

feat(openid4vci): verify a wallet key proof (OpenID4VCI 1.0 App. F.1)

1 participant