Skip to content

Commit 892f585

Browse files
committed
fix(oid4vp): enforce final request constraints
1 parent 04beebf commit 892f585

9 files changed

Lines changed: 168 additions & 64 deletions

File tree

waltid-libraries/credentials/waltid-dcql/src/commonMain/kotlin/id/walt/dcql/DcqlMatcher.kt

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -344,16 +344,18 @@ object DcqlMatcher {
344344

345345
/** Match a Claims Path Pointer against the disclosure's complete resolved location. */
346346
fun DcqlDisclosure.matchesPath(queryPath: List<JsonElement>): Boolean {
347-
val normalizedQueryPath = queryPath.dropWhile { segment ->
348-
segment is JsonPrimitive && segment.isString && segment.content == "$"
349-
}
350347
val disclosurePath = location ?: name
351-
?.takeIf { normalizedQueryPath.size == 1 }
348+
?.takeIf { queryPath.size == 1 }
352349
?.let { listOf(JsonPrimitive(it)) }
353350
?: return false
354-
if (normalizedQueryPath.size != disclosurePath.size) return false
355-
return normalizedQueryPath.zip(disclosurePath).all { (querySegment, actualSegment) ->
356-
querySegment is JsonNull || querySegment == actualSegment
351+
if (queryPath.size != disclosurePath.size) return false
352+
return queryPath.zip(disclosurePath).all { (querySegment, actualSegment) ->
353+
when (querySegment) {
354+
is JsonNull -> actualSegment is JsonPrimitive &&
355+
!actualSegment.isString &&
356+
actualSegment.intOrNull?.let { it >= 0 } == true
357+
else -> querySegment == actualSegment
358+
}
357359
}
358360
}
359361

waltid-libraries/credentials/waltid-dcql/src/commonTest/kotlin/id/walt/dcql/DcqlMatcherTest.kt

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package id.walt.dcql
33
import kotlinx.serialization.json.*
44
import kotlin.test.Test
55
import kotlin.test.assertEquals
6+
import kotlin.test.assertFalse
67
import kotlin.test.assertIs
78
import kotlin.test.assertTrue
89

@@ -320,6 +321,46 @@ class DcqlMatcherTest {
320321
assertEquals(employeeName, selected)
321322
}
322323

324+
@Test
325+
fun sdJwtDisclosureMatchingUsesExactClaimsPathPointerSemantics() {
326+
val objectPropertyDisclosure = DcqlDisclosure(
327+
name = "name",
328+
value = JsonPrimitive("Alice"),
329+
location = listOf(JsonPrimitive("name")),
330+
)
331+
val privateSsnDisclosure = DcqlDisclosure(
332+
name = "ssn",
333+
value = JsonPrimitive("123-45-6789"),
334+
location = listOf(JsonPrimitive("address"), JsonPrimitive("private"), JsonPrimitive("ssn")),
335+
)
336+
val arrayDisclosure = DcqlDisclosure(
337+
name = "name",
338+
value = JsonPrimitive("Alice"),
339+
location = listOf(JsonPrimitive("people"), JsonPrimitive(0), JsonPrimitive("name")),
340+
)
341+
342+
assertFalse(
343+
DcqlMatcher.run {
344+
objectPropertyDisclosure.matchesPath(listOf(JsonPrimitive("$"), JsonPrimitive("name")))
345+
},
346+
"A leading dollar sign is an ordinary property name, not a root marker",
347+
)
348+
assertFalse(
349+
DcqlMatcher.run {
350+
privateSsnDisclosure.matchesPath(
351+
listOf(JsonPrimitive("address"), JsonNull, JsonPrimitive("ssn"))
352+
)
353+
},
354+
"The null wildcard must not match an object-property segment",
355+
)
356+
assertTrue(
357+
DcqlMatcher.run {
358+
arrayDisclosure.matchesPath(listOf(JsonPrimitive("people"), JsonNull, JsonPrimitive("name")))
359+
},
360+
"The null wildcard matches a non-negative array index",
361+
)
362+
}
363+
323364
@Test
324365
fun trustedAuthoritiesFailsClosedWithoutChecker() {
325366
val query = DcqlParser.parse(

waltid-libraries/protocols/waltid-openid4vc-wallet/src/commonTest/kotlin/id/walt/wallet2/handlers/WalletPresentationHandlerRequirementsTest.kt

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -475,8 +475,8 @@ class WalletPresentationHandlerRequirementsTest {
475475

476476
@Test
477477
fun disclosureSelectionKeepsRequiredClaimsWhenFilteringDisclosures() {
478-
val givenName = claimPath("$", "given_name")
479-
val ageOver18 = claimPath("$", "age_over_18")
478+
val givenName = claimPath("given_name")
479+
val ageOver18 = claimPath("age_over_18")
480480
val matched = matchResult(
481481
"pid" to "cred-1",
482482
selectedDisclosures = mapOf(
@@ -487,8 +487,8 @@ class WalletPresentationHandlerRequirementsTest {
487487
credentialQuery(
488488
queryId,
489489
claims = listOf(
490-
ClaimsQuery(id = "given_name", pathStrings = listOf("$", "given_name")),
491-
ClaimsQuery(id = "age_over_18", pathStrings = listOf("$", "age_over_18")),
490+
ClaimsQuery(id = "given_name", pathStrings = listOf("given_name")),
491+
ClaimsQuery(id = "age_over_18", pathStrings = listOf("age_over_18")),
492492
),
493493
)
494494
},
@@ -511,9 +511,9 @@ class WalletPresentationHandlerRequirementsTest {
511511

512512
@Test
513513
fun disclosureSelectionFiltersOptionalSelectivelyDisclosableClaimsOnly() {
514-
val givenName = claimPath("$", "given_name")
515-
val ageOver18 = claimPath("$", "age_over_18")
516-
val vct = claimPath("$", "vct")
514+
val givenName = claimPath("given_name")
515+
val ageOver18 = claimPath("age_over_18")
516+
val vct = claimPath("vct")
517517
val matched = matchResult(
518518
"pid" to "cred-1",
519519
selectedDisclosures = mapOf(
@@ -525,9 +525,9 @@ class WalletPresentationHandlerRequirementsTest {
525525
credentialQuery(
526526
queryId,
527527
claims = listOf(
528-
ClaimsQuery(id = "given_name", pathStrings = listOf("$", "given_name")),
529-
ClaimsQuery(id = "age_over_18", pathStrings = listOf("$", "age_over_18")),
530-
ClaimsQuery(id = "vct", pathStrings = listOf("$", "vct")),
528+
ClaimsQuery(id = "given_name", pathStrings = listOf("given_name")),
529+
ClaimsQuery(id = "age_over_18", pathStrings = listOf("age_over_18")),
530+
ClaimsQuery(id = "vct", pathStrings = listOf("vct")),
531531
),
532532
claimSets = listOf(
533533
listOf("given_name", "vct"),
@@ -552,9 +552,9 @@ class WalletPresentationHandlerRequirementsTest {
552552

553553
@Test
554554
fun presentationPreviewIncludesOptionalClaimSetDisclosuresWhenMatcherSelectedMinimalSet() {
555-
val givenName = claimPath("$", "given_name")
556-
val familyName = claimPath("$", "family_name")
557-
val birthDate = claimPath("$", "birth_date")
555+
val givenName = claimPath("given_name")
556+
val familyName = claimPath("family_name")
557+
val birthDate = claimPath("birth_date")
558558
val matched = matchResult(
559559
"pid" to "cred-1",
560560
selectedDisclosures = mapOf(
@@ -580,9 +580,9 @@ class WalletPresentationHandlerRequirementsTest {
580580

581581
@Test
582582
fun disclosureSelectionCanIncludeOptionalClaimSetDisclosureWhenMatcherSelectedMinimalSet() {
583-
val givenName = claimPath("$", "given_name")
584-
val familyName = claimPath("$", "family_name")
585-
val birthDate = claimPath("$", "birth_date")
583+
val givenName = claimPath("given_name")
584+
val familyName = claimPath("family_name")
585+
val birthDate = claimPath("birth_date")
586586
val matched = matchResult(
587587
"pid" to "cred-1",
588588
selectedDisclosures = mapOf(
@@ -614,8 +614,8 @@ class WalletPresentationHandlerRequirementsTest {
614614

615615
@Test
616616
fun disclosureSelectionOmitsSelectivelyDisclosableClaimsWhenNoClaimsAreRequested() {
617-
val givenName = claimPath("$", "given_name")
618-
val vct = claimPath("$", "vct")
617+
val givenName = claimPath("given_name")
618+
val vct = claimPath("vct")
619619
val matched = matchResult(
620620
"pid" to "cred-1",
621621
selectedDisclosures = mapOf(
@@ -637,9 +637,9 @@ class WalletPresentationHandlerRequirementsTest {
637637

638638
@Test
639639
fun disclosureSelectionRejectsOptionalSubsetThatDoesNotSatisfyClaimSets() {
640-
val givenName = claimPath("$", "given_name")
641-
val ageOver18 = claimPath("$", "age_over_18")
642-
val portrait = claimPath("$", "portrait")
640+
val givenName = claimPath("given_name")
641+
val ageOver18 = claimPath("age_over_18")
642+
val portrait = claimPath("portrait")
643643
val matched = matchResult(
644644
"pid" to "cred-1",
645645
selectedDisclosures = mapOf(
@@ -651,9 +651,9 @@ class WalletPresentationHandlerRequirementsTest {
651651
credentialQuery(
652652
queryId,
653653
claims = listOf(
654-
ClaimsQuery(id = "given_name", pathStrings = listOf("$", "given_name")),
655-
ClaimsQuery(id = "age_over_18", pathStrings = listOf("$", "age_over_18")),
656-
ClaimsQuery(id = "portrait", pathStrings = listOf("$", "portrait")),
654+
ClaimsQuery(id = "given_name", pathStrings = listOf("given_name")),
655+
ClaimsQuery(id = "age_over_18", pathStrings = listOf("age_over_18")),
656+
ClaimsQuery(id = "portrait", pathStrings = listOf("portrait")),
657657
),
658658
claimSets = listOf(
659659
listOf("given_name", "age_over_18"),
@@ -773,9 +773,9 @@ class WalletPresentationHandlerRequirementsTest {
773773
credentialQuery(
774774
queryId,
775775
claims = listOf(
776-
ClaimsQuery(id = "given_name", pathStrings = listOf("$", "given_name")),
777-
ClaimsQuery(id = "family_name", pathStrings = listOf("$", "family_name")),
778-
ClaimsQuery(id = "birth_date", pathStrings = listOf("$", "birth_date")),
776+
ClaimsQuery(id = "given_name", pathStrings = listOf("given_name")),
777+
ClaimsQuery(id = "family_name", pathStrings = listOf("family_name")),
778+
ClaimsQuery(id = "birth_date", pathStrings = listOf("birth_date")),
779779
),
780780
claimSets = listOf(
781781
listOf("given_name", "family_name"),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package id.walt.openid4vp.clientidprefix
2+
3+
import id.walt.crypto.keys.KeyType
4+
5+
/**
6+
* Enforces the JOSE algorithm and certificate public-key binding before signature verification.
7+
*
8+
* This check is intentionally independent of the crypto backend: some backends retry malformed
9+
* ECDSA JWS signatures using an algorithm inferred from the key type, which must never allow the
10+
* Request Object's declared `alg` to be ignored.
11+
*/
12+
internal fun requestObjectAlgorithmMatchesKey(algorithm: String?, keyType: KeyType): Boolean =
13+
algorithm == keyType.jwsAlg

waltid-libraries/protocols/waltid-openid4vp-clientidprefix/src/commonMain/kotlin/id/walt/openid4vp/clientidprefix/prefixes/X509Hash.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
package id.walt.openid4vp.clientidprefix.prefixes
22

33
import id.walt.crypto.keys.jwk.JWKKey
4-
import id.walt.crypto.keys.KeyType
54
import id.walt.crypto.utils.Base64Utils.decodeFromBase64
65
import id.walt.crypto.utils.Base64Utils.encodeToBase64Url
76
import id.walt.crypto.utils.JwsUtils.decodeJws
87
import id.walt.openid4vp.clientidprefix.ClientIdError
98
import id.walt.openid4vp.clientidprefix.ClientValidationResult
109
import id.walt.openid4vp.clientidprefix.RequestContext
10+
import id.walt.openid4vp.clientidprefix.requestObjectAlgorithmMatchesKey
1111
import id.walt.x509.CertificateDer
1212
import id.walt.x509.validateCertificateChain
1313
import id.walt.x509.platformSupportsPkixCertificatePathValidation
@@ -76,7 +76,7 @@ data class X509Hash(val hash: String, override val rawValue: String) : ClientId
7676
// Verify the JWS independently so signature failures are not reported as trust failures.
7777
val key = runCatching { JWKKey.importFromDerCertificate(leafCertificate.bytes.toByteArray()).getOrThrow() }
7878
.getOrElse { return ClientValidationResult.Failure(ClientIdError.InvalidSignature) }
79-
if (requestObjectAlgorithm == "ES256" && key.keyType != KeyType.secp256r1) {
79+
if (!requestObjectAlgorithmMatchesKey(requestObjectAlgorithm, key.keyType)) {
8080
return ClientValidationResult.Failure(ClientIdError.InvalidSignature)
8181
}
8282
key.verifyJws(jws).getOrElse {

waltid-libraries/protocols/waltid-openid4vp-clientidprefix/src/commonMain/kotlin/id/walt/openid4vp/clientidprefix/prefixes/X509SanDns.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
package id.walt.openid4vp.clientidprefix.prefixes
22

33
import id.walt.crypto.keys.jwk.JWKKey
4-
import id.walt.crypto.keys.KeyType
54
import id.walt.crypto.utils.Base64Utils.decodeFromBase64
65
import id.walt.crypto.utils.JwsUtils.decodeJws
76
import id.walt.openid4vp.clientidprefix.ClientIdError
87
import id.walt.openid4vp.clientidprefix.ClientValidationResult
98
import id.walt.openid4vp.clientidprefix.RequestContext
109
import id.walt.openid4vp.clientidprefix.extractSanDnsNamesFromDer
10+
import id.walt.openid4vp.clientidprefix.requestObjectAlgorithmMatchesKey
1111
import id.walt.x509.CertificateDer
1212
import id.walt.x509.validateCertificateChain
1313
import id.walt.x509.platformSupportsPkixCertificatePathValidation
@@ -82,7 +82,7 @@ data class X509SanDns(val dnsName: String, override val rawValue: String) : Clie
8282
val leafCertDer = leafCertificate.bytes.toByteArray()
8383
val key = runCatching { JWKKey.importFromDerCertificate(leafCertDer).getOrThrow() }
8484
.getOrElse { return ClientValidationResult.Failure(ClientIdError.InvalidSignature) }
85-
if (requestObjectAlgorithm == "ES256" && key.keyType != KeyType.secp256r1) {
85+
if (!requestObjectAlgorithmMatchesKey(requestObjectAlgorithm, key.keyType)) {
8686
return ClientValidationResult.Failure(ClientIdError.InvalidSignature)
8787
}
8888
log.trace { "Imported key from leaf cert der for X509SanDns: $key" }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package id.walt.openid4vp.clientidprefix
2+
3+
import id.walt.crypto.keys.KeyType
4+
import kotlin.test.Test
5+
import kotlin.test.assertFalse
6+
import kotlin.test.assertTrue
7+
8+
class RequestObjectJwsAlgorithmTest {
9+
10+
@Test
11+
fun `declared JWS algorithm must match certificate public key type`() {
12+
assertTrue(requestObjectAlgorithmMatchesKey("ES256", KeyType.secp256r1))
13+
assertTrue(requestObjectAlgorithmMatchesKey("ES384", KeyType.secp384r1))
14+
assertTrue(requestObjectAlgorithmMatchesKey("ES512", KeyType.secp521r1))
15+
assertTrue(requestObjectAlgorithmMatchesKey("EdDSA", KeyType.Ed25519))
16+
17+
assertFalse(requestObjectAlgorithmMatchesKey("ES384", KeyType.secp256r1))
18+
assertFalse(requestObjectAlgorithmMatchesKey("ES256", KeyType.secp384r1))
19+
assertFalse(requestObjectAlgorithmMatchesKey(null, KeyType.secp256r1))
20+
}
21+
}

waltid-libraries/protocols/waltid-openid4vp-wallet/src/commonMain/kotlin/id/waltid/openid4vp/wallet/response/ResponseEncryptionHandler.kt

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ object ResponseEncryptionHandler {
3030
data class EncryptionConfig(
3131
val verifierJwk: JsonObject,
3232
val verifierKey: JWKKey,
33-
val keyId: String?,
33+
val keyId: String,
3434
val encAlgorithm: String,
3535
val algAlgorithm: String,
3636
val verifierKeyThumbprint: String,
@@ -68,6 +68,20 @@ object ResponseEncryptionHandler {
6868

6969
val keys = clientMetadata.jwks?.keys.orEmpty()
7070
require(keys.isNotEmpty()) { "No encryption keys found in client_metadata.jwks" }
71+
// OID4VP 1.0 §5.1 requires every inline JWK to have a request-unique `kid`,
72+
// including keys that will later be excluded by encryption eligibility checks.
73+
val keyIds = keys.mapIndexed { index, jwk ->
74+
(jwk["kid"] as? JsonPrimitive)
75+
?.takeIf { it.isString }
76+
?.content
77+
?.takeIf { it.isNotBlank() }
78+
?: throw IllegalArgumentException(
79+
"Every JWK in client_metadata.jwks must have a non-blank string kid (invalid key at index $index)"
80+
)
81+
}
82+
require(keyIds.size == keyIds.toSet().size) {
83+
"Every JWK in client_metadata.jwks must have a request-unique kid"
84+
}
7185

7286
val eligibleKeys = keys.filter(::isSupportedEncryptionKey).map { jwk ->
7387
val key = JWKKey.importJWK(jwk.toString()).getOrThrow()
@@ -83,9 +97,9 @@ object ResponseEncryptionHandler {
8397
)
8498

8599
val verifierJwkData = selected.jwk
86-
val keyId = verifierJwkData["kid"]?.jsonPrimitive?.contentOrNull
100+
val keyId = requireNotNull(verifierJwkData["kid"]?.jsonPrimitive?.contentOrNull)
87101
val alg = requireNotNull(verifierJwkData["alg"]?.jsonPrimitive?.contentOrNull)
88-
log.trace { "Selected verifier encryption key: ${keyId ?: selected.thumbprint}" }
102+
log.trace { "Selected verifier encryption key: $keyId" }
89103

90104
// Select content encryption algorithm
91105
// Default is A128GCM per OID4VP spec
@@ -150,6 +164,9 @@ object ResponseEncryptionHandler {
150164
require(config.algAlgorithm == SUPPORTED_ALG) {
151165
"Unsupported JWE alg ${config.algAlgorithm}"
152166
}
167+
require(config.verifierJwk["kid"]?.jsonPrimitive?.contentOrNull == config.keyId) {
168+
"Encryption configuration kid does not match the selected verifier JWK"
169+
}
153170
log.trace { "Encrypting authorization response payload" }
154171
return config.verifierKey.encryptJwe(
155172
payload.toString().encodeToByteArray(),

0 commit comments

Comments
 (0)