Skip to content

Commit faa0cd2

Browse files
committed
fix(WAL-995): bind credential proofs to holder DID
1 parent b74e6e0 commit faa0cd2

2 files changed

Lines changed: 160 additions & 10 deletions

File tree

waltid-libraries/protocols/waltid-openid4vc-wallet/src/commonMain/kotlin/id/walt/wallet2/handlers/WalletIssuanceSessionService.kt

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import kotlinx.serialization.json.JsonPrimitive
6262
import kotlinx.serialization.json.buildJsonArray
6363
import kotlinx.serialization.json.buildJsonObject
6464
import kotlinx.serialization.json.contentOrNull
65+
import kotlinx.serialization.json.jsonArray
6566
import kotlinx.serialization.json.jsonObject
6667
import kotlinx.serialization.json.jsonPrimitive
6768
import kotlinx.serialization.json.put
@@ -825,30 +826,72 @@ class WalletIssuanceSessionService(
825826
): String {
826827
val methods = requireNotNull(configuration.cryptographicBindingMethodsSupported)
827828
val builder = JwtProofBuilder()
828-
val proofs = if (methods.any { it is CryptographicBindingMethod.Jwk || it is CryptographicBindingMethod.CoseKey }) {
829+
val didKeyId = resolveHolderDidKeyId(active)
830+
val didMethod = didKeyId
831+
?.removePrefix("did:")
832+
?.substringBefore(':')
833+
val supportsHolderDid = didMethod != null && methods.any {
834+
it is CryptographicBindingMethod.Did && it.method == didMethod
835+
}
836+
// Prefer a DID only after proving that its verification method belongs to the selected key.
837+
// This preserves the holder identity needed by W3C VC issuers without weakening key binding.
838+
val proofs = if (supportsHolderDid) {
829839
builder.buildJwtProof(
830840
key = active.key,
831841
audience = active.resolved.offer.credentialIssuer,
832842
nonce = nonce,
833-
includeJwk = true,
843+
keyId = didKeyId,
834844
)
835-
} else {
836-
val didUrl = active.did?.takeIf { '#' in it }
837-
?: error("DID-bound credential proof requires a holder DID URL identifying the selected key")
838-
val method = didUrl.removePrefix("did:").substringBefore(':')
839-
require(methods.any { it is CryptographicBindingMethod.Did && it.method == method }) {
840-
"Selected holder DID method is not supported"
841-
}
845+
} else if (methods.any { it is CryptographicBindingMethod.Jwk || it is CryptographicBindingMethod.CoseKey }) {
842846
builder.buildJwtProof(
843847
key = active.key,
844848
audience = active.resolved.offer.credentialIssuer,
845849
nonce = nonce,
846-
keyId = didUrl,
850+
includeJwk = true,
847851
)
852+
} else {
853+
error("Issuer requires a DID that is bound to the selected holder key")
848854
}
849855
return proofs.jwt?.singleOrNull() ?: error("Credential proof builder returned no JWT")
850856
}
851857

858+
private suspend fun resolveHolderDidKeyId(active: ActiveSession): String? {
859+
val did = active.did ?: return null
860+
val baseDid = did.substringBefore('#')
861+
val requestedKeyId = did.takeIf { '#' in it }
862+
val selectedJwk = active.key.getPublicKey().exportJWKObject()
863+
val storedDid = wallet.didStore?.getDid(baseDid)
864+
865+
if (storedDid != null) {
866+
val verificationMethods = storedDid.document["verificationMethod"]?.jsonArray ?: return null
867+
for (method in verificationMethods) {
868+
val methodObject = method.jsonObject
869+
val methodId = methodObject["id"]?.jsonPrimitive?.contentOrNull ?: continue
870+
if (requestedKeyId != null && methodId != requestedKeyId) continue
871+
val publicKeyJwk = methodObject["publicKeyJwk"]?.jsonObject ?: continue
872+
if (publicJwkMatches(publicKeyJwk, selectedJwk)) return methodId
873+
}
874+
return null
875+
}
876+
877+
val staticDidMatches = wallet.staticDid?.substringBefore('#') == baseDid
878+
val staticKeyJwk = wallet.staticKey?.getPublicKey()?.exportJWKObject()
879+
val staticKeyMatches = staticKeyJwk != null && publicJwkMatches(staticKeyJwk, selectedJwk)
880+
return did.takeIf { staticDidMatches && staticKeyMatches }
881+
}
882+
883+
private fun publicJwkMatches(first: JsonObject, second: JsonObject): Boolean {
884+
// Compare RFC JWK public members directly. Platform-backed keys and imported JWKs may
885+
// calculate thumbprints through different providers even when their public keys are equal.
886+
val fields = when (first["kty"]?.jsonPrimitive?.contentOrNull) {
887+
"EC" -> listOf("kty", "crv", "x", "y")
888+
"RSA" -> listOf("kty", "n", "e")
889+
"OKP" -> listOf("kty", "crv", "x")
890+
else -> return false
891+
}
892+
return fields.all { field -> first[field] != null && first[field] == second[field] }
893+
}
894+
852895
private suspend fun fetchNonce(metadata: CredentialIssuerMetadata): NonceResult {
853896
val endpoint = metadata.nonceEndpoint
854897
?: return NonceResult(cNonce = null, dpopNonce = null)

waltid-libraries/protocols/waltid-openid4vc-wallet/src/jvmTest/kotlin/id/walt/wallet2/handlers/WalletIssuanceSessionServiceTest.kt

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import id.walt.credentials.examples.SdJwtExamples
88
import id.walt.wallet2.data.Wallet
99
import id.walt.wallet2.data.StoredCredential
1010
import id.walt.wallet2.data.WalletCredentialStore
11+
import id.walt.wallet2.data.WalletDidEntry
12+
import id.walt.wallet2.stores.inmemory.InMemoryDidStore
1113
import io.ktor.client.HttpClient
1214
import io.ktor.client.engine.mock.MockEngine
1315
import io.ktor.client.engine.mock.MockRequestHandleScope
@@ -235,6 +237,99 @@ class WalletIssuanceSessionServiceTest {
235237
assertEquals(1, credentialCalls)
236238
}
237239

240+
@Test
241+
fun proofPrefersHolderDidBoundToSelectedKey() = runTest {
242+
val key = JWKKey.generate(KeyType.secp256r1)
243+
val holderDid = "did:jwk:holder"
244+
val holderDidKeyId = "$holderDid#0"
245+
val didStore = InMemoryDidStore().also { store ->
246+
store.addDid(
247+
WalletDidEntry(
248+
did = holderDid,
249+
document = didDocument(holderDid, holderDidKeyId, key),
250+
)
251+
)
252+
}
253+
val client = client { request ->
254+
when (request.url.toString()) {
255+
ISSUER_METADATA -> jsonResponse(
256+
issuerMetadata(proofRequired = true)
257+
.replace(
258+
"\"cryptographic_binding_methods_supported\":[\"jwk\"]",
259+
"\"cryptographic_binding_methods_supported\":[\"jwk\",\"did:jwk\"]",
260+
)
261+
)
262+
AS_METADATA -> jsonResponse(authorizationServerMetadata(authorizationCode = false))
263+
TOKEN_ENDPOINT -> jsonResponse("""{"access_token":"access","token_type":"Bearer"}""")
264+
NONCE_ENDPOINT -> jsonResponse("""{"c_nonce":"endpoint-nonce"}""")
265+
CREDENTIAL_ENDPOINT -> {
266+
val body = Json.parseToJsonElement(request.bodyText()).jsonObject
267+
val proof = body["proofs"]!!.jsonObject["jwt"]!!.jsonArray.single().jsonPrimitive.content
268+
val proofHeader = jwtPart(proof, 0)
269+
assertEquals(holderDidKeyId, proofHeader["kid"]?.jsonPrimitive?.content)
270+
assertEquals(null, proofHeader["jwk"])
271+
jsonResponse("""{"transaction_id":"transaction-1"}""", HttpStatusCode.Accepted)
272+
}
273+
else -> respondError(HttpStatusCode.NotFound)
274+
}
275+
}
276+
val service = WalletIssuanceSessionService(
277+
wallet = Wallet("test", staticKey = key, didStore = didStore),
278+
httpClient = client,
279+
)
280+
281+
val session = service.start(preAuthorizedRequest().copy(did = holderDid))
282+
assertIs<WalletIssuanceOutcome.Deferred>(service.continuePreAuthorized(session.id))
283+
}
284+
285+
@Test
286+
fun proofNeverUsesHolderDidBoundToAnotherKey() = runTest {
287+
val selectedKey = JWKKey.generate(KeyType.secp256r1)
288+
val otherKey = JWKKey.generate(KeyType.secp256r1)
289+
val holderDid = "did:jwk:other-holder"
290+
val didStore = InMemoryDidStore().also { store ->
291+
store.addDid(
292+
WalletDidEntry(
293+
did = holderDid,
294+
document = didDocument(holderDid, "$holderDid#0", otherKey),
295+
)
296+
)
297+
}
298+
val client = client { request ->
299+
when (request.url.toString()) {
300+
ISSUER_METADATA -> jsonResponse(
301+
issuerMetadata(proofRequired = true)
302+
.replace(
303+
"\"cryptographic_binding_methods_supported\":[\"jwk\"]",
304+
"\"cryptographic_binding_methods_supported\":[\"jwk\",\"did:jwk\"]",
305+
)
306+
)
307+
AS_METADATA -> jsonResponse(authorizationServerMetadata(authorizationCode = false))
308+
TOKEN_ENDPOINT -> jsonResponse("""{"access_token":"access","token_type":"Bearer"}""")
309+
NONCE_ENDPOINT -> jsonResponse("""{"c_nonce":"endpoint-nonce"}""")
310+
CREDENTIAL_ENDPOINT -> {
311+
val body = Json.parseToJsonElement(request.bodyText()).jsonObject
312+
val proof = body["proofs"]!!.jsonObject["jwt"]!!.jsonArray.single().jsonPrimitive.content
313+
val proofHeader = jwtPart(proof, 0)
314+
assertEquals(null, proofHeader["kid"])
315+
assertEquals(
316+
Json.parseToJsonElement(selectedKey.getPublicKey().exportJWK()).jsonObject,
317+
proofHeader["jwk"],
318+
)
319+
jsonResponse("""{"transaction_id":"transaction-1"}""", HttpStatusCode.Accepted)
320+
}
321+
else -> respondError(HttpStatusCode.NotFound)
322+
}
323+
}
324+
val service = WalletIssuanceSessionService(
325+
wallet = Wallet("test", staticKey = selectedKey, didStore = didStore),
326+
httpClient = client,
327+
)
328+
329+
val session = service.start(preAuthorizedRequest().copy(did = holderDid))
330+
assertIs<WalletIssuanceOutcome.Deferred>(service.continuePreAuthorized(session.id))
331+
}
332+
238333
@Test
239334
fun insecureNonceEndpointRequiresExplicitTestOptIn() = runTest {
240335
val key = JWKKey.generate(KeyType.secp256r1)
@@ -586,6 +681,18 @@ class WalletIssuanceSessionServiceTest {
586681
private fun HttpRequestData.bodyText(): String =
587682
(body as OutgoingContent.ByteArrayContent).bytes().decodeToString()
588683

684+
private suspend fun didDocument(did: String, keyId: String, key: JWKKey) = buildJsonObject {
685+
put("id", did)
686+
put("verificationMethod", buildJsonArray {
687+
add(buildJsonObject {
688+
put("id", keyId)
689+
put("controller", did)
690+
put("type", "JsonWebKey2020")
691+
put("publicKeyJwk", Json.parseToJsonElement(key.getPublicKey().exportJWK()))
692+
})
693+
})
694+
}
695+
589696
private fun jwtPart(jwt: String, index: Int) =
590697
Json.parseToJsonElement(jwt.split('.')[index].decodeFromBase64Url().decodeToString()).jsonObject
591698

0 commit comments

Comments
 (0)