-
Notifications
You must be signed in to change notification settings - Fork 0
Issuing with OpenID4VCI
openvc verifies the key proof a wallet sends to your Credential Endpoint, and hands you back the public key it demonstrated possession of. That key is what you bind the credential to.
It does not run the endpoint. Per ADR-0007, the split is:
openvc: attacker-controlled bytes that must be verified or parsed fail-closed. You: anything with a lifetime, a socket, or a deployment policy.
So the OAuth Authorization Server, the HTTP routing, the c_nonce store, the Credential
Response body, deferred and notification bookkeeping, and DPoP are yours (or your
framework's). What this supports claiming is OpenID4VCI 1.0 key-proof verification —
not "issuance", and not HAIP.
The same posture covers the two untrusted JSON documents that come before the key
proof: the Credential Offer (§4.1.1) a wallet scans, and the Credential Issuer
Metadata (§11.2.3) it fetches under credential_issuer. Both are attacker-controlled
bytes; both parsers raise on a malformed constraint rather than silently narrowing it:
from openvc.openid4vci import (
GRANT_PRE_AUTHORIZED_CODE,
parse_credential_issuer_metadata,
parse_credential_offer,
)
wallet_scanned_json = """
{"credential_issuer": "https://issuer.example",
"credential_configuration_ids": ["UniversityDegree"],
"grants": {"urn:ietf:params:oauth:grant-type:pre-authorized_code":
{"pre-authorized_code": "SplxlOBeZQ..."}}}
"""
offer = parse_credential_offer(wallet_scanned_json) # object or JSON string
assert offer.credential_issuer.startswith("https://") # enforced, not hoped for
fetched_metadata_json = """
{"credential_issuer": "https://issuer.example",
"credential_endpoint": "https://issuer.example/credential",
"batch_credential_issuance": {"batch_size": 5}}
"""
metadata = parse_credential_issuer_metadata(fetched_metadata_json)
batch = metadata.batch_size or 1 # 1 is the fail-closed default
assert GRANT_PRE_AUTHORIZED_CODE in offer.grants
code = offer.grants[GRANT_PRE_AUTHORIZED_CODE]["pre-authorized_code"]
assert metadata.batch_size == 5What is pinned:
| Member | Rule |
|---|---|
credential_issuer |
present, an absolute https URL — it feeds the key proof's aud check |
credential_configuration_ids |
non-empty array of distinct non-empty strings |
credential_endpoint, nonce_endpoint, … |
absolute https when present |
authorization_servers |
when present, a non-empty array of https URLs |
batch_credential_issuance.batch_size |
an integer ≥ 2 |
What is not narrowed: grants members openvc does not know, per-configuration
shapes, display, encryption parameters — all reach you verbatim (in the typed fields
and in raw), because an ecosystem's extension points are none of a parser's business.
Nothing is fetched. A by-reference credential_offer_uri is your injected Fetch
to resolve, and signed_metadata (a JWT) reaches you as a string to verify — or not —
under your own trust rules.
import time
from openvc.keys import Ed25519SigningKey, P256SigningKey
from openvc.openid4vci import verify_credential_request_proofs
from openvc.proof._jws import sign_compact
from openvc.proof.sd_jwt import SdJwtVcProofSuite
CREDENTIAL_ISSUER = "https://issuer.example"
issuer = Ed25519SigningKey.generate(kid=f"{CREDENTIAL_ISSUER}#key-1")
# Your nonce store. See below — this MUST be atomic.
issued = {"c-nonce-abc"}
def consume_nonce(nonce):
try:
issued.remove(nonce)
return True
except KeyError:
return False
# --- the wallet mints a key proof (OID4VCI 1.0 App. F.1) ---
wallet = P256SigningKey.generate(kid="wallet-key-1")
key_proof = sign_compact(
{"typ": "openid4vci-proof+jwt", "alg": wallet.alg, "jwk": wallet.public_jwk()},
{"aud": CREDENTIAL_ISSUER, "iat": int(time.time()), "nonce": "c-nonce-abc"},
signing_key=wallet)
# --- your Credential Endpoint verifies it, then issues ---
proof, = verify_credential_request_proofs(
{"credential_configuration_id": "UniversityDegree",
"proofs": {"jwt": [key_proof]}},
credential_issuer=CREDENTIAL_ISSUER,
check_nonce=consume_nonce)
sd_jwt = SdJwtVcProofSuite().issue(
{"iss": CREDENTIAL_ISSUER, "degree": "BSc"}, signing_key=issuer,
vct="https://credentials.example/degree",
holder_jwk=proof.public_jwk) # <- the key the proof earned
assert proof.key_source == "jwk"Wire it into your framework however you like — openvc never sees the request object, the access token, or the socket:
@app.post("/credential")
def credential_endpoint(request):
grant = my_as.introspect(request.headers["authorization"]) # yours: OAuth AS
proofs = verify_credential_request_proofs(
request.json(), credential_issuer=CREDENTIAL_ISSUER,
check_nonce=my_nonce_store.consume)
sd_jwt = SdJwtVcProofSuite().issue(
grant.claims, signing_key=ISSUER_KEY, vct=VCT, holder_jwk=proofs[0].public_jwk)
return {"credentials": [{"credential": sd_jwt}]} # yours: the bodycheck_nonce is a required parameter. Replay is the property a key proof exists to
defend, and a plain expected_nonce string could not express "consume once,
atomically" — a caller comparing after the fact would have verified a signature and
not the replay property.
Your callable must mark the nonce used and report validity in one step: a Redis
SET key val NX, a SQL DELETE … RETURNING. The bug to avoid:
# WRONG — two concurrent requests both observe the nonce as unused.
if nonce in store:
store.discard(nonce)
return Trueopenvc.cache.TtlCache is not suitable: it documents its own lack of single-flight,
which is benign for a read cache and fatal for a single-use token.
openvc calls it exactly once per request, and only after every signature has verified — so an unauthenticated attacker cannot burn your nonces by spraying garbage.
Pre-authorized codes, transaction_id and notification_id are entirely yours: they
are opaque identifiers with no bytes for openvc to get right, so it does not generate
them.
Structure and allow-lists run before any cryptography. Any failure rejects the whole request — there is no partial issuance.
| Check | |
|---|---|
| 1 |
typ is openid4vci-proof+jwt — so a KB-JWT, VP-JWT or status-list token cannot be replayed as a key proof |
| 2 |
alg is allow-listed (ES256/ES384/EdDSA/Ed25519), before any crypto |
| 3 | unknown crit rejected |
| 4 |
key_attestation, if present, is a string and parses as a key attestation |
| 4b | if you passed require_key_attestation=True, the header must carry one — before crypto, before the nonce is spent |
| 5 |
exactly one of jwk / kid / x5c / trust_chain
|
| 6 | the key binds to the header alg — no ES256 over an Ed25519 key; no private members in a jwk
|
| 7 | with a key_attestation: the key is one of its attested_keys (App. D) |
| 8 | the signature |
| 9 |
aud equals your Credential Issuer Identifier; a multi-valued aud is rejected |
| 10 |
iat freshness — both stale and future-dated |
| 11 |
exp / nbf if present; iss if you pinned expected_client_id
|
| 12 | across the batch: one shared nonce, consumed once, no two proofs on the same key |
Two of these carry the weight. iat in both directions: without the future-dated
check a wallet signs once with iat = now + 10y and holds a proof that never expires.
Exactly one key parameter: 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.
jwk works out of the box. The other two are opt-in, and fail closed without their
enabling argument:
| Header | Enable with | Absent ⇒ |
|---|---|---|
jwk |
— | always available |
x5c |
trust_anchors=[...] |
rejected — an unanchored chain is decoration, not trust |
kid |
resolve_proof_key= or resolve_proof_key_in_context=
|
rejected |
trust_chain |
— | typed UnsupportedProofType (OpenID Federation is out of scope) |
key_attestation |
require_key_attestation=True to require it |
not a key parameter — see below; a header carrying only this one is rejected. Default is not to require it. |
The form EU wallet stacks emit: the header carries a kid and a key_attestation
(App. D), and the key that signed the proof is one of the attestation's attested_keys.
There is nothing for a registry to look up, so resolve_proof_key — which sees only the
kid — cannot answer. resolve_proof_key_in_context gets everything openvc knows at
that moment, attestation already parsed
(examples/13_oid4vci_key_attestation.py is this, end to end and runnable):
from openvc.openid4vci import peek_key_attestation, verify_credential_request_proofs
proofs = verify_credential_request_proofs(
body, credential_issuer=CREDENTIAL_ISSUER, check_nonce=store.consume,
# this ecosystem reads `kid` as a position in attested_keys; yours may differ
resolve_proof_key_in_context=lambda ctx: ctx.key_attestation.attested_keys[int(ctx.kid)])
attested = peek_key_attestation(proofs[0].key_attestation) # UNVERIFIED
if "iso_18045_high" not in attested.key_storage: # your policy, your call
raise PermissionError("this credential needs high-assurance key storage")Pass one resolver or the other, never both — a precedence between two key resolvers is the same silent-preference defect the one-key-parameter rule exists to prevent.
Which key a kid names is not specified. The spec's own example uses it as an index;
other wallets use each JWK's kid member, or an RFC 7638 thumbprint. openvc will not
guess between them — that is your ecosystem's rule, and it is three lines above.
If your metadata publishes key_attestations_required, pass
require_key_attestation=True. A missing header is then a structure failure —
ClaimsInvalid — before the signature and before check_nonce runs. Without
the flag you only notice afterwards, by reading VerifiedProof.key_attestation, and
the nonce is already spent: the wallet's §8.3.1 retry becomes a loop. The policy is
yours; the ordering is only available here. Default False, so an issuer that does
not advertise the requirement is unchanged.
What openvc does enforce is App. D's MUST: with a key_attestation present, the key
that signed the proof must be one of its attested_keys. Be clear about what that buys.
It stops no attacker — nothing here verifies the attestation's signature, so whoever
forges a proof also forges an attestation listing their own key. It catches an honest
wallet, or your own resolver, handing over a key the wallet never claimed, which would
otherwise mint a credential bound to the wrong key and verify cleanly.
Deciding an attestation is genuine — its signature, its wallet-provider anchor, its
key_storage level, its exp, its status — is yours. peek_key_attestation reads one
without verifying anything, KEY_ATTESTATION_TYP is exported for the typ pin, and
ADR-0007 D9 records why the trust model itself is not in openvc.
batch_size defaults to 1, so an issuer that never advertised
batch_credential_issuance rejects a batch rather than minting one credential per proof
off a single grant. Raise it deliberately:
proofs = verify_credential_request_proofs(
body, credential_issuer=CREDENTIAL_ISSUER,
check_nonce=store.consume, batch_size=5)All proofs in a batch must carry the same nonce, and no two may be bound to the same key — N credentials must mean N keys.
| Raised | Meaning | Map to |
|---|---|---|
CredentialRequestMalformed |
the §8.2 wire contract is violated | invalid_credential_request |
ProofReplayed |
your store rejected the nonce |
invalid_nonce — hand out a fresh one and let the wallet retry |
UnsupportedProofType |
di_vp, trust_chain, … |
invalid_proof |
ClaimsInvalid / SignatureInvalid / MalformedToken / UnsupportedAlgorithm
|
the shared proof leaves | invalid_proof |
ProofReplayed is deliberately not a ClaimsInvalid: "here is a fresh nonce, try
again" is a different answer from "your proof is wrong".
Key-attestation trust: the signature, the wallet-provider anchor, and whether
key_storage / user_authentication / status meet your bar. openvc parses one and
binds the proof key to it (above); believing it needs an anchor class of its own
(ADR-0007 D9). The attestation proof type — a key attestation with no proof of
possession at all — is likewise out, and refused as UnsupportedProofType. di_vp
proofs, credential-response encryption, mdoc issuance and DPoP are out of scope too.
HAIP additionally requires DPoP, key-attestation trust and client authentication, all of
which live in your endpoint.
See also: SD-JWT VC for the issuance side, Keys & HSM
backends for SigningKey, and Security model.
📖 Published automatically from the wiki/ directory of the main repository — edits made directly on the wiki are overwritten by the next sync. To change a page, open a PR against wiki/. Every python block on these pages is executed by CI. · API reference · LGPL-3.0-or-later
Start
Proof formats
Verifying in practice
- Presentations & OpenID4VP
- Resolving issuer keys
- Relying-party certificates
- Credential schemas
- Status lists
- Trust anchors: EU TL & EBSI
- Async verification
Issuing
Walkthroughs
Operating
Reference