Skip to content

Commit 4b944df

Browse files
committed
feat(eudi): declare key attestations in the OpenID4VCI configuration
badge_credential_configuration() emitted a proof_types_supported.jwt object carrying proof_signing_alg_values_supported and nothing else, so an issuer that requires a wallet key attestation had no way to advertise it and had to patch the returned dict — the hand-written, drifting copy this function exists to prevent. A downstream OpenID4VCI issuer that delegates the OB 3.0 entry to us reported its wallet library rejecting the whole /.well-known/openid-credential-issuer document over the missing member. key_attestations_required= now takes that object: {} for "required, no additional constraints", or the OpenID4VCI 1.0 D.2 resistance values the issuer accepts under key_storage / user_authentication. The default stays omitted, and that default is normative rather than conservative. Section 12.2.4 says the parameter "MUST NOT be present in the metadata" unless the issuer requires an attestation, and an empty object is not a neutral placeholder — it is the positive claim that one is needed without additional constraints. Emitting it unasked would have every badge issuer declare it demands a hardware-backed attestation from every holder, locking out the software wallets that cannot produce one; Open Badges are not high-assurance identity credentials and the library must not say otherwise on the issuer's behalf. HAIP requires wallets to support key attestations (4.5.1); it does not require issuers to demand them, and Keycloak fixed the same interop report by omitting the field (keycloak/keycloak#44736). Callers who pass nothing get the same bytes as v4.1.0, verified against that tag. The value is validated and defensively copied, following _status_claim: EudiError on a non-mapping, a key other than the two D.2 defines, an empty array where the spec says non-empty, a bare string where an array belongs (list("iso_18045_moderate") is otherwise a list of letters), or a non-string level. The resistance values themselves are not checked — D.2 lets each ecosystem define its own. Out of scope, unchanged: verifying a key attestation an incoming Credential Request carries (the #272 proof path, not metadata), and the enclosing metadata document, which stays deployment policy. Closes #276. Refs #271, #272.
1 parent e8e7130 commit 4b944df

4 files changed

Lines changed: 177 additions & 4 deletions

File tree

‎Changelog.txt‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,27 @@ OpenBadgesLib - Changelog
44
Newest first. Dates are ISO 8601 (YYYY-MM-DD).
55

66

7+
* v4.2.0 - unreleased
8+
9+
The OpenID4VCI credential configuration can declare key attestations.
10+
11+
- feat(eudi): `badge_credential_configuration()` takes
12+
`key_attestations_required=`, so an issuer that requires a wallet key
13+
attestation can advertise it — `{}` for "required, no additional
14+
constraints", or the OpenID4VCI 1.0 D.2 resistance values it accepts under
15+
`key_storage` / `user_authentication`. The default stays **omitted**, and
16+
that default is normative rather than conservative: §12.2.4 says the
17+
parameter "MUST NOT be present in the metadata" unless the issuer requires an
18+
attestation, and an empty object is not a neutral placeholder but the
19+
positive claim that one is needed — emitting it unasked would have every
20+
badge issuer demand a hardware-backed attestation and lock out the software
21+
wallets that cannot produce one. Callers who pass nothing get the same bytes
22+
as before. The shape is validated (`EudiError` on an unknown key, an empty
23+
array where the spec says non-empty, a bare string where an array belongs, or
24+
a non-string level) and defensively copied; the resistance values are not
25+
checked, since D.2 lets each ecosystem define its own (#276).
26+
27+
728
* v4.1.0 - 2026-07-27
829

930
The EUDI SD-JWT VC track becomes revocable (IETF Token Status List) and

‎openbadgeslib/ob3/eudi.py‎

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -275,12 +275,61 @@ def _status_claim(status: Mapping[str, Any]) -> dict[str, Any]:
275275
("credentialSubject",): "Recipient",
276276
}
277277

