diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ab46496c..250204a2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ Release 3.6.0: - Move `JsonWebKeySet` to library `at.asitplus.crypto:datatypes-jws` - `DefaultVerifierJwsService` may load public keys for verifying JWS from a JWK Set URL in the header, see constructor argument `jwkSetRetriever` (cf. to `OidcSiopWallet`) - `OidcSiopWallet` and `OidcSiopVerifier` implement response mode `direct_post.jwt`, as per OpenID for Verifiable Presentations draft 20 + - `OidcSiopVerifier`: Add constructor parameter `attestationJwt` to create authentication requests as JWS with an Verifier Attestation JWT in header `jwt` (see OpenId4VP draft 20) - `OidcSiopVerifier`: Rename `createAuthnRequestAsRequestObject()` to `createAuthnRequestAsSignedRequestObject()`, also changing the return type - `OidcSiopWallet`: Rename constructor parameter `jwkSetRetriever` to a more general `remoteResourceRetriever`, to use it for various parameters defined by reference - `OidcSiopWallet`: Replace constructor parameter `verifierJwsService` with `requestObjectJwsVerifier` to allow callers to verify JWS objects with a pre-registered key (as in the OpenId4VP client ID scheme "pre-registered") diff --git a/kmp-crypto b/kmp-crypto index 1ba1c1756..839c6d70c 160000 --- a/kmp-crypto +++ b/kmp-crypto @@ -1 +1 @@ -Subproject commit 1ba1c17566a672bea9102db53365d75b820a08d9 +Subproject commit 839c6d70c9990ed2547d523fcc303bc82a3c2481 diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt index 9af2d3452..a3cc96bdb 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt @@ -3,6 +3,7 @@ package at.asitplus.wallet.lib.oidc import at.asitplus.KmmResult import at.asitplus.crypto.datatypes.CryptoPublicKey import at.asitplus.crypto.datatypes.jws.JsonWebKeySet +import at.asitplus.crypto.datatypes.jws.JwsHeader import at.asitplus.crypto.datatypes.jws.JwsSigned import at.asitplus.crypto.datatypes.jws.toJsonWebKey import at.asitplus.wallet.lib.agent.CryptoService @@ -27,6 +28,7 @@ import at.asitplus.wallet.lib.jws.DefaultVerifierJwsService import at.asitplus.wallet.lib.jws.JwsService import at.asitplus.wallet.lib.jws.VerifierJwsService import at.asitplus.wallet.lib.oidc.OpenIdConstants.ClientIdSchemes.REDIRECT_URI +import at.asitplus.wallet.lib.oidc.OpenIdConstants.ClientIdSchemes.VERIFIER_ATTESTATION import at.asitplus.wallet.lib.oidc.OpenIdConstants.ID_TOKEN import at.asitplus.wallet.lib.oidc.OpenIdConstants.PREFIX_DID_KEY import at.asitplus.wallet.lib.oidc.OpenIdConstants.SCOPE_OPENID @@ -62,6 +64,11 @@ class OidcSiopVerifier( private val verifierJwsService: VerifierJwsService, timeLeewaySeconds: Long = 300L, private val clock: Clock = Clock.System, + /** + * Verifier Attestation JWT (from OID4VP) to include (in header `jwt`) when creating request objects as JWS, + * to allow the Wallet to verify the authenticity of this Verifier. + */ + private val attestationJwt: JwsSigned? = null, ) { private val timeLeeway = timeLeewaySeconds.toDuration(DurationUnit.SECONDS) @@ -77,6 +84,7 @@ class OidcSiopVerifier( jwsService: JwsService = DefaultJwsService(cryptoService), timeLeewaySeconds: Long = 300L, clock: Clock = Clock.System, + attestationJwt: JwsSigned? = null, ) = OidcSiopVerifier( verifier = verifier, relyingPartyUrl = relyingPartyUrl, @@ -85,6 +93,7 @@ class OidcSiopVerifier( verifierJwsService = verifierJwsService, timeLeewaySeconds = timeLeewaySeconds, clock = clock, + attestationJwt = attestationJwt, ) } @@ -232,8 +241,12 @@ class OidcSiopVerifier( requestObject.copy(audience = relyingPartyUrl, issuer = relyingPartyUrl) ) val signedJws = jwsService.createSignedJwsAddingParams( + header = JwsHeader( + algorithm = jwsService.algorithm, + attestationJwt = attestationJwt?.serialize(), + ), payload = requestObjectSerialized.encodeToByteArray(), - addKeyId = true + addJsonWebKey = true, ).getOrElse { Napier.w("Could not sign JWS form authnRequest", it) return KmmResult.failure(it) @@ -273,7 +286,7 @@ class OidcSiopVerifier( responseType = "$ID_TOKEN $VP_TOKEN", clientId = relyingPartyUrl, redirectUrl = relyingPartyUrl, - clientIdScheme = REDIRECT_URI, + clientIdScheme = attestationJwt?.let { VERIFIER_ATTESTATION } ?: REDIRECT_URI, scope = listOfNotNull(SCOPE_OPENID, SCOPE_PROFILE, credentialScheme?.vcType).joinToString(" "), nonce = uuid4().toString().also { challengeMutex.withLock { challengeSet += it } }, clientMetadata = metadata, diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt index 4e6fef4e5..9bc393f26 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopInteropTest.kt @@ -1,6 +1,5 @@ package at.asitplus.wallet.lib.oidc -import at.asitplus.crypto.datatypes.jws.JsonWebKeySet import at.asitplus.crypto.datatypes.jws.JweAlgorithm import at.asitplus.crypto.datatypes.jws.JwsAlgorithm import at.asitplus.crypto.datatypes.jws.JwsSigned diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt index f70678421..5c73816eb 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt @@ -1,7 +1,11 @@ package at.asitplus.wallet.lib.oidc import at.asitplus.crypto.datatypes.io.Base64UrlStrict +import at.asitplus.crypto.datatypes.jws.JsonWebKey +import at.asitplus.crypto.datatypes.jws.JsonWebToken +import at.asitplus.crypto.datatypes.jws.JwsHeader import at.asitplus.crypto.datatypes.jws.JwsSigned +import at.asitplus.crypto.datatypes.jws.toJwsAlgorithm import at.asitplus.wallet.lib.agent.CryptoService import at.asitplus.wallet.lib.agent.DefaultCryptoService import at.asitplus.wallet.lib.agent.Holder @@ -11,6 +15,7 @@ import at.asitplus.wallet.lib.agent.Verifier import at.asitplus.wallet.lib.agent.VerifierAgent import at.asitplus.wallet.lib.data.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.jws.DefaultJwsService import at.asitplus.wallet.lib.jws.DefaultVerifierJwsService import at.asitplus.wallet.lib.oidvci.OAuth2Exception import at.asitplus.wallet.lib.oidvci.decodeFromPostBody @@ -32,7 +37,9 @@ import io.kotest.matchers.types.shouldBeInstanceOf import io.ktor.http.* import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString import kotlinx.coroutines.runBlocking +import kotlinx.datetime.Clock import kotlin.random.Random +import kotlin.time.Duration.Companion.seconds class OidcSiopProtocolTest : FreeSpec({ @@ -237,6 +244,63 @@ class OidcSiopProtocolTest : FreeSpec({ } } + "test with request object and Attestation JWT" { + val sprsCryptoService = DefaultCryptoService() + val attestationJwt = buildAttestationJwt(sprsCryptoService, relyingPartyUrl, verifierCryptoService) + verifierSiop = OidcSiopVerifier.newInstance( + verifier = verifierAgent, + cryptoService = verifierCryptoService, + relyingPartyUrl = relyingPartyUrl, + attestationJwt = attestationJwt + ) + val authnRequestWithRequestObject = verifierSiop.createAuthnRequestUrlWithRequestObject( + walletUrl = walletUrl, + credentialScheme = ConstantIndex.AtomicAttribute2023 + ).getOrThrow().also { println(it) } + + + holderSiop = OidcSiopWallet.newInstance( + holder = holderAgent, + cryptoService = holderCryptoService, + requestObjectJwsVerifier = verifierAttestationVerifier(sprsCryptoService.jsonWebKey) + ) + val authnResponse = + holderSiop.createAuthnResponse(authnRequestWithRequestObject).getOrThrow() + authnResponse.shouldBeInstanceOf() + .also { println(it) } + + val result = verifierSiop.validateAuthnResponse(authnResponse.url) + result.shouldBeInstanceOf() + result.vp.verifiableCredentials.shouldNotBeEmpty() + result.vp.verifiableCredentials.forEach { + it.vc.credentialSubject.shouldBeInstanceOf() + } + } + "test with request object and invalid Attestation JWT" { + val sprsCryptoService = DefaultCryptoService() + val attestationJwt = buildAttestationJwt(sprsCryptoService, relyingPartyUrl, verifierCryptoService) + + verifierSiop = OidcSiopVerifier.newInstance( + verifier = verifierAgent, + cryptoService = verifierCryptoService, + relyingPartyUrl = relyingPartyUrl, + attestationJwt = attestationJwt + ) + val authnRequestWithRequestObject = verifierSiop.createAuthnRequestUrlWithRequestObject( + walletUrl = walletUrl, + credentialScheme = ConstantIndex.AtomicAttribute2023 + ).getOrThrow().also { println(it) } + + holderSiop = OidcSiopWallet.newInstance( + holder = holderAgent, + cryptoService = holderCryptoService, + requestObjectJwsVerifier = verifierAttestationVerifier(DefaultCryptoService().jsonWebKey) + ) + shouldThrow { + holderSiop.createAuthnResponse(authnRequestWithRequestObject).getOrThrow() + } + } + "test with request object from request_uri as URL query parameters" { val authnRequest = verifierSiop.createAuthnRequestUrl( walletUrl = walletUrl, @@ -259,8 +323,7 @@ class OidcSiopProtocolTest : FreeSpec({ } ) - val authnResponse = - holderSiop.createAuthnResponse(authRequestUrlWithRequestUri).getOrThrow() + val authnResponse = holderSiop.createAuthnResponse(authRequestUrlWithRequestUri).getOrThrow() authnResponse.shouldBeInstanceOf() .also { println(it) } @@ -291,8 +354,7 @@ class OidcSiopProtocolTest : FreeSpec({ } ) - val authnResponse = - holderSiop.createAuthnResponse(authRequestUrlWithRequestUri).getOrThrow() + val authnResponse = holderSiop.createAuthnResponse(authRequestUrlWithRequestUri).getOrThrow() authnResponse.shouldBeInstanceOf() .also { println(it) } @@ -324,13 +386,43 @@ class OidcSiopProtocolTest : FreeSpec({ ) shouldThrow { - holderSiop.createAuthnResponse(authRequestUrlWithRequestUri).getOrThrow().also { - println(it) - } + holderSiop.createAuthnResponse(authRequestUrlWithRequestUri).getOrThrow() } } }) +private suspend fun buildAttestationJwt( + sprsCryptoService: DefaultCryptoService, + relyingPartyUrl: String, + verifierCryptoService: CryptoService +): JwsSigned = DefaultJwsService(sprsCryptoService).createSignedJws( + header = JwsHeader( + algorithm = sprsCryptoService.algorithm.toJwsAlgorithm(), + ), + payload = JsonWebToken( + issuer = "sprs", // allows Wallet to determine the issuer's key + subject = relyingPartyUrl, + issuedAt = Clock.System.now(), + expiration = Clock.System.now().plus(10.seconds), + notBefore = Clock.System.now(), + confirmationKey = verifierCryptoService.jsonWebKey, + ).serialize().encodeToByteArray() +).getOrThrow() + +private fun verifierAttestationVerifier(trustedKey: JsonWebKey) = + object : RequestObjectJwsVerifier { + override fun invoke(jws: JwsSigned, authnRequest: AuthenticationRequestParameters): Boolean { + val attestationJwt = jws.header.attestationJwt?.let { JwsSigned.parse(it) } + ?: return false + val verifierJwsService = DefaultVerifierJwsService() + if (!verifierJwsService.verifyJws(attestationJwt, trustedKey)) + return false + val verifierPublicKey = JsonWebToken.deserialize(attestationJwt.payload.decodeToString()) + .getOrNull()?.confirmationKey ?: return false + return verifierJwsService.verifyJws(jws, verifierPublicKey) + } + } + private suspend fun verifySecondProtocolRun( verifierSiop: OidcSiopVerifier, walletUrl: String,