diff --git a/waltid-libraries/credentials/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/schemes/JwsSignatureScheme.kt b/waltid-libraries/credentials/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/schemes/JwsSignatureScheme.kt index b6254a539..988145eee 100644 --- a/waltid-libraries/credentials/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/schemes/JwsSignatureScheme.kt +++ b/waltid-libraries/credentials/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/schemes/JwsSignatureScheme.kt @@ -1,10 +1,13 @@ package id.walt.credentials.schemes +import id.walt.crypto.exceptions.VerificationException import id.walt.crypto.keys.Key import id.walt.crypto.utils.JsonUtils.toJsonObject import id.walt.crypto.utils.JwsUtils.decodeJws import id.walt.did.dids.DidService import id.walt.did.dids.DidUtils +import id.walt.sdjwt.JWTCryptoProvider +import id.walt.sdjwt.SDJwt import io.github.oshai.kotlinlogging.KotlinLogging import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -36,6 +39,8 @@ class JwsSignatureScheme : SignatureScheme { const val VC = "vc" } + data class KeyInfo(val keyId: String, val key: Key) + fun toPayload(data: JsonObject, jwtOptions: Map = emptyMap()) = mapOf( JwsOption.ISSUER to jwtOptions[JwsOption.ISSUER], @@ -44,6 +49,29 @@ class JwsSignatureScheme : SignatureScheme { *(jwtOptions.entries.map { it.toPair() }.toTypedArray()) ).toJsonObject() + @JvmBlocking + @JvmAsync + @JsPromise + @JsExport.Ignore + suspend fun getIssuerKeyInfo(jws: String): KeyInfo { + val jwsParsed = jws.decodeJws() + val keyId = jwsParsed.header[JwsHeader.KEY_ID]!!.jsonPrimitive.content + val issuerId = (jwsParsed.payload[JwsOption.ISSUER]?.jsonPrimitive?.content ?: keyId) + val key = if (DidUtils.isDidUrl(issuerId)) { + log.trace { "Resolving key from issuer did: $issuerId" } + DidService.resolveToKey(issuerId) + .also { + if (log.isTraceEnabled()) { + val exportedJwk = it.getOrNull()?.getPublicKey()?.exportJWK() + log.trace { "Imported key: $it from did: $issuerId, public is: $exportedJwk" } + } + } + .getOrThrow() + } else + TODO("Issuer IDs other than DIDs are currently not supported for W3C credentials.") + return KeyInfo(keyId, key) + } + /** * args: * - kid: Key ID @@ -73,32 +101,21 @@ class JwsSignatureScheme : SignatureScheme { @JsPromise @JsExport.Ignore suspend fun verify(data: String): Result = runCatching { - val jws = data.decodeJws() - - val header = jws.header - val payload = jws.payload - - val issuerDid = (payload[JwsOption.ISSUER] ?: header[JwsHeader.KEY_ID])!!.jsonPrimitive.content - if (DidUtils.isDidUrl(issuerDid)) { - verifyForIssuerDid(issuerDid, data) - } else { - TODO() - } + val keyInfo = getIssuerKeyInfo(data) + return keyInfo.key.verifyJws(data.split("~")[0]) + .also { log.trace { "Verification result: $it" } } } - private suspend fun verifyForIssuerDid(issuerDid: String, data: String): JsonElement { - log.trace { "Verifying with issuer did: $issuerDid" } - - return DidService.resolveToKey(issuerDid) - .also { - if (log.isTraceEnabled()) { - val exportedJwk = it.getOrNull()?.getPublicKey()?.exportJWK() - log.trace { "Imported key: $it from did: $issuerDid, public is: $exportedJwk" } - } - } - .getOrThrow() - .verifyJws(data.split("~")[0]) - .also { log.trace { "Verification result: $it" } } - .getOrThrow() + @JvmBlocking + @JvmAsync + @JsPromise + @JsExport.Ignore + suspend fun verifySDJwt(data: String, jwtCryptoProvider: JWTCryptoProvider): Result = runCatching { + return SDJwt.verifyAndParse(data, jwtCryptoProvider).let { + if(it.verified) + Result.success(it.sdJwt.fullPayload) + else + Result.failure(VerificationException(it.message ?: "Verification failed")) + } } } diff --git a/waltid-libraries/credentials/waltid-verification-policies/src/commonMain/kotlin/id/walt/policies/policies/JwtSignaturePolicy.kt b/waltid-libraries/credentials/waltid-verification-policies/src/commonMain/kotlin/id/walt/policies/policies/JwtSignaturePolicy.kt index ed0f33060..9974c9e20 100644 --- a/waltid-libraries/credentials/waltid-verification-policies/src/commonMain/kotlin/id/walt/policies/policies/JwtSignaturePolicy.kt +++ b/waltid-libraries/credentials/waltid-verification-policies/src/commonMain/kotlin/id/walt/policies/policies/JwtSignaturePolicy.kt @@ -3,7 +3,7 @@ package id.walt.policies.policies import id.walt.credentials.schemes.JwsSignatureScheme import id.walt.credentials.utils.VCFormat import id.walt.policies.JwtVerificationPolicy -import id.walt.sdjwt.SDJwtVC +import id.walt.sdjwt.SDJwt import kotlinx.serialization.Serializable import love.forte.plugin.suspendtrans.annotation.JsPromise import love.forte.plugin.suspendtrans.annotation.JvmAsync @@ -26,6 +26,17 @@ class JwtSignaturePolicy : JwtVerificationPolicy( @JsPromise @JsExport.Ignore override suspend fun verify(credential: String, args: Any?, context: Map): Result { - return JwsSignatureScheme().verify(credential) + return JwsSignatureScheme().let { + if(SDJwt.isSDJwt(credential, sdOnly = true)) { + val keyInfo = it.getIssuerKeyInfo(credential) + it.verifySDJwt( + credential, JWTCryptoProviderManager.getDefaultJWTCryptoProvider( + mapOf(keyInfo.keyId to keyInfo.key) + ) + ) + } + else + it.verify(credential) + } } } diff --git a/waltid-libraries/credentials/waltid-verification-policies/src/commonMain/kotlin/id/walt/policies/policies/SdJwtVCSignaturePolicy.kt b/waltid-libraries/credentials/waltid-verification-policies/src/commonMain/kotlin/id/walt/policies/policies/SdJwtVCSignaturePolicy.kt index 411e08fe5..750938729 100644 --- a/waltid-libraries/credentials/waltid-verification-policies/src/commonMain/kotlin/id/walt/policies/policies/SdJwtVCSignaturePolicy.kt +++ b/waltid-libraries/credentials/waltid-verification-policies/src/commonMain/kotlin/id/walt/policies/policies/SdJwtVCSignaturePolicy.kt @@ -3,6 +3,7 @@ package id.walt.policies.policies import id.walt.credentials.schemes.JwsSignatureScheme import id.walt.credentials.utils.VCFormat import id.walt.credentials.utils.randomUUID +import id.walt.crypto.exceptions.VerificationException import id.walt.crypto.keys.Key import id.walt.crypto.keys.jwk.JWKKey import id.walt.crypto.utils.JsonUtils.toJsonElement @@ -58,7 +59,12 @@ class SdJwtVCSignaturePolicy(): JwtVerificationPolicy() { requiresHolderKeyBinding = true, context["clientId"]?.toString(), context["challenge"]?.toString() - ).let { Result.success(sdJwtVC.undisclosedPayload) } + ).let { + if(it.verified) + Result.success(sdJwtVC.undisclosedPayload) + else + Result.failure(VerificationException("SD-JWT verification failed")) + } } } diff --git a/waltid-libraries/sdjwt/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDJwt.kt b/waltid-libraries/sdjwt/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDJwt.kt index dfc75e937..de9558d21 100644 --- a/waltid-libraries/sdjwt/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDJwt.kt +++ b/waltid-libraries/sdjwt/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDJwt.kt @@ -145,7 +145,7 @@ open class SDJwt internal constructor( * @param jwtCryptoProvider JWT crypto provider, that implements standard JWT token verification on the target platform */ fun verify(jwtCryptoProvider: JWTCryptoProvider, keyID: String? = null): VerificationResult { - return jwtCryptoProvider.verify(jwt, keyID).let { + return jwtCryptoProvider.verify(jwt, keyID ?: this.keyID).let { VerificationResult( sdJwt = this, signatureVerified = it.verified, @@ -265,8 +265,8 @@ open class SDJwt internal constructor( /** * Check the given string, whether it matches the pattern of an SD-JWT */ - fun isSDJwt(value: String): Boolean { - return Regex(SD_JWT_PATTERN).matches(value) + fun isSDJwt(value: String, sdOnly: Boolean = false): Boolean { + return Regex(SD_JWT_PATTERN).matches(value) && (!sdOnly || value.contains("~")) } } } diff --git a/waltid-libraries/sdjwt/waltid-sdjwt/src/jvmTest/kotlin/id/walt/sdjwt/SDJwtTestJVM.kt b/waltid-libraries/sdjwt/waltid-sdjwt/src/jvmTest/kotlin/id/walt/sdjwt/SDJwtTestJVM.kt index 4cda21f82..fa0d204b4 100644 --- a/waltid-libraries/sdjwt/waltid-sdjwt/src/jvmTest/kotlin/id/walt/sdjwt/SDJwtTestJVM.kt +++ b/waltid-libraries/sdjwt/waltid-sdjwt/src/jvmTest/kotlin/id/walt/sdjwt/SDJwtTestJVM.kt @@ -8,6 +8,8 @@ import korlibs.crypto.SHA256 import korlibs.crypto.encoding.ASCII import kotlinx.datetime.Clock import kotlinx.serialization.json.* +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.test.* class SDJwtTestJVM { @@ -99,13 +101,31 @@ class SDJwtTestJVM { val isValid = parsedUndisclosedJwt.verify(cryptoProvider).verified println("Undisclosed SD-JWT verified: $isValid") + val disclosedJwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~WyJ4RFk5VjBtOG43am82ZURIUGtNZ1J3Iiwic3ViIiwiMTIzIl0~" val parsedDisclosedJwtVerifyResult = SDJwt.verifyAndParse( - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~WyJ4RFk5VjBtOG43am82ZURIUGtNZ1J3Iiwic3ViIiwiMTIzIl0~", + disclosedJwt, cryptoProvider ) // print full payload with disclosed fields println("Disclosed JWT payload:") println(parsedDisclosedJwtVerifyResult.sdJwt.fullPayload.toString()) + + val forgedDisclosure = parsedDisclosedJwtVerifyResult.sdJwt.jwt + "~" + forgeDislosure(parsedDisclosedJwtVerifyResult.sdJwt.disclosureObjects.first()) + val forgedDisclosureVerifyResult = SDJwt.verifyAndParse( + forgedDisclosure, cryptoProvider + ) + assertFalse(forgedDisclosureVerifyResult.verified) + assertTrue(forgedDisclosureVerifyResult.signatureVerified) + assertFalse(forgedDisclosureVerifyResult.disclosuresVerified) + } + + @OptIn(ExperimentalEncodingApi::class) + fun forgeDislosure(disclosure: SDisclosure): String { + return Base64.UrlSafe.encode(buildJsonArray { + add(disclosure.salt) + add(disclosure.key) + add(JsonPrimitive("")) + }.toString().encodeToByteArray()).trimEnd('=') } @Test diff --git a/waltid-services/waltid-e2e-tests/src/test/kotlin/ExchangeExternalSignaturesApi.kt b/waltid-services/waltid-e2e-tests/src/test/kotlin/ExchangeExternalSignaturesApi.kt index eac11b37f..0b0c81a31 100644 --- a/waltid-services/waltid-e2e-tests/src/test/kotlin/ExchangeExternalSignaturesApi.kt +++ b/waltid-services/waltid-e2e-tests/src/test/kotlin/ExchangeExternalSignaturesApi.kt @@ -25,6 +25,7 @@ import id.walt.oid4vc.data.OpenId4VPProfile import id.walt.oid4vc.data.ProofType import id.walt.sdjwt.SDField import id.walt.sdjwt.SDMap +import id.walt.sdjwt.SDisclosure import id.walt.sdjwt.utils.Base64Utils.encodeToBase64Url import id.walt.verifier.oidc.RequestedCredential import id.walt.webwallet.db.models.WalletCredential @@ -49,6 +50,9 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.decodeFromByteArray import kotlinx.serialization.json.* +import sun.font.StrikeCache +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.test.assertEquals import kotlin.test.assertNotEquals import kotlin.test.assertNotNull @@ -326,6 +330,7 @@ class ExchangeExternalSignatures { ) testOID4VP(openbadgeSdJwtPresentationRequest) testOID4VP(openbadgeSdJwtPresentationRequest, true) + testOID4VP(openbadgeSdJwtPresentationRequest, true, true) clearWalletCredentials() } @@ -338,6 +343,7 @@ class ExchangeExternalSignatures { ) testOID4VPSdJwtVc() testOID4VPSdJwtVc(true) + testOID4VPSdJwtVc(true, true) clearWalletCredentials() testPreAuthorizedOID4VCI( useOptionalParameters = false, @@ -490,6 +496,7 @@ class ExchangeExternalSignatures { private suspend fun testOID4VP( presentationRequest: String, addDisclosures: Boolean = false, + forgeDisclosures: Boolean = false, ) { lateinit var presentationRequestURL: String lateinit var verificationID: String @@ -522,7 +529,9 @@ class ExchangeExternalSignatures { presentationRequest = presentationRequestURL, selectedCredentialIdList = matchedCredentialList.map { it.id }, disclosures = if (addDisclosures) matchedCredentialList.filter { it.disclosures != null }.associate { - Pair(it.id, listOf(it.disclosures!!)) + Pair(it.id, listOf( + if(forgeDisclosures) forgeSDisclosureString(it.disclosures!!) else it.disclosures!! + )) } else null, ) println(prepareRequest) @@ -530,10 +539,12 @@ class ExchangeExternalSignatures { setBody(prepareRequest) }.expectSuccess() val prepareResponse = response.body() - client.post("/wallet-api/wallet/$walletId/exchange/external_signatures/presentation/submit") { + val submitResponse = client.post("/wallet-api/wallet/$walletId/exchange/external_signatures/presentation/submit") { setBody(SubmitOID4VPRequest.build(prepareResponse, disclosures = if (addDisclosures) matchedCredentialList.filter { it.disclosures != null }.associate { - Pair(it.id, listOf(it.disclosures!!)) + Pair(it.id, listOf( + if(forgeDisclosures) forgeSDisclosureString(it.disclosures!!) else it.disclosures!! + )) } else null, w3cJwtVpProof = prepareResponse.w3CJwtVpProofParameters?.let { params -> holderKey.signJws( @@ -550,12 +561,16 @@ class ExchangeExternalSignatures { ) }) ) - }.expectSuccess() + } + if(!forgeDisclosures) + submitResponse.expectSuccess() + else + submitResponse.expectFailure() verifierSessionApi.get(verificationID) { sessionInfo -> assert(sessionInfo.tokenResponse?.vpToken?.jsonPrimitive?.contentOrNull?.expectLooksLikeJwt() != null) { "Received no valid token response!" } assert(sessionInfo.tokenResponse?.presentationSubmission != null) { "should have a presentation submission after submission" } - assert(sessionInfo.verificationResult == true) { "overall verification should be valid" } + assert(sessionInfo.verificationResult == !forgeDisclosures) { "overall verification should be ${!forgeDisclosures}" } sessionInfo.policyResults.let { require(it != null) { "policyResults should be available after running policies" } assert(it.size > 1) { "no policies have run" } @@ -565,6 +580,7 @@ class ExchangeExternalSignatures { private suspend fun testOID4VPSdJwtVc( addDisclosures: Boolean = false, + forgeDisclosures: Boolean = false, ) { lateinit var presentationRequestURL: String lateinit var resolvedPresentationRequestURL: String @@ -611,7 +627,9 @@ class ExchangeExternalSignatures { presentationRequest = presentationRequestURL, selectedCredentialIdList = matchedCredentialList.map { it.id }, disclosures = if (addDisclosures) matchedCredentialList.filter { it.disclosures != null }.associate { - Pair(it.id, listOf(it.disclosures!!)) + Pair(it.id, listOf( + if(forgeDisclosures) forgeSDisclosureString(it.disclosures!!) else it.disclosures!! + )) } else null, ) println(prepareRequest) @@ -619,10 +637,12 @@ class ExchangeExternalSignatures { setBody(prepareRequest) }.expectSuccess() val prepareResponse = response.body() - client.post("/wallet-api/wallet/$walletId/exchange/external_signatures/presentation/submit") { + val submitResponse = client.post("/wallet-api/wallet/$walletId/exchange/external_signatures/presentation/submit") { setBody(SubmitOID4VPRequest.build(prepareResponse, disclosures = if (addDisclosures) matchedCredentialList.filter { it.disclosures != null }.associate { - Pair(it.id, listOf(it.disclosures!!)) + Pair(it.id, listOf( + if(forgeDisclosures) forgeSDisclosureString(it.disclosures!!) else it.disclosures!! + )) } else null, w3cJwtVpProof = prepareResponse.w3CJwtVpProofParameters?.let { params -> holderKey.signJws( @@ -639,12 +659,16 @@ class ExchangeExternalSignatures { ) }) ) - }.expectSuccess() + } + if(!forgeDisclosures) + submitResponse.expectSuccess() + else + submitResponse.expectFailure() verifierSessionApi.get(verificationID) { sessionInfo -> // assert(sessionInfo.tokenResponse?.vpToken?.jsonPrimitive?.contentOrNull?.expectLooksLikeJwt() != null) { "Received no valid token response!" } assert(sessionInfo.tokenResponse?.presentationSubmission != null) { "should have a presentation submission after submission" } - assert(sessionInfo.verificationResult == true) { "overall verification should be valid" } + assert(sessionInfo.verificationResult == !forgeDisclosures) { "overall verification should be ${!forgeDisclosures}" } sessionInfo.policyResults.let { require(it != null) { "policyResults should be available after running policies" } assert(it.size > 1) { "no policies have run" } @@ -748,4 +772,15 @@ class ExchangeExternalSignatures { } } } + + @OptIn(ExperimentalEncodingApi::class) + fun forgeSDisclosureString(disclosures: String): String { + return disclosures.split("~").filter { it.isNotEmpty() }.map { SDisclosure.parse(it) }.map { disclosure -> + Base64.UrlSafe.encode(buildJsonArray { + add(disclosure.salt) + add(disclosure.key) + add(JsonPrimitive("")) + }.toString().encodeToByteArray()).trimEnd('=') + }.joinToString("~") + } } diff --git a/waltid-services/waltid-wallet-api/src/test/kotlin/id/walt/webwallet/utils/X5CValidatorTest.kt b/waltid-services/waltid-wallet-api/src/test/kotlin/id/walt/webwallet/utils/X5CValidatorTest.kt index cc50284af..8ec91bd5e 100644 --- a/waltid-services/waltid-wallet-api/src/test/kotlin/id/walt/webwallet/utils/X5CValidatorTest.kt +++ b/waltid-services/waltid-wallet-api/src/test/kotlin/id/walt/webwallet/utils/X5CValidatorTest.kt @@ -17,7 +17,7 @@ class X5CValidatorTest { //we don't care about the bit size of the key, it's a test case (as long as it's bigger than 512) private val keyPairGenerator = KeyPairGenerator .getInstance("RSA").apply { - initialize(1024) + initialize(2048) } //x.509 certificate expiration dates