278+
# The only two constraints OpenID4VCI 1.0 Appendix D.2 defines inside
279+
# ``key_attestations_required``. The *values* are deliberately extensible there
280+
# ("ecosystems may define their own values"), so only the shape is checked here,
281+
# never the level strings themselves.
282+
_KEY_ATTESTATION_CONSTRAINTS = ("key_storage", "user_authentication")
283+
284+
285+
def _key_attestations_required(value: Mapping[str, Any]) -> dict[str, Any]:
286+
"""Validate and copy a ``key_attestations_required`` object.
287+
288+
Empty is legal and meaningful — OpenID4VCI 1.0 §12.2.4: with both
289+
``key_storage`` and ``user_authentication`` absent the object indicates "a key
290+
attestation is needed without additional constraints". What is rejected is the
291+
shape a wallet cannot act on: an unknown key, an empty array where the spec
292+
says non-empty, or a bare string where an array belongs — that last one is
293+
silent otherwise, since ``list("iso_18045_moderate")`` is a list of letters.
294+
"""
295+
if not isinstance(value, Mapping):
296+
raise EudiError(
297+
"key_attestations_required must be a mapping (use {} for 'required, "
298+
"no additional constraints'), got %s" % type(value).__name__)
299+
unknown = sorted(k for k in value if k not in _KEY_ATTESTATION_CONSTRAINTS)
300+
if unknown:
301+
raise EudiError(
302+
"unknown key_attestations_required key(s) %s; OpenID4VCI 1.0 D.2 "
303+
"defines only %s" % (", ".join(unknown),
304+
", ".join(_KEY_ATTESTATION_CONSTRAINTS)))
305+
required: dict[str, Any] = {}
306+
for name in _KEY_ATTESTATION_CONSTRAINTS:
307+
if name not in value:
308+
continue
309+
levels = value[name]
310+
if isinstance(levels, (str, bytes)) or not isinstance(levels, (list, tuple)):
311+
raise EudiError(
312+
"key_attestations_required.%s must be a list of attack potential "
313+
"resistance values, got %s" % (name, type(levels).__name__))
314+
if not levels:
315+
raise EudiError(
316+
"key_attestations_required.%s must be a non-empty array; omit the "
317+
"key entirely to leave it unconstrained" % name)
318+
for level in levels:
319+
if not isinstance(level, str):
320+
raise EudiError(
321+
"key_attestations_required.%s values must be strings, got %s"
322+
% (name, type(level).__name__))
323+
required[name] = list(levels)
324+
return required
325+
278326

