docs(adr): ADR-0007 — OID4VCI key-proof verifier, protocol layer out#139
Merged
Conversation
Settles which half of OpenID4VCI openvc owns. Ships no code. OID4VCI is two things welded together: a small amount of security-critical cryptography over attacker-controlled bytes, and a large amount of stateful protocol orchestration. They split cleanly, and the code says where the seam is. The decisive fact is that the crypto cannot live downstream. openvc_ebsi, the only existing plugin, imports exclusively public API and works at the suite level. A key-proof verifier cannot: it needs _jws.parse_compact/verify_compact and _verify_common.reject_unknown_crit/check_jwt_temporal/match_alg, all private and all documented as carrying no stability guarantee. That leaves three options downstream — depend on unstable private API, force a permanent public-surface expansion for one consumer, or reimplement JOSE verification, which ADR-0002 forbids in as many words. So the verifier is core's, or it is duplicated. The orchestration half is equally clearly not core's: it is state, sockets and deployment policy. openbadgeslib already depends on openvc-core behind its [eudi] extra and, being an application library, can legitimately ship the Redis or SQL nonce store openvc refuses to. Hence the rule the ADR adopts, which decides any future case: attacker- controlled bytes that must be verified or parsed fail-closed are openvc's; anything with a lifetime, a socket or a deployment policy is the consumer's. In core: wallet key-proof verification (App. F.1) and fail-closed parsing of untrusted Credential Offers and Issuer Metadata. Out: nonce and code stores, every endpoint, the AS, and the response/offer/metadata builders. Two consequences worth noting. The charter does not move. Verifying attacker-controlled bytes and failing closed is what openvc already does — the same posture as the OpenID4VP verifier, one protocol over. No out-of-scope entry is retracted. What is corrected is an inaccurate summary: the ROADMAP called the library read/verify-only, untrue since 1.0, and the accurate statement of the same boundary is credential-format production in, protocols/sessions/servers out. The security property generalises rather than doubles. An earlier draft of this ADR took the whole protocol layer and accepted a co-equal second property, a third asset and a rewritten attacker table. Under the split none of that is needed: openvc's output is still a verification decision over attacker- controlled bytes, so "no wrong-accept" widens from "credential" to "credential or key proof", and Security-Model gains one row rather than a new table. The wrong-issue property belongs to whoever mints the credential, downstream. D4 survives the rescope unchanged and stays load-bearing: nonce single-use is part of proof verification but the store has a lifetime, so the obligation goes into the type signature as a required injected callable. A plain expected_nonce string is rejected as a design — equality cannot express consume-once- atomically, and it would make the fail-open path the ergonomic one. The ADR closes with the contract openbadgeslib builds against, so the downstream work is well-defined rather than implied.
luisgf
added a commit
that referenced
this pull request
Jul 24, 2026
Two additive features on 1.22.0: the RFC 7638 JWK thumbprint (#140) and OpenID4VCI 1.0 wallet key-proof verification (#141), the latter scoped by ADR-0007 (#139). Also folds in the documentation anti-drift pass the release gate found. Merging the feature PRs turned four statements from planned into shipped without the prose following: - README grew the issuer-side capability row, worded as the ADR requires — key proof verification, never "issuance" and never HAIP — and its example count went stale at ten against twelve scripts. - ROADMAP's OID4VCI bullet recorded the scope verdict but not that the verifier had landed; it now points at #141 as shipped and #142 as next. - Security-Model still said the attacker row was "coming". It is now an actual row: an attacker who requests a credential rather than presenting one, and the controls that stop them. - threat-model still said the property would widen "when that code lands". It has, so the object widens from credential to credential-or-key-proof, and the invariant catalog gains I17 (typ pin, exactly one key parameter, key-to-alg binding) and I18 (iat freshness both ways, nonce consumed once after every signature verifies), both cited to real lines. Gate: flake8, mypy over 61 source files, 1553 tests passing with 25 skipped, and build + twine check on both artifacts.
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.
Settles which half of OpenID4VCI openvc owns. Ships no code.
OID4VCI is two things welded together: a small amount of security-critical cryptography
over attacker-controlled bytes, and a large amount of stateful protocol orchestration.
They split cleanly, and the code says where the seam is.
The decisive fact: the crypto cannot live downstream
openvc_ebsi— the only existing plugin — imports exclusively public API and worksat the suite level. A key-proof verifier cannot: it needs the raw-JWS layer.
parse_compact,verify_compactproof/_jws.py:58,74reject_unknown_critproof/_verify_common.py:44check_jwt_temporalproof/_verify_common.py:172match_alg,ALG_PROFILEproof/_verify_common.py:302,295CONVENTIONS.mdstates leading-underscore names carry no stability guarantee, sodownstream has three options and all three are bad: depend on unstable private API;
force a permanent public-surface expansion for one consumer; or reimplement JOSE
verification, which ADR-0002
forbids in as many words ("no signature check exists twice to drift").
The orchestration half is equally clearly not core's — it is state, sockets and
deployment policy.
openbadgeslibalready depends onopenvc-corebehind its[eudi]extra and, being an application library, can legitimately ship the Redis/SQL nonce store
openvc refuses to.
The rule this adopts
In: wallet key-proof verification (OID4VCI 1.0 App. F.1) and fail-closed parsing of
untrusted Credential Offers and Issuer Metadata.
Out: nonce/code stores, every endpoint, the AS, and the response/offer/metadata
builders.
Two costs this scope avoids
closed is what openvc already does — the same posture as the OpenID4VP verifier, one
protocol over. No out-of-scope entry is retracted. What is corrected is an
inaccurate summary: the ROADMAP called the library "read/verify-only", untrue since
1.0 (
VcJwtProofSuite.sign,SdJwtVcProofSuite.issueand the status-list buildersall write). The accurate statement of the same boundary is credential-format
production in; protocols, sessions and servers out.
the whole protocol layer and accepted a co-equal second property, a third asset and a
rewritten attacker table. Under the split none of that is needed: openvc's output is
still a verification decision over attacker-controlled bytes, so no wrong-accept
widens from "credential" to "credential or key proof", and
Security-Modelgainsone row rather than a new table.
Load-bearing design decisions
So the obligation goes into the type signature:
ConsumeNonceis a requiredinjected callable and the verifier raises without it. A plain
expected_nonce: strisrejected as a design — equality cannot express "consume once, atomically", which would
make the fail-open path the ergonomic one. Also documented:
openvc.cache.TtlCacheisnot suitable (no single-flight,
cache.py:24-27).iatfreshness is new logic:check_jwt_temporalreadsexp/nbfandnever
iat. Both directions enforced, because without the future-dated check a walletsigns once with
iat = now + 10yand holds a proof that never expires.jwk/kid/x5c/trust_chain, counted. Two present letsan attacker pair a
kidnaming an honest key with ajwkthey control; anything that"prefers
jwk" accepts silently. Structurally the same defect feat: parse EUDI relying-party registration certificate (WRPRC) — JWT/CWT entitlements (ETSI TS 119 475) #89's adversarial reviewfound.
Bounded claim
"OpenID4VCI 1.0 key-proof verification" — symmetric with how openvc already presents
OpenID4VP (verify what arrives; build no request, host no endpoint). Not "issuance", not
"HAIP issuance": HAIP requires DPoP, key attestations and client authentication, all
downstream.
Follow-ups
Core issues in dependency order — RFC 7638 thumbprint → key-proof verification →
offer/metadata parsers. The ADR closes with the contract
openbadgeslibbuilds against,so the downstream work is well-defined rather than implied.
Verification
flake8clean ·mypyclean (60 source files) ·pytest1382 passed, 22 skipped ·gitlintclean ·test_docs_links.py+test_docs_blocks.pygreen.