[java-idp-plugin-vci] branch main updated: Initial mdoc support
Codeberg
noreply at shibboleth.net
Thu Oct 1 16:14:13 UTC 2026
This is an automated email from the git hooks/post-receive script.
codeberg pushed a commit to branch main
in repository java-idp-plugin-vci.
View the commit online:
https://codeberg.org/Shibboleth/java-idp-plugin-vci/commit/99e662d4fdec45e56c88517ae6b652288e127834
The following commit(s) were added to refs/heads/main by this push:
new 99e662d Initial mdoc support
99e662d is described below
commit 99e662d4fdec45e56c88517ae6b652288e127834
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Thu Oct 1 19:13:48 2026 +0300
Initial mdoc support
---
README.md | 370 ++++++++++++++-------
...eth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz | Bin 403161 -> 443000 bytes
...idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz.asc | 22 +-
.../plugin/openidvci/credential/ClaimType.java | 105 ++++++
.../credential/CredentialConfiguration.java | 51 ++-
.../CredentialIssuanceConfiguration.java | 22 +-
.../statuslist/context/StatusListTokenContext.java | 23 ++
.../MdocCredentialConfigurationTest.java | 77 +++++
.../plugin/openidvci/cose/impl/CborSupport.java | 196 +++++++++++
.../plugin/openidvci/cose/impl/CborTag.java | 62 ++++
.../plugin/openidvci/cose/impl/CoseAlgorithms.java | 96 ++++++
.../plugin/openidvci/cose/impl/CoseKeySupport.java | 108 ++++++
.../openidvci/cose/impl/CoseSign1Signer.java | 260 +++++++++++++++
.../plugin/openidvci/cose/impl/package-info.java | 20 ++
.../openidvci/credential/impl/MdocElements.java | 202 +++++++++++
.../credential/impl/MobileSecurityObject.java | 268 +++++++++++++++
.../impl/SelectiveDisclosureClaimSetUtil.java | 19 +-
.../messaging/context/CredentialsContext.java | 24 ++
...ignatureSigningConfigurationLookupFunction.java | 7 +-
.../impl/AddClaimsToMobileSecurityObject.java | 123 +++++++
.../profile/impl/AddMobileSecurityObjectShell.java | 187 +++++++++++
.../profile/impl/SignMobileSecurityObject.java | 163 +++++++++
.../profile/logic/CredentialFormatPredicate.java | 61 ++++
.../logic/StatusListCwtRequestedPredicate.java | 70 ++++
.../impl/StatusListCwtResponseEncoder.java | 49 +++
.../impl/StatusListResponseEncoderFactory.java | 85 +++++
.../statuslist/encoding/impl/package-info.java | 20 ++
.../messaging/impl/StatusListCwtResponse.java | 56 ++++
.../FormOutboundStatusListResponseMessage.java | 9 +-
.../profile/impl/SignStatusListToken.java | 55 +++
.../statuslist/profile/impl/StatusListCwt.java | 113 +++++++
.../flows/oauth/status-list/list/list-beans.xml | 30 +-
.../idp/flows/oauth/status-list/list/list-flow.xml | 14 +
.../openid/vci/credentials/credentials-beans.xml | 24 +-
.../openid/vci/credentials/credentials-flow.xml | 3 +
.../openidvci/cose/impl/CborSupportTest.java | 92 +++++
.../plugin/openidvci/cose/impl/CborTestReader.java | 92 +++++
.../openidvci/cose/impl/CoseSign1SignerTest.java | 173 ++++++++++
.../credential/impl/MdocElementsTest.java | 87 +++++
.../credential/impl/MobileSecurityObjectTest.java | 116 +++++++
.../logic/StatusListCwtRequestedPredicateTest.java | 44 +++
.../statuslist/profile/impl/StatusListCwtTest.java | 53 +++
42 files changed, 3503 insertions(+), 148 deletions(-)
diff --git a/README.md b/README.md
index 9cb425b..7ad72a3 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@
- [Step 2: Add a did:web credential](#step-2-add-a-did-web-credential)
- [Step 3: Add a plain url credential](#step-3-add-a-plain-url-credential)
- [Step 4: Make the url credential an OpenID Federation credential](#step-4-make-the-url-credential-an-openid-federation-credential)
- - [Step 5: Add a HAIP credential](#step-5-add-a-haip-credential)
+ - [Step 5: Add HAIP credentials](#step-5-add-haip-credentials)
- [Step 6: Rolling the signing key](#step-6-rolling-the-signing-key)
- [What we have now](#what-we-have-now)
- [Requesting a credential by configuration or by identifier](#requesting-a-credential-by-configuration-or-by-identifier)
@@ -62,14 +62,14 @@ Both issuance flows are implemented:
where the wallet makes a normal OAuth2 authorization request and the credential
content comes in an IdP attribute
-Two credential formats are issued, `dc+sd-jwt` (IETF SD-JWT VC) and `vc+sd-jwt` (W3C VCDM 2.0
-enveloped in SD-JWT). Both do selective disclosure. Revocation is published as
-[IETF Token Status List](https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/).
+Three credential formats are issued, `dc+sd-jwt` (IETF SD-JWT VC), `vc+sd-jwt` (W3C VCDM 2.0
+enveloped in SD-JWT) and `mso_mdoc` (ISO mdoc). All three do selective disclosure. Revocation is
+published as [IETF Token Status List](https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/).
Things you need to know about this add-on:
- It is not certified. Do not deploy it in production.
-- Credentials are signed with EC keys only. The signing actions do not support anything else.
+- Credentials are signed with EC keys only.
## Capabilities
@@ -78,77 +78,78 @@ limitations, **No** is not built and **No, by design** is left out on purpose.
### [OpenID for Verifiable Credential Issuance 1.0](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html)
-This is the base. The profile tables below list only what the profile requires in addition to it.
-Endpoints, metadata and error responses the specification mandates are not listed.
+Optional features of the specification that the profiles below do not name.
| Feature | Status |
|---|---|
-| Pre-Authorized Code Flow | Yes |
-| Transaction Code, `tx_code` | Yes |
-| Authorization Code Flow | Yes |
-| Credential Offer by value | Yes |
| Credential Offer by reference, `credential_offer_uri` | No |
| `issuer_state` | No, by design |
-| Separate Authorization Server | No, by design |
-| Authorization request with `scope` | Yes |
-| Authorization request with `authorization_details` | Partial |
-| PAR and PKCE | Yes |
-| Refresh tokens | No |
-| DPoP | Yes |
-| Wallet Attestation, `attest_jwt_client_auth` | Yes |
-| Nonce endpoint | Yes |
| Credential request by `credential_configuration_id` or `credential_identifier` | Yes |
-| Proof type `jwt` | Yes |
-| Key attestation in a `jwt` proof, `key_attestations_required` | Yes |
-| Proof types `di_vp` and `attestation` | No |
-| Batch issuance | Yes |
+| Proof type `di_vp` | No |
| Credential Response encryption | No |
| Credential Request encryption | No |
| Deferred Credential Endpoint | No |
| Notification Endpoint | No |
-| Signed Credential Issuer metadata | Yes |
| Localised metadata by `Accept-Language` | No |
-| Format `dc+sd-jwt` | Yes |
-| Format `vc+sd-jwt` | Yes |
-| Formats `jwt_vc_json`, `jwt_vc_json-ld`, `ldp_vc`, `mso_mdoc` | No |
-| Holder binding by `did:jwk` and `jwk` | Yes |
-| Holder binding by `did:web` | No |
+| Formats `jwt_vc_json`, `jwt_vc_json-ld`, `ldp_vc` | No |
### [Decentralized Identity Interop Profile 5](https://fidescommunity.github.io/DIIP/)
-Requirements in addition to OpenID for Verifiable Credential Issuance 1.0 above.
-
| Requirement | Status |
|---|---|
-| W3C VCDM 2.0 secured with SD-JWT | Partial |
+| Format IETF SD-JWT VC, `dc+sd-jwt` | Yes |
+| Format W3C VCDM 2.0 secured with SD-JWT, `vc+sd-jwt` | Partial |
| ES256 | Yes |
| Issuer identifier `did:jwk` | Yes |
| Issuer identifier `did:web` | Yes |
-| Issuer initiated, same device and cross device | Yes |
-| PKCE `S256` | Yes |
+| Holder identifier `did:jwk` | Yes |
+| Holder identifier `did:web` | No |
| `cnf` as DID URL | Yes |
+| Authorization Code Flow | Yes |
+| Pre-Authorized Code Flow | Yes |
+| Transaction Code, `tx_code` | Yes |
+| Separate Authorization Server | No, by design |
+| PKCE `S256` | Yes |
+| PAR | Yes |
+| Issuer initiated, same device and cross device | Yes |
+| Immediate issuance | Yes |
+| Authorization request with `scope` | Yes |
+| Authorization request with `authorization_details` | Partial |
+| Proof type `jwt` | Yes |
| Validity, `nbf` `exp` `validFrom` `validUntil` | Yes |
| Token Status List | Yes |
| OpenID Federation, Entity Configuration, `fed` and `termsOfUse` | Yes |
### [OpenID4VC High Assurance Interoperability Profile 1.0](https://openid.net/specs/openid4vc-high-assurance-interoperability-profile-1_0-final.html)
-Requirements in addition to OpenID for Verifiable Credential Issuance 1.0 above.
-
| Requirement | Status |
|---|---|
-| Issuer key as `x5c` chain in the credential | Yes |
-| `x5c` in Status List Token | Yes |
-| `x5c` in signed Credential Issuer metadata | Yes |
-| ES256 | Yes |
-| PAR and PKCE `S256` required | Yes |
+| Authorization Code Flow | Yes |
+| Format IETF SD-JWT VC, `dc+sd-jwt` | Yes |
+| Format ISO mdoc, `mso_mdoc` | Yes |
+| PKCE `S256` | Yes |
+| PAR | Yes |
| `iss` in authorization response, RFC 9207 | Yes |
-| DPoP required | Yes |
-| Wallet Attestation required at PAR and token endpoint | Yes |
-| Holder key in `cnf` `jwk` member | Partial |
-| Same device and cross device offers | Partial |
+| DPoP | Yes |
+| Authorization server metadata, RFC 8414 | Yes |
+| `scope` for every Credential Configuration | Yes |
+| Signed Credential Issuer metadata with `x5c` | Yes |
+| Nonce endpoint | Yes |
+| Credential Offer, same device and cross device | Yes |
+| Wallet Attestation at PAR and token endpoint | Yes |
+| Key attestation in a `jwt` proof, `key_attestation` | Yes |
+| Proof type `attestation` | No |
| Batch issuance advertised by `batch_credential_issuance` | Yes |
-| Unique, unpredictable status list index | Yes |
+| Refresh tokens | No |
+| SD-JWT VC with `exp` and `status` | Yes |
+| Holder key in `cnf` `jwk` member | Partial |
+| Issuer key as `x5c` chain in the SD-JWT VC | Yes |
+| `x5c` in Status List Token | Yes |
+| Unique, unpredictable Status List index | Yes |
+| Issuer key as `x5chain` in the mdoc | Yes |
+| mdoc status as Status List Token in CWT with `x5chain` | Yes |
+| ES256, COSE `-7` | Yes |
+| SHA-256 digests | Yes |
## Plugin Installation
@@ -244,14 +245,14 @@ but it is not guaranteed that they work for you as such.
Before you start, the OP needs an EC signing key, see
[Key Generation](https://shibboleth.atlassian.net/wiki/spaces/IDPPLUGINS/pages/1376878976/OIDC+OP#Key-Generation)
of the OIDC OP wiki page. A normal installation of the OP plugin has one,
-*credentials/idp-signing-es.jwk*. Steps 1 to 4 sign with it. The plugin signs credentials with
-EC keys only and does not use the RSA key of the OP.
+*credentials/idp-signing-es.jwk*. Steps 1 to 4 sign with it.
1. A did:jwk credential with both flows and the Status List. Signed with the key of the OP.
2. A second credential, did:web.
3. A third credential, the issuer is the plain url.
4. The third credential is made an OpenID Federation credential.
-5. A fourth credential, for a HAIP wallet. Signing key gets a certificate chain.
+5. A fourth and a fifth credential, for a HAIP wallet, as SD-JWT VC and as mdoc. Signing key gets
+ a certificate chain.
6. Rolling the signing key to the next one.
For steps 1 to 4 the wallet is [Paradym](https://paradym.id). At the end the installation
@@ -419,6 +420,7 @@ It goes inside the credential's own configuration, next to `format` and `vct`:
| `status_list` | Boolean | `openidvci.status-list.includeByDefault`, `true` | Whether the credential takes a Status List slot. |
| `issuer` | String | `openidvci.issuer` | What the credential says issued it. `did:jwk`, `did:web` or `url`. See [Step 2](#step-2-add-a-did-web-credential) and [Step 3](#step-3-add-a-plain-url-credential). |
| `oidfed` | Boolean | `true` | Whether the credential states the Entity Identifier of this deployment, once the deployment is in OpenID Federation. See [Step 4](#step-4-make-the-url-credential-an-openid-federation-credential). |
+| `claim_types` | Array | | Type of a claim value in an mdoc where JSON has none, a date for example, `{"path": [...], "type": ...}`. See [The mdoc credential](#the-mdoc-credential). |
All of them are optional and so is the member itself.
@@ -442,18 +444,17 @@ What ends up in the credential:
|---|---|
| `dc+sd-jwt` | `iat` is the instant of issuance, `exp` closes the window, and `nbf` opens it whenever that is not the instant of issuance |
| `vc+sd-jwt` | The same three, and `validFrom` and `validUntil` as the same two ends of the window |
+| `mso_mdoc` | `validityInfo` of the mobile security object, `signed` is the instant of issuance, `validFrom` and `validUntil` the two ends of the window |
A credential whose window opens at issuance carries no `nbf`. The instant of issuance is rounded
-down to the full hour, so that every credential of one batch carries the same `iat` and `exp` and
-the two do not link the credentials to each other. To round to something else you need to set
+down to the full hour. To round to something else you need to set
`openidvci.credentialTimestampPrecision` in *conf/openid-vci.properties*:
```properties
openidvci.credentialTimestampPrecision = PT15M
```
-Rounding moves the window earlier, never later, so the expiration still chooses the same
-[Status List](#status-list) it would have chosen unrounded. Keep the precision shorter than the
+Keep the precision shorter than the
shortest `lifetime` you issue. A longer one closes the window before the credential is issued and
nothing is issued.
@@ -575,6 +576,9 @@ file is in [Discovery documents](#discovery-documents).
</details>
+Wallets running in a browser need also the `Access-Control-Allow-Origin` rule of the whole file in
+[Discovery documents](#discovery-documents).
+
#### Pre-authorized code flow
**File(s):** *metadata/oidc-client.json*, *conf/relying-party.xml*
@@ -697,9 +701,8 @@ openid-credential-offer://?credential_offer=%7B%22credential_issuer%22:%22https:
</details>
-The offer in example asks for both credentials at once and the wallet then may pick one or the other.
-Do not really do that. A wallet that is offered two credentials in one offer most likely fails miserably
-with it, so keep to one credential per offer. The two are here only to remind us it should be possible.
+The offer in example asks for both credentials at once. Keep to one credential per offer, many
+wallets do not handle two.
Note that the paths are flat in both, also in the `vc+sd-jwt` one. The `credentialSubject`
nesting of that format is done when the credential is built.
@@ -902,8 +905,7 @@ is told not to take one.
Withdrawing that credential is setting bit 41337 of list `2027-03`. The list a credential lands
in follows from the month the credential **expires**, not from the month it was issued. With the
default `credentialLifetime` of `P180D` a credential that is issued in September 2026 belongs to
-the list of March 2027. As we need to keep track of credentials and revocation we place them
-to buckets per expiration.
+the list of March 2027.
The part after `~` tells under which issuer identifier the credential was issued. A Status List
Token has to have the same issuer as the credential. For `did:jwk` the part is the thumbprint
@@ -932,8 +934,7 @@ One property you need to change before you issue anything, in *conf/openid-vci.p
openidvci.status-list.index.permutationKey = <a stable secret>
```
-Slots are handed out as a keyed permutation of a counter, so the order in which credentials
-were issued cannot be read from their `idx`. The allocator does not initialise without a key
+The allocator does not initialise without a key
and it warns for as long as the shipped placeholder is in place. If you change the key later,
slots that are already used are handed out again.
@@ -943,13 +944,8 @@ A Status List has bits and nothing else in it, so the mapping from a credential
in the assignment records. The credential endpoint writes one record per issuance that took
slots, and nothing in the IdP reads them back.
-Shibboleth is not a register of issued credentials and it is not meant to become one. Somebody
-decides one day that a credential has to be withdrawn and the reason may be anything you are
-able to imagine, from a person being sacked to a whole department being closed. That decision
-is not made in Shibboleth and what is needed to make it is not in Shibboleth either. This is
-why the records are offered to the outside for a limited time only. You read them out, store
-them in the system that already knows about your people and make the decision there with
-tooling that is meant for it. When it is made, you call the revoke api of this plugin.
+Store the records in the system that knows who holds which credential, and call the revoke api
+from there.
You collect the records to whatever system of yours knows who holds which credential:
@@ -1028,8 +1024,9 @@ list, so enable it in the relying party that owns the issuance and nowhere else.
##### Reading the list back
-The published list is a signed JWT of media type `application/statuslist+jwt`. You fetch it
-with the `uri` of the credential and it needs no authentication:
+The published list is a signed JWT of media type `application/statuslist+jwt`, or a CWT when
+asked, see [OAuth.StatusList](#oauth-statuslist). You fetch it with the `uri` of the credential
+and it needs no authentication:
<details>
<summary>Example of fetching a Status List Token</summary>
@@ -1056,10 +1053,9 @@ Its claims, `lst` being the compressed list itself:
</details>
-A list is published until its month is over. By then every credential in it has expired. Asking
-for a month that has ended is `410 retired_status_list` and asking for something that is not a
-month at all is `404 unknown_status_list`, so a verifier is able to tell a withdrawn list from
-a wrong URL.
+A list is published until its month is over. Asking for a month that has ended is
+`410 retired_status_list` and asking for something that is not a month at all is
+`404 unknown_status_list`.
### Step 2: Add a did:web credential
@@ -1305,7 +1301,7 @@ and wire it in *etc/jetty-rewrite-rules.xml*:
*static/jwt-vc-issuer.json* as it comes with the plugin is fine. Its `jwks_uri` is the keyset of
the OP, served by `OIDC.Keyset` that is already in the anonymous relying party. The keyset has
-the keys of the OP, and those still sign.
+the keys of the OP, which sign the credentials until Step 5.
Scope, scopes supported, attribute encoder and offer as in Step 2, with
`GeantIncubatorDiploma_URL`.
@@ -1407,29 +1403,38 @@ A `vc+sd-jwt` credential carries the same as `termsOfUse`:
metadata. Set `openidvci.oidfed.decorateFederationEntity = false` to state them in the
metadata file of the federation module instead.
-### Step 5: Add a HAIP credential
+### Step 5: Add HAIP credentials
-Last credential is for a HAIP wallet. HAIP wants the issuer key as a certificate chain in the
-`x5c` header of the credential, of the Status List Token and of signed metadata. The wallet
-authenticates with a Wallet Attestation and its key proof carries a key attestation.
+Last two credentials are for a HAIP wallet. HAIP takes a credential as SD-JWT VC or as ISO mdoc,
+see [HAIP 1.0](https://openid.net/specs/openid4vc-high-assurance-interoperability-profile-1_0-final.html#vc-profiles).
+This step adds one of each.
What you configure:
-- a signing key with a certificate chain, used first
-- a url credential, only a url credential carries the chain in `x5c`
-- key attestation required for the credential, and the attester CA you trust
+- a signing key with a certificate, used first
+- a Status List signing key with a certificate of its own
+- a url credential
+- an mdoc credential
+- key attestation required for the credentials, and the attester CA you trust
- the wallet as a client with `attest_jwt_client_auth`
- signed Credential Issuer metadata
#### Certificate chain
-You need an EC signing key, its certificate and the chain above it, without the root, see
+You need an EC signing key and its certificate, issued by a CA the verifier trusts, see
[HAIP issuer key resolution](https://openid.net/specs/openid4vc-high-assurance-interoperability-profile-1_0-final.html#issuer-key-resolution).
-Below they are *credentials/vci-signing.key*, *credentials/vci-signing.crt* and
-*credentials/vci-issuing-ca.crt*.
+For the mdoc the certificates follow the profiles of Annex B of
+[ISO/IEC 18013-5](https://www.iso.org/standard/69084.html): the signing certificate is a Document
+Signer certificate issued directly by the IACA root, and Status List Tokens are signed with a
+certificate of their own.
+
+Below the Document Signer is *credentials/vci-signing.key* and *credentials/vci-signing.crt*, the
+Status List signer *credentials/vci-status-list.key* and *credentials/vci-status-list.crt*. The
+IACA root is not configured, the verifier has it as its trust anchor.
-In *conf/openid-vci-credentials.xml* the key is defined with its certificates, leaf first, and
-put **first** in the list. The key needs a name, `p:keyNames`, for the did:web document:
+In *conf/openid-vci-credentials.xml* the signing key is put **first** in the list. The did:web
+credential of Step 2 is still issued, so the new keys need a name, `p:keyNames`, for the did:web
+document:
```xml
<bean id="openidvci.X509ESSigningCredential" parent="shibboleth.BasicX509CredentialFactoryBean"
@@ -1437,7 +1442,15 @@ put **first** in the list. The key needs a name, `p:keyNames`, for the did:web d
<property name="certificates">
<list>
<value>%{idp.home}/credentials/vci-signing.crt</value>
- <value>%{idp.home}/credentials/vci-issuing-ca.crt</value>
+ </list>
+ </property>
+</bean>
+
+<bean id="openidvci.X509StatusListSigningCredential" parent="shibboleth.BasicX509CredentialFactoryBean"
+ p:privateKey="%{idp.home}/credentials/vci-status-list.key" p:keyNames="statuslist-x509">
+ <property name="certificates">
+ <list>
+ <value>%{idp.home}/credentials/vci-status-list.crt</value>
</list>
</property>
</bean>
@@ -1447,18 +1460,27 @@ put **first** in the list. The key needs a name, `p:keyNames`, for the did:web d
<ref bean="openidvci.OPESSigningCredential" />
</util:list>
+<util:list id="openidvci.status-list.SigningCredentials">
+ <ref bean="openidvci.X509StatusListSigningCredential" />
+ <ref bean="openidvci.OPESSigningCredential" />
+</util:list>
+
<util:list id="openidvci.issuerMetadata.SigningCredentials">
<ref bean="openidvci.X509ESSigningCredential" />
</util:list>
```
+The credentials of Steps 1 to 4 were signed with the key of the OP, so it stays in both lists,
+after the new keys, see [Step 6](#step-6-rolling-the-signing-key). In the Status List list it signs
+the Status Lists of the did:jwk credential of Step 1.
+
The chain is emitted whenever the issuer of the credential, Status List Token or metadata is an https url, there is no
switch for it. That means the url credential of Steps 3 and 4 now carries `x5c` too, and no
`kid`. A verifier of it moves from `/.well-known/jwt-vc-issuer` to the chain. The did:jwk and
did:web credentials do not carry a chain, their identifier resolves the key. This is a key
rollover, see [Step 6](#step-6-rolling-the-signing-key).
-#### The credential
+#### The SD-JWT VC credential
`GeantIncubatorDiploma_HAIP` in *metadata/verifiable-credentials.json*, a url credential with key
attestation required:
@@ -1510,11 +1532,125 @@ attestation required:
</details>
-`key_storage` and `user_authentication` of `key_attestations_required` list the attack resistance
-the wallet has to attest for its key storage and user authentication. `iso_18045_moderate` is
-resistance to attack potential Moderate, see
-[attack potential resistance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-attack-potential-resistance)
-for the other values.
+For the values of `key_attestations_required`, see
+[attack potential resistance](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-attack-potential-resistance).
+
+Add `<AttributeEncoder xsi:type="oidc:OIDCString" name="GeantIncubatorDiploma_HAIP" />` to the
+attribute definition of Step 1 and release the attribute also to the HAIP wallet in
+*conf/attribute-filter.xml*.
+
+#### The mdoc credential
+
+`GeantIncubatorDiploma_MDOC` is a diploma in
+[ISO mdoc](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#mdocs) format.
+An mdoc carries the certificate chain of its signing key in the `x5chain` header, the Credential
+Issuer identifier is not used.
+
+<details>
+<summary>Example snippet of metadata/verifiable-credentials.json</summary>
+
+```json
+{
+ "credential_configurations_supported": {
+ ...
+ "GeantIncubatorDiploma_MDOC": {
+ "format": "mso_mdoc",
+ "doctype": "org.geant.incubator.diploma.1",
+ "scope": "GeantIncubatorDiploma_MDOC",
+ "cryptographic_binding_methods_supported": ["cose_key"],
+ "credential_signing_alg_values_supported": [-7],
+ "proof_types_supported": {
+ "jwt": {
+ "proof_signing_alg_values_supported": ["ES256"],
+ "key_attestations_required": {
+ "key_storage": ["iso_18045_moderate"],
+ "user_authentication": ["iso_18045_moderate"]
+ }
+ }
+ },
+ "_issuance_configuration": {
+ "issuer": "url",
+ "lifetime": "P45D",
+ "claim_types": [
+ { "path": ["org.geant.incubator.1", "issue_date"], "type": "full-date" }
+ ]
+ },
+ "credential_metadata": {
+ "display": [
+ { "name": "Incubator Diploma, mdoc", "locale": "en" }
+ ],
+ "claims": [
+ { "path": ["org.geant.incubator.1", "diploma"], "display": [{ "name": "Diploma", "locale": "en" }] },
+ { "path": ["org.geant.incubator.1", "issue_date"], "display": [{ "name": "Issued", "locale": "en" }] },
+ { "path": ["org.geant.incubator.1", "mail"], "display": [{ "name": "Email", "locale": "en" }] },
+ { "path": ["org.geant.incubator.1", "given_name"], "display": [{ "name": "Given names", "locale": "en" }] },
+ { "path": ["org.geant.incubator.1", "family_name"], "display": [{ "name": "Family name", "locale": "en" }] }
+ ]
+ }
+ }
+ }
+}
+```
+
+</details>
+
+What is different from an SD-JWT VC configuration:
+
+- `doctype` instead of `vct`, required.
+- A claim `path` starts with the name space and the data element identifier, see
+ [Claims Path Pointer](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#claims_path_pointer).
+ A shorter path is refused when the file is read.
+- `credential_signing_alg_values_supported` has COSE algorithm identifiers, `-7` is ES256.
+- `cryptographic_binding_methods_supported` is `cose_key`.
+- `issuer` decides only the url of the Status List. With `url` the Status List signer of
+ [Certificate chain](#certificate-chain) signs it.
+- `lifetime` is shorter than what is left of the Document Signer certificate.
+- `claim_types` gives a claim a type JSON does not have. It is not published.
+
+A claim value goes in as its JSON type, decimal numbers are refused. A date or a byte string
+needs a `claim_types` entry. The definition of the document type tells which element is which,
+for an mDL [ISO/IEC 18013-5](https://www.iso.org/standard/69084.html):
+
+| `type` | Value you give | In the mdoc |
+|---|---|---|
+| `full-date` | `1990-01-02` | `full-date`, tag 1004, [RFC 8943](https://www.rfc-editor.org/rfc/rfc8943) |
+| `tdate` | `2026-10-01T10:00:00Z` or epoch seconds | `tdate`, tag 0, [RFC 8949 3.4.1](https://www.rfc-editor.org/rfc/rfc8949#section-3.4.1) |
+| `bstr` | base64 or base64url | byte string, [RFC 8949 3.1](https://www.rfc-editor.org/rfc/rfc8949#section-3.1), a `portrait` for example |
+| `int` | a number or a string of digits | integer |
+| `bool` | `true` or `false`, also as a string | boolean |
+
+The paths differ from the SD-JWT VC credentials, so the content is an attribute of its own:
+
+<details>
+<summary>Example snippet of conf/attribute-resolver.xml</summary>
+
+```xml
+<AttributeDefinition id="GeantIncubatorDiplomaMdoc" xsi:type="ScriptedAttribute">
+ <InputAttributeDefinition ref="mail" />
+ <InputAttributeDefinition ref="givenName" />
+ <InputAttributeDefinition ref="sn" />
+ <Script><![CDATA[
+ var ns = 'org.geant.incubator.1';
+ var credential = '['
+ + '{"path":["' + ns + '","diploma"],"value":"Scrum Master"},'
+ + '{"path":["' + ns + '","issue_date"],"value":"2026-06-15"},'
+ + '{"path":["' + ns + '","mail"],"value":"' + mail.getValues().get(0) + '"},'
+ + '{"path":["' + ns + '","given_name"],"value":"' + givenName.getValues().get(0) + '"},'
+ + '{"path":["' + ns + '","family_name"],"value":"' + sn.getValues().get(0) + '"}'
+ + ']';
+ GeantIncubatorDiplomaMdoc.addValue(credential);
+ ]]></Script>
+ <AttributeEncoder xsi:type="oidc:OIDCString" name="GeantIncubatorDiploma_MDOC" />
+</AttributeDefinition>
+```
+
+</details>
+
+Add `GeantIncubatorDiplomaMdoc` to `idp.oidc.encodedAttributes` in *conf/oidc.properties* and
+release it to the HAIP wallet in *conf/attribute-filter.xml*.
+
+The Status List needs nothing more for the mdoc, it is also served as CWT, see
+[OAuth.StatusList](#oauth-statuslist).
#### Attestations
@@ -1574,11 +1710,11 @@ The client id is the `sub` of the Wallet Attestation of the wallet. In *metadata
"client_id": "haip-wallet",
"client_name": "HAIP Wallet",
"redirect_uris": ["https://wallet.example.org/callback"],
- "scope": "openid GeantIncubatorDiploma_HAIP",
+ "scope": "openid GeantIncubatorDiploma_HAIP GeantIncubatorDiploma_MDOC",
"audience": "credentials",
"token_endpoint_auth_method": "attest_jwt_client_auth",
"response_types": ["code"],
- "grant_types": ["authorization_code", "urn:ietf:params:oauth:grant-type:pre-authorized_code"]
+ "grant_types": ["authorization_code"]
}
]
```
@@ -1586,12 +1722,9 @@ The client id is the `sub` of the Wallet Attestation of the wallet. In *metadata
</details>
In *conf/relying-party.xml* PKCE and PAR are required, `iss` is returned in the authorization
-response, the authorization code lives one minute and both token and PAR endpoints take the
-Wallet Attestation. HAIP requires the Credential Issuer to follow
-[FAPI 2.0](https://openid.net/specs/fapi-security-profile-2_0-final.html), and
-[FAPI 2.0 5.3.2.1](https://openid.net/specs/fapi-security-profile-2_0-final.html#section-5.3.2.1)
-allows an authorization code at most 60 seconds. The OP plugin default is five minutes, property
-`idp.oidc.authorizeCode.defaultLifetime`:
+response, the authorization code lives one minute, see
+[FAPI 2.0 5.3.2.1](https://openid.net/specs/fapi-security-profile-2_0-final.html#section-5.3.2.1),
+and both token and PAR endpoints take the Wallet Attestation:
<details>
<summary>Example snippet of conf/relying-party.xml</summary>
@@ -1626,10 +1759,6 @@ DPoP settings are left to their defaults, so a DPoP nonce is required.
`openidvci.PushedAuthorizationDPoPJktManipulationStrategy` on **OAUTH2.PAR** is recommended with
DPoP, see [RFC 9449 10.1](https://www.rfc-editor.org/rfc/rfc9449#section-10.1).
-Add `<AttributeEncoder xsi:type="oidc:OIDCString" name="GeantIncubatorDiploma_HAIP" />` to the
-attribute definition and release the attribute also to the HAIP wallet in
-*conf/attribute-filter.xml*.
-
The whole *static/oauth-authorization-server.json* after this step. `token_endpoint_auth_methods_supported`
lists the methods your `tokenEndpointAuthMethods` allow on the token and PAR endpoints, here `none`
for Paradym and `attest_jwt_client_auth` for the HAIP wallet:
@@ -1680,7 +1809,8 @@ for Paradym and `attest_jwt_client_auth` for the HAIP wallet:
"GeantIncubatorDiploma_W3C",
"GeantIncubatorDiploma_DIDWEB",
"GeantIncubatorDiploma_URL",
- "GeantIncubatorDiploma_HAIP"
+ "GeantIncubatorDiploma_HAIP",
+ "GeantIncubatorDiploma_MDOC"
],
"authorization_details_types_supported":[
"openid_credential"
@@ -1711,15 +1841,16 @@ of the credentials it signed.
| `did:jwk` | The identifier is the key, so it changes. New credentials have a new issuer. Old credentials verify on their own, the key is inside, but a verifier that trusts the issuer by its identifier has to be told the new one. The Status List url of an old credential has the thumbprint of the old key, and that list is signed with the old key. If the old key is not in the list anymore, the Status List Token of the old credentials is not served and they cannot be checked. |
| `did:web` | The identifier stays. The did:web document lists every key of the list, each by its name, so the new key needs a name of its own. Old credentials have the `kid` of the old key and it stays in the document as long as it is in the list. |
| url, no chain | The identifier stays. The key is found by `kid` from the keyset of the OP, so the new key goes to **shibboleth.oidc.SigningCredentialsToPublish** before it signs, and the old one stays there until its credentials have expired. |
-| url, with chain | The identifier stays. The credential carries its chain, a verifier trusts the root. A new leaf under the same CA needs nothing published. The Status List of url credentials is not tied to a key, it is signed with whatever key is first. |
+| url, with chain | The identifier stays. The credential carries its chain, a verifier trusts the root. A new leaf under the same CA needs nothing published. The Status List of url credentials is not tied to a key, it is signed with the first key of **openidvci.status-list.SigningCredentials**. |
| OpenID Federation | The Entity Configuration publishes the keys of the list. Same as did:web. |
+| mdoc | Same as url with chain. |
So did:jwk is not good for a deployment that rotates keys and revokes credentials. Use it for
testing and for credentials that live shorter than the key.
-Status List Tokens may be signed with a key of their own, **openidvci.status-list.SigningCredentials**.
-Do not do that while any credential is did:jwk, the issuer of the Status List Token
-would be another key than the issuer of the credential.
+Status List Tokens may be signed with a key of their own, **openidvci.status-list.SigningCredentials**,
+as in [Step 5](#certificate-chain). While any credential is did:jwk, keep the keys that signed
+them in that list too.
### What we have now
@@ -1729,6 +1860,7 @@ would be another key than the issuer of the credential.
| `GeantIncubatorDiploma_DIDWEB` | `did:web:issuer.example.org` | `/.well-known/did.json` | no | Paradym |
| `GeantIncubatorDiploma_URL` | `https://issuer.example.org` | `x5c`, earlier `/.well-known/jwt-vc-issuer` | yes | Paradym |
| `GeantIncubatorDiploma_HAIP` | `https://issuer.example.org` | `x5c` | no | HAIP wallet |
+| `GeantIncubatorDiploma_MDOC` | none | `x5chain` | no | HAIP wallet |
The whole *conf/relying-party.xml* is in [Relying party configuration](#relying-party-configuration)
and the whole *etc/jetty-rewrite-rules.xml* in [Discovery documents](#discovery-documents).
@@ -1866,8 +1998,7 @@ The identifier is the configuration id and the `dataset_id` joined with `#`:
The wallet then asks for each with `credential_identifier` in the
[Credential Request](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-request)
and ends up with two `UniversityDegree` credentials, Computer Science and Mathematics. The token
-response has the identifiers also when the wallet did not ask with `authorization_details`, as
-they are the only way to address one of the datasets.
+response has the identifiers also when the wallet did not ask with `authorization_details`.
### Pre-authorized code flow
@@ -2031,7 +2162,8 @@ Endpoint `/idp/profile/oauth/status-list/list`, profile ID `http://geant.org/ns/
| `outboundInterceptorFlows` | List<String> | | Interceptor flows run before the response is sent, also for an error response. |
Request, a `GET` with `id`, the month and the issuer tag as in the `uri` of a credential. Response
-is `application/statuslist+jwt`. A month that has ended is `410 retired_status_list`, a value
+is `application/statuslist+jwt`, or `application/statuslist+cwt` when the `Accept` header asks for
+the CWT and not the JWT. A month that has ended is `410 retired_status_list`, a value
that is not a month `404 unknown_status_list`.
#### OAuth.StatusList.Assignments
@@ -2088,6 +2220,7 @@ Member `_issuance_configuration` of a credential configuration. It is not publis
| `status_list` | Boolean | `openidvci.status-list.includeByDefault` | Whether the credential takes a Status List slot. |
| `issuer` | String | `openidvci.issuer` | `did:jwk`, `did:web`, `url` or the identifier itself. |
| `oidfed` | Boolean | `true` | Whether the credential states the Entity Identifier, when *conf/openid-vci-oidfed.xml* is imported. |
+| `claim_types` | Array | | `{"path": [...], "type": ...}` per claim of an mdoc. `full-date`, `tdate`, `bstr`, `int` or `bool`. |
### Credential Issuer identifiers
@@ -2467,7 +2600,7 @@ The file as it is at the end of [First installation, by example](#first-installa
<ref bean="OpenID.VCI.IssuerMetadata" />
<!-- Anyone may read the keys that signed a credential -->
<ref bean="OAuth.JWTVCIssuer" />
- <!-- Anyone may read the did:web document, needed when openidvci.issuer is did:web -->
+ <!-- Anyone may read the did:web document, needed for did:web credentials -->
<ref bean="DID.Document" />
<!-- Anyone may read the authorization server metadata -->
<ref bean="OAuth.AuthorizationServer" />
@@ -2477,15 +2610,6 @@ The file as it is at the end of [First installation, by example](#first-installa
<ref bean="OAuth.StatusList" />
<!-- Anyone may read the Entity Configuration, of the federation plugin -->
<ref bean="OIDFED.Configuration" />
- <!-- The three below are for unregistered wallets running the code flow -->
- <bean parent="OIDC.SSO"
- p:authorizationCodeClaimsSetManipulationStrategy-ref="openidvci.TokenManipulationStrategy"
- p:forcePKCE="true"
- p:requirePushedAuthorizationRequest="true" />
- <bean parent="OpenID.VCI.Token"
- p:tokenEndpointAuthMethods="#{{'none'}}"
- p:accessTokenClaimsSetManipulationStrategy-ref="openidvci.TokenManipulationStrategy" />
- <bean parent="OpenID.VCI.Credentials" />
</list>
</property>
</bean>
diff --git a/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz b/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz
index 6f50ec4..a230e78 100644
Binary files a/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz and b/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz differ
diff --git a/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz.asc b/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz.asc
index 2cf139c..cb16c07 100644
--- a/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz.asc
+++ b/download/shibboleth-idp-plugin-openid-vci-0.9.0-SNAPSHOT.tar.gz.asc
@@ -1,14 +1,14 @@
-----BEGIN PGP SIGNATURE-----
-iQHPBAABCAA5FiEEoCV/y8Vr4Y+djZGw+mRLGOJQ2wsFAmq6jZYbFIAAAAAABAAO
-bWFudTIsMi41KzEuMTIsMCwzAAoJEPpkSxjiUNsLPEEL/0zuV4pP2dfbBxlFOSPK
-+sYF0TVTAKLQCpwqn75uMD64A+Ao/3EvRJBQVHX2h9YCKXO/KHyyFyHz20nokpNa
-rAd4KKx33TP2kiIg2yXCKk2CquBZIAbnH88eVLqULJ771wfuoxE37r6MK/0RihtV
-Q1VoYG3kmsiayqThbzdiRrNG7kcCQRCnESaaRAEWPw3J+4FOoHY9xayCE8WrwcRN
-tCg+qW7trR+clddZDRPH0eXa1luMv5TX9d4dF4FLfm6Y/viYmfYJUCKggMhkijcq
-0b9WlD9Nwmi57ccreNCHp28RwoHSlpXQpGSY3yKGoV14nDUcwKv1D/+cPcRxN5DN
-UMaeEyyI2AAsUQN3voCOmdAC7lKK7bBKPDWt2xp2NEoe6+u69X8McNBKIXpQytzi
-wEjelX/qyYsICN89a4wyMi0avSSD4Dkeeh/U+sdJs42F9hU4t4CBR2lovY+RJHq2
-0DOSkP9ED+5o9WvMdKgCknF8f04YDIu78rolo/7jQoa+SA==
-=Bz4A
+iQHPBAABCAA5FiEEoCV/y8Vr4Y+djZGw+mRLGOJQ2wsFAmq+hpMbFIAAAAAABAAO
+bWFudTIsMi41KzEuMTIsMCwzAAoJEPpkSxjiUNsLmWoMAK17Qyjv1M1dp46eA9Y3
+JmVgbTz/pgLoO1BL5ExVnMmVHJzDQa02zTuNUeOcO69NpUAgi2gh/ubkSpiFO1H/
+9Hwnx9BiXTmT1BZVtpo/32ypbEUm3+/tCPs9QivRMvgjtU64lmpGZ3zJsozyNmlg
+a3UlGlMxZFPAZFDQYS3nAMTM42t2me4F71HZ9EullBWtO40qJNTJmzoF1PGgVD2B
+Kdy2t7J9Q437SbAmZbY3qfqi90w/gxp6GBNj+WaTwZA52MEjW/XirtUOGQguJep1
+97g07pumqAKtKGlaJdR6r4rCs1USUfXrbPuWoylYZpWnzzDMGrtJM27HS2y+sjdr
+l6696ctWtCYqja/nGH0w+2NBLY+4AEUpfdaSUbEfpLlVhdpVth5d+nmp/DsLclYJ
+j6ogY9Es2yOVag1WDMwarb4lA9Hv1vIv4Xhy3DDLXdlS3AWnkjxUwXyU+PhZLFcp
+tZCdUdLcX7m4NlXvF+wgGjMsA0RJj2vBhEG1+jc14H3dRQ==
+=AjPx
-----END PGP SIGNATURE-----
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/ClaimType.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/ClaimType.java
new file mode 100644
index 0000000..e2dec95
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/ClaimType.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.credential;
+
+import java.util.List;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Type a claim value is encoded as in an mdoc, an entry of 'claim_types' in
+ * {@link CredentialIssuanceConfiguration}.
+ */
+ at JsonIgnoreProperties(ignoreUnknown = true)
+public final class ClaimType {
+
+ /** Date as a tag 1004 text string. */
+ @Nonnull
+ public static final String FULL_DATE = "full-date";
+
+ /** Date and time as a tag 0 text string. */
+ @Nonnull
+ public static final String TDATE = "tdate";
+
+ /** Byte string, from base64 or base64url. */
+ @Nonnull
+ public static final String BSTR = "bstr";
+
+ /** Integer, from a number or a string. */
+ @Nonnull
+ public static final String INT = "int";
+
+ /** Boolean, from a boolean or a string. */
+ @Nonnull
+ public static final String BOOL = "bool";
+
+ /** Supported types. */
+ @Nonnull
+ private static final Set<String> TYPES = Set.of(FULL_DATE, TDATE, BSTR, INT, BOOL);
+
+ /** Path of the claim. */
+ @Nonnull
+ private final List<String> path;
+
+ /** Type of the claim value. */
+ @Nonnull
+ private final String type;
+
+ /**
+ * Constructor.
+ *
+ * @param claimPath path of the claim
+ * @param claimType type of the claim value
+ */
+ @JsonCreator
+ private ClaimType(@JsonProperty("path") final List<String> claimPath,
+ @JsonProperty("type") final String claimType) {
+ if (claimPath == null || claimPath.isEmpty()) {
+ throw new IllegalArgumentException("claim_types entry has no path");
+ }
+ if (claimType == null || !TYPES.contains(claimType)) {
+ throw new IllegalArgumentException("claim_types entry " + claimPath + " has type '" + claimType
+ + "', not one of " + TYPES);
+ }
+ path = List.copyOf(claimPath);
+ type = claimType;
+ }
+
+ /**
+ * Get the path of the claim.
+ *
+ * @return path of the claim
+ */
+ @Nonnull
+ public List<String> getPath() {
+ return path;
+ }
+
+ /**
+ * Get the type of the claim value.
+ *
+ * @return type of the claim value
+ */
+ @Nonnull
+ public String getType() {
+ return type;
+ }
+}
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java
index 2d732c8..0be9dcd 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialConfiguration.java
@@ -52,6 +52,10 @@ public final class CredentialConfiguration {
@Nonnull
public static final String ISSUANCE_CONFIGURATION = "_issuance_configuration";
+ /** Format identifier of an mdoc. */
+ @Nonnull
+ public static final String MSO_MDOC = "mso_mdoc";
+
/** Identifies the format of this Credential. */
@Nonnull
@JsonProperty("format")
@@ -114,6 +118,12 @@ public final class CredentialConfiguration {
@JsonInclude(Include.NON_NULL)
private final String vct;
+ /** Document type of an mdoc ({@code mso_mdoc}). */
+ @Nullable
+ @JsonProperty("doctype")
+ @JsonInclude(Include.NON_NULL)
+ private final String doctype;
+
/**
* Settings of this deployment for this Credential.
*
@@ -153,28 +163,32 @@ public final class CredentialConfiguration {
* and display of issued Credentials
* @param vct Verifiable Credential Type
* identifier (SD-JWT VC formats)
+ * @param doctype Document type (mdoc)
* @param issuanceConfiguration Settings of this deployment for
* this Credential
*/
@JsonCreator
private CredentialConfiguration(@JsonProperty("format") @Nonnull final String format,
@JsonProperty("scope") @Nullable final String scope,
- @JsonProperty("credential_signing_alg_values_supported") @Nullable final List<String> credentialSigningAlgValuesSupported,
+ @JsonProperty("credential_signing_alg_values_supported") @Nullable final List<Object> credentialSigningAlgValuesSupported,
@JsonProperty("cryptographic_binding_methods_supported") @Nullable final List<String> cryptographicBindingMethodsSupported,
@JsonProperty("credential_definition") @Nullable final CredentialDefinition credentialDefinition,
@JsonProperty("proof_types_supported") @Nullable final Map<String, ProofTypeSupported> proofTypesSupported,
@JsonProperty("credential_metadata") @Nullable final CredentialMetadata credentialMetadata,
@JsonProperty("vct") @Nullable final String vct,
+ @JsonProperty("doctype") @Nullable final String doctype,
@JsonProperty(ISSUANCE_CONFIGURATION) @Nullable final CredentialIssuanceConfiguration issuanceConfiguration) {
assert format != null;
this.format = format;
this.scope = scope;
- this.credentialSigningAlgValuesSupported = credentialSigningAlgValuesSupported;
+ this.credentialSigningAlgValuesSupported = credentialSigningAlgValuesSupported == null ? null
+ : credentialSigningAlgValuesSupported.stream().map(String::valueOf).toList();
this.cryptographicBindingMethodsSupported = cryptographicBindingMethodsSupported;
this.credentialDefinition = credentialDefinition;
this.proofTypesSupported = proofTypesSupported;
this.credentialMetadata = credentialMetadata;
this.vct = vct;
+ this.doctype = doctype;
this.issuanceConfiguration = issuanceConfiguration;
// W3C family of credentials require credential definition
if (credentialDefinition == null && "vc+sd-jwt".equals(format)) {
@@ -184,6 +198,9 @@ public final class CredentialConfiguration {
if (vct == null && "dc+sd-jwt".equals(format)) {
throw new IllegalArgumentException("vct is missing");
}
+ if (MSO_MDOC.equals(format)) {
+ validateMdoc();
+ }
}
// Checkstyle: ParameterNumber ON
@@ -264,6 +281,26 @@ public final class CredentialConfiguration {
return credentialMetadata;
}
+ /**
+ * Check that an mdoc has a document type and claim paths of a name space and a
+ * data element identifier.
+ */
+ private void validateMdoc() {
+ if (doctype == null) {
+ throw new IllegalArgumentException("doctype is missing");
+ }
+ if (credentialMetadata == null || credentialMetadata.getClaims() == null) {
+ return;
+ }
+ for (final ClaimDescription claim : credentialMetadata.getClaims()) {
+ final List<Object> path = claim.getPath();
+ if (path.size() < 2 || !(path.get(0) instanceof String) || !(path.get(1) instanceof String)) {
+ throw new IllegalArgumentException("mso_mdoc claim path " + path
+ + " does not start with a name space and a data element identifier");
+ }
+ }
+ }
+
/**
* Get Verifiable Credential Type identifier (SD-JWT VC formats).
*
@@ -274,6 +311,16 @@ public final class CredentialConfiguration {
return vct;
}
+ /**
+ * Get document type (mdoc).
+ *
+ * @return Document type
+ */
+ @Nullable
+ public String getDoctype() {
+ return doctype;
+ }
+
/**
* Get the settings of this deployment for this Credential.
*
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfiguration.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfiguration.java
index 4726f8f..5c23ee8 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfiguration.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/CredentialIssuanceConfiguration.java
@@ -19,6 +19,7 @@ package org.geant.shibboleth.plugin.openidvci.credential;
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeParseException;
+import java.util.List;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
@@ -62,6 +63,11 @@ public final class CredentialIssuanceConfiguration {
@Nullable
private final String issuer;
+ /** Types of claim values in an mdoc. */
+ @Nonnull
+ private final List<ClaimType> claimTypes;
+
+// Checkstyle: ParameterNumber OFF
/**
* Constructor.
*
@@ -76,19 +82,23 @@ public final class CredentialIssuanceConfiguration {
* @param issuerIdentifier Credential Issuer identifier credentials of this
* configuration carry, one of 'did:jwk', 'did:web'
* and 'url', overriding the one of the deployment
+ * @param types types of claim values in an mdoc
*/
@JsonCreator
private CredentialIssuanceConfiguration(@JsonProperty("lifetime") @Nullable final String credentialLifetime,
@JsonProperty("not_before") @Nullable final String validityStart,
@JsonProperty("status_list") @Nullable final Boolean inStatusList,
@JsonProperty("oidfed") @Nullable final Boolean inOIDFederation,
- @JsonProperty("issuer") @Nullable final String issuerIdentifier) {
+ @JsonProperty("issuer") @Nullable final String issuerIdentifier,
+ @JsonProperty("claim_types") @Nullable final List<ClaimType> types) {
lifetime = parseLifetime(credentialLifetime);
notBefore = validityStart == null || validityStart.trim().isEmpty() ? null : ValidityStart.parse(validityStart);
statusList = inStatusList;
oidFederation = inOIDFederation;
issuer = issuerIdentifier == null || issuerIdentifier.trim().isEmpty() ? null : issuerIdentifier.trim();
+ claimTypes = types == null ? List.of() : List.copyOf(types);
}
+// Checkstyle: ParameterNumber ON
/**
* Get the length of the validity window.
@@ -141,6 +151,16 @@ public final class CredentialIssuanceConfiguration {
return issuer;
}
+ /**
+ * Get the types of claim values in an mdoc.
+ *
+ * @return types of claim values, empty when the configuration states none
+ */
+ @Nonnull
+ public List<ClaimType> getClaimTypes() {
+ return claimTypes;
+ }
+
/**
* Resolve the instant the validity window of a credential opens.
*
diff --git a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/context/StatusListTokenContext.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/context/StatusListTokenContext.java
index b297b34..75e4c84 100644
--- a/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/context/StatusListTokenContext.java
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/context/StatusListTokenContext.java
@@ -38,6 +38,10 @@ public class StatusListTokenContext extends BaseContext {
@Nullable
private String signedToken;
+ /** Signed status list token in CWT format. */
+ @Nullable
+ private byte[] signedCwt;
+
/**
* Thumbprint of the key the token has to be signed with, null to leave it to
* the signing configuration.
@@ -83,6 +87,25 @@ public class StatusListTokenContext extends BaseContext {
signedToken = token;
}
+ /**
+ * Get signed status list token in CWT format.
+ *
+ * @return Signed status list token in CWT format
+ */
+ @Nullable
+ public byte[] getSignedCwt() {
+ return signedCwt;
+ }
+
+ /**
+ * Set signed status list token in CWT format.
+ *
+ * @param token Signed status list token in CWT format
+ */
+ public void setSignedCwt(@Nullable final byte[] token) {
+ signedCwt = token;
+ }
+
/**
* Get the thumbprint of the key the token has to be signed with.
*
diff --git a/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/MdocCredentialConfigurationTest.java b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/MdocCredentialConfigurationTest.java
new file mode 100644
index 0000000..5da6eca
--- /dev/null
+++ b/openid-vci-api/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/MdocCredentialConfigurationTest.java
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.credential;
+
+import java.util.List;
+
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+
+/**
+ * Unit tests for {@link CredentialConfiguration} of format mso_mdoc.
+ */
+public class MdocCredentialConfigurationTest {
+
+ private static final String CLAIMS = "\"credential_metadata\":{\"claims\":["
+ + "{\"path\":[\"org.iso.18013.5.1\",\"given_name\"]},"
+ + "{\"path\":[\"org.iso.18013.5.1\",\"birth_date\"],\"mandatory\":true}]}";
+
+ @Test
+ public void parse() throws JsonProcessingException {
+
+ final CredentialConfiguration configuration = CredentialConfiguration.parse("{\"format\":\"mso_mdoc\","
+ + "\"doctype\":\"org.iso.18013.5.1.mDL\",\"cryptographic_binding_methods_supported\":[\"cose_key\"],"
+ + "\"credential_signing_alg_values_supported\":[-7,-9]," + CLAIMS + ","
+ + "\"_issuance_configuration\":{\"claim_types\":["
+ + "{\"path\":[\"org.iso.18013.5.1\",\"birth_date\"],\"type\":\"full-date\"}]}}");
+
+ Assert.assertEquals(configuration.getFormat(), CredentialConfiguration.MSO_MDOC);
+ Assert.assertEquals(configuration.getDoctype(), "org.iso.18013.5.1.mDL");
+ Assert.assertEquals(configuration.getCredentialSigningAlgValuesSupported(), List.of("-7", "-9"));
+ final List<ClaimType> types = configuration.getIssuanceConfiguration().getClaimTypes();
+ Assert.assertEquals(types.size(), 1);
+ Assert.assertEquals(types.get(0).getPath(), List.of("org.iso.18013.5.1", "birth_date"));
+ Assert.assertEquals(types.get(0).getType(), ClaimType.FULL_DATE);
+ }
+
+ @Test(expectedExceptions = JsonProcessingException.class)
+ public void noDoctype() throws JsonProcessingException {
+ CredentialConfiguration.parse("{\"format\":\"mso_mdoc\"," + CLAIMS + "}");
+ }
+
+ @Test(expectedExceptions = JsonProcessingException.class)
+ public void pathWithoutNameSpace() throws JsonProcessingException {
+ CredentialConfiguration.parse("{\"format\":\"mso_mdoc\",\"doctype\":\"org.iso.18013.5.1.mDL\","
+ + "\"credential_metadata\":{\"claims\":[{\"path\":[\"given_name\"]}]}}");
+ }
+
+ @Test(expectedExceptions = JsonProcessingException.class)
+ public void unknownClaimType() throws JsonProcessingException {
+ CredentialConfiguration.parse("{\"format\":\"mso_mdoc\",\"doctype\":\"org.iso.18013.5.1.mDL\","
+ + "\"_issuance_configuration\":{\"claim_types\":["
+ + "{\"path\":[\"org.iso.18013.5.1\",\"birth_date\"],\"type\":\"date\"}]}}");
+ }
+
+ @Test
+ public void sdJwtIgnoresDoctype() throws JsonProcessingException {
+ final CredentialConfiguration configuration = CredentialConfiguration
+ .parse("{\"format\":\"dc+sd-jwt\",\"vct\":\"Diploma\"}");
+ Assert.assertNull(configuration.getDoctype());
+ Assert.assertTrue(configuration.getIssuanceConfiguration() == null);
+ }
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CborSupport.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CborSupport.java
new file mode 100644
index 0000000..9e8b490
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CborSupport.java
@@ -0,0 +1,196 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.cose.impl;
+
+import java.io.ByteArrayOutputStream;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import net.shibboleth.shared.codec.EncodingException;
+
+/**
+ * CBOR encoder, RFC 8949.
+ */
+public final class CborSupport {
+
+ /** Major type of an unsigned integer. */
+ private static final int UNSIGNED = 0;
+
+ /** Major type of a negative integer. */
+ private static final int NEGATIVE = 1;
+
+ /** Major type of a byte string. */
+ private static final int BYTES = 2;
+
+ /** Major type of a text string. */
+ private static final int TEXT = 3;
+
+ /** Major type of an array. */
+ private static final int ARRAY = 4;
+
+ /** Major type of a map. */
+ private static final int MAP = 5;
+
+ /** Major type of a tag. */
+ private static final int TAG = 6;
+
+ /** Encoding of false. */
+ private static final int FALSE = 0xf4;
+
+ /** Encoding of true. */
+ private static final int TRUE = 0xf5;
+
+ /** Encoding of null. */
+ private static final int NULL = 0xf6;
+
+ /** Largest unsigned value of 64 bits. */
+ @Nonnull
+ private static final BigInteger MAX_UNSIGNED = BigInteger.ONE.shiftLeft(64).subtract(BigInteger.ONE);
+
+ /** Constructor. */
+ private CborSupport() {
+ }
+
+ /**
+ * Encode a data item.
+ *
+ * @param item data item
+ * @return encoding
+ * @throws EncodingException if the item or one it contains is of no supported type
+ */
+ @Nonnull
+ public static byte[] encode(@Nullable final Object item) throws EncodingException {
+ final ByteArrayOutputStream out = new ByteArrayOutputStream();
+ write(out, item);
+ return out.toByteArray();
+ }
+
+ /**
+ * Write a data item.
+ *
+ * @param out output
+ * @param item data item
+ * @throws EncodingException if the item is of no supported type
+ */
+ private static void write(@Nonnull final ByteArrayOutputStream out, @Nullable final Object item)
+ throws EncodingException {
+ if (item == null) {
+ out.write(NULL);
+ } else if (item instanceof Boolean flag) {
+ out.write(flag ? TRUE : FALSE);
+ } else if (item instanceof Integer || item instanceof Long) {
+ writeInteger(out, BigInteger.valueOf(((Number) item).longValue()));
+ } else if (item instanceof BigInteger number) {
+ writeInteger(out, number);
+ } else if (item instanceof String text) {
+ final byte[] utf8 = text.getBytes(StandardCharsets.UTF_8);
+ writeHead(out, TEXT, BigInteger.valueOf(utf8.length));
+ out.writeBytes(utf8);
+ } else if (item instanceof byte[] bytes) {
+ writeHead(out, BYTES, BigInteger.valueOf(bytes.length));
+ out.writeBytes(bytes);
+ } else {
+ writeStructure(out, item);
+ }
+ }
+
+ /**
+ * Write an array, a map or a tagged data item.
+ *
+ * @param out output
+ * @param item data item
+ * @throws EncodingException if the item is of no supported type
+ */
+ private static void writeStructure(@Nonnull final ByteArrayOutputStream out, @Nonnull final Object item)
+ throws EncodingException {
+ if (item instanceof List<?> list) {
+ writeHead(out, ARRAY, BigInteger.valueOf(list.size()));
+ for (final Object element : list) {
+ write(out, element);
+ }
+ } else if (item instanceof Map<?, ?> map) {
+ writeHead(out, MAP, BigInteger.valueOf(map.size()));
+ for (final Map.Entry<?, ?> entry : map.entrySet()) {
+ write(out, entry.getKey());
+ write(out, entry.getValue());
+ }
+ } else if (item instanceof CborTag tagged) {
+ writeHead(out, TAG, BigInteger.valueOf(tagged.tag()));
+ write(out, tagged.value());
+ } else {
+ throw new EncodingException("No CBOR encoding for " + item.getClass().getName());
+ }
+ }
+
+ /**
+ * Write an integer.
+ *
+ * @param out output
+ * @param number integer
+ * @throws EncodingException if the integer does not fit 64 bits
+ */
+ private static void writeInteger(@Nonnull final ByteArrayOutputStream out, @Nonnull final BigInteger number)
+ throws EncodingException {
+ if (number.signum() >= 0) {
+ writeHead(out, UNSIGNED, number);
+ } else {
+ writeHead(out, NEGATIVE, number.negate().subtract(BigInteger.ONE));
+ }
+ }
+
+ /**
+ * Write the initial byte and argument of a data item.
+ *
+ * @param out output
+ * @param majorType major type
+ * @param argument argument
+ * @throws EncodingException if the argument does not fit 64 bits
+ */
+ private static void writeHead(@Nonnull final ByteArrayOutputStream out, final int majorType,
+ @Nonnull final BigInteger argument) throws EncodingException {
+ if (argument.signum() < 0 || argument.compareTo(MAX_UNSIGNED) > 0) {
+ throw new EncodingException("CBOR argument out of range: " + argument);
+ }
+ final int head = majorType << 5;
+ final int additional;
+ final int length;
+ if (argument.compareTo(BigInteger.valueOf(24)) < 0) {
+ out.write(head | argument.intValue());
+ return;
+ } else if (argument.bitLength() <= 8) {
+ additional = 24;
+ length = 1;
+ } else if (argument.bitLength() <= 16) {
+ additional = 25;
+ length = 2;
+ } else if (argument.bitLength() <= 32) {
+ additional = 26;
+ length = 4;
+ } else {
+ additional = 27;
+ length = 8;
+ }
+ out.write(head | additional);
+ for (int shift = (length - 1) * 8; shift >= 0; shift -= 8) {
+ out.write(argument.shiftRight(shift).intValue() & 0xff);
+ }
+ }
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CborTag.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CborTag.java
new file mode 100644
index 0000000..d0be166
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CborTag.java
@@ -0,0 +1,62 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.cose.impl;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * A CBOR tagged data item, RFC 8949 section 3.4.
+ *
+ * @param tag tag number
+ * @param value tagged data item
+ */
+public record CborTag(long tag, @Nonnull Object value) {
+
+ /** Standard date/time string. */
+ public static final long DATE_TIME = 0;
+
+ /** Encoded CBOR data item. */
+ public static final long ENCODED_CBOR = 24;
+
+ /** COSE_Sign1 message. */
+ public static final long COSE_SIGN1 = 18;
+
+ /** Full date string, RFC 8943. */
+ public static final long FULL_DATE = 1004;
+
+ /**
+ * Constructor.
+ *
+ * @param tag tag number
+ * @param value tagged data item
+ */
+ public CborTag {
+ Constraint.isNotNull(value, "Tagged data item cannot be null");
+ }
+
+ /**
+ * Wrap an encoded data item in tag 24.
+ *
+ * @param encoded encoded data item
+ * @return tagged byte string
+ */
+ @Nonnull
+ public static CborTag encodedCbor(@Nonnull final byte[] encoded) {
+ return new CborTag(ENCODED_CBOR, encoded);
+ }
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CoseAlgorithms.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CoseAlgorithms.java
new file mode 100644
index 0000000..7614545
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CoseAlgorithms.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.cose.impl;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * COSE algorithm identifiers of the ECDSA signatures this plugin makes, and their JOSE and JCA names.
+ */
+public final class CoseAlgorithms {
+
+ /** ECDSA w/ SHA-256. */
+ public static final int ES256 = -7;
+
+ /** ECDSA w/ SHA-384. */
+ public static final int ES384 = -35;
+
+ /** ECDSA w/ SHA-512. */
+ public static final int ES512 = -36;
+
+ /** COSE identifier by JOSE name. */
+ @Nonnull
+ private static final Map<String, Integer> BY_JOSE_NAME = Map.of("ES256", ES256, "ES384", ES384, "ES512", ES512);
+
+ /** JOSE name by COSE identifier, fully specified ones included. */
+ @Nonnull
+ private static final Map<String, String> JOSE_NAME = Map.of("-7", "ES256", "-9", "ES256", "-35", "ES384",
+ "-51", "ES384", "-36", "ES512", "-52", "ES512");
+
+ /** JCA name of a signature in the IEEE P1363 form COSE uses, by COSE identifier. */
+ @Nonnull
+ private static final Map<Integer, String> JCA_NAME = Map.of(ES256, "SHA256withECDSAinP1363Format", ES384,
+ "SHA384withECDSAinP1363Format", ES512, "SHA512withECDSAinP1363Format");
+
+ /** Constructor. */
+ private CoseAlgorithms() {
+ }
+
+ /**
+ * Get the COSE identifier of a JOSE algorithm.
+ *
+ * @param joseName JOSE name
+ * @return COSE identifier, or null for none supported
+ */
+ @Nullable
+ public static Integer fromJoseName(@Nullable final String joseName) {
+ return joseName == null ? null : BY_JOSE_NAME.get(joseName);
+ }
+
+ /**
+ * Get the JCA name of a COSE algorithm.
+ *
+ * @param algorithm COSE identifier
+ * @return JCA name, or null for none supported
+ */
+ @Nullable
+ public static String jcaName(final int algorithm) {
+ return JCA_NAME.get(algorithm);
+ }
+
+ /**
+ * Translate COSE identifiers to JOSE names.
+ *
+ * @param algorithms COSE identifiers as strings
+ * @return JOSE names of the supported ones
+ */
+ @Nonnull
+ public static List<String> toJoseNames(@Nonnull final List<String> algorithms) {
+ final List<String> names = new ArrayList<>();
+ for (final String algorithm : algorithms) {
+ final String name = algorithm == null ? null : JOSE_NAME.get(algorithm.trim());
+ if (name != null && !names.contains(name)) {
+ names.add(name);
+ }
+ }
+ return names;
+ }
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CoseKeySupport.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CoseKeySupport.java
new file mode 100644
index 0000000..6c9c15d
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CoseKeySupport.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.cose.impl;
+
+import java.math.BigInteger;
+import java.security.interfaces.ECPublicKey;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.shared.codec.EncodingException;
+
+/**
+ * COSE_Key, RFC 9052 section 7, of an EC2 public key.
+ */
+public final class CoseKeySupport {
+
+ /** Label of the key type. */
+ public static final int KTY = 1;
+
+ /** Key type EC2. */
+ public static final int EC2 = 2;
+
+ /** Label of the curve. */
+ public static final int CRV = -1;
+
+ /** Label of the x coordinate. */
+ public static final int X = -2;
+
+ /** Label of the y coordinate. */
+ public static final int Y = -3;
+
+ /** Curve P-256. */
+ public static final int P256 = 1;
+
+ /** Curve P-384. */
+ public static final int P384 = 2;
+
+ /** Curve P-521. */
+ public static final int P521 = 3;
+
+ /** Constructor. */
+ private CoseKeySupport() {
+ }
+
+ /**
+ * Express an EC public key as a COSE_Key.
+ *
+ * @param key public key
+ * @return COSE_Key
+ * @throws EncodingException if the curve is none of P-256, P-384 and P-521
+ */
+ @Nonnull
+ public static Map<Object, Object> toCoseKey(@Nonnull final ECPublicKey key) throws EncodingException {
+ final int size = key.getParams().getCurve().getField().getFieldSize();
+ final int curve;
+ switch (size) {
+ case 256:
+ curve = P256;
+ break;
+ case 384:
+ curve = P384;
+ break;
+ case 521:
+ curve = P521;
+ break;
+ default:
+ throw new EncodingException("No COSE curve of " + size + " bits");
+ }
+ final int length = (size + 7) / 8;
+ final Map<Object, Object> coseKey = new LinkedHashMap<>();
+ coseKey.put(KTY, EC2);
+ coseKey.put(CRV, curve);
+ coseKey.put(X, unsigned(key.getW().getAffineX(), length));
+ coseKey.put(Y, unsigned(key.getW().getAffineY(), length));
+ return coseKey;
+ }
+
+ /**
+ * Write a coordinate as a big-endian unsigned integer of fixed length.
+ *
+ * @param value coordinate
+ * @param length length in bytes
+ * @return coordinate bytes
+ */
+ @Nonnull
+ private static byte[] unsigned(@Nonnull final BigInteger value, final int length) {
+ final byte[] bytes = value.toByteArray();
+ final byte[] fixed = new byte[length];
+ final int copy = Math.min(bytes.length, length);
+ System.arraycopy(bytes, bytes.length - copy, fixed, length - copy, copy);
+ return fixed;
+ }
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CoseSign1Signer.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CoseSign1Signer.java
new file mode 100644
index 0000000..976c136
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CoseSign1Signer.java
@@ -0,0 +1,260 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.cose.impl;
+
+import java.security.GeneralSecurityException;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.Signature;
+import java.security.cert.CertificateEncodingException;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import net.shibboleth.shared.codec.EncodingException;
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * COSE_Sign1 signer with ECDSA, RFC 9052.
+ */
+public final class CoseSign1Signer {
+
+ /** Label of the algorithm header. */
+ public static final int ALG = 1;
+
+ /** Label of the key identifier header. */
+ public static final int KID = 4;
+
+ /** Label of the content type header. */
+ public static final int TYP = 16;
+
+ /** Label of the certificate chain header. */
+ public static final int X5CHAIN = 33;
+
+ /** Context of a COSE_Sign1 signature. */
+ @Nonnull
+ private static final String CONTEXT = "Signature1";
+
+ /** Signing key. */
+ @Nonnull
+ private final PrivateKey privateKey;
+
+ /** COSE algorithm. */
+ private final int algorithm;
+
+ /** Certificate chain naming the key, or null. */
+ @Nullable
+ private final List<X509Certificate> chain;
+
+ /** Key identifier naming the key, or null. */
+ @Nullable
+ private final byte[] keyId;
+
+ /** Whether the certificate chain goes to the protected headers. */
+ private final boolean chainProtected;
+
+ /**
+ * Constructor.
+ *
+ * @param key signing key
+ * @param alg COSE algorithm, one of {@link CoseAlgorithms}
+ * @param certificates certificate chain naming the key, or null
+ * @param kid key identifier naming the key when there is no chain, or null
+ * @throws EncodingException if the algorithm is not supported
+ */
+ public CoseSign1Signer(@Nonnull final PrivateKey key, final int alg,
+ @Nullable final List<X509Certificate> certificates, @Nullable final byte[] kid)
+ throws EncodingException {
+ this(key, alg, certificates, kid, false);
+ }
+
+ /**
+ * Constructor.
+ *
+ * @param key signing key
+ * @param alg COSE algorithm, one of {@link CoseAlgorithms}
+ * @param certificates certificate chain naming the key, or null
+ * @param kid key identifier naming the key when there is no chain, or null
+ * @param protectChain whether the certificate chain goes to the protected headers
+ * @throws EncodingException if the algorithm is not supported
+ */
+ public CoseSign1Signer(@Nonnull final PrivateKey key, final int alg,
+ @Nullable final List<X509Certificate> certificates, @Nullable final byte[] kid,
+ final boolean protectChain) throws EncodingException {
+ if (CoseAlgorithms.jcaName(alg) == null) {
+ throw new EncodingException("Unsupported COSE algorithm " + alg);
+ }
+ privateKey = Constraint.isNotNull(key, "Signing key cannot be null");
+ algorithm = alg;
+ chain = certificates == null || certificates.isEmpty() ? null : List.copyOf(certificates);
+ keyId = kid;
+ chainProtected = protectChain;
+ }
+
+ /**
+ * Sign a payload.
+ *
+ * @param payload payload
+ * @param protectedExtras protected headers in addition to the algorithm, or null
+ * @return the COSE_Sign1 array
+ * @throws EncodingException if signing fails
+ */
+ @Nonnull
+ public List<Object> sign(@Nonnull final byte[] payload, @Nullable final Map<Object, Object> protectedExtras)
+ throws EncodingException {
+ final Map<Object, Object> protectedHeaders = new LinkedHashMap<>();
+ protectedHeaders.put(ALG, algorithm);
+ if (protectedExtras != null) {
+ protectedHeaders.putAll(protectedExtras);
+ }
+ if (chain != null && chainProtected) {
+ protectedHeaders.put(X5CHAIN, encodedChain());
+ }
+ final byte[] protectedBytes = CborSupport.encode(protectedHeaders);
+ final List<Object> sign1 = new ArrayList<>(4);
+ sign1.add(protectedBytes);
+ sign1.add(unprotectedHeaders());
+ sign1.add(payload);
+ sign1.add(signature(toBeSigned(protectedBytes, payload)));
+ return sign1;
+ }
+
+ /**
+ * Build the Sig_structure of a COSE_Sign1 with no external data.
+ *
+ * @param protectedBytes encoded protected headers
+ * @param payload payload
+ * @return encoded Sig_structure
+ * @throws EncodingException if encoding fails
+ */
+ @Nonnull
+ public static byte[] toBeSigned(@Nonnull final byte[] protectedBytes, @Nonnull final byte[] payload)
+ throws EncodingException {
+ return CborSupport.encode(List.of(CONTEXT, protectedBytes, new byte[0], payload));
+ }
+
+ /**
+ * Build the unprotected headers.
+ *
+ * @return unprotected headers
+ * @throws EncodingException if a certificate cannot be encoded
+ */
+ @Nonnull
+ private Map<Object, Object> unprotectedHeaders() throws EncodingException {
+ final Map<Object, Object> headers = new LinkedHashMap<>();
+ if (chain != null) {
+ if (!chainProtected) {
+ headers.put(X5CHAIN, encodedChain());
+ }
+ } else if (keyId != null) {
+ headers.put(KID, keyId);
+ }
+ return headers;
+ }
+
+ /**
+ * Encode the certificate chain, a byte string for one certificate and an array for more.
+ *
+ * @return encoded chain
+ * @throws EncodingException if a certificate cannot be encoded
+ */
+ @Nonnull
+ private Object encodedChain() throws EncodingException {
+ assert chain != null;
+ final List<Object> encoded = new ArrayList<>(chain.size());
+ try {
+ for (final X509Certificate certificate : chain) {
+ encoded.add(certificate.getEncoded());
+ }
+ } catch (final CertificateEncodingException e) {
+ throw new EncodingException("Unable to encode the certificate chain", e);
+ }
+ return encoded.size() == 1 ? encoded.get(0) : encoded;
+ }
+
+ /**
+ * Sign with ECDSA, the signature as r and s concatenated.
+ *
+ * @param toBeSigned bytes to sign
+ * @return signature
+ * @throws EncodingException if signing fails
+ */
+ @Nonnull
+ private byte[] signature(@Nonnull final byte[] toBeSigned) throws EncodingException {
+ final String jcaName = CoseAlgorithms.jcaName(algorithm);
+ assert jcaName != null;
+ try {
+ Signature signer;
+ boolean der = false;
+ try {
+ signer = Signature.getInstance(jcaName);
+ } catch (final NoSuchAlgorithmException e) {
+ signer = Signature.getInstance(jcaName.replace("inP1363Format", ""));
+ der = true;
+ }
+ signer.initSign(privateKey);
+ signer.update(toBeSigned);
+ final byte[] signature = signer.sign();
+ return der ? derToConcat(signature, coordinateLength()) : signature;
+ } catch (final GeneralSecurityException e) {
+ throw new EncodingException("Unable to sign", e);
+ }
+ }
+
+ /**
+ * Get the length of one signature coordinate of the algorithm.
+ *
+ * @return length in bytes
+ */
+ private int coordinateLength() {
+ if (algorithm == CoseAlgorithms.ES384) {
+ return 48;
+ }
+ return algorithm == CoseAlgorithms.ES512 ? 66 : 32;
+ }
+
+ /**
+ * Convert a DER encoded ECDSA signature to r and s concatenated.
+ *
+ * @param der DER encoded signature
+ * @param length length of one coordinate
+ * @return r and s concatenated
+ * @throws EncodingException if the signature is not a DER sequence of two integers
+ */
+ @Nonnull
+ static byte[] derToConcat(@Nonnull final byte[] der, final int length) throws EncodingException {
+ try {
+ int offset = 1;
+ offset += (der[offset] & 0x80) != 0 ? 1 + (der[offset] & 0x7f) : 1;
+ final byte[] concat = new byte[2 * length];
+ for (int part = 0; part < 2; part++) {
+ final int size = der[offset + 1] & 0xff;
+ final int start = offset + 2;
+ final int copy = Math.min(size, length);
+ System.arraycopy(der, start + size - copy, concat, part * length + length - copy, copy);
+ offset = start + size;
+ }
+ return concat;
+ } catch (final ArrayIndexOutOfBoundsException e) {
+ throw new EncodingException("Malformed DER signature", e);
+ }
+ }
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/package-info.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/package-info.java
new file mode 100644
index 0000000..18130b3
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/cose/impl/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * CBOR and COSE encoding.
+ */
+package org.geant.shibboleth.plugin.openidvci.cose.impl;
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MdocElements.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MdocElements.java
new file mode 100644
index 0000000..58605ca
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MdocElements.java
@@ -0,0 +1,202 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.credential.impl;
+
+import java.math.BigInteger;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CborTag;
+import org.geant.shibboleth.plugin.openidvci.credential.ClaimType;
+
+import net.shibboleth.shared.codec.Base64Support;
+import net.shibboleth.shared.codec.DecodingException;
+import net.shibboleth.shared.codec.EncodingException;
+
+/**
+ * Data elements of an mdoc from claims given as a path and a value.
+ */
+public final class MdocElements {
+
+ /** Constructor. */
+ private MdocElements() {
+ }
+
+ /**
+ * Group claims into element values by element identifier by name space.
+ *
+ * @param claims claims as path and value
+ * @param types type of a claim value by claim path
+ * @return element values by element identifier by name space
+ * @throws EncodingException if a path is short, a value cannot take its type or two claims collide
+ */
+ @Nonnull
+ public static Map<String, Map<String, Object>> collect(
+ @Nonnull final List<? extends Map.Entry<List<String>, ?>> claims,
+ @Nonnull final Map<List<String>, String> types) throws EncodingException {
+ final Map<String, Map<String, Object>> nameSpaces = new LinkedHashMap<>();
+ for (final Map.Entry<List<String>, ?> claim : claims) {
+ final List<String> path = claim.getKey();
+ if (path.size() < 2) {
+ throw new EncodingException("Claim path " + path + " names no name space and data element");
+ }
+ final Object value = toCbor(claim.getValue(), types.get(path));
+ final Map<String, Object> elements = nameSpaces.computeIfAbsent(path.get(0), ns -> new LinkedHashMap<>());
+ if (path.size() == 2) {
+ if (elements.containsKey(path.get(1))) {
+ throw new EncodingException("Claim path " + path + " given twice");
+ }
+ elements.put(path.get(1), value);
+ } else {
+ place(elements, path, 1, value);
+ }
+ }
+ return nameSpaces;
+ }
+
+ /**
+ * Place a value in nested maps along a path.
+ *
+ * @param parent map to place in
+ * @param path claim path
+ * @param index index of the path component naming the entry of the parent
+ * @param value value
+ * @throws EncodingException if the path passes a value that is not a map
+ */
+ @SuppressWarnings("unchecked")
+ private static void place(@Nonnull final Map<String, Object> parent, @Nonnull final List<String> path,
+ final int index, @Nonnull final Object value) throws EncodingException {
+ final String name = path.get(index);
+ if (index == path.size() - 1) {
+ if (parent.containsKey(name)) {
+ throw new EncodingException("Claim path " + path + " given twice");
+ }
+ parent.put(name, value);
+ return;
+ }
+ final Object child = parent.computeIfAbsent(name, n -> new LinkedHashMap<String, Object>());
+ if (!(child instanceof Map)) {
+ throw new EncodingException("Claim path " + path + " passes a value that is not a map");
+ }
+ place((Map<String, Object>) child, path, index + 1, value);
+ }
+
+ /**
+ * Convert a JSON value to a CBOR data item.
+ *
+ * @param value JSON value
+ * @param type type from {@link ClaimType}, or null for the type of the JSON value
+ * @return CBOR data item
+ * @throws EncodingException if the value cannot take the type
+ */
+ @Nonnull
+ public static Object toCbor(@Nullable final Object value, @Nullable final String type) throws EncodingException {
+ if (value == null) {
+ throw new EncodingException("Claim value is null");
+ }
+ if (value instanceof List<?> list) {
+ final List<Object> items = new ArrayList<>(list.size());
+ for (final Object item : list) {
+ items.add(toCbor(item, type));
+ }
+ return items;
+ }
+ return type != null ? typed(value, type) : untyped(value);
+ }
+
+ /**
+ * Convert a JSON value to a CBOR data item of the type of the JSON value.
+ *
+ * @param value JSON value
+ * @return CBOR data item
+ * @throws EncodingException if the value is a decimal number
+ */
+ @Nonnull
+ private static Object untyped(@Nonnull final Object value) throws EncodingException {
+ if (value instanceof Map<?, ?> map) {
+ final Map<Object, Object> entries = new LinkedHashMap<>();
+ for (final Map.Entry<?, ?> entry : map.entrySet()) {
+ entries.put(String.valueOf(entry.getKey()), toCbor(entry.getValue(), null));
+ }
+ return entries;
+ }
+ if (value instanceof String || value instanceof Boolean || value instanceof Integer
+ || value instanceof Long || value instanceof BigInteger) {
+ return value;
+ }
+ throw new EncodingException("Claim value " + value + " of " + value.getClass().getSimpleName()
+ + " has no mdoc encoding");
+ }
+
+ /**
+ * Convert a value to a CBOR data item of a type.
+ *
+ * @param value value
+ * @param type type from {@link ClaimType}
+ * @return CBOR data item
+ * @throws EncodingException if the value cannot take the type
+ */
+ @Nonnull
+ private static Object typed(@Nonnull final Object value, @Nonnull final String type) throws EncodingException {
+ final String text = String.valueOf(value).trim();
+ try {
+ switch (type) {
+ case ClaimType.FULL_DATE:
+ return new CborTag(CborTag.FULL_DATE, LocalDate.parse(text).toString());
+ case ClaimType.TDATE:
+ return MobileSecurityObject.tdate(instant(value, text));
+ case ClaimType.BSTR:
+ return Base64Support.decodeURLSafe(text);
+ case ClaimType.INT:
+ return new BigInteger(text);
+ case ClaimType.BOOL:
+ if ("true".equalsIgnoreCase(text) || "false".equalsIgnoreCase(text)) {
+ return Boolean.valueOf(text);
+ }
+ break;
+ default:
+ break;
+ }
+ } catch (final DateTimeParseException | IllegalArgumentException | DecodingException e) {
+ throw new EncodingException("Claim value '" + text + "' is no " + type, e);
+ }
+ throw new EncodingException("Claim value '" + text + "' is no " + type);
+ }
+
+ /**
+ * Read an instant from epoch seconds or an ISO-8601 date and time.
+ *
+ * @param value value
+ * @param text value as text
+ * @return instant
+ */
+ @Nonnull
+ private static Instant instant(@Nonnull final Object value, @Nonnull final String text) {
+ if (value instanceof Number || text.chars().allMatch(Character::isDigit)) {
+ return Instant.ofEpochSecond(Long.parseLong(text));
+ }
+ return OffsetDateTime.parse(text).toInstant();
+ }
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MobileSecurityObject.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MobileSecurityObject.java
new file mode 100644
index 0000000..3bc77cc
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MobileSecurityObject.java
@@ -0,0 +1,268 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.credential.impl;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CborSupport;
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CborTag;
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CoseSign1Signer;
+
+import net.shibboleth.shared.codec.EncodingException;
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * Mobile security object and the issuer signed items of one mdoc, ISO/IEC 18013-5 section 9.1.2.
+ */
+public final class MobileSecurityObject {
+
+ /** Version of the mobile security object. */
+ @Nonnull
+ public static final String VERSION = "1.0";
+
+ /** Digest algorithm of the value digests. */
+ @Nonnull
+ public static final String DIGEST_ALGORITHM = "SHA-256";
+
+ /** Length of the random salt of an item. */
+ private static final int RANDOM_LENGTH = 16;
+
+ /** Source of salts and digest identifiers. */
+ @Nonnull
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ /** Document type. */
+ @Nonnull
+ private final String docType;
+
+ /** Device key as a COSE_Key. */
+ @Nonnull
+ private final Map<Object, Object> deviceKey;
+
+ /** Instant of signing. */
+ @Nonnull
+ private final Instant signed;
+
+ /** Start of validity. */
+ @Nonnull
+ private final Instant validFrom;
+
+ /** End of validity. */
+ @Nonnull
+ private final Instant validUntil;
+
+ /** Status, or null. */
+ @Nullable
+ private Map<String, Object> status;
+
+ /** Element values by element identifier by name space. */
+ @Nonnull
+ private final Map<String, Map<String, Object>> nameSpaces = new LinkedHashMap<>();
+
+ /**
+ * Constructor.
+ *
+ * @param type document type
+ * @param key device key as a COSE_Key
+ * @param issued instant of signing
+ * @param notBefore start of validity
+ * @param expires end of validity
+ */
+ public MobileSecurityObject(@Nonnull final String type, @Nonnull final Map<Object, Object> key,
+ @Nonnull final Instant issued, @Nonnull final Instant notBefore, @Nonnull final Instant expires) {
+ docType = Constraint.isNotNull(type, "Document type cannot be null");
+ deviceKey = Constraint.isNotNull(key, "Device key cannot be null");
+ signed = Constraint.isNotNull(issued, "Signing instant cannot be null");
+ validFrom = Constraint.isNotNull(notBefore, "Start of validity cannot be null");
+ validUntil = Constraint.isNotNull(expires, "End of validity cannot be null");
+ }
+
+ /**
+ * Set the status list entry of the mdoc.
+ *
+ * @param index slot in the status list
+ * @param uri uri of the status list
+ */
+ public void setStatusList(final long index, @Nonnull final String uri) {
+ final Map<String, Object> statusList = new LinkedHashMap<>();
+ statusList.put("idx", index);
+ statusList.put("uri", uri);
+ status = Map.of("status_list", statusList);
+ }
+
+ /**
+ * Add a data element.
+ *
+ * @param nameSpace name space
+ * @param elementIdentifier data element identifier
+ * @param elementValue data element value as a CBOR data item
+ * @throws EncodingException if the name space already has the element
+ */
+ public void addElement(@Nonnull final String nameSpace, @Nonnull final String elementIdentifier,
+ @Nonnull final Object elementValue) throws EncodingException {
+ final Map<String, Object> elements = nameSpaces.computeIfAbsent(nameSpace, ns -> new LinkedHashMap<>());
+ if (elements.containsKey(elementIdentifier)) {
+ throw new EncodingException("Element " + elementIdentifier + " of " + nameSpace + " given twice");
+ }
+ elements.put(elementIdentifier, elementValue);
+ }
+
+ /**
+ * Get the data elements by element identifier by name space.
+ *
+ * @return data elements
+ */
+ @Nonnull
+ public Map<String, Map<String, Object>> getNameSpaces() {
+ return Collections.unmodifiableMap(nameSpaces);
+ }
+
+ /**
+ * Build, sign and encode the IssuerSigned structure.
+ *
+ * @param signer signer of the issuer authentication
+ * @return encoded IssuerSigned
+ * @throws EncodingException if encoding or signing fails
+ */
+ @Nonnull
+ public byte[] encodeIssuerSigned(@Nonnull final CoseSign1Signer signer) throws EncodingException {
+ final Map<String, Object> issuerNameSpaces = new LinkedHashMap<>();
+ final Map<String, Object> valueDigests = new LinkedHashMap<>();
+ for (final Map.Entry<String, Map<String, Object>> nameSpace : nameSpaces.entrySet()) {
+ final List<Object> items = new ArrayList<>();
+ final Map<Object, Object> digests = new LinkedHashMap<>();
+ final List<Integer> digestIds = shuffledIds(nameSpace.getValue().size());
+ int ordinal = 0;
+ for (final Map.Entry<String, Object> element : nameSpace.getValue().entrySet()) {
+ final int digestId = digestIds.get(ordinal++);
+ final CborTag item = CborTag.encodedCbor(CborSupport.encode(
+ issuerSignedItem(digestId, element.getKey(), element.getValue())));
+ items.add(item);
+ digests.put(digestId, sha256(CborSupport.encode(item)));
+ }
+ issuerNameSpaces.put(nameSpace.getKey(), items);
+ valueDigests.put(nameSpace.getKey(), digests);
+ }
+ final byte[] payload = CborSupport.encode(CborTag.encodedCbor(CborSupport.encode(mso(valueDigests))));
+ final Map<String, Object> issuerSigned = new LinkedHashMap<>();
+ issuerSigned.put("nameSpaces", issuerNameSpaces);
+ issuerSigned.put("issuerAuth", signer.sign(payload, null));
+ return CborSupport.encode(issuerSigned);
+ }
+
+ /**
+ * Build an IssuerSignedItem.
+ *
+ * @param digestId digest identifier
+ * @param elementIdentifier data element identifier
+ * @param elementValue data element value
+ * @return IssuerSignedItem
+ */
+ @Nonnull
+ private static Map<String, Object> issuerSignedItem(final int digestId, @Nonnull final String elementIdentifier,
+ @Nonnull final Object elementValue) {
+ final byte[] random = new byte[RANDOM_LENGTH];
+ RANDOM.nextBytes(random);
+ final Map<String, Object> item = new LinkedHashMap<>();
+ item.put("digestID", digestId);
+ item.put("random", random);
+ item.put("elementIdentifier", elementIdentifier);
+ item.put("elementValue", elementValue);
+ return item;
+ }
+
+ /**
+ * Build the MobileSecurityObject.
+ *
+ * @param valueDigests value digests
+ * @return MobileSecurityObject
+ */
+ @Nonnull
+ private Map<String, Object> mso(@Nonnull final Map<String, Object> valueDigests) {
+ final Map<String, Object> validityInfo = new LinkedHashMap<>();
+ validityInfo.put("signed", tdate(signed));
+ validityInfo.put("validFrom", tdate(validFrom));
+ validityInfo.put("validUntil", tdate(validUntil));
+ final Map<String, Object> mso = new LinkedHashMap<>();
+ mso.put("version", VERSION);
+ mso.put("digestAlgorithm", DIGEST_ALGORITHM);
+ mso.put("valueDigests", valueDigests);
+ mso.put("deviceKeyInfo", Map.of("deviceKey", deviceKey));
+ mso.put("docType", docType);
+ mso.put("validityInfo", validityInfo);
+ if (status != null) {
+ mso.put("status", status);
+ }
+ return mso;
+ }
+
+ /**
+ * Express an instant as a tdate with no fraction of a second.
+ *
+ * @param instant instant
+ * @return tdate
+ */
+ @Nonnull
+ public static CborTag tdate(@Nonnull final Instant instant) {
+ return new CborTag(CborTag.DATE_TIME,
+ DateTimeFormatter.ISO_INSTANT.format(instant.truncatedTo(ChronoUnit.SECONDS)));
+ }
+
+ /**
+ * Get digest identifiers 0 to count - 1 in random order.
+ *
+ * @param count number of identifiers
+ * @return identifiers
+ */
+ @Nonnull
+ private static List<Integer> shuffledIds(final int count) {
+ final List<Integer> ids = new ArrayList<>(count);
+ for (int id = 0; id < count; id++) {
+ ids.add(id);
+ }
+ Collections.shuffle(ids, RANDOM);
+ return ids;
+ }
+
+ /**
+ * Digest with SHA-256.
+ *
+ * @param input input
+ * @return digest
+ * @throws EncodingException if SHA-256 is not available
+ */
+ @Nonnull
+ private static byte[] sha256(@Nonnull final byte[] input) throws EncodingException {
+ try {
+ return MessageDigest.getInstance("SHA-256").digest(input);
+ } catch (final NoSuchAlgorithmException e) {
+ throw new EncodingException("SHA-256 not available", e);
+ }
+ }
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/SelectiveDisclosureClaimSetUtil.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/SelectiveDisclosureClaimSetUtil.java
index b1dee54..896c725 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/SelectiveDisclosureClaimSetUtil.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/credential/impl/SelectiveDisclosureClaimSetUtil.java
@@ -20,7 +20,6 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
-import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -33,6 +32,8 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
+import net.shibboleth.shared.codec.Base64Support;
+import net.shibboleth.shared.codec.EncodingException;
import net.shibboleth.shared.security.IdentifierGenerationStrategy;
import net.shibboleth.shared.security.impl.SecureRandomIdentifierGenerationStrategy;
@@ -124,12 +125,16 @@ public class SelectiveDisclosureClaimSetUtil {
} catch (final NoSuchAlgorithmException e) {
throw new IllegalArgumentException("Unsupported disclosure digest algorithm " + algorithm, e);
}
- for (final Map.Entry<String, Object> claim : sdClaims.entrySet()) {
- final String disclosure = Base64.getUrlEncoder().withoutPadding()
- .encodeToString(encode(claim.getKey(), claim.getValue()).getBytes(StandardCharsets.UTF_8));
- urlEncodedDisclosures.add(disclosure);
- encodedDisclosures.add(Base64.getUrlEncoder().withoutPadding()
- .encodeToString(digest.digest(disclosure.getBytes(StandardCharsets.UTF_8))));
+ try {
+ for (final Map.Entry<String, Object> claim : sdClaims.entrySet()) {
+ final String disclosure = Base64Support.encodeURLSafe(
+ encode(claim.getKey(), claim.getValue()).getBytes(StandardCharsets.UTF_8));
+ urlEncodedDisclosures.add(disclosure);
+ encodedDisclosures.add(
+ Base64Support.encodeURLSafe(digest.digest(disclosure.getBytes(StandardCharsets.UTF_8))));
+ }
+ } catch (final EncodingException e) {
+ throw new IllegalArgumentException("Unable to encode disclosures", e);
}
// We mess up the order so it cannot be used to determine anything in '_sd'.
Collections.sort(encodedDisclosures);
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
index 548c4fa..9d79ef7 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/messaging/context/CredentialsContext.java
@@ -23,6 +23,7 @@ import java.util.List;
import javax.annotation.Nullable;
import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
+import org.geant.shibboleth.plugin.openidvci.credential.impl.MobileSecurityObject;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedCredential;
import org.opensaml.messaging.context.BaseContext;
@@ -86,6 +87,10 @@ public class CredentialsContext extends BaseContext {
@Nullable
private List<String> disclosures;
+ /** Per proof mobile security objects of mdoc credentials ready to be signed. */
+ @Nullable
+ private List<MobileSecurityObject> mobileSecurityObjects;
+
/**
* Get validated proofs of wallet.
*
@@ -342,4 +347,23 @@ public class CredentialsContext extends BaseContext {
this.signedJWTCredentials = signedCredentials;
}
+ /**
+ * Get per proof mobile security objects of mdoc credentials.
+ *
+ * @return Per proof mobile security objects
+ */
+ @Nullable
+ public List<MobileSecurityObject> getMobileSecurityObjects() {
+ return mobileSecurityObjects;
+ }
+
+ /**
+ * Set per proof mobile security objects of mdoc credentials.
+ *
+ * @param objects Per proof mobile security objects
+ */
+ public void setMobileSecurityObjects(@Nullable final List<MobileSecurityObject> objects) {
+ mobileSecurityObjects = objects;
+ }
+
}
\ No newline at end of file
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/navigate/CredentialSignatureSigningConfigurationLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/navigate/CredentialSignatureSigningConfigurationLookupFunction.java
index ef13df1..481b431 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/navigate/CredentialSignatureSigningConfigurationLookupFunction.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/navigate/CredentialSignatureSigningConfigurationLookupFunction.java
@@ -21,6 +21,7 @@ import java.util.function.Function;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CoseAlgorithms;
import org.geant.shibboleth.plugin.openidvci.profile.config.OpenIDVCIConfiguration;
import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
@@ -126,7 +127,11 @@ public class CredentialSignatureSigningConfigurationLookupFunction
return configuration;
}
- final List<String> supported = credentialConfiguration.getCredentialSigningAlgValuesSupported();
+ final List<String> declared = credentialConfiguration.getCredentialSigningAlgValuesSupported();
+ final List<String> supported = declared != null
+ && CredentialConfiguration.MSO_MDOC.equals(credentialConfiguration.getFormat())
+ ? CoseAlgorithms.toJoseNames(declared)
+ : declared;
if (supported == null || supported.isEmpty()) {
log.debug("Credential configuration declares no credential_signing_alg_values_supported, "
+ "signing algorithms not restricted");
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddClaimsToMobileSecurityObject.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddClaimsToMobileSecurityObject.java
new file mode 100644
index 0000000..4814c23
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddClaimsToMobileSecurityObject.java
@@ -0,0 +1,123 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.credential.ClaimType;
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialIssuanceConfiguration;
+import org.geant.shibboleth.plugin.openidvci.credential.impl.MdocElements;
+import org.geant.shibboleth.plugin.openidvci.credential.impl.MobileSecurityObject;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.CredentialOfferRequestedClaim;
+import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.codec.EncodingException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action that adds the credential content {@link CredentialsContext#getCredentialContent()} as data
+ * elements to each {@link MobileSecurityObject} of {@link CredentialsContext#getMobileSecurityObjects()}.
+ */
+public class AddClaimsToMobileSecurityObject extends AbstractProfileAction {
+
+ /** Class logger. */
+ @Nonnull
+ private Logger log = LoggerFactory.getLogger(AddClaimsToMobileSecurityObject.class);
+
+ /** Credentials context. */
+ @NonnullBeforeExec
+ private CredentialsContext ctx;
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ if (!super.doPreExecute(profileRequestContext)) {
+ return false;
+ }
+ ctx = profileRequestContext.getInboundMessageContext() == null ? null
+ : profileRequestContext.getInboundMessageContext().getSubcontext(CredentialsContext.class);
+ if (ctx == null || ctx.getCredentialConfiguration() == null) {
+ log.error("{} No credentials context", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+ if (ctx.getCredentialContent() == null) {
+ log.error("{} No credential content in context", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
+ return false;
+ }
+ if (ctx.getMobileSecurityObjects() == null || ctx.getMobileSecurityObjects().isEmpty()) {
+ log.error("{} No mobile security objects in context", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
+ return false;
+ }
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ final List<Map.Entry<List<String>, Object>> claims = new ArrayList<>();
+ for (final CredentialOfferRequestedClaim claim : ctx.getCredentialContent().getRequestedCredential()) {
+ claims.add(Map.entry(claim.getPath(), claim.getValue()));
+ }
+ try {
+ final Map<String, Map<String, Object>> nameSpaces = MdocElements.collect(claims, claimTypes());
+ for (final MobileSecurityObject mso : ctx.getMobileSecurityObjects()) {
+ for (final Map.Entry<String, Map<String, Object>> nameSpace : nameSpaces.entrySet()) {
+ for (final Map.Entry<String, Object> element : nameSpace.getValue().entrySet()) {
+ mso.addElement(nameSpace.getKey(), element.getKey(), element.getValue());
+ }
+ }
+ }
+ } catch (final EncodingException e) {
+ log.error("{} Unable to form the data elements: {}", getLogPrefix(), e.getMessage());
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
+ }
+ }
+
+ /**
+ * Get the types of claim values by claim path.
+ *
+ * @return types by claim path
+ */
+ @Nonnull
+ private Map<List<String>, String> claimTypes() {
+ final Map<List<String>, String> types = new HashMap<>();
+ final CredentialIssuanceConfiguration settings = ctx.getCredentialConfiguration().getIssuanceConfiguration();
+ if (settings != null) {
+ for (final ClaimType type : settings.getClaimTypes()) {
+ types.put(type.getPath(), type.getType());
+ }
+ }
+ return types;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddMobileSecurityObjectShell.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddMobileSecurityObjectShell.java
new file mode 100644
index 0000000..73b4aa6
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/AddMobileSecurityObjectShell.java
@@ -0,0 +1,187 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import java.security.interfaces.ECPublicKey;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CoseKeySupport;
+import org.geant.shibboleth.plugin.openidvci.credential.impl.MobileSecurityObject;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
+import org.geant.shibboleth.plugin.openidvci.security.impl.DidSupport;
+import org.geant.shibboleth.plugin.openidvci.statuslist.context.StatusListContext;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.slf4j.Logger;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.JWK;
+import com.nimbusds.jose.util.Base64URL;
+import com.nimbusds.jwt.SignedJWT;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.codec.EncodingException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action that creates a {@link MobileSecurityObject} per proof and sets them to
+ * {@link CredentialsContext#setMobileSecurityObjects(List)}.
+ */
+public class AddMobileSecurityObjectShell extends AbstractProfileAction {
+
+ /** Class logger. */
+ @Nonnull
+ private Logger log = LoggerFactory.getLogger(AddMobileSecurityObjectShell.class);
+
+ /** Credentials context. */
+ @NonnullBeforeExec
+ private CredentialsContext ctx;
+
+ /** Document type. */
+ @NonnullBeforeExec
+ private String docType;
+
+ /** Status list context if allocated for this issuance. */
+ @Nullable
+ private StatusListContext statusListContext;
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ if (!super.doPreExecute(profileRequestContext)) {
+ return false;
+ }
+ ctx = profileRequestContext.getInboundMessageContext() == null ? null
+ : profileRequestContext.getInboundMessageContext().getSubcontext(CredentialsContext.class);
+ if (ctx == null || ctx.getCredentialConfiguration() == null) {
+ log.error("{} No credentials context", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+ docType = ctx.getCredentialConfiguration().getDoctype();
+ if (docType == null) {
+ log.error("{} Credential configuration has no doctype", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+ return false;
+ }
+ if (!hasWindowAndProofs(profileRequestContext)) {
+ return false;
+ }
+ if (profileRequestContext.getOutboundMessageContext() != null) {
+ statusListContext = profileRequestContext.getOutboundMessageContext()
+ .getSubcontext(StatusListContext.class);
+ }
+ return true;
+ }
+
+ /**
+ * Check that the validity window is resolved and the request carries proofs.
+ *
+ * @param profileRequestContext profile request context
+ * @return true if both are there
+ */
+ private boolean hasWindowAndProofs(@Nonnull final ProfileRequestContext profileRequestContext) {
+ if (ctx.getIssuedAt() == null || ctx.getNotBefore() == null || ctx.getExpiresAt() == null) {
+ log.error("{} Credential validity window has not been resolved for this profile request", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+ return false;
+ }
+ if (ctx.getProofs() == null || ctx.getProofs().isEmpty()) {
+ log.error("{} An mdoc is bound to a device key, the request carries no proof", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_PROOF);
+ return false;
+ }
+ return true;
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ final List<MobileSecurityObject> objects = new ArrayList<>();
+ final List<SignedJWT> proofs = ctx.getProofs();
+ for (int ordinal = 0; ordinal < proofs.size(); ordinal++) {
+ final ECPublicKey deviceKey;
+ final MobileSecurityObject mso;
+ try {
+ deviceKey = deviceKey(proofs.get(ordinal));
+ if (deviceKey == null) {
+ log.error("{} Proof carries no EC holder key this issuer binds to", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_PROOF);
+ return;
+ }
+ mso = new MobileSecurityObject(docType, CoseKeySupport.toCoseKey(deviceKey), ctx.getIssuedAt(),
+ ctx.getNotBefore(), ctx.getExpiresAt());
+ } catch (final ParseException | JOSEException | EncodingException e) {
+ log.error("{} Failed to read the holder key", getLogPrefix(), e);
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_PROOF);
+ return;
+ }
+ addStatus(mso, ordinal);
+ objects.add(mso);
+ }
+ ctx.setMobileSecurityObjects(objects);
+ }
+
+ /**
+ * Add the status list slot allocated for a credential.
+ *
+ * @param mso mobile security object
+ * @param ordinal position of the credential within this issuance
+ */
+ private void addStatus(@Nonnull final MobileSecurityObject mso, final int ordinal) {
+ if (statusListContext == null || statusListContext.getStatusListUri() == null) {
+ return;
+ }
+ final Long index = statusListContext.getIndex(ordinal);
+ if (index != null) {
+ mso.setStatusList(index, statusListContext.getStatusListUri());
+ } else {
+ log.warn("{} No status list slot allocated for credential {} of this issuance", getLogPrefix(), ordinal);
+ }
+ }
+
+ /**
+ * Read the holder key of a proof, from its 'jwk' header or a did:jwk 'kid'.
+ *
+ * @param proof previously verified proof
+ * @return holder key, or null if the proof names no EC key this way
+ * @throws ParseException if the did:jwk is no JWK
+ * @throws JOSEException if the key cannot be converted
+ */
+ @Nullable
+ private static ECPublicKey deviceKey(@Nonnull final SignedJWT proof) throws ParseException, JOSEException {
+ JWK key = proof.getHeader().getJWK();
+ final String kid = proof.getHeader().getKeyID();
+ if (key == null && kid != null && kid.startsWith(DidSupport.DID_JWK_PREFIX) && kid.endsWith("#0")) {
+ key = JWK.parse(new Base64URL(kid.substring(DidSupport.DID_JWK_PREFIX.length(), kid.length() - 2))
+ .decodeToString());
+ }
+ return key instanceof ECKey ecKey ? ecKey.toECPublicKey() : null;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SignMobileSecurityObject.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SignMobileSecurityObject.java
new file mode 100644
index 0000000..f694700
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SignMobileSecurityObject.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.profile.impl;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CoseAlgorithms;
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CoseSign1Signer;
+import org.geant.shibboleth.plugin.openidvci.credential.impl.MobileSecurityObject;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
+import org.opensaml.security.x509.X509Credential;
+import org.slf4j.Logger;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
+import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.codec.Base64Support;
+import net.shibboleth.shared.codec.EncodingException;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action that signs each {@link MobileSecurityObject} of
+ * {@link CredentialsContext#getMobileSecurityObjects()} and stores the
+ * base64url encoded IssuerSigned structures to
+ * {@link CredentialsContext#setSignedCredentials}.
+ */
+public class SignMobileSecurityObject extends AbstractProfileAction {
+
+ /** Class logger. */
+ @Nonnull
+ private Logger log = LoggerFactory.getLogger(SignMobileSecurityObject.class);
+
+ /**
+ * Strategy used to locate the {@link SecurityParametersContext} to use for
+ * signing.
+ */
+ @Nonnull
+ private Function<ProfileRequestContext, SecurityParametersContext> securityParametersLookupStrategy;
+
+ /** Credentials context. */
+ @NonnullBeforeExec
+ private CredentialsContext ctx;
+
+ /** Signer of the issuer authentication. */
+ @NonnullBeforeExec
+ private CoseSign1Signer signer;
+
+ /** Constructor. */
+ public SignMobileSecurityObject() {
+ final Function<ProfileRequestContext, SecurityParametersContext> splcs = new ChildContextLookup<>(
+ SecurityParametersContext.class).compose(new OutboundMessageContextLookup());
+ assert splcs != null;
+ securityParametersLookupStrategy = splcs;
+ }
+
+ /**
+ * Set the strategy used to locate the {@link SecurityParametersContext} to use.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setSecurityParametersLookupStrategy(
+ @Nonnull final Function<ProfileRequestContext, SecurityParametersContext> strategy) {
+ checkSetterPreconditions();
+ securityParametersLookupStrategy = Constraint.isNotNull(strategy,
+ "SecurityParameterContext lookup strategy cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ if (!super.doPreExecute(profileRequestContext)) {
+ return false;
+ }
+ ctx = profileRequestContext.getInboundMessageContext() == null ? null
+ : profileRequestContext.getInboundMessageContext().getSubcontext(CredentialsContext.class);
+ if (ctx == null || ctx.getMobileSecurityObjects() == null) {
+ log.error("{} No mobile security objects to sign", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
+ return false;
+ }
+ final SecurityParametersContext secParamCtx = securityParametersLookupStrategy.apply(profileRequestContext);
+ signer = secParamCtx == null ? null : buildSigner(secParamCtx.getSignatureSigningParameters());
+ if (signer == null) {
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_SEC_CFG);
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Build the signer of the issuer authentication.
+ *
+ * @param parameters signing parameters
+ * @return signer, or null if the parameters name no X.509 credential and COSE algorithm
+ */
+ @Nullable
+ private CoseSign1Signer buildSigner(@Nullable final SignatureSigningParameters parameters) {
+ if (parameters == null || !(parameters.getSigningCredential() instanceof X509Credential credential)
+ || credential.getPrivateKey() == null) {
+ log.error("{} An mdoc is signed with an X.509 credential, none is available", getLogPrefix());
+ return null;
+ }
+ final Integer algorithm = CoseAlgorithms.fromJoseName(parameters.getSignatureAlgorithm());
+ if (algorithm == null) {
+ log.error("{} Signature algorithm {} has no COSE counterpart", getLogPrefix(),
+ parameters.getSignatureAlgorithm());
+ return null;
+ }
+ try {
+ return new CoseSign1Signer(credential.getPrivateKey(), algorithm,
+ new ArrayList<>(credential.getEntityCertificateChain()), null);
+ } catch (final EncodingException e) {
+ log.error("{} Unable to sign with the credential", getLogPrefix(), e);
+ return null;
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ final List<String> credentials = new ArrayList<>();
+ try {
+ for (final MobileSecurityObject mso : ctx.getMobileSecurityObjects()) {
+ credentials.add(Base64Support.encodeURLSafe(mso.encodeIssuerSigned(signer)));
+ }
+ } catch (final EncodingException e) {
+ log.error("{} Signing mdoc failed", getLogPrefix(), e);
+ ActionSupport.buildEvent(profileRequestContext, OpenIDVCIEventIds.INVALID_CREDENTIAL);
+ return;
+ }
+ ctx.setSignedCredentials(credentials);
+ log.info("{} Signed {} mdoc credentials", getLogPrefix(), credentials.size());
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialFormatPredicate.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialFormatPredicate.java
new file mode 100644
index 0000000..da9cac6
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialFormatPredicate.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.profile.logic;
+
+import java.util.function.Predicate;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * Whether the Credential Configuration in issue is of a format.
+ */
+public class CredentialFormatPredicate implements Predicate<ProfileRequestContext> {
+
+ /** Format to match. */
+ @Nonnull
+ private final String format;
+
+ /**
+ * Constructor.
+ *
+ * @param credentialFormat format to match
+ */
+ public CredentialFormatPredicate(@Nonnull final String credentialFormat) {
+ format = Constraint.isNotNull(credentialFormat, "Format cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean test(@Nullable final ProfileRequestContext profileRequestContext) {
+
+ if (profileRequestContext == null || profileRequestContext.getInboundMessageContext() == null) {
+ return false;
+ }
+ final CredentialsContext ctx = profileRequestContext.getInboundMessageContext()
+ .getSubcontext(CredentialsContext.class);
+ final CredentialConfiguration configuration = ctx == null ? null : ctx.getCredentialConfiguration();
+
+ return configuration != null && format.equals(configuration.getFormat());
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/StatusListCwtRequestedPredicate.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/StatusListCwtRequestedPredicate.java
new file mode 100644
index 0000000..73e18d7
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/StatusListCwtRequestedPredicate.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.profile.logic;
+
+import java.util.Locale;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import jakarta.servlet.http.HttpServletRequest;
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * Whether the request asks for a Status List Token in CWT format: its 'Accept'
+ * header names 'application/statuslist+cwt' and not 'application/statuslist+jwt'.
+ */
+public class StatusListCwtRequestedPredicate implements Predicate<ProfileRequestContext> {
+
+ /** Media type of the CWT format. */
+ @Nonnull
+ private static final String CWT = "application/statuslist+cwt";
+
+ /** Media type of the JWT format. */
+ @Nonnull
+ private static final String JWT = "application/statuslist+jwt";
+
+ /** Supplier of the servlet request the header is read from. */
+ @Nonnull
+ private final Supplier<HttpServletRequest> httpServletRequestSupplier;
+
+ /**
+ * Constructor.
+ *
+ * @param supplier supplier of the servlet request
+ */
+ public StatusListCwtRequestedPredicate(@Nonnull final Supplier<HttpServletRequest> supplier) {
+ httpServletRequestSupplier = Constraint.isNotNull(supplier, "Servlet request supplier cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean test(@Nullable final ProfileRequestContext profileRequestContext) {
+
+ final HttpServletRequest request = httpServletRequestSupplier.get();
+ final String accept = request == null ? null : request.getHeader("Accept");
+ if (accept == null) {
+ return false;
+ }
+ final String types = accept.toLowerCase(Locale.ROOT);
+ return types.contains(CWT) && !types.contains(JWT);
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/encoding/impl/StatusListCwtResponseEncoder.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/encoding/impl/StatusListCwtResponseEncoder.java
new file mode 100644
index 0000000..963f07f
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/encoding/impl/StatusListCwtResponseEncoder.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.statuslist.encoding.impl;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+import org.geant.shibboleth.plugin.openidvci.statuslist.messaging.impl.StatusListCwtResponse;
+import org.opensaml.messaging.encoder.MessageEncodingException;
+import org.opensaml.messaging.encoder.servlet.AbstractHttpServletResponseMessageEncoder;
+
+import jakarta.servlet.http.HttpServletResponse;
+
+/**
+ * Encoder writing a {@link StatusListCwtResponse} as the binary body of the response.
+ */
+public class StatusListCwtResponseEncoder extends AbstractHttpServletResponseMessageEncoder {
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doEncode() throws MessageEncodingException {
+ if (!(getMessageContext().getMessage() instanceof StatusListCwtResponse message)) {
+ throw new MessageEncodingException("No CWT status list token to encode");
+ }
+ final HttpServletResponse response = getHttpServletResponse();
+ response.setStatus(HttpServletResponse.SC_OK);
+ response.setContentType(StatusListCwtResponse.CONTENT_TYPE);
+ response.setContentLength(message.getToken().length);
+ try (OutputStream out = response.getOutputStream()) {
+ out.write(message.getToken());
+ } catch (final IOException e) {
+ throw new MessageEncodingException("Unable to write the status list token", e);
+ }
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/encoding/impl/StatusListResponseEncoderFactory.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/encoding/impl/StatusListResponseEncoderFactory.java
new file mode 100644
index 0000000..fde03c4
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/encoding/impl/StatusListResponseEncoderFactory.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.statuslist.encoding.impl;
+
+import java.util.function.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.statuslist.messaging.impl.StatusListCwtResponse;
+import org.opensaml.messaging.encoder.MessageEncoder;
+import org.opensaml.profile.context.ProfileRequestContext;
+
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.component.AbstractInitializableComponent;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * Factory returning the CWT encoder for a {@link StatusListCwtResponse} and the
+ * default encoder for any other message.
+ */
+public class StatusListResponseEncoderFactory extends AbstractInitializableComponent
+ implements Function<ProfileRequestContext, MessageEncoder> {
+
+ /** Encoder of messages other than CWT responses. */
+ @NonnullAfterInit
+ private MessageEncoder messageEncoder;
+
+ /** Encoder of CWT responses. */
+ @NonnullAfterInit
+ private MessageEncoder cwtMessageEncoder;
+
+ /**
+ * Set the encoder of messages other than CWT responses.
+ *
+ * @param encoder encoder
+ */
+ public void setMessageEncoder(@Nonnull final MessageEncoder encoder) {
+ checkSetterPreconditions();
+ messageEncoder = Constraint.isNotNull(encoder, "Message encoder cannot be null");
+ }
+
+ /**
+ * Set the encoder of CWT responses.
+ *
+ * @param encoder encoder
+ */
+ public void setCwtMessageEncoder(@Nonnull final MessageEncoder encoder) {
+ checkSetterPreconditions();
+ cwtMessageEncoder = Constraint.isNotNull(encoder, "CWT message encoder cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+ if (messageEncoder == null || cwtMessageEncoder == null) {
+ throw new ComponentInitializationException("Message encoders cannot be null");
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nullable
+ public MessageEncoder apply(@Nullable final ProfileRequestContext input) {
+ final Object message = input == null || input.getOutboundMessageContext() == null ? null
+ : input.getOutboundMessageContext().getMessage();
+ return message instanceof StatusListCwtResponse ? cwtMessageEncoder : messageEncoder;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/encoding/impl/package-info.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/encoding/impl/package-info.java
new file mode 100644
index 0000000..fdd303e
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/encoding/impl/package-info.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Encoding of Status List responses.
+ */
+package org.geant.shibboleth.plugin.openidvci.statuslist.encoding.impl;
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListCwtResponse.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListCwtResponse.java
new file mode 100644
index 0000000..f4ead91
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/messaging/impl/StatusListCwtResponse.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.statuslist.messaging.impl;
+
+import javax.annotation.Nonnull;
+
+import net.shibboleth.shared.logic.Constraint;
+
+/**
+ * Response carrying a signed Status List Token in CWT format, as in
+ * <a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/">
+ * draft-ietf-oauth-status-list</a>.
+ */
+public class StatusListCwtResponse {
+
+ /** Media type of a status list token in CWT format. */
+ @Nonnull
+ public static final String CONTENT_TYPE = "application/statuslist+cwt";
+
+ /** Signed status list token. */
+ @Nonnull
+ private final byte[] token;
+
+ /**
+ * Constructor.
+ *
+ * @param statusListToken signed status list token
+ */
+ public StatusListCwtResponse(@Nonnull final byte[] statusListToken) {
+ token = Constraint.isNotNull(statusListToken, "Status list token cannot be null");
+ }
+
+ /**
+ * Get the signed status list token.
+ *
+ * @return signed status list token
+ */
+ @Nonnull
+ public byte[] getToken() {
+ return token;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/FormOutboundStatusListResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/FormOutboundStatusListResponseMessage.java
index ffba9e1..b14cd0e 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/FormOutboundStatusListResponseMessage.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/FormOutboundStatusListResponseMessage.java
@@ -21,6 +21,7 @@ import java.util.function.Function;
import javax.annotation.Nonnull;
import org.geant.shibboleth.plugin.openidvci.statuslist.context.StatusListTokenContext;
+import org.geant.shibboleth.plugin.openidvci.statuslist.messaging.impl.StatusListCwtResponse;
import org.geant.shibboleth.plugin.openidvci.statuslist.messaging.impl.StatusListSuccessResponse;
import org.opensaml.messaging.context.navigate.ChildContextLookup;
import org.opensaml.profile.action.ActionSupport;
@@ -81,7 +82,7 @@ public class FormOutboundStatusListResponseMessage extends AbstractProfileAction
}
ctx = statusListTokenContextLookupStrategy.apply(profileRequestContext);
- if (ctx == null || ctx.getSignedToken() == null) {
+ if (ctx == null || ctx.getSignedToken() == null && ctx.getSignedCwt() == null) {
log.error("{} No signed status list token to return", getLogPrefix());
ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
return false;
@@ -93,6 +94,12 @@ public class FormOutboundStatusListResponseMessage extends AbstractProfileAction
/** {@inheritDoc} */
@Override
protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+ if (ctx.getSignedCwt() != null) {
+ profileRequestContext.ensureOutboundMessageContext()
+ .setMessage(new StatusListCwtResponse(ctx.getSignedCwt()));
+ log.debug("{} Returning a CWT status list token of {} bytes", getLogPrefix(), ctx.getSignedCwt().length);
+ return;
+ }
profileRequestContext.ensureOutboundMessageContext()
.setMessage(new StatusListSuccessResponse(ctx.getSignedToken()));
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/SignStatusListToken.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/SignStatusListToken.java
index 8991722..cb17ca0 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/SignStatusListToken.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/SignStatusListToken.java
@@ -16,12 +16,17 @@
package org.geant.shibboleth.plugin.openidvci.statuslist.profile.impl;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
+import java.util.function.Predicate;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CoseAlgorithms;
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CoseSign1Signer;
import org.geant.shibboleth.plugin.openidvci.security.impl.CertificateChainJWSTokenSigner;
import org.geant.shibboleth.plugin.openidvci.security.impl.DidSupport;
import org.geant.shibboleth.plugin.openidvci.statuslist.context.StatusListTokenContext;
@@ -31,6 +36,7 @@ import org.opensaml.profile.action.EventIds;
import org.opensaml.profile.context.ProfileRequestContext;
import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
import org.opensaml.security.credential.Credential;
+import org.opensaml.security.x509.X509Credential;
import org.slf4j.Logger;
import com.nimbusds.jose.JOSEException;
@@ -43,8 +49,10 @@ import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
import net.shibboleth.shared.annotation.constraint.NonnullBeforeExec;
+import net.shibboleth.shared.codec.EncodingException;
import net.shibboleth.shared.collection.CollectionSupport;
import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.logic.PredicateSupport;
import net.shibboleth.shared.primitive.LoggerFactory;
/**
@@ -88,6 +96,10 @@ public class SignStatusListToken extends AbstractProfileAction {
@NonnullAfterInit
private SignatureSigningParameters signatureSigningParameters;
+ /** Condition for signing the token in CWT format. */
+ @Nonnull
+ private Predicate<ProfileRequestContext> cwtCondition = PredicateSupport.alwaysFalse();
+
/** Constructor. */
public SignStatusListToken() {
final Function<ProfileRequestContext, SecurityParametersContext> splcs = new ChildContextLookup<>(
@@ -125,6 +137,16 @@ public class SignStatusListToken extends AbstractProfileAction {
"StatusListTokenContext lookup strategy cannot be null");
}
+ /**
+ * Set the condition for signing the token in CWT format.
+ *
+ * @param condition condition
+ */
+ public void setCwtCondition(@Nonnull final Predicate<ProfileRequestContext> condition) {
+ checkSetterPreconditions();
+ cwtCondition = Constraint.isNotNull(condition, "CWT condition cannot be null");
+ }
+
/**
* Get the signing parameters to apply.
*
@@ -247,6 +269,10 @@ public class SignStatusListToken extends AbstractProfileAction {
return;
}
+ if (cwtCondition.test(profileRequestContext)) {
+ signCwt(profileRequestContext, parameters);
+ return;
+ }
try {
ctx.setSignedToken(new CertificateChainJWSTokenSigner(
DidSupport.nameKeyByIssuer(parameters, ctx.getClaims().getIssuer()),
@@ -259,4 +285,33 @@ public class SignStatusListToken extends AbstractProfileAction {
}
}
+ /**
+ * Sign the token in CWT format into {@link StatusListTokenContext#setSignedCwt}.
+ *
+ * @param profileRequestContext profile request context
+ * @param parameters parameters to sign with
+ */
+ private void signCwt(@Nonnull final ProfileRequestContext profileRequestContext,
+ @Nonnull final SignatureSigningParameters parameters) {
+ final Credential credential = parameters.getSigningCredential();
+ final Integer algorithm = CoseAlgorithms.fromJoseName(parameters.getSignatureAlgorithm());
+ if (credential == null || credential.getPrivateKey() == null || algorithm == null) {
+ log.error("{} No EC signing key for a CWT status list token", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_SEC_CFG);
+ return;
+ }
+ final String keyName = DidSupport.keyName(credential);
+ try {
+ final CoseSign1Signer signer = new CoseSign1Signer(credential.getPrivateKey(), algorithm,
+ credential instanceof X509Credential x509 ? new ArrayList<>(x509.getEntityCertificateChain())
+ : null,
+ keyName == null ? null : keyName.getBytes(StandardCharsets.UTF_8), true);
+ ctx.setSignedCwt(StatusListCwt.sign(ctx.getClaims(), signer));
+ log.debug("{} Signed CWT status list token for {}", getLogPrefix(), ctx.getClaims().getSubject());
+ } catch (final EncodingException e) {
+ log.error("{} Signing CWT status list token failed", getLogPrefix(), e);
+ ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+ }
+ }
+
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/StatusListCwt.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/StatusListCwt.java
new file mode 100644
index 0000000..40be0d6
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/StatusListCwt.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.statuslist.profile.impl;
+
+import java.text.ParseException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import javax.annotation.Nonnull;
+
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CborSupport;
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CborTag;
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CoseSign1Signer;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+
+import net.shibboleth.shared.codec.Base64Support;
+import net.shibboleth.shared.codec.DecodingException;
+import net.shibboleth.shared.codec.EncodingException;
+
+/**
+ * Status List Token in CWT format, draft-ietf-oauth-status-list section 5.2, of
+ * the claims of the JWT format.
+ */
+public final class StatusListCwt {
+
+ /** Media type of a Status List Token in CWT format. */
+ @Nonnull
+ public static final String MEDIA_TYPE = "application/statuslist+cwt";
+
+ /** Claim key of the subject. */
+ private static final int SUB = 2;
+
+ /** Claim key of the expiration time. */
+ private static final int EXP = 4;
+
+ /** Claim key of the issue time. */
+ private static final int IAT = 6;
+
+ /** Claim key of the time to live. */
+ private static final int TTL = 65534;
+
+ /** Claim key of the status list. */
+ private static final int STATUS_LIST = 65533;
+
+ /** Constructor. */
+ private StatusListCwt() {
+ }
+
+ /**
+ * Sign the claims of a Status List Token as a CWT.
+ *
+ * @param claims claims of the JWT format
+ * @param signer signer
+ * @return the tagged COSE_Sign1
+ * @throws EncodingException if the claims cannot be expressed or signing fails
+ */
+ @Nonnull
+ public static byte[] sign(@Nonnull final JWTClaimsSet claims, @Nonnull final CoseSign1Signer signer)
+ throws EncodingException {
+ final Map<Object, Object> protectedExtras = Map.of(CoseSign1Signer.TYP, MEDIA_TYPE);
+ return CborSupport.encode(new CborTag(CborTag.COSE_SIGN1,
+ signer.sign(CborSupport.encode(toCwtClaims(claims)), protectedExtras)));
+ }
+
+ /**
+ * Express the claims of the JWT format as CWT claims.
+ *
+ * @param claims claims of the JWT format
+ * @return CWT claims
+ * @throws EncodingException if a claim is missing or malformed
+ */
+ @Nonnull
+ public static Map<Object, Object> toCwtClaims(@Nonnull final JWTClaimsSet claims) throws EncodingException {
+ try {
+ final Map<String, Object> statusList = claims.getJSONObjectClaim("status_list");
+ if (claims.getSubject() == null || claims.getIssueTime() == null || statusList == null
+ || !(statusList.get("lst") instanceof String lst) || !(statusList.get("bits") instanceof Number)) {
+ throw new EncodingException("Status list token claims are incomplete");
+ }
+ final Map<String, Object> cborList = new LinkedHashMap<>();
+ cborList.put("bits", ((Number) statusList.get("bits")).intValue());
+ cborList.put("lst", Base64Support.decodeURLSafe(lst));
+ final Map<Object, Object> cwt = new LinkedHashMap<>();
+ cwt.put(SUB, claims.getSubject());
+ cwt.put(IAT, claims.getIssueTime().toInstant().getEpochSecond());
+ if (claims.getExpirationTime() != null) {
+ cwt.put(EXP, claims.getExpirationTime().toInstant().getEpochSecond());
+ }
+ final Long ttl = claims.getLongClaim("ttl");
+ if (ttl != null) {
+ cwt.put(TTL, ttl);
+ }
+ cwt.put(STATUS_LIST, cborList);
+ return cwt;
+ } catch (final ParseException | IllegalArgumentException | DecodingException e) {
+ throw new EncodingException("Status list token claims are malformed", e);
+ }
+ }
+}
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-beans.xml
index 17040cb..031f0ef 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-beans.xml
@@ -61,11 +61,39 @@
<bean id="SignStatusListToken"
class="org.geant.shibboleth.plugin.openidvci.statuslist.profile.impl.SignStatusListToken"
scope="prototype"
- p:signingCredentials="#{getObject('openidvci.status-list.SigningCredentials') ?: getObject('openidvci.SigningCredentials') ?: getObject('shibboleth.oidc.SigningCredentialsFactory')}" />
+ p:signingCredentials="#{getObject('openidvci.status-list.SigningCredentials') ?: getObject('openidvci.SigningCredentials') ?: getObject('shibboleth.oidc.SigningCredentialsFactory')}">
+ <property name="cwtCondition">
+ <bean class="org.geant.shibboleth.plugin.openidvci.profile.logic.StatusListCwtRequestedPredicate"
+ c:supplier-ref="shibboleth.HttpServletRequestSupplier" />
+ </property>
+ </bean>
<bean id="FormOutboundMessage"
class="org.geant.shibboleth.plugin.openidvci.statuslist.profile.impl.FormOutboundStatusListResponseMessage"
scope="prototype" />
+
+ <!-- Encoders of CommitStatusListResponse, JWT and CWT. -->
+ <bean id="EncodeStatusListResponse" class="org.opensaml.profile.action.impl.EncodeMessage" scope="prototype"
+ p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier">
+ <property name="messageEncoderFactory">
+ <bean class="org.geant.shibboleth.plugin.openidvci.statuslist.encoding.impl.StatusListResponseEncoderFactory"
+ scope="prototype"
+ p:messageEncoder-ref="openidvci.status-list.NimbusEncoder"
+ p:cwtMessageEncoder-ref="openidvci.status-list.CwtEncoder" />
+ </property>
+ </bean>
+
+ <bean id="openidvci.status-list.NimbusEncoder"
+ class="net.shibboleth.idp.plugin.oidc.op.encoding.impl.NimbusResponseEncoder" scope="prototype"
+ init-method="" p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier"
+ p:velocityEngine-ref="shibboleth.VelocityEngine"
+ p:objectMapper-ref="#{'%{idp.oidc.logging.objectMapper:shibboleth.oidc.JSONObjectMapper}'.trim()}" />
+
+ <bean id="openidvci.status-list.CwtEncoder"
+ class="org.geant.shibboleth.plugin.openidvci.statuslist.encoding.impl.StatusListCwtResponseEncoder"
+ scope="prototype" init-method=""
+ p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier" />
+
<bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
scope="prototype" p:defaultStatusCode="400" p:defaultCode="invalid_request"
p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier">
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-flow.xml
index 7b2d3f8..55cc94e 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-flow.xml
@@ -4,6 +4,10 @@
xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd"
parent="openid/vci/abstract-api">
+ <on-start>
+ <set name="flowScope.transitionAfterOutboundIntercept" value="'CommitStatusListResponse'" />
+ </on-start>
+
<action-state id="InitializeMandatoryContexts">
<evaluate expression="InitializeProfileRequestContext"/>
<evaluate expression="PopulateMetricContext"/>
@@ -19,6 +23,16 @@
<transition on="proceed" to="BuildResponseMessage"/>
</action-state>
+ <end-state id="CommitStatusListResponse">
+ <on-entry>
+ <evaluate expression="CallOutboundMessageHandler"/>
+ <evaluate expression="EncodeStatusListResponse"/>
+ <evaluate expression="PostResponsePopulateAuditContext"/>
+ <evaluate expression="WriteAuditLog"/>
+ <evaluate expression="RecordResponseComplete"/>
+ </on-entry>
+ </end-state>
+
<bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/oauth/status-list/list/list-beans.xml"/>
</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
index af9b300..96d494a 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-beans.xml
@@ -149,8 +149,14 @@
p:issuer="%{openidvci.issuer:}"
p:deploymentIssuer="#{getObject('shibboleth.oidc.issuer')}" />
+ <bean id="openidvci.MdocCondition" class="org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialFormatPredicate"
+ c:credentialFormat="mso_mdoc" />
+
+ <bean id="openidvci.NotMdocCondition" parent="shibboleth.Conditions.NOT" c:_0-ref="openidvci.MdocCondition" />
+
<bean id="AddCredentialShell" class="org.geant.shibboleth.plugin.openidvci.profile.impl.AddCredentialShell"
scope="prototype" p:issuerLookupStrategy-ref="openidvci.IssuerLookupStrategy"
+ p:activationCondition-ref="openidvci.NotMdocCondition"
p:stateEntityIdentifier="#{getObject('openidvci.oidfed.CredentialIssuerEntityConfigurationMetadataDecorator') != null}"
p:metadataResolver-ref="#{'%{openidvci.issuerMetadata.resolver:openidvci.DefaultCredentialIssuerMetadataResolver}'.trim()}" />
@@ -161,7 +167,23 @@
scope="prototype" p:issuerLookupStrategy-ref="openidvci.IssuerLookupStrategy" />
<bean id="SignJWTCredential" class="org.geant.shibboleth.plugin.openidvci.profile.impl.SignJWTCredential"
- scope="prototype" p:issuerLookupStrategy-ref="shibboleth.ResponderIdLookup.Simple" >
+ scope="prototype" p:issuerLookupStrategy-ref="shibboleth.ResponderIdLookup.Simple"
+ p:activationCondition-ref="openidvci.NotMdocCondition">
+ <property name="securityParametersLookupStrategy">
+ <bean parent="shibboleth.Functions.Compose"
+ c:g-ref="shibboleth.oidc.ChildLookup.JWTSecurityParameters"
+ c:f-ref="shibboleth.MessageContextLookup.Outbound" />
+ </property>
+ </bean>
+
+ <bean id="AddMobileSecurityObjectShell" class="org.geant.shibboleth.plugin.openidvci.profile.impl.AddMobileSecurityObjectShell"
+ scope="prototype" p:activationCondition-ref="openidvci.MdocCondition" />
+
+ <bean id="AddClaimsToMobileSecurityObject" class="org.geant.shibboleth.plugin.openidvci.profile.impl.AddClaimsToMobileSecurityObject"
+ scope="prototype" p:activationCondition-ref="openidvci.MdocCondition" />
+
+ <bean id="SignMobileSecurityObject" class="org.geant.shibboleth.plugin.openidvci.profile.impl.SignMobileSecurityObject"
+ scope="prototype" p:activationCondition-ref="openidvci.MdocCondition">
<property name="securityParametersLookupStrategy">
<bean parent="shibboleth.Functions.Compose"
c:g-ref="shibboleth.oidc.ChildLookup.JWTSecurityParameters"
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml
index ac86c21..65aeae8 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/openid/vci/credentials/credentials-flow.xml
@@ -54,6 +54,9 @@
<evaluate expression="OptionallyFormSelectiveDisclosureJWTCredential" />
<evaluate expression="OptionallyFormJsonLdSelectiveDisclosureJWTCredential" />
<evaluate expression="SignJWTCredential" />
+ <evaluate expression="AddMobileSecurityObjectShell" />
+ <evaluate expression="AddClaimsToMobileSecurityObject" />
+ <evaluate expression="SignMobileSecurityObject" />
<evaluate expression="'proceed'" />
<transition on="proceed" to="BuildResponseMessage" />
</action-state>
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CborSupportTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CborSupportTest.java
new file mode 100644
index 0000000..5f114f0
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CborSupportTest.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.cose.impl;
+
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.testng.Assert;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import net.shibboleth.shared.codec.EncodingException;
+
+/**
+ * Unit tests for {@link CborSupport}, vectors of RFC 8949 Appendix A.
+ */
+public class CborSupportTest {
+
+ private static Map<Object, Object> map(final Object... entries) {
+ final Map<Object, Object> map = new LinkedHashMap<>();
+ for (int i = 0; i < entries.length; i += 2) {
+ map.put(entries[i], entries[i + 1]);
+ }
+ return map;
+ }
+
+ private static byte[] hex(final String hex) {
+ return HexFormat.of().parseHex(hex);
+ }
+
+ @DataProvider
+ public Object[][] vectors() {
+ final List<Object> twentyFive = new ArrayList<>();
+ for (int i = 1; i <= 25; i++) {
+ twentyFive.add(i);
+ }
+ return new Object[][] {
+ {0, "00"}, {1, "01"}, {10, "0a"}, {23, "17"}, {24, "1818"}, {25, "1819"}, {100, "1864"},
+ {1000, "1903e8"}, {1000000, "1a000f4240"}, {1000000000000L, "1b000000e8d4a51000"},
+ {new BigInteger("18446744073709551615"), "1bffffffffffffffff"},
+ {new BigInteger("-18446744073709551616"), "3bffffffffffffffff"},
+ {-1, "20"}, {-10, "29"}, {-100, "3863"}, {-1000, "3903e7"},
+ {false, "f4"}, {true, "f5"}, {null, "f6"},
+ {new byte[0], "40"}, {hex("01020304"), "4401020304"},
+ {"", "60"}, {"a", "6161"}, {"IETF", "6449455446"}, {"\"\\", "62225c"}, {"ü", "62c3bc"},
+ {"水", "63e6b0b4"}, {"𐅑", "64f0908591"},
+ {List.of(), "80"}, {List.of(1, 2, 3), "83010203"},
+ {List.of(1, List.of(2, 3), List.of(4, 5)), "8301820203820405"},
+ {twentyFive, "98190102030405060708090a0b0c0d0e0f101112131415161718181819"},
+ {map(), "a0"}, {map(1, 2, 3, 4), "a201020304"}, {map("a", 1, "b", List.of(2, 3)), "a26161016162820203"},
+ {List.of("a", map("b", "c")), "826161a161626163"},
+ {new CborTag(0, "2013-03-21T20:04:00Z"), "c074323031332d30332d32315432303a30343a30305a"},
+ {new CborTag(1, 1363896240), "c11a514b67b0"},
+ {new CborTag(23, hex("01020304")), "d74401020304"},
+ {CborTag.encodedCbor(hex("6449455446")), "d818456449455446"},
+ {new CborTag(CborTag.FULL_DATE, "1940-10-09"), "d903ec6a313934302d31302d3039"},
+ {map("bits", 1, "lst", hex("78dadbb918000217015d")), "a2646269747301636c73744a78dadbb918000217015d"},
+ };
+ }
+
+ @Test(dataProvider = "vectors")
+ public void encode(final Object item, final String expected) throws EncodingException {
+ Assert.assertEquals(HexFormat.of().formatHex(CborSupport.encode(item)), expected);
+ }
+
+ @Test(expectedExceptions = EncodingException.class)
+ public void noFloats() throws EncodingException {
+ CborSupport.encode(1.5);
+ }
+
+ @Test(expectedExceptions = EncodingException.class)
+ public void outOfRange() throws EncodingException {
+ CborSupport.encode(BigInteger.ONE.shiftLeft(64));
+ }
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CborTestReader.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CborTestReader.java
new file mode 100644
index 0000000..19502dd
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CborTestReader.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.cose.impl;
+
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * CBOR reader for tests, of what {@link CborSupport} writes. Integers are read as BigInteger.
+ */
+public final class CborTestReader {
+
+ private final byte[] data;
+
+ private int offset;
+
+ private CborTestReader(final byte[] input) {
+ data = input;
+ }
+
+ public static Object read(final byte[] input) {
+ final CborTestReader reader = new CborTestReader(input);
+ final Object item = reader.next();
+ if (reader.offset != input.length) {
+ throw new IllegalArgumentException("Trailing bytes");
+ }
+ return item;
+ }
+
+ private BigInteger argument(final int additional) {
+ if (additional < 24) {
+ return BigInteger.valueOf(additional);
+ }
+ final int length = 1 << (additional - 24);
+ final BigInteger value = new BigInteger(1, Arrays.copyOfRange(data, offset, offset + length));
+ offset += length;
+ return value;
+ }
+
+ private Object next() {
+ final int initial = data[offset++] & 0xff;
+ final int major = initial >> 5;
+ final int additional = initial & 0x1f;
+ if (major == 7) {
+ return additional == 20 ? Boolean.FALSE : additional == 21 ? Boolean.TRUE : null;
+ }
+ final BigInteger argument = argument(additional);
+ switch (major) {
+ case 0:
+ return argument;
+ case 1:
+ return argument.negate().subtract(BigInteger.ONE);
+ case 2:
+ case 3:
+ final byte[] bytes = Arrays.copyOfRange(data, offset, offset + argument.intValue());
+ offset += bytes.length;
+ return major == 2 ? bytes : new String(bytes, StandardCharsets.UTF_8);
+ case 4:
+ final List<Object> list = new ArrayList<>();
+ for (int i = 0; i < argument.intValue(); i++) {
+ list.add(next());
+ }
+ return list;
+ case 5:
+ final Map<Object, Object> map = new LinkedHashMap<>();
+ for (int i = 0; i < argument.intValue(); i++) {
+ map.put(next(), next());
+ }
+ return map;
+ default:
+ return new CborTag(argument.longValue(), next());
+ }
+ }
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CoseSign1SignerTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CoseSign1SignerTest.java
new file mode 100644
index 0000000..4155966
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/cose/impl/CoseSign1SignerTest.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.cose.impl;
+
+import java.io.ByteArrayInputStream;
+import java.math.BigInteger;
+import java.nio.charset.StandardCharsets;
+import java.security.AlgorithmParameters;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.security.interfaces.ECPublicKey;
+import java.security.spec.ECGenParameterSpec;
+import java.security.spec.ECParameterSpec;
+import java.security.spec.ECPoint;
+import java.security.spec.ECPrivateKeySpec;
+import java.security.spec.ECPublicKeySpec;
+import java.util.Base64;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Map;
+
+import org.testng.Assert;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+/**
+ * Unit tests for {@link CoseSign1Signer} and {@link CoseKeySupport}, vector
+ * ecdsa-sig-01 of https://github.com/cose-wg/Examples.
+ */
+public class CoseSign1SignerTest {
+
+ private static final String X = "usWxHK2PmfnHKwXPS54m0kTcGJ90UiglWiGahtagnv8";
+
+ private static final String Y = "IBOL-C3BttVivg-lSreASjpkttcsz-1rb7btKLv8EX4";
+
+ private static final String D = "V8kgd2ZBRuh2dgyVINBUqpPDr7BOMGcF22CQMIUHtNM";
+
+ private static final String TO_BE_SIGNED =
+ "846a5369676e61747572653145a2012603004054546869732069732074686520636f6e74656e742e";
+
+ private static final String SIGNATURE = "6520bbaf2081d7e0ed0f95f76eb0733d667005f7467cec4b87b9381a6ba1ede8"
+ + "e00df29f32a37230f39a842a54821fdd223092819d7728efb9d3a0080b75380b";
+
+ private static final byte[] PAYLOAD = "This is the content.".getBytes(StandardCharsets.UTF_8);
+
+ private static final String CERTIFICATE = "MIIB6DCCAY6gAwIBAgIUYYxo8Hm2aRgAKBdj0V7BBgXuRKQwCgYIKoZIzj0EAwIw"
+ + "QDEaMBgGA1UECgwRU0QtVkNJLVZQIEV4YW1wbGUxIjAgBgNVBAMMGVNELVZDSS1W"
+ + "UCBFeGFtcGxlIFJvb3QgQ0EwHhcNMjYwOTIzMDUwMjI1WhcNMzYwOTIwMDUwMjI1"
+ + "WjBAMRowGAYDVQQKDBFTRC1WQ0ktVlAgRXhhbXBsZTEiMCAGA1UEAwwZU0QtVkNJ"
+ + "LVZQIEV4YW1wbGUgUm9vdCBDQTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABLUB"
+ + "I03jJIDyL+S2UPTAWRd1RPHZWRcSIzJFdDRxSuK4IAUnVnBY6gjWLRAErpjgyJZn"
+ + "VtHvWJrtDqQF4r7iICqjZjBkMB0GA1UdDgQWBBT/tnoj3Z9fjJrjdITqE3WuAas/"
+ + "PDAfBgNVHSMEGDAWgBT/tnoj3Z9fjJrjdITqE3WuAas/PDASBgNVHRMBAf8ECDAG"
+ + "AQH/AgEBMA4GA1UdDwEB/wQEAwIBBjAKBggqhkjOPQQDAgNIADBFAiEAguhKVLy5"
+ + "FsK52bdSMylaNHfyHqLY4+wuIYqGMHzUn4gCIENxX2OcoLhzEZo7zWlmY8GUccTb"
+ + "493asnVzZQVpULOH";
+
+ private PublicKey publicKey;
+
+ private PrivateKey privateKey;
+
+ @BeforeClass
+ public void keys() throws Exception {
+ final AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC");
+ parameters.init(new ECGenParameterSpec("secp256r1"));
+ final ECParameterSpec p256 = parameters.getParameterSpec(ECParameterSpec.class);
+ final KeyFactory factory = KeyFactory.getInstance("EC");
+ publicKey = factory.generatePublic(new ECPublicKeySpec(new ECPoint(unsigned(X), unsigned(Y)), p256));
+ privateKey = factory.generatePrivate(new ECPrivateKeySpec(unsigned(D), p256));
+ }
+
+ private static BigInteger unsigned(final String base64url) {
+ return new BigInteger(1, Base64.getUrlDecoder().decode(base64url));
+ }
+
+ private boolean verifies(final byte[] signed, final byte[] signature) throws Exception {
+ final Signature verifier = Signature.getInstance("SHA256withECDSAinP1363Format");
+ verifier.initVerify(publicKey);
+ verifier.update(signed);
+ return verifier.verify(signature);
+ }
+
+ @Test
+ public void toBeSigned() throws Exception {
+ final byte[] protectedBytes = CborSupport.encode(Map.of(CoseSign1Signer.ALG, CoseAlgorithms.ES256));
+ Assert.assertEquals(HexFormat.of().formatHex(protectedBytes), "a10126");
+ final byte[] vectorProtected = HexFormat.of().parseHex("a201260300");
+ Assert.assertEquals(HexFormat.of().formatHex(CoseSign1Signer.toBeSigned(vectorProtected, PAYLOAD)),
+ TO_BE_SIGNED);
+ Assert.assertTrue(verifies(HexFormat.of().parseHex(TO_BE_SIGNED), HexFormat.of().parseHex(SIGNATURE)));
+ }
+
+ @Test
+ public void sign() throws Exception {
+ final CoseSign1Signer signer = new CoseSign1Signer(privateKey, CoseAlgorithms.ES256, null,
+ "11".getBytes(StandardCharsets.UTF_8));
+ final List<Object> sign1 = signer.sign(PAYLOAD, Map.of(3, 0));
+ final String encoded = HexFormat.of().formatHex(CborSupport.encode(new CborTag(CborTag.COSE_SIGN1, sign1)));
+ Assert.assertTrue(encoded.startsWith(
+ "d28445a201260300a10442313154546869732069732074686520636f6e74656e742e5840"));
+ Assert.assertTrue(verifies(HexFormat.of().parseHex(TO_BE_SIGNED), (byte[]) sign1.get(3)));
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ public void chainPlacement() throws Exception {
+ final X509Certificate certificate = (X509Certificate) CertificateFactory.getInstance("X.509")
+ .generateCertificate(new ByteArrayInputStream(Base64.getDecoder().decode(CERTIFICATE)));
+
+ final List<Object> unprotectedChain = new CoseSign1Signer(privateKey, CoseAlgorithms.ES256,
+ List.of(certificate), null).sign(PAYLOAD, null);
+ Assert.assertEquals(HexFormat.of().formatHex((byte[]) unprotectedChain.get(0)), "a10126");
+ Assert.assertEquals((byte[]) ((Map<Object, Object>) unprotectedChain.get(1)).get(CoseSign1Signer.X5CHAIN),
+ certificate.getEncoded());
+
+ final List<Object> protectedChain = new CoseSign1Signer(privateKey, CoseAlgorithms.ES256,
+ List.of(certificate), null, true).sign(PAYLOAD, Map.of(CoseSign1Signer.TYP, "application/x"));
+ final Map<Object, Object> protectedHeaders = (Map<Object, Object>) CborTestReader
+ .read((byte[]) protectedChain.get(0));
+ Assert.assertEquals((byte[]) protectedHeaders.get(BigInteger.valueOf(CoseSign1Signer.X5CHAIN)),
+ certificate.getEncoded());
+ Assert.assertTrue(((Map<Object, Object>) protectedChain.get(1)).isEmpty());
+ Assert.assertTrue(verifies(CoseSign1Signer.toBeSigned((byte[]) protectedChain.get(0), PAYLOAD),
+ (byte[]) protectedChain.get(3)));
+ }
+
+ @Test
+ public void derToConcat() throws Exception {
+ for (int i = 0; i < 20; i++) {
+ final Signature signer = Signature.getInstance("SHA256withECDSA");
+ signer.initSign(privateKey);
+ signer.update(PAYLOAD);
+ final byte[] concat = CoseSign1Signer.derToConcat(signer.sign(), 32);
+ Assert.assertEquals(concat.length, 64);
+ final Signature verifier = Signature.getInstance("SHA256withECDSAinP1363Format");
+ verifier.initVerify(publicKey);
+ verifier.update(PAYLOAD);
+ Assert.assertTrue(verifier.verify(concat));
+ }
+ }
+
+ @Test
+ public void coseKey() throws Exception {
+ final String expected = "a401022001215820" + HexFormat.of().formatHex(Base64.getUrlDecoder().decode(X))
+ + "225820" + HexFormat.of().formatHex(Base64.getUrlDecoder().decode(Y));
+ Assert.assertEquals(HexFormat.of().formatHex(
+ CborSupport.encode(CoseKeySupport.toCoseKey((ECPublicKey) publicKey))), expected);
+ }
+
+ @Test
+ public void algorithms() {
+ Assert.assertEquals(CoseAlgorithms.toJoseNames(List.of("-7", "-9", "-35", "-257")), List.of("ES256", "ES384"));
+ Assert.assertEquals(CoseAlgorithms.fromJoseName("ES256"), Integer.valueOf(-7));
+ Assert.assertNull(CoseAlgorithms.fromJoseName("RS256"));
+ }
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MdocElementsTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MdocElementsTest.java
new file mode 100644
index 0000000..f4383ff
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MdocElementsTest.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.credential.impl;
+
+import java.math.BigInteger;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CborTag;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import net.shibboleth.shared.codec.EncodingException;
+
+/**
+ * Unit tests for {@link MdocElements}.
+ */
+public class MdocElementsTest {
+
+ private static final String NS = "org.iso.18013.5.1";
+
+ @Test
+ public void collect() throws EncodingException {
+ final Map<String, Map<String, Object>> nameSpaces = MdocElements.collect(List.of(
+ Map.entry(List.of(NS, "given_name"), "Gemma"),
+ Map.entry(List.of(NS, "address", "street"), "Main 1"),
+ Map.entry(List.of(NS, "address", "postal"), "00100"),
+ Map.entry(List.of(NS + ".aamva", "organ_donor"), 1)),
+ Map.of(List.of(NS, "address", "postal"), "int"));
+
+ Assert.assertEquals(new ArrayList<>(nameSpaces.keySet()), List.of(NS, NS + ".aamva"));
+ Assert.assertEquals(nameSpaces.get(NS).get("given_name"), "Gemma");
+ Assert.assertEquals(nameSpaces.get(NS).get("address"),
+ Map.of("street", "Main 1", "postal", BigInteger.valueOf(100)));
+ Assert.assertEquals(nameSpaces.get(NS + ".aamva").get("organ_donor"), 1);
+ }
+
+ @Test
+ public void types() throws EncodingException {
+ Assert.assertEquals(MdocElements.toCbor("1990-01-02", "full-date"),
+ new CborTag(CborTag.FULL_DATE, "1990-01-02"));
+ Assert.assertEquals(MdocElements.toCbor("2026-10-01T10:00:00.5+03:00", "tdate"),
+ new CborTag(CborTag.DATE_TIME, "2026-10-01T07:00:00Z"));
+ Assert.assertEquals(MdocElements.toCbor(1700000000, "tdate"),
+ new CborTag(CborTag.DATE_TIME, "2023-11-14T22:13:20Z"));
+ Assert.assertEquals((byte[]) MdocElements.toCbor("AQID-_8", "bstr"), new byte[] {1, 2, 3, -5, -1});
+ Assert.assertEquals(MdocElements.toCbor("TRUE", "bool"), Boolean.TRUE);
+ Assert.assertEquals(MdocElements.toCbor(List.of("1", "2"), "int"),
+ List.of(BigInteger.ONE, BigInteger.TWO));
+ Assert.assertEquals(MdocElements.toCbor(Map.of("a", true), null), Map.of("a", true));
+ }
+
+ @Test(expectedExceptions = EncodingException.class)
+ public void shortPath() throws EncodingException {
+ MdocElements.collect(List.of(Map.entry(List.of("given_name"), "Gemma")), Map.of());
+ }
+
+ @Test(expectedExceptions = EncodingException.class)
+ public void badDate() throws EncodingException {
+ MdocElements.toCbor("1990-13-01", "full-date");
+ }
+
+ @Test(expectedExceptions = EncodingException.class)
+ public void decimal() throws EncodingException {
+ MdocElements.toCbor(1.5, null);
+ }
+
+ @Test(expectedExceptions = EncodingException.class)
+ public void collision() throws EncodingException {
+ MdocElements.collect(List.of(Map.entry(List.of(NS, "address"), "Main 1"),
+ Map.entry(List.of(NS, "address", "street"), "Main 1")), Map.of());
+ }
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MobileSecurityObjectTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MobileSecurityObjectTest.java
new file mode 100644
index 0000000..74bd119
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/credential/impl/MobileSecurityObjectTest.java
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.credential.impl;
+
+import java.math.BigInteger;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.MessageDigest;
+import java.security.Signature;
+import java.security.interfaces.ECPublicKey;
+import java.security.spec.ECGenParameterSpec;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CborSupport;
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CborTag;
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CborTestReader;
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CoseAlgorithms;
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CoseKeySupport;
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CoseSign1Signer;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import net.shibboleth.shared.codec.EncodingException;
+
+/**
+ * Unit tests for {@link MobileSecurityObject}.
+ */
+ at SuppressWarnings("unchecked")
+public class MobileSecurityObjectTest {
+
+ private static final String NS = "org.iso.18013.5.1";
+
+ private static KeyPair keyPair() throws Exception {
+ final KeyPairGenerator generator = KeyPairGenerator.getInstance("EC");
+ generator.initialize(new ECGenParameterSpec("secp256r1"));
+ return generator.generateKeyPair();
+ }
+
+ @Test
+ public void issuerSigned() throws Exception {
+ final KeyPair issuer = keyPair();
+ final KeyPair device = keyPair();
+ final Instant now = Instant.parse("2026-10-01T10:00:00.250Z");
+ final MobileSecurityObject mso = new MobileSecurityObject("org.iso.18013.5.1.mDL",
+ CoseKeySupport.toCoseKey((ECPublicKey) device.getPublic()), now, now, now.plusSeconds(3600));
+ mso.setStatusList(7, "https://issuer.example.org/statuslist");
+ mso.addElement(NS, "given_name", "Gemma");
+ mso.addElement(NS, "birth_date", MdocElements.toCbor("1990-01-02", "full-date"));
+ mso.addElement(NS + ".aamva", "organ_donor", 1);
+
+ final byte[] encoded = mso.encodeIssuerSigned(new CoseSign1Signer(issuer.getPrivate(), CoseAlgorithms.ES256,
+ null, "issuer".getBytes()));
+
+ final Map<Object, Object> issuerSigned = (Map<Object, Object>) CborTestReader.read(encoded);
+ final List<Object> issuerAuth = (List<Object>) issuerSigned.get("issuerAuth");
+ final byte[] payload = (byte[]) issuerAuth.get(2);
+ final Signature verifier = Signature.getInstance("SHA256withECDSAinP1363Format");
+ verifier.initVerify(issuer.getPublic());
+ verifier.update(CoseSign1Signer.toBeSigned((byte[]) issuerAuth.get(0), payload));
+ Assert.assertTrue(verifier.verify((byte[]) issuerAuth.get(3)));
+
+ final CborTag msoBytes = (CborTag) CborTestReader.read(payload);
+ Assert.assertEquals(msoBytes.tag(), CborTag.ENCODED_CBOR);
+ final Map<Object, Object> content = (Map<Object, Object>) CborTestReader.read((byte[]) msoBytes.value());
+ Assert.assertEquals(content.get("version"), "1.0");
+ Assert.assertEquals(content.get("docType"), "org.iso.18013.5.1.mDL");
+ final Map<Object, Object> validity = (Map<Object, Object>) content.get("validityInfo");
+ Assert.assertEquals(((CborTag) validity.get("signed")).value(), "2026-10-01T10:00:00Z");
+ final Map<Object, Object> status = (Map<Object, Object>) ((Map<Object, Object>) content.get("status"))
+ .get("status_list");
+ Assert.assertEquals(status.get("idx"), BigInteger.valueOf(7));
+
+ final Map<Object, Object> digests = (Map<Object, Object>) content.get("valueDigests");
+ final Map<Object, Object> nameSpaces = (Map<Object, Object>) issuerSigned.get("nameSpaces");
+ final List<String> elements = new ArrayList<>();
+ for (final Map.Entry<Object, Object> nameSpace : nameSpaces.entrySet()) {
+ for (final Object tagged : (List<Object>) nameSpace.getValue()) {
+ final Map<Object, Object> item = (Map<Object, Object>) CborTestReader
+ .read((byte[]) ((CborTag) tagged).value());
+ final byte[] digest = MessageDigest.getInstance("SHA-256").digest(CborSupport.encode(tagged));
+ final byte[] expected = (byte[]) ((Map<Object, Object>) digests.get(nameSpace.getKey()))
+ .get(item.get("digestID"));
+ Assert.assertTrue(Arrays.equals(digest, expected));
+ Assert.assertEquals(((byte[]) item.get("random")).length, 16);
+ elements.add(item.get("elementIdentifier").toString());
+ }
+ }
+ Assert.assertEquals(elements, List.of("given_name", "birth_date", "organ_donor"));
+ }
+
+ @Test(expectedExceptions = EncodingException.class)
+ public void elementTwice() throws Exception {
+ final MobileSecurityObject mso = new MobileSecurityObject("doc",
+ CoseKeySupport.toCoseKey((ECPublicKey) keyPair().getPublic()), Instant.now(), Instant.now(),
+ Instant.now());
+ mso.addElement(NS, "given_name", "Gemma");
+ mso.addElement(NS, "given_name", "Erasmus");
+ }
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/logic/StatusListCwtRequestedPredicateTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/logic/StatusListCwtRequestedPredicateTest.java
new file mode 100644
index 0000000..6e50976
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/logic/StatusListCwtRequestedPredicateTest.java
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.profile.logic;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+/**
+ * Unit tests for {@link StatusListCwtRequestedPredicate}.
+ */
+public class StatusListCwtRequestedPredicateTest {
+
+ private boolean test(final String accept) {
+ final MockHttpServletRequest request = new MockHttpServletRequest();
+ if (accept != null) {
+ request.addHeader("Accept", accept);
+ }
+ return new StatusListCwtRequestedPredicate(() -> request).test(null);
+ }
+
+ @Test
+ public void accept() {
+ Assert.assertTrue(test("application/statuslist+cwt"));
+ Assert.assertTrue(test("Application/StatusList+CWT, */*;q=0.1"));
+ Assert.assertFalse(test("application/statuslist+jwt"));
+ Assert.assertFalse(test("application/statuslist+jwt, application/statuslist+cwt"));
+ Assert.assertFalse(test("*/*"));
+ Assert.assertFalse(test(null));
+ }
+}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/StatusListCwtTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/StatusListCwtTest.java
new file mode 100644
index 0000000..58144f9
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/StatusListCwtTest.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2025, GÉANT
+ *
+ * Licensed under the Apache License, Version 2.0 (the “License”); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an “AS IS” BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.geant.shibboleth.plugin.openidvci.statuslist.profile.impl;
+
+import java.util.Date;
+import java.util.HexFormat;
+import java.util.Map;
+
+import org.geant.shibboleth.plugin.openidvci.cose.impl.CborSupport;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jwt.JWTClaimsSet;
+
+import net.shibboleth.shared.codec.EncodingException;
+
+/**
+ * Unit tests for {@link StatusListCwt}, the example of draft-ietf-oauth-status-list section 5.2.
+ */
+public class StatusListCwtTest {
+
+ private static final String EXAMPLE = "a502782168747470733a2f2f6578616d706c652e636f6d2f7374617475736c697374732f31"
+ + "061a648c5bea041a8898dfea19fffe19a8c019fffda2646269747301636c73744a78dadbb918000217015d";
+
+ @Test
+ public void claims() throws EncodingException {
+ final JWTClaimsSet claims = new JWTClaimsSet.Builder().issuer("https://example.com")
+ .subject("https://example.com/statuslists/1").issueTime(new Date(1686920170000L))
+ .expirationTime(new Date(2291720170000L)).claim("ttl", 43200L)
+ .claim("status_list", Map.of("bits", 1, "lst", "eNrbuRgAAhcBXQ")).build();
+
+ Assert.assertEquals(HexFormat.of().formatHex(CborSupport.encode(StatusListCwt.toCwtClaims(claims))),
+ EXAMPLE);
+ }
+
+ @Test(expectedExceptions = EncodingException.class)
+ public void incomplete() throws EncodingException {
+ StatusListCwt.toCwtClaims(new JWTClaimsSet.Builder().subject("https://example.com/statuslists/1").build());
+ }
+}
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list