Skip to content

feat(rp-registration): parse and verify EUDI WRPRC (ETSI TS 119 475)#133

Merged
luisgf merged 3 commits into
mainfrom
feat/wrprc
Jul 19, 2026
Merged

feat(rp-registration): parse and verify EUDI WRPRC (ETSI TS 119 475)#133
luisgf merged 3 commits into
mainfrom
feat/wrprc

Conversation

@luisgf

@luisgf luisgf commented Jul 19, 2026

Copy link
Copy Markdown
Owner

feat(rp-registration): parse and verify EUDI WRPRC (ETSI TS 119 475)

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

luisgf added 3 commits July 19, 2026 12:02
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
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.
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.
@luisgf
luisgf merged commit ddddfe8 into main Jul 19, 2026
10 checks passed
@luisgf
luisgf deleted the feat/wrprc branch July 19, 2026 10:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: parse EUDI relying-party registration certificate (WRPRC) — JWT/CWT entitlements (ETSI TS 119 475)

1 participant