Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,40 @@ All notable changes to **openvc** are documented here. The format follows

### Added

- **OpenID4VCI 1.0 wallet key-proof verification**
([#141](https://github.com/luisgf/openvc/issues/141)). A new
`openvc.openid4vci` verifies the `openid4vci-proof+jwt` a wallet sends to a
Credential Endpoint (OID4VCI 1.0 App. F.1) and returns the public key it
demonstrated possession of — the value
`SdJwtVcProofSuite.issue(holder_jwk=…)` binds the credential to.
`parse_credential_request` pins the §8.2 wire contract;
`verify_credential_request_proofs` runs the checks in a fixed order, structure and
allow-lists **before** any crypto: the `typ` pin (so a KB-JWT, VP-JWT or
status-list token cannot be replayed as a key proof), the algorithm allow-list,
unknown `crit`, **exactly one** of the `jwk`/`kid`/`x5c`/`trust_chain` header key
parameters, the key↔`alg` (kty, crv) binding, the signature, `aud` pinned to the
Credential Issuer Identifier with a multi-valued `aud` rejected, and **`iat`
freshness in both directions** — stale *and* future-dated, which is new logic
because `check_jwt_temporal` reads `exp`/`nbf` and never `iat`. Across a batch:
one shared nonce, no two proofs on the same key.

**Nonce state is the caller's, injected as a required callable** (`ConsumeNonce`)
rather than documented in prose — a plain `expected_nonce` string cannot express
"consume once, atomically", and would make the fail-open path the ergonomic one.
It is invoked exactly once per request and only *after* every signature has
verified, so an unauthenticated attacker cannot burn nonces. Replay surfaces as a
distinct `ProofReplayed` so an endpoint can answer `invalid_nonce` (fresh nonce,
retry) rather than rejecting the wallet.

Stateless and transport-free by design
([ADR-0007](https://github.com/luisgf/openvc/blob/main/docs/adr/ADR-0007-oid4vci-issuer-side.md)):
no endpoint, no Authorization Server, no state store, and no response/offer/metadata
builders — those belong to the issuing application. Key attestations are captured
**unverified**; `di_vp` and OpenID Federation `trust_chain` proof keys raise a typed
`UnsupportedProofType`. What this supports claiming is *OpenID4VCI 1.0 key-proof
verification* — not "issuance", and not HAIP, which additionally requires DPoP, key
attestations and client authentication. No new runtime dependency.

- **RFC 7638 JWK Thumbprint** ([#140](https://github.com/luisgf/openvc/issues/140)).
`openvc.keys` grows `jwk_thumbprint` (base64url) and `jwk_thumbprint_bytes` (the
raw digest), covering `EC`, `OKP`, `RSA` and `oct` keys. The canonical form is
Expand Down
13 changes: 13 additions & 0 deletions docs/api/openid4vci.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# OpenID4VCI key proofs

Verify the key proof a wallet sends to a Credential Endpoint, and get back the public
key it demonstrated possession of — the value
`SdJwtVcProofSuite.issue(holder_jwk=...)` binds the credential to.

Stateless and transport-free: no endpoint, no Authorization Server, no nonce store.
Nonce single-use is injected as a required callable. See
[ADR-0007](https://github.com/luisgf/openvc/blob/main/docs/adr/ADR-0007-oid4vci-issuer-side.md)
for the boundary and the [Issuing with OpenID4VCI](https://github.com/luisgf/openvc/wiki/Issuing-with-OpenID4VCI)
guide for the flow.

::: openvc.openid4vci
86 changes: 86 additions & 0 deletions examples/12_oid4vci_key_proof.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""
12 — OpenID4VCI: verify a wallet's key proof, then issue bound to the key it proved.

The wallet POSTs a Credential Request carrying an `openid4vci-proof+jwt`; the issuer
verifies it and gets back the wallet's public key, which is exactly what
`SdJwtVcProofSuite.issue` binds the credential to via `cnf`.

openvc does the cryptography. The endpoint, the OAuth grant and the nonce store are
YOURS — here the store is a set, in production it must be atomic (see ADR-0007).

Run: python examples/12_oid4vci_key_proof.py
"""
import time

from _common import did_key_p256

from openvc import verify_credential
from openvc.keys import 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"
VCT = "https://credentials.example/university-degree"

issuer, issuer_did = did_key_p256()
wallet = P256SigningKey.generate(kid="wallet-key-1")

# --- the issuer's nonce store. Yours. Must be ATOMIC in production: a Redis SET NX or
# a SQL DELETE ... RETURNING — a read-then-write lets two concurrent requests both win.
issued_nonces = {"c_nonce_from_the_nonce_endpoint"}


def consume_nonce(nonce: str) -> bool:
"""Mark the nonce used and report whether it was valid — in ONE step.

`set.remove` either removes and returns, or raises: there is no window between
the check and the removal. Writing this as `if n in store: store.discard(n)` is
the bug that re-opens the replay window under concurrency.
"""
try:
issued_nonces.remove(nonce)
return True
except KeyError:
return False


# --- wallet side: mint the key proof (App. F.1) ------------------------------------
key_proof = sign_compact(
{"typ": "openid4vci-proof+jwt", "alg": wallet.alg, "jwk": wallet.public_jwk()},
{"aud": CREDENTIAL_ISSUER, # this issuer, and only this one
"iat": int(time.time()), # freshness, checked both ways
"nonce": "c_nonce_from_the_nonce_endpoint"},
signing_key=wallet,
)
credential_request = {
"credential_configuration_id": "UniversityDegree",
"proofs": {"jwt": [key_proof]},
}

# --- issuer side: verify, then issue -----------------------------------------------
proof, = verify_credential_request_proofs(
credential_request,
credential_issuer=CREDENTIAL_ISSUER,
check_nonce=consume_nonce, # state: injected, never stored here
)
print("proof verified — key source:", proof.key_source, "| thumbprint:", proof.thumbprint)

sd_jwt = SdJwtVcProofSuite().issue(
{"iss": issuer_did, "degree": "BSc Computer Science"},
signing_key=issuer, vct=VCT, disclosable=["degree"],
holder_jwk=proof.public_jwk, # <- the key the proof demonstrated
)

# --- the credential verifies, and is bound to the wallet key -----------------------
result = verify_credential(sd_jwt)
assert result.raw.confirmation == {"jwk": wallet.public_jwk()}
print("issued and bound to the wallet key:", result.format)

# --- replay: the nonce is single-use, so the same proof cannot be redeemed twice ----
try:
verify_credential_request_proofs(
credential_request, credential_issuer=CREDENTIAL_ISSUER,
check_nonce=consume_nonce)
except Exception as exc:
print("replay rejected:", type(exc).__name__)
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ python examples/01_verify_pipeline.py
| `09_haip_encrypted_response.py` | HAIP `direct_post.jwt`: the wallet returns the `vp_token` inside a JWE (`ECDH-ES`/AES-GCM); the verifier decrypts with its `KeyAgreementKey` and verifies in one call (`verify_encrypted_vp_response`) |
| `10_sd_jwt_type_metadata.py` | SD-JWT VC Type Metadata: an issuer pins the type with `vct#integrity`; the verifier resolves the metadata, checks integrity + `vct`, and validates the claims against the type's `claims` metadata |
| `11_spanish_university_credential.py` | a Spanish university diploma end to end: the issuer's document-signer chains to an **FNMT** anchor (the Spanish trusted list / `x5c`), and the diploma is an **SD-JWT VC** verified with that FNMT-anchored key + holder binding — trust + credential, offline ([walkthrough](https://github.com/luisgf/openvc/wiki/Spanish-University-Credential)) |
| `12_oid4vci_key_proof.py` | OpenID4VCI: a wallet mints an `openid4vci-proof+jwt`, the issuer verifies it (`typ` pin, `aud`, `iat` freshness both ways, single-use nonce) and issues an SD-JWT VC bound via `cnf` to the key the proof demonstrated — then the same proof is replayed and rejected ([guide](https://github.com/luisgf/openvc/wiki/Issuing-with-OpenID4VCI)) |

`_common.py` holds the shared `did_key_ed25519()` / `did_key_p256()` helpers that
mint a signing key already keyed to its `did:key` verification method.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ nav:
- Verification pipeline: api/verification.md
- Async verification: api/async.md
- OpenID4VP presentations: api/openid4vp.md
- OpenID4VCI key proofs: api/openid4vci.md
- ISO mdoc (mso_mdoc): api/mdoc.md
- Proof suites: api/proofs.md
- DIDs & keys: api/dids-keys.md
Expand Down
Loading
Loading