Skip to content

Commit dc44726

Browse files
Merge branch 'main' into feature/wal-1119
2 parents 7fec43b + 90ef684 commit dc44726

14 files changed

Lines changed: 600 additions & 170 deletions

File tree

waltid-applications/waltid-wallet-demo-compose/androidApp/src/androidTest/java/id/walt/walletdemo/compose/android/EudiPublicBackendE2ETest.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,12 @@ import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.waitForResource
1919
import id.walt.walletdemo.compose.android.WalletComposeE2EHelper.waitForStatus
2020
import kotlinx.coroutines.runBlocking
2121
import org.junit.Assert.assertTrue
22-
import org.junit.Ignore
2322
import org.junit.Test
2423
import org.junit.runner.RunWith
2524

2625
@RunWith(AndroidJUnit4::class)
2726
class EudiPublicBackendE2ETest {
2827

29-
@Ignore("EUDI backend certificate expired 2026-07-04 - https://github.com/eu-digital-identity-wallet/eudi-srv-web-issuing-eudiw-py/issues/168")
3028
@Test
3129
fun receiveAndPresentAgainstEudiPublicBackends() = runBlocking {
3230
val args = InstrumentationRegistry.getArguments()

waltid-applications/waltid-wallet-demo-ios/iosApp/iosAppTests/MobileWalletIntegrationTests.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,7 @@ final class MobileWalletIntegrationTests: XCTestCase {
8282
XCTAssertTrue(result.message.contains("Received"), "Message should confirm receipt: \(result.message)")
8383
}
8484

85-
// EUDI backend certificate expired 2026-07-04 - https://github.com/eu-digital-identity-wallet/eudi-srv-web-issuing-eudiw-py/issues/168
86-
func skip_testReceiveAndPresentFullFlow() async throws {
85+
func testReceiveAndPresentFullFlow() async throws {
8786
let controller = makeController()
8887

8988
let bootstrapResult = try await controller.bootstrap()
@@ -114,8 +113,7 @@ final class MobileWalletIntegrationTests: XCTestCase {
114113
try await TestHelpers.waitForVerifierSuccess(transactionID: transaction.transactionId, timeoutSeconds: verifierPollingTimeout)
115114
}
116115

117-
// EUDI backend certificate expired 2026-07-04 - https://github.com/eu-digital-identity-wallet/eudi-srv-web-issuing-eudiw-py/issues/168
118-
func skip_testCredentialPersistsAcrossControllerRecreation() async throws {
116+
func testCredentialPersistsAcrossControllerRecreation() async throws {
119117
let controller1 = makeController()
120118

121119
let bootstrapResult = try await controller1.bootstrap()

waltid-libraries/credentials/waltid-credential-key-resolver/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,13 @@ kotlin {
3333
}
3434
commonTest.dependencies {
3535
implementation(kotlin("test"))
36+
implementation(identityLibs.kotlinx.coroutines.test)
3637
}
3738
jvmTest.dependencies {
3839
implementation(identityLibs.slf4j.simple)
40+
implementation(identityLibs.ktor.serialization.kotlinx.json)
41+
implementation(identityLibs.ktor.server.content.negotiation)
42+
implementation(identityLibs.ktor.server.netty)
3943
}
4044
}
4145
}

waltid-libraries/credentials/waltid-credential-key-resolver/src/commonMain/kotlin/id/walt/credentials/keyresolver/resolvers/DidKeyResolver.kt

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,57 @@ import io.github.oshai.kotlinlogging.KotlinLogging
77
object DidKeyResolver : BaseKeyResolver {
88
private val log = KotlinLogging.logger { }
99

10-
/**
11-
* Resolves a key from a DID document.
12-
* If [kid] is provided, attempts to match it against the keys in the DID document.
13-
* Falls back to the first key if no match is found or [kid] is null.
14-
*/
1510
suspend fun resolveKeyFromDid(issuerId: String, kid: String? = null): Key {
1611
log.debug { "Resolving key via DID: $issuerId (kid=$kid)" }
1712
val keys = DidService.resolveToKeys(issuerId).getOrThrow()
1813

1914
if (keys.isEmpty()) throw Exception("No valid key found in DID document for $issuerId")
2015

21-
if (kid != null) {
22-
// Try to match by key ID — the DID key ID is typically the full DID fragment, e.g. did:example:123#key-1
23-
val matched = keys.firstOrNull { it.getKeyId() == kid || kid.endsWith("#${it.getKeyId()}") }
16+
if (!kid.isNullOrBlank()) {
17+
val matched = findMatchingKey(keys, issuerId, kid)
2418
if (matched != null) {
2519
log.debug { "Matched key by kid '$kid' in DID document for $issuerId" }
2620
return matched
2721
}
28-
log.debug { "No key with kid '$kid' found in DID document for $issuerId, falling back to first key" }
22+
if (keys.size == 1) {
23+
log.debug { "No key with kid '$kid' found in single-key DID document for $issuerId, using the only key" }
24+
return keys.first()
25+
}
26+
throw NoSuchElementException("No key with kid '$kid' found in DID document for $issuerId")
2927
}
3028

3129
return keys.first()
3230
}
31+
32+
/**
33+
* Attempts to find a matching key using multiple matching strategies.
34+
* This handles various kid formats that may be used in JWT headers:
35+
* - Full DID URL with fragment: did:web:example.com#key-1
36+
* - Just the fragment: key-1
37+
* - Azure Key Vault URLs: https://vault.azure.net/keys/xxx
38+
* - Full verification method ID: did:web:example.com#https://vault.azure.net/keys/xxx
39+
*/
40+
private suspend fun findMatchingKey(keys: Set<Key>, issuerId: String, kid: String): Key? {
41+
val kidCandidates = idCandidates(kid)
42+
return keys.firstOrNull { key ->
43+
val keyId = key.getKeyId()
44+
idCandidates(keyId).any { it in kidCandidates } ||
45+
(keyId == issuerId && removeFragment(kid) == issuerId)
46+
}
47+
}
48+
49+
private fun idCandidates(id: String): Set<String> =
50+
setOfNotNull(id, extractFragment(id))
51+
52+
private fun extractFragment(didUrl: String): String? {
53+
val fragmentIndex = didUrl.indexOf('#')
54+
return if (fragmentIndex >= 0 && fragmentIndex < didUrl.length - 1) {
55+
didUrl.substring(fragmentIndex + 1)
56+
} else {
57+
null
58+
}
59+
}
60+
61+
private fun removeFragment(didUrl: String): String =
62+
didUrl.substringBefore('#')
3363
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package id.walt.credentials.keyresolver.resolvers
2+
3+
import id.walt.crypto.keys.Key
4+
import id.walt.did.dids.DidService
5+
import id.walt.did.dids.resolver.DidResolver
6+
import id.walt.did.dids.resolver.local.DidWebResolver
7+
import id.walt.webdatafetching.WebDataFetcher
8+
import io.ktor.serialization.kotlinx.json.json
9+
import io.ktor.server.application.install
10+
import io.ktor.server.engine.EmbeddedServer
11+
import io.ktor.server.engine.embeddedServer
12+
import io.ktor.server.netty.NettyApplicationEngine
13+
import io.ktor.server.netty.Netty
14+
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
15+
import io.ktor.server.response.respond
16+
import io.ktor.server.routing.get
17+
import io.ktor.server.routing.routing
18+
import kotlinx.coroutines.test.runTest
19+
import kotlinx.serialization.json.Json
20+
import kotlinx.serialization.json.JsonObject
21+
import kotlin.test.AfterTest
22+
import kotlin.test.BeforeTest
23+
import kotlin.test.Test
24+
import kotlin.test.assertEquals
25+
import kotlin.test.assertFailsWith
26+
27+
class DidKeyResolverKeyRotationTest {
28+
29+
private var previousWebResolver: DidResolver? = null
30+
private lateinit var server: EmbeddedServer<NettyApplicationEngine, NettyApplicationEngine.Configuration>
31+
32+
@BeforeTest
33+
fun setup() {
34+
DidWebResolver.enableHttps(false)
35+
server = embeddedServer(Netty, port = DID_WEB_PORT) {
36+
install(ContentNegotiation) {
37+
json(Json { ignoreUnknownKeys = true })
38+
}
39+
routing {
40+
get("/key-rotation/did.json") {
41+
call.respond(Json.decodeFromString<JsonObject>(keyRotationDidDocument))
42+
}
43+
}
44+
}.start(wait = false)
45+
46+
previousWebResolver = DidService.resolverMethods["web"]
47+
DidService.registerResolverForMethod("web", testDidWebResolver)
48+
}
49+
50+
@AfterTest
51+
fun tearDown() {
52+
previousWebResolver?.let {
53+
DidService.registerResolverForMethod("web", it)
54+
} ?: DidService.resolverMethods.remove("web")
55+
56+
server.stop()
57+
DidWebResolver.enableHttps(true)
58+
}
59+
60+
@Test
61+
fun `resolves rotated old key by full verification method kid`() = runTest {
62+
val key = DidKeyResolver.resolveKeyFromDid(KEY_ROTATION_DID, "$KEY_ROTATION_DID#$OLD_KEY_ID")
63+
64+
assertEquals("$KEY_ROTATION_DID#$OLD_KEY_ID", key.getKeyId())
65+
}
66+
67+
@Test
68+
fun `resolves rotated new key by full verification method kid even when it is not first`() = runTest {
69+
val key = DidKeyResolver.resolveKeyFromDid(KEY_ROTATION_DID, "$KEY_ROTATION_DID#$NEW_KEY_ID")
70+
71+
assertEquals("$KEY_ROTATION_DID#$NEW_KEY_ID", key.getKeyId())
72+
}
73+
74+
@Test
75+
fun `resolves rotated key when jwt kid is the public key jwk kid`() = runTest {
76+
val key = DidKeyResolver.resolveKeyFromDid(KEY_ROTATION_DID, OLD_KEY_ID)
77+
78+
assertEquals("$KEY_ROTATION_DID#$OLD_KEY_ID", key.getKeyId())
79+
}
80+
81+
@Test
82+
fun `fails instead of falling back to first key when supplied kid is unknown`() = runTest {
83+
assertFailsWith<NoSuchElementException> {
84+
DidKeyResolver.resolveKeyFromDid(KEY_ROTATION_DID, "https://vault.azure.net/keys/missing")
85+
}
86+
}
87+
88+
@Test
89+
fun `keeps first key fallback when no kid is supplied`() = runTest {
90+
val key = DidKeyResolver.resolveKeyFromDid(KEY_ROTATION_DID)
91+
92+
assertEquals("$KEY_ROTATION_DID#$OLD_KEY_ID", key.getKeyId())
93+
}
94+
95+
private companion object {
96+
const val DID_WEB_PORT = 18089
97+
const val KEY_ROTATION_DID = "did:web:localhost%3A$DID_WEB_PORT:key-rotation"
98+
const val OLD_KEY_ID = "https://vault.azure.net/keys/old-key-xxx"
99+
const val NEW_KEY_ID = "https://vault.azure.net/keys/new-key-yyy"
100+
101+
val didWebResolver = DidWebResolver(WebDataFetcher(id = "did-key-resolver-key-rotation-test"))
102+
103+
val testDidWebResolver = object : DidResolver {
104+
override val name: String = "key-rotation-test-did-web-resolver"
105+
106+
override suspend fun getSupportedMethods(): Result<Set<String>> = Result.success(setOf("web"))
107+
108+
override suspend fun resolve(did: String): Result<JsonObject> =
109+
didWebResolver.resolve(did).map { it.toJsonObject() }
110+
111+
override suspend fun resolveToKey(did: String): Result<Key> =
112+
didWebResolver.resolveToKey(did)
113+
114+
override suspend fun resolveToKeys(did: String): Result<Set<Key>> =
115+
didWebResolver.resolveToKeys(did)
116+
}
117+
118+
val keyRotationDidDocument = """
119+
{
120+
"assertionMethod": [
121+
"$KEY_ROTATION_DID#$OLD_KEY_ID",
122+
"$KEY_ROTATION_DID#$NEW_KEY_ID"
123+
],
124+
"authentication": [
125+
"$KEY_ROTATION_DID#$OLD_KEY_ID",
126+
"$KEY_ROTATION_DID#$NEW_KEY_ID"
127+
],
128+
"@context": "https://www.w3.org/ns/did/v1",
129+
"id": "$KEY_ROTATION_DID",
130+
"verificationMethod": [
131+
{
132+
"controller": "$KEY_ROTATION_DID",
133+
"id": "$KEY_ROTATION_DID#$OLD_KEY_ID",
134+
"publicKeyJwk": {
135+
"alg": "EdDSA",
136+
"crv": "Ed25519",
137+
"kid": "$OLD_KEY_ID",
138+
"kty": "OKP",
139+
"use": "sig",
140+
"x": "qBDsYw3k62mUT8UmEx99Xz3yckiSRmTsL6aa21ZcAVM"
141+
},
142+
"type": "JsonWebKey2020"
143+
},
144+
{
145+
"controller": "$KEY_ROTATION_DID",
146+
"id": "$KEY_ROTATION_DID#$NEW_KEY_ID",
147+
"publicKeyJwk": {
148+
"alg": "ES256K",
149+
"crv": "secp256k1",
150+
"kid": "$NEW_KEY_ID",
151+
"kty": "EC",
152+
"use": "sig",
153+
"x": "eKx_FaLzPMT4ndwvImdV_pTv-JX1SQpJ8tDK6GLiYIE",
154+
"y": "-TzpGxGLPnXWMJWTqYvqn55Z8Xi-J_ZM40bjtjGaELs"
155+
},
156+
"type": "JsonWebKey2020"
157+
}
158+
]
159+
}
160+
""".trimIndent()
161+
}
162+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package id.walt.crypto.exceptions
2+
3+
object ExternalKmsError {
4+
private const val REDACTED = "<redacted>"
5+
private const val MAX_MESSAGE_LENGTH = 2_000
6+
7+
private val secretAssignmentPattern =
8+
Regex("(?i)(client_secret|clientSecret|secret_id|secretId|secretAccessKey|accessKey|token|authorization)\"?\\s*[:=]\\s*\"?[^\"]+\"?")
9+
private val bearerTokenPattern = Regex("(?i)(Bearer\\s+)[A-Za-z0-9._~+/=-]+")
10+
11+
internal fun sanitize(message: String): String =
12+
message
13+
.replace(secretAssignmentPattern) {
14+
"${it.groupValues[1]}=$REDACTED"
15+
}
16+
.replace(bearerTokenPattern) {
17+
"${it.groupValues[1]}$REDACTED"
18+
}
19+
.take(MAX_MESSAGE_LENGTH)
20+
21+
internal fun requestFailed(
22+
provider: String,
23+
operation: String,
24+
message: String,
25+
cause: Throwable? = null,
26+
): Nothing = throw KeyVaultUnavailable("$provider $operation failed: ${sanitize(message)}", cause)
27+
28+
internal fun generationFailed(
29+
provider: String,
30+
keyType: String,
31+
cause: Throwable,
32+
): Nothing {
33+
if (cause is KeyTypeNotSupportedException) throw cause
34+
35+
throw KeyCreationFailed(
36+
message = "Failed to generate $keyType key using $provider: ${sanitize(cause.safeMessage())}",
37+
cause = cause,
38+
)
39+
}
40+
41+
private fun Throwable.safeMessage(): String =
42+
message ?: this::class.simpleName ?: "unknown error"
43+
}

0 commit comments

Comments
 (0)