Skip to content

Commit 495e0b2

Browse files
committed
Add an option for a Wallet to configure supported Proof types
1 parent 0a77735 commit 495e0b2

7 files changed

Lines changed: 251 additions & 9 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,7 @@ data class OpenId4VCIConfig(
578578
val parUsage: ParUsage = ParUsage.IfSupported,
579579
val clock: Clock = Clock.systemDefaultZone(),
580580
val issuerMetadataPolicy: IssuerMetadataPolicy = IssuerMetadataPolicy.IgnoreSigned,
581+
val proofs: ProofsConfig,
581582
)
582583
```
583584

@@ -597,6 +598,7 @@ Options available:
597598
- `IssuerMetadataPolicy.RequireSigned`: require the presence of signed metadata and use only values from signed metadata
598599
- `IssuerMetadataPolicy.PreferSigned`: presence of signed metadata is optional, if present values from signed metadata take precedence
599600
- `IssuerMetadataPolicy.IgnoreSigned`: signed metadata are ignored
601+
- proofs: Proofs types supported by the Wallet. Wallet can choose whether to support non-device-bound attestations, and device-bound attestations alongside the supported proof types.
600602

601603
Trust between the Wallet and the Signer of the signed metadata advertised by the Credential Issuer is established using one of the following ways:
602604
- `IssuerTrust.ByPublicKey`: trusting the public key used to sign the metadata

src/main/kotlin/eu/europa/ec/eudi/openid4vci/Config.kt

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package eu.europa.ec.eudi.openid4vci
1818
import com.nimbusds.jose.CompressionAlgorithm
1919
import com.nimbusds.jose.EncryptionMethod
2020
import com.nimbusds.jose.JWEAlgorithm
21+
import com.nimbusds.jose.JWSAlgorithm
2122
import com.nimbusds.jose.crypto.ECDHEncrypter
2223
import com.nimbusds.jose.crypto.RSAEncrypter
2324
import com.nimbusds.jose.crypto.impl.ContentCryptoProvider
@@ -73,6 +74,7 @@ sealed interface ClientAuthentication : java.io.Serializable {
7374
* @param parUsage whether to use PAR in case of authorization code grant
7475
* @param clock Wallet's clock
7576
* @param issuerMetadataPolicy policy concerning signed metadata usage
77+
* @param proofs proofs supported by the Wallet
7678
*/
7779
data class OpenId4VCIConfig(
7880
val clientAuthentication: ClientAuthentication,
@@ -84,11 +86,14 @@ data class OpenId4VCIConfig(
8486
val parUsage: ParUsage = ParUsage.IfSupported,
8587
val clock: Clock = Clock.systemDefaultZone(),
8688
val issuerMetadataPolicy: IssuerMetadataPolicy = IssuerMetadataPolicy.IgnoreSigned,
89+
val proofs: ProofsConfig,
8790
) {
8891

8992
/**
90-
* Creates a new [OpenId4VCIConfig] instance for a Wallet that uses [a Public OAuth 2.0 Client][ClientAuthentication.None].
93+
* Creates a new [OpenId4VCIConfig] instance for a Wallet that uses [a Public OAuth 2.0 Client][ClientAuthentication.None], and all
94+
* [Proof Types][ProofsConfig].
9195
*/
96+
@Deprecated(message = "Replace with the primary constructor")
9297
constructor(
9398
clientId: ClientId,
9499
authFlowRedirectionURI: URI,
@@ -109,12 +114,13 @@ data class OpenId4VCIConfig(
109114
parUsage,
110115
clock,
111116
issuerMetadataPolicy,
117+
ProofsConfig.All,
112118
)
113119

114120
/**
115-
* Creates a new [OpenId4VCIConfig] instance for a Wallet.
121+
* Creates a new [OpenId4VCIConfig] instance for a Wallet that supports all [Proof Types][ProofsConfig].
116122
*/
117-
@Deprecated(message = "Replace with the constructor that uses DPoPUsage.")
123+
@Deprecated(message = "Replace with the primary constructor")
118124
constructor (
119125
clientAuthentication: ClientAuthentication,
120126
authFlowRedirectionURI: URI,
@@ -135,12 +141,14 @@ data class OpenId4VCIConfig(
135141
parUsage,
136142
clock,
137143
issuerMetadataPolicy,
144+
ProofsConfig.All,
138145
)
139146

140147
/**
141-
* Creates a new [OpenId4VCIConfig] instance for a Wallet that uses [a Public OAuth 2.0 Client][ClientAuthentication.None].
148+
* Creates a new [OpenId4VCIConfig] instance for a Wallet that uses [a Public OAuth 2.0 Client][ClientAuthentication.None], and
149+
* supports all [Proof Types][ProofsConfig].
142150
*/
143-
@Deprecated(message = "Replace with the constructor that uses DPoPUsage.")
151+
@Deprecated(message = "Replace with the primary constructor")
144152
constructor(
145153
clientId: ClientId,
146154
authFlowRedirectionURI: URI,
@@ -161,6 +169,7 @@ data class OpenId4VCIConfig(
161169
parUsage,
162170
clock,
163171
issuerMetadataPolicy,
172+
ProofsConfig.All,
164173
)
165174

166175
@Deprecated(
@@ -320,3 +329,53 @@ sealed interface IssuerMetadataPolicy {
320329
*/
321330
data object IgnoreSigned : IssuerMetadataPolicy
322331
}
332+
333+
/**
334+
* Wallet supported Proofs.
335+
*
336+
* A Wallet has to configure whether it supports non-device-bound attestations, as well as the Proofs supported for device-bound attestations.
337+
*
338+
* @property supportsDeviceBound Whether the Wallet supports non-device-bound attestations.
339+
* @property deviceBound Whether the Wallet supports device-bound attestations, and the Proofs it supports.
340+
*/
341+
data class ProofsConfig(
342+
val supportsNonDeviceBound: Boolean,
343+
val deviceBound: DeviceBound?,
344+
) {
345+
val supportsDeviceBound: Boolean
346+
get() = null != deviceBound
347+
348+
companion object {
349+
val All: ProofsConfig = ProofsConfig(
350+
supportsNonDeviceBound = true,
351+
deviceBound = DeviceBound(null, DeviceBound.Proof.entries.toSet()),
352+
)
353+
354+
val None: ProofsConfig = ProofsConfig(
355+
supportsNonDeviceBound = false,
356+
deviceBound = null,
357+
)
358+
}
359+
360+
/**
361+
* The Proofs a Wallet supports for device-bound attestations.
362+
*
363+
* @property algorithms The JWS Algorithms the Wallet supports for device-bound attestations. Set to *null* if the Wallet supports all algorithms.
364+
* @property proofs The Proofs the Wallet supports for device-bound attestations.
365+
*/
366+
data class DeviceBound(
367+
val algorithms: Set<JWSAlgorithm>?,
368+
val proofs: Set<Proof>,
369+
) {
370+
init {
371+
require(null == algorithms || algorithms.isNotEmpty()) { "At least one algorithm must be supported" }
372+
require(proofs.isNotEmpty()) { "At least one proof must be supported" }
373+
}
374+
375+
enum class Proof {
376+
JwtProofWithoutKeyAttestation,
377+
JwtProofWithKeyAttestation,
378+
AttestationProof,
379+
}
380+
}
381+
}

src/main/kotlin/eu/europa/ec/eudi/openid4vci/internal/RequestIssuanceImpl.kt

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ internal class RequestIssuanceImpl(
104104
credentialConfigId: CredentialConfigurationIdentifier,
105105
grant: Grant,
106106
): Pair<List<Proof>, Nonce?> {
107-
proofsSpecification.ensureCompatibleWith(credentialConfigId)
107+
val credentialConfiguration = credentialSupportedById(credentialConfigId)
108+
config.proofs.ensureCompatibleWith(credentialConfiguration.proofTypesSupported)
109+
proofsSpecification.ensureCompatibleWith(credentialConfiguration)
108110

109111
return when (proofsSpecification) {
110112
is ProofsSpecification.NoProofs -> emptyList<Proof>() to null
@@ -147,8 +149,7 @@ internal class RequestIssuanceImpl(
147149
}
148150
}
149151

150-
private fun ProofsSpecification.ensureCompatibleWith(credentialConfigId: CredentialConfigurationIdentifier) {
151-
val credentialConfiguration = credentialSupportedById(credentialConfigId)
152+
private fun ProofsSpecification.ensureCompatibleWith(credentialConfiguration: CredentialConfiguration) {
152153
val proofTypesSupported = credentialConfiguration.proofTypesSupported
153154

154155
when (this) {
@@ -181,9 +182,11 @@ internal class RequestIssuanceImpl(
181182
}
182183

183184
is ProofsSpecification.AttestationProof -> {
184-
requireNotNull(proofTypesSupported[ProofType.ATTESTATION]) {
185+
val proofRequirement = proofTypesSupported[ProofType.ATTESTATION]
186+
requireNotNull(proofRequirement) {
185187
"Credential configuration doesn't support attestation proofs."
186188
}
189+
check(proofRequirement is ProofTypeMeta.Attestation)
187190
}
188191
}
189192
}
@@ -404,3 +407,39 @@ internal sealed interface SubmissionOutcomeInternal {
404407
is Failed -> SubmissionOutcome.Failed(error)
405408
}
406409
}
410+
411+
private fun ProofsConfig.ensureCompatibleWith(supportedProofTypes: ProofTypesSupported) {
412+
when (supportedProofTypes) {
413+
ProofTypesSupported.Empty -> {
414+
require(supportsNonDeviceBound) { "Wallet doesn't support non-device-bound attestations" }
415+
}
416+
417+
else -> {
418+
val deviceBoundConfig = requireNotNull(deviceBound) { "Wallet doesn't support device-bound attestations" }
419+
420+
val supportsJwtProofs = supportedProofTypes[ProofType.JWT]?.let { jwtProof ->
421+
check(jwtProof is ProofTypeMeta.Jwt)
422+
val supportsAnySigningAlgorithm =
423+
if (null == deviceBoundConfig.algorithms) true
424+
else jwtProof.algorithms.any { it in deviceBoundConfig.algorithms }
425+
val proofType = when (jwtProof.keyAttestationRequirement) {
426+
KeyAttestationRequirement.NotRequired -> ProofsConfig.DeviceBound.Proof.JwtProofWithoutKeyAttestation
427+
is KeyAttestationRequirement.Required -> ProofsConfig.DeviceBound.Proof.JwtProofWithKeyAttestation
428+
}
429+
val supportsProofType = proofType in deviceBoundConfig.proofs
430+
supportsAnySigningAlgorithm && supportsProofType
431+
} ?: false
432+
433+
val supportsAttestationProofs = supportedProofTypes[ProofType.ATTESTATION]?.let { attestationProof ->
434+
check(attestationProof is ProofTypeMeta.Attestation)
435+
val supportsAnySigningAlgorithm =
436+
if (null == deviceBoundConfig.algorithms) true
437+
else attestationProof.algorithms.any { it in deviceBoundConfig.algorithms }
438+
val supportsProofType = ProofsConfig.DeviceBound.Proof.AttestationProof in deviceBoundConfig.proofs
439+
supportsAnySigningAlgorithm && supportsProofType
440+
} ?: false
441+
442+
require(supportsJwtProofs || supportsAttestationProofs) { "Wallet doesn't support any of the advertised Proofs" }
443+
}
444+
}
445+
}

src/test/kotlin/eu/europa/ec/eudi/openid4vci/IssuanceSingleRequestTest.kt

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package eu.europa.ec.eudi.openid4vci
1717

18+
import com.nimbusds.jose.JWSAlgorithm
1819
import com.nimbusds.jose.JWSObject
1920
import com.nimbusds.jose.jwk.Curve
2021
import com.nimbusds.jose.jwk.gen.ECKeyGenerator
@@ -995,4 +996,141 @@ class IssuanceSingleRequestTest {
995996
authorizedRequest.request(requestPayload, attestationProofSpec()).getOrThrow()
996997
}
997998
}
999+
1000+
@Test
1001+
fun `when wallet does not support non device bound attestation, issuance fails`() =
1002+
runTest {
1003+
val mockedHttpClient = mockedHttpClient(
1004+
credentialIssuerMetadataWellKnownMocker(),
1005+
authServerWellKnownMocker(),
1006+
parPostMocker(),
1007+
tokenPostMocker(),
1008+
)
1009+
val (authorizedRequest, issuer) = authorizeRequestForCredentialOffer(
1010+
config = OpenId4VCIConfiguration.copy(proofs = ProofsConfig.None),
1011+
credentialOfferStr = CredentialOfferWithMDLMdoc_NO_GRANTS,
1012+
httpClient = mockedHttpClient,
1013+
)
1014+
1015+
val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0]
1016+
with(issuer) {
1017+
val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId)
1018+
val exception = assertFailsWith<IllegalArgumentException> {
1019+
authorizedRequest.request(requestPayload, ProofsSpecification.NoProofs).getOrThrow()
1020+
}
1021+
assertEquals("Wallet doesn't support non-device-bound attestations", exception.message)
1022+
}
1023+
}
1024+
1025+
@Test
1026+
fun `when wallet does not support device bound attestation, issuance fails`() =
1027+
runTest {
1028+
val mockedHttpClient = mockedHttpClient(
1029+
credentialIssuerMetadataWellKnownMocker(),
1030+
authServerWellKnownMocker(),
1031+
parPostMocker(),
1032+
tokenPostMocker(),
1033+
)
1034+
val (authorizedRequest, issuer) = authorizeRequestForCredentialOffer(
1035+
config = OpenId4VCIConfiguration.copy(proofs = ProofsConfig.None),
1036+
credentialOfferStr = CredentialOfferWithSdJwtVc_NO_GRANTS,
1037+
httpClient = mockedHttpClient,
1038+
)
1039+
1040+
val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0]
1041+
with(issuer) {
1042+
val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId)
1043+
val exception = assertFailsWith<IllegalArgumentException> {
1044+
authorizedRequest.request(requestPayload, noKeyAttestationJwtProofsSpec()).getOrThrow()
1045+
}
1046+
assertEquals("Wallet doesn't support device-bound attestations", exception.message)
1047+
}
1048+
}
1049+
1050+
@Test
1051+
fun `when wallet does supports device bound attestation, but none of the advertised algorithms, issuance fails`() =
1052+
runTest {
1053+
val mockedHttpClient = mockedHttpClient(
1054+
credentialIssuerMetadataWellKnownMocker(),
1055+
authServerWellKnownMocker(),
1056+
parPostMocker(),
1057+
tokenPostMocker(),
1058+
)
1059+
val (authorizedRequest, issuer) = authorizeRequestForCredentialOffer(
1060+
config = OpenId4VCIConfiguration.copy(
1061+
proofs = ProofsConfig(
1062+
supportsNonDeviceBound = false,
1063+
deviceBound = ProofsConfig.DeviceBound(
1064+
algorithms = setOf(
1065+
JWSAlgorithm.ES512,
1066+
),
1067+
proofs = setOf(ProofsConfig.DeviceBound.Proof.JwtProofWithoutKeyAttestation),
1068+
),
1069+
),
1070+
),
1071+
credentialOfferStr = CredentialOfferWithSdJwtVc_NO_GRANTS,
1072+
httpClient = mockedHttpClient,
1073+
)
1074+
1075+
val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0]
1076+
with(issuer) {
1077+
val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId)
1078+
val exception = assertFailsWith<IllegalArgumentException> {
1079+
authorizedRequest.request(requestPayload, noKeyAttestationJwtProofsSpec()).getOrThrow()
1080+
}
1081+
assertEquals("Wallet doesn't support any of the advertised Proofs", exception.message)
1082+
}
1083+
}
1084+
1085+
@Test
1086+
fun `when wallet does not support the required device bound attestation proof, issuance fails`() =
1087+
runTest {
1088+
suspend fun test(
1089+
supportedProofType: ProofsConfig.DeviceBound.Proof,
1090+
credentialOffer: String,
1091+
proofsSpecification: ProofsSpecification,
1092+
) {
1093+
val mockedHttpClient = mockedHttpClient(
1094+
credentialIssuerMetadataWellKnownMocker(IssuerMetadataVersion.ATTESTATION_PROOF_SUPPORTED),
1095+
authServerWellKnownMocker(),
1096+
parPostMocker(),
1097+
tokenPostMocker(),
1098+
)
1099+
val (authorizedRequest, issuer) = authorizeRequestForCredentialOffer(
1100+
config = OpenId4VCIConfiguration.copy(
1101+
proofs = ProofsConfig(
1102+
supportsNonDeviceBound = false,
1103+
deviceBound = ProofsConfig.DeviceBound(
1104+
algorithms = null,
1105+
proofs = setOf(supportedProofType),
1106+
),
1107+
),
1108+
),
1109+
credentialOfferStr = credentialOffer,
1110+
httpClient = mockedHttpClient,
1111+
)
1112+
1113+
val credentialConfigurationId = issuer.credentialOffer.credentialConfigurationIdentifiers[0]
1114+
with(issuer) {
1115+
val requestPayload = IssuanceRequestPayload.ConfigurationBased(credentialConfigurationId)
1116+
val exception = assertFailsWith<IllegalArgumentException> {
1117+
authorizedRequest.request(requestPayload, proofsSpecification).getOrThrow()
1118+
}
1119+
assertEquals("Wallet doesn't support any of the advertised Proofs", exception.message)
1120+
}
1121+
}
1122+
1123+
// Wallet supports Jwt Proofs without Key Attestations, but Credential Issuer requires Jwt Proofs with Key Attestations
1124+
test(
1125+
ProofsConfig.DeviceBound.Proof.JwtProofWithoutKeyAttestation,
1126+
CredentialOfferWithSdJwtVc_NO_GRANTS,
1127+
noKeyAttestationJwtProofsSpec(),
1128+
)
1129+
1130+
// Wallet supports Jwt Proofs with Key Attestations, but Credential Issuer requires Jwt Proofs without Key Attestations
1131+
test(ProofsConfig.DeviceBound.Proof.JwtProofWithKeyAttestation, CredentialOfferMsoMdoc_NO_GRANTS, keyAttestationJwtProofsSpec())
1132+
1133+
// Wallet supports Attestation Proofs, but Credential Issuer requires Jwt Proofs without Key Attestations
1134+
test(ProofsConfig.DeviceBound.Proof.AttestationProof, CredentialOfferMsoMdoc_NO_GRANTS, attestationProofSpec())
1135+
}
9981136
}

src/test/kotlin/eu/europa/ec/eudi/openid4vci/TestUtils.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ val OpenId4VCIConfiguration = OpenId4VCIConfig(
8989
authFlowRedirectionURI = URI.create("eudi-wallet//auth"),
9090
encryptionSupportConfig = EncryptionSupportConfig(Curve.P_256, 2048, CredentialResponseEncryptionPolicy.SUPPORTED),
9191
dPoPUsage = DPoPUsage.Never,
92+
proofs = ProofsConfig.All,
9293
)
9394

9495
val OpenId4VCIConfigurationWithDpopSigner = OpenId4VCIConfig(
@@ -101,6 +102,7 @@ val OpenId4VCIConfigurationWithDpopSigner = OpenId4VCIConfig(
101102
alg = JWSAlgorithm.ES256,
102103
),
103104
),
105+
proofs = ProofsConfig.All,
104106
)
105107

106108
suspend fun authorizeRequestForCredentialOffer(

src/test/kotlin/eu/europa/ec/eudi/openid4vci/examples/OfferBasedIssuanceUsingPreAuthorizationFlow.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ fun main(): Unit = runBlocking {
2828
authFlowRedirectionURI = URI.create("urn:ietf:wg:oauth:2.0:oob"),
2929
encryptionSupportConfig = EncryptionSupportConfig(Curve.P_256, 2048, CredentialResponseEncryptionPolicy.SUPPORTED),
3030
dPoPUsage = DPoPUsage.Never,
31+
proofs = ProofsConfig.All,
3132
)
3233
val credentialOfferUrl =
3334
"openid-credential-offer://?credential_offer_uri=https%3A%2F%2Ftrial.authlete.net" +

src/test/kotlin/eu/europa/ec/eudi/openid4vci/examples/PidDevIssuer.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ internal object PidDevIssuer :
3939
authorizeIssuanceConfig = AuthorizeIssuanceConfig.FAVOR_SCOPES,
4040
dPoPUsage = DPoPUsage.Required(CryptoGenerator.ecSigner()),
4141
parUsage = ParUsage.Required,
42+
proofs = ProofsConfig.All,
4243
)
4344

4445
val PID_SdJwtVC_config_id = CredentialConfigurationIdentifier("eu.europa.ec.eudi.pid_vc_sd_jwt")

0 commit comments

Comments
 (0)