Skip to content

feat(keys): RFC 7638 JWK thumbprint#143

Merged
luisgf merged 1 commit into
mainfrom
feat/140-jwk-thumbprint
Jul 24, 2026
Merged

feat(keys): RFC 7638 JWK thumbprint#143
luisgf merged 1 commit into
mainfrom
feat/140-jwk-thumbprint

Conversation

@luisgf

@luisgf luisgf commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Closes #140.

A key's stable identifier — the value used as a kid, inside a cnf claim, and as a
DPoP jkt — was not computable anywhere in the library. The only prior mention of a
thumbprint is the mdoc_jwk_thumbprint parameter openid4vp takes: a value the
caller had to derive themselves, because openvc could not do it for them.

jwk_thumbprint(jwk, *, hash_name="sha-256") -> str        # base64url, unpadded
jwk_thumbprint_bytes(jwk, *, hash_name="sha-256") -> bytes # the raw digest

Covers EC, OKP, RSA and oct.

The design decision that matters

The canonical form is built from an explicit allow-list of the members RFC 7638
§3.2 (and RFC 8037 §2 for OKP) declares required — not by filtering out a deny-list
of private ones.

That inversion is the security-relevant part. kid, use, alg, x5c and every
private member (d, p, q, dp, dq, qi) are excluded by construction, so:

  • a private key and its public half hash identically, as the RFC requires;
  • a member nobody anticipated can never leak into a digest.

A deny-list would have to be kept exhaustive forever, and would fail silently the first
time it was not.

Fail-closed

An unknown or non-string kty, a missing required member, or a non-string member value
raises InvalidKey rather than digesting a partial key. The canonical form is defined
over string members, so coercing anything else would silently produce a digest for a key
the caller does not hold.

Serialization follows RFC 7638 §3 exactly: sort_keys for the lexicographic
code-point order, compact separators for the forbidden whitespace, and
ensure_ascii=False so the UTF-8 form is digested rather than a \u-escaped variant.

Golden vectors — real, published

Both spec worked examples are pinned:

Source Thumbprint
RFC 7638 §3.1 (RSA) NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs
RFC 8037 §2 (Ed25519) kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k

Both carry alg and kid in the specs' own examples, so they double as the exclusion
test
— a filtering bug moves the digest off the published value rather than passing
quietly. This is one of the rare cases where the spec hands us exact expected output, so
no self-made fixtures were needed.

40 tests in total: the vectors, exclusion, canonical ordering, input-order independence,
cross-curve non-collision, hash selection, every fail-closed path, and a round-trip
through every software backend including P256KeyAgreementKey (whose use: "enc" tag
must not leak in).

Also

dcapi_session_transcript's docstring now points at jwk_thumbprint_bytes, which
returns the raw digest that ISO 18013-7 handover expects — closing the wart where the
caller was told to compute it themselves.

Why now

Prerequisite for the OpenID4VCI key-proof verifier (#141), which must reject a batch
where two proofs share a thumbprint (ADR-0007 D6).
Useful on its own for cnf/kid construction and did:jwk work.

Verification

flake8 clean · mypy clean (60 source files) · pytest 1423 passed, 22 skipped
(+41) · gitlint clean. No new runtime dependency — stdlib hashlib + json.

Closes #140.

A key's stable identifier — the value used as a kid, inside a cnf claim, and as a
DPoP jkt — was not computable anywhere in the library. The only prior mention of a
thumbprint is the mdoc_jwk_thumbprint parameter openid4vp takes, a value the caller
had to derive themselves because openvc could not do it for them.

jwk_thumbprint returns the base64url form, jwk_thumbprint_bytes the raw digest.
Both cover EC, OKP, RSA and oct.

The canonical form is built from an explicit allow-list of the members RFC 7638
§3.2 — and RFC 8037 §2 for OKP — declares required, not by filtering a deny-list of
private ones. That inversion is the security-relevant part: kid, use, alg, x5c and
every private member (d, p, q, dp, dq, qi) are excluded by construction, so a
private key and its public half hash identically and a member nobody anticipated
can never leak into a digest.

Fails closed rather than digesting a partial key: an unknown or non-string kty, a
missing required member, or a non-string member value raises InvalidKey. The
canonical form is defined over string members, so coercing anything else would
silently produce a digest for a key the caller does not hold.

Serialization follows RFC 7638 §3 exactly — sort_keys for the lexicographic
code-point order, compact separators for the forbidden whitespace, and
ensure_ascii=False so the UTF-8 form is digested rather than a \u-escaped variant.

Pinned by both published golden vectors: RFC 7638 §3.1 (RSA) and RFC 8037 §2
(Ed25519). Both carry alg and kid in the spec's own example, so they double as the
exclusion test — a filtering bug would move the digest off the published value.
Forty tests in total, covering the vectors, exclusion, canonical ordering,
input-order independence, hash selection, and every fail-closed path.

dcapi_session_transcript's docstring now points at jwk_thumbprint_bytes, which
returns the raw digest that handover expects.

Prerequisite for the OpenID4VCI key-proof verifier (#141), which must reject a
batch where two proofs share a thumbprint — but useful on its own for cnf/kid
construction and did:jwk work.
@luisgf
luisgf merged commit 6cf808f into main Jul 24, 2026
13 of 14 checks passed
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.

feat(keys): RFC 7638 JWK thumbprint

1 participant