279327
def badge_credential_configuration(
280328
*,
281329
vct: str = OB3_SD_JWT_VCT,
282330
signing_alg_values: Iterable[str] = ("ES256",),
283331
proof_signing_alg_values: Optional[Iterable[str]] = None,
332+
key_attestations_required: Optional[Mapping[str, Any]] = None,
284333
cryptographic_binding_methods: Iterable[str] = ("jwk",),
285334
display: Optional[Iterable[Mapping[str, Any]]] = None,
286335
scope: Optional[str] = None,
@@ -293,7 +342,8 @@ def badge_credential_configuration(
293342
``credential_signing_alg_values_supported``,
294343
``cryptographic_binding_methods_supported``, ``proof_types_supported``
295344
(``jwt``, whose ``proof_signing_alg_values_supported`` defaults to
296-
*signing_alg_values*), ``claims`` and ``display``, plus ``scope`` when given.
345+
*signing_alg_values*), ``claims`` and ``display``, plus ``scope`` and the
346+
proof type's ``key_attestations_required`` when given.
297347
Put it in your metadata document under the ``credential_configuration_id`` you
298348
want wallets to request::
299349
@@ -313,6 +363,18 @@ def badge_credential_configuration(
313363
passes its own name, logo and locales. *signing_alg_values* defaults to
314364
``ES256``, the algorithm HAIP pins for EUDI wallets.
315365
366+
*key_attestations_required* is omitted by default, and that default is
367+
normative rather than conservative: OpenID4VCI 1.0 §12.2.4 says the parameter
368+
"MUST NOT be present in the metadata" unless the issuer requires a key
369+
attestation, and an **empty** object is not a neutral placeholder — it is the
370+
positive claim "a key attestation is needed without additional constraints".
371+
Emitting it unasked would have every badge issuer demand a hardware-backed
372+
attestation from every holder and lock out software wallets. An issuer that
373+
does require one passes ``{}``, or ``{"key_storage": [...],
374+
"user_authentication": [...]}`` with the Appendix D.2 resistance values it
375+
accepts; the shape is validated (:class:`EudiError`), the values are not —
376+
D.2 lets each ecosystem define its own.
377+
316378
Two things this deliberately does NOT do. It does not build the enclosing
317379
``/.well-known/openid-credential-issuer`` document — ``credential_issuer``,
318380
the endpoints, the authorization servers and per-tenant display are
@@ -329,6 +391,10 @@ def badge_credential_configuration(
329391
"""
330392
algs = list(signing_alg_values)
331393
proof_algs = list(proof_signing_alg_values) if proof_signing_alg_values is not None else algs
394+
proof_jwt: dict[str, Any] = {"proof_signing_alg_values_supported": proof_algs}
395+
if key_attestations_required is not None:
396+
proof_jwt["key_attestations_required"] = _key_attestations_required(
397+
key_attestations_required)
332398
claims: list[dict[str, Any]] = []
333399
for claim in badge_type_metadata(vct)["claims"]:
334400
path = list(claim["path"])
@@ -345,9 +411,7 @@ def badge_credential_configuration(
345411
"vct": vct,
346412
"credential_signing_alg_values_supported": algs,
347413
"cryptographic_binding_methods_supported": list(cryptographic_binding_methods),
348-
"proof_types_supported": {
349-
"jwt": {"proof_signing_alg_values_supported": proof_algs},
350-
},
414+
"proof_types_supported": {"jwt": proof_jwt},
351415
"claims": claims,
352416
"display": ([dict(d) for d in display] if display is not None
353417
else [{"name": "Open Badge 3.0", "locale": locale}]),

‎tests/test_ob3_eudi_oid4vci.py‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
DEFAULT_DISCLOSABLE,
1313
OB3_SD_JWT_FORMAT,
1414
OB3_SD_JWT_VCT,
15+
EudiError,
1516
badge_credential_configuration,
1617
badge_to_sd_jwt_claims,
1718
badge_type_metadata,
@@ -29,6 +30,8 @@ def test_defaults(self):
2930
"proof_signing_alg_values_supported"] == ["ES256"]
3031
assert cfg["display"] == [{"name": "Open Badge 3.0", "locale": "en"}]
3132
assert "scope" not in cfg # only when asked for
33+
assert "key_attestations_required" not in cfg[
34+
"proof_types_supported"]["jwt"] # ditto — see below
3235

3336
def test_caller_overrides(self):
3437
cfg = badge_credential_configuration(
@@ -57,6 +60,74 @@ def test_is_json_serializable(self):
5760
json.loads(json.dumps(badge_credential_configuration()))
5861

5962

63+
class TestKeyAttestationsRequired:
64+
"""An empty ``key_attestations_required`` is not a placeholder: OpenID4VCI
65+
1.0 §12.2.4 makes it the claim "a key attestation is needed without
66+
additional constraints", and says the parameter MUST NOT be present at all
67+
unless the issuer requires one. So the default is absence, and asking for it
68+
is explicit."""
69+
70+
def _jwt(self, **kwargs):
71+
return badge_credential_configuration(**kwargs)["proof_types_supported"]["jwt"]
72+
73+
def test_absent_by_default(self):
74+
# Do not "fix" a strict wallet by emitting {} here: that would declare
75+
# every badge issuer demands a key attestation, locking out the software
76+
# wallets that cannot produce one.
77+
assert "key_attestations_required" not in self._jwt()
78+
79+
def test_empty_object_means_required_without_constraints(self):
80+
assert self._jwt(key_attestations_required={})[
81+
"key_attestations_required"] == {}
82+
83+
def test_constraints_are_emitted(self):
84+
jwt = self._jwt(key_attestations_required={
85+
"key_storage": ["iso_18045_moderate"],
86+
"user_authentication": ["iso_18045_moderate", "iso_18045_high"]})
87+
assert jwt["key_attestations_required"] == {
88+
"key_storage": ["iso_18045_moderate"],
89+
"user_authentication": ["iso_18045_moderate", "iso_18045_high"]}
90+
assert jwt["proof_signing_alg_values_supported"] == ["ES256"]
91+
92+
def test_is_copied_not_aliased(self):
93+
mine = {"key_storage": ["iso_18045_moderate"]}
94+
emitted = self._jwt(key_attestations_required=mine)[
95+
"key_attestations_required"]
96+
emitted["user_authentication"] = ["iso_18045_high"]
97+
emitted["key_storage"].append("iso_18045_basic")
98+
assert mine == {"key_storage": ["iso_18045_moderate"]}
99+
100+
def test_is_json_serializable(self):
101+
import json
102+
json.loads(json.dumps(badge_credential_configuration(
103+
key_attestations_required={"key_storage": ["iso_18045_moderate"]})))
104+
105+
def test_unknown_key_is_rejected(self):
106+
with pytest.raises(EudiError):
107+
self._jwt(key_attestations_required={
108+
"key_storag": ["iso_18045_moderate"]})
109+
110+
def test_empty_array_is_rejected(self):
111+
# The spec says "non-empty array"; omitting the key is how you say
112+
# "unconstrained".
113+
with pytest.raises(EudiError):
114+
self._jwt(key_attestations_required={"key_storage": []})
115+
116+
def test_bare_string_is_rejected(self):
117+
# Left alone this would be emitted as a list of single letters.
118+
with pytest.raises(EudiError):
119+
self._jwt(key_attestations_required={
120+
"user_authentication": "iso_18045_moderate"})
121+
122+
def test_non_string_level_is_rejected(self):
123+
with pytest.raises(EudiError):
124+
self._jwt(key_attestations_required={"key_storage": [4]})
125+
126+
def test_non_mapping_is_rejected(self):
127+
with pytest.raises(EudiError):
128+
self._jwt(key_attestations_required=["iso_18045_moderate"])
129+
130+
60131
class TestNoDrift:
61132
"""The reason this function exists: a hand-written copy gets the vct and the
62133
claim set wrong, and a wallet then either rejects the credential or displays

‎wiki/Python-API-OB3.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,23 @@ metadata = { # your document, your policy
374374

375375
The entry's `vct` is the one `issue_badge_sd_jwt()` actually stamps and its `claims` are derived from the same `badge_type_metadata()` document you serve at that `vct` — the two artifacts cannot drift, which is the failure this prevents (a wallet that rejects the credential, or displays nothing). `credential_signing_alg_values_supported` defaults to `ES256`, the HAIP algorithm for EUDI wallets; `cryptographic_binding_methods_supported` to `jwk`, matching the `holder_jwk=` key binding above. Pure-Python: no `[eudi]` extra needed.
376376

377+
#### Key attestations
378+
379+
The `jwt` proof type can also declare `key_attestations_required` — what the issuer expects a wallet to prove about where it keeps the holder's key. It is **omitted by default, and that is the normative default**, not a conservative one. OpenID4VCI 1.0 §12.2.4: *"If the Credential Issuer does not require a key attestation, this parameter MUST NOT be present in the metadata."* An empty object is not a neutral placeholder either — it is the positive claim *"a key attestation is needed without additional constraints"*. So do not add it to satisfy a strict wallet parser: you would be telling every holder that your badge demands a hardware-backed attestation, and the software wallets that cannot produce one would be locked out of an ordinary Open Badge.
380+
381+
An issuer that genuinely requires one asks for it, either bare or with the Appendix D.2 resistance values it accepts:
382+
383+
```python
384+
badge_credential_configuration(key_attestations_required={}) # required, unconstrained
385+
386+
badge_credential_configuration(key_attestations_required={
387+
'key_storage': ['iso_18045_moderate'],
388+
'user_authentication': ['iso_18045_moderate'],
389+
})
390+
```
391+
392+
The shape is validated — an unknown key, an empty array (the spec says *non-empty*), a bare string where an array belongs, or a non-string level all raise `EudiError` at build time rather than at a holder's wallet. The resistance *values* are not checked: D.2 defines `iso_18045_high` / `iso_18045_moderate` / `iso_18045_enhanced-basic` / `iso_18045_basic` but lets each ecosystem define its own. Note that HAIP requires *wallets* to support key attestations (§4.5.1); it does not require issuers to demand them.
393+
377394
**Out of scope on purpose:** the enclosing `/.well-known/openid-credential-issuer` document (endpoints, authorization servers, per-tenant display — deployment policy, and one document per tenant in a multi-tenant issuer), the Credential Offer, the Credential Response and the nonce store. The protocol endpoints are the application's; this library supplies the format knowledge. Note that OpenID4VCI 1.0 Final carries `claims`/`display` at the top level of the configuration, which is what this emits; the EU reference issuer nests them under `credential_metadata` — nest them yourself if you must match that deployment.
378395

379396
### Verifying a third-party badge via eIDAS X.509 / EU Trusted Lists

0 commit comments

Comments
 (0)