Skip to content

docs(adr): ADR-0007 — OID4VCI key-proof verifier, protocol layer out#139

Merged
luisgf merged 1 commit into
mainfrom
docs/adr-0007-oid4vci-scope
Jul 24, 2026
Merged

docs(adr): ADR-0007 — OID4VCI key-proof verifier, protocol layer out#139
luisgf merged 1 commit into
mainfrom
docs/adr-0007-oid4vci-scope

Conversation

@luisgf

@luisgf luisgf commented Jul 24, 2026

Copy link
Copy Markdown
Owner

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 works
at the suite level. A key-proof verifier cannot: it needs the raw-JWS layer.

Needed Location Public?
parse_compact, verify_compact proof/_jws.py:58,74 No
reject_unknown_crit proof/_verify_common.py:44 No
check_jwt_temporal proof/_verify_common.py:172 No
match_alg, ALG_PROFILE proof/_verify_common.py:302,295 No

CONVENTIONS.md states leading-underscore names carry no stability guarantee, so
downstream 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. openbadgeslib already depends on openvc-core behind its [eudi]
extra and, being an application library, can legitimately ship the Redis/SQL nonce store
openvc refuses to.

The rule this adopts

In core: attacker-controlled bytes that must be verified or parsed fail-closed.
Downstream: anything with a lifetime, a socket, or a deployment policy.

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

  • The charter does not move (D3). 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 (VcJwtProofSuite.sign, SdJwtVcProofSuite.issue and the status-list builders
    all write). The accurate statement of the same boundary is credential-format
    production in; protocols, sessions and servers out
    .
  • The security property generalises rather than doubles (D8). An earlier draft 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.

Load-bearing design decisions

  • D4 — nonce single-use is part of proof verification, but the store has a lifetime.
    So the obligation goes into the type signature: ConsumeNonce is a required
    injected callable and the verifier raises without it. A plain expected_nonce: str is
    rejected as a design — equality cannot express "consume once, atomically", which would
    make the fail-open path the ergonomic one. Also documented: openvc.cache.TtlCache is
    not suitable (no single-flight, cache.py:24-27).
  • D5iat freshness is new logic: check_jwt_temporal reads exp/nbf and
    never iat. Both directions enforced, because without the future-dated check a wallet
    signs once with iat = now + 10y and holds a proof that never expires.
  • D5exactly 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; 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 review
    found.

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 openbadgeslib builds against,
so the downstream work is well-defined rather than implied.

Verification

flake8 clean · mypy clean (60 source files) · pytest 1382 passed, 22 skipped ·
gitlint clean · test_docs_links.py + test_docs_blocks.py green.

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
luisgf merged commit c9087ad into main Jul 24, 2026
13 of 14 checks passed
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.
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