[java-idp-plugin-vci] branch main updated: did:web as issuer, related endpoints. Reworking statuslist so that it is able to form response to all identifier types accordingly. Statuslist supports now also case od did:jwk key rollover by maintaining old keys
Codeberg
noreply at shibboleth.net
Fri Sep 11 14:05:02 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/eb33f63ba1a68ccd575eb013ac4128086ddc2f9d
The following commit(s) were added to refs/heads/main by this push:
new eb33f63 did:web as issuer, related endpoints. Reworking statuslist so that it is able to form response to all identifier types accordingly. Statuslist supports now also case od did:jwk key rollover by maintaining old keys
eb33f63 is described below
commit eb33f63ba1a68ccd575eb013ac4128086ddc2f9d
Author: Janne Lauros <janne.lauros at csc.fi>
AuthorDate: Fri Sep 11 17:04:48 2026 +0300
did:web as issuer, related endpoints. Reworking statuslist so that it is able to form response to all identifier types accordingly. Statuslist supports now also case od did:jwk key rollover by maintaining old keys
---
README.md | 252 ++++++++++++++++++--
.../CredentialIssuanceConfiguration.java | 25 +-
.../openidvci/statuslist/StatusListIssuerTag.java | 130 +++++++++++
.../statuslist/context/StatusListTokenContext.java | 27 +++
.../impl/DefaultMetadataDocumentConfiguration.java | 8 +-
.../FormOutboundDidDocumentResponseMessage.java | 256 +++++++++++++++++++++
.../openidvci/profile/impl/SignJWTCredential.java | 4 +-
.../logic/CredentialIssuerLookupFunction.java | 84 ++++++-
.../logic/StatusListIssuerTagLookupFunction.java | 154 +++++++++++++
.../openidvci/security/impl/DidJwkSupport.java | 112 ---------
.../plugin/openidvci/security/impl/DidSupport.java | 254 ++++++++++++++++++++
.../statuslist/profile/impl/AllocateIndex.java | 23 +-
.../profile/impl/FormStatusListToken.java | 148 ++++++++++--
.../profile/impl/RevokeStatusListIndex.java | 4 +-
.../profile/impl/SignStatusListToken.java | 94 +++++++-
.../idp/flows/did/document/document-beans.xml | 43 ++++
.../idp/flows/did/document/document-flow.xml | 22 ++
.../oauth/status-list/assign/assign-beans.xml | 8 +
.../flows/oauth/status-list/list/list-beans.xml | 9 +-
.../openid/vci/credentials/credentials-beans.xml | 3 +-
.../idp/service/relying-party/postconfig.xml | 9 +
.../plugin/openidvci/conf/openid-vci.properties | 10 +-
.../shibboleth/plugin/openidvci/module.properties | 7 +
.../logic/CredentialIssuerLookupFunctionTest.java | 30 +++
.../openidvci/security/impl/DidJwkSupportTest.java | 93 --------
.../openidvci/security/impl/DidSupportTest.java | 131 +++++++++++
26 files changed, 1674 insertions(+), 266 deletions(-)
diff --git a/README.md b/README.md
index bc9d1a7..bda40ff 100644
--- a/README.md
+++ b/README.md
@@ -32,8 +32,12 @@ Things you need to know about this add-on:
- It is not certified against any conformance suite. Do not deploy it in production.
- Credentials are signed with EC keys only. The signing actions do not support anything else.
-- `did:web` is not supported as Credential Issuer identifier. The issuer is either `did:jwk` or
- the plain issuer URL.
+- `did:webvh` is not supported as Credential Issuer identifier. The identifier is `did:jwk`,
+ `did:web` or the plain issuer url. See
+ [Credential Issuer identifiers](#credential-issuer-identifiers).
+- A wallet that names its own key by `did:web` is not supported. The key proof of a holder is
+ read as a `did:jwk` or as an embedded `jwk`, and the credential binds to the holder the same
+ way.
## Plugin Installation
@@ -282,8 +286,9 @@ it.
#### Settings of your own, per credential
Everything above is published exactly as you write it. The `_issuance_configuration` member is
-not. It states how long a credential is valid and whether it goes on a Status List, and it is
-dropped from the published `credential_configurations_supported`, so a wallet never sees it.
+not. It states how long a credential is valid, what it says issued it and whether it goes on a
+Status List, and it is dropped from the published `credential_configurations_supported`, so a
+wallet never sees it.
It goes inside the credential's own configuration, next to `format` and `vct`:
@@ -296,7 +301,8 @@ It goes inside the credential's own configuration, next to `format` and `vct`:
"_issuance_configuration": {
"lifetime": "P365D",
"not_before": "2026-08-01T06:00Z",
- "status_list": true
+ "status_list": true,
+ "issuer": "did:web"
},
"credential_metadata": { "...": "..." }
}
@@ -310,6 +316,7 @@ It goes inside the credential's own configuration, next to `format` and `vct`:
| `not_before` | Duration, moment or date | The instant of issuance | Where the validity window opens. |
| `status_list` | Boolean | `openidvci.status-list.includeByDefault`, `true` | Whether the credential takes a Status List slot. |
| `oidfed` | Boolean | `true` | Whether the credential states the Entity Identifier of this deployment in OpenID Federation. |
+| `issuer` | String | `openidvci.issuer` | What the credential says issued it, overriding the deployment. `did:jwk`, `did:web` and `url` are the three identifiers of [Credential Issuer identifiers](#credential-issuer-identifiers). Stating nothing takes whatever `openidvci.issuer` says. |
All three are optional and so is the member itself. A credential that states nothing behaves
exactly as it did before you knew this member existed.
@@ -440,10 +447,20 @@ If you do take a key of your own into use, defining **openidvci.SigningCredentia
Credential Issuer identifier is `did:jwk`, which is the default. The key travels inside the
credential itself and there is nothing to publish.
-Publishing starts to matter when you set `openidvci.issuer` to the plain issuer url instead.
-The key is then named rather than carried, so add it to
-**shibboleth.oidc.SigningCredentialsToPublish** in *conf/oidc-credentials.xml*. Nobody is able
-to verify a credential that is signed with a key they cannot find.
+Publishing starts to matter when the identifier names the key rather than carries it. Set
+`openidvci.issuer` to the plain issuer url and the key is named, so add it to
+**shibboleth.oidc.SigningCredentialsToPublish** in *conf/oidc-credentials.xml*, which is where
+`/.well-known/jwt-vc-issuer` points a verifier. Set it to `did:web` and there is nothing to add
+anywhere, as the document at `/.well-known/did.json` is formed from
+**openidvci.SigningCredentials** and **openidvci.status-list.SigningCredentials** themselves.
+Either way, nobody is able to verify a credential that is signed with a key they cannot find.
+See [Credential Issuer identifiers](#credential-issuer-identifiers) for what each identifier
+asks you to publish.
+
+With `did:web` those two lists decide what is verifiable, so they are also where key rollover
+happens. Add the new key to the list, let the document be read with both keys in it, start
+signing with the new one and remove the old one only once nothing unexpired was signed with it.
+A key removed while credentials still name it is a key a verifier no longer finds.
Stating this deployment in OpenID Federation turns the list from optional into required. The
Entity Configuration publishes those keys as the `jwks` of `vc_issuer`, and the bean that puts
@@ -456,6 +473,114 @@ You need to import the file where its beans are needed:
- into *conf/global.xml* instead, if you state this deployment in OpenID Federation. Publishing
the list in an Entity Configuration works only from the root context.
+### Credential Issuer identifiers
+
+**File(s):** *conf/openid-vci.properties*, *conf/relying-party.xml*
+**Format:** Spring Properties, Native Spring
+
+One property decides what an issued credential says issued it. `openidvci.issuer` takes three
+values, and each one decides both the `iss` of the credential and what this deployment has to
+publish for a verifier to check the signature. The same value decides the `iss` of a Status List
+Token, so the two always agree.
+
+| `openidvci.issuer` | What a credential says issued it | What you publish |
+|---|---|---|
+| empty or `did:jwk`, the default | the did:jwk of the signing key | nothing |
+| `did:web` | the did:web of this deployment | the did:web document, at `/.well-known/did.json` |
+| `url` | the issuer of the OP | JWT VC issuer metadata, at `/.well-known/jwt-vc-issuer` |
+
+Values other than the three are used as the identifier itself. The Status List Token of such a
+credential names the issuer of the OP.
+
+A Credential Configuration is able to state an `issuer` of its own, with the same three values,
+and it overrides the deployment. See
+[Settings of your own, per credential](#settings-of-your-own-per-credential).
+
+**DID.Document** has to be in *conf/relying-party.xml* for the did:web document to be served,
+**OAuth.JWTVCIssuer** for the JWT VC issuer metadata. See
+[Relying party configuration](#relying-party-configuration).
+
+#### Where a verifier finds the key
+
+| `openidvci.issuer` | Where the key is |
+|---|---|
+| `did:jwk` | inside the credential, nothing is published |
+| `did:web` | `/.well-known/did.json`, formed from **openidvci.SigningCredentials** and **openidvci.status-list.SigningCredentials** as it is read |
+| `url` | `/.well-known/jwt-vc-issuer` and the `jwks_uri` it names, a template in *static/*, keys from **shibboleth.oidc.SigningCredentialsToPublish** |
+
+<details>
+<summary>Example of the document at /.well-known/did.json</summary>
+
+```json
+{
+ "@context": [
+ "https://www.w3.org/ns/did/v1",
+ "https://w3id.org/security/suites/jws-2020/v1"
+ ],
+ "id": "did:web:issuer.example.org",
+ "verificationMethod": [
+ {
+ "id": "did:web:issuer.example.org#signingvci",
+ "type": "JsonWebKey2020",
+ "controller": "did:web:issuer.example.org",
+ "publicKeyJwk": {
+ "kty": "EC",
+ "crv": "P-256",
+ "kid": "signingvci",
+ "x": "qQ9ESeIrQ36JijWM-8xdcjXwY46RW3p9YDtP0MVaLnE",
+ "y": "5yVZRqwp1tde_CnKC662wW-XFQhEOrGAPi0LTG4OpI8"
+ }
+ }
+ ],
+ "assertionMethod": [
+ "did:web:issuer.example.org#signingvci"
+ ]
+}
+```
+
+Both signing credential lists are stated, a key that appears in both only once.
+
+</details>
+
+#### Differences
+
+| | `did:jwk` | `did:web` | `url` |
+|---|---|---|---|
+| Anything to publish | no | yes, from the plugin | yes, from *static/* and the OP keyset |
+| Identifier survives a key rollover | **no** | yes | yes |
+| Same identifier for credentials and Status List Tokens | yes | yes | yes |
+| Old signing keys have to be kept | yes, while credentials live | no | no |
+| Credential points back at a resolvable issuer | no | yes | yes |
+
+A `did:jwk` identifier is the signing key, so rotating the key changes the identifier. `did:web`
+and `url` name this deployment, so the identifier outlives every key.
+
+#### Status Lists
+
+A Status List Token is published under the identifier of the credential pointing at it. The
+status list url carries which identifier that is, so a deployment issuing more than one kind of
+credential is answered correctly for each.
+
+Rotating the signing key with `did:web` or `url`: publish the new key alongside the old one as in
+[Signing keys](#signing-keys), nothing else.
+
+Rotating it with `did:jwk`: add the new key to **openidvci.status-list.SigningCredentials**
+instead of replacing the old one, and keep every key that has signed credentials there while
+those credentials are unexpired. A key removed too early makes the endpoint answer `500` for the
+credentials naming it, and the log names the key it looked for.
+
+`"status_list": false` keeps a credential off the list, see
+[Settings of your own, per credential](#settings-of-your-own-per-credential).
+
+#### OpenID Federation
+
+`openidvci.issuer` does not affect the federation identity. A credential carries the Entity
+Identifier in `fed`, or in `termsOfUse` when it is a `vc+sd-jwt` one, whatever the identifier is.
+
+`credential_issuer` of your Credential Issuer metadata is an https url in every case, and the
+federation profile requires it to equal your Entity Identifier. See
+[Whether a credential names this deployment in a federation](#whether-a-credential-names-this-deployment-in-a-federation).
+
### Pre-authorized code flow
**File(s):** *metadata/oidc-client.json*, *conf/relying-party.xml*, *conf/openid-vci.properties*
@@ -960,6 +1085,11 @@ Cache-Control: no-store
</details>
+The `id` is the one in the credential's `status_list` url. When credentials of more than one
+Credential Issuer identifier are issued, that url carries which identifier the credential was
+issued under and the id you copy from it carries the same. Either form is accepted here, a slot
+belongs to the month whatever the credential says issued it.
+
Revoking is idempotent and it goes one way only. Nothing in the plugin turns a slot back to
valid.
@@ -1032,14 +1162,15 @@ enabled by default.
| `/idp/profile/openid/vci/nonce` | **OpenID.VCI.Nonce** | `http://geant.org/ns/profiles/openid/vci/nonce` |
| `/idp/profile/oauth/jwt-vc-issuer` | **OAuth.JWTVCIssuer** | `http://geant.org/ns/profiles/oauth/jwt-vc-issuer` |
| `/idp/profile/oauth/authorization-server` | **OAuth.AuthorizationServer** | `http://geant.org/ns/profiles/oauth/authorization-server` |
+| `/idp/profile/did/document` | **DID.Document** | `http://geant.org/ns/profiles/did/document` |
| `/idp/profile/oauth/status-list/list` | **OAuth.StatusList** | `http://geant.org/ns/profiles/oauth/status-list` |
| `/idp/profile/oauth/status-list/revoke` | **OAuth.StatusList.Revoke** | `http://geant.org/ns/profiles/oauth/status-list/revoke` |
| `/idp/profile/oauth/status-list/assignments` | **OAuth.StatusList.Assignments** | `http://geant.org/ns/profiles/oauth/status-list/assignments` |
Some of these endpoints are reached without any client registration, so they belong to
**shibboleth.UnverifiedRelyingParty**. **OpenID.VCI.IssuerMetadata**, **OpenID.VCI.Nonce**,
-**OAuth.JWTVCIssuer** and **OAuth.AuthorizationServer** serve wallets and **OAuth.StatusList**
-serves verifiers. **OpenID.VCI.Token** and
+**OAuth.JWTVCIssuer** and **OAuth.AuthorizationServer** serve wallets, **OAuth.StatusList** and
+**DID.Document** serve verifiers. **OpenID.VCI.Token** and
**OpenID.VCI.Credentials** belong there too if you accept unregistered wallets.
<details>
@@ -1055,6 +1186,8 @@ serves verifiers. **OpenID.VCI.Token** and
<ref bean="OAuth.JWTVCIssuer" />
<!-- Anyone may read the authorization server metadata -->
<ref bean="OAuth.AuthorizationServer" />
+ <!-- Anyone may read the did:web document, needed when openidvci.issuer is did:web -->
+ <ref bean="DID.Document" />
<!-- Anyone may ask for a nonce -->
<ref bean="OpenID.VCI.Nonce" />
<!-- Anyone may read a Status List -->
@@ -1144,9 +1277,16 @@ works for as long as the OP has an EC key. Signing does ES256, ES384 and ES512 o
#### What a credential says issued it
+One property, three kinds of value, and the same value lands in a Status List Token. What each
+one publishes and how a verifier reaches the key is in
+[Credential Issuer identifiers](#credential-issuer-identifiers).
+
| Switch | Why you would touch it |
|---|---|
-| `openidvci.issuer` | The `iss` of a credential is the plain issuer url instead of the `did:jwk` of the signing key. Empty is the default and gives `did:jwk`, which carries the key inside the credential and needs nothing published. |
+| `openidvci.issuer` empty | The default. The `iss` is the `did:jwk` of the signing key, which carries the key inside the credential and needs nothing published. The identifier changes when the key does, so a deployment that rotates keys and revokes credentials wants one of the two below. |
+| `openidvci.issuer` = `did:web` | The `iss` is the `did:web` of this deployment, derived from the issuer of the OP. A verifier reads the keys from `/.well-known/did.json`, which needs the **DID.Document** profile enabled. |
+| `openidvci.issuer` = `url` | The `iss` is the issuer of the OP. A verifier reads the keys through `/.well-known/jwt-vc-issuer`, which needs the **OAuth.JWTVCIssuer** profile enabled and the key in **shibboleth.oidc.SigningCredentialsToPublish**. |
+| `issuer` of a credential | One Credential Configuration says something else than the deployment. Credentials of more than one identifier are issued side by side, each with a Status List of its own to match. |
#### What the discovery documents say
@@ -1221,7 +1361,9 @@ side storage rather than the memory backed default of a cluster.
A wallet reads three documents and every one of them comes from an endpoint of this plugin.
Each endpoint reads a file of *static/*, and each of those files is a Velocity template where
`$baseUrl`, `$issuer` and `$host` are resolved from the issuer of the OP. None of them names
-your host, so none of them needs editing to name it.
+your host, so none of them needs editing to name it. A fourth document is published for
+verifiers rather than for wallets, and that one has no file behind it at all. It is described
+after these three.
| Document | Endpoint | File |
|---|---|---|
@@ -1274,7 +1416,9 @@ cannot be read, so a broken file of credentials takes the metadata endpoint down
`/.well-known/jwt-vc-issuer` tells a verifier which keys sign your credentials. Its `issuer`
has to be the same value the credentials carry as `iss`. The document matters only when your
Credential Issuer identifier is the plain issuer url. With `did:jwk`, which is the default, the
-key travels inside the credential and nobody reads the document.
+key travels inside the credential, and with `did:web` the keys come from a document of their
+own. In neither case does anybody read this one. See
+[Credential Issuer identifiers](#credential-issuer-identifiers).
<details>
<summary>Contents of static/jwt-vc-issuer.json as it comes with the plugin</summary>
@@ -1288,6 +1432,12 @@ key travels inside the credential and nobody reads the document.
</details>
+A fourth document is published when your Credential Issuer identifier is `did:web`, and it is
+unlike the three above. `/.well-known/did.json` comes from `/idp/profile/did/document` and there
+is no file of *static/* behind it. The document is formed from the keys credentials and Status
+List Tokens are signed with, so there is nothing to write and nothing to keep in step by hand.
+See [DID.Document](#diddocument).
+
`/.well-known/oauth-authorization-server` tells a wallet where the token endpoint is. Note that
`token_endpoint` is the VCI one and not the OP's own.
@@ -1364,6 +1514,15 @@ responses, so that header belongs to the same file. With Jetty rewrite handler:
</New>
</Arg>
</Call>
+ <!-- Needed only when openidvci.issuer is did:web -->
+ <Call name="addRule">
+ <Arg>
+ <New class="org.eclipse.jetty.rewrite.handler.RewritePatternRule">
+ <Set name="pattern">/.well-known/did.json</Set>
+ <Set name="replacement">/idp/profile/did/document</Set>
+ </New>
+ </Arg>
+ </Call>
<!-- The JSON-LD context of the vc+sd-jwt credential is a static file of Jetty -->
<Call name="addRule">
<Arg>
@@ -1400,11 +1559,11 @@ advertises the OP token endpoint instead of `/idp/profile/openid/vci/token` and
list your credential scopes. A wallet that reads only that document fails, so wire
`/.well-known/oauth-authorization-server` to **OAuth.AuthorizationServer** as above.
-Each profile of this plugin is described below. Six of the ten inherit the settings of the
+Each profile of this plugin is described below. Six of the eleven inherit the settings of the
OAuth2/OIDC profile configurations of the OP plugin, `tokenEndpointAuthMethods`,
`accessTokenLifetime`, `forcePKCE`, `securityConfiguration` and the DPoP settings among them.
-**OpenID.VCI.IssuerMetadata**, **OAuth.JWTVCIssuer**, **OAuth.AuthorizationServer** and
-**OAuth.StatusList** are the exceptions. They serve requests that have no client at all and
+**OpenID.VCI.IssuerMetadata**, **OAuth.JWTVCIssuer**, **OAuth.AuthorizationServer**,
+**DID.Document** and **OAuth.StatusList** are the exceptions. They serve requests that have no client at all and
inherit nothing of that. Every setting of this plugin is in
[Profile configuration settings](#profile-configuration-settings), the ones a profile really
reads are named with the profile.
@@ -1496,6 +1655,54 @@ requestable with the code flow.
There is nothing to configure per relying party.
+### DID.Document
+
+Publishes the did:web document of this deployment. A `GET` of `/idp/profile/did/document`, no
+authentication and no request parameters. Wire `/.well-known/did.json` to it, that is where a
+verifier looks.
+
+<details>
+<summary>Example snippet of conf/relying-party.xml</summary>
+
+```xml
+<bean id="shibboleth.UnverifiedRelyingParty" parent="RelyingParty">
+ <property name="profileConfigurations">
+ <list>
+ ...
+ <ref bean="DID.Document" />
+ </list>
+ </property>
+</bean>
+```
+
+</details>
+
+You need this endpoint only when you set `openidvci.issuer` to `did:web`. A verifier resolves
+that identifier to this document to find the key that signed a credential, and it looks for the
+document only when the credential names its issuer that way. With `did:jwk` the key travels
+inside the credential and with the plain issuer url the keys come from
+`/.well-known/jwt-vc-issuer`, so in both cases nothing fetches this document and enabling it
+changes nothing.
+
+There is no file behind the document and no template. It is formed from
+**openidvci.SigningCredentials** and **openidvci.status-list.SigningCredentials**, the public
+half of each key, so that the keys it states cannot drift from the keys in use. A key that
+appears in both lists is stated once. The subject of the document is the `did:web` derived from
+the issuer of the OP, each key is a verification method named by the `did:web`, `#` and the name
+the key carries, and every one of them is listed under `assertionMethod`.
+
+That makes those two lists the record of which keys are verifiable. A key is introduced by
+adding it to a list and retired by removing it, and removing one while credentials still name it
+leaves them unverifiable. See
+[Credential Issuer identifiers](#credential-issuer-identifiers) for the document itself and for
+what rollover asks of you.
+
+The endpoint answers `500 server_error` when the issuer of the OP names no host, or when none of
+the signing credentials is an EC or RSA key, rather than publish a document without the keys a
+verifier came for.
+
+There is nothing to configure per relying party. The document is the same for everyone.
+
### OpenID.VCI.CredentialOffer
Creates a Credential Offer. A `POST` of `application/json` to
@@ -1661,6 +1868,12 @@ authentication and no request parameters.
Publishes a Status List Token. A `GET` of `/idp/profile/oauth/status-list/list`, no
authentication. The response is of media type `application/statuslist+jwt`.
+The `id` parameter names the list, and a verifier takes the whole url from the credential it is
+checking. When credentials of more than one Credential Issuer identifier are issued, that url
+also says which identifier the credential was issued under, and the token is published under the
+same one so that the two agree. See
+[Credential Issuer identifiers](#credential-issuer-identifiers).
+
<details>
<summary>Example snippet of conf/relying-party.xml</summary>
@@ -1793,6 +2006,8 @@ examples of this document. `OIDC.Keyset`, `OIDC.Configuration`, `OAUTH2.TokenAud
<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 -->
+ <ref bean="DID.Document" />
<!-- Anyone may read the authorization server metadata -->
<ref bean="OAuth.AuthorizationServer" />
<!-- Anyone may ask for a nonce -->
@@ -1874,7 +2089,7 @@ plugin and is loaded automatically, you do not need to touch `idp.additionalProp
| Name | Type | Default | Description |
|---|---|---|---|
-| `openidvci.issuer` | String | | Credential Issuer value used in credentials. Empty means `did:jwk`. |
+| `openidvci.issuer` | String | | Credential Issuer value used in credentials and in Status List Tokens. Empty and `did:jwk` both mean the did:jwk of the signing key, `did:web` the did:web of this deployment and `url` the issuer of the OP. Any other value is the identifier itself. A Credential Configuration is able to state an `issuer` of its own and override this per credential. See [Credential Issuer identifiers](#credential-issuer-identifiers). |
| `openidvci.authn.flows` | String | `OAuth2Client` | Regular expression matching the login flows to enable for VCI endpoints. |
| `openidvci.StorageService` | Bean ID | Bean named **shibboleth.StorageService** | Storage for Credential Offers. Requires server-side storage. |
| `openidvci.preAuthorizedCode.defaultLifetime` | Duration | `PT10M` | Lifetime of the pre-authorized code. |
@@ -1917,6 +2132,7 @@ plugin and is loaded automatically, you do not need to touch `idp.additionalProp
| `openidvci.logging.issuer-metadata` | String | `OpenID.VCI.IssuerMetadata` | Logging id of the issuer-metadata flow. |
| `openidvci.logging.jwt-vc-issuer` | String | `OAuth.JWTVCIssuer` | Logging id of the jwt-vc-issuer flow. |
| `openidvci.logging.authorization-server` | String | `OAuth.AuthorizationServer` | Logging id of the authorization-server flow. |
+| `openidvci.logging.did-document` | String | `DID.Document` | Logging id of the did:web document flow. |
| `openidvci.logging.status-list.list` | String | `OAuth.StatusList` | Logging id of the status list flow. |
| `openidvci.logging.status-list.revoke` | String | `OAuth.StatusList.Revoke` | Logging id of the revoke flow. |
| `openidvci.logging.status-list.assignments` | String | `OAuth.StatusList.Assignments` | Logging id of the assignments flow. |
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 cf13e63..4726f8f 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
@@ -52,10 +52,16 @@ public final class CredentialIssuanceConfiguration {
@Nullable
private final Boolean statusList;
- /** Whether credentials of this configuration are stated in OpenID Federation. */
+ /**
+ * Whether credentials of this configuration are stated in OpenID Federation.
+ */
@Nullable
private final Boolean oidFederation;
+ /** Credential Issuer identifier credentials of this configuration carry. */
+ @Nullable
+ private final String issuer;
+
/**
* Constructor.
*
@@ -67,16 +73,21 @@ public final class CredentialIssuanceConfiguration {
* Status List slot
* @param inOIDFederation whether credentials of this configuration are
* stated in OpenID Federation
+ * @param issuerIdentifier Credential Issuer identifier credentials of this
+ * configuration carry, one of 'did:jwk', 'did:web'
+ * and 'url', overriding the one of the deployment
*/
@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("oidfed") @Nullable final Boolean inOIDFederation,
+ @JsonProperty("issuer") @Nullable final String issuerIdentifier) {
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();
}
/**
@@ -120,6 +131,16 @@ public final class CredentialIssuanceConfiguration {
return oidFederation;
}
+ /**
+ * Get the Credential Issuer identifier credentials of this configuration carry.
+ *
+ * @return the identifier, or null to take the one of the deployment
+ */
+ @Nullable
+ public String getIssuer() {
+ return issuer;
+ }
+
/**
* 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/StatusListIssuerTag.java b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/StatusListIssuerTag.java
new file mode 100644
index 0000000..f244eee
--- /dev/null
+++ b/openid-vci-api/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/StatusListIssuerTag.java
@@ -0,0 +1,130 @@
+/*
+ * 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;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Tag naming the issuer a Status List Token is published under, as it travels
+ * in the url of a status list.
+ *
+ * Storage is keyed by the bucket alone, so the tag is a runtime matter only and
+ * every credential of a month shares one list. A url with no tag names no
+ * issuer.
+ */
+public final class StatusListIssuerTag {
+
+ /**
+ * Separator of the bucket and the tag. Unreserved, and no part of a bucket id.
+ */
+ @Nonnull
+ public static final String SEPARATOR = "~";
+
+ /** Tag of a list handed out under the plain issuer url. */
+ @Nonnull
+ public static final String URL = "url";
+
+ /** Tag of a list handed out under the did:web of this deployment. */
+ @Nonnull
+ public static final String DID_WEB = "did-web";
+
+ /** Tag of a list handed out under a did:jwk, followed by the key it names. */
+ @Nonnull
+ public static final String DID_JWK = "did-jwk";
+
+ /** Separator of the did:jwk tag and the thumbprint of the key. */
+ @Nonnull
+ private static final String THUMBPRINT_SEPARATOR = ":";
+
+ /** Constructor. */
+ private StatusListIssuerTag() {
+ }
+
+ /**
+ * Build the tag of a did:jwk issuer.
+ *
+ * @param thumbprint thumbprint of the key the identifier names
+ * @return the tag
+ */
+ @Nonnull
+ public static String didJwk(@Nonnull final String thumbprint) {
+ return DID_JWK + THUMBPRINT_SEPARATOR + thumbprint;
+ }
+
+ /**
+ * Build the value a status list url carries.
+ *
+ * @param bucketId identifier of the bucket
+ * @param tag tag of the issuer, or null for none
+ * @return the value, the bucket alone when there is no tag
+ */
+ @Nonnull
+ public static String reference(@Nonnull final String bucketId, @Nullable final String tag) {
+ return tag == null || tag.isEmpty() ? bucketId : bucketId + SEPARATOR + tag;
+ }
+
+ /**
+ * Get the bucket of a value a status list url carries, the part storage is
+ * keyed by.
+ *
+ * @param reference value to read
+ * @return the bucket, or null when there is nothing to read
+ */
+ @Nullable
+ public static String bucketOf(@Nullable final String reference) {
+ if (reference == null) {
+ return null;
+ }
+ final int separator = reference.indexOf(SEPARATOR);
+ return separator < 0 ? reference : reference.substring(0, separator);
+ }
+
+ /**
+ * Get the tag of a value a status list url carries.
+ *
+ * @param reference value to read
+ * @return the tag, or null when the value carries none
+ */
+ @Nullable
+ public static String tagOf(@Nullable final String reference) {
+ if (reference == null) {
+ return null;
+ }
+ final int separator = reference.indexOf(SEPARATOR);
+ if (separator < 0 || separator + SEPARATOR.length() >= reference.length()) {
+ return null;
+ }
+ return reference.substring(separator + SEPARATOR.length());
+ }
+
+ /**
+ * Get the thumbprint a did:jwk tag names.
+ *
+ * @param tag tag to read
+ * @return the thumbprint, or null when the tag names no key
+ */
+ @Nullable
+ public static String thumbprintOf(@Nullable final String tag) {
+ if (tag == null || !tag.startsWith(DID_JWK + THUMBPRINT_SEPARATOR)) {
+ return null;
+ }
+ final String thumbprint = tag.substring(DID_JWK.length() + THUMBPRINT_SEPARATOR.length());
+ return thumbprint.isEmpty() ? null : thumbprint;
+ }
+
+}
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 bb165a5..b297b34 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,13 @@ public class StatusListTokenContext extends BaseContext {
@Nullable
private String signedToken;
+ /**
+ * Thumbprint of the key the token has to be signed with, null to leave it to
+ * the signing configuration.
+ */
+ @Nullable
+ private String keyThumbprint;
+
/**
* Get claims of status list token, before signing.
*
@@ -76,4 +83,24 @@ public class StatusListTokenContext extends BaseContext {
signedToken = token;
}
+ /**
+ * Get the thumbprint of the key the token has to be signed with.
+ *
+ * @return the thumbprint, or null to leave the key to the signing configuration
+ */
+ @Nullable
+ public String getKeyThumbprint() {
+ return keyThumbprint;
+ }
+
+ /**
+ * Set the thumbprint of the key the token has to be signed with.
+ *
+ * @param thumbprint the thumbprint, or null to leave it to the signing
+ * configuration
+ */
+ public void setKeyThumbprint(@Nullable final String thumbprint) {
+ keyThumbprint = thumbprint;
+ }
+
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfiguration.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfiguration.java
index c843be7..0639be5 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfiguration.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/config/impl/DefaultMetadataDocumentConfiguration.java
@@ -37,8 +37,12 @@ public class DefaultMetadataDocumentConfiguration extends AbstractInterceptorAwa
/** ID for the authorization server metadata profile configuration. */
@Nonnull
@NotEmpty
- public static final String PROFILE_ID_AUTHORIZATION_SERVER =
- "http://geant.org/ns/profiles/oauth/authorization-server";
+ public static final String PROFILE_ID_AUTHORIZATION_SERVER = "http://geant.org/ns/profiles/oauth/authorization-server";
+
+ /** ID for the did:web document profile configuration. */
+ @Nonnull
+ @NotEmpty
+ public static final String PROFILE_ID_DID_DOCUMENT = "http://geant.org/ns/profiles/did/document";
/**
* Constructor.
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundDidDocumentResponseMessage.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundDidDocumentResponseMessage.java
new file mode 100644
index 0000000..47c6902
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/FormOutboundDidDocumentResponseMessage.java
@@ -0,0 +1,256 @@
+/*
+ * 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.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.messaging.impl.MetadataDocumentSuccessResponse;
+import org.geant.shibboleth.plugin.openidvci.security.impl.DidSupport;
+import org.opensaml.profile.action.ActionSupport;
+import org.opensaml.profile.action.EventIds;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.credential.Credential;
+import org.slf4j.Logger;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.nimbusds.jose.jwk.JWK;
+
+import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.oidc.security.CredentialConversionUtil;
+import net.shibboleth.oidc.security.credential.JWKCredential;
+import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.collection.CollectionSupport;
+import net.shibboleth.shared.component.ComponentInitializationException;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Action publishing the did:web document of this deployment.
+ *
+ * States the public half of every key credentials and Status List Tokens are
+ * signed with. Formed from the signing credentials rather than read from a
+ * file, so a key is published for as long as it is among them.
+ */
+public class FormOutboundDidDocumentResponseMessage extends AbstractProfileAction {
+
+ /** Member naming the JSON-LD contexts of the document. */
+ @Nonnull
+ public static final String CONTEXT = "@context";
+
+ /** Member naming the subject of the document. */
+ @Nonnull
+ public static final String ID = "id";
+
+ /** Member carrying the verification methods. */
+ @Nonnull
+ public static final String VERIFICATION_METHOD = "verificationMethod";
+
+ /**
+ * Member carrying the verification methods a credential may be asserted with.
+ */
+ @Nonnull
+ public static final String ASSERTION_METHOD = "assertionMethod";
+
+ /** Member naming the controller of a verification method. */
+ @Nonnull
+ public static final String CONTROLLER = "controller";
+
+ /** Member naming the type of a verification method. */
+ @Nonnull
+ public static final String TYPE = "type";
+
+ /** Member carrying the key of a verification method. */
+ @Nonnull
+ public static final String PUBLIC_KEY_JWK = "publicKeyJwk";
+
+ /** Member naming the algorithm of a key. */
+ @Nonnull
+ public static final String ALG = "alg";
+
+ /** Type given to every verification method of the document. */
+ @Nonnull
+ public static final String JSON_WEB_KEY = "JsonWebKey2020";
+
+ /** Contexts of the document. */
+ @Nonnull
+ private static final List<String> CONTEXTS = CollectionSupport.listOf("https://www.w3.org/ns/did/v1",
+ "https://w3id.org/security/suites/jws-2020/v1");
+
+ /** Class logger. */
+ @Nonnull
+ private final Logger log = LoggerFactory.getLogger(FormOutboundDidDocumentResponseMessage.class);
+
+ /**
+ * Url this deployment is reached at, the did:web identifier is derived from it.
+ */
+ @Nullable
+ private String deploymentIssuer;
+
+ /** Credentials issued Verifiable Credentials are signed with. */
+ @Nonnull
+ private List<Credential> credentialSigningCredentials = CollectionSupport.emptyList();
+
+ /** Credentials Status List Tokens are signed with. */
+ @Nonnull
+ private List<Credential> statusListSigningCredentials = CollectionSupport.emptyList();
+
+ /** The did:web identifier of this deployment. */
+ @NonnullAfterInit
+ private String didWeb;
+
+ /**
+ * Set the url this deployment is reached at.
+ *
+ * @param issuer url of this deployment
+ */
+ public void setDeploymentIssuer(@Nullable final String issuer) {
+ checkSetterPreconditions();
+ deploymentIssuer = issuer;
+ }
+
+ /**
+ * Set the credentials issued Verifiable Credentials are signed with.
+ *
+ * @param credentials the credentials, or null for none
+ */
+ public void setCredentialSigningCredentials(@Nullable final List<Credential> credentials) {
+ checkSetterPreconditions();
+ credentialSigningCredentials = credentials == null ? CollectionSupport.emptyList()
+ : CollectionSupport.copyToList(credentials);
+ }
+
+ /**
+ * Set the credentials Status List Tokens are signed with.
+ *
+ * @param credentials the credentials, or null for none
+ */
+ public void setStatusListSigningCredentials(@Nullable final List<Credential> credentials) {
+ checkSetterPreconditions();
+ statusListSigningCredentials = credentials == null ? CollectionSupport.emptyList()
+ : CollectionSupport.copyToList(credentials);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doInitialize() throws ComponentInitializationException {
+ super.doInitialize();
+
+ final String derived = DidSupport.toDidWeb(deploymentIssuer);
+ if (derived == null) {
+ throw new ComponentInitializationException(
+ "Issuer '" + deploymentIssuer + "' names no host, unable to derive a did:web identifier");
+ }
+ didWeb = derived;
+
+ if (credentialSigningCredentials.isEmpty() && statusListSigningCredentials.isEmpty()) {
+ throw new ComponentInitializationException("No signing credentials to publish");
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ final List<Object> methods = new ArrayList<>();
+ final List<Object> assertions = new ArrayList<>();
+ final Set<String> stated = new LinkedHashSet<>();
+
+ verificationMethods(credentialSigningCredentials, methods, assertions, stated);
+ verificationMethods(statusListSigningCredentials, methods, assertions, stated);
+
+ if (methods.isEmpty()) {
+ log.error("{} None of the signing keys could be stated, there is no document to publish", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+ return;
+ }
+
+ final Map<String, Object> document = new LinkedHashMap<>();
+ document.put(CONTEXT, CONTEXTS);
+ document.put(ID, didWeb);
+ document.put(VERIFICATION_METHOD, methods);
+ document.put(ASSERTION_METHOD, assertions);
+
+ try {
+ profileRequestContext.ensureOutboundMessageContext()
+ .setMessage(new MetadataDocumentSuccessResponse(document));
+ } catch (final JsonProcessingException e) {
+ log.error("{} Could not serialize the did:web document", getLogPrefix(), e);
+ ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
+ return;
+ }
+
+ log.debug("{} Publishing the did:web document of {}, verification methods {}", getLogPrefix(), didWeb, stated);
+ }
+
+ /**
+ * State every credential of a list as a verification method of the document.
+ *
+ * A credential already stated is left out, the two lists this deployment signs
+ * with are commonly the same key.
+ *
+ * @param credentials credentials to state
+ * @param methods verification methods to write to
+ * @param assertions assertion methods to write to
+ * @param stated verification methods already stated
+ */
+ private void verificationMethods(@Nonnull final List<Credential> credentials, @Nonnull final List<Object> methods,
+ @Nonnull final List<Object> assertions, @Nonnull final Set<String> stated) {
+
+ for (final Credential credential : credentials) {
+
+ final String keyName = DidSupport.keyName(credential);
+ if (keyName == null) {
+ log.warn("Signing credential names its key by nothing, leaving it out of the did:web document");
+ continue;
+ }
+
+ // Conversion keeps the public components only.
+ final JWK key = CredentialConversionUtil.credentialToKey(credential);
+ if (key == null) {
+ log.warn("Signing key '{}' is neither RSA nor EC, leaving it out of the did:web document", keyName);
+ continue;
+ }
+
+ final String id = didWeb + "#" + keyName;
+ if (!stated.add(id)) {
+ continue;
+ }
+
+ final Map<String, Object> published = new LinkedHashMap<>(key.toJSONObject());
+ if (credential instanceof final JWKCredential jwkCredential && jwkCredential.getAlgorithm() != null) {
+ published.put(ALG, jwkCredential.getAlgorithm().getName());
+ }
+
+ final Map<String, Object> method = new LinkedHashMap<>();
+ method.put(ID, id);
+ method.put(TYPE, JSON_WEB_KEY);
+ method.put(CONTROLLER, didWeb);
+ method.put(PUBLIC_KEY_JWK, published);
+
+ methods.add(method);
+ assertions.add(id);
+ }
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SignJWTCredential.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SignJWTCredential.java
index de257ed..abfba63 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SignJWTCredential.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/impl/SignJWTCredential.java
@@ -23,7 +23,7 @@ import java.util.function.Function;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
-import org.geant.shibboleth.plugin.openidvci.security.impl.DidJwkSupport;
+import org.geant.shibboleth.plugin.openidvci.security.impl.DidSupport;
import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
import org.geant.shibboleth.plugin.openidvci.messaging.impl.OpenIDVCICredentialsRequest;
import org.geant.shibboleth.plugin.openidvci.profile.OpenIDVCIEventIds;
@@ -164,7 +164,7 @@ public class SignJWTCredential extends AbstractOIDCResponseAction {
for (final JWTClaimsSet credential : ctx.getJWTCredentials()) {
try {
credentials.add(new JWSTokenSigner(
- DidJwkSupport.nameKeyByIssuer(signatureSigningParameters, credential.getIssuer()))
+ DidSupport.nameKeyByIssuer(signatureSigningParameters, credential.getIssuer()))
.sign(credential, format).serialize());
} catch (final SignatureException e) {
log.error("{} Signing credential failed", getLogPrefix(), e);
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialIssuerLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialIssuerLookupFunction.java
index 3ebb165..aa1410c 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialIssuerLookupFunction.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialIssuerLookupFunction.java
@@ -23,6 +23,10 @@ import java.util.function.Function;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialConfiguration;
+import org.geant.shibboleth.plugin.openidvci.credential.CredentialIssuanceConfiguration;
+import org.geant.shibboleth.plugin.openidvci.messaging.context.CredentialsContext;
+import org.geant.shibboleth.plugin.openidvci.security.impl.DidSupport;
import org.opensaml.messaging.context.navigate.ChildContextLookup;
import org.opensaml.profile.context.ProfileRequestContext;
import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
@@ -39,14 +43,16 @@ import net.shibboleth.shared.logic.Constraint;
import net.shibboleth.shared.primitive.LoggerFactory;
/**
- * Issuer identifier for everything this plugin signs. By default the
- * <a href="https://github.com/quartzjer/did-jwk/blob/main/spec.md">did:jwk</a>
- * of the resolved signing key. Setting an identifier overrides this, that is a
- * placeholder for future types and this all propably needs additional
- * modifications at that point.
+ * Issuer identifier for everything this plugin signs.
*
- * TODO: Whole did:jwk and key rotation needs still thinking over. Current
- * implementation is pretty much stuck with one key.
+ * By default the
+ * <a href="https://github.com/quartzjer/did-jwk/blob/main/spec.md">did:jwk</a>
+ * of the resolved signing key. A configured identifier overrides it,
+ * '{@value DidSupport#DID_WEB_KEYWORD}' giving the did:web of this deployment,
+ * '{@value DidSupport#DID_JWK_KEYWORD}' the did:jwk of the signing key,
+ * '{@value DidSupport#URL_KEYWORD}' the url of this deployment and anything
+ * else taken as the identifier itself. A Credential Configuration that states
+ * an issuer of its own overrides the deployment.
*/
public class CredentialIssuerLookupFunction implements Function<ProfileRequestContext, String> {
@@ -66,6 +72,12 @@ public class CredentialIssuerLookupFunction implements Function<ProfileRequestCo
@Nullable
private String configuredIssuer;
+ /**
+ * Url this deployment is reached at, a did:web identifier is derived from it.
+ */
+ @Nullable
+ private String deploymentIssuer;
+
/** Constructor. */
public CredentialIssuerLookupFunction() {
final Function<ProfileRequestContext, SecurityParametersContext> splcs = new ChildContextLookup<>(
@@ -88,8 +100,10 @@ public class CredentialIssuerLookupFunction implements Function<ProfileRequestCo
/**
* Set an issuer identifier to use in place of one derived from the signing key.
*
- * Placeholder for future types and this all propably needs additional
- * modifications at that point.
+ * '{@value DidSupport#DID_WEB_KEYWORD}' asks for the did:web of this
+ * deployment, '{@value DidSupport#DID_JWK_KEYWORD}' for the did:jwk of the
+ * signing key and '{@value DidSupport#URL_KEYWORD}' for the url of this
+ * deployment. Any other value is the identifier itself.
*
* @param issuer issuer identifier, or null to derive one
*/
@@ -99,12 +113,35 @@ public class CredentialIssuerLookupFunction implements Function<ProfileRequestCo
}
}
+ /**
+ * Set the url this deployment is reached at, the did:web is derived from it.
+ *
+ * @param issuer url of this deployment
+ */
+ public void setDeploymentIssuer(@Nullable final String issuer) {
+ deploymentIssuer = issuer;
+ }
+
/** {@inheritDoc} */
@Override
@Nullable
public String apply(@Nullable final ProfileRequestContext profileRequestContext) {
- if (configuredIssuer != null) {
- return configuredIssuer;
+ final String configured = configuredIssuer(profileRequestContext);
+ if (DidSupport.DID_WEB_KEYWORD.equals(configured)) {
+ final String didWeb = DidSupport.toDidWeb(deploymentIssuer);
+ if (didWeb == null) {
+ log.error("Issuer '{}' names no host, unable to derive a did:web issuer", deploymentIssuer);
+ }
+ return didWeb;
+ }
+ if (DidSupport.URL_KEYWORD.equals(configured)) {
+ if (deploymentIssuer == null || deploymentIssuer.isEmpty()) {
+ log.error("No issuer of the OP to name this deployment by");
+ }
+ return deploymentIssuer;
+ }
+ if (configured != null && !DidSupport.DID_JWK_KEYWORD.equals(configured)) {
+ return configured;
}
if (profileRequestContext == null) {
return null;
@@ -131,6 +168,31 @@ public class CredentialIssuerLookupFunction implements Function<ProfileRequestCo
return toDidJwk(credential.getPublicKey());
}
+ /**
+ * Get the issuer identifier configured for the credential in issue, the one of
+ * the Credential Configuration when it states one and the one of the deployment
+ * otherwise.
+ *
+ * @param profileRequestContext current profile request context
+ * @return the configured identifier, or null to derive one
+ */
+ @Nullable
+ private String configuredIssuer(@Nullable final ProfileRequestContext profileRequestContext) {
+
+ if (profileRequestContext != null && profileRequestContext.getInboundMessageContext() != null) {
+ final CredentialsContext ctx = profileRequestContext.getInboundMessageContext()
+ .getSubcontext(CredentialsContext.class);
+ final CredentialConfiguration configuration = ctx == null ? null : ctx.getCredentialConfiguration();
+ final CredentialIssuanceConfiguration settings = configuration == null ? null
+ : configuration.getIssuanceConfiguration();
+ final String perCredential = settings == null ? null : settings.getIssuer();
+ if (perCredential != null) {
+ return perCredential;
+ }
+ }
+ return configuredIssuer;
+ }
+
/**
* Encode a public key as a did:jwk identifier.
*
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/StatusListIssuerTagLookupFunction.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/StatusListIssuerTagLookupFunction.java
new file mode 100644
index 0000000..4cc19d7
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/profile/logic/StatusListIssuerTagLookupFunction.java
@@ -0,0 +1,154 @@
+/*
+ * 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.Function;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+import org.geant.shibboleth.plugin.openidvci.security.impl.DidSupport;
+import org.geant.shibboleth.plugin.openidvci.statuslist.StatusListIssuerTag;
+import org.opensaml.messaging.context.navigate.ChildContextLookup;
+import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.profile.context.navigate.OutboundMessageContextLookup;
+import org.opensaml.security.credential.Credential;
+import org.slf4j.Logger;
+
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.jwk.JWK;
+
+import net.shibboleth.oidc.security.CredentialConversionUtil;
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+import net.shibboleth.oidc.security.jose.context.SecurityParametersContext;
+import net.shibboleth.shared.logic.Constraint;
+import net.shibboleth.shared.primitive.LoggerFactory;
+
+/**
+ * Tag naming the Credential Issuer identifier a credential is issued under, for
+ * the url of the status list it takes a slot of.
+ *
+ * A did:web or url identifier is the same for every credential of this
+ * deployment and takes a static tag. A did:jwk identifier names a key, so its
+ * tag carries the thumbprint of that key.
+ */
+public class StatusListIssuerTagLookupFunction implements Function<ProfileRequestContext, String> {
+
+ /** Class logger. */
+ @Nonnull
+ private final Logger log = LoggerFactory.getLogger(StatusListIssuerTagLookupFunction.class);
+
+ /** Strategy used to resolve the issuer of the credential in issue. */
+ @Nonnull
+ private Function<ProfileRequestContext, String> issuerLookupStrategy;
+
+ /** Strategy used to locate {@link SecurityParametersContext} to read. */
+ @Nonnull
+ private Function<ProfileRequestContext, SecurityParametersContext> securityParametersLookupStrategy;
+
+ /** Constructor. */
+ public StatusListIssuerTagLookupFunction() {
+ issuerLookupStrategy = new CredentialIssuerLookupFunction();
+ final Function<ProfileRequestContext, SecurityParametersContext> splcs = new ChildContextLookup<>(
+ SecurityParametersContext.class).compose(new OutboundMessageContextLookup());
+ assert splcs != null;
+ securityParametersLookupStrategy = splcs;
+ }
+
+ /**
+ * Set the strategy used to resolve the issuer of the credential in issue.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setIssuerLookupStrategy(@Nonnull final Function<ProfileRequestContext, String> strategy) {
+ issuerLookupStrategy = Constraint.isNotNull(strategy, "Issuer lookup strategy cannot be null");
+ }
+
+ /**
+ * Set strategy used to locate the {@link SecurityParametersContext} to read.
+ *
+ * @param strategy lookup strategy
+ */
+ public void setSecurityParametersLookupStrategy(
+ @Nonnull final Function<ProfileRequestContext, SecurityParametersContext> strategy) {
+ securityParametersLookupStrategy = Constraint.isNotNull(strategy,
+ "SecurityParametersContext lookup strategy cannot be null");
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ @Nullable
+ public String apply(@Nullable final ProfileRequestContext profileRequestContext) {
+
+ final String issuer = issuerLookupStrategy.apply(profileRequestContext);
+ if (issuer == null) {
+ log.debug("No issuer resolved, status list url carries no tag");
+ return null;
+ }
+
+ if (issuer.startsWith(DidSupport.DID_WEB_PREFIX)) {
+ return StatusListIssuerTag.DID_WEB;
+ }
+ if (!issuer.startsWith(DidSupport.DID_JWK_PREFIX)) {
+ return StatusListIssuerTag.URL;
+ }
+
+ final String thumbprint = thumbprint(profileRequestContext);
+ if (thumbprint == null) {
+ log.error("Unable to name the key of issuer '{}', status list url carries no tag", issuer);
+ return null;
+ }
+ return StatusListIssuerTag.didJwk(thumbprint);
+ }
+
+ /**
+ * Get the thumbprint of the key the credential in issue is signed with.
+ *
+ * @param profileRequestContext current profile request context
+ * @return the thumbprint, or null when there is no key to take one of
+ */
+ @Nullable
+ private String thumbprint(@Nullable final ProfileRequestContext profileRequestContext) {
+
+ if (profileRequestContext == null) {
+ return null;
+ }
+
+ final SecurityParametersContext secParamCtx = securityParametersLookupStrategy.apply(profileRequestContext);
+ final SignatureSigningParameters params = secParamCtx == null ? null
+ : secParamCtx.getSignatureSigningParameters();
+ final Credential credential = params == null ? null : params.getSigningCredential();
+ if (credential == null) {
+ log.debug("No signing credential, unable to take a thumbprint of it");
+ return null;
+ }
+
+ final JWK key = CredentialConversionUtil.credentialToKey(credential);
+ if (key == null) {
+ log.debug("Signing key is neither RSA nor EC, unable to take a thumbprint of it");
+ return null;
+ }
+
+ try {
+ return key.computeThumbprint().toString();
+ } catch (final JOSEException e) {
+ log.error("Unable to take a thumbprint of the signing key", e);
+ return null;
+ }
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidJwkSupport.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidJwkSupport.java
deleted file mode 100644
index 7509889..0000000
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidJwkSupport.java
+++ /dev/null
@@ -1,112 +0,0 @@
-/*
- * 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.security.impl;
-
-import java.security.PrivateKey;
-import java.security.PublicKey;
-
-import javax.annotation.Nonnull;
-import javax.annotation.Nullable;
-import javax.crypto.SecretKey;
-
-import org.opensaml.security.credential.Credential;
-
-import net.shibboleth.oidc.security.credential.BasicJWKCredential;
-import net.shibboleth.oidc.security.credential.JWKCredential;
-import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
-
-/**
- * Support for naming the signing key of a token by the did:jwk it is issued
- * under.
- *
- */
-public final class DidJwkSupport {
-
- /** Prefix of a did:jwk identifier. */
- @Nonnull
- private static final String PREFIX = "did:jwk:";
-
- /** Fragment naming the only verification method of a did:jwk document. */
- @Nonnull
- private static final String VERIFICATION_METHOD = "#0";
-
- /** Constructor. */
- private DidJwkSupport() {
- }
-
- /**
- * Name the signing key of the parameters by the issuer of the token.
- *
- * A verifier resolves the key of a did:jwk issuer by the 'kid' header, which
- * has to name the verification method of the document rather than the key
- * itself. An issuer that is no did:jwk leaves the key named as it is.
- *
- * @param parameters parameters to name the key of
- * @param issuer issuer the token names
- * @return parameters naming the key, or the given ones when there is nothing to
- * name it by
- */
- @Nonnull
- public static SignatureSigningParameters nameKeyByIssuer(@Nonnull final SignatureSigningParameters parameters,
- @Nullable final String issuer) {
-
- final Credential credential = parameters.getSigningCredential();
- if (issuer == null || !issuer.startsWith(PREFIX) || credential == null) {
- return parameters;
- }
-
- final SignatureSigningParameters named = new SignatureSigningParameters();
- named.setSignatureAlgorithm(parameters.getSignatureAlgorithm());
- named.setSigningCredential(nameKey(credential, issuer + VERIFICATION_METHOD));
- return named;
- }
-
- /**
- * Copy a credential, naming its key.
- *
- * @param credential credential to copy
- * @param keyName name to give the key
- * @return the copy
- */
- @Nonnull
- private static Credential nameKey(@Nonnull final Credential credential, @Nonnull final String keyName) {
-
- final BasicJWKCredential copy = new BasicJWKCredential();
- copy.getKeyNames().add(keyName);
- copy.setKid(keyName);
-
- final PublicKey publicKey = credential.getPublicKey();
- if (publicKey != null) {
- copy.setPublicKey(publicKey);
- }
- final PrivateKey privateKey = credential.getPrivateKey();
- if (privateKey != null) {
- copy.setPrivateKey(privateKey);
- }
- final SecretKey secretKey = credential.getSecretKey();
- if (secretKey != null) {
- copy.setSecretKey(secretKey);
- }
- copy.setUsageType(credential.getUsageType());
- copy.setEntityId(credential.getEntityId());
- if (credential instanceof final JWKCredential jwk) {
- copy.setAlgorithm(jwk.getAlgorithm());
- }
- return copy;
- }
-
-}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidSupport.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidSupport.java
new file mode 100644
index 0000000..d82ac49
--- /dev/null
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidSupport.java
@@ -0,0 +1,254 @@
+/*
+ * 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.security.impl;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.interfaces.ECPublicKey;
+
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+import javax.crypto.SecretKey;
+
+import org.opensaml.security.credential.Credential;
+
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.util.Base64URL;
+
+import net.shibboleth.oidc.security.credential.BasicJWKCredential;
+import net.shibboleth.oidc.security.credential.JWKCredential;
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+
+/**
+ * Naming of a signing key by the DID a token is issued under.
+ *
+ * The 'kid' names a verification method of the DID document, '#0' for did:jwk
+ * and the name of the key for did:web. An issuer that is no DID leaves the key
+ * named as it is.
+ */
+public final class DidSupport {
+
+ /** Prefix of a did:jwk identifier. */
+ @Nonnull
+ public static final String DID_JWK_PREFIX = "did:jwk:";
+
+ /** Prefix of a did:web identifier. */
+ @Nonnull
+ public static final String DID_WEB_PREFIX = "did:web:";
+
+ /**
+ * Value of the configured issuer asking for a did:web derived from this
+ * deployment, rather than naming one literally.
+ */
+ @Nonnull
+ public static final String DID_WEB_KEYWORD = "did:web";
+
+ /**
+ * Value of the configured issuer asking for the did:jwk of the signing key,
+ * rather than naming an identifier literally.
+ */
+ @Nonnull
+ public static final String DID_JWK_KEYWORD = "did:jwk";
+
+ /**
+ * Value of the configured issuer asking for the url of this deployment, the
+ * issuer of the OP, rather than naming one literally.
+ */
+ @Nonnull
+ public static final String URL_KEYWORD = "url";
+
+ /** Fragment naming the only verification method of a did:jwk document. */
+ @Nonnull
+ private static final String DID_JWK_VERIFICATION_METHOD = "#0";
+
+ /** Separator of a DID and the fragment naming a verification method of it. */
+ @Nonnull
+ private static final String FRAGMENT = "#";
+
+ /**
+ * Encoded form of the port separator, a did:web identifier has no ':' of its
+ * own.
+ */
+ @Nonnull
+ private static final String ENCODED_PORT_SEPARATOR = "%3A";
+
+ /** Constructor. */
+ private DidSupport() {
+ }
+
+ /**
+ * Derive the did:web identifier of a deployment from the url it is reached at.
+ * Only the host is taken, a path is dropped and a port is encoded.
+ *
+ * @param deploymentIssuer url of this deployment
+ * @return the did:web identifier, or null if the url names no host
+ */
+ @Nullable
+ public static String toDidWeb(@Nullable final String deploymentIssuer) {
+
+ if (deploymentIssuer == null || deploymentIssuer.isEmpty()) {
+ return null;
+ }
+
+ final URI uri;
+ try {
+ uri = new URI(deploymentIssuer);
+ } catch (final URISyntaxException e) {
+ return null;
+ }
+
+ final String host = uri.getHost();
+ if (host == null || host.isEmpty()) {
+ return null;
+ }
+
+ final int port = uri.getPort();
+ return port == -1 ? DID_WEB_PREFIX + host : DID_WEB_PREFIX + host + ENCODED_PORT_SEPARATOR + port;
+ }
+
+ /**
+ * Encode a public key as a did:jwk identifier.
+ *
+ * @param publicKey key to encode
+ * @return the identifier, or null if the key is of an unsupported type
+ */
+ @Nullable
+ public static String toDidJwk(@Nullable final PublicKey publicKey) {
+
+ if (!(publicKey instanceof final ECPublicKey ecKey)) {
+ return null;
+ }
+ final Curve curve = Curve.forECParameterSpec(ecKey.getParams());
+ if (curve == null) {
+ return null;
+ }
+ return DID_JWK_PREFIX + Base64URL.encode(new ECKey.Builder(curve, ecKey).build().toJSONString()).toString();
+ }
+
+ /**
+ * Name the verification method a DID issuer resolves a key by.
+ *
+ * @param issuer issuer of the token
+ * @param keyName name the key carries, for the methods that need one
+ * @return the verification method, or null when the issuer resolves no key
+ */
+ @Nullable
+ public static String verificationMethod(@Nullable final String issuer, @Nullable final String keyName) {
+
+ if (issuer == null) {
+ return null;
+ }
+ if (issuer.startsWith(DID_JWK_PREFIX)) {
+ return issuer + DID_JWK_VERIFICATION_METHOD;
+ }
+ if (issuer.startsWith(DID_WEB_PREFIX) && keyName != null && !keyName.isEmpty()) {
+ return issuer + FRAGMENT + keyName;
+ }
+ return null;
+ }
+
+ /**
+ * Get the name a credential carries its key by.
+ *
+ * @param credential credential to read
+ * @return the name, or null when the credential names its key by nothing
+ */
+ @Nullable
+ public static String keyName(@Nullable final Credential credential) {
+
+ if (credential instanceof final JWKCredential jwk) {
+ final String kid = jwk.getKid();
+ if (kid != null && !kid.isEmpty()) {
+ return kid;
+ }
+ }
+ if (credential != null) {
+ for (final String name : credential.getKeyNames()) {
+ if (name != null && !name.isEmpty()) {
+ return name;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Name the signing key of the parameters by the issuer of the token.
+ *
+ * @param parameters parameters to name the key of
+ * @param issuer issuer the token names
+ * @return parameters naming the key, or the given ones when there is nothing to
+ * name it by
+ */
+ @Nonnull
+ public static SignatureSigningParameters nameKeyByIssuer(@Nonnull final SignatureSigningParameters parameters,
+ @Nullable final String issuer) {
+
+ final Credential credential = parameters.getSigningCredential();
+ if (credential == null) {
+ return parameters;
+ }
+
+ final String method = verificationMethod(issuer, keyName(credential));
+ if (method == null) {
+ return parameters;
+ }
+
+ final SignatureSigningParameters named = new SignatureSigningParameters();
+ named.setSignatureAlgorithm(parameters.getSignatureAlgorithm());
+ named.setSigningCredential(nameKey(credential, method));
+ return named;
+ }
+
+ /**
+ * Copy a credential, naming its key.
+ *
+ * @param credential credential to copy
+ * @param keyName name to give the key
+ * @return the copy
+ */
+ @Nonnull
+ private static Credential nameKey(@Nonnull final Credential credential, @Nonnull final String keyName) {
+
+ final BasicJWKCredential copy = new BasicJWKCredential();
+ copy.getKeyNames().add(keyName);
+ copy.setKid(keyName);
+
+ final PublicKey publicKey = credential.getPublicKey();
+ if (publicKey != null) {
+ copy.setPublicKey(publicKey);
+ }
+ final PrivateKey privateKey = credential.getPrivateKey();
+ if (privateKey != null) {
+ copy.setPrivateKey(privateKey);
+ }
+ final SecretKey secretKey = credential.getSecretKey();
+ if (secretKey != null) {
+ copy.setSecretKey(secretKey);
+ }
+ copy.setUsageType(credential.getUsageType());
+ copy.setEntityId(credential.getEntityId());
+ if (credential instanceof final JWKCredential jwk) {
+ copy.setAlgorithm(jwk.getAlgorithm());
+ }
+ return copy;
+ }
+
+}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/AllocateIndex.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/AllocateIndex.java
index ed372c9..252187a 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/AllocateIndex.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/AllocateIndex.java
@@ -28,6 +28,7 @@ import javax.annotation.Nullable;
import org.geant.shibboleth.plugin.openidvci.statuslist.MonthlyBucketStrategy;
import org.geant.shibboleth.plugin.openidvci.statuslist.StatusListBucket;
+import org.geant.shibboleth.plugin.openidvci.statuslist.StatusListIssuerTag;
import org.geant.shibboleth.plugin.openidvci.statuslist.StatusListParameters;
import org.geant.shibboleth.plugin.openidvci.statuslist.context.StatusListContext;
import org.geant.shibboleth.plugin.openidvci.statuslist.storage.StatusListIndexAllocator;
@@ -82,6 +83,13 @@ public class AllocateIndex extends AbstractProfileAction {
@Nonnull
private String pathPrefix = "/idp/profile/oauth/status-list/list";
+ /**
+ * Strategy used to resolve the tag naming the issuer of the credential in
+ * issue.
+ */
+ @Nullable
+ private Function<ProfileRequestContext, String> issuerTagLookupStrategy;
+
/** Expiration shared by credentials issued. */
@Nullable
private Instant expiration;
@@ -145,6 +153,17 @@ public class AllocateIndex extends AbstractProfileAction {
}
}
+ /**
+ * Set the strategy used to resolve the tag naming the issuer of the credential
+ * in issue. The tag travels in the status list url and nowhere else.
+ *
+ * @param strategy lookup strategy, or null to publish urls with no tag
+ */
+ public void setIssuerTagLookupStrategy(@Nullable final Function<ProfileRequestContext, String> strategy) {
+ checkSetterPreconditions();
+ issuerTagLookupStrategy = strategy;
+ }
+
/** {@inheritDoc} */
@Override
protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
@@ -205,8 +224,10 @@ public class AllocateIndex extends AbstractProfileAction {
assert scheme != null;
final String serverName = request.getServerName();
assert serverName != null;
+ final String tag = issuerTagLookupStrategy == null ? null
+ : issuerTagLookupStrategy.apply(profileRequestContext);
statusListUri = buildURIIgnoreDefaultPorts(scheme, serverName, request.getServerPort(), pathPrefix,
- StatusListParameters.BUCKET_PARAMETER + "=" + bucket.id());
+ StatusListParameters.BUCKET_PARAMETER + "=" + StatusListIssuerTag.reference(bucket.id(), tag));
} catch (final URISyntaxException e) {
log.error("{} Unable to build status list URI", getLogPrefix(), e);
ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_PROFILE_CTX);
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/FormStatusListToken.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/FormStatusListToken.java
index ceae6fc..6a7fb09 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/FormStatusListToken.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/FormStatusListToken.java
@@ -20,15 +20,19 @@ import java.io.IOException;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
+import java.util.List;
import java.util.Map;
import java.util.function.Function;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
+import org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialIssuerLookupFunction;
+import org.geant.shibboleth.plugin.openidvci.security.impl.DidSupport;
import org.geant.shibboleth.plugin.openidvci.statuslist.MonthlyBucketStrategy;
import org.geant.shibboleth.plugin.openidvci.statuslist.StatusListBucket;
import org.geant.shibboleth.plugin.openidvci.statuslist.StatusListCodec;
+import org.geant.shibboleth.plugin.openidvci.statuslist.StatusListIssuerTag;
import org.geant.shibboleth.plugin.openidvci.statuslist.StatusListParameters;
import org.geant.shibboleth.plugin.openidvci.statuslist.context.StatusListTokenContext;
import org.geant.shibboleth.plugin.openidvci.statuslist.profile.StatusListEventIds;
@@ -37,19 +41,22 @@ import org.opensaml.messaging.context.MessageContext;
import org.opensaml.profile.action.ActionSupport;
import org.opensaml.profile.action.EventIds;
import org.opensaml.profile.context.ProfileRequestContext;
+import org.opensaml.security.credential.Credential;
import org.slf4j.Logger;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jwt.JWTClaimsSet;
import net.shibboleth.idp.profile.AbstractProfileAction;
import net.shibboleth.idp.profile.IdPEventIds;
+import net.shibboleth.oidc.security.CredentialConversionUtil;
import net.shibboleth.shared.annotation.constraint.NonnullAfterInit;
+import net.shibboleth.shared.collection.CollectionSupport;
import net.shibboleth.shared.component.ComponentInitializationException;
import net.shibboleth.shared.logic.Constraint;
import net.shibboleth.shared.primitive.LoggerFactory;
-import org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialIssuerLookupFunction;
-
/**
* Action that forms claims of a Status List Token and sets them to
* {@link StatusListTokenContext} located under
@@ -86,10 +93,28 @@ public class FormStatusListToken extends AbstractProfileAction {
@Nullable
private StatusListBucket bucket;
+ /** Url this deployment is reached at, a did:web issuer is derived from it. */
+ @Nullable
+ private String deploymentIssuer;
+
+ /** Issuer identifier configured for this deployment. */
+ @Nullable
+ private String configuredIssuer;
+
+ /** Credentials a Status List Token may be signed with. */
+ @Nonnull
+ private List<Credential> signingCredentials = CollectionSupport.emptyList();
+
+ /** The value of the request parameter naming the list, tag and all. */
+ @Nullable
+ private String reference;
+
+ /** Thumbprint of the key this token has to be signed with. */
+ @Nullable
+ private String keyThumbprint;
+
/** Constructor. */
public FormStatusListToken() {
- // TODO: Token must name the same issuer as the credentials referring to it.
- // With did:jwk key rollover will need us to redesign things.
issuerLookupStrategy = new CredentialIssuerLookupFunction();
}
@@ -140,6 +165,93 @@ public class FormStatusListToken extends AbstractProfileAction {
StatusListParameters.CAPACITY, emptyList.length());
}
+ /**
+ * Set the url this deployment is reached at, a did:web issuer is derived from
+ * it.
+ *
+ * @param issuer url of this deployment
+ */
+ public void setDeploymentIssuer(@Nullable final String issuer) {
+ checkSetterPreconditions();
+ deploymentIssuer = issuer;
+ }
+
+ /**
+ * Set the issuer identifier configured for this deployment, answering a list
+ * handed out under the plain issuer url. The keyword resolves to the issuer of
+ * the OP, a literal url to itself.
+ *
+ * @param issuer the configured identifier
+ */
+ public void setConfiguredIssuer(@Nullable final String issuer) {
+ checkSetterPreconditions();
+ configuredIssuer = issuer == null || issuer.isEmpty() ? null : issuer;
+ }
+
+ /**
+ * Set the credentials a Status List Token may be signed with. A list handed out
+ * under a did:jwk issuer names one of them, so every key that has signed such
+ * credentials belongs here while they live.
+ *
+ * @param credentials the credentials, or null for none
+ */
+ public void setSigningCredentials(@Nullable final List<Credential> credentials) {
+ checkSetterPreconditions();
+ signingCredentials = credentials == null ? CollectionSupport.emptyList()
+ : CollectionSupport.copyToList(credentials);
+ }
+
+ /**
+ * Resolve the issuer a tagged status list is published under.
+ *
+ * @param tag tag the request carries
+ * @return the issuer, or null when the tag names none this deployment answers
+ * for
+ */
+ @Nullable
+ private String issuerOfTag(@Nonnull final String tag) {
+
+ if (StatusListIssuerTag.DID_WEB.equals(tag)) {
+ return DidSupport.toDidWeb(deploymentIssuer);
+ }
+ if (StatusListIssuerTag.URL.equals(tag)) {
+ return configuredIssuer != null && configuredIssuer.startsWith("https://") ? configuredIssuer
+ : deploymentIssuer;
+ }
+
+ final String thumbprint = StatusListIssuerTag.thumbprintOf(tag);
+ if (thumbprint == null) {
+ log.error("Tag '{}' of the status list names no issuer this deployment answers for", tag);
+ return null;
+ }
+
+ for (final Credential candidate : signingCredentials) {
+ final JWK key = CredentialConversionUtil.credentialToKey(candidate);
+ if (key == null) {
+ continue;
+ }
+ try {
+ if (!thumbprint.equals(key.computeThumbprint().toString())) {
+ continue;
+ }
+ } catch (final JOSEException e) {
+ log.warn("Unable to take a thumbprint of a signing key, leaving it out", e);
+ continue;
+ }
+ final String issuer = DidSupport.toDidJwk(candidate.getPublicKey());
+ if (issuer == null) {
+ log.error("Key '{}' is of a type a did:jwk issuer cannot name", thumbprint);
+ return null;
+ }
+ keyThumbprint = thumbprint;
+ return issuer;
+ }
+
+ log.error("No signing credential has thumbprint '{}', the key that signed those credentials is no longer "
+ + "among the ones Status List Tokens are signed with", thumbprint);
+ return null;
+ }
+
/** {@inheritDoc} */
@Override
protected boolean doPreExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
@@ -157,21 +269,22 @@ public class FormStatusListToken extends AbstractProfileAction {
return false;
}
- issuerId = issuerLookupStrategy.apply(profileRequestContext);
- if (issuerId == null) {
- log.error("{} Unable to determine issuer of the status list token", getLogPrefix());
- ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
- return false;
- }
-
- final String bucketId = getHttpServletRequest().getParameter(StatusListParameters.BUCKET_PARAMETER);
- bucket = MonthlyBucketStrategy.parse(bucketId);
+ reference = getHttpServletRequest().getParameter(StatusListParameters.BUCKET_PARAMETER);
+ bucket = MonthlyBucketStrategy.parse(StatusListIssuerTag.bucketOf(reference));
if (bucket == null) {
- log.error("{} '{}' in parameter '{}' does not name a status list", getLogPrefix(), bucketId,
+ log.error("{} '{}' in parameter '{}' does not name a status list", getLogPrefix(), reference,
StatusListParameters.BUCKET_PARAMETER);
ActionSupport.buildEvent(profileRequestContext, StatusListEventIds.NO_STATUS_LIST);
return false;
}
+
+ final String tag = StatusListIssuerTag.tagOf(reference);
+ issuerId = tag == null ? issuerLookupStrategy.apply(profileRequestContext) : issuerOfTag(tag);
+ if (issuerId == null) {
+ log.error("{} Unable to determine issuer of the status list token", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, IdPEventIds.INVALID_PROFILE_CONFIG);
+ return false;
+ }
if (!bucket.end().isAfter(Instant.now())) {
log.info("{} Status list {} ended at {} and is no longer published", getLogPrefix(), bucket.id(),
bucket.end());
@@ -197,8 +310,9 @@ public class FormStatusListToken extends AbstractProfileAction {
ActionSupport.buildEvent(profileRequestContext, EventIds.IO_ERROR);
return;
}
+ // The subject has to be the url the credential points at, tag and all.
final String subject = getHttpServletRequest().getRequestURL().append('?')
- .append(StatusListParameters.BUCKET_PARAMETER).append('=').append(bucket.id()).toString();
+ .append(StatusListParameters.BUCKET_PARAMETER).append('=').append(reference).toString();
final JWTClaimsSet claims = new JWTClaimsSet.Builder().issuer(issuerId).subject(subject)
.issueTime(Date.from(Instant.now())).expirationTime(Date.from(bucket.end()))
.claim("ttl", ttl.toSeconds())
@@ -206,7 +320,9 @@ public class FormStatusListToken extends AbstractProfileAction {
final MessageContext outbound = profileRequestContext.getOutboundMessageContext();
assert outbound != null;
- outbound.ensureSubcontext(StatusListTokenContext.class).setClaims(claims);
+ final StatusListTokenContext tokenCtx = outbound.ensureSubcontext(StatusListTokenContext.class);
+ tokenCtx.setClaims(claims);
+ tokenCtx.setKeyThumbprint(keyThumbprint);
log.debug("{} Formed status list token for {}", getLogPrefix(), subject);
}
diff --git a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/RevokeStatusListIndex.java b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/RevokeStatusListIndex.java
index 9b097a1..274060b 100644
--- a/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/RevokeStatusListIndex.java
+++ b/openid-vci-impl/src/main/java/org/geant/shibboleth/plugin/openidvci/statuslist/profile/impl/RevokeStatusListIndex.java
@@ -21,6 +21,7 @@ import java.io.IOException;
import javax.annotation.Nonnull;
import org.geant.shibboleth.plugin.openidvci.statuslist.MonthlyBucketStrategy;
+import org.geant.shibboleth.plugin.openidvci.statuslist.StatusListIssuerTag;
import org.geant.shibboleth.plugin.openidvci.statuslist.StatusListBucket;
import org.geant.shibboleth.plugin.openidvci.statuslist.StatusListParameters;
import org.geant.shibboleth.plugin.openidvci.statuslist.messaging.impl.StatusListRevokeRequest;
@@ -95,7 +96,8 @@ public class RevokeStatusListIndex extends AbstractProfileAction {
return false;
}
- bucket = MonthlyBucketStrategy.parse(request.getId());
+ // The bucket alone keys storage, a tag the credential carries is stripped.
+ bucket = MonthlyBucketStrategy.parse(StatusListIssuerTag.bucketOf(request.getId()));
if (bucket == null) {
log.error("{} '{}' does not name a status list", getLogPrefix(), request.getId());
ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_MSG_CTX);
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 1646ad2..85ba383 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,27 +16,34 @@
package org.geant.shibboleth.plugin.openidvci.statuslist.profile.impl;
+import java.util.List;
import java.util.function.Function;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
-import org.geant.shibboleth.plugin.openidvci.security.impl.DidJwkSupport;
+import org.geant.shibboleth.plugin.openidvci.security.impl.DidSupport;
import org.geant.shibboleth.plugin.openidvci.statuslist.context.StatusListTokenContext;
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.credential.Credential;
import org.slf4j.Logger;
+import com.nimbusds.jose.JOSEException;
+import com.nimbusds.jose.jwk.JWK;
+
import net.shibboleth.idp.profile.AbstractProfileAction;
+import net.shibboleth.oidc.security.CredentialConversionUtil;
import net.shibboleth.oidc.security.impl.JWSTokenSigner;
import net.shibboleth.oidc.security.jose.SignatureException;
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.collection.CollectionSupport;
import net.shibboleth.shared.logic.Constraint;
import net.shibboleth.shared.primitive.LoggerFactory;
@@ -54,6 +61,10 @@ public class SignStatusListToken extends AbstractProfileAction {
@Nonnull
private static final String TOKEN_TYPE = "statuslist+jwt";
+ /** Credentials a Status List Token may be signed with. */
+ @Nonnull
+ private List<Credential> signingCredentials = CollectionSupport.emptyList();
+
/** Class logger. */
@Nonnull
private Logger log = LoggerFactory.getLogger(SignStatusListToken.class);
@@ -157,13 +168,88 @@ public class SignStatusListToken extends AbstractProfileAction {
return true;
}
+ /**
+ * Set the credentials a Status List Token may be signed with, searched when the
+ * issuer names one key.
+ *
+ * @param credentials the credentials, or null for none
+ */
+ public void setSigningCredentials(@Nullable final List<Credential> credentials) {
+ checkSetterPreconditions();
+ signingCredentials = credentials == null ? CollectionSupport.emptyList()
+ : CollectionSupport.copyToList(credentials);
+ }
+
+ /**
+ * Take the parameters signing with the key a thumbprint names.
+ *
+ * @param parameters parameters resolved by the signing configuration
+ * @param thumbprint thumbprint of the required key, or null when any key does
+ * @return the parameters to sign with, or null when the required key is not
+ * among the ones this deployment signs with
+ */
+ @Nullable
+ private SignatureSigningParameters withRequiredKey(@Nonnull final SignatureSigningParameters parameters,
+ @Nullable final String thumbprint) {
+
+ if (thumbprint == null) {
+ return parameters;
+ }
+ if (thumbprint.equals(thumbprintOf(parameters.getSigningCredential()))) {
+ return parameters;
+ }
+
+ for (final Credential candidate : signingCredentials) {
+ if (thumbprint.equals(thumbprintOf(candidate))) {
+ final SignatureSigningParameters required = new SignatureSigningParameters();
+ required.setSignatureAlgorithm(parameters.getSignatureAlgorithm());
+ required.setSigningCredential(candidate);
+ return required;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get the thumbprint of the key of a credential.
+ *
+ * @param credential credential to read
+ * @return the thumbprint, or null when there is none to take
+ */
+ @Nullable
+ private String thumbprintOf(@Nullable final Credential credential) {
+
+ if (credential == null) {
+ return null;
+ }
+ final JWK key = CredentialConversionUtil.credentialToKey(credential);
+ if (key == null) {
+ return null;
+ }
+ try {
+ return key.computeThumbprint().toString();
+ } catch (final JOSEException e) {
+ log.warn("{} Unable to take a thumbprint of a signing key", getLogPrefix(), e);
+ return null;
+ }
+ }
+
/** {@inheritDoc} */
@Override
protected void doExecute(@Nonnull final ProfileRequestContext profileRequestContext) {
+
+ final SignatureSigningParameters parameters = withRequiredKey(signatureSigningParameters,
+ ctx.getKeyThumbprint());
+ if (parameters == null) {
+ log.error("{} The key the issuer of this status list names is not among the ones Status List Tokens "
+ + "are signed with", getLogPrefix());
+ ActionSupport.buildEvent(profileRequestContext, EventIds.INVALID_SEC_CFG);
+ return;
+ }
+
try {
- ctx.setSignedToken(new JWSTokenSigner(
- DidJwkSupport.nameKeyByIssuer(signatureSigningParameters, ctx.getClaims().getIssuer()))
- .sign(ctx.getClaims(), TOKEN_TYPE).serialize());
+ ctx.setSignedToken(new JWSTokenSigner(DidSupport.nameKeyByIssuer(parameters, ctx.getClaims().getIssuer()))
+ .sign(ctx.getClaims(), TOKEN_TYPE).serialize());
log.debug("{} Signed status list token for {}", getLogPrefix(), ctx.getClaims().getSubject());
} catch (final SignatureException e) {
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/did/document/document-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/did/document/document-beans.xml
new file mode 100644
index 0000000..6e0ce45
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/did/document/document-beans.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<beans
+ xmlns="http://www.springframework.org/schema/beans"
+ xmlns:c="http://www.springframework.org/schema/c"
+ xmlns:context="http://www.springframework.org/schema/context"
+ xmlns:p="http://www.springframework.org/schema/p"
+ xmlns:util="http://www.springframework.org/schema/util"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
+ http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
+ http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd"
+ default-init-method="initialize" default-destroy-method="destroy">
+
+ <bean id="openidvci.profileId" class="java.lang.String"
+ c:_0="#{T(org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration).PROFILE_ID_DID_DOCUMENT}"/>
+ <bean id="openidvci.loggingId" class="java.lang.String"
+ c:_0="%{openidvci.logging.did-document:DID.Document}"/>
+
+ <bean id="InitializeUnverifiedRelyingPartyContext"
+ class="net.shibboleth.idp.plugin.oidc.op.profile.impl.InitializeUnverifiedRelyingPartyContext" scope="prototype" />
+
+ <bean id="FormOutboundMessage"
+ class="org.geant.shibboleth.plugin.openidvci.profile.impl.FormOutboundDidDocumentResponseMessage"
+ scope="prototype"
+ p:deploymentIssuer="#{getObject('shibboleth.oidc.issuer')}"
+ p:credentialSigningCredentials="#{getObject('openidvci.SigningCredentials') ?: getObject('shibboleth.oidc.SigningCredentialsFactory')}"
+ p:statusListSigningCredentials="#{getObject('openidvci.status-list.SigningCredentials') ?: getObject('openidvci.SigningCredentials') ?: getObject('shibboleth.oidc.SigningCredentialsFactory')}" />
+
+ <bean id="BuildErrorResponseFromEvent" class="net.shibboleth.oidc.profile.impl.BuildJSONErrorResponseFromEvent"
+ scope="prototype" p:defaultStatusCode="500" p:defaultCode="server_error"
+ p:httpServletResponseSupplier-ref="shibboleth.HttpServletResponseSupplier">
+ <property name="eventContextLookupStrategy">
+ <bean class="net.shibboleth.idp.profile.context.navigate.WebFlowCurrentEventLookupFunction" />
+ </property>
+ <property name="mappedErrors">
+ <map value-type="com.nimbusds.oauth2.sdk.ErrorObject">
+ <entry key="#{T(org.opensaml.profile.action.EventIds).IO_ERROR}"
+ value="#{T(com.nimbusds.oauth2.sdk.OAuth2Error).SERVER_ERROR}" />
+ </map>
+ </property>
+ </bean>
+
+</beans>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/did/document/document-flow.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/did/document/document-flow.xml
new file mode 100644
index 0000000..b4ebaa6
--- /dev/null
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/did/document/document-flow.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0"?>
+<flow xmlns="http://www.springframework.org/schema/webflow"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://www.springframework.org/schema/webflow http://www.springframework.org/schema/webflow/spring-webflow.xsd"
+ parent="openid/vci/abstract-api">
+
+ <action-state id="InitializeMandatoryContexts">
+ <evaluate expression="InitializeProfileRequestContext" />
+ <evaluate expression="PopulateMetricContext" />
+ <evaluate expression="FlowStartPopulateAuditContext" />
+ <evaluate expression="InitializeOutboundMessageContext" />
+ <evaluate expression="InitializeUnverifiedRelyingPartyContext" />
+ <evaluate expression="SelectRelyingPartyConfiguration" />
+ <evaluate expression="SelectProfileConfiguration" />
+ <evaluate expression="'proceed'" />
+
+ <transition on="proceed" to="BuildResponseMessage" />
+ </action-state>
+
+ <bean-import resource="classpath:/META-INF/net/shibboleth/idp/flows/did/document/document-beans.xml" />
+
+</flow>
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assign/assign-beans.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assign/assign-beans.xml
index e674bc0..f892fc3 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assign/assign-beans.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/flows/oauth/status-list/assign/assign-beans.xml
@@ -34,6 +34,14 @@
<property name="indexCountLookupStrategy">
<bean class="org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialCountLookupFunction" />
</property>
+ <!--
+ Names the Credential Issuer identifier of the credential in the status list url, so the
+ endpoint answers under it. Storage is keyed by the bucket alone, the tag is url only.
+ -->
+ <property name="issuerTagLookupStrategy">
+ <bean class="org.geant.shibboleth.plugin.openidvci.profile.logic.StatusListIssuerTagLookupFunction"
+ p:issuerLookupStrategy-ref="openidvci.IssuerLookupStrategy" />
+ </property>
</bean>
<bean id="openidvci.status-list.AssignmentStore"
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 d9329b1..17040cb 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
@@ -38,7 +38,8 @@
<bean id="openidvci.IssuerLookupStrategy"
class="org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialIssuerLookupFunction"
- p:issuer="%{openidvci.issuer:}" />
+ p:issuer="%{openidvci.issuer:}"
+ p:deploymentIssuer="#{getObject('shibboleth.oidc.issuer')}" />
<bean id="openidvci.status-list.Store"
class="org.geant.shibboleth.plugin.openidvci.statuslist.storage.StatusListStore"
@@ -52,11 +53,15 @@
p:statusListStore-ref="openidvci.status-list.Store"
p:ttl="%{openidvci.status-list.token.defaultLifetime:PT1H}"
p:issuerLookupStrategy-ref="openidvci.IssuerLookupStrategy"
+ p:deploymentIssuer="#{getObject('shibboleth.oidc.issuer')}"
+ p:configuredIssuer="%{openidvci.issuer:}"
+ p:signingCredentials="#{getObject('openidvci.status-list.SigningCredentials') ?: getObject('openidvci.SigningCredentials') ?: getObject('shibboleth.oidc.SigningCredentialsFactory')}"
p:httpServletRequestSupplier-ref="shibboleth.HttpServletRequestSupplier" />
<bean id="SignStatusListToken"
class="org.geant.shibboleth.plugin.openidvci.statuslist.profile.impl.SignStatusListToken"
- scope="prototype" />
+ scope="prototype"
+ p:signingCredentials="#{getObject('openidvci.status-list.SigningCredentials') ?: getObject('openidvci.SigningCredentials') ?: getObject('shibboleth.oidc.SigningCredentialsFactory')}" />
<bean id="FormOutboundMessage"
class="org.geant.shibboleth.plugin.openidvci.statuslist.profile.impl.FormOutboundStatusListResponseMessage"
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 ed1d67f..a9eaf03 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
@@ -140,7 +140,8 @@
<bean id="openidvci.IssuerLookupStrategy"
class="org.geant.shibboleth.plugin.openidvci.profile.logic.CredentialIssuerLookupFunction"
- p:issuer="%{openidvci.issuer:}" />
+ p:issuer="%{openidvci.issuer:}"
+ p:deploymentIssuer="#{getObject('shibboleth.oidc.issuer')}" />
<bean id="AddCredentialShell" class="org.geant.shibboleth.plugin.openidvci.profile.impl.AddCredentialShell"
scope="prototype" p:issuerLookupStrategy-ref="openidvci.IssuerLookupStrategy"
diff --git a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
index dcca201..ba16b66 100644
--- a/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
+++ b/openid-vci-impl/src/main/resources/META-INF/net/shibboleth/idp/service/relying-party/postconfig.xml
@@ -71,6 +71,15 @@
c:id="#{T(org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration).PROFILE_ID_AUTHORIZATION_SERVER}"
p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}" />
+ <!--
+ Endpoint publishing the did:web document of this deployment, the keys a verifier
+ resolves a credential of a did:web issuer by. Serves anyone, as the two above do.
+ -->
+ <bean id="DID.Document" lazy-init="true"
+ class="org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration"
+ c:id="#{T(org.geant.shibboleth.plugin.openidvci.profile.config.impl.DefaultMetadataDocumentConfiguration).PROFILE_ID_DID_DOCUMENT}"
+ p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}" />
+
<bean id="OAuth.StatusList" lazy-init="true"
class="org.geant.shibboleth.plugin.openidvci.statuslist.profile.config.impl.DefaultStatusListConfiguration"
p:securityConfiguration-ref="%{idp.security.oidc.config:shibboleth.oidc.DefaultSecurityConfiguration}"
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
index 919a7d0..af47ca2 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/conf/openid-vci.properties
@@ -1,5 +1,13 @@
# Set the Credential Issuer value used in credentials
-# Leave empty value for the default of did:jwk
+# Leave empty value for the default of did:jwk, which carries the key inside the credential and
+# needs nothing published. Value 'did:web' asks for the did:web identifier of this deployment,
+# derived from the issuer of the OP, and a verifier resolves it from the document published at
+# /.well-known/did.json. Any other value is the identifier itself, an https url being the one a
+# verifier resolves from /.well-known/jwt-vc-issuer.
+# A did:jwk identifier names the signing key, so it changes when that key does. Rotating the key
+# leaves every credential issued under the previous one naming an identifier this deployment no
+# longer answers for, and their Status List Tokens no longer match. Deployments that rotate keys
+# and revoke credentials want 'did:web' or a url here.
openidvci.issuer =
# Regular expression matching OpenID VCI login flows to enable.
diff --git a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties
index a200663..f59c763 100644
--- a/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties
+++ b/openid-vci-impl/src/main/resources/org/geant/shibboleth/plugin/openidvci/module.properties
@@ -44,6 +44,13 @@ other two documents a wallet reads. Urls in static/jwt-vc-issuer.json and \
static/oauth-authorization-server.json name your host already, both are Velocity templates \
evaluated against the issuer of the OP. Add every credential configuration id you want \
requestable with the authorization code flow to scopes_supported of the latter. \
+Set openidvci.issuer to did:web to name this deployment by its did:web identifier in the \
+credentials it issues, and wire /.well-known/did.json to /idp/profile/did/document to publish \
+the document a verifier resolves that identifier from. The document is formed from the keys \
+credentials and Status List Tokens are signed with, so there is no file to edit and nothing to \
+keep in step by hand. A key stays in it for as long as it is among those signing credentials, \
+so introduce a key by adding it to the list and retire it by removing it only once nothing \
+unexpired was signed with it. \
If you run the OpenID Federation plugin, add <import resource="openid-vci-oidfed.xml" /> in \
conf/global.xml to state this deployment as a Credential Issuer in its Entity Configuration. Do \
not import it without that plugin, the IdP will not start.
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialIssuerLookupFunctionTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialIssuerLookupFunctionTest.java
index 063e0f7..12fefb7 100644
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialIssuerLookupFunctionTest.java
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/profile/logic/CredentialIssuerLookupFunctionTest.java
@@ -65,4 +65,34 @@ public class CredentialIssuerLookupFunctionTest {
Assert.assertNull(new CredentialIssuerLookupFunction().apply(null));
}
+ @Test
+ public void testDidWebKeyword() {
+ final CredentialIssuerLookupFunction function = new CredentialIssuerLookupFunction();
+ function.setIssuer("did:web");
+ function.setDeploymentIssuer("https://example.org/idp/path");
+ Assert.assertEquals(function.apply(null), "did:web:example.org");
+ }
+
+ @Test
+ public void testDidJwkKeywordIsNotTakenLiterally() {
+ final CredentialIssuerLookupFunction function = new CredentialIssuerLookupFunction();
+ function.setIssuer("did:jwk");
+ Assert.assertNull(function.apply(null));
+ }
+
+ @Test
+ public void testUrlKeyword() {
+ final CredentialIssuerLookupFunction function = new CredentialIssuerLookupFunction();
+ function.setIssuer("url");
+ function.setDeploymentIssuer("https://example.org/idp");
+ Assert.assertEquals(function.apply(null), "https://example.org/idp");
+ }
+
+ @Test
+ public void testIdentifierTakenAsItIs() {
+ final CredentialIssuerLookupFunction function = new CredentialIssuerLookupFunction();
+ function.setIssuer("https://example.org");
+ Assert.assertEquals(function.apply(null), "https://example.org");
+ }
+
}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidJwkSupportTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidJwkSupportTest.java
deleted file mode 100644
index 1703a34..0000000
--- a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidJwkSupportTest.java
+++ /dev/null
@@ -1,93 +0,0 @@
-/*
- * 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.security.impl;
-
-import org.testng.Assert;
-import org.testng.annotations.BeforeMethod;
-import org.testng.annotations.DataProvider;
-import org.testng.annotations.Test;
-
-import com.nimbusds.jose.jwk.Curve;
-import com.nimbusds.jose.jwk.ECKey;
-import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
-
-import net.shibboleth.oidc.jwa.support.SignatureConstants;
-import net.shibboleth.oidc.security.CredentialConversionUtil;
-import net.shibboleth.oidc.security.credential.BasicJWKCredential;
-import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
-
-/**
- * Unit tests for {@link DidJwkSupport}.
- */
-public class DidJwkSupportTest {
-
- private static final String DID = "did:jwk:eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6IngiLCJ5IjoieSJ9";
-
- private ECKey key;
-
- private SignatureSigningParameters parameters;
-
- @BeforeMethod
- protected void setUp() throws Exception {
- key = new ECKeyGenerator(Curve.P_256).keyID("signingvci").generate();
-
- final BasicJWKCredential credential = new BasicJWKCredential();
- credential.setPublicKey(key.toECPublicKey());
- credential.setPrivateKey(key.toECPrivateKey());
- credential.setKid(key.getKeyID());
-
- parameters = new SignatureSigningParameters();
- parameters.setSigningCredential(credential);
- parameters.setSignatureAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_ES_256);
- }
-
- @Test
- public void testNamedByDidJwk() throws Exception {
- final SignatureSigningParameters named = DidJwkSupport.nameKeyByIssuer(parameters, DID);
-
- Assert.assertEquals(CredentialConversionUtil.resolveKid(named.getSigningCredential()), DID + "#0");
- Assert.assertEquals(named.getSignatureAlgorithm(), SignatureConstants.ALGO_ID_SIGNATURE_ES_256);
- Assert.assertEquals(named.getSigningCredential().getPublicKey(), key.toECPublicKey());
- Assert.assertEquals(named.getSigningCredential().getPrivateKey(), key.toECPrivateKey());
- }
-
- /** The key of the given parameters is left as it is. */
- @Test
- public void testGivenParametersUntouched() throws Exception {
- DidJwkSupport.nameKeyByIssuer(parameters, DID);
-
- Assert.assertEquals(CredentialConversionUtil.resolveKid(parameters.getSigningCredential()), "signingvci");
- }
-
- @Test(dataProvider = "notDidJwk")
- public void testIssuerIsNoDidJwk(final String issuer) throws Exception {
- Assert.assertSame(DidJwkSupport.nameKeyByIssuer(parameters, issuer), parameters);
- }
-
- @DataProvider(name = "notDidJwk")
- public Object[][] notDidJwk() {
- return new Object[][] { { null }, { "" }, { "https://example.org" }, { "did:web:example.org" }, };
- }
-
- @Test
- public void testNoSigningCredential() throws Exception {
- final SignatureSigningParameters empty = new SignatureSigningParameters();
-
- Assert.assertSame(DidJwkSupport.nameKeyByIssuer(empty, DID), empty);
- }
-
-}
diff --git a/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidSupportTest.java b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidSupportTest.java
new file mode 100644
index 0000000..6dd2f74
--- /dev/null
+++ b/openid-vci-impl/src/test/java/org/geant/shibboleth/plugin/openidvci/security/impl/DidSupportTest.java
@@ -0,0 +1,131 @@
+/*
+ * 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.security.impl;
+
+import org.testng.Assert;
+import org.testng.annotations.BeforeMethod;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
+
+import net.shibboleth.oidc.jwa.support.SignatureConstants;
+import net.shibboleth.oidc.security.CredentialConversionUtil;
+import net.shibboleth.oidc.security.credential.BasicJWKCredential;
+import net.shibboleth.oidc.security.jose.SignatureSigningParameters;
+
+/** Unit tests for {@link DidSupport}. */
+public class DidSupportTest {
+
+ private static final String DID_JWK = "did:jwk:eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6IngiLCJ5IjoieSJ9";
+
+ private static final String DID_WEB = "did:web:example.org";
+
+ private ECKey key;
+
+ private SignatureSigningParameters parameters;
+
+ @BeforeMethod
+ protected void setUp() throws Exception {
+ key = new ECKeyGenerator(Curve.P_256).keyID("signingvci").generate();
+ final BasicJWKCredential credential = new BasicJWKCredential();
+ credential.setPublicKey(key.toECPublicKey());
+ credential.setPrivateKey(key.toECPrivateKey());
+ credential.setKid(key.getKeyID());
+ parameters = new SignatureSigningParameters();
+ parameters.setSigningCredential(credential);
+ parameters.setSignatureAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_ES_256);
+ }
+
+ @Test
+ public void testNamedByDidJwk() throws Exception {
+ final SignatureSigningParameters named = DidSupport.nameKeyByIssuer(parameters, DID_JWK);
+ Assert.assertEquals(CredentialConversionUtil.resolveKid(named.getSigningCredential()), DID_JWK + "#0");
+ Assert.assertEquals(named.getSignatureAlgorithm(), SignatureConstants.ALGO_ID_SIGNATURE_ES_256);
+ Assert.assertEquals(named.getSigningCredential().getPublicKey(), key.toECPublicKey());
+ Assert.assertEquals(named.getSigningCredential().getPrivateKey(), key.toECPrivateKey());
+ }
+
+ @Test
+ public void testNamedByDidWeb() throws Exception {
+ final SignatureSigningParameters named = DidSupport.nameKeyByIssuer(parameters, DID_WEB);
+ Assert.assertEquals(CredentialConversionUtil.resolveKid(named.getSigningCredential()),
+ DID_WEB + "#signingvci");
+ Assert.assertEquals(named.getSignatureAlgorithm(), SignatureConstants.ALGO_ID_SIGNATURE_ES_256);
+ Assert.assertEquals(named.getSigningCredential().getPublicKey(), key.toECPublicKey());
+ Assert.assertEquals(named.getSigningCredential().getPrivateKey(), key.toECPrivateKey());
+ }
+
+ @Test(dataProvider = "dids")
+ public void testGivenParametersUntouched(final String issuer) throws Exception {
+ DidSupport.nameKeyByIssuer(parameters, issuer);
+ Assert.assertEquals(CredentialConversionUtil.resolveKid(parameters.getSigningCredential()), "signingvci");
+ }
+
+ @DataProvider(name = "dids")
+ public Object[][] dids() {
+ return new Object[][] { { DID_JWK }, { DID_WEB }, };
+ }
+
+ @Test(dataProvider = "notDid")
+ public void testIssuerIsNoDid(final String issuer) throws Exception {
+ Assert.assertSame(DidSupport.nameKeyByIssuer(parameters, issuer), parameters);
+ }
+
+ @DataProvider(name = "notDid")
+ public Object[][] notDid() {
+ return new Object[][] { { null }, { "" }, { "https://example.org" }, { "did:webvh:abc:example.org" }, };
+ }
+
+ @Test
+ public void testDidWebWithoutKeyName() throws Exception {
+ final BasicJWKCredential unnamed = new BasicJWKCredential();
+ unnamed.setPublicKey(key.toECPublicKey());
+ final SignatureSigningParameters nameless = new SignatureSigningParameters();
+ nameless.setSigningCredential(unnamed);
+ Assert.assertSame(DidSupport.nameKeyByIssuer(nameless, DID_WEB), nameless);
+ }
+
+ @Test
+ public void testNoSigningCredential() throws Exception {
+ final SignatureSigningParameters empty = new SignatureSigningParameters();
+ Assert.assertSame(DidSupport.nameKeyByIssuer(empty, DID_JWK), empty);
+ }
+
+ @Test(dataProvider = "deploymentIssuers")
+ public void testToDidWeb(final String issuer, final String expected) throws Exception {
+ Assert.assertEquals(DidSupport.toDidWeb(issuer), expected);
+ }
+
+ @DataProvider(name = "deploymentIssuers")
+ public Object[][] deploymentIssuers() {
+ return new Object[][] {
+ { "https://example.org", "did:web:example.org" },
+ { "https://example.org/", "did:web:example.org" },
+ { "https://example.org/idp/path", "did:web:example.org" },
+ { "https://example.org:8443", "did:web:example.org%3A8443" },
+ { "https://example.org:8443/idp", "did:web:example.org%3A8443" },
+ { null, null },
+ { "", null },
+ { "not a url", null },
+ { "urn:example:org", null },
+ };
+ }
+
+}
--
To stop receiving notification emails like this one, please contact
the administrator of this repository.
More information about the commits
mailing list