feat(openid4vci): verify a wallet key proof (OpenID4VCI 1.0 App. F.1)#144
Merged
Conversation
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
force-pushed
the
feat/141-oid4vci-key-proof
branch
from
July 24, 2026 20:46
d16b3a9 to
2c09fbc
Compare
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.
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.openid4vciverifiesevery
openid4vci-proof+jwtin it and returns the public key each demonstratedpossession of — exactly what
SdJwtVcProofSuite.issuebinds a credential to viacnf.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.
typpin → alg allow-list →crit→ exactly one key parameter → key material rules→ signature →
aud→iatfreshness →exp/nbf→iss→ (batch) one sharednonce, consumed once, no duplicate keys.
Three carry the weight
typpin stops type confusion: without it a KB-JWT, VP-JWT, ID token orstatus-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.
jwk/kid/x5c/trust_chain, counted. Two present lets anattacker pair a
kidnaming an honest key with ajwkthey control, and anythingthat 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/claimsprecedence — so it is pinned by test, not by care.iatfreshness is new logic.check_jwt_temporalreadsexp/nbfand neveriat, but freshness is the property a key proof exists to carry and wallets need notset
exp. Both directions are enforced; the future-dated one is what implementationsforget — without it a wallet signs once with
iat = now + 10yand holds a proof thatnever goes stale.
NaNis rejected explicitly, for the reasoncheck_jwt_temporalalready 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:
ConsumeNonceis a required injected callable and the verifier raises without one.
A plain
expected_nonce: strwas 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.
ProofReplayedis deliberately not aClaimsInvalid, so an endpoint can answerinvalid_nonceand hand out a fresh one rather than rejecting the wallet.Fail-closed defaults
No
trust_anchors⇒x5crejected (an unanchored chain is decoration, not trust).No
resolve_proof_key⇒kidrejected.trust_chain⇒ typedUnsupportedProofType,never silently ignored.
batch_sizedefaults to 1, so an issuer that neveradvertised batch issuance cannot be made to mint one credential per proof off one grant.
Two findings from the adversarial pass
x5cchain was path-validated against the wall clock while everything elsehonoured the injected
now— so a frozen-clock verification checked the certificatewindow against real time.
nowis now threaded through and pinned by test.consume_nonceusingdiscard()always returnsTrue— the exact read-then-writeshape the guide warns about. Both the example and the wiki now show
remove()andname the wrong version explicitly.
Reuse, not restatement
parse_compact/verify_compactfor the signature,reject_unknown_crit,check_jwt_temporalforexp/nbf,x5c's path-validation core (deliberately notresolve_x5c_key, whoseiss→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 "OpenID4VCI1.0 key-proof verification" — not "issuance", and not HAIP.
Verification
flake8clean ·mypyclean (61 source files) ·pytest1553 passed, 25 skipped(+130) ·
gitlintclean. No new runtime dependency.129 tests covering the full check order and the adversarial corpus (typ confusion,
alg:none, two key parameters,din ajwk, alg↔crv mismatch, unanchored and expiredx5c, future/NaN/stale/missingiat, nonce replay, cross-issuer and multi-valuedaud, divergent batch nonces, duplicate thumbprints, unknowncrit, hostile-depthJSON, batch overflow), plus
examples/12running the flow end to end offline and thenreplaying the same proof to show it rejected.