Skip to content

Issuer-key discovery

Beyond DIDs, an issuer key can be discovered from an https URL or an X.509 chain.

/.well-known/jwt-vc-issuer

openvc.jwt_vc_issuer

openvc.jwt_vc_issuer — SD-JWT VC / OID4VC issuer-key discovery.

When a JOSE credential's iss is an https URL rather than a DID, the issuer publishes its signing keys at a well-known endpoint (draft-ietf-oauth-sd-jwt-vc, "JWT VC Issuer Metadata"). For iss = https://host/path the metadata lives at

https://host/.well-known/jwt-vc-issuer/path

(the well-known segment is inserted between the host and the issuer path). The document is JSON with a REQUIRED issuer — which must equal the iss (anti-substitution) — and either an inline jwks JWK Set or a jwks_uri to fetch one. The issuer JWT's kid header selects the key.

Fetching is delegated to an injected fetch (pass :func:openvc.fetch.https_json_fetch so the SSRF guards apply), keeping this module transport-agnostic. This is the opt-in HTTPS-issuer counterpart to DID-based key resolution in the pipeline.

JwtVcIssuerError

Bases: OpenvcError

The issuer metadata could not be resolved, did not match, or has no key.

Source code in src/openvc/jwt_vc_issuer.py
34
35
class JwtVcIssuerError(OpenvcError):
    """The issuer metadata could not be resolved, did not match, or has no key."""

jwt_vc_issuer_metadata_url(iss)

The JWT VC Issuer Metadata URL for an https iss — the well-known segment inserted between the host and the issuer's path.

Source code in src/openvc/jwt_vc_issuer.py
38
39
40
41
42
43
44
45
46
47
def jwt_vc_issuer_metadata_url(iss: str) -> str:
    """The JWT VC Issuer Metadata URL for an https *iss* — the well-known segment
    inserted between the host and the issuer's path."""
    parsed = urlparse(iss)
    if parsed.scheme != "https":
        raise JwtVcIssuerError(f"issuer {iss!r} is not an https URL")
    if not parsed.netloc:
        raise JwtVcIssuerError(f"issuer {iss!r} has no host")
    path = parsed.path if parsed.path and parsed.path != "/" else ""
    return urlunparse(("https", parsed.netloc, _WELL_KNOWN + path, "", "", ""))

resolve_jwt_vc_issuer_key(iss, kid, *, fetch)

Resolve the public JWK for an https iss + kid via its JWT VC Issuer Metadata. Verifies the metadata's issuer equals iss before trusting any key. fetch performs the (SSRF-guarded) https GETs.

Source code in src/openvc/jwt_vc_issuer.py
90
91
92
93
94
95
96
97
98
def resolve_jwt_vc_issuer_key(iss: str, kid: str | None, *, fetch: Fetch) -> dict[str, Any]:
    """Resolve the public JWK for an https *iss* + *kid* via its JWT VC Issuer
    Metadata. Verifies the metadata's ``issuer`` equals *iss* before trusting any
    key. *fetch* performs the (SSRF-guarded) https GETs."""
    metadata = fetch(jwt_vc_issuer_metadata_url(iss))
    jwks, jwks_uri = _inline_jwks_or_uri(metadata, iss)
    if jwks is None:
        jwks = fetch(jwks_uri)  # type: ignore[arg-type]  # jwks_uri is str when jwks is None
    return _key_from_jwks(jwks, kid)

resolve_jwt_vc_issuer_key_async(iss, kid, *, fetch) async

Async :func:resolve_jwt_vc_issuer_key — awaits the (up to two) https GETs; identical metadata validation and key selection (the same pure code).

Source code in src/openvc/jwt_vc_issuer.py
101
102
103
104
105
106
107
108
109
110
async def resolve_jwt_vc_issuer_key_async(
    iss: str, kid: str | None, *, fetch: AsyncFetch
) -> dict[str, Any]:
    """Async :func:`resolve_jwt_vc_issuer_key` — awaits the (up to two) https GETs;
    identical metadata validation and key selection (the same pure code)."""
    metadata = await fetch(jwt_vc_issuer_metadata_url(iss))
    jwks, jwks_uri = _inline_jwks_or_uri(metadata, iss)
    if jwks is None:
        jwks = await fetch(jwks_uri)  # type: ignore[arg-type]  # str when jwks is None
    return _key_from_jwks(jwks, kid)

X.509 x5c

openvc.x5c

openvc.x5c — validate a JOSE x5c certificate chain and bind it to the issuer.

Some issuers (notably eIDAS / EUDI document signers) anchor trust in X.509 rather than a DID: the JOSE header carries x5c, a chain of base64 (not base64url) DER certificates, leaf first. This validates that chain to a caller-provided set of trust anchors and returns the leaf's public key as a JWK for the signature check.

Two things make this safe:

  • Path validation (signatures, validity window, name chaining, and basicConstraints on CA certs) is done by cryptography's X.509 verifier — which refuses to skip basicConstraints, so a non-CA cert cannot be smuggled in as an intermediate. Only the TLS-specific EKU requirement is relaxed (a VC issuer cert is not a TLS server/client cert). Requires cryptography >= 45.
  • Issuer binding — the token's iss must appear in the leaf certificate's Subject Alternative Name (a matching URI, or a DNS name equal to the iss host). Without it, a holder of any certificate under a trusted anchor could forge a credential naming an arbitrary issuer.

Only an EC P-256 leaf is usable (the JOSE allow-list is {ES256, ES384, EdDSA, Ed25519}; an RSA leaf is rejected by the algorithm allow-list anyway). openvc ships no root store — the trust anchors are the caller's.

X5cError

Bases: OpenvcError

The x5c chain is malformed, does not validate, is not bound to the issuer, or has an unusable key.

Source code in src/openvc/x5c.py
36
37
38
class X5cError(OpenvcError):
    """The x5c chain is malformed, does not validate, is not bound to the issuer,
    or has an unusable key."""

load_x5c_chain(x5c)

Load a JOSE x5c header value — base64 (not base64url) DER certificates, leaf first — into x509.Certificate objects. Raises :class:X5cError on a missing, empty, or malformed chain. Shared with the EUDI registration-certificate lane (:mod:openvc.rp_registration), which carries its signer chain the same way.

Source code in src/openvc/x5c.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def load_x5c_chain(x5c: Sequence[str]) -> list:
    """Load a JOSE ``x5c`` header value — base64 (not base64url) DER certificates, leaf
    first — into ``x509.Certificate`` objects. Raises :class:`X5cError` on a missing,
    empty, or malformed chain. Shared with the EUDI registration-certificate lane
    (:mod:`openvc.rp_registration`), which carries its signer chain the same way."""
    from cryptography import x509
    if not isinstance(x5c, (list, tuple)) or not x5c:
        raise X5cError("x5c header is missing or empty")
    chain = []
    for entry in x5c:
        if not isinstance(entry, str):
            raise X5cError("x5c entries must be base64 strings")
        try:
            chain.append(x509.load_der_x509_certificate(base64.b64decode(entry)))
        except Exception as exc:
            raise X5cError(f"x5c entry is not a valid certificate: {exc}") from exc
    return chain

