From c1ef1f414d08613881e7d3301e6a0e05a979f7de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Gonz=C3=A1lez=20Fern=C3=A1ndez?= Date: Sun, 19 Jul 2026 12:02:46 +0200 Subject: [PATCH 1/3] feat(rp-registration): parse and verify EUDI WRPRC (ETSI TS 119 475) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other half of #67's WRPAC: where the access certificate authenticates *who is asking*, the registration certificate answers "were they registered to ask for this?" — it carries the relying party's entitlements and the credentials/attributes it may request. New module `openvc.rp_registration`, same trusted/untrusted split as `openvc.rp_cert`. Both profiled forms are read: the JWT (rc-wrp+jwt) over the JOSE lane and the CWT (rc-wrp+cwt) over the dependency-free CBOR/COSE codec, sharing the algorithm allow-list applied before any crypto and the x5c primitives now exported from `openvc.x5c`. Verification alone only proves a registrar signed something, so two fail-closed cross-checks carry the authorization decision: the WRPRC's `sub` is bound to the WRPAC's entity identifier (GEN-5.1.1-04 — otherwise an attacker pairs their own valid WRPAC with someone else's valid WRPRC), and a DCQL request must stay inside the registered format/meta/claim-path scope. Only a verify subset of the JAdES baseline B-B profile is implemented: the signed-header profile plus chain validation, no signature-policy processing, timestamps or augmentation. Three properties of the profile that are easy to get wrong are handled explicitly: one WRPRC carries exactly one intended use (clause 5.2.4 flattens TS5's nested intendedUse[]), `exp` is optional so the 12-month ceiling binds only when present, and the CWT form has no claim-key mapping in the spec — its claims map is read accepting both RFC 8392 integer keys and text keys, and that lane stays provisional until a real artifact exists to pin. Two published spec defects are absorbed rather than inherited (sname/name, intermediary.sub/act.sub); the German BMI rc-rp+jwt profile is refused by name rather than half-parsed under the wrong claim semantics. No official signed vectors exist, so the suite pins the informative Annex C payload verbatim and otherwise builds both forms over the library's own machinery — 89 tests, negative paths first. Also fixes documentation drift: the WRPAC was attributed to TS 119 475 (it is TS 119 411-8) and described as carrying registered entitlements, which is the WRPRC's job. Closes #89 --- CHANGELOG.md | 62 ++ docs/api/discovery.md | 12 +- src/openvc/rp_registration.py | 916 +++++++++++++++++++++++++++++ src/openvc/x5c.py | 33 +- tests/test_rp_registration.py | 844 ++++++++++++++++++++++++++ wiki/Home.md | 3 +- wiki/Relying-Party-Certificates.md | 93 ++- 7 files changed, 1946 insertions(+), 17 deletions(-) create mode 100644 src/openvc/rp_registration.py create mode 100644 tests/test_rp_registration.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c50800a..3ee675d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,68 @@ All notable changes to **openvc** are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project aims for [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.21.0] — unreleased + +### Added + +- **EUDI relying-party *registration* certificates (WRPRC, ETSI TS 119 475 V1.2.1 + clause 5.2)** — the other half of [#67](https://github.com/luisgf/openvc/issues/67)'s + WRPAC. Where the access certificate authenticates *who is asking*, the registration + certificate answers **"were they registered to ask for this?"**: it carries the + relying party's entitlements and the credentials/attributes it may request. New + module `openvc.rp_registration`, with the library's usual trusted/untrusted split — + `parse_rp_registration_certificate` (UNTRUSTED, header-profile only) and + `verify_rp_registration_certificate`, which anchors the signer's chain in + caller-provided registrar roots. Both profiled forms are read: the **JWT** + (`rc-wrp+jwt`) over the JOSE lane, the **CWT** (`rc-wrp+cwt`) over the + dependency-free CBOR/COSE codec, sharing the `{ES256, ES384, EdDSA, Ed25519}` + allow-list applied *before* any crypto and the `x5c` primitives now exported from + `openvc.x5c` (`load_x5c_chain`, `load_der_chain`, `leaf_public_jwk`). + + Verification alone only proves a registrar signed *something*, so two cross-checks + carry the authorization decision, both fail-closed: + `check_matches_access_certificate` binds the WRPRC's `sub` to the WRPAC's + `entity_identifier` (GEN-5.1.1-04 — without it an attacker pairs their own valid + WRPAC with someone else's valid WRPRC and inherits that scope; an identifier absent + on either side is a failure, never a match), and `check_request_within_registration` + requires every DCQL credential query to match a registered `format` whose `meta` + covers the requested one, with every requested claim `path` inside the registered + paths. A registered container covers its members; a request naming no claims asks for + everything and is refused against an enumerated registration. + + Only a *verify subset* of the JAdES baseline B-B profile GEN-5.2.1-04 mandates is + implemented — the signed-header profile (`typ`, allow-listed `alg`, the `x5c` chain, + a `crit` that fails closed on any parameter this verifier does not process) plus the + chain validation. No signature-policy processing, timestamps, or augmentation. + + Three properties of the profile are easy to get wrong and are handled explicitly: + one WRPRC carries **exactly one** intended use (clause 5.2.4 flattens TS5's nested + `intendedUse[]`); **`exp` is optional** (Table 10), so the 12-month ceiling + (GEN-5.2.4-08) binds only when it is present and `require_expiry=True` is opt-in; and + the **CWT form has no claim-key mapping** in TS 119 475 — the envelope is fully + specified (RFC 9052/9360) and implemented, while the claims map is read accepting both + the RFC 8392 integer keys and text keys, which is the only reading available to an + issuer today. That lane is provisional until a real artifact exists to pin. Two + published spec defects are absorbed rather than inherited: `intermediary.sname` + (Table 10) vs `intermediary.name` (Annex C), and `intermediary.sub` vs `act.sub` + (GEN-5.2.4-09) — both spellings are read. The German BMI *Architekturkonzept* + certificate (`rc-rp+jwt`) is a different profile and is refused by name rather than + half-parsed under the wrong claim semantics. + + No official signed vectors exist (the deliverable ships one informative, unsigned + Annex C example; the 2026 EAA Plugtests covered TS 119 472-1), so `tests/test_rp_ + registration.py` pins the **Annex C payload verbatim** and otherwise builds both forms + over the library's own machinery — 89 tests, negative paths first. Recording a real + third-party artifact stays a gated follow-up. + ([#89](https://github.com/luisgf/openvc/issues/89)) + +### Fixed + +- **Documentation drift**: the WRPAC was attributed to ETSI TS 119 475 (it is + **TS 119 411-8**; 475 is the WRPRC) in the wiki module map and the API reference, + where it was also described as carrying "registered entitlements" — the WRPRC's job, + not the WRPAC's. + ## [1.20.4] — 2026-07-18 ### Security diff --git a/docs/api/discovery.md b/docs/api/discovery.md index 6571948..4644dde 100644 --- a/docs/api/discovery.md +++ b/docs/api/discovery.md @@ -12,7 +12,15 @@ Beyond DIDs, an issuer key can be discovered from an https URL or an X.509 chain ## EUDI relying-party certificates (WRPAC) -Parse an EUDI relying-party access certificate (ETSI TS 119 475) to read its -registered entitlements — verify-side only. +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 + +## 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 diff --git a/src/openvc/rp_registration.py b/src/openvc/rp_registration.py new file mode 100644 index 0000000..b714911 --- /dev/null +++ b/src/openvc/rp_registration.py @@ -0,0 +1,916 @@ +""" +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. +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any, Iterable, Mapping, Sequence + +from .errors import OpenvcError + +__all__ = [ + "ENTITLEMENT_URI_PREFIX", + "RelyingPartyRegistrationCertificate", + "RequestableCredential", + "RpRegistrationError", + "WRPRC_CWT_TYP", + "WRPRC_JWT_TYP", + "WRPRC_POLICY_OID", + "check_matches_access_certificate", + "check_request_within_registration", + "parse_rp_registration_certificate", + "verify_rp_registration_certificate", +] + + +class RpRegistrationError(OpenvcError): + """A relying-party registration certificate is malformed, does not validate to + the provided anchors, or fails a registered-scope cross-check.""" + + +# --------------------------------------------------------------------------- # +# The ETSI TS 119 475 V1.2.1 clause 5.2 profile +# --------------------------------------------------------------------------- # + +WRPRC_JWT_TYP = "rc-wrp+jwt" # clause 5.2.2 Table 5 +WRPRC_CWT_TYP = "rc-wrp+cwt" # clause 5.2.3 Table 6 + +#: Registered entitlements are URIs under this namespace (clause A.2; OID arc +#: ``0.4.0.19475.1``). GEN-5.2.4-03 requires at least one. +ENTITLEMENT_URI_PREFIX = "https://uri.etsi.org/19475/Entitlement/" + +#: The WRPRC signature-policy OID (clause 6.1.3). +WRPRC_POLICY_OID = "0.4.0.19475.3.1" + +# GEN-5.2.4-08: when `exp` is present it shall be no later than 12 months after the +# payload `iat`. Measured as 366 days so a leap year is not a spurious rejection. +_MAX_VALIDITY = timedelta(days=366) + +# The German BMI Architekturkonzept defines a *different*, incompatible registration +# certificate under a near-identical media type. Half-parsing it would silently apply +# the wrong claim semantics, so it is named and refused. +_BMI_TYP = "rc-rp+jwt" + +# JOSE header parameters this verifier processes, and so may accept in `crit` +# (RFC 7515 §4.1.11). `alg`/`typ`/`x5c` are IANA-registered and would not appear there; +# `iat` as a *header* parameter is JAdES-specific (TS 119 182-1 clause 5.1.11), and +# JAdES V1.1.1-era producers listed their non-registered parameters in `crit`. Anything +# outside this set fails closed — an unprocessed critical parameter is precisely what +# `crit` exists to stop being ignored. +_KNOWN_CRIT = frozenset({"alg", "typ", "x5c", "iat"}) + +# COSE header labels: RFC 9052 §3.1 (`alg`), RFC 9596 (`typ`), RFC 9360 §2 (`x5chain`). +_COSE_HDR_TYP = 16 + +# RFC 8392 §3.1 — the registered CWT claim keys, mapped onto their text names. TS 119 +# 475 allocates no integer labels of its own, so its claims can only travel as text. +_CWT_REGISTERED_CLAIMS = { + 1: "iss", 2: "sub", 3: "aud", 4: "exp", 5: "nbf", 6: "iat", 7: "cti", +} + + +@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.""" + format: str | None + meta: Mapping[str, Any] + claim_paths: tuple[tuple[Any, ...], ...] + raw: Mapping[str, Any] + + +@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"))) + + +# --------------------------------------------------------------------------- # +# claim extraction +# --------------------------------------------------------------------------- # + +def _str_or_none(value: Any) -> str | None: + """A non-empty string, else ``None``. An empty/whitespace value normalises to + ``None`` so a caller's ``is None`` guard is not defeated by ``''`` — and so a blank + identifier can never satisfy a cross-check (see + :func:`check_matches_access_certificate`).""" + return value.strip() if isinstance(value, str) and value.strip() else None + + +def _mapping_or_none(value: Any) -> Mapping[str, Any] | None: + return value if isinstance(value, Mapping) else None + + +def _str_tuple(value: Any, *, allow_scalar: bool = False) -> tuple[str, ...]: + """The non-empty strings of a JSON array, in order. + + A scalar string is coerced only when *allow_scalar* — for ``entitlements``, where a + malformed value silently becoming a one-element **grant** is a privilege question, + it is not.""" + if isinstance(value, str): + return (value.strip(),) if allow_scalar and value.strip() else () + if not isinstance(value, (list, tuple)): + return () + return tuple(s.strip() for s in value if isinstance(s, str) and s.strip()) + + +def _mapping_tuple(value: Any) -> tuple[Mapping[str, Any], ...]: + """A localized-text array (``purpose``, ``srv_description``) as a flat tuple of + objects. ``srv_description`` is typed as an array *of arrays* and the Annex C + example nests it one level deeper than ``purpose``, so one level of nesting is + flattened rather than dropped.""" + if not isinstance(value, (list, tuple)): + return () + out: list[Mapping[str, Any]] = [] + for item in value: + if isinstance(item, Mapping): + out.append(item) + elif isinstance(item, (list, tuple)): + out.extend(sub for sub in item if isinstance(sub, Mapping)) + return tuple(out) + + +def _numeric_date(value: Any, *, field: str) -> datetime | None: + """A NumericDate (RFC 7519 §2) as an aware UTC datetime, or ``None`` if absent. + + A present-but-unusable value fails closed rather than being dropped: a bool, a + non-number, or a non-finite float (``NaN``/``Infinity`` — which ``json.loads`` + accepts, and against which every comparison is ``False``, i.e. *never expires*) + raises instead of silently disabling the temporal check.""" + import math + + if value is None: + return None + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise RpRegistrationError( + f"WRPRC {field} must be a finite NumericDate, got {value!r}") + try: + return datetime.fromtimestamp(float(value), tz=timezone.utc) + except (OverflowError, OSError, ValueError) as exc: + raise RpRegistrationError(f"WRPRC {field} {value!r} is out of range") from exc + + +def _claim_paths(entry: Mapping[str, Any]) -> tuple[tuple[Any, ...], ...]: + """The claim paths of one ``credentials`` entry. + + TS 119 475 names the member ``claim`` (singular, as in Annex C); the DCQL shape it + mirrors (OpenID4VP 1.0) names it ``claims``. Both spellings are read — on the + registration side so a producer following either does not silently register + *nothing*, and on the request side so a query is never under-read into looking + narrower than it is.""" + raw = entry.get("claim") + if raw is None: + raw = entry.get("claims") + if not isinstance(raw, (list, tuple)): + return () + paths: list[tuple[Any, ...]] = [] + for item in raw: + if not isinstance(item, Mapping): + continue + path = item.get("path") + if isinstance(path, (list, tuple)): + paths.append(tuple(path)) + elif isinstance(path, str): # B.2.10 types `path` as a string + paths.append((path,)) + return tuple(paths) + + +def _requestable(value: Any) -> tuple[RequestableCredential, ...]: + if not isinstance(value, (list, tuple)): + return () + out = [] + for entry in value: + if not isinstance(entry, Mapping): + continue + out.append(RequestableCredential( + format=_str_or_none(entry.get("format")), + meta=_mapping_or_none(entry.get("meta")) or {}, + claim_paths=_claim_paths(entry), + raw=entry, + )) + return tuple(out) + + +def _build( + header: Mapping[str, Any], claims: Mapping[str, Any], *, form: str, +) -> RelyingPartyRegistrationCertificate: + public_body = claims.get("public_body") + return RelyingPartyRegistrationCertificate( + subject_identifier=_str_or_none(claims.get("sub")), + trade_name=_str_or_none(claims.get("name")), + legal_name=_str_or_none(claims.get("sub_ln")), + given_name=_str_or_none(claims.get("sub_gn")), + family_name=_str_or_none(claims.get("sub_fn")), + country=_str_or_none(claims.get("country")), + entitlements=_str_tuple(claims.get("entitlements")), + intended_use_id=_str_or_none(claims.get("intended_use_id")), + credentials=_requestable(claims.get("credentials")), + provides_attestations=_requestable(claims.get("provides_attestations")), + purpose=_mapping_tuple(claims.get("purpose")), + service_description=_mapping_tuple(claims.get("srv_description")), + policy_ids=_str_tuple(claims.get("policy_id"), allow_scalar=True), + certificate_policy=_str_or_none(claims.get("certificate_policy")), + registry_uri=_str_or_none(claims.get("registry_uri")), + privacy_policy=_str_or_none(claims.get("privacy_policy")), + info_uri=_str_or_none(claims.get("info_uri")), + support_uri=_str_or_none(claims.get("support_uri")), + supervisory_authority=_mapping_or_none(claims.get("supervisory_authority")), + intermediary=_mapping_or_none(claims.get("intermediary")), + public_body=public_body if isinstance(public_body, bool) else None, + status=_mapping_or_none(claims.get("status")), + issued_at=_numeric_date(claims.get("iat"), field="iat"), + expires_at=_numeric_date(claims.get("exp"), field="exp"), + form=form, + header=header, + claims=claims, + ) + + +# --------------------------------------------------------------------------- # +# envelope: the JWT (JAdES B-B subset) and CWT (COSE_Sign1) forms +# --------------------------------------------------------------------------- # + +def _reject_unknown_crit(header: Mapping[str, Any]) -> None: + """RFC 7515 §4.1.11 — a verifier MUST reject a JWS whose ``crit`` names an + extension it does not process. + + The plain-JWS lanes (:func:`openvc.proof._verify_common.reject_unknown_crit`) + process no extensions at all and so reject ``crit`` outright. This lane cannot: a + JAdES V1.1.1-era producer listed its non-registered header parameters there, so the + check allow-lists exactly the parameters this verifier *understands* + (:data:`_KNOWN_CRIT`) and fails closed on the rest.""" + if "crit" not in header: + return + crit = header["crit"] + if not isinstance(crit, list) or not crit or not all(isinstance(c, str) for c in crit): + raise RpRegistrationError("WRPRC 'crit' must be a non-empty array of strings") + unknown = [c for c in crit if c not in _KNOWN_CRIT] + if unknown: + raise RpRegistrationError( + f"WRPRC marks header parameter(s) {unknown!r} critical, which this verifier " + f"does not process (understood: {sorted(_KNOWN_CRIT)})") + missing = [c for c in crit if c not in header] + if missing: + raise RpRegistrationError( + f"WRPRC 'crit' names header parameter(s) {missing!r} that are not present") + + +def _check_alg(alg: Any) -> str: + """Allow-list the signature algorithm BEFORE any crypto runs — the invariant the + whole library keeps. TS 119 475 defers the algorithm set to TS 119 182-1 clause + 5.1.2, which requires an IANA-registered identifier and *recommends* the TS 119 312 + set; openvc's ``{ES256, ES384, EdDSA, Ed25519}`` is a subset of it, so RS*/HS*/ + ``none`` never reach a verify call.""" + from .proof.vc_jwt import ALLOWED_ALGS + + if not isinstance(alg, str) or alg not in ALLOWED_ALGS: + raise RpRegistrationError( + f"WRPRC alg {alg!r} is not permitted (need one of {sorted(ALLOWED_ALGS)})") + return alg + + +def _check_typ(typ: Any, expected: str) -> None: + if typ == expected: + return + if typ == _BMI_TYP: + raise RpRegistrationError( + f"token typ {_BMI_TYP!r} is the German BMI Architekturkonzept registration " + f"certificate, a different profile with different claim names — not the " + f"ETSI TS 119 475 WRPRC ({expected!r}) this module parses") + raise RpRegistrationError(f"WRPRC typ must be {expected!r}, got {typ!r}") + + +def _jwt_envelope(token: str) -> tuple[dict[str, Any], dict[str, Any], bytes, bytes]: + """Split + profile-check the JWT form, WITHOUT verifying the signature.""" + from .proof._jws import parse_compact + from .proof.errors import ProofError + + try: + header, claims, signing_input, signature = parse_compact(token) + except ProofError as exc: + raise RpRegistrationError(f"WRPRC is not a valid compact JWS: {exc}") from exc + _check_typ(header.get("typ"), WRPRC_JWT_TYP) + _check_alg(header.get("alg")) + _reject_unknown_crit(header) + return header, claims, signing_input, signature + + +def _cwt_envelope(data: bytes) -> tuple[dict[str, Any], dict[str, Any], Any]: + """Parse + profile-check the CWT form (a ``COSE_Sign1`` whose payload is a CBOR + claims map), WITHOUT verifying the signature. + + The COSE lane's own hardening applies: ``alg`` is read from the **protected** header + only (never from the unsigned one), and an unhandled critical label fails closed + (:mod:`openvc.cose`).""" + from . import cbor, cose + + try: + sign1 = cose.parse_sign1(cbor.decode(data)) + except (cbor.CborError, cose.CoseError) as exc: + raise RpRegistrationError(f"WRPRC is not a valid COSE_Sign1: {exc}") from exc + + typ = sign1.protected_header.get(_COSE_HDR_TYP) + if isinstance(typ, (bytes, bytearray)): + typ = bytes(typ).decode("utf-8", "replace") + _check_typ(typ, WRPRC_CWT_TYP) + + try: + alg = sign1.alg + except cose.CoseError as exc: + raise RpRegistrationError(f"WRPRC COSE header: {exc}") from exc + if alg not in cose.COSE_ALG_TO_JOSE: + raise RpRegistrationError( + f"WRPRC COSE alg {alg!r} is not permitted (need one of " + f"{sorted(cose.COSE_ALG_TO_JOSE)})") + + if sign1.payload is None: + raise RpRegistrationError("WRPRC COSE_Sign1 has a detached payload (no claims)") + try: + raw_claims = cbor.decode(sign1.payload) + except cbor.CborError as exc: + raise RpRegistrationError(f"WRPRC CWT claims are not valid CBOR: {exc}") from exc + if not isinstance(raw_claims, dict): + raise RpRegistrationError("WRPRC CWT claims must be a CBOR map") + return dict(sign1.protected_header), _normalise_cwt_claims(raw_claims), sign1 + + +def _normalise_cwt_claims(raw: Mapping[Any, Any]) -> dict[str, Any]: + """Map a CWT claims map onto the text claim names so both forms of a WRPRC produce + the same typed object. + + TS 119 475 allocates no CBOR labels for its own claims, so they can only travel as + text keys; the RFC 8392 registered set may travel either way and is translated. A + registered integer key that **collides** with an already-present text key fails + closed — two spellings of ``exp`` in one token is a parser-differential wedge, not + something to resolve by last-write-wins.""" + claims: dict[str, Any] = {} + for key, value in raw.items(): + if isinstance(key, bool): + continue + name = _CWT_REGISTERED_CLAIMS.get(key) if isinstance(key, int) else None + if name is None: + if not isinstance(key, str): + continue # an unregistered integer label: not ours to read + name = key + if name in claims: + raise RpRegistrationError( + f"WRPRC CWT carries claim {name!r} twice (integer and text key)") + claims[name] = value + return claims + + +def _chain_from_jwt(header: Mapping[str, Any]) -> list[Any]: + from .x5c import X5cError, load_x5c_chain + + x5c = header.get("x5c") + if x5c is None: + raise RpRegistrationError( + "WRPRC JWS header has no 'x5c' chain to anchor the registrar " + "(TS 119 475 Table 5; TS 119 182-1 clause 5.1.7 requires a signing-certificate " + "reference)") + try: + return load_x5c_chain(x5c) + except X5cError as exc: + raise RpRegistrationError(f"WRPRC 'x5c': {exc}") from exc + + +def _chain_from_cwt(sign1: Any) -> list[Any]: + from . import cose + from .x5c import X5cError, load_der_chain + + try: + ders = cose.x5chain_ders(sign1) + except cose.CoseError as exc: + raise RpRegistrationError( + f"WRPRC COSE_Sign1 has no usable x5chain to anchor the registrar: {exc}") from exc + try: + return load_der_chain(ders) + except X5cError as exc: + raise RpRegistrationError(f"WRPRC x5chain: {exc}") from exc + + +# --------------------------------------------------------------------------- # +# entry points +# --------------------------------------------------------------------------- # + +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)") + + +def _check_temporal( + reg: RelyingPartyRegistrationCertificate, *, now: datetime | None, leeway_s: int, + max_validity: timedelta | None, require_expiry: bool, +) -> None: + 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) + leeway = timedelta(seconds=max(0, leeway_s)) + + if reg.expires_at is None and require_expiry: + raise RpRegistrationError( + "WRPRC has no 'exp' (conformant per TS 119 475 Table 10, but this call " + "required one) — check the 'status' claim for revocation instead") + if reg.expires_at is not None and instant - leeway > reg.expires_at: + raise RpRegistrationError(f"WRPRC expired at {reg.expires_at.isoformat()}") + + not_before = _numeric_date(reg.claims.get("nbf"), field="nbf") or reg.issued_at + if not_before is not None and instant + leeway < not_before: + raise RpRegistrationError(f"WRPRC is not valid before {not_before.isoformat()}") + + # GEN-5.2.4-08 — binds only when `exp` is present. + if (max_validity is not None and reg.expires_at is not None + and reg.issued_at is not None + and reg.expires_at - reg.issued_at > max_validity): + raise RpRegistrationError( + f"WRPRC validity {reg.expires_at - reg.issued_at} exceeds the {max_validity} " + f"maximum (TS 119 475 GEN-5.2.4-08)") + + +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, applied only when ``exp`` + is present. ``None`` disables it. + * *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 registered entitlement; + a WRPRC without one authorizes nothing anyway. + + **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 reg.entitlements: + raise RpRegistrationError( + "WRPRC registers no entitlements (TS 119 475 GEN-5.2.4-03 requires at least " + "one) — it authorizes nothing") + return reg + + +def _verify_signature( + form: str, header: Mapping[str, Any], public_jwk: dict[str, Any], *, + signing_input: bytes, signature: bytes, sign1: Any, +) -> None: + from .keys import KeyBackendError, verify_signature + + if form == "jwt": + try: + ok = verify_signature( + alg=_check_alg(header.get("alg")), public_jwk=public_jwk, + signing_input=signing_input, signature=signature) + except KeyBackendError as exc: + raise RpRegistrationError(f"WRPRC signature could not be checked: {exc}") from exc + else: + from . import cose + try: + ok = cose.verify_sign1(sign1, public_jwk=public_jwk) + except (cose.CoseError, KeyBackendError) as exc: + raise RpRegistrationError(f"WRPRC signature could not be checked: {exc}") from exc + if not ok: + raise RpRegistrationError("WRPRC signature verification failed") + + +def _load_certs(certs: Iterable[Any], what: str) -> list[Any]: + """Coerce each entry through the same loader :mod:`openvc.rp_cert` uses, so a + registrar anchor may be an ``x509.Certificate``, DER/PEM bytes, or a base64 string — + and a bad entry is a typed error, not a silent drop that would fail-open the set.""" + from .rp_cert import RpCertError, _load_cert + + loaded = [] + for entry in certs: + try: + loaded.append(_load_cert(entry)) + except RpCertError as exc: + raise RpRegistrationError(f"{what}: {exc}") from exc + return loaded + + +def _require_eku(leaf: Any, required_eku: str) -> None: + from cryptography import x509 + from cryptography.x509.oid import ExtensionOID + + try: + eku = leaf.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE).value + except x509.ExtensionNotFound: + raise RpRegistrationError( + f"WRPRC signing certificate has no extendedKeyUsage (needs {required_eku!r})") + if required_eku not in {oid.dotted_string for oid in eku}: + raise RpRegistrationError( + f"WRPRC signing certificate lacks the required extendedKeyUsage {required_eku!r}") + + +# --------------------------------------------------------------------------- # +# cross-checks — from "a valid registration" to "authorized for this request" +# --------------------------------------------------------------------------- # + +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}") + + +def _path_covered(registered: tuple[Any, ...], requested: Sequence[Any]) -> bool: + """Whether a registered claim path covers a requested one. + + A registered path covers a requested path when it is a **prefix** of it under + element-wise matching: registering the container ``["address"]`` covers + ``["address", "locality"]``, because a DCQL selection of ``address`` already returns + the whole object — so this is not a widening. ``None`` (the DCQL array wildcard) in + the *registered* path matches any element; a ``None`` in the *requested* path is + only covered by a registered ``None``, so registering index ``2`` never grants + "every element".""" + if len(registered) > len(requested): + return False + for reg_el, req_el in zip(registered, requested): + if reg_el is None: + continue + # `True == 1` in Python: without the bool guard, a registered index 1 would + # cover a requested `True` and vice versa. + if reg_el != req_el or isinstance(reg_el, bool) is not isinstance(req_el, bool): + return False + return True + + +def _meta_covered(registered: Mapping[str, Any], requested: Mapping[str, Any]) -> bool: + """Whether a registered ``meta`` covers a requested one. + + Every constraint the request carries must be present in the registration and no + wider there: list values (``vct_values``, ``doctype_values``) must be a subset, + scalars must be equal. A request carrying a constraint the registration does not + mention is **not** covered — otherwise a party registered for ``vct:Diploma`` could + request ``vct:BankAccount`` under the same entry. A request carrying *no* + constraints is unrestricted, so it is covered only by an equally unrestricted + registration.""" + if not requested: + return not registered + for key, want in requested.items(): + if key not in registered: + return False + have = registered[key] + if isinstance(want, (list, tuple)): + if not isinstance(have, (list, tuple)) or not set(want) <= set(have): + return False + elif isinstance(have, (list, tuple)): + if want not in have: + return False + elif want != have: + return False + return True + + +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 = _claim_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") diff --git a/src/openvc/x5c.py b/src/openvc/x5c.py index bd04dae..3565e7a 100644 --- a/src/openvc/x5c.py +++ b/src/openvc/x5c.py @@ -38,7 +38,11 @@ class X5cError(OpenvcError): or has an unusable key.""" -def _load_chain(x5c: Sequence[str]) -> list: +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") @@ -70,7 +74,7 @@ def _check_issuer_binding(leaf: Any, iss: str) -> None: raise X5cError(f"issuer {iss!r} is not in the x5c leaf certificate's SAN (not bound)") -def _leaf_public_jwk(leaf: Any) -> dict[str, Any]: +def _leaf_p256_jwk(leaf: Any) -> dict[str, Any]: from cryptography.hazmat.primitives.asymmetric import ec pub = leaf.public_key() if not isinstance(pub, ec.EllipticCurvePublicKey) or not isinstance(pub.curve, ec.SECP256R1): @@ -148,13 +152,17 @@ def resolve_x5c_key( Raises :class:`X5cError` on a malformed chain, a path-validation failure, an unbound issuer, or a non-P-256 leaf key.""" - chain = _load_chain(x5c) + 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_public_jwk(chain[0]) + return _leaf_p256_jwk(chain[0]) -def _load_der_chain(x5chain: Sequence[Any]) -> list: +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") @@ -169,7 +177,11 @@ def _load_der_chain(x5chain: Sequence[Any]) -> list: return chain -def _leaf_ec_jwk(leaf: Any) -> dict[str, Any]: +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): @@ -213,7 +225,7 @@ def check_mdoc_signed_within_ds_validity(x5chain: Sequence[Any], signed: datetim 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] + 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 " @@ -235,16 +247,19 @@ def resolve_mdoc_signer_key( ``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) + 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_ec_jwk(chain[0]) + return leaf_public_jwk(chain[0]) __all__ = [ "X5cError", "resolve_x5c_key", "validate_cert_chain", + "load_x5c_chain", + "load_der_chain", + "leaf_public_jwk", "resolve_mdoc_signer_key", "check_mdoc_signed_within_ds_validity", ] diff --git a/tests/test_rp_registration.py b/tests/test_rp_registration.py new file mode 100644 index 0000000..1440095 --- /dev/null +++ b/tests/test_rp_registration.py @@ -0,0 +1,844 @@ +""" +tests/test_rp_registration.py — EUDI relying-party **registration** certificate +(WRPRC, ETSI TS 119 475 V1.2.1 clause 5.2) parsing, verification and scope cross-checks +(``openvc.rp_registration``, issue #89). + +No official *signed* WRPRC vectors exist: the ETSI deliverable ships one informative, +unsigned JSON example (Annex C), the 2026 EAA Plugtests covered TS 119 472-1 rather than +119 475, and CIR (EU) 2025/848 — under whose Art. 8 registration certificates are +*optional per Member State* — only applies from 2026-12-24. So these pin the **Annex C +payload verbatim** and otherwise build WRPRCs in both profiled forms over the library's +own JOSE and COSE machinery. Recording a real third-party artifact stays a separate, +gated follow-up. + +The emphasis is the negative space: a token that is not shaped like a WRPRC, one signed +under an algorithm or a chain that was not authorized, one whose scope does not cover +what is being asked for, and the binding hole that would let a valid-but-unrelated +registration be paired with a request. +""" +from __future__ import annotations + +import base64 +import datetime as dt +import json + +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, rsa +from cryptography.x509.oid import NameOID, ObjectIdentifier + +from openvc import cbor +from openvc.keys import P256SigningKey +from openvc.rp_registration import ( + RpRegistrationError, + check_matches_access_certificate, + check_request_within_registration, + parse_rp_registration_certificate, + verify_rp_registration_certificate, +) + +_NOW = dt.datetime(2026, 1, 1, tzinfo=dt.timezone.utc) +_AT = dt.datetime(2026, 6, 1, tzinfo=dt.timezone.utc) # a pinned evaluation instant +_IAT = int(_AT.timestamp()) - 3600 +_EXP = int(_AT.timestamp()) + 86400 +ENTITLEMENT = "https://uri.etsi.org/19475/Entitlement/Service_Provider" +REGISTRAR_EKU = "0.4.0.19475.2.1" # a placeholder registrar EKU (the real one is caller-gated) + + +# --------------------------------------------------------------------------- # +# fixtures — a registrar CA, a signing leaf, and both WRPRC forms +# --------------------------------------------------------------------------- # + +def _ku(**kw: bool) -> x509.KeyUsage: + base = dict(digital_signature=False, content_commitment=False, key_encipherment=False, + data_encipherment=False, key_agreement=False, key_cert_sign=False, + crl_sign=False, encipher_only=False, decipher_only=False) + base.update(kw) + return x509.KeyUsage(**base) + + +def _cert(subject, issuer, subject_key, signer_key, exts, *, before=None, after=None): + b = (x509.CertificateBuilder().subject_name(subject).issuer_name(issuer) + .public_key(subject_key.public_key()).serial_number(x509.random_serial_number()) + .not_valid_before(before or _NOW - dt.timedelta(days=1)) + .not_valid_after(after or _NOW + dt.timedelta(days=3650))) + for ext, crit in exts: + b = b.add_extension(ext, crit) + return b.sign(signer_key, hashes.SHA256()) + + +def _ca(cn="Test Registrar Root"): + key = ec.generate_private_key(ec.SECP256R1()) + name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]) + cert = _cert(name, name, key, key, + [(x509.BasicConstraints(True, None), True), + (_ku(key_cert_sign=True, crl_sign=True), True)]) + return cert, key, name + + +def _signer(ca_key, ca_name, *, key=None, eku=REGISTRAR_EKU, cn="Registrar Signing Key"): + """A registration-certificate signing leaf under the registrar CA.""" + key = key or ec.generate_private_key(ec.SECP256R1()) + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]) + exts = [(x509.BasicConstraints(False, None), True)] + if eku is not None: + exts.append((x509.ExtendedKeyUsage([ObjectIdentifier(eku)]), False)) + return _cert(subject, ca_name, key, ca_key, exts), key + + +def _b64(cert) -> str: + return base64.b64encode(cert.public_bytes(serialization.Encoding.DER)).decode() + + +def _b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def claims(**overrides) -> dict: + """A WRPRC claim set keyed by the clause-5.2.4 claim names. One relying party, one + intended use (the payload is flat — clause 5.2.4 carries no ``intended_use`` + container), two requestable credentials.""" + base = { + "sub": "VATES-B12345678", + "name": "Acme Age Check", + "sub_ln": "Acme Servicios Digitales SL", + "country": "ES", + "registry_uri": "https://registrar.example/ES", + "policy_id": ["0.4.0.19475.3.1"], + "certificate_policy": "https://registrar.example/cp", + "iat": _IAT, + "exp": _EXP, + "entitlements": [ENTITLEMENT], + "intended_use_id": "age-verification", + "purpose": [{"lang": "en-US", "value": "Checking the minimum age"}], + "srv_description": [[{"lang": "en-US", "value": "Acme Age Check"}]], + "credentials": [ + { + "format": "dc+sd-jwt", + "meta": {"vct_values": ["urn:eudi:pid:1"]}, + "claim": [{"path": ["age_equal_or_over", "18"]}, {"path": ["address"]}], + }, + { + "format": "mso_mdoc", + "meta": {"doctype_value": "org.iso.18013.5.1.mDL"}, + "claim": [{"path": ["org.iso.18013.5.1", "age_over_18"]}], + }, + ], + "supervisory_authority": { + "email": "supervisory@aepd.es", "phone": "+34 900 000 000", + "uri": "https://aepd.es/supervisory-authority", + }, + "status": {"status_list": {"uri": "https://registrar.example/sl/1", "idx": 7}}, + } + base.update(overrides) + return {k: v for k, v in base.items() if v is not _ABSENT} + + +_ABSENT = object() + + +def jwt_wrprc(payload, chain, key, *, typ="rc-wrp+jwt", alg="ES256", header=None) -> str: + """A signed compact-JWS WRPRC (the clause-5.2.2 Table 5 shape: typ + alg + x5c).""" + head = {"typ": typ, "alg": alg, "x5c": [_b64(c) for c in chain]} + head.update(header or {}) + signing_input = ( + f"{_b64url(json.dumps(head, separators=(',', ':')).encode())}." + f"{_b64url(json.dumps(payload, separators=(',', ':')).encode())}") + sig = P256SigningKey(key, "registrar").sign(signing_input.encode()) + return f"{signing_input}.{_b64url(sig)}" + + +# RFC 8392 registered CWT claim keys, so the CWT form exercises the integer-key path. +_CWT_KEYS = {"sub": 2, "exp": 4, "nbf": 5, "iat": 6} + + +def cwt_wrprc(payload, chain, key, *, typ="rc-wrp+cwt", alg=-7, protected=None, + detached=False) -> bytes: + """A signed COSE_Sign1 WRPRC, registered claims carried under their integer keys.""" + body = cbor.encode({_CWT_KEYS.get(k, k): v for k, v in payload.items()}) + prot = {1: alg, 16: typ, 33: [c.public_bytes(serialization.Encoding.DER) for c in chain]} + prot.update(protected or {}) + prot_bytes = cbor.encode(prot) + sig = P256SigningKey(key, "registrar").sign( + cbor.encode(["Signature1", prot_bytes, b"", body])) + return cbor.encode([prot_bytes, {}, None if detached else body, sig]) + + +@pytest.fixture() +def registrar(): + """(root_cert, signing_chain, signing_private_key) for a well-formed registrar.""" + root, root_key, root_name = _ca() + leaf, leaf_key = _signer(root_key, root_name) + return root, [leaf], leaf_key + + +# --------------------------------------------------------------------------- # +# parse — the claim view +# --------------------------------------------------------------------------- # + +def test_parse_reads_the_clause_5_2_4_claim_set(registrar): + root, chain, key = registrar + reg = parse_rp_registration_certificate(jwt_wrprc(claims(), chain, key)) + assert reg.form == "jwt" + assert reg.subject_identifier == "VATES-B12345678" # `sub` — the semantic id + assert reg.trade_name == "Acme Age Check" # `name` + assert reg.legal_name == "Acme Servicios Digitales SL" # `sub_ln` + assert reg.country == "ES" + assert reg.entitlements == (ENTITLEMENT,) + assert reg.policy_ids == ("0.4.0.19475.3.1",) # an ARRAY of OIDs + assert reg.intended_use_id == "age-verification" + assert reg.issued_at == dt.datetime.fromtimestamp(_IAT, tz=dt.timezone.utc) + assert reg.status == {"status_list": {"uri": "https://registrar.example/sl/1", "idx": 7}} + assert reg.supervisory_authority is not None + assert reg.supervisory_authority["email"] == "supervisory@aepd.es" + # `srv_description` is an array *of arrays* while `purpose` is flat; both normalise. + assert reg.purpose[0]["value"] == "Checking the minimum age" + assert reg.service_description[0]["value"] == "Acme Age Check" + assert len(reg.credentials) == 2 + assert reg.credentials[0].format == "dc+sd-jwt" + assert reg.credentials[0].claim_paths == (("age_equal_or_over", "18"), ("address",)) + + +def test_parse_reads_the_cwt_form_into_the_same_shape(registrar): + root, chain, key = registrar + reg = parse_rp_registration_certificate(cwt_wrprc(claims(), chain, key)) + assert reg.form == "cwt" + assert reg.subject_identifier == "VATES-B12345678" # integer key 2 -> sub + assert reg.issued_at == dt.datetime.fromtimestamp(_IAT, tz=dt.timezone.utc) + assert reg.entitlements == (ENTITLEMENT,) # text key, as it must be + assert len(reg.credentials) == 2 + + +def test_parse_pins_the_annex_c_example(registrar): + """The one artifact ETSI publishes: TS 119 475 V1.2.1 Annex C, verbatim. + + It is informative and unsigned (and as printed, not even valid JSON — a trailing + comma and a stray brace in the header), so it is pinned as the *payload* under a + signature of our own. It is the only third-party statement of the claim shape, and + it disagrees with the normative tables in two documented places: `intermediary` + carries `name` where Table 10 says `sname`, and it omits `exp`/`intended_use_id`. + """ + annex_c = { + "name": "Example Company", + "sub_ln": "Example Company GmbH", + "sub": "LEIXG-529900T8BM49AURSDO55", + "country": "DE", + "registry_uri": "https://registrar.com", + "srv_description": [[ + {"lang": "en-US", "value": "Awesome Service by Example Company"}, + {"lang": "de-DE", "value": "Super Dienst von Example Company"}, + ]], + "entitlements": ["https://uri.etsi.org/19475/Entitlement/Non_Q_EAA_Provider"], + "privacy_policy": "https://example.com/privacy-policy", + "info_uri": "https://example.com/info", + "support_uri": "https://example.com/support", + "supervisory_authority": { + "email": "supervisory@dpa.com", + "phone": "+49 123 4567890", + "uri": "https://dpa.com/supervisory-authority", + }, + "policy_id": ["0.4.0.19475.3.1"], + "certificate_policy": "https://registrar.com/certificate-policy", + "iat": 1683000000, + "status": {"status_list": {"idx": 0, "uri": "https://example.com/statuslists/1"}}, + "purpose": [ + {"lang": "en-US", "value": "Required for checking the minimum age"}, + {"lang": "de-DE", "value": "Benötigt für die Überprüfung des Mindestalters"}, + ], + "credentials": [ + {"format": "dc+sd-jwt", "meta": {"vct_values": ["urn:eudi:pid:de:1"]}, + "claim": [{"path": ["age_equal_or_over", "18"]}]}, + {"format": "mso_mdoc", "meta": {"doctype_value": "eu.europa.ec.eudi.pid.1"}, + "claim": [{"path": ["eu.europa.ec.eudi.pid.1", "age_over_18"]}]}, + ], + "provides_attestations": [ + {"format": "dc+sd-jwt", + "meta": {"vct_values": ["https://example.com/attestations/age_over_18"]}}, + ], + "intermediary": {"sub": "LEIXG-INTERMEDIARY-1234567890", + "name": "Intermediary Services Ltd."}, + } + root, chain, key = registrar + reg = parse_rp_registration_certificate(jwt_wrprc(annex_c, chain, key)) + assert reg.subject_identifier == "LEIXG-529900T8BM49AURSDO55" + assert reg.trade_name == "Example Company" + assert reg.country == "DE" + assert reg.entitlements == ( + "https://uri.etsi.org/19475/Entitlement/Non_Q_EAA_Provider",) + assert reg.policy_ids == ("0.4.0.19475.3.1",) + assert reg.expires_at is None # Annex C carries no `exp` — conformant + assert reg.intended_use_id is None # nor `intended_use_id` + assert len(reg.service_description) == 2 # the doubly-nested array flattens + assert len(reg.purpose) == 2 + assert len(reg.provides_attestations) == 1 + assert reg.intermediary_identifier == "LEIXG-INTERMEDIARY-1234567890" + assert reg.intermediary_name == "Intermediary Services Ltd." # Annex C spells it `name` + # ...and the request the example registers is inside its own scope. + check_request_within_registration(reg, {"credentials": [{ + "id": "pid", "format": "dc+sd-jwt", "meta": {"vct_values": ["urn:eudi:pid:de:1"]}, + "claims": [{"path": ["age_equal_or_over", "18"]}]}]}) + + +def test_intermediary_name_also_reads_the_normative_sname_spelling(registrar): + # Table 10 says `sname`; Annex C says `name`. Both are accepted. + root, chain, key = registrar + reg = parse_rp_registration_certificate(jwt_wrprc( + claims(intermediary={"sub": "VATDE-INT1", "sname": "Intermediary GmbH"}), chain, key)) + assert reg.intermediary_name == "Intermediary GmbH" + assert reg.intermediary_identifier == "VATDE-INT1" + + +def test_intermediary_identifier_also_reads_the_act_sub_spelling(registrar): + # GEN-5.2.4-09 names the field `act.sub`, which appears nowhere else in the spec. + root, chain, key = registrar + reg = parse_rp_registration_certificate(jwt_wrprc( + claims(act={"sub": "VATDE-INT2"}), chain, key)) + assert reg.intermediary_identifier == "VATDE-INT2" + + +# --------------------------------------------------------------------------- # +# parse — the signed-header profile, without trust +# --------------------------------------------------------------------------- # + +def test_parse_rejects_a_token_that_is_not_shaped_like_a_wrprc(registrar): + root, chain, key = registrar + with pytest.raises(RpRegistrationError, match="typ"): + parse_rp_registration_certificate(jwt_wrprc(claims(), chain, key, typ="JWT")) + with pytest.raises(RpRegistrationError, match="typ"): + parse_rp_registration_certificate(cwt_wrprc(claims(), chain, key, typ="application/cwt")) + + +def test_parse_names_and_refuses_the_german_bmi_profile(registrar): + # `rc-rp+jwt` is the BMI Architekturkonzept's registration certificate: a different + # profile with different claim names. Half-parsing it would apply the wrong + # semantics to `sub`, `purpose` and the credential sets. + root, chain, key = registrar + with pytest.raises(RpRegistrationError, match="BMI"): + parse_rp_registration_certificate(jwt_wrprc(claims(), chain, key, typ="rc-rp+jwt")) + + +@pytest.mark.parametrize("alg", ["RS256", "HS256", "none", "ES512", None, 7]) +def test_parse_rejects_algorithms_outside_the_allow_list(registrar, alg): + # The allow-list runs before any crypto — an RS*/HS*/none header never reaches a + # verify call, the invariant the whole library keeps. + root, chain, key = registrar + with pytest.raises(RpRegistrationError, match="alg"): + parse_rp_registration_certificate(jwt_wrprc(claims(), chain, key, alg=alg)) + + +@pytest.mark.parametrize("bad", [b"not cbor", "not.a.jws", "", 42, None, [], b""]) +def test_parse_rejects_malformed_input_as_typed_error(bad): + with pytest.raises(RpRegistrationError): + parse_rp_registration_certificate(bad) + + +# --------------------------------------------------------------------------- # +# crit — the JAdES wrinkle +# --------------------------------------------------------------------------- # + +def test_crit_accepts_a_jades_parameter_this_verifier_processes(registrar): + # JAdES V1.1.1-era producers listed their non-registered header parameters in + # `crit`; the header `iat` (TS 119 182-1 clause 5.1.11) is the live example. + root, chain, key = registrar + token = jwt_wrprc(claims(), chain, key, header={"crit": ["iat"], "iat": _IAT}) + assert parse_rp_registration_certificate(token).form == "jwt" + + +def test_crit_rejects_a_parameter_this_verifier_does_not_process(registrar): + root, chain, key = registrar + token = jwt_wrprc(claims(), chain, key, + header={"crit": ["sigD"], "sigD": {"pars": ["x"]}}) + with pytest.raises(RpRegistrationError, match="critical"): + parse_rp_registration_certificate(token) + + +def test_crit_rejects_a_named_parameter_that_is_absent(registrar): + # RFC 7515 §4.1.11: every name in `crit` must actually be present in the header. + root, chain, key = registrar + token = jwt_wrprc(claims(), chain, key, header={"crit": ["iat"]}) + with pytest.raises(RpRegistrationError, match="not present"): + parse_rp_registration_certificate(token) + + +@pytest.mark.parametrize("crit", [[], "iat", [1], {}, None]) +def test_crit_rejects_malformed_shapes(registrar, crit): + root, chain, key = registrar + token = jwt_wrprc(claims(), chain, key, header={"crit": crit, "iat": _IAT}) + with pytest.raises(RpRegistrationError): + parse_rp_registration_certificate(token) + + +def test_a_header_iat_does_not_displace_the_payload_iat(registrar): + # JAdES puts a claimed signing time in the *header* `iat`; TS 119 475 puts issuance + # in the *payload* `iat`. They are different fields and must not be collapsed — + # GEN-5.2.4-08's 12-month ceiling is anchored to the payload's. + root, chain, key = registrar + token = jwt_wrprc(claims(), chain, key, header={"iat": _IAT - 999999}) + reg = verify_rp_registration_certificate(token, trust_anchors=[root], now=_AT) + assert reg.issued_at == dt.datetime.fromtimestamp(_IAT, tz=dt.timezone.utc) + assert reg.header["iat"] == _IAT - 999999 + + +# --------------------------------------------------------------------------- # +# verify — chain, signature, temporal +# --------------------------------------------------------------------------- # + +def test_verify_accepts_a_wrprc_signed_under_the_registrar_anchor(registrar): + root, chain, key = registrar + for token in (jwt_wrprc(claims(), chain, key), cwt_wrprc(claims(), chain, key)): + reg = verify_rp_registration_certificate(token, trust_anchors=[root], now=_AT) + assert reg.subject_identifier == "VATES-B12345678" + + +def test_verify_rejects_a_tampered_payload(registrar): + root, chain, key = registrar + head, _, sig = jwt_wrprc(claims(), chain, key).split(".") + forged = claims(entitlements=["https://uri.etsi.org/19475/Entitlement/PID_Provider"]) + swapped = f"{head}.{_b64url(json.dumps(forged, separators=(',', ':')).encode())}.{sig}" + with pytest.raises(RpRegistrationError, match="signature"): + verify_rp_registration_certificate(swapped, trust_anchors=[root], now=_AT) + + +def test_verify_rejects_a_tampered_cwt_payload(registrar): + root, chain, key = registrar + prot, unprot, _, sig = cbor.decode(cwt_wrprc(claims(), chain, key)) + forged = cbor.encode({_CWT_KEYS.get(k, k): v for k, v in claims( + entitlements=["https://uri.etsi.org/19475/Entitlement/PID_Provider"]).items()}) + with pytest.raises(RpRegistrationError, match="signature"): + verify_rp_registration_certificate( + cbor.encode([prot, unprot, forged, sig]), trust_anchors=[root], now=_AT) + + +def test_verify_rejects_a_chain_under_a_different_anchor(registrar): + root, chain, key = registrar + other, _, _ = _ca(cn="Some Other Root") + with pytest.raises(RpRegistrationError, match="trust anchor"): + verify_rp_registration_certificate( + jwt_wrprc(claims(), chain, key), trust_anchors=[other], now=_AT) + + +def test_verify_rejects_a_self_signed_registrar(registrar): + # The classic forgery: an attacker mints their own CA + leaf and signs a WRPRC + # granting themselves every entitlement. It must not validate to the real anchor. + root, _, _ = registrar + rogue_ca, rogue_key, rogue_name = _ca(cn="Rogue Registrar") + rogue_leaf, rogue_leaf_key = _signer(rogue_key, rogue_name) + with pytest.raises(RpRegistrationError, match="trust anchor"): + verify_rp_registration_certificate( + jwt_wrprc(claims(), [rogue_leaf], rogue_leaf_key), trust_anchors=[root], now=_AT) + + +def test_verify_requires_at_least_one_anchor(registrar): + root, chain, key = registrar + with pytest.raises(RpRegistrationError, match="anchor"): + verify_rp_registration_certificate( + jwt_wrprc(claims(), chain, key), trust_anchors=[], now=_AT) + + +def test_verify_rejects_mistyped_trust_params(registrar): + root, chain, key = registrar + token = jwt_wrprc(claims(), chain, key) + with pytest.raises(RpRegistrationError): + verify_rp_registration_certificate(token, trust_anchors=root, now=_AT) + with pytest.raises(RpRegistrationError): + verify_rp_registration_certificate( + token, trust_anchors=[root], intermediates=root, now=_AT) + with pytest.raises(RpRegistrationError): + verify_rp_registration_certificate(token, trust_anchors=[root], now="2026-06-01") + with pytest.raises(RpRegistrationError): + verify_rp_registration_certificate(token, trust_anchors=[b"not a cert"], now=_AT) + + +def test_verify_accepts_anchors_in_any_certificate_form(registrar): + root, chain, key = registrar + token = jwt_wrprc(claims(), chain, key) + der = root.public_bytes(serialization.Encoding.DER) + for anchor in (root, der, root.public_bytes(serialization.Encoding.PEM), + base64.b64encode(der).decode()): + assert verify_rp_registration_certificate( + token, trust_anchors=[anchor], now=_AT).country == "ES" + + +def test_verify_refuses_non_ca_intermediate_smuggling(): + # A non-CA certificate must not pass as a signing link (basicConstraints is enforced). + root, root_key, root_name = _ca() + imp_key = ec.generate_private_key(ec.SECP256R1()) + imp_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Not A CA")]) + impostor = _cert(imp_name, root_name, imp_key, root_key, + [(x509.BasicConstraints(False, None), True)]) + leaf, leaf_key = _signer(imp_key, imp_name) + with pytest.raises(RpRegistrationError, match="trust anchor"): + verify_rp_registration_certificate( + jwt_wrprc(claims(), [leaf, impostor], leaf_key), trust_anchors=[root], now=_AT) + + +def test_verify_requires_a_signing_certificate_chain(registrar): + root, chain, key = registrar + signing_input = ( + f"{_b64url(json.dumps({'typ': 'rc-wrp+jwt', 'alg': 'ES256'}).encode())}." + f"{_b64url(json.dumps(claims()).encode())}") + sig = P256SigningKey(key, "registrar").sign(signing_input.encode()) + with pytest.raises(RpRegistrationError, match="x5c"): + verify_rp_registration_certificate( + f"{signing_input}.{_b64url(sig)}", trust_anchors=[root], now=_AT) + + +def test_verify_rejects_an_empty_x5c(registrar): + # Annex C prints `"x5c": []`. Taken literally that anchors nothing. + root, chain, key = registrar + with pytest.raises(RpRegistrationError, match="x5c"): + verify_rp_registration_certificate( + jwt_wrprc(claims(), chain, key, header={"x5c": []}), + trust_anchors=[root], now=_AT) + + +def test_verify_enforces_required_eku(registrar): + root, chain, key = registrar + token = jwt_wrprc(claims(), chain, key) + verify_rp_registration_certificate( + token, trust_anchors=[root], required_eku=REGISTRAR_EKU, now=_AT) # ok + with pytest.raises(RpRegistrationError, match="extendedKeyUsage"): + verify_rp_registration_certificate( + token, trust_anchors=[root], required_eku="2.9.9.9.9", now=_AT) + + +def test_verify_rejects_a_signer_with_no_eku_when_one_is_required(): + root, root_key, root_name = _ca() + leaf, leaf_key = _signer(root_key, root_name, eku=None) + with pytest.raises(RpRegistrationError, match="extendedKeyUsage"): + verify_rp_registration_certificate( + jwt_wrprc(claims(), [leaf], leaf_key), trust_anchors=[root], + required_eku=REGISTRAR_EKU, now=_AT) + + +def test_verify_rejects_an_rsa_signer_key(): + # An RSA leaf produces no allow-listed JWK: it must fail closed, not crash untyped. + root, root_key, root_name = _ca() + leaf, _ = _signer(root_key, root_name, key=rsa.generate_private_key( + public_exponent=65537, key_size=2048)) + with pytest.raises(RpRegistrationError): + verify_rp_registration_certificate( + jwt_wrprc(claims(), [leaf], ec.generate_private_key(ec.SECP256R1())), + trust_anchors=[root], now=_AT) + + +def test_verify_enforces_the_temporal_claims(registrar): + root, chain, key = registrar + expired = jwt_wrprc(claims(exp=int(_AT.timestamp()) - 7200), chain, key) + with pytest.raises(RpRegistrationError, match="expired"): + verify_rp_registration_certificate(expired, trust_anchors=[root], now=_AT) + + future = jwt_wrprc(claims(iat=int(_AT.timestamp()) + 7200, + exp=int(_AT.timestamp()) + 90000), chain, key) + with pytest.raises(RpRegistrationError, match="not valid before"): + verify_rp_registration_certificate(future, trust_anchors=[root], now=_AT) + + +def test_an_absent_expiry_is_conformant_but_can_be_refused_by_policy(registrar): + # `exp` is OPTIONAL (Table 10): revocation runs through `status`. Accept by default, + # and let a stricter caller demand one. + root, chain, key = registrar + token = jwt_wrprc(claims(exp=_ABSENT), chain, key) + assert verify_rp_registration_certificate( + token, trust_anchors=[root], now=_AT).expires_at is None + with pytest.raises(RpRegistrationError, match="exp"): + verify_rp_registration_certificate( + token, trust_anchors=[root], now=_AT, require_expiry=True) + + +@pytest.mark.parametrize("literal", ["NaN", "Infinity", "-Infinity"]) +def test_verify_rejects_a_non_finite_expiry(registrar, literal): + # NaN/Infinity survive json.loads and make every comparison False — i.e. never + # expires. A present-but-unusable bound must fail closed, not be dropped. + root, chain, key = registrar + head, _, sig = jwt_wrprc(claims(), chain, key).split(".") + raw = json.dumps(claims()).replace(f'"exp": {_EXP}', f'"exp": {literal}') + with pytest.raises(RpRegistrationError, match="exp"): + parse_rp_registration_certificate(f"{head}.{_b64url(raw.encode())}.{sig}") + + +@pytest.mark.parametrize("bad_exp", [True, "soon", [], {}]) +def test_verify_rejects_a_non_numeric_expiry(registrar, bad_exp): + root, chain, key = registrar + with pytest.raises(RpRegistrationError, match="exp"): + parse_rp_registration_certificate(jwt_wrprc(claims(exp=bad_exp), chain, key)) + + +def test_verify_rejects_a_validity_longer_than_twelve_months(registrar): + # GEN-5.2.4-08 caps `exp` at 12 months after the payload `iat`. + root, chain, key = registrar + long_lived = jwt_wrprc(claims(iat=_IAT, exp=_IAT + 400 * 86400), chain, key) + with pytest.raises(RpRegistrationError, match="exceeds"): + verify_rp_registration_certificate(long_lived, trust_anchors=[root], now=_AT) + # ...and the ceiling is opt-out-able for a profile that legitimately differs. + assert verify_rp_registration_certificate( + long_lived, trust_anchors=[root], now=_AT, max_validity=None).country == "ES" + + +def test_verify_requires_at_least_one_entitlement(registrar): + # GEN-5.2.4-03. A WRPRC with no entitlement authorizes nothing. + root, chain, key = registrar + token = jwt_wrprc(claims(entitlements=[]), chain, key) + with pytest.raises(RpRegistrationError, match="entitlement"): + verify_rp_registration_certificate(token, trust_anchors=[root], now=_AT) + assert verify_rp_registration_certificate( + token, trust_anchors=[root], now=_AT, require_entitlement=False).entitlements == () + + +def test_a_scalar_entitlement_string_does_not_become_a_grant(registrar): + # A malformed `entitlements` must not silently collapse into a one-element grant. + root, chain, key = registrar + reg = parse_rp_registration_certificate( + jwt_wrprc(claims(entitlements=ENTITLEMENT), chain, key)) + assert reg.entitlements == () + + +# --------------------------------------------------------------------------- # +# CWT-specific fail-closed paths +# --------------------------------------------------------------------------- # + +def test_cwt_rejects_a_claim_carried_under_both_an_integer_and_a_text_key(registrar): + # Two spellings of one claim is a parser-differential wedge: whoever reads the other + # spelling sees a different token. Fail closed rather than pick a winner. + root, chain, key = registrar + body = cbor.encode({**{_CWT_KEYS.get(k, k): v for k, v in claims().items()}, + "exp": _EXP + 999999}) + prot = cbor.encode({1: -7, 16: "rc-wrp+cwt", + 33: [c.public_bytes(serialization.Encoding.DER) for c in chain]}) + sig = P256SigningKey(key, "registrar").sign( + cbor.encode(["Signature1", prot, b"", body])) + with pytest.raises(RpRegistrationError, match="twice"): + verify_rp_registration_certificate( + cbor.encode([prot, {}, body, sig]), trust_anchors=[root], now=_AT) + + +def test_cwt_rejects_a_detached_payload(registrar): + root, chain, key = registrar + with pytest.raises(RpRegistrationError, match="detached"): + parse_rp_registration_certificate(cwt_wrprc(claims(), chain, key, detached=True)) + + +def test_cwt_rejects_an_algorithm_outside_the_allow_list(registrar): + root, chain, key = registrar + with pytest.raises(RpRegistrationError, match="alg"): + parse_rp_registration_certificate(cwt_wrprc(claims(), chain, key, alg=-257)) # RS256 + + +def test_cwt_reads_alg_from_the_protected_header_only(registrar): + # An `alg` in the unprotected header is not covered by the signature; honouring it + # would let an attacker choose the verification algorithm on an unsigned field. + root, chain, key = registrar + body = cbor.encode({_CWT_KEYS.get(k, k): v for k, v in claims().items()}) + prot = cbor.encode({16: "rc-wrp+cwt", + 33: [c.public_bytes(serialization.Encoding.DER) for c in chain]}) + sig = P256SigningKey(key, "registrar").sign( + cbor.encode(["Signature1", prot, b"", body])) + with pytest.raises(RpRegistrationError, match="alg"): + parse_rp_registration_certificate(cbor.encode([prot, {1: -7}, body, sig])) + + +# --------------------------------------------------------------------------- # +# cross-check 1 — binding the WRPRC to the WRPAC that authenticated the caller +# --------------------------------------------------------------------------- # + +class _Wrpac: + """Stand-in for openvc.rp_cert.RelyingPartyAccessCertificate.""" + + def __init__(self, entity_identifier=None, trade_name=None): + self.entity_identifier = entity_identifier + self.trade_name = trade_name + + +def _reg(**overrides): + root, root_key, root_name = _ca() + leaf, leaf_key = _signer(root_key, root_name) + return parse_rp_registration_certificate( + jwt_wrprc(claims(**overrides), [leaf], leaf_key)) + + +def test_binding_accepts_the_same_relying_party(): + check_matches_access_certificate(_reg(), _Wrpac("VATES-B12345678", "Acme Age Check")) + + +def test_binding_rejects_a_different_entity(): + # The attack: present your own valid WRPAC alongside someone else's valid WRPRC and + # inherit their registered scope. + with pytest.raises(RpRegistrationError, match="different relying parties"): + check_matches_access_certificate(_reg(), _Wrpac("VATES-ATTACKER01")) + + +@pytest.mark.parametrize("wrpac,overrides", [ + (_Wrpac(None), {}), # missing on the WRPAC + (_Wrpac("VATES-B12345678"), {"sub": None}), # missing on the WRPRC + (_Wrpac(None), {"sub": None}), # missing on both + (_Wrpac(" "), {}), # blank on the WRPAC + (_Wrpac("VATES-B12345678"), {"sub": " "}), # blank on the WRPRC +]) +def test_binding_never_matches_on_absent_identifiers(wrpac, overrides): + # `None == None` would be a successful bind — a fail-open hole exactly where the + # check exists to close one. A blank string must not satisfy it either. + with pytest.raises(RpRegistrationError, match="missing"): + check_matches_access_certificate(_reg(**overrides), wrpac) + + +def test_binding_can_additionally_compare_trade_names(): + check_matches_access_certificate( + _reg(), _Wrpac("VATES-B12345678", "Acme Age Check"), match_trade_name=True) + with pytest.raises(RpRegistrationError, match="trade name"): + check_matches_access_certificate( + _reg(), _Wrpac("VATES-B12345678", "Acme S.L."), match_trade_name=True) + + +def test_binding_accepts_a_real_wrpac_object(): + # The documented pairing: openvc.rp_cert's own parsed object, not just a stand-in. + from openvc.rp_cert import parse_rp_access_certificate + + ca, ca_key, ca_name = _ca(cn="ACA Root") + subject = x509.Name([ + x509.NameAttribute(NameOID.ORGANIZATION_IDENTIFIER, "VATES-B12345678"), + x509.NameAttribute(NameOID.COMMON_NAME, "Acme Age Check"), + ]) + leaf_key = ec.generate_private_key(ec.SECP256R1()) + wrpac = parse_rp_access_certificate( + _cert(subject, ca_name, leaf_key, ca_key, [(x509.BasicConstraints(False, None), True)])) + check_matches_access_certificate(_reg(), wrpac, match_trade_name=True) + + +# --------------------------------------------------------------------------- # +# cross-check 2 — the request must fall inside the registered scope +# --------------------------------------------------------------------------- # + +def _dcql(*credentials): + return {"credentials": list(credentials)} + + +_PID = {"id": "pid", "format": "dc+sd-jwt", "meta": {"vct_values": ["urn:eudi:pid:1"]}} + + +def test_request_within_the_registered_scope_passes(): + check_request_within_registration( + _reg(), _dcql({**_PID, "claims": [{"path": ["age_equal_or_over", "18"]}]}), + intended_use_id="age-verification") + + +def test_the_intended_use_assertion_must_match(): + with pytest.raises(RpRegistrationError, match="intended use"): + check_request_within_registration( + _reg(), _dcql({**_PID, "claims": [{"path": ["address"]}]}), + intended_use_id="profiling") + + +def test_request_for_an_unregistered_format_is_refused(): + with pytest.raises(RpRegistrationError, match="does not register"): + check_request_within_registration(_reg(), _dcql({ + "id": "x", "format": "ldp_vc", "meta": {"vct_values": ["urn:eudi:pid:1"]}, + "claims": [{"path": ["address"]}]})) + + +def test_request_for_an_unregistered_claim_is_refused(): + with pytest.raises(RpRegistrationError, match="claim path"): + check_request_within_registration( + _reg(), _dcql({**_PID, "claims": [{"path": ["nationality"]}]})) + + +def test_a_registered_container_covers_its_members(): + # Registering ["address"] covers ["address","locality"] — a DCQL selection of the + # container already returns the whole object, so this is not a widening. + check_request_within_registration( + _reg(), _dcql({**_PID, "claims": [{"path": ["address", "locality"]}]})) + + +def test_a_registered_member_does_not_cover_its_container(): + with pytest.raises(RpRegistrationError, match="claim path"): + check_request_within_registration( + _reg(), _dcql({**_PID, "claims": [{"path": ["age_equal_or_over"]}]})) + + +def test_a_registered_index_does_not_grant_the_array_wildcard(): + reg = _reg(credentials=[{"format": "dc+sd-jwt", "meta": {}, + "claim": [{"path": ["degrees", 0, "name"]}]}]) + check_request_within_registration(reg, _dcql({ + "id": "d", "format": "dc+sd-jwt", "claims": [{"path": ["degrees", 0, "name"]}]})) + with pytest.raises(RpRegistrationError, match="claim path"): + check_request_within_registration(reg, _dcql({ + "id": "d", "format": "dc+sd-jwt", + "claims": [{"path": ["degrees", None, "name"]}]})) + + +def test_a_registered_wildcard_covers_any_index(): + reg = _reg(credentials=[{"format": "dc+sd-jwt", "meta": {}, + "claim": [{"path": ["degrees", None, "name"]}]}]) + check_request_within_registration(reg, _dcql({ + "id": "d", "format": "dc+sd-jwt", "claims": [{"path": ["degrees", 3, "name"]}]})) + + +def test_a_boolean_does_not_match_an_integer_index(): + # `True == 1` in Python; without a type guard a registered index 1 would cover a + # requested `True` (and a registered `True` would cover index 1). + reg = _reg(credentials=[{"format": "dc+sd-jwt", "meta": {}, + "claim": [{"path": ["a", 1]}]}]) + with pytest.raises(RpRegistrationError, match="claim path"): + check_request_within_registration(reg, _dcql({ + "id": "d", "format": "dc+sd-jwt", "claims": [{"path": ["a", True]}]})) + + +def test_meta_must_be_covered_not_merely_present(): + # Registered for urn:eudi:pid:1 — asking under a different vct is a scope escalation. + with pytest.raises(RpRegistrationError, match="does not register"): + check_request_within_registration(_reg(), _dcql({ + "id": "pid", "format": "dc+sd-jwt", + "meta": {"vct_values": ["urn:eudi:bank-account:1"]}, + "claims": [{"path": ["address"]}]})) + + +def test_an_unconstrained_request_does_not_inherit_a_constrained_registration(): + # No `meta` means "any credential of this format"; the registration is narrower. + with pytest.raises(RpRegistrationError, match="does not register"): + check_request_within_registration(_reg(), _dcql({ + "id": "pid", "format": "dc+sd-jwt", "claims": [{"path": ["address"]}]})) + + +def test_a_request_naming_no_claims_is_refused_against_an_enumerated_registration(): + # An absent `claims` asks for every attribute — it cannot be inside an explicit list. + with pytest.raises(RpRegistrationError, match="every attribute"): + check_request_within_registration(_reg(), _dcql(_PID)) + + +def test_an_entry_registering_no_claim_paths_grants_nothing(): + reg = _reg(credentials=[{"format": "dc+sd-jwt", "meta": {"vct_values": ["v"]}}]) + with pytest.raises(RpRegistrationError, match="claim path"): + check_request_within_registration(reg, _dcql({ + "id": "d", "format": "dc+sd-jwt", "meta": {"vct_values": ["v"]}, + "claims": [{"path": ["anything"]}]})) + + +def test_scope_is_not_unioned_across_differently_scoped_entries(): + # Registered: vct A grants `name`, vct B grants `age`. Asking for A.age must fail — + # unioning claim paths across entries of the same format would escalate scope. + reg = _reg(credentials=[ + {"format": "dc+sd-jwt", "meta": {"vct_values": ["A"]}, "claim": [{"path": ["name"]}]}, + {"format": "dc+sd-jwt", "meta": {"vct_values": ["B"]}, "claim": [{"path": ["age"]}]}, + ]) + check_request_within_registration(reg, _dcql({ + "id": "a", "format": "dc+sd-jwt", "meta": {"vct_values": ["A"]}, + "claims": [{"path": ["name"]}]})) + with pytest.raises(RpRegistrationError, match="claim path"): + check_request_within_registration(reg, _dcql({ + "id": "a", "format": "dc+sd-jwt", "meta": {"vct_values": ["A"]}, + "claims": [{"path": ["age"]}]})) + + +def test_a_request_spelling_claims_either_way_is_read(registrar): + # The registration side says `claim`, DCQL says `claims`. A query using the spec's + # singular spelling must not be under-read into looking like "no claims requested". + with pytest.raises(RpRegistrationError, match="claim path"): + check_request_within_registration( + _reg(), _dcql({**_PID, "claim": [{"path": ["nationality"]}]})) + + +@pytest.mark.parametrize("query", [{}, {"credentials": []}, {"credentials": "x"}, + {"credentials": ["not an object"]}, "not a query", None]) +def test_malformed_dcql_queries_are_typed_errors(query): + with pytest.raises(RpRegistrationError): + check_request_within_registration(_reg(), query) diff --git a/wiki/Home.md b/wiki/Home.md index 401385b..05c264f 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -60,7 +60,8 @@ src/openvc/ core — knows nothing about EBSI or badges cache.py opt-in TTL caching for DID resolution jwt_vc_issuer.py issuer keys via /.well-known/jwt-vc-issuer x5c.py X.509 x5c chain trust + SAN issuer binding - rp_cert.py EUDI relying-party access certificates (WRPAC, ETSI TS 119 475) + rp_cert.py EUDI relying-party access certificates (WRPAC, ETSI TS 119 411-8) + rp_registration.py EUDI relying-party registration certificates (WRPRC, ETSI TS 119 475) trustlist/ EU Trusted Lists (LOTL → TL) → X.509 anchors status/ W3C Bitstring + IETF Token Status List (check + issue) schema.py credentialSchema validation (opt-in) diff --git a/wiki/Relying-Party-Certificates.md b/wiki/Relying-Party-Certificates.md index 41c93da..d66fc5c 100644 --- a/wiki/Relying-Party-Certificates.md +++ b/wiki/Relying-Party-Certificates.md @@ -1,4 +1,4 @@ -# Relying-party certificates (EUDI WRPAC) +# Relying-party certificates (EUDI WRPAC + WRPRC) Under CIR (EU) 2025/848 every EUDI wallet **relying party** carries a **Wallet-Relying Party Access Certificate (WRPAC)** — an X.509 certificate (ETSI TS 119 411-8) that @@ -42,11 +42,94 @@ The module does **not** hardcode which EKU or certificate-policy OID the EUDI pr mandates (that value is still settling); it surfaces the parsed sets so you gate on them with `required_eku` or by inspecting the returned object. +## The registration certificate (WRPRC) + +The WRPAC answers *“who is asking?”*. The **registration certificate** — WRPRC, CIR (EU) +2025/848 Art. 8, *optional per Member State* — answers the other half: **“were they +registered to ask for this?”** It carries the relying party’s registered **entitlements** +and the credentials and attributes it may request. + +A WRPRC is **not** X.509. ETSI TS 119 475 V1.2.1 clause 5.2 profiles it as a signed +**JWT** (`typ: rc-wrp+jwt`) or **CWT** (`typ: rc-wrp+cwt`), so `openvc.rp_registration` +reads it over the JOSE lane and the CBOR/COSE codec respectively, anchoring the signer’s +`x5c` / `x5chain` chain in **your** registrar roots. Same trusted/untrusted split: + + +```python +from openvc.rp_registration import ( + check_matches_access_certificate, + check_request_within_registration, + verify_rp_registration_certificate, +) + +reg = verify_rp_registration_certificate( + wrprc_jwt, # a compact-JWS str, or COSE_Sign1 bytes for the CWT form + trust_anchors=registrar_roots, # the registration-CA roots you trust +) +print(reg.subject_identifier) # `sub` — the EN 319 412-1 semantic identifier +print(reg.entitlements) # https://uri.etsi.org/19475/Entitlement/... +print(reg.credentials) # what it may request, per format + +# 1. this registration really describes the party the WRPAC authenticated +check_matches_access_certificate(reg, rp) # rp from verify_rp_access_certificate +# 2. ...and the request stays inside the registered scope +check_request_within_registration(reg, dcql_query) +``` + +`parse_rp_registration_certificate(token)` is the UNTRUSTED counterpart — it enforces the +signed-header profile (`typ`, the `{ES256, ES384, EdDSA, Ed25519}` allow-list *before* any +crypto, a fail-closed `crit`) but checks no signature and validates no chain. + +### The two cross-checks are the point + +Verifying the signature only proves a registrar signed *something*. The authorization +decision needs both cross-checks, and both fail closed: + +- `check_matches_access_certificate` binds the WRPRC’s `sub` to the WRPAC’s + `entity_identifier` (GEN-5.1.1-04). Without it, an attacker pairs their own valid WRPAC + with **someone else’s** valid WRPRC and inherits that party’s scope. An identifier + absent on either side is a failure, never a match. +- `check_request_within_registration` requires every DCQL credential query to match a + registered `format` whose `meta` covers the requested one, and every requested claim + `path` to fall inside the registered paths. A registered container (`["address"]`) + covers its members (`["address","locality"]`); a request naming *no* claims asks for + everything and is refused against an enumerated registration. + +### Where the specification is thinner than it looks + +Three things are worth knowing, because each is a place a naïve reading goes wrong: + +- **One WRPRC = one intended use.** TS5’s data model nests `intendedUse[0..*]`, but clause + 5.2.4 flattens it: `credentials`, `purpose` and `intended_use_id` are top-level claims. + A relying party with several intended uses holds several WRPRCs. +- **`exp` is optional** (Table 10) — an absent expiry is conformant, and revocation runs + through the `status` claim (an IETF [Token Status List](Status-Lists), which + `openvc.status.check_token_status` resolves; `verify_…` does not fetch it for you). The + 12-month ceiling (GEN-5.2.4-08) therefore binds only when `exp` is present. Pass + `require_expiry=True` if your policy refuses a certificate that can only be revoked. +- **The CWT form has no claim-key mapping.** TS 119 475 presents its claim tables once, + format-agnostically, and never allocates CBOR integer labels for them. The envelope + (RFC 9052 + RFC 9360) is fully specified and implemented; the claims map is read + accepting both the RFC 8392 registered integer keys and text keys — the only reading + available to an issuer today. Treat the CWT lane as **provisional** until a real + artifact exists to pin. + +Only the JWT form’s signature profile is constrained further: GEN-5.2.1-04 requires +**JAdES baseline B-B** (ETSI TS 119 182-1). openvc implements a *verify subset* — the +signed-header profile and the chain validation — not a full JAdES library: no +signature-policy processing, no timestamps, no augmentation. Since JAdES clause 5.1.11 +mandates a *header* `iat` that TS 119 475 Table 5 omits, the header `iat` is surfaced but +not required; the security-bearing timestamps are the payload’s. + +Two known spec defects are absorbed rather than inherited: `intermediary` is spelled +`sname` in Table 10 and `name` in the Annex C example (both are read, via +`reg.intermediary_name`), and the intermediary identifier appears as both +`intermediary.sub` and `act.sub` (both via `reg.intermediary_identifier`). + ## What this is not -- **The registration certificate (WRPRC)** — the *entitlements / intended-use* - artifact — is a signed **JWT or CWT** (ETSI TS 119 475), not an X.509 certificate, - and its claim mapping is not finalised yet. It is tracked separately and is out of - scope for `openvc.rp_cert` today. +- **The German BMI registration certificate** (`rc-rp+jwt`, from the eIDAS 2.0 + *Architekturkonzept*) is a **different profile** with different claim names. It is + detected and refused by name rather than half-parsed under the wrong semantics. - **Registrar workflows / certificate issuance.** openvc is a verifier/consumer: parse + validate only. From 28e71de9c846551a5b7d74ac07b49fb351236ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Gonz=C3=A1lez=20Fern=C3=A1ndez?= Date: Sun, 19 Jul 2026 12:06:10 +0200 Subject: [PATCH 2/3] fix(rp-registration): compare meta by equality, not by set membership A `meta` value is attacker-influenced JSON and may hold unhashable members (an object, an array). Subset-testing it with `set(want) <= set(have)` raised a bare TypeError straight past the OpenvcError family. Found by adversarial review; equality containment has no such edge. --- src/openvc/rp_registration.py | 9 +++++++-- tests/test_rp_registration.py | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/openvc/rp_registration.py b/src/openvc/rp_registration.py index b714911..3e6b9de 100644 --- a/src/openvc/rp_registration.py +++ b/src/openvc/rp_registration.py @@ -826,7 +826,12 @@ def _meta_covered(registered: Mapping[str, Any], requested: Mapping[str, Any]) - mention is **not** covered — otherwise a party registered for ``vct:Diploma`` could request ``vct:BankAccount`` under the same entry. A request carrying *no* constraints is unrestricted, so it is covered only by an equally unrestricted - registration.""" + registration. + + Subset testing is deliberately done with ``in`` (equality) rather than by building + ``set``s: a ``meta`` value is attacker-influenced JSON and may hold unhashable + members (an object, an array), which would make the set construction raise a bare + ``TypeError`` straight past this library's error family.""" if not requested: return not registered for key, want in requested.items(): @@ -834,7 +839,7 @@ def _meta_covered(registered: Mapping[str, Any], requested: Mapping[str, Any]) - return False have = registered[key] if isinstance(want, (list, tuple)): - if not isinstance(have, (list, tuple)) or not set(want) <= set(have): + if not isinstance(have, (list, tuple)) or not all(w in have for w in want): return False elif isinstance(have, (list, tuple)): if want not in have: diff --git a/tests/test_rp_registration.py b/tests/test_rp_registration.py index 1440095..7520d61 100644 --- a/tests/test_rp_registration.py +++ b/tests/test_rp_registration.py @@ -792,6 +792,22 @@ def test_meta_must_be_covered_not_merely_present(): "claims": [{"path": ["address"]}]})) +@pytest.mark.parametrize("value", [[{"a": 1}], [["x"]], [{"a": 1}, "b"]]) +def test_unhashable_meta_values_fail_closed_not_with_a_bare_typeerror(value): + # `meta` is attacker-influenced JSON. Subset-testing it by building sets raised a + # bare TypeError ("unhashable type: 'dict'") straight past the OpenvcError family — + # found by adversarial review. Equality containment has no such edge. + reg = _reg(credentials=[{"format": "dc+sd-jwt", "meta": {"vct_values": value}, + "claim": [{"path": ["a"]}]}]) + check_request_within_registration(reg, _dcql({ + "id": "d", "format": "dc+sd-jwt", "meta": {"vct_values": value}, + "claims": [{"path": ["a"]}]})) + with pytest.raises(RpRegistrationError): + check_request_within_registration(reg, _dcql({ + "id": "d", "format": "dc+sd-jwt", "meta": {"vct_values": [{"other": 2}]}, + "claims": [{"path": ["a"]}]})) + + def test_an_unconstrained_request_does_not_inherit_a_constrained_registration(): # No `meta` means "any credential of this format"; the registration is narrower. with pytest.raises(RpRegistrationError, match="does not register"): From 04c67d7453576fa407052657a57bf348451c1e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Gonz=C3=A1lez=20Fern=C3=A1ndez?= Date: Sun, 19 Jul 2026 12:16:18 +0200 Subject: [PATCH 3/3] security(rp-registration): close six adversarial-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one that mattered: the claim-path reader took `claim` before `claims` on both sides. That is correct for a registration (the spec's spelling), but on a *request* it let a relying party hide a narrow decoy in `claim` and the real, broader ask in `claims` — openvc authorized the decoy while the wallet, which follows DCQL, answers `claims`. Unknown query members are ignored downstream, so the escalating query stayed valid end to end. The request side now takes the union of both spellings; the registration side keeps precedence, since only there does a second spelling risk widening a grant. Also closed: - A bare OverflowError escaped the OpenvcError family on a bignum iat/exp/nbf: math.isfinite casts to float, and json.loads yields such ints from the wire. - An empty registered {"path": []} was a blanket grant over the whole credential — an empty tuple is a prefix of every path. - A non-object `meta` was coerced to {}, turning a malformed constraint into no constraint and widening the entry to every credential of that format. Absent and malformed are now distinct: {} matches an unconstrained request, None matches nothing. (Not hypothetical — clause B.2.9 types `meta` as a string while clause 5.2.4 and Annex C use an object.) - `meta` matching conflated True with 1, unlike the path matcher. - The entitlement floor accepted any non-empty string while ENTITLEMENT_URI_PREFIX sat exported-but-unused; it now checks the clause-A.2 namespace GEN-5.2.4-03 requires, with require_entitlement=False to opt out. Each has a regression test named for the attack. The binding check, the algorithm allow-list, the chain handling and the CWT parser were probed and held. --- CHANGELOG.md | 28 ++++++-- src/openvc/rp_registration.py | 123 +++++++++++++++++++++++++--------- tests/test_rp_registration.py | 107 +++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ee675d..1f8b7d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,10 +53,30 @@ All notable changes to **openvc** are documented here. The format follows half-parsed under the wrong claim semantics. No official signed vectors exist (the deliverable ships one informative, unsigned - Annex C example; the 2026 EAA Plugtests covered TS 119 472-1), so `tests/test_rp_ - registration.py` pins the **Annex C payload verbatim** and otherwise builds both forms - over the library's own machinery — 89 tests, negative paths first. Recording a real - third-party artifact stays a gated follow-up. + Annex C example; the 2026 EAA Plugtests covered TS 119 472-1), so + `tests/test_rp_registration.py` pins the **Annex C payload verbatim** and otherwise + builds both forms over the library's own machinery — 106 tests, negative paths first. + Recording a real third-party artifact stays a gated follow-up. + + An adversarial review of the verify and authorization paths found six issues, all + fixed here with a regression test named for the attack. The one that mattered: + the claim-path reader took `claim` before `claims` on **both** sides, which is + correct for a registration (the spec's spelling) but on a *request* let a relying + party hide a narrow decoy in `claim` and the real, broader ask in `claims` — openvc + authorized the decoy while the wallet, following DCQL, answers `claims`, and unknown + query members are ignored downstream so the escalating query stayed valid end to end. + The request side now takes the **union** of both spellings; the registration side + keeps precedence, since only there does a second spelling risk *widening* a grant. + Also closed: a bare `OverflowError` escaping the `OpenvcError` family on a bignum + `iat`/`exp`/`nbf` (`math.isfinite` casts to float, and `json.loads` yields such ints + from the wire); an empty registered `{"path": []}` acting as a blanket grant over the + whole credential (an empty tuple is a prefix of everything); a non-object `meta` being + coerced to `{}`, which turned a malformed *constraint* into *no* constraint and + widened the entry to every credential of that format; `meta` matching conflating + `True` with `1`; and an entitlement floor that accepted any non-empty string while + `ENTITLEMENT_URI_PREFIX` sat exported-but-unused — it now checks the clause-A.2 + namespace GEN-5.2.4-03 actually requires. The binding check, the algorithm allow-list, + the chain handling and the CWT parser were probed and held. ([#89](https://github.com/luisgf/openvc/issues/89)) ### Fixed diff --git a/src/openvc/rp_registration.py b/src/openvc/rp_registration.py index 3e6b9de..f0d9c5f 100644 --- a/src/openvc/rp_registration.py +++ b/src/openvc/rp_registration.py @@ -140,9 +140,17 @@ class RequestableCredential: ``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.""" + 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] + meta: Mapping[str, Any] | None claim_paths: tuple[tuple[Any, ...], ...] raw: Mapping[str, Any] @@ -274,7 +282,14 @@ def _numeric_date(value: Any, *, field: str) -> datetime | None: if value is None: return None - if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise RpRegistrationError( + f"WRPRC {field} must be a finite NumericDate, got {value!r}") + try: + finite = math.isfinite(value) + except OverflowError: # a bignum int: `json.loads` yields one for + finite = False # `1e400`-style literals, and isfinite casts + if not finite: # to float — so this must not escape untyped raise RpRegistrationError( f"WRPRC {field} must be a finite NumericDate, got {value!r}") try: @@ -283,19 +298,10 @@ def _numeric_date(value: Any, *, field: str) -> datetime | None: raise RpRegistrationError(f"WRPRC {field} {value!r} is out of range") from exc -def _claim_paths(entry: Mapping[str, Any]) -> tuple[tuple[Any, ...], ...]: - """The claim paths of one ``credentials`` entry. - - TS 119 475 names the member ``claim`` (singular, as in Annex C); the DCQL shape it - mirrors (OpenID4VP 1.0) names it ``claims``. Both spellings are read — on the - registration side so a producer following either does not silently register - *nothing*, and on the request side so a query is never under-read into looking - narrower than it is.""" - raw = entry.get("claim") - if raw is None: - raw = entry.get("claims") +def _paths_under(entry: Mapping[str, Any], key: str) -> list[tuple[Any, ...]]: + raw = entry.get(key) if not isinstance(raw, (list, tuple)): - return () + return [] paths: list[tuple[Any, ...]] = [] for item in raw: if not isinstance(item, Mapping): @@ -305,7 +311,32 @@ def _claim_paths(entry: Mapping[str, Any]) -> tuple[tuple[Any, ...], ...]: paths.append(tuple(path)) elif isinstance(path, str): # B.2.10 types `path` as a string paths.append((path,)) - return tuple(paths) + return paths + + +def _registered_paths(entry: Mapping[str, Any]) -> tuple[tuple[Any, ...], ...]: + """The claim paths one ``credentials`` entry **grants**. + + TS 119 475 names the member ``claim`` (singular, as in Annex C); the DCQL shape it + mirrors (OpenID4VP 1.0) names it ``claims``. On this side the spec's spelling wins + and the other is only a fallback: taking one list rather than the union of both + means a second spelling can never *widen* a grant.""" + return tuple(_paths_under(entry, "claim") or _paths_under(entry, "claims")) + + +def _requested_paths(query: Mapping[str, Any]) -> tuple[tuple[Any, ...], ...]: + """The claim paths one DCQL credential query **asks for** — the **union** of both + spellings. + + Precedence would be a scope-escalation wedge here, and in the opposite direction to + the registration side. A relying party that puts a narrow decoy in ``claim`` and the + real request in ``claims`` gets authorized against the decoy while the wallet, which + follows DCQL, answers ``claims`` (unknown query members are ignored downstream, so + the escalating query stays valid end to end). Requiring *every* path under *either* + spelling to be registered is the only reading that cannot be gamed.""" + seen = _paths_under(query, "claims") + seen.extend(p for p in _paths_under(query, "claim") if p not in seen) + return tuple(seen) def _requestable(value: Any) -> tuple[RequestableCredential, ...]: @@ -315,10 +346,11 @@ def _requestable(value: Any) -> tuple[RequestableCredential, ...]: for entry in value: if not isinstance(entry, Mapping): continue + raw_meta = entry.get("meta") out.append(RequestableCredential( format=_str_or_none(entry.get("format")), - meta=_mapping_or_none(entry.get("meta")) or {}, - claim_paths=_claim_paths(entry), + meta={} if raw_meta is None else _mapping_or_none(raw_meta), + claim_paths=_registered_paths(entry), raw=entry, )) return tuple(out) @@ -608,13 +640,17 @@ def verify_rp_registration_certificate( 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, applied only when ``exp`` - is present. ``None`` disables it. + * *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 registered entitlement; - a WRPRC without one authorizes nothing anyway. + * *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 @@ -680,10 +716,12 @@ def verify_rp_registration_certificate( 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 reg.entitlements: + if require_entitlement and not any( + e.startswith(ENTITLEMENT_URI_PREFIX) for e in reg.entitlements): raise RpRegistrationError( - "WRPRC registers no entitlements (TS 119 475 GEN-5.2.4-03 requires at least " - "one) — it authorizes nothing") + 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 @@ -804,8 +842,14 @@ def _path_covered(registered: tuple[Any, ...], requested: Sequence[Any]) -> bool the whole object — so this is not a widening. ``None`` (the DCQL array wildcard) in the *registered* path matches any element; a ``None`` in the *requested* path is only covered by a registered ``None``, so registering index ``2`` never grants - "every element".""" - if len(registered) > len(requested): + "every element". + + An **empty** registered path grants nothing. It is a prefix of every path, so + without this guard a single malformed ``{"path": []}`` entry would silently become a + blanket grant over the whole credential — the exact opposite of the fail-closed + reading the rest of this module states. Neither DCQL nor TS 119 475 gives an empty + path that meaning.""" + if not registered or len(registered) > len(requested): return False for reg_el, req_el in zip(registered, requested): if reg_el is None: @@ -817,7 +861,7 @@ def _path_covered(registered: tuple[Any, ...], requested: Sequence[Any]) -> bool return True -def _meta_covered(registered: Mapping[str, Any], requested: Mapping[str, Any]) -> bool: +def _meta_covered(registered: Mapping[str, Any] | None, requested: Mapping[str, Any]) -> bool: """Whether a registered ``meta`` covers a requested one. Every constraint the request carries must be present in the registration and no @@ -831,7 +875,12 @@ def _meta_covered(registered: Mapping[str, Any], requested: Mapping[str, Any]) - Subset testing is deliberately done with ``in`` (equality) rather than by building ``set``s: a ``meta`` value is attacker-influenced JSON and may hold unhashable members (an object, an array), which would make the set construction raise a bare - ``TypeError`` straight past this library's error family.""" + ``TypeError`` straight past this library's error family. + + A ``registered`` of ``None`` — the entry carried a ``meta`` that is not an object — + covers nothing (see :class:`RequestableCredential`).""" + if registered is None: + return False if not requested: return not registered for key, want in requested.items(): @@ -839,16 +888,24 @@ def _meta_covered(registered: Mapping[str, Any], requested: Mapping[str, Any]) - return False have = registered[key] if isinstance(want, (list, tuple)): - if not isinstance(have, (list, tuple)) or not all(w in have for w in want): + if not isinstance(have, (list, tuple)) or not all( + any(_same(w, h) for h in have) for w in want): return False elif isinstance(have, (list, tuple)): - if want not in have: + if not any(_same(want, h) for h in have): return False - elif want != have: + elif not _same(want, have): return False return True +def _same(a: Any, b: Any) -> bool: + """Equality that does not conflate ``True`` with ``1`` — Python's ``==`` does, so + without this a registered ``1`` would satisfy a requested ``True``. Mirrors the + guard :func:`_path_covered` applies to path elements, so the two agree.""" + return a == b and isinstance(a, bool) is isinstance(b, bool) + + def check_request_within_registration( registration: RelyingPartyRegistrationCertificate, dcql_query: Mapping[str, Any], @@ -906,7 +963,7 @@ def check_request_within_registration( f"credential query {label!r} asks for format {fmt!r} with meta " f"{dict(want_meta)!r}, which this WRPRC does not register") - wanted = _claim_paths(query) + wanted = _requested_paths(query) if not wanted: if any(not c.claim_paths for c in candidates): continue # unrestricted request, unrestricted grant diff --git a/tests/test_rp_registration.py b/tests/test_rp_registration.py index 7520d61..be30559 100644 --- a/tests/test_rp_registration.py +++ b/tests/test_rp_registration.py @@ -858,3 +858,110 @@ def test_a_request_spelling_claims_either_way_is_read(registrar): def test_malformed_dcql_queries_are_typed_errors(query): with pytest.raises(RpRegistrationError): check_request_within_registration(_reg(), query) + + +# --------------------------------------------------------------------------- # +# adversarial-review regressions (issue #89) +# --------------------------------------------------------------------------- # + +def test_a_decoy_claim_key_cannot_narrow_a_request(): + # CRITICAL. `_claim_paths` read `claim` first and fell back to `claims`. That is + # right for the registration side (the spec's spelling), but applied to a *request* + # it let a relying party put a narrow decoy in `claim` and the real, broader ask in + # `claims`: openvc authorized the decoy while the wallet — which follows DCQL — + # answers `claims`. Unknown query members are ignored downstream, so the escalating + # query stayed valid end to end. The request side now takes the union of both. + with pytest.raises(RpRegistrationError, match="birth_date"): + check_request_within_registration(_reg(), _dcql({ + **_PID, + "claim": [{"path": ["age_equal_or_over", "18"]}], # the decoy + "claims": [{"path": ["birth_date"]}, {"path": ["family_name"]}]})) + + +def test_a_request_using_only_the_spec_spelling_is_still_read(): + # The union must not lose the singular spelling either. + check_request_within_registration( + _reg(), _dcql({**_PID, "claim": [{"path": ["address"]}]})) + with pytest.raises(RpRegistrationError, match="claim path"): + check_request_within_registration( + _reg(), _dcql({**_PID, "claim": [{"path": ["nationality"]}]})) + + +def test_a_registration_grant_is_not_widened_by_a_second_spelling(): + # Precedence on the registration side is the fail-closed direction: one list, never + # the union, so a second spelling cannot add to a grant. + reg = _reg(credentials=[{ + "format": "dc+sd-jwt", "meta": {}, + "claim": [{"path": ["name"]}], + "claims": [{"path": ["ssn"]}]}]) + with pytest.raises(RpRegistrationError, match="claim path"): + check_request_within_registration(reg, _dcql({ + "id": "d", "format": "dc+sd-jwt", "claims": [{"path": ["ssn"]}]})) + + +@pytest.mark.parametrize("field", ["iat", "exp", "nbf"]) +def test_a_bignum_numericdate_is_a_typed_error_not_an_overflow(registrar, field): + # HIGH. `math.isfinite` casts to float, so a `10**400` literal — which json.loads + # happily yields — raised a bare OverflowError straight past the OpenvcError family. + root, chain, key = registrar + token = jwt_wrprc(claims(**{field: 10 ** 400}), chain, key) + with pytest.raises(RpRegistrationError, match=field): + verify_rp_registration_certificate(token, trust_anchors=[root], now=_AT) + + +def test_an_empty_registered_path_grants_nothing(): + # MEDIUM. An empty tuple is a prefix of every path, so `{"path": []}` silently + # became a blanket grant over the whole credential. + reg = _reg(credentials=[{"format": "dc+sd-jwt", "meta": {"vct_values": ["v"]}, + "claim": [{"path": []}]}]) + for wanted in (["ssn"], ["biometric_template"], ["address", "locality"]): + with pytest.raises(RpRegistrationError, match="claim path"): + check_request_within_registration(reg, _dcql({ + "id": "d", "format": "dc+sd-jwt", "meta": {"vct_values": ["v"]}, + "claims": [{"path": wanted}]})) + + +@pytest.mark.parametrize("bad_meta", ["urn:eudi:pid:1", 42, ["urn:eudi:pid:1"], True]) +def test_a_malformed_meta_matches_nothing_rather_than_everything(bad_meta): + # MEDIUM. Coercing a non-object `meta` to `{}` turned a malformed *constraint* into + # *no* constraint — the entry then matched the broadest possible request (no `meta` + # at all = any credential of that format) while denying the one it plainly meant. + reg = _reg(credentials=[{"format": "dc+sd-jwt", "meta": bad_meta, + "claim": [{"path": ["address"]}]}]) + assert reg.credentials[0].meta is None + for query in ({"id": "p", "format": "dc+sd-jwt", "claims": [{"path": ["address"]}]}, + {"id": "p", "format": "dc+sd-jwt", "meta": {"vct_values": ["x"]}, + "claims": [{"path": ["address"]}]}): + with pytest.raises(RpRegistrationError, match="does not register"): + check_request_within_registration(reg, _dcql(query)) + + +def test_an_absent_meta_still_means_unconstrained(): + # ...and the distinction is preserved: absent `meta` is not malformed `meta`. + reg = _reg(credentials=[{"format": "dc+sd-jwt", "claim": [{"path": ["address"]}]}]) + assert reg.credentials[0].meta == {} + check_request_within_registration(reg, _dcql({ + "id": "p", "format": "dc+sd-jwt", "claims": [{"path": ["address"]}]})) + + +def test_meta_does_not_conflate_booleans_with_integers(): + # LOW. `True == 1` in Python; `_meta_covered` lacked the guard `_path_covered` has. + reg = _reg(credentials=[{"format": "dc+sd-jwt", "meta": {"k": [1]}, + "claim": [{"path": ["a"]}]}]) + with pytest.raises(RpRegistrationError, match="does not register"): + check_request_within_registration(reg, _dcql({ + "id": "d", "format": "dc+sd-jwt", "meta": {"k": [True]}, + "claims": [{"path": ["a"]}]})) + + +def test_the_entitlement_floor_checks_the_etsi_namespace(registrar): + # LOW. The floor was "≥1 non-empty string", so any junk URI satisfied it while + # ENTITLEMENT_URI_PREFIX sat exported-but-unused, reading like a check that existed. + # GEN-5.2.4-03 requires one from clause A.2. + root, chain, key = registrar + token = jwt_wrprc(claims(entitlements=["urn:attacker:whatever"]), chain, key) + with pytest.raises(RpRegistrationError, match="ENTITLEMENT|uri.etsi.org"): + verify_rp_registration_certificate(token, trust_anchors=[root], now=_AT) + assert verify_rp_registration_certificate( + token, trust_anchors=[root], now=_AT, require_entitlement=False, + ).entitlements == ("urn:attacker:whatever",)