From ae3a93015964dcadb68516ca97b0a79ccb59ea29 Mon Sep 17 00:00:00 2001 From: Asser Hakala Date: Tue, 10 Feb 2026 13:11:16 +0100 Subject: [PATCH 1/2] fix: reject proof signed with non-attested key Calling `Flow.first()` caused a NoSuchElementException when no matching attested key was found, resulting in a generic error message. Use `firstOrNull()` and return an explicit InvalidProof instead. Signed-off-by: Asser Hakala --- .../pidissuer/domain/KeyAttestationJWT.kt | 4 +- .../adapter/out/jose/ValidateJwtProofTest.kt | 66 ++++++++++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/KeyAttestationJWT.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/KeyAttestationJWT.kt index 2a48bece..8c481aad 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/KeyAttestationJWT.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/domain/KeyAttestationJWT.kt @@ -27,7 +27,7 @@ import com.nimbusds.jose.jwk.JWK import com.nimbusds.jose.jwk.RSAKey import com.nimbusds.jwt.SignedJWT import kotlinx.coroutines.flow.channelFlow -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch data class KeyAttestationJWT private constructor( @@ -131,4 +131,4 @@ internal suspend fun signatureVerifiedByKey(vararg tasks: suspend () -> JWK?): J tasks.forEach { launch { send(it()) } } - }.first { it != null } + }.firstOrNull { it != null } diff --git a/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ValidateJwtProofTest.kt b/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ValidateJwtProofTest.kt index a0b1a35b..d67dce44 100644 --- a/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ValidateJwtProofTest.kt +++ b/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ValidateJwtProofTest.kt @@ -23,7 +23,9 @@ import com.nimbusds.jose.JOSEObjectType import com.nimbusds.jose.JWSAlgorithm import com.nimbusds.jose.JWSHeader import com.nimbusds.jose.crypto.ECDSASigner +import com.nimbusds.jose.jwk.Curve import com.nimbusds.jose.jwk.ECKey +import com.nimbusds.jose.jwk.gen.ECKeyGenerator import com.nimbusds.jose.jwk.gen.RSAKeyGenerator import com.nimbusds.jose.util.Base64 import com.nimbusds.jose.util.Base64URL @@ -31,15 +33,18 @@ import com.nimbusds.jose.util.X509CertChainUtils import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.SignedJWT import eu.europa.ec.eudi.pidissuer.adapter.out.mdl.mobileDrivingLicenceV1 +import eu.europa.ec.eudi.pidissuer.adapter.out.pid.pidSdJwtVcV1 import eu.europa.ec.eudi.pidissuer.domain.* import eu.europa.ec.eudi.pidissuer.domain.Clock import eu.europa.ec.eudi.pidissuer.loadResource +import eu.europa.ec.eudi.pidissuer.port.input.IssueCredentialError import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import java.security.cert.X509Certificate import java.util.* import kotlin.test.* +import kotlin.time.Duration.Companion.minutes internal class ValidateJwtProofTest { @@ -246,6 +251,37 @@ internal class ValidateJwtProofTest { assertTrue { result.isLeft() } } + @Test + internal fun `fails when proof is signed with a key not present in attested keys`() = runTest { + val credentialConfiguration = pidSdJwtVcV1( + JWSAlgorithm.ES256, + checkNotNull(ECDSASigner.SUPPORTED_ALGORITHMS.toNonEmptySetOrNull()), + KeyAttestationRequirement.Required(null, null), + ) + val proofSigningKey = ECKeyGenerator(Curve.P_256) + .algorithm(JWSAlgorithm.ES256) + .generate() + val attestedKey = ECKeyGenerator(Curve.P_256) + .algorithm(JWSAlgorithm.ES256) + .generate() + val keyAttestationJwt = generateKeyAttestationJwt(attestedKey, "nonce") + val signedJwtProof = + generateSignedJwt(proofSigningKey, "nonce") { + customParam( + "key_attestation", + keyAttestationJwt.serialize(), + ) + } + val result = + validateJwtProof( + UnvalidatedProof.Jwt(signedJwtProof.serialize()), + credentialConfiguration, + clock.now(), + ) + + assertIs(result.leftOrNull()) + } + private fun generateSignedJwt( key: ECKey, nonce: String, @@ -257,14 +293,42 @@ internal class ValidateJwtProofTest { .apply { headersProvider() } .build() + val now = clock.now() + val exp = now.plus(5.minutes) + val claims = JWTClaimsSet.Builder() .audience(issuer.externalForm) - .issueTime(clock.now().toJavaDate()) + .issueTime(now.toJavaDate()) + .expirationTime(exp.toJavaDate()) .claim("nonce", nonce) .build() return SignedJWT(header, claims).apply { sign(ECDSASigner(key)) } } + + private suspend fun generateKeyAttestationJwt( + attestedKey: ECKey, + nonce: String, + algorithm: JWSAlgorithm = ECDSASigner.SUPPORTED_ALGORITHMS.first(), + ): SignedJWT { + val signingKey = loadKey() + val header = JWSHeader.Builder(algorithm) + .type(JOSEObjectType("key-attestation+jwt")) + .x509CertChain(loadChain().map { Base64.encode(it.encoded) }) + .build() + + val now = clock.now() + val exp = now.plus(5.minutes) + + val claims = JWTClaimsSet.Builder() + .issueTime(now.toJavaDate()) + .expirationTime(exp.toJavaDate()) + .claim("nonce", nonce) + .claim("attested_keys", arrayOf(attestedKey.toPublicJWK().toJSONObject())) + .build() + + return SignedJWT(header, claims).apply { sign(ECDSASigner(signingKey)) } + } } private suspend fun loadChain(): NonEmptyList = From c513522885d628af70ac51798827ba452666c2ec Mon Sep 17 00:00:00 2001 From: Asser Hakala Date: Wed, 11 Feb 2026 16:34:34 +0100 Subject: [PATCH 2/2] test(issuance): move non-attested key test to WalletApiTest Revert changes in ValidateJwtProofTest since test is relocated Signed-off-by: Asser Hakala --- .../adapter/input/web/WalletApiTest.kt | 35 +++++++++- .../adapter/out/jose/ValidateJwtProofTest.kt | 66 +------------------ 2 files changed, 35 insertions(+), 66 deletions(-) diff --git a/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/WalletApiTest.kt b/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/WalletApiTest.kt index c175704e..d765631e 100644 --- a/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/WalletApiTest.kt +++ b/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/input/web/WalletApiTest.kt @@ -825,6 +825,39 @@ internal class WalletApiEncryptionOptionalKeyAttestationsRequiredTest : BaseWall assertEquals("Invalid proof JWT: Key Attestation 'nonce' does not match JWT Proof 'nonce'", response.errorDescription) } + @Test + internal fun `issuance with jwt proof that contains key attestation fails when proof is signed with non-attested key`() = + runTest { + val authentication = dPoPTokenAuthentication(clock = clock) + val cNonce = generateNonce(clock.now(), 5L.minutes) + val nonAttestedProofKey = ECKeyGenerator(Curve.P_256).generate() + val attestedKey = ECKeyGenerator(Curve.P_256).generate() + val keyAttestationJwt = keyAttestationJWT(proofSigningKey = attestedKey) + + val proofs = jwtProof(credentialIssuerMetadata.id, clock, cNonce, nonAttestedProofKey) { + customParam("key_attestation", keyAttestationJwt.serialize()) + }.toJwtProofs() + + val response = client() + .mutateWith(mockAuthentication(authentication)) + .post() + .uri(WalletApi.CREDENTIAL_ENDPOINT) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(requestByCredentialIdentifier(proofs)) + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus().isBadRequest() + .expectBody() + .returnResult() + .let { assertNotNull(it.responseBody) } + + assertEquals(CredentialErrorTypeTo.INVALID_PROOF, response.type) + assertEquals( + "Invalid proof JWT: Key attestation does not contain a key that verifies the jwt proof signature", + response.errorDescription, + ) + } + @Test fun `issuance with attestation proof (without 'exp' claim) is successful`() = runTest { val authentication = dPoPTokenAuthentication(clock = clock) @@ -1527,7 +1560,7 @@ private suspend fun keyAttestationJWT( clock: Clock = Clock.System, expiresAt: Instant = clock.now() + 1.days, includeExpiresAt: Boolean = true, - extraKeys: () -> List, + extraKeys: () -> List = { emptyList() }, ): SignedJWT { val keyAttestationSigningKey = loadECKey("key-attestation-key.pem") val signer = ECDSASigner(keyAttestationSigningKey) diff --git a/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ValidateJwtProofTest.kt b/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ValidateJwtProofTest.kt index d67dce44..a0b1a35b 100644 --- a/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ValidateJwtProofTest.kt +++ b/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ValidateJwtProofTest.kt @@ -23,9 +23,7 @@ import com.nimbusds.jose.JOSEObjectType import com.nimbusds.jose.JWSAlgorithm import com.nimbusds.jose.JWSHeader import com.nimbusds.jose.crypto.ECDSASigner -import com.nimbusds.jose.jwk.Curve import com.nimbusds.jose.jwk.ECKey -import com.nimbusds.jose.jwk.gen.ECKeyGenerator import com.nimbusds.jose.jwk.gen.RSAKeyGenerator import com.nimbusds.jose.util.Base64 import com.nimbusds.jose.util.Base64URL @@ -33,18 +31,15 @@ import com.nimbusds.jose.util.X509CertChainUtils import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.SignedJWT import eu.europa.ec.eudi.pidissuer.adapter.out.mdl.mobileDrivingLicenceV1 -import eu.europa.ec.eudi.pidissuer.adapter.out.pid.pidSdJwtVcV1 import eu.europa.ec.eudi.pidissuer.domain.* import eu.europa.ec.eudi.pidissuer.domain.Clock import eu.europa.ec.eudi.pidissuer.loadResource -import eu.europa.ec.eudi.pidissuer.port.input.IssueCredentialError import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext import java.security.cert.X509Certificate import java.util.* import kotlin.test.* -import kotlin.time.Duration.Companion.minutes internal class ValidateJwtProofTest { @@ -251,37 +246,6 @@ internal class ValidateJwtProofTest { assertTrue { result.isLeft() } } - @Test - internal fun `fails when proof is signed with a key not present in attested keys`() = runTest { - val credentialConfiguration = pidSdJwtVcV1( - JWSAlgorithm.ES256, - checkNotNull(ECDSASigner.SUPPORTED_ALGORITHMS.toNonEmptySetOrNull()), - KeyAttestationRequirement.Required(null, null), - ) - val proofSigningKey = ECKeyGenerator(Curve.P_256) - .algorithm(JWSAlgorithm.ES256) - .generate() - val attestedKey = ECKeyGenerator(Curve.P_256) - .algorithm(JWSAlgorithm.ES256) - .generate() - val keyAttestationJwt = generateKeyAttestationJwt(attestedKey, "nonce") - val signedJwtProof = - generateSignedJwt(proofSigningKey, "nonce") { - customParam( - "key_attestation", - keyAttestationJwt.serialize(), - ) - } - val result = - validateJwtProof( - UnvalidatedProof.Jwt(signedJwtProof.serialize()), - credentialConfiguration, - clock.now(), - ) - - assertIs(result.leftOrNull()) - } - private fun generateSignedJwt( key: ECKey, nonce: String, @@ -293,42 +257,14 @@ internal class ValidateJwtProofTest { .apply { headersProvider() } .build() - val now = clock.now() - val exp = now.plus(5.minutes) - val claims = JWTClaimsSet.Builder() .audience(issuer.externalForm) - .issueTime(now.toJavaDate()) - .expirationTime(exp.toJavaDate()) + .issueTime(clock.now().toJavaDate()) .claim("nonce", nonce) .build() return SignedJWT(header, claims).apply { sign(ECDSASigner(key)) } } - - private suspend fun generateKeyAttestationJwt( - attestedKey: ECKey, - nonce: String, - algorithm: JWSAlgorithm = ECDSASigner.SUPPORTED_ALGORITHMS.first(), - ): SignedJWT { - val signingKey = loadKey() - val header = JWSHeader.Builder(algorithm) - .type(JOSEObjectType("key-attestation+jwt")) - .x509CertChain(loadChain().map { Base64.encode(it.encoded) }) - .build() - - val now = clock.now() - val exp = now.plus(5.minutes) - - val claims = JWTClaimsSet.Builder() - .issueTime(now.toJavaDate()) - .expirationTime(exp.toJavaDate()) - .claim("nonce", nonce) - .claim("attested_keys", arrayOf(attestedKey.toPublicJWK().toJSONObject())) - .build() - - return SignedJWT(header, claims).apply { sign(ECDSASigner(signingKey)) } - } } private suspend fun loadChain(): NonEmptyList =