validate_cert_chain(leaf, intermediates, *, trust_anchors, now=None)

Path-validate leaf + intermediates (x509.Certificate objects, leaf first) to one of trust_anchors at now — the shared X.509 core behind both the JOSE x5c path and the mdoc IssuerAuth adapter (ADR-0005 D5). Checks chain signatures, validity windows, name chaining and basicConstraints (cryptography refuses to skip the latter, so a non-CA cert cannot be smuggled in as an intermediate); only the TLS-specific EKU is relaxed (a VC/mdoc signer cert is not a TLS server cert). Raises :class:X5cError on any failure; returns None on success.

Source code in src/openvc/x5c.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def validate_cert_chain(
    leaf: Any,
    intermediates: Sequence[Any],
    *,
    trust_anchors: Sequence[Any],
    now: datetime | None = None,
) -> None:
    """Path-validate *leaf* + *intermediates* (``x509.Certificate`` objects, leaf first)
    to one of *trust_anchors* at *now* — the shared X.509 core behind both the JOSE
    ``x5c`` path and the mdoc IssuerAuth adapter (ADR-0005 D5). Checks chain signatures,
    validity windows, name chaining and ``basicConstraints`` (``cryptography`` refuses to
    skip the latter, so a non-CA cert cannot be smuggled in as an intermediate); only the
    TLS-specific EKU is relaxed (a VC/mdoc signer cert is not a TLS server cert). Raises
    :class:`X5cError` on any failure; returns ``None`` on success."""
    from cryptography import x509
    from cryptography.x509.verification import (
        ExtensionPolicy,
        PolicyBuilder,
        Store,
        VerificationError,
    )

    anchors = [a for a in trust_anchors if isinstance(a, x509.Certificate)]
    if not anchors:
        raise X5cError("no trust anchors given (a sequence of x509.Certificate roots)")

    # webpki CA policy keeps basicConstraints/path checks strict; permit_all on the
    # end-entity relaxes only the TLS EKU a VC / mdoc signer cert would not carry.
    builder = (
        PolicyBuilder().store(Store(anchors)).time(_instant(now))
        .extension_policies(
            ca_policy=ExtensionPolicy.webpki_defaults_ca(),
            ee_policy=ExtensionPolicy.permit_all())
    )
    try:
        builder.build_client_verifier().verify(leaf, list(intermediates))
    except VerificationError as exc:
        raise X5cError(f"certificate chain did not validate to a trust anchor: {exc}") from exc

resolve_x5c_key(x5c, iss, *, trust_anchors, now=None)

Validate the x5c chain (leaf first) against trust_anchors (trusted root x509.Certificate objects), confirm the leaf is bound to iss via its SAN, and return the leaf's public key as an EC P-256 JWK.

Raises :class:X5cError on a malformed chain, a path-validation failure, an unbound issuer, or a non-P-256 leaf key.

Source code in src/openvc/x5c.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def resolve_x5c_key(
    x5c: Sequence[str],
    iss: str,
    *,
    trust_anchors: Sequence[Any],
    now: datetime | None = None,
) -> dict[str, Any]:
    """Validate the *x5c* chain (leaf first) against *trust_anchors* (trusted root
    ``x509.Certificate`` objects), confirm the leaf is bound to *iss* via its SAN,
    and return the leaf's public key as an EC P-256 JWK.

    Raises :class:`X5cError` on a malformed chain, a path-validation failure, an
    unbound issuer, or a non-P-256 leaf key."""
    chain = load_x5c_chain(x5c)
    validate_cert_chain(chain[0], chain[1:], trust_anchors=trust_anchors, now=now)
    _check_issuer_binding(chain[0], iss)
    return _leaf_p256_jwk(chain[0])

load_der_chain(x5chain)

Load a COSE x5chain (RFC 9360 label 33) — raw DER certificates, leaf first — into x509.Certificate objects. Raises :class:X5cError on a missing, empty, or malformed chain. Shared by the mdoc IssuerAuth and EUDI registration-certificate (CWT) lanes.

Source code in src/openvc/x5c.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def load_der_chain(x5chain: Sequence[Any]) -> list:
    """Load a COSE ``x5chain`` (RFC 9360 label 33) — raw DER certificates, leaf first —
    into ``x509.Certificate`` objects. Raises :class:`X5cError` on a missing, empty, or
    malformed chain. Shared by the mdoc ``IssuerAuth`` and EUDI registration-certificate
    (CWT) lanes."""
    from cryptography import x509
    if not isinstance(x5chain, (list, tuple)) or not x5chain:
        raise X5cError("mdoc x5chain is missing or empty")
    chain = []
    for der in x5chain:
        if not isinstance(der, (bytes, bytearray)):
            raise X5cError("mdoc x5chain entries must be DER byte strings")
        try:
            chain.append(x509.load_der_x509_certificate(bytes(der)))
        except Exception as exc:
            raise X5cError(f"mdoc x5chain entry is not a valid certificate: {exc}") from exc
    return chain

leaf_public_jwk(leaf)

A certificate's public key as a JWK, restricted to the curves :func:openvc.keys.verify_signature accepts (EC P-256 / P-384, Ed25519). Any other key type — notably RSA — raises :class:X5cError rather than producing a JWK no allow-listed algorithm could consume.

Source code in src/openvc/x5c.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def leaf_public_jwk(leaf: Any) -> dict[str, Any]:
    """A certificate's public key as a JWK, restricted to the curves
    :func:`openvc.keys.verify_signature` accepts (EC P-256 / P-384, Ed25519). Any other
    key type — notably RSA — raises :class:`X5cError` rather than producing a JWK no
    allow-listed algorithm could consume."""
    from cryptography.hazmat.primitives.asymmetric import ec, ed25519
    pub = leaf.public_key()
    if isinstance(pub, ec.EllipticCurvePublicKey):
        if isinstance(pub.curve, ec.SECP256R1):
            name, size = "P-256", 32
        elif isinstance(pub.curve, ec.SECP384R1):
            name, size = "P-384", 48
        else:
            raise X5cError(f"mdoc signer key curve {pub.curve.name!r} is not P-256 or P-384")
        nums = pub.public_numbers()
        return {"kty": "EC", "crv": name,
                "x": _b64url(nums.x.to_bytes(size, "big")),
                "y": _b64url(nums.y.to_bytes(size, "big"))}
    if isinstance(pub, ed25519.Ed25519PublicKey):
        from cryptography.hazmat.primitives import serialization
        raw = pub.public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
        return {"kty": "OKP", "crv": "Ed25519", "x": _b64url(raw)}
    raise X5cError("mdoc signer leaf key is not EC P-256/P-384 or Ed25519")

check_mdoc_signed_within_ds_validity(x5chain, signed)

ISO 18013-5 §9.3.1: the MSO signed time must fall within the document-signer certificate's own validity window. (The chain path is validated at verification time — the conservative policy — so a currently-expired DS is still rejected; this additionally catches a signed inconsistent with the cert that produced it.) Raises :class:X5cError.

Source code in src/openvc/x5c.py
223
224
225
226
227
228
229
230
231
232
def check_mdoc_signed_within_ds_validity(x5chain: Sequence[Any], signed: datetime) -> None:
    """ISO 18013-5 §9.3.1: the MSO ``signed`` time must fall within the document-signer
    certificate's own validity window. (The chain *path* is validated at verification time
    — the conservative policy — so a currently-expired DS is still rejected; this additionally
    catches a ``signed`` inconsistent with the cert that produced it.) Raises :class:`X5cError`."""
    leaf = load_der_chain(x5chain)[0]
    nb, na = leaf.not_valid_before_utc, leaf.not_valid_after_utc
    if not (nb <= signed <= na):
        raise X5cError(f"MSO signed {signed.isoformat()} is outside the document-signer "
                       f"certificate validity [{nb.isoformat()} .. {na.isoformat()}]")

resolve_mdoc_signer_key(x5chain, *, trust_anchors, now=None)

Validate an mdoc IssuerAuth x5chain (COSE label 33: DER certificates, leaf first) to a caller-provided IACA anchor set and return the document-signer leaf's public key as a JWK (P-256 / P-384 / Ed25519).

Unlike :func:resolve_x5c_key there is no iss→SAN binding: mdoc trust is "the DS cert chains to a trusted IACA root" (ISO 18013-5 §9.1.2). The docType and validityInfo are bound against the MSO by the mdoc verifier, not the certificate. Raises :class:X5cError on a malformed chain, a path-validation failure, or an unusable leaf key.

Source code in src/openvc/x5c.py
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def resolve_mdoc_signer_key(
    x5chain: Sequence[Any],
    *,
    trust_anchors: Sequence[Any],
    now: datetime | None = None,
) -> dict[str, Any]:
    """Validate an mdoc ``IssuerAuth`` ``x5chain`` (COSE label 33: DER certificates,
    leaf first) to a caller-provided **IACA** anchor set and return the document-signer
    leaf's public key as a JWK (P-256 / P-384 / Ed25519).

    Unlike :func:`resolve_x5c_key` there is **no** ``iss``→SAN binding: mdoc trust is
    "the DS cert chains to a trusted IACA root" (ISO 18013-5 §9.1.2). The ``docType`` and
    ``validityInfo`` are bound against the MSO by the mdoc verifier, not the certificate.
    Raises :class:`X5cError` on a malformed chain, a path-validation failure, or an
    unusable leaf key."""
    chain = load_der_chain(x5chain)
    validate_cert_chain(chain[0], chain[1:], trust_anchors=trust_anchors, now=now)
    _require_mdoc_ds_eku(chain[0])
    return leaf_public_jwk(chain[0])

EUDI relying-party certificates (WRPAC)

Parse an EUDI relying-party access certificate (ETSI TS 119 411-8) to read who is asking — entity identifier, service identifier, trade name — verify-side only.

openvc.rp_cert

openvc.rp_cert — parse and validate an EUDI Wallet-Relying-Party Access Certificate (WRPAC).

Under CIR (EU) 2025/848 every wallet relying party carries an access certificate (WRPAC, mandatory): an X.509 certificate profiled by ETSI TS 119 411-8 that authenticates who is asking — the relying party's EU-wide entity identifier, its service identifier and trade name — rooted in the Access Certificate Authority (ACA) trust anchors a Member State notifies. This reads that certificate over the existing cryptography X.509 machinery and exposes its attributes as a typed object the caller can gate on, with the same fail-closed posture as :mod:openvc.x5c.

Two entry points, mirroring the library's trusted/untrusted split:

  • :func:parse_rp_access_certificate — read the attributes WITHOUT establishing trust (UNTRUSTED, like peek_*); for inspection only.
  • :func:verify_rp_access_certificate — validate the chain to caller-provided ACA anchors first, then parse; the result is safe to act on.

The registration certificate (WRPRC — the entitlements artifact) is a signed JWT or CWT (ETSI TS 119 475), not an X.509 certificate, so it lives in its own module: :mod:openvc.rp_registration, which also carries the cross-check binding a WRPRC back to the WRPAC parsed here. This module is WRPAC-only. Scope: parse + validate. NOT registrar workflows or certificate issuance.

RpCertError

Bases: OpenvcError

A relying-party access certificate is malformed, does not validate to the provided anchors, or lacks a required attribute.

Source code in src/openvc/rp_cert.py
37
38
39
class RpCertError(OpenvcError):
    """A relying-party access certificate is malformed, does not validate to the
    provided anchors, or lacks a required attribute."""

RelyingPartyAccessCertificate dataclass

The parsed attributes of a WRPAC — the answer to "who is asking?".

entity_identifier is the EU-wide unique identifier (subject organizationIdentifier, OID 2.5.4.97); trade_name is the human-readable name (subject commonName). extended_key_usages, certificate_policies and registration_records (the Subject Information Access locations pointing at the RP's registration record) are the caller-gateable attribute set — this module does not hardcode which EKU/policy the EUDI profile mandates (that OID is still settling); it surfaces them for the caller to check. public_jwk is the leaf's EC public key as a JWK when it is P-256/P-384, else None.

Source code in src/openvc/rp_cert.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
@dataclass(frozen=True)
class RelyingPartyAccessCertificate:
    """The parsed attributes of a WRPAC — the answer to "who is asking?".

    ``entity_identifier`` is the EU-wide unique identifier (subject
    ``organizationIdentifier``, OID 2.5.4.97); ``trade_name`` is the human-readable
    name (subject ``commonName``). ``extended_key_usages``, ``certificate_policies``
    and ``registration_records`` (the Subject Information Access locations pointing at
    the RP's registration record) are the caller-gateable attribute set — this module
    does not hardcode which EKU/policy the EUDI profile mandates (that OID is still
    settling); it surfaces them for the caller to check. ``public_jwk`` is the leaf's
    EC public key as a JWK when it is P-256/P-384, else ``None``."""
    entity_identifier: str | None
    trade_name: str | None
    organization_name: str | None
    country: str | None
    subject: str
    serial_number: str
    not_before: datetime
    not_after: datetime
    extended_key_usages: tuple[str, ...]
    certificate_policies: tuple[str, ...]
    registration_records: tuple[str, ...]
    public_jwk: dict[str, Any] | None
    certificate: Any                       # the underlying x509.Certificate

parse_rp_access_certificate(cert)

Parse a WRPAC's attributes WITHOUT establishing trust.

UNTRUSTED — it does not validate the chain or the signature. Use it only to inspect a certificate (e.g. to read its registration-record URL); call :func:verify_rp_access_certificate to root it in ACA anchors before making any trust decision on the identity it names.

Source code in src/openvc/rp_cert.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
def parse_rp_access_certificate(cert: Any) -> RelyingPartyAccessCertificate:
    """Parse a WRPAC's attributes WITHOUT establishing trust.

    UNTRUSTED — it does not validate the chain or the signature. Use it only to
    inspect a certificate (e.g. to read its registration-record URL); call
    :func:`verify_rp_access_certificate` to root it in ACA anchors before making any
    trust decision on the identity it names.
    """
    from cryptography.x509.oid import NameOID

    c = _load_cert(cert)
    try:
        return RelyingPartyAccessCertificate(
            entity_identifier=_subject_value(c, NameOID.ORGANIZATION_IDENTIFIER),
            trade_name=_subject_value(c, NameOID.COMMON_NAME),
            organization_name=_subject_value(c, NameOID.ORGANIZATION_NAME),
            country=_subject_value(c, NameOID.COUNTRY_NAME),
            subject=c.subject.rfc4514_string(),
            serial_number=format(c.serial_number, "x"),
            not_before=c.not_valid_before_utc,
            not_after=c.not_valid_after_utc,
            extended_key_usages=_extended_key_usages(c),
            certificate_policies=_certificate_policies(c),
            registration_records=_registration_records(c),
            public_jwk=_public_jwk(c),
            certificate=c,
        )
    except RpCertError:
        raise
    except Exception as exc:                            # a malformed extension/name field
        raise RpCertError(f"could not parse relying-party certificate: {exc}") from exc

verify_rp_access_certificate(cert, *, trust_anchors, intermediates=(), required_eku=None, now=None)

Validate a WRPAC and return its parsed attributes.

The chain (leaf cert + any intermediates) is path-validated to trust_anchors (the ACA roots — the trusted-list anchors that root them; each an x509.Certificate, DER/PEM bytes, or a base64 string) by cryptography's verifier: signatures, the validity window, basicConstraints and path length are enforced; only the TLS-specific EKU requirement is relaxed (a WRPAC is an e-seal/signature certificate, not a TLS server cert). If required_eku (an EKU OID dotted string) is given, the leaf must carry it.

This proves the certificate chains to your anchors (and carries required_eku if given) — it does NOT, by itself, prove the certificate is a WRPAC. If your trust_anchors certify end-entities beyond relying-party access certificates (e.g. a broad national/eIDAS root rather than a dedicated ACA), pass required_eku — or gate on the returned certificate_policies — to distinguish a WRPAC. With no such gate this accepts any end-entity under the anchor.

Raises :class:RpCertError on a malformed certificate, a bad/empty anchor set, a path-validation failure, or a missing required EKU. Same fail-closed posture as :func:openvc.x5c.resolve_x5c_key.

Source code in src/openvc/rp_cert.py
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
def verify_rp_access_certificate(
    cert: Any,
    *,
    trust_anchors: Sequence[Any],
    intermediates: Sequence[Any] = (),
    required_eku: str | None = None,
    now: datetime | None = None,
) -> RelyingPartyAccessCertificate:
    """Validate a WRPAC and return its parsed attributes.

    The chain (leaf *cert* + any *intermediates*) is path-validated to *trust_anchors*
    (the ACA roots — the trusted-list anchors that root them; each an
    ``x509.Certificate``, DER/PEM bytes, or a base64 string) by ``cryptography``'s
    verifier: signatures, the validity window, ``basicConstraints`` and path length are
    enforced; only the TLS-specific EKU requirement is relaxed (a WRPAC is an
    e-seal/signature certificate, not a TLS server cert). If *required_eku* (an EKU OID
    dotted string) is given, the leaf must carry it.

    **This proves the certificate chains to your anchors (and carries *required_eku* if
    given) — it does NOT, by itself, prove the certificate is a WRPAC.** If your
    *trust_anchors* certify end-entities beyond relying-party access certificates (e.g. a
    broad national/eIDAS root rather than a dedicated ACA), pass *required_eku* — or gate
    on the returned ``certificate_policies`` — to distinguish a WRPAC. With no such gate
    this accepts any end-entity under the anchor.

    Raises :class:`RpCertError` on a malformed certificate, a bad/empty anchor set, a
    path-validation failure, or a missing required EKU. Same fail-closed posture as
    :func:`openvc.x5c.resolve_x5c_key`.
    """
    from cryptography.x509.verification import (
        ExtensionPolicy,
        PolicyBuilder,
        Store,
        VerificationError,
    )

    # Fail closed — with a typed error — on a mistyped trust parameter (e.g. a single
    # Certificate where a sequence is expected, or a string `now`) rather than leaking a
    # bare TypeError/AttributeError past the OpenvcError family.
    if isinstance(trust_anchors, (str, bytes)) or not isinstance(trust_anchors, Iterable):
        raise RpCertError("trust_anchors must be a sequence of ACA roots, not a single value")
    if isinstance(intermediates, (str, bytes)) or not isinstance(intermediates, Iterable):
        raise RpCertError("intermediates must be a sequence of certificates")
    if now is not None and not isinstance(now, datetime):
        raise RpCertError("now must be a datetime or None")

    # Load anchors through the same coercion as cert/intermediates — symmetric, and a
    # bad anchor is a typed error, not a silent drop that would fail-open the anchor set.
    anchors = [_load_cert(a) for a in trust_anchors]
    if not anchors:
        raise RpCertError("no trust anchors given (a sequence of ACA roots)")

    leaf = _load_cert(cert)
    inter = [_load_cert(i) for i in intermediates]

    if now is None:
        instant = datetime.now(timezone.utc)
    elif now.tzinfo is None:                            # a naive now is taken as UTC, not
        instant = now.replace(tzinfo=timezone.utc)      # silently as the host's local time
    else:
        instant = now.astimezone(timezone.utc)

    # webpki CA policy keeps basicConstraints/path checks strict; permit_all on the
    # end-entity relaxes only the TLS EKU a WRPAC (an e-seal cert) would not carry.
    builder = (
        PolicyBuilder().store(Store(anchors)).time(instant)
        .extension_policies(
            ca_policy=ExtensionPolicy.webpki_defaults_ca(),
            ee_policy=ExtensionPolicy.permit_all())
    )
    try:
        builder.build_client_verifier().verify(leaf, inter)
    except VerificationError as exc:
        raise RpCertError(f"WRPAC did not validate to a trust anchor: {exc}") from exc

    parsed = parse_rp_access_certificate(leaf)
    if required_eku is not None and required_eku not in parsed.extended_key_usages:
        raise RpCertError(
            f"WRPAC does not carry the required extendedKeyUsage {required_eku!r} "
            f"(has {list(parsed.extended_key_usages)})")
    return parsed

EUDI relying-party certificates (WRPRC)

Parse and verify an EUDI relying-party registration certificate (ETSI TS 119 475) — the signed JWT/CWT carrying the registered entitlements and requestable attributes — and cross-check it against a WRPAC and a presentation request.

openvc.rp_registration

openvc.rp_registration — parse and verify an EUDI Wallet-Relying-Party Registration Certificate (WRPRC).

Under CIR (EU) 2025/848 a wallet relying party carries two artifacts. The access certificate (WRPAC, Art. 7 — mandatory) authenticates who is asking: it is X.509 and lives in :mod:openvc.rp_cert. The registration certificate (WRPRC, Art. 8 — optional per Member State) is the other half, and answers a different question: "were they registered to ask for this?" It carries the relying party's registered entitlements and the credentials/attributes it may request.

A WRPRC is not an X.509 certificate. ETSI TS 119 475 V1.2.1 clause 5.2 profiles it as a signed JWT (typ: rc-wrp+jwt, clause 5.2.2) or CWT (typ: rc-wrp+cwt, clause 5.2.3), so this module reads it over machinery openvc already has: the JOSE lane for the JWT form (with the {ES256, ES384, EdDSA, Ed25519} allow-list applied before any crypto, as everywhere else), the dependency-free CBOR/COSE codec for the CWT form (:mod:openvc.cbor / :mod:openvc.cose, ADR-0005), and :func:openvc.x5c.validate_cert_chain for the signer's chain against caller-provided registrar anchors — openvc ships no root store.

Three things about the profile are worth knowing before you read the code, because each one is a place the specification is thinner than it looks:

  • One WRPRC carries exactly one intended use. TS5's data model nests an intendedUse[0..*] array, but clause 5.2.4 flattens it away: credentials, purpose and intended_use_id are top-level payload claims. A relying party with several intended uses holds several WRPRCs.
  • exp is optional (Table 10). An absent expiry is conformant — revocation runs through the status claim (an IETF Token Status List, which :func:openvc.status.check_token_status resolves). The clause-5.2.4 GEN-5.2.4-08 twelve-month ceiling therefore binds only when exp is present.
  • The CWT form has no claim-key mapping. TS 119 475 presents its claim tables once, format-agnostically, with text field names; it never allocates CBOR integer labels for them, and TS 119 152-1 (CBOR AdES) is a forward reference. The envelope is fully specified (RFC 9052 + RFC 9360) and is implemented here; the claims map is read accepting both the RFC 8392 registered integer keys and text keys, which is the only reading available to an issuer today. Treat the CWT lane as provisional until a real artifact exists to pin.

JAdES scope. GEN-5.2.1-04 requires the JWT form to be signed as a JAdES baseline B-B signature (ETSI TS 119 182-1). This implements a verify subset of B-B — the signed-header profile (typ, allow-listed alg, the x5c chain of clause 5.1.7 / 5.1.8, a fail-closed crit) and the chain validation — not a full JAdES library: no signature-policy processing, no timestamps, no augmentation to higher levels. Note that JAdES clause 5.1.11 mandates a header iat for signatures made after 2025-07-15 while TS 119 475 Table 5 omits it; since the two normative texts disagree, the header iat is surfaced but not required — the security-bearing timestamps are the payload's.

Two entry points, mirroring the library's trusted/untrusted split (and :mod:openvc.rp_cert):

  • :func:parse_rp_registration_certificate — read the claims WITHOUT establishing trust (UNTRUSTED, like peek_*); for inspection only.
  • :func:verify_rp_registration_certificate — validate the signature to caller-provided registrar anchors first, then parse; the result is safe to act on.

Two cross-checks turn the parsed object into an authorization decision: :func:check_matches_access_certificate (this WRPRC describes the party that WRPAC authenticates) and :func:check_request_within_registration (a DCQL request asks only for what was registered).

Scope: parse + verify + cross-check. NOT registrar workflows or certificate issuance — openvc is a consumer.

RpRegistrationError

Bases: OpenvcError

A relying-party registration certificate is malformed, does not validate to the provided anchors, or fails a registered-scope cross-check.

Source code in src/openvc/rp_registration.py
89
90
91
class RpRegistrationError(OpenvcError):
    """A relying-party registration certificate is malformed, does not validate to
    the provided anchors, or fails a registered-scope cross-check."""

RequestableCredential dataclass

One entry of the registered credentials (clause 5.2.4 Table 9) or provides_attestations (Table 8) arrays — the credential format the relying party may ask for and, within it, the claim paths it registered.

claim_paths holds each registered path as a tuple; None inside a path is the DCQL array wildcard. An entry that registers no paths grants no attributes — see :func:check_request_within_registration for that fail-closed reading.

meta is {} when the entry carries no constraint (which matches an equally unconstrained request) and None when it carried one that is not an object. The two are deliberately distinct: coercing a malformed constraint to {} would turn it into no constraint, i.e. widen the entry to every credential of that format. A None here matches nothing. (The distinction is not hypothetical — the specification's own data model, clause B.2.9, types meta as a string while clause 5.2.4 and the Annex C example use an object.)

Source code in src/openvc/rp_registration.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
@dataclass(frozen=True)
class RequestableCredential:
    """One entry of the registered ``credentials`` (clause 5.2.4 Table 9) or
    ``provides_attestations`` (Table 8) arrays — the credential *format* the relying
    party may ask for and, within it, the claim ``path``s it registered.

    ``claim_paths`` holds each registered path as a tuple; ``None`` inside a path is the
    DCQL array wildcard. An entry that registers **no** paths grants no attributes —
    see :func:`check_request_within_registration` for that fail-closed reading.

    ``meta`` is ``{}`` when the entry carries no constraint (which matches an equally
    unconstrained request) and ``None`` when it carried one that is **not** an object.
    The two are deliberately distinct: coercing a malformed constraint to ``{}`` would
    turn it into *no* constraint, i.e. widen the entry to every credential of that
    format. A ``None`` here matches nothing. (The distinction is not hypothetical — the
    specification's own data model, clause B.2.9, types ``meta`` as a string while
    clause 5.2.4 and the Annex C example use an object.)"""
    format: str | None
    meta: Mapping[str, Any] | None
    claim_paths: tuple[tuple[Any, ...], ...]
    raw: Mapping[str, Any]

RelyingPartyRegistrationCertificate dataclass

The parsed content of a WRPRC.

subject_identifier is the sub claim: the ETSI EN 319 412-1 semantic identifier of the relying party (VATES-B12345678, LEIXG-…, NTRDE-…). Table 7 NOTE 2 is worth repeating — sub always identifies the relying party, never the intermediary, even when an intermediary presents the certificate. It is what :func:check_matches_access_certificate binds against the WRPAC.

header and claims are the raw, verbatim protected header and claim set: the typed fields are a view, and a caller needing a claim this release does not model reads it from claims rather than going without. form is "jwt" or "cwt".

Source code in src/openvc/rp_registration.py
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
@dataclass(frozen=True)
class RelyingPartyRegistrationCertificate:
    """The parsed content of a WRPRC.

    ``subject_identifier`` is the ``sub`` claim: the ETSI EN 319 412-1 **semantic
    identifier** of the relying party (``VATES-B12345678``, ``LEIXG-…``, ``NTRDE-…``).
    Table 7 NOTE 2 is worth repeating — ``sub`` always identifies the relying party,
    *never* the intermediary, even when an intermediary presents the certificate. It is
    what :func:`check_matches_access_certificate` binds against the WRPAC.

    ``header`` and ``claims`` are the raw, verbatim protected header and claim set: the
    typed fields are a *view*, and a caller needing a claim this release does not model
    reads it from ``claims`` rather than going without. ``form`` is ``"jwt"`` or
    ``"cwt"``."""
    # identity (Table 7)
    subject_identifier: str | None
    trade_name: str | None
    legal_name: str | None
    given_name: str | None
    family_name: str | None
    country: str | None
    # registered scope (Tables 7–9)
    entitlements: tuple[str, ...]
    intended_use_id: str | None
    credentials: tuple[RequestableCredential, ...]
    provides_attestations: tuple[RequestableCredential, ...]
    purpose: tuple[Mapping[str, Any], ...]
    service_description: tuple[Mapping[str, Any], ...]
    # governance / contact (Tables 7, 10)
    policy_ids: tuple[str, ...]
    certificate_policy: str | None
    registry_uri: str | None
    privacy_policy: str | None
    info_uri: str | None
    support_uri: str | None
    supervisory_authority: Mapping[str, Any] | None
    intermediary: Mapping[str, Any] | None
    public_body: bool | None
    status: Mapping[str, Any] | None
    # temporal
    issued_at: datetime | None
    expires_at: datetime | None
    # provenance
    form: str
    header: Mapping[str, Any]
    claims: Mapping[str, Any]

    @property
    def intermediary_identifier(self) -> str | None:
        """The intermediary's semantic identifier, if the WRPRC is intermediated.

        The specification names this three ways — ``intermediary.sub`` (Table 10),
        and ``act.sub`` (GEN-5.2.4-09, which appears nowhere else). Both are read."""
        if self.intermediary is not None:
            sub = _str_or_none(self.intermediary.get("sub"))
            if sub is not None:
                return sub
        act = self.claims.get("act")
        return _str_or_none(act.get("sub")) if isinstance(act, Mapping) else None

    @property
    def intermediary_name(self) -> str | None:
        """The intermediary's common name. Table 10 spells the field ``sname`` while the
        Annex C example uses ``name``; since Annex C is what implementers copy but only
        the table is normative, both spellings are accepted."""
        if self.intermediary is None:
            return None
        return (_str_or_none(self.intermediary.get("sname"))
                or _str_or_none(self.intermediary.get("name")))

intermediary_identifier property

The intermediary's semantic identifier, if the WRPRC is intermediated.

The specification names this three ways — intermediary.sub (Table 10), and act.sub (GEN-5.2.4-09, which appears nowhere else). Both are read.

intermediary_name property

The intermediary's common name. Table 10 spells the field sname while the Annex C example uses name; since Annex C is what implementers copy but only the table is normative, both spellings are accepted.

parse_rp_registration_certificate(token)

Parse a WRPRC's claims WITHOUT establishing trust.

token is the JWT form (a compact-JWS str) or the CWT form (bytes: a COSE_Sign1). The signed-header profile is still enforced — typ, the algorithm allow-list, a fail-closed crit — because a token that is not shaped like a WRPRC should not be reported as one; but the signature is not checked and no chain is validated.

UNTRUSTED — the returned entitlements are whatever the bytes claimed. Use it only to inspect a token (e.g. to read its sub before choosing anchors); call :func:verify_rp_registration_certificate before making any authorization decision on the registered scope it names.

Source code in src/openvc/rp_registration.py
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
def parse_rp_registration_certificate(
    token: str | bytes,
) -> RelyingPartyRegistrationCertificate:
    """Parse a WRPRC's claims WITHOUT establishing trust.

    *token* is the JWT form (a compact-JWS ``str``) or the CWT form (``bytes``: a
    ``COSE_Sign1``). The signed-header profile is still enforced — ``typ``, the
    algorithm allow-list, a fail-closed ``crit`` — because a token that is not *shaped*
    like a WRPRC should not be reported as one; but **the signature is not checked and
    no chain is validated**.

    UNTRUSTED — the returned entitlements are whatever the bytes claimed. Use it only to
    inspect a token (e.g. to read its ``sub`` before choosing anchors); call
    :func:`verify_rp_registration_certificate` before making any authorization decision
    on the registered scope it names.
    """
    if isinstance(token, str):
        header, claims, _, _ = _jwt_envelope(token)
        return _build(header, claims, form="jwt")
    if isinstance(token, (bytes, bytearray)):
        header, claims, _ = _cwt_envelope(bytes(token))
        return _build(header, claims, form="cwt")
    raise RpRegistrationError(
        "WRPRC must be a compact-JWS string (rc-wrp+jwt) or COSE_Sign1 bytes (rc-wrp+cwt)")

verify_rp_registration_certificate(token, *, trust_anchors, intermediates=(), now=None, leeway_s=60, required_eku=None, max_validity=_MAX_VALIDITY, require_expiry=False, require_entitlement=True)

Verify a WRPRC and return its parsed content.

In order: the signed-header profile (typ, the {ES256, ES384, EdDSA, Ed25519} allow-list applied before any crypto, a fail-closed crit); the signer's chain — x5c for the JWT form, x5chain for the CWT form, plus any caller-supplied intermediates — path-validated to trust_anchors (the registrar roots) by cryptography's verifier; then the signature against that chain's leaf key; then the temporal claims and the entitlement floor.

Policy knobs, all defaulting to the specification's own reading:

  • required_eku — additionally require this EKU OID on the signing leaf.
  • max_validity — the GEN-5.2.4-08 twelve-month ceiling. It is measured from the payload iat, so it applies only when exp and iat are both present; a token carrying neither has an unbounded lifetime that only status retires. None disables the ceiling.
  • require_expiryexp is optional in TS 119 475 (Table 10), so this defaults to False. Set it if your policy refuses a certificate that can only be retired through revocation.
  • require_entitlement — GEN-5.2.4-03 requires at least one entitlement from clause A.2, checked as a URI under :data:ENTITLEMENT_URI_PREFIX; a WRPRC without one authorizes nothing anyway. Turn it off for a registrar issuing outside that namespace.

This proves the token was signed by a certificate that chains to your anchors — it does NOT, by itself, prove the signer was entitled to register this relying party. As with :func:openvc.rp_cert.verify_rp_access_certificate, if your anchors certify end-entities beyond registrars, pass required_eku (or gate on the returned header) to distinguish one. Trust is anchored through the chain, not through iss — TS 119 475 defines no iss claim at all. openvc ships no root store.

It also does not check the status claim: a WRPRC is revoked through the IETF Token Status List, which :func:openvc.status.check_token_status resolves. That needs network access, so it stays an explicit, separate call.

Raises :class:RpRegistrationError on a malformed token, a rejected header, a path-validation failure, a bad signature, or a failed policy check.

Source code in src/openvc/rp_registration.py
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
def verify_rp_registration_certificate(
    token: str | bytes,
    *,
    trust_anchors: Sequence[Any],
    intermediates: Sequence[Any] = (),
    now: datetime | None = None,
    leeway_s: int = 60,
    required_eku: str | None = None,
    max_validity: timedelta | None = _MAX_VALIDITY,
    require_expiry: bool = False,
    require_entitlement: bool = True,
) -> RelyingPartyRegistrationCertificate:
    """Verify a WRPRC and return its parsed content.

    In order: the signed-header profile (``typ``, the ``{ES256, ES384, EdDSA, Ed25519}``
    allow-list applied **before** any crypto, a fail-closed ``crit``); the signer's chain
    — ``x5c`` for the JWT form, ``x5chain`` for the CWT form, plus any caller-supplied
    *intermediates* — path-validated to *trust_anchors* (the registrar roots) by
    ``cryptography``'s verifier; then the **signature** against that chain's leaf key;
    then the temporal claims and the entitlement floor.

    Policy knobs, all defaulting to the specification's own reading:

    * *required_eku* — additionally require this EKU OID on the signing leaf.
    * *max_validity* — the GEN-5.2.4-08 twelve-month ceiling. It is measured from the
      payload ``iat``, so it applies only when ``exp`` **and** ``iat`` are both present;
      a token carrying neither has an unbounded lifetime that only ``status`` retires.
      ``None`` disables the ceiling.
    * *require_expiry* — ``exp`` is **optional** in TS 119 475 (Table 10), so this
      defaults to ``False``. Set it if your policy refuses a certificate that can only
      be retired through revocation.
    * *require_entitlement* — GEN-5.2.4-03 requires at least one entitlement from clause
      A.2, checked as a URI under :data:`ENTITLEMENT_URI_PREFIX`; a WRPRC without one
      authorizes nothing anyway. Turn it off for a registrar issuing outside that
      namespace.

    **This proves the token was signed by a certificate that chains to your anchors — it
    does NOT, by itself, prove the signer was entitled to register *this* relying
    party.** As with :func:`openvc.rp_cert.verify_rp_access_certificate`, if your anchors
    certify end-entities beyond registrars, pass *required_eku* (or gate on the returned
    ``header``) to distinguish one. Trust is anchored through the chain, not through
    ``iss`` — TS 119 475 defines no ``iss`` claim at all. openvc ships no root store.

    It also does **not** check the ``status`` claim: a WRPRC is revoked through the IETF
    Token Status List, which :func:`openvc.status.check_token_status` resolves. That
    needs network access, so it stays an explicit, separate call.

    Raises :class:`RpRegistrationError` on a malformed token, a rejected header, a
    path-validation failure, a bad signature, or a failed policy check.
    """
    from .x5c import X5cError, leaf_public_jwk, validate_cert_chain

    if isinstance(trust_anchors, (str, bytes)) or not isinstance(trust_anchors, Iterable):
        raise RpRegistrationError(
            "trust_anchors must be a sequence of registrar roots, not a single value")
    if isinstance(intermediates, (str, bytes)) or not isinstance(intermediates, Iterable):
        raise RpRegistrationError("intermediates must be a sequence of certificates")
    if now is not None and not isinstance(now, datetime):
        raise RpRegistrationError("now must be a datetime or None")

    anchors = _load_certs(trust_anchors, "trust_anchors")
    if not anchors:
        raise RpRegistrationError("no trust anchors given (a sequence of registrar roots)")
    extra = _load_certs(intermediates, "intermediates")

    sign1: Any = None
    if isinstance(token, str):
        header, claims, signing_input, signature = _jwt_envelope(token)
        chain = _chain_from_jwt(header)
        form = "jwt"
    elif isinstance(token, (bytes, bytearray)):
        header, claims, sign1 = _cwt_envelope(bytes(token))
        chain = _chain_from_cwt(sign1)
        signing_input = signature = b""
        form = "cwt"
    else:
        raise RpRegistrationError(
            "WRPRC must be a compact-JWS string (rc-wrp+jwt) or COSE_Sign1 bytes (rc-wrp+cwt)")

    try:
        validate_cert_chain(chain[0], chain[1:] + extra, trust_anchors=anchors, now=now)
    except X5cError as exc:
        raise RpRegistrationError(
            f"WRPRC signing certificate did not validate to a trust anchor: {exc}") from exc

    if required_eku is not None:
        _require_eku(chain[0], required_eku)

    try:
        public_jwk = leaf_public_jwk(chain[0])
    except X5cError as exc:
        raise RpRegistrationError(f"WRPRC signing certificate: {exc}") from exc

    _verify_signature(
        form, header, public_jwk,
        signing_input=signing_input, signature=signature, sign1=sign1)

    reg = _build(header, claims, form=form)
    _check_temporal(reg, now=now, leeway_s=leeway_s, max_validity=max_validity,
                    require_expiry=require_expiry)
    if require_entitlement and not any(
            e.startswith(ENTITLEMENT_URI_PREFIX) for e in reg.entitlements):
        raise RpRegistrationError(
            f"WRPRC registers no entitlement under {ENTITLEMENT_URI_PREFIX} "
            f"(TS 119 475 GEN-5.2.4-03 requires at least one from clause A.2; got "
            f"{list(reg.entitlements)}) — it authorizes nothing")
    return reg

check_matches_access_certificate(registration, access, *, match_trade_name=False)

Bind a verified WRPRC to the verified WRPAC that authenticated the caller.

Both artifacts must name the same relying party: the WRPRC's sub — its ETSI EN 319 412-1 semantic identifier — must equal the WRPAC's entity_identifier (subject organizationIdentifier, the same namespace). That is GEN-5.1.1-04. Without this bind, an attacker presenting their own valid WRPAC could pair it with someone else's valid WRPRC and inherit that party's registered scope.

An identifier missing on either side is a failure, never a match: comparing two absent values would make None == None a successful bind — a fail-open hole exactly where the check exists to close one.

match_trade_name additionally requires the WRPRC's name to equal the WRPAC's trade_name. It is off by default: the two are free-text and legitimately differ in punctuation or legal suffix, so a mismatch is weak evidence and would produce false rejections. Note the comparison is intentionally on the identifier, which is registry-controlled.

access is a :class:~openvc.rp_cert.RelyingPartyAccessCertificate (or anything exposing entity_identifier / trade_name). Raises :class:RpRegistrationError on any mismatch; returns None on success.

Source code in src/openvc/rp_registration.py
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
def check_matches_access_certificate(
    registration: RelyingPartyRegistrationCertificate,
    access: Any,
    *,
    match_trade_name: bool = False,
) -> None:
    """Bind a verified WRPRC to the verified WRPAC that authenticated the caller.

    Both artifacts must name the **same** relying party: the WRPRC's ``sub`` — its ETSI
    EN 319 412-1 semantic identifier — must equal the WRPAC's ``entity_identifier``
    (subject ``organizationIdentifier``, the same namespace). That is GEN-5.1.1-04.
    Without this bind, an attacker presenting their own valid WRPAC could pair it with
    *someone else's* valid WRPRC and inherit that party's registered scope.

    An identifier missing on **either** side is a failure, never a match: comparing two
    absent values would make ``None == None`` a successful bind — a fail-open hole
    exactly where the check exists to close one.

    *match_trade_name* additionally requires the WRPRC's ``name`` to equal the WRPAC's
    ``trade_name``. It is off by default: the two are free-text and legitimately differ
    in punctuation or legal suffix, so a mismatch is weak evidence and would produce
    false rejections. Note the comparison is intentionally on the *identifier*, which is
    registry-controlled.

    *access* is a :class:`~openvc.rp_cert.RelyingPartyAccessCertificate` (or anything
    exposing ``entity_identifier`` / ``trade_name``). Raises
    :class:`RpRegistrationError` on any mismatch; returns ``None`` on success.
    """
    theirs = _str_or_none(getattr(access, "entity_identifier", None))
    ours = registration.subject_identifier
    if ours is None or theirs is None:
        raise RpRegistrationError(
            "cannot bind WRPRC to WRPAC: the relying-party identifier is missing on "
            f"{'the WRPRC (sub)' if ours is None else 'the WRPAC (organizationIdentifier)'}")
    if ours != theirs:
        raise RpRegistrationError(
            f"WRPRC subject identifier {ours!r} does not match the WRPAC's {theirs!r} — "
            f"these are two different relying parties")

    if not match_trade_name:
        return
    their_name = _str_or_none(getattr(access, "trade_name", None))
    if registration.trade_name is None or their_name is None:
        raise RpRegistrationError(
            "cannot compare trade names: missing on "
            f"{'the WRPRC' if registration.trade_name is None else 'the WRPAC'}")
    if registration.trade_name != their_name:
        raise RpRegistrationError(
            f"WRPRC trade name {registration.trade_name!r} does not match the WRPAC's "
            f"{their_name!r}")

check_request_within_registration(registration, dcql_query, *, intended_use_id=None)

Check a presentation request against the registered scope: every credential and attribute the DCQL query asks for must appear in the WRPRC's credentials.

dcql_query is the OpenID4VP 1.0 dcql_query the relying party sent — the same object :func:openvc.verify_vp_token consumes. For each of its credential queries this requires a registered entry of the same format whose meta covers the requested one, and every requested claim path to fall inside that entry's registered paths (a registered container covers its members; see :func:_path_covered).

A WRPRC carries one intended use (clause 5.2.4 flattens TS5's nested model), so intended_use_id is an optional assertion rather than a selector: pass it and the certificate's own intended_use_id must equal it. Note the claim is itself optional in the profile — a WRPRC that omits it cannot satisfy this assertion.

Fail-closed by construction. A request that names no claims is asking for everything in that credential and is refused unless the registration is equally unrestricted; a registered entry that lists no claim paths grants no attributes. Both readings deny rather than widen — the opposite default would turn an incomplete registration into a blanket entitlement.

This is an authorization check on top of verification: call it only on a WRPRC that :func:verify_rp_registration_certificate accepted and :func:check_matches_access_certificate bound to the requesting party. Raises :class:RpRegistrationError on anything out of scope; returns None on success.

Source code in src/openvc/rp_registration.py
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
def check_request_within_registration(
    registration: RelyingPartyRegistrationCertificate,
    dcql_query: Mapping[str, Any],
    *,
    intended_use_id: str | None = None,
) -> None:
    """Check a presentation request against the registered scope: every credential and
    attribute the DCQL query asks for must appear in the WRPRC's ``credentials``.

    *dcql_query* is the OpenID4VP 1.0 ``dcql_query`` the relying party sent — the same
    object :func:`openvc.verify_vp_token` consumes. For each of its credential queries
    this requires a registered entry of the same ``format`` whose ``meta`` covers the
    requested one, and every requested claim ``path`` to fall inside that entry's
    registered paths (a registered container covers its members; see
    :func:`_path_covered`).

    A WRPRC carries **one** intended use (clause 5.2.4 flattens TS5's nested model), so
    *intended_use_id* is an optional assertion rather than a selector: pass it and the
    certificate's own ``intended_use_id`` must equal it. Note the claim is itself
    optional in the profile — a WRPRC that omits it cannot satisfy this assertion.

    **Fail-closed by construction.** A request that names no claims is asking for
    *everything* in that credential and is refused unless the registration is equally
    unrestricted; a registered entry that lists no claim paths grants no attributes.
    Both readings deny rather than widen — the opposite default would turn an incomplete
    registration into a blanket entitlement.

    This is an *authorization* check on top of verification: call it only on a WRPRC
    that :func:`verify_rp_registration_certificate` accepted and
    :func:`check_matches_access_certificate` bound to the requesting party. Raises
    :class:`RpRegistrationError` on anything out of scope; returns ``None`` on success.
    """
    if intended_use_id is not None and registration.intended_use_id != intended_use_id:
        raise RpRegistrationError(
            f"WRPRC registers intended use {registration.intended_use_id!r}, not "
            f"{intended_use_id!r}")
    if not isinstance(dcql_query, Mapping):
        raise RpRegistrationError("dcql_query must be a JSON object")
    queries = dcql_query.get("credentials")
    if not isinstance(queries, (list, tuple)) or not queries:
        raise RpRegistrationError("dcql_query has no 'credentials' array to check")

    for index, query in enumerate(queries):
        if not isinstance(query, Mapping):
            raise RpRegistrationError(f"dcql_query credential #{index} is not an object")
        label = _str_or_none(query.get("id")) or f"#{index}"
        fmt = _str_or_none(query.get("format"))
        want_meta = _mapping_or_none(query.get("meta")) or {}
        candidates = [
            c for c in registration.credentials
            if c.format == fmt and _meta_covered(c.meta, want_meta)
        ]
        if not candidates:
            raise RpRegistrationError(
                f"credential query {label!r} asks for format {fmt!r} with meta "
                f"{dict(want_meta)!r}, which this WRPRC does not register")

        wanted = _requested_paths(query)
        if not wanted:
            if any(not c.claim_paths for c in candidates):
                continue                     # unrestricted request, unrestricted grant
            raise RpRegistrationError(
                f"credential query {label!r} names no claims (asks for every attribute), "
                f"but this WRPRC registers an explicit attribute list")
        granted = tuple(p for c in candidates for p in c.claim_paths)
        for path in wanted:
            if not any(_path_covered(reg, path) for reg in granted):
                raise RpRegistrationError(
                    f"credential query {label!r} requests claim path {list(path)!r}, "
                    f"which this WRPRC does not register")