Skip to content

Commit

Permalink
Merge pull request #839 from walt-id/wal-313-fix-sd-property-check
Browse files Browse the repository at this point in the history
Wal 313 fix sd property check
  • Loading branch information
philpotisk authored Dec 10, 2024
2 parents c58dfba + 2d506c5 commit 4de8aaf
Show file tree
Hide file tree
Showing 7 changed files with 132 additions and 43 deletions.
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<String, JsonElement> = emptyMap()) =
mapOf(
JwsOption.ISSUER to jwtOptions[JwsOption.ISSUER],
Expand All @@ -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
Expand Down Expand Up @@ -73,32 +101,21 @@ class JwsSignatureScheme : SignatureScheme {
@JsPromise
@JsExport.Ignore
suspend fun verify(data: String): Result<JsonElement> = 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<JsonElement> = runCatching {
return SDJwt.verifyAndParse(data, jwtCryptoProvider).let {
if(it.verified)
Result.success(it.sdJwt.fullPayload)
else
Result.failure(VerificationException(it.message ?: "Verification failed"))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +26,17 @@ class JwtSignaturePolicy : JwtVerificationPolicy(
@JsPromise
@JsExport.Ignore
override suspend fun verify(credential: String, args: Any?, context: Map<String, Any>): Result<Any> {
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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SDJwt> {
return jwtCryptoProvider.verify(jwt, keyID).let {
return jwtCryptoProvider.verify(jwt, keyID ?: this.keyID).let {
VerificationResult(
sdJwt = this,
signatureVerified = it.verified,
Expand Down Expand Up @@ -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("~"))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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("<forged>"))
}.toString().encodeToByteArray()).trimEnd('=')
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -326,6 +330,7 @@ class ExchangeExternalSignatures {
)
testOID4VP(openbadgeSdJwtPresentationRequest)
testOID4VP(openbadgeSdJwtPresentationRequest, true)
testOID4VP(openbadgeSdJwtPresentationRequest, true, true)
clearWalletCredentials()
}

Expand All @@ -338,6 +343,7 @@ class ExchangeExternalSignatures {
)
testOID4VPSdJwtVc()
testOID4VPSdJwtVc(true)
testOID4VPSdJwtVc(true, true)
clearWalletCredentials()
testPreAuthorizedOID4VCI(
useOptionalParameters = false,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -522,18 +529,22 @@ 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)
response = client.post("/wallet-api/wallet/$walletId/exchange/external_signatures/presentation/prepare") {
setBody(prepareRequest)
}.expectSuccess()
val prepareResponse = response.body<PrepareOID4VPResponse>()
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(
Expand All @@ -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" }
Expand All @@ -565,6 +580,7 @@ class ExchangeExternalSignatures {

private suspend fun testOID4VPSdJwtVc(
addDisclosures: Boolean = false,
forgeDisclosures: Boolean = false,
) {
lateinit var presentationRequestURL: String
lateinit var resolvedPresentationRequestURL: String
Expand Down Expand Up @@ -611,18 +627,22 @@ 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)
response = client.post("/wallet-api/wallet/$walletId/exchange/external_signatures/presentation/prepare") {
setBody(prepareRequest)
}.expectSuccess()
val prepareResponse = response.body<PrepareOID4VPResponse>()
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(
Expand All @@ -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" }
Expand Down Expand Up @@ -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("<forged>"))
}.toString().encodeToByteArray()).trimEnd('=')
}.joinToString("~")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 4de8aaf

Please sign in to comment.