From bba54cca84a2e2e0c5b7ad40846af456f70035d8 Mon Sep 17 00:00:00 2001 From: abhip2565 Date: Tue, 25 Aug 2026 22:08:32 +0530 Subject: [PATCH 1/7] refactor(keyresolver): centralise JWK member names and key types JWK member names and 'kty' values were repeated as string literals across the resolvers and PresentationVerifier - 'OKP' alone appeared four times. Collect the RFC 7517 vocabulary in JwkParams. Signed-off-by: abhip2565 --- .../vcverifier/PresentationVerifier.kt | 19 +++++++------- .../vercred/vcverifier/constants/JwkParams.kt | 25 +++++++++++++++++++ .../vercred/vcverifier/keyResolver/Utils.kt | 14 +++++------ 3 files changed, 42 insertions(+), 16 deletions(-) create mode 100644 vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/constants/JwkParams.kt diff --git a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/PresentationVerifier.kt b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/PresentationVerifier.kt index 76d05afe..510f4acd 100644 --- a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/PresentationVerifier.kt +++ b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/PresentationVerifier.kt @@ -51,6 +51,7 @@ import io.mosip.vercred.vcverifier.exception.PublicKeyNotFoundException import io.mosip.vercred.vcverifier.exception.SignatureNotSupportedException import io.mosip.vercred.vcverifier.exception.SignatureVerificationException import io.mosip.vercred.vcverifier.exception.UnknownException +import io.mosip.vercred.vcverifier.constants.JwkParams import io.mosip.vercred.vcverifier.keyResolver.PublicKeyResolverFactory import io.mosip.vercred.vcverifier.keyResolver.decompressP256Key import io.mosip.vercred.vcverifier.signature.impl.ED25519SignatureVerifierImpl @@ -410,20 +411,20 @@ class PresentationVerifier { } private fun comparePublicKeyJson(publicKeyJson1: JSONObject, publicKeyJson2: JSONObject): Boolean { - val keyType = publicKeyJson1.optString("kty") - if (keyType != publicKeyJson2.optString("kty")) return false + val keyType = publicKeyJson1.optString(JwkParams.KTY) + if (keyType != publicKeyJson2.optString(JwkParams.KTY)) return false return when (keyType) { - "EC" -> publicKeyJson1.optString("crv") == publicKeyJson2.optString("crv") && - publicKeyJson1.optString("x") == publicKeyJson2.optString("x") && - publicKeyJson1.optString("y") == publicKeyJson2.optString("y") + JwkParams.KEY_TYPE_EC -> publicKeyJson1.optString(JwkParams.CRV) == publicKeyJson2.optString(JwkParams.CRV) && + publicKeyJson1.optString(JwkParams.X) == publicKeyJson2.optString(JwkParams.X) && + publicKeyJson1.optString(JwkParams.Y) == publicKeyJson2.optString(JwkParams.Y) - "OKP" -> publicKeyJson1.optString("crv") == publicKeyJson2.optString("crv") && - publicKeyJson1.optString("x") == publicKeyJson2.optString("x") + JwkParams.KEY_TYPE_OKP -> publicKeyJson1.optString(JwkParams.CRV) == publicKeyJson2.optString(JwkParams.CRV) && + publicKeyJson1.optString(JwkParams.X) == publicKeyJson2.optString(JwkParams.X) // Only reachable via did:jwk; did:key RSA is not supported - "RSA" -> publicKeyJson1.optString("n") == publicKeyJson2.optString("n") && - publicKeyJson1.optString("e") == publicKeyJson2.optString("e") + JwkParams.KEY_TYPE_RSA -> publicKeyJson1.optString(JwkParams.N) == publicKeyJson2.optString(JwkParams.N) && + publicKeyJson1.optString(JwkParams.E) == publicKeyJson2.optString(JwkParams.E) else -> false } diff --git a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/constants/JwkParams.kt b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/constants/JwkParams.kt new file mode 100644 index 00000000..01523194 --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/constants/JwkParams.kt @@ -0,0 +1,25 @@ +package io.mosip.vercred.vcverifier.constants + +object JwkParams { + + const val KEYS = "keys" + + const val KID = "kid" + const val KTY = "kty" + const val CRV = "crv" + const val ALG = "alg" + const val USE = "use" + const val KEY_OPS = "key_ops" + + const val X = "x" + const val Y = "y" + const val N = "n" + const val E = "e" + + const val KEY_TYPE_EC = CredentialVerifierConstants.JWK_KEY_TYPE_EC + const val KEY_TYPE_RSA = "RSA" + const val KEY_TYPE_OKP = "OKP" + + const val USE_SIGNATURE = "sig" + const val KEY_OP_VERIFY = "verify" +} \ No newline at end of file diff --git a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/Utils.kt b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/Utils.kt index f7f3c2c8..9fe247c5 100644 --- a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/Utils.kt +++ b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/Utils.kt @@ -28,11 +28,11 @@ import io.mosip.vercred.vcverifier.constants.CredentialVerifierConstants.RSA_MUL import io.mosip.vercred.vcverifier.constants.CredentialVerifierConstants.RSA_MULTICODEC_SECOND import io.mosip.vercred.vcverifier.constants.CredentialVerifierConstants.RSA_PROOF_TYPE import io.mosip.vercred.vcverifier.constants.CredentialVerifierConstants.SECP256K1 +import io.mosip.vercred.vcverifier.constants.JwkParams import io.mosip.vercred.vcverifier.exception.PublicKeyNotFoundException import io.mosip.vercred.vcverifier.exception.PublicKeyResolutionFailedException import io.mosip.vercred.vcverifier.exception.PublicKeyTypeNotSupportedException import io.mosip.vercred.vcverifier.exception.SignatureNotSupportedException -import io.mosip.vercred.vcverifier.signature.bouncyCastleProvider import io.mosip.vercred.vcverifier.utils.Base64Decoder import org.bouncycastle.jce.ECNamedCurveTable import org.bouncycastle.jce.provider.BouncyCastleProvider @@ -100,7 +100,7 @@ fun getPublicKeyObjectFromPemPublicKey(publicKeyPem: String, keyType: String): P fun getPublicKeyFromJWK(jwk: Map, keyType: String): PublicKey { return when (keyType) { ES256K_KEY_TYPE_2019,ES256_KEY_TYPE_2019,JWK_KEY_TYPE_EC -> getECPublicKey(jwk) - ED25519_KEY_TYPE_2020, "OKP" -> getEdPublicKey(jwk) + ED25519_KEY_TYPE_2020, JwkParams.KEY_TYPE_OKP -> getEdPublicKey(jwk) RSA_KEY_TYPE, RSA_ALGORITHM -> getRSAPublicKey(jwk) else -> throw PublicKeyTypeNotSupportedException("Unsupported key type: $keyType") } @@ -114,7 +114,7 @@ fun getPublicKeyFromJWK(jwkStr: String, keyType: String): PublicKey { return when (keyType) { ES256K_KEY_TYPE_2019,ES256_KEY_TYPE_2019,JWK_KEY_TYPE_EC -> getECPublicKey(jwk) - ED25519_KEY_TYPE_2020, "OKP" -> getEdPublicKey(jwk) + ED25519_KEY_TYPE_2020, JwkParams.KEY_TYPE_OKP -> getEdPublicKey(jwk) RSA_KEY_TYPE, RSA_ALGORITHM -> getRSAPublicKey(jwk) else -> throw PublicKeyTypeNotSupportedException("Unsupported key type: $keyType") } @@ -134,9 +134,9 @@ private fun getRSAPublicKey(jwk: Map): PublicKey { internal fun getEdPublicKey(jwk: Map): PublicKey { - val keyType = jwk["kty"] - require(keyType == "OKP") { throw PublicKeyResolutionFailedException("KeyType - $keyType is not supported. Supported: OKP") } - val curve = jwk["crv"] + val keyType = jwk[JwkParams.KTY] + require(keyType == JwkParams.KEY_TYPE_OKP) { throw PublicKeyResolutionFailedException("KeyType - $keyType is not supported. Supported: OKP") } + val curve = jwk[JwkParams.CRV] require(curve == ED25519_ALGORITHM) { throw PublicKeyResolutionFailedException("Curve - $curve is not supported. Supported: Ed25519") } val xB64Url = @@ -152,7 +152,7 @@ internal fun getEdPublicKey(jwk: Map): PublicKey { private fun getECPublicKey(jwk: Map): PublicKey { - val curve = jwk["crv"]?.toString() ?: throw IllegalArgumentException("Missing 'crv' field for EC key") + val curve = jwk[JwkParams.CRV]?.toString() ?: throw IllegalArgumentException("Missing 'crv' field for EC key") val xBase64 = jwk["x"]?.toString() ?: throw PublicKeyResolutionFailedException("Missing 'x'") From 3d49e2b13b900f13857dd2025ad06edb04d2f0fc Mon Sep 17 00:00:00 2001 From: abhip2565 Date: Tue, 25 Aug 2026 22:08:32 +0530 Subject: [PATCH 2/7] fix(network): bound and harden every outbound request Every URL fetched comes from credential content that has not been verified yet, yet requests were unbounded, followed redirects including downgrades to plaintext, and connected to any resolved address. Route them through one client with timeouts, a response size cap, no redirects and non-public addresses refused. draft-ietf-oauth-sd-jwt-vc-10 10.1 requires the time and size bounds. Redirects and address restriction are configurable via NetworkPolicy. Signed-off-by: abhip2565 --- .../statusChecker/LdpStatusChecker.kt | 9 +- .../networkManager/NetworkManagerClient.kt | 190 +++++++++++++++++- .../StatusListRevocationCheckerTest.kt | 4 + .../NetworkManagerClientTest.kt | 109 ++++++++++ .../networkManager/PublicAddressTest.kt | 68 +++++++ .../src/test/java/testutils/TestUtils.kt | 2 +- 6 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClientTest.kt create mode 100644 vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/networkManager/PublicAddressTest.kt diff --git a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/LdpStatusChecker.kt b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/LdpStatusChecker.kt index 26d5a6be..4d17c806 100644 --- a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/LdpStatusChecker.kt +++ b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/LdpStatusChecker.kt @@ -37,6 +37,8 @@ import java.io.IOException import java.util.logging.Logger import java.util.zip.GZIPInputStream +private const val STATUS_LIST_MAX_RESPONSE_BYTES = 5L * 1024 * 1024 +private const val STATUS_LIST_CALL_TIMEOUT_SECONDS = 30L /** * Generic StatusList2021 checker for LDP VCs. @@ -137,7 +139,12 @@ class LdpStatusChecker() { val statusListVCMap: Map<*, *> try { - statusListVCMap = sendHTTPRequest(statusListCredentialUrl, GET) + statusListVCMap = sendHTTPRequest( + statusListCredentialUrl, + GET, + maxResponseBytes = STATUS_LIST_MAX_RESPONSE_BYTES, + callTimeoutSeconds = STATUS_LIST_CALL_TIMEOUT_SECONDS + ) ?: throw StatusCheckException( "Failed to retrieve status list VC", STATUS_RETRIEVAL_ERROR diff --git a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClient.kt b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClient.kt index 5511304b..8cee3c15 100644 --- a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClient.kt +++ b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClient.kt @@ -2,23 +2,57 @@ package io.mosip.vercred.vcverifier.networkManager import io.mosip.vercred.vcverifier.exception.NetworkManagerClientExceptions import io.mosip.vercred.vcverifier.utils.Util +import okhttp3.Dns import okhttp3.FormBody import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response +import java.io.ByteArrayOutputStream +import java.io.InputStream import java.io.InterruptedIOException +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.UnknownHostException +import java.util.concurrent.TimeUnit +private const val DEFAULT_CALL_TIMEOUT_SECONDS = 10L +private const val CONNECT_TIMEOUT_SECONDS = 5L +private const val READ_TIMEOUT_SECONDS = 5L +private const val DEFAULT_MAX_RESPONSE_BYTES = 256L * 1024 + +object NetworkPolicy { + @Volatile + @JvmStatic + var restrictToPublicHosts: Boolean = true + + @Volatile + @JvmStatic + var followRedirects: Boolean = false +} class NetworkManagerClient { companion object { + + private val httpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .callTimeout(DEFAULT_CALL_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .followRedirects(false) + .followSslRedirects(false) + .dns(PublicAddressDns) + .build() + } + fun sendHTTPRequest( url: String, method: HttpMethod, bodyParams: Map? = null, - headers: Map? = null + headers: Map? = null, + maxResponseBytes: Long = DEFAULT_MAX_RESPONSE_BYTES, + callTimeoutSeconds: Long = DEFAULT_CALL_TIMEOUT_SECONDS ): Map? { try { - val client = OkHttpClient.Builder().build() val request: Request when (method) { HttpMethod.POST -> { @@ -36,15 +70,24 @@ class NetworkManagerClient { HttpMethod.GET -> request = Request.Builder().url(url).get().build() } - val response: Response = client.newCall(request).execute() - if (response.isSuccessful) { + clientFor(callTimeoutSeconds).newCall(request).execute().use { response -> + if (response.isRedirect) { + throw Exception( + "Refusing to follow redirect from $url to " + + "${response.header("Location")}. Set NetworkPolicy.followRedirects " + + "to true if this endpoint legitimately redirects." + ) + } + if (!response.isSuccessful) throw Exception(response.toString()) + return response.body?.let { body -> + if (body.contentLength() > maxResponseBytes) { + throw ResponseTooLargeException(maxResponseBytes) + } Util.convertJsonToMap( - body.byteStream().bufferedReader().use { it.readText() } + body.byteStream().use { readBounded(it, maxResponseBytes) } ) } - } else { - throw Exception(response.toString()) } } catch (exception: InterruptedIOException) { val specificException = @@ -56,9 +99,138 @@ class NetworkManagerClient { throw specificException } } + + private fun clientFor(callTimeoutSeconds: Long): OkHttpClient { + val overrideTimeout = callTimeoutSeconds != DEFAULT_CALL_TIMEOUT_SECONDS + val allowRedirects = NetworkPolicy.followRedirects + if (!overrideTimeout && !allowRedirects) return httpClient + + return httpClient.newBuilder() + .apply { if (overrideTimeout) callTimeout(callTimeoutSeconds, TimeUnit.SECONDS) } + .followRedirects(allowRedirects) + .build() + } + + private fun readBounded(stream: InputStream, maxResponseBytes: Long): String { + val collected = ByteArrayOutputStream() + val chunk = ByteArray(8192) + var total = 0L + while (true) { + val read = stream.read(chunk) + if (read == -1) break + total += read + if (total > maxResponseBytes) throw ResponseTooLargeException(maxResponseBytes) + collected.write(chunk, 0, read) + } + return collected.toString(Charsets.UTF_8.name()) + } + + private class ResponseTooLargeException(maxResponseBytes: Long) : + Exception("Response exceeds the $maxResponseBytes byte limit") + + private object PublicAddressDns : Dns { + override fun lookup(hostname: String): List { + val addresses = Dns.SYSTEM.lookup(hostname) + if (!NetworkPolicy.restrictToPublicHosts) return addresses + if (addresses.isEmpty() || addresses.any { !it.isPublicAddress() }) { + throw UnknownHostException("Refusing non-public host: $hostname") + } + return addresses + } + } } } +internal fun InetAddress.isPublicAddress(): Boolean { + if (isAnyLocalAddress || isLoopbackAddress || isLinkLocalAddress || + isSiteLocalAddress || isMulticastAddress + ) return false + + return when (this) { + is Inet4Address -> isPublicIpv4(address) + is Inet6Address -> isPublicIpv6(this) + else -> false + } +} + +private fun ByteArray.octet(index: Int) = this[index].toInt() and 0xff + +/** 10/8, 172.16/12, 192.168/16, 127/8 and 169.254/16 are already refused by [isPublicAddress]. */ +private fun isPublicIpv4(bytes: ByteArray): Boolean { + val a = bytes.octet(0) + val b = bytes.octet(1) + val c = bytes.octet(2) + return when { + a == 0 -> false // 0.0.0.0/8 "this network" + a >= 224 -> false // multicast, 240/4 reserved, broadcast + a == 100 && b in 64..127 -> false // 100.64/10 carrier-grade NAT + a == 192 && b == 0 && c == 0 -> false // 192.0.0.0/24 IETF protocol assignments + a == 192 && b == 0 && c == 2 -> false // 192.0.2.0/24 TEST-NET-1 + a == 192 && b == 88 && c == 99 -> false // 192.88.99.0/24 6to4 relay anycast + a == 198 && b in 18..19 -> false // 198.18.0.0/15 benchmarking + a == 198 && b == 51 && c == 100 -> false // 198.51.100.0/24 TEST-NET-2 + a == 203 && b == 0 && c == 113 -> false // 203.0.113.0/24 TEST-NET-3 + else -> true + } +} + +private fun isPublicIpv6(address: Inet6Address): Boolean { + val bytes = address.address + if ((bytes.octet(0) and 0xfe) == 0xfc) return false // fc00::/7 unique local + if (bytes.octet(0) == 0x20 && bytes.octet(1) == 0x01 && + bytes.octet(2) == 0x0d && bytes.octet(3) == 0xb8 + ) return false // 2001:db8::/32 documentation + + // A transition address can look globally routable while naming an internal IPv4 target, so the + // address it tunnels is judged on its own merits. + embeddedIpv4(address)?.let { return it.isPublicAddress() } + + // ::/96 and ::ffff:0:0/96 — normally normalised to Inet4Address; refused defensively. + if ((0 until 10).all { bytes[it].toInt() == 0 }) return false + return true +} + +/** + * The IPv4 address tunnelled inside [address], or null if it tunnels none. + * + * Each mechanism packs the address differently, so each is matched on its own marker bytes: + * 6to4 places it directly after the `2002:` prefix, Teredo places it last and bit-inverts it, and + * ISATAP and NAT64 place it last unaltered. ISATAP is identified by its `00:00:5e:fe` interface + * identifier rather than a prefix, so it can appear under a globally routable prefix. + */ +private fun embeddedIpv4(address: Inet6Address): InetAddress? { + val bytes = address.address + val embedded = when { + is6to4(bytes) -> bytes.copyOfRange(2, 6) + isTeredo(bytes) -> ByteArray(4) { (bytes[12 + it].toInt().inv() and 0xff).toByte() } + isNat64(bytes) || isIsatap(bytes) -> bytes.copyOfRange(12, 16) + else -> null + } + return embedded?.let(InetAddress::getByAddress) +} + +/** 2002::/16 — RFC 3056. */ +private fun is6to4(bytes: ByteArray) = + bytes.octet(0) == 0x20 && bytes.octet(1) == 0x02 + +/** 2001:0::/32 — RFC 4380. */ +private fun isTeredo(bytes: ByteArray) = + bytes.octet(0) == 0x20 && bytes.octet(1) == 0x01 && + bytes.octet(2) == 0x00 && bytes.octet(3) == 0x00 + +/** + * RFC 5214 — interface identifier `:00:5e:fe`. Teredo is excluded first because a Teredo + * address can coincidentally carry the same identifier bytes. + */ +private fun isIsatap(bytes: ByteArray) = + !isTeredo(bytes) && (bytes.octet(8) and 0xfc) == 0x00 && + bytes.octet(9) == 0x00 && bytes.octet(10) == 0x5e && bytes.octet(11) == 0xfe + +/** 64:ff9b::/96 — RFC 6052 well-known prefix. */ +private fun isNat64(bytes: ByteArray) = + bytes.octet(0) == 0x00 && bytes.octet(1) == 0x64 && + bytes.octet(2) == 0xff && bytes.octet(3) == 0x9b + enum class HttpMethod { POST, GET -} \ No newline at end of file +} diff --git a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/StatusListRevocationCheckerTest.kt b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/StatusListRevocationCheckerTest.kt index 0579d452..ac9955f8 100644 --- a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/StatusListRevocationCheckerTest.kt +++ b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/StatusListRevocationCheckerTest.kt @@ -8,6 +8,7 @@ import io.mockk.unmockkAll import io.mosip.vercred.vcverifier.credentialverifier.types.LdpVerifiableCredential import io.mosip.vercred.vcverifier.exception.StatusCheckErrorCode import io.mosip.vercred.vcverifier.exception.StatusCheckException +import io.mosip.vercred.vcverifier.networkManager.NetworkPolicy import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer import org.junit.jupiter.api.AfterEach @@ -30,6 +31,8 @@ class StatusListRevocationCheckerTest { @BeforeEach fun setup() { MockKAnnotations.init(this) + // MockWebServer binds to loopback, which the public-address guard refuses by design. + NetworkPolicy.restrictToPublicHosts = false mockkConstructor(LdpVerifiableCredential::class) every { anyConstructed().verify(any()) } returns true checker = LdpStatusChecker() @@ -37,6 +40,7 @@ class StatusListRevocationCheckerTest { @AfterEach fun teardown() { + NetworkPolicy.restrictToPublicHosts = true unmockkAll() } diff --git a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClientTest.kt b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClientTest.kt new file mode 100644 index 00000000..9c2b9014 --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClientTest.kt @@ -0,0 +1,109 @@ +package io.mosip.vercred.vcverifier.networkManager + +import io.mosip.vercred.vcverifier.exception.NetworkManagerClientExceptions +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class NetworkManagerClientTest { + + private lateinit var server: MockWebServer + + @BeforeEach + fun setUp() { + server = MockWebServer().apply { start() } + } + + @AfterEach + fun tearDown() { + NetworkPolicy.restrictToPublicHosts = true + NetworkPolicy.followRedirects = false + server.shutdown() + } + + private fun url() = server.url("/resource").toString() + + @Test + fun `refuses a host resolving to a non-public address`() { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{"ok":true}""")) + + val error = assertThrows(NetworkManagerClientExceptions.NetworkRequestFailed::class.java) { + NetworkManagerClient.sendHTTPRequest(url(), HttpMethod.GET) + } + + assertTrue(error.message!!.contains("Refusing non-public host")) + } + + @Test + fun `allows a non-public host when the deployment opts out`() { + NetworkPolicy.restrictToPublicHosts = false + server.enqueue(MockResponse().setResponseCode(200).setBody("""{"ok":true}""")) + + val response = NetworkManagerClient.sendHTTPRequest(url(), HttpMethod.GET) + + assertEquals(true, response!!["ok"]) + } + + @Test + fun `refuses a response larger than the size limit`() { + NetworkPolicy.restrictToPublicHosts = false + val oversized = """{"padding":"${"a".repeat(4096)}"}""" + server.enqueue(MockResponse().setResponseCode(200).setBody(oversized)) + + val error = assertThrows(NetworkManagerClientExceptions.NetworkRequestFailed::class.java) { + NetworkManagerClient.sendHTTPRequest(url(), HttpMethod.GET, maxResponseBytes = 1024) + } + + assertTrue(error.message!!.contains("exceeds the 1024 byte limit")) + } + + @Test + fun `refuses a redirect by default and names the target`() { + NetworkPolicy.restrictToPublicHosts = false + server.enqueue( + MockResponse().setResponseCode(302) + .setHeader("Location", server.url("/moved").toString()) + ) + + val error = assertThrows(NetworkManagerClientExceptions.NetworkRequestFailed::class.java) { + NetworkManagerClient.sendHTTPRequest(url(), HttpMethod.GET) + } + + assertTrue(error.message!!.contains("Refusing to follow redirect")) + assertTrue(error.message!!.contains("/moved")) + } + + @Test + fun `follows a redirect when the deployment opts in`() { + NetworkPolicy.restrictToPublicHosts = false + NetworkPolicy.followRedirects = true + server.enqueue( + MockResponse().setResponseCode(302) + .setHeader("Location", server.url("/moved").toString()) + ) + server.enqueue(MockResponse().setResponseCode(200).setBody("""{"ok":true}""")) + + val response = NetworkManagerClient.sendHTTPRequest(url(), HttpMethod.GET) + + assertEquals(true, response!!["ok"]) + } + + @Test + fun `never follows a redirect that downgrades to plaintext, even when opted in`() { + NetworkPolicy.restrictToPublicHosts = false + NetworkPolicy.followRedirects = true + server.enqueue( + MockResponse().setResponseCode(302) + .setHeader("Location", "http://insecure.example/resource") + ) + + assertThrows(NetworkManagerClientExceptions.NetworkRequestFailed::class.java) { + NetworkManagerClient.sendHTTPRequest(url(), HttpMethod.GET) + } + } +} diff --git a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/networkManager/PublicAddressTest.kt b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/networkManager/PublicAddressTest.kt new file mode 100644 index 00000000..e2bcf2b6 --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/networkManager/PublicAddressTest.kt @@ -0,0 +1,68 @@ +package io.mosip.vercred.vcverifier.networkManager + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.net.InetAddress + +class PublicAddressTest { + + private fun isPublic(literal: String) = + InetAddress.getByName(literal).isPublicAddress() + + @Test + fun `accepts globally routable addresses`() { + listOf("8.8.8.8", "1.1.1.1", "93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946") + .forEach { assertTrue(isPublic(it), "$it should be treated as public") } + } + + @Test + fun `refuses loopback, private and link-local IPv4`() { + listOf("127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.169.254", "0.0.0.0") + .forEach { assertFalse(isPublic(it), "$it should be refused") } + } + + @Test + fun `refuses special-use IPv4 ranges`() { + listOf( + "100.64.0.1", // carrier-grade NAT + "192.0.0.1", // IETF protocol assignments + "192.0.2.1", // TEST-NET-1 + "192.88.99.1", // 6to4 relay anycast + "198.18.0.1", // benchmarking + "198.51.100.1", // TEST-NET-2 + "203.0.113.1", // TEST-NET-3 + "240.0.0.1", // reserved + "255.255.255.255" // broadcast + ).forEach { assertFalse(isPublic(it), "$it should be refused") } + } + + @Test + fun `refuses IPv6 transition addresses that embed an internal IPv4 target`() { + listOf( + "2002:7f00:0001::", // 6to4 wrapping 127.0.0.1 + "2002:0a00:0001::", // 6to4 wrapping 10.0.0.1 + "2002:a9fe:a9fe::", // 6to4 wrapping 169.254.169.254 + "64:ff9b::7f00:1", // NAT64 wrapping 127.0.0.1 + "2001:0:53aa:64c:0:0:80ff:fffe", // Teredo wrapping 127.0.0.1 + "2606:2800:220::5efe:0a00:0001", // ISATAP with a global prefix, wrapping 10.0.0.1 + "fe80::5efe:7f00:1" // ISATAP wrapping 127.0.0.1 + ).forEach { assertFalse(isPublic(it), "$it should be refused") } + } + + @Test + fun `allows a 6to4 address wrapping a public IPv4`() { + assertTrue(isPublic("2002:0808:0808::")) + } + + @Test + fun `refuses unique-local and documentation IPv6`() { + listOf("fc00::1", "fd00::1", "2001:db8::1", "::1", "::") + .forEach { assertFalse(isPublic(it), "$it should be refused") } + } + + @Test + fun `refuses IPv4-mapped loopback`() { + assertFalse(isPublic("::ffff:127.0.0.1")) + } +} diff --git a/vc-verifier/kotlin/vcverifier/src/test/java/testutils/TestUtils.kt b/vc-verifier/kotlin/vcverifier/src/test/java/testutils/TestUtils.kt index b3a6ba8d..c59cc2ad 100644 --- a/vc-verifier/kotlin/vcverifier/src/test/java/testutils/TestUtils.kt +++ b/vc-verifier/kotlin/vcverifier/src/test/java/testutils/TestUtils.kt @@ -11,7 +11,7 @@ fun readClasspathFile(path: String): String = val mapper = com.fasterxml.jackson.module.kotlin.jacksonObjectMapper() fun mockHttpResponse(url: String, responseJson: String) { - every { NetworkManagerClient.Companion.sendHTTPRequest(url, any()) } answers { + every { NetworkManagerClient.Companion.sendHTTPRequest(url, any(), any(), any(), any(), any()) } answers { mapper.readValue(responseJson, Map::class.java) as Map? } } \ No newline at end of file From cdad7d48f4a5b5a59ee5e6fa4e4626b3cc3ec8e7 Mon Sep 17 00:00:00 2001 From: abhip2565 Date: Tue, 25 Aug 2026 22:08:32 +0530 Subject: [PATCH 3/7] refactor(keyresolver): extract JWKS key selection and tighten it Selection used firstOrNull on 'kid', so a duplicate silently resolved the first match and nothing checked the key was published for signature verification. Require an unambiguous match plus use, key_ops and algorithm suitability; an unlabelled key stays a candidate since RFC 7515 4.1.4 makes 'kid' a hint. Duplicate 'kid' and non-verification keys are now rejected on the CwtVerifier path too. Signed-off-by: abhip2565 --- .../keyResolver/types/jwks/JwksKeySelector.kt | 115 ++++++++++++ .../types/jwks/JwksPublicKeyResolver.kt | 21 +-- .../types/jwks/JwksPublicKeyResolverTest.kt | 175 ++++++++++++++++++ 3 files changed, 292 insertions(+), 19 deletions(-) create mode 100644 vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksKeySelector.kt create mode 100644 vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksPublicKeyResolverTest.kt diff --git a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksKeySelector.kt b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksKeySelector.kt new file mode 100644 index 00000000..799d82b3 --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksKeySelector.kt @@ -0,0 +1,115 @@ +package io.mosip.vercred.vcverifier.keyResolver.types.jwks + +import io.mosip.vercred.vcverifier.constants.CredentialVerifierConstants.ED25519_ALGORITHM +import io.mosip.vercred.vcverifier.constants.CredentialVerifierConstants.JWS_EDDSA_SIGN_ALGO_CONST +import io.mosip.vercred.vcverifier.constants.CredentialVerifierConstants.JWS_ES256K_SIGN_ALGO_CONST +import io.mosip.vercred.vcverifier.constants.CredentialVerifierConstants.JWS_ES256_SIGN_ALGO_CONST +import io.mosip.vercred.vcverifier.constants.CredentialVerifierConstants.JWS_PS256_SIGN_ALGO_CONST +import io.mosip.vercred.vcverifier.constants.CredentialVerifierConstants.JWS_RS256_SIGN_ALGO_CONST +import io.mosip.vercred.vcverifier.constants.CredentialVerifierConstants.P256 +import io.mosip.vercred.vcverifier.constants.CredentialVerifierConstants.SECP256K1 +import io.mosip.vercred.vcverifier.exception.PublicKeyNotFoundException +import io.mosip.vercred.vcverifier.exception.PublicKeyResolutionFailedException +import io.mosip.vercred.vcverifier.constants.JwkParams +import io.mosip.vercred.vcverifier.keyResolver.getPublicKeyFromJWK +import java.security.PublicKey + +private val PRIVATE_JWK_PARAMS = setOf("d", "p", "q", "dp", "dq", "qi", "k", "oth") + +private data class KeyConstraint(val keyType: String, val curve: String? = null) + +private val ALGORITHM_KEY_CONSTRAINTS = mapOf( + JWS_ES256_SIGN_ALGO_CONST to KeyConstraint(JwkParams.KEY_TYPE_EC, P256), + JWS_ES256K_SIGN_ALGO_CONST to KeyConstraint(JwkParams.KEY_TYPE_EC, SECP256K1), + JWS_RS256_SIGN_ALGO_CONST to KeyConstraint(JwkParams.KEY_TYPE_RSA), + JWS_PS256_SIGN_ALGO_CONST to KeyConstraint(JwkParams.KEY_TYPE_RSA), + JWS_EDDSA_SIGN_ALGO_CONST to KeyConstraint(JwkParams.KEY_TYPE_OKP, ED25519_ALGORITHM) +) + +internal fun selectKeyFromJwks( + jwks: Map<*, *>, + keyId: String?, + algorithm: String? = null +): PublicKey { + val keys = jwks[JwkParams.KEYS] as? List<*> + ?: throw PublicKeyNotFoundException("JWKS 'keys' array not found") + val publishedKeys = keys.filterIsInstance>() + + val candidates = if (keyId != null) { + val exactMatches = publishedKeys.filter { it[JwkParams.KID] == keyId } + when { + // RFC 7517 4.5 only SHOULDs distinct 'kid' values; duplicates are rejected regardless for security + exactMatches.size > 1 -> + throw PublicKeyNotFoundException("Multiple keys found for kid=$keyId") + exactMatches.size == 1 -> { + val jwk = exactMatches.single() + validateVerificationKey(jwk, algorithm)?.let { + throw PublicKeyResolutionFailedException(it) + } + return toPublicKey(jwk) + } + else -> publishedKeys.filter { it[JwkParams.KID] == null }.ifEmpty { + throw PublicKeyNotFoundException("No matching key found for kid=$keyId") + } + } + } else { + publishedKeys + } + + val validationErrors = candidates.map { it to validateVerificationKey(it, algorithm) } + val usableKeys = validationErrors.filter { (_, problem) -> problem == null }.map { (jwk, _) -> jwk } + + if (usableKeys.size == 1) return toPublicKey(usableKeys.single()) + + validationErrors.singleOrNull()?.second?.let { throw PublicKeyResolutionFailedException(it) } + + throw PublicKeyNotFoundException( + if (usableKeys.isEmpty()) { + "No usable verification key found in JWKS" + (algorithm?.let { " for alg=$it" } ?: "") + } else { + "Cannot select between ${usableKeys.size} usable keys in JWKS; " + + if (keyId == null) "the JWT should carry a 'kid'" + else "the issuer should publish a 'kid' for each key" + } + ) +} + +private fun toPublicKey(jwk: Map<*, *>): PublicKey { + val keyType = jwk[JwkParams.KTY]?.toString() + ?: throw PublicKeyNotFoundException("Missing 'kty' in JWK") + + @Suppress("UNCHECKED_CAST") + return getPublicKeyFromJWK(jwk as Map, keyType) +} + + +private fun validateVerificationKey(jwk: Map<*, *>, algorithm: String?): String? { + if (PRIVATE_JWK_PARAMS.any { it in jwk.keys }) { + return "JWK must not contain private key material" + } + + val jwkAlgorithm = jwk[JwkParams.ALG]?.toString() + if (algorithm != null && jwkAlgorithm != null && jwkAlgorithm != algorithm) { + return "JWK 'alg' does not match the expected algorithm" + } + + val use = jwk[JwkParams.USE]?.toString() + if (use != null && use != JwkParams.USE_SIGNATURE) { + return "JWK 'use' must be '${JwkParams.USE_SIGNATURE}'" + } + + val keyOps = jwk[JwkParams.KEY_OPS] as? List<*> + if (keyOps != null && keyOps.none { it?.toString() == JwkParams.KEY_OP_VERIFY }) { + return "JWK 'key_ops' must permit '${JwkParams.KEY_OP_VERIFY}'" + } + + val constraint = algorithm?.let { ALGORITHM_KEY_CONSTRAINTS[it] } ?: return null + if (jwk[JwkParams.KTY]?.toString() != constraint.keyType) { + return "JWK 'kty' must be '${constraint.keyType}' for alg=$algorithm" + } + if (constraint.curve != null && jwk[JwkParams.CRV]?.toString() != constraint.curve) { + return "JWK 'crv' must be '${constraint.curve}' for alg=$algorithm" + } + + return null +} diff --git a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksPublicKeyResolver.kt b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksPublicKeyResolver.kt index 58cc9813..3dec1402 100644 --- a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksPublicKeyResolver.kt +++ b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksPublicKeyResolver.kt @@ -2,38 +2,21 @@ package io.mosip.vercred.vcverifier.keyResolver.types.jwks import io.mosip.vercred.vcverifier.exception.PublicKeyNotFoundException import io.mosip.vercred.vcverifier.keyResolver.PublicKeyResolver -import io.mosip.vercred.vcverifier.keyResolver.getPublicKeyFromJWK import io.mosip.vercred.vcverifier.networkManager.HttpMethod.GET import io.mosip.vercred.vcverifier.networkManager.NetworkManagerClient.Companion.sendHTTPRequest import java.security.PublicKey -import java.util.logging.Logger class JwksPublicKeyResolver : PublicKeyResolver { - private val logger = Logger.getLogger(JwksPublicKeyResolver::class.java.name) - override fun resolve(uri: String, keyId: String?): PublicKey { try { val response = sendHTTPRequest(uri, GET) ?: throw PublicKeyNotFoundException("JWKS response is null") - val keys = response["keys"] as? List<*> - ?: throw PublicKeyNotFoundException("JWKS 'keys' array not found") - - val jwk = keys - .filterIsInstance>() - .firstOrNull { it["kid"] == keyId } - ?: throw PublicKeyNotFoundException("No matching key found for kid=$keyId") - - val kty = jwk["kty"]?.toString() - ?: throw PublicKeyNotFoundException("Missing 'kty' in JWK") - - return getPublicKeyFromJWK(jwk, kty) + return selectKeyFromJwks(response, keyId) } catch (e: Exception) { throw if (e is PublicKeyNotFoundException) e else PublicKeyNotFoundException("Failed to resolve JWKS public key: ${e.message}").apply { initCause(e) } } } - - companion object -} \ No newline at end of file +} diff --git a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksPublicKeyResolverTest.kt b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksPublicKeyResolverTest.kt new file mode 100644 index 00000000..6f2e309c --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksPublicKeyResolverTest.kt @@ -0,0 +1,175 @@ +package io.mosip.vercred.vcverifier.keyResolver.types.jwks + +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkAll +import io.mosip.vercred.vcverifier.exception.PublicKeyNotFoundException +import io.mosip.vercred.vcverifier.networkManager.NetworkManagerClient +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class JwksPublicKeyResolverTest { + + private val uri = "https://issuer.example/jwks.json" + private val resolver = JwksPublicKeyResolver() + + private val publicJwk = mapOf( + "kid" to "signing-key-1", + "kty" to "EC", + "crv" to "P-256", + "x" to "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", + "y" to "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM" + ) + + @BeforeEach + fun setUp() { + mockkObject(NetworkManagerClient.Companion) + } + + @AfterEach + fun tearDown() { + clearAllMocks() + unmockkAll() + } + + private fun mockJwks(vararg keys: Map) { + every { NetworkManagerClient.sendHTTPRequest(uri, any()) } returns mapOf("keys" to keys.toList()) + } + + @Test + fun `resolves the key matching the requested kid`() { + val otherKey = publicJwk + mapOf("kid" to "signing-key-2") + mockJwks(publicJwk, otherKey) + + assertEquals("EC", resolver.resolve(uri, "signing-key-1").algorithm) + } + + @Test + fun `resolves the only published key when no kid is supplied`() { + mockJwks(publicJwk) + + assertEquals("EC", resolver.resolve(uri, null).algorithm) + } + + @Test + fun `throws when no kid is supplied and the set is ambiguous`() { + mockJwks(publicJwk, publicJwk + mapOf("kid" to "signing-key-2")) + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve(uri, null) + } + + assertTrue(error.message!!.contains("Cannot select between 2 usable keys")) + } + + @Test + fun `resolves an unlabelled key when the kid matches nothing labelled`() { + mockJwks(publicJwk - "kid") + + assertEquals("EC", resolver.resolve(uri, "signing-key-1").algorithm) + } + + @Test + fun `does not borrow a key labelled with a different kid`() { + mockJwks(publicJwk + mapOf("kid" to "signing-key-2")) + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve(uri, "signing-key-1") + } + + assertEquals("No matching key found for kid=signing-key-1", error.message) + } + + @Test + fun `throws when no key matches the kid`() { + mockJwks(publicJwk) + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve(uri, "unknown-key") + } + + assertEquals("No matching key found for kid=unknown-key", error.message) + } + + @Test + fun `throws when more than one key matches the kid`() { + mockJwks(publicJwk, publicJwk) + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve(uri, "signing-key-1") + } + + assertEquals("Multiple keys found for kid=signing-key-1", error.message) + } + + @Test + fun `throws when the keys array is missing`() { + every { NetworkManagerClient.sendHTTPRequest(uri, any()) } returns mapOf("issuer" to "https://issuer.example") + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve(uri, "signing-key-1") + } + + assertEquals("JWKS 'keys' array not found", error.message) + } + + @Test + fun `throws when the response is null`() { + every { NetworkManagerClient.sendHTTPRequest(uri, any()) } returns null + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve(uri, "signing-key-1") + } + + assertEquals("JWKS response is null", error.message) + } + + @Test + fun `rejects a key published for encryption`() { + mockJwks(publicJwk + mapOf("use" to "enc")) + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve(uri, "signing-key-1") + } + + assertTrue(error.message!!.contains("JWK 'use' must be 'sig'")) + } + + @Test + fun `rejects a key whose key_ops does not permit verify`() { + mockJwks(publicJwk + mapOf("key_ops" to listOf("encrypt"))) + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve(uri, "signing-key-1") + } + + assertTrue(error.message!!.contains("JWK 'key_ops' must permit 'verify'")) + } + + @Test + fun `rejects a JWK carrying private key material`() { + mockJwks(publicJwk + mapOf("d" to "870MB6gfuTJ4HtUnUvYMyJpr5eUZNP4Bk43bVdj3eAE")) + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve(uri, "signing-key-1") + } + + assertTrue(error.message!!.contains("JWK must not contain private key material")) + } + + @Test + fun `throws when kty is missing`() { + mockJwks(publicJwk - "kty") + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve(uri, "signing-key-1") + } + + assertEquals("Missing 'kty' in JWK", error.message) + } +} From 7b3b1d214c61db801d44a3a7423179b13d60bf92 Mon Sep 17 00:00:00 2001 From: abhip2565 Date: Tue, 25 Aug 2026 22:08:32 +0530 Subject: [PATCH 4/7] feat(sdjwt): resolve the issuer key from kid via issuer metadata or DID Only x5c was accepted, so any credential whose issuer publishes its keys as a JWK Set was rejected. Select the mechanism from 'iss' as draft-ietf-oauth-sd-jwt-vc-10 3.5 requires: an HTTPS 'iss' resolves through JWT VC Issuer Metadata, a DID through DID resolution, and x5c still wins when present. A DID in 'kid' is never dereferenced when 'iss' is HTTPS, since 10.2 forbids letting a credential choose the mechanism. DID resolution is an ecosystem addition 3.5 permits, not part of the draft; a trusted issuer policy is still missing and a TODO records it. Fixtures are real credentials and metadata from live issuers, replayed through a mock. Signed-off-by: abhip2565 --- .../verifier/SdJwtVerifier.kt | 79 +++- .../jwks/SdJwtVcIssuerMetadataResolver.kt | 76 +++ .../vcverifier/CredentialsVerifierTest.kt | 53 ++- .../verifier/SdJwtVerifierTest.kt | 234 ++++++++- .../jwks/SdJwtVcIssuerMetadataResolverTest.kt | 446 ++++++++++++++++++ .../jwksReferencedByJwksUri.json | 15 + .../metadataMatchingCredentialX5c.json | 15 + .../metadataWithInlineJwks.json | 21 + .../issuer_metadata/metadataWithJwksUri.json | 4 + .../sdJwtVcResolvableByX5cAndKid.txt | 1 + .../sd-jwt_vc/sdJwtVcWithDidKeyIssuer.txt | 1 + .../sd-jwt_vc/sdJwtVcWithX5cMultipleSans.txt | 1 + .../sd-jwt_vc/sdJwtVcWithX5cNoSan.txt | 1 + .../sdJwtVcWithX5cSanMatchingIss.txt | 1 + 14 files changed, 932 insertions(+), 16 deletions(-) create mode 100644 vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/SdJwtVcIssuerMetadataResolver.kt create mode 100644 vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/SdJwtVcIssuerMetadataResolverTest.kt create mode 100644 vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/jwksReferencedByJwksUri.json create mode 100644 vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/metadataMatchingCredentialX5c.json create mode 100644 vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/metadataWithInlineJwks.json create mode 100644 vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/metadataWithJwksUri.json create mode 100644 vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcResolvableByX5cAndKid.txt create mode 100644 vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithDidKeyIssuer.txt create mode 100644 vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithX5cMultipleSans.txt create mode 100644 vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithX5cNoSan.txt create mode 100644 vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithX5cSanMatchingIss.txt diff --git a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/credentialverifier/verifier/SdJwtVerifier.kt b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/credentialverifier/verifier/SdJwtVerifier.kt index 394688a6..7c0f884c 100644 --- a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/credentialverifier/verifier/SdJwtVerifier.kt +++ b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/credentialverifier/verifier/SdJwtVerifier.kt @@ -1,11 +1,33 @@ package io.mosip.vercred.vcverifier.credentialverifier.verifier import com.nimbusds.jose.JWSObject +import io.mosip.vercred.vcverifier.constants.DidMethod +import io.mosip.vercred.vcverifier.keyResolver.types.did.DidPublicKeyResolver +import io.mosip.vercred.vcverifier.keyResolver.types.jwks.SdJwtVcIssuerMetadataResolver import io.mosip.vercred.vcverifier.utils.Base64Decoder import io.mosip.vercred.vcverifier.utils.Util import io.mosip.vercred.vcverifier.utils.Util.verifyJwt import java.security.PublicKey +private const val DID_SCHEME = "did:" +private const val HTTPS_SCHEME = "https://" +/** + * DID methods accepted for the *issuer* key. + * + * `did:key` and `did:jwk` are self-certifying — the identifier *is* the key — which gives the + * strongest possible integrity between the `iss` value and the verification key, as + * draft-ietf-oauth-sd-jwt-vc-10 10.2 requires of an ecosystem-defined mechanism. What they do not + * establish is *authenticity*: that the DID belongs to the Issuer it claims to be. + * + * TODO: authenticity belongs to a trusted issuer policy, which this library does not yet have. + * Section 3.5 requires the mechanism to be "permitted for the given Issuer according to policy", + * and 10.2 requires that an attacker cannot influence which mechanism is used for a given `iss`. + * Until a trust list exists, a valid signature proves only that the holder of the named key signed + * the credential, not that the Issuer is one the Verifier trusts. The same gap leaves `x5c` + * certificates unchained; one trust policy would close both. + */ +private val PERMITTED_ISSUER_DID_METHODS = setOf(DidMethod.WEB, DidMethod.KEY, DidMethod.JWK) + class SdJwtVerifier { fun verify(credential: String): Boolean { @@ -19,12 +41,61 @@ class SdJwtVerifier { require(parts.size == 3) { "Invalid JWT format" } val jwsObject = JWSObject.parse(jwt) - val certBase64 = jwsObject.header.x509CertChain?.firstOrNull()?.toString() - ?: throw IllegalArgumentException("No X.509 certificate found in JWT header") + val header = jwsObject.header + val certBase64 = header.x509CertChain?.firstOrNull()?.toString() + val publicKey = if (certBase64 != null) { + getPublicKeyFromCertificate(certBase64) + } else { + resolvePublicKeyFromIssuer( + issuerClaim(jwsObject), + header.keyID, + header.algorithm.name + ) + } + + return verifyJwt(jwt, publicKey, header.algorithm.name) + } - val publicKey = getPublicKeyFromCertificate(certBase64) + private fun issuerClaim(jwsObject: JWSObject): String = + jwsObject.payload.toJSONObject()?.get("iss") as? String + ?: throw IllegalArgumentException( + "JWT 'iss' claim is required when no 'x5c' is present in the JWT header" + ) + + internal fun resolvePublicKeyFromIssuer( + issuer: String, + keyId: String?, + algorithm: String + ): PublicKey = when { + issuer.startsWith(DID_SCHEME) -> resolvePublicKeyFromDid(issuer, keyId) + issuer.startsWith(HTTPS_SCHEME, ignoreCase = true) -> + SdJwtVcIssuerMetadataResolver().resolve(issuer, keyId, algorithm) + + else -> throw IllegalArgumentException( + "JWT 'iss' must be a DID or an HTTPS URL to resolve the issuer key" + ) + } - return verifyJwt(jwt, publicKey, jwsObject.header.algorithm.name) + private fun resolvePublicKeyFromDid(issuer: String, keyId: String?): PublicKey { + require(!issuer.contains(Regex("[/?#]"))) { + "JWT 'iss' DID must not contain path, query, or fragment components" + } + val method = DidMethod.fromValue(issuer.removePrefix(DID_SCHEME).substringBefore(':')) + require(method in PERMITTED_ISSUER_DID_METHODS) { + "JWT 'iss' DID method is not supported for issuer keys. Supported: " + + PERMITTED_ISSUER_DID_METHODS.joinToString { "$DID_SCHEME${it.value}" } + } + requireNotNull(keyId) { + "JWT 'kid' is required when resolving the issuer key from a DID" + } + val verificationMethod = when { + keyId.startsWith("#") -> "$issuer$keyId" + keyId.startsWith("$issuer#") -> keyId + else -> throw IllegalArgumentException( + "JWT 'kid' must be a fragment or an absolute DID URL controlled by JWT 'iss'" + ) + } + return DidPublicKeyResolver().resolve(verificationMethod) } private fun getPublicKeyFromCertificate(certBase64: String): PublicKey { diff --git a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/SdJwtVcIssuerMetadataResolver.kt b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/SdJwtVcIssuerMetadataResolver.kt new file mode 100644 index 00000000..3bc9efe5 --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/SdJwtVcIssuerMetadataResolver.kt @@ -0,0 +1,76 @@ +package io.mosip.vercred.vcverifier.keyResolver.types.jwks + +import io.mosip.vercred.vcverifier.exception.PublicKeyNotFoundException +import io.mosip.vercred.vcverifier.exception.PublicKeyResolutionFailedException +import io.mosip.vercred.vcverifier.networkManager.HttpMethod.GET +import io.mosip.vercred.vcverifier.networkManager.NetworkManagerClient.Companion.sendHTTPRequest +import java.net.URI +import java.net.URISyntaxException +import java.security.PublicKey + +private const val WELL_KNOWN_PREFIX = "/.well-known/jwt-vc-issuer" + +internal class SdJwtVcIssuerMetadataResolver { + + fun resolve(issuer: String, keyId: String?, algorithm: String): PublicKey { + val metadataUri = metadataUriFor(issuer) + val metadata = sendHTTPRequest(metadataUri.toString(), GET) + ?: throw PublicKeyNotFoundException("JWT VC Issuer Metadata response is null") + + if (metadata["issuer"] != issuer) { + throw PublicKeyResolutionFailedException( + "JWT VC Issuer Metadata 'issuer' must exactly match the JWT 'iss' claim" + ) + } + + val inlineJwks = metadata["jwks"] as? Map<*, *> + val jwksUri = metadata["jwks_uri"] as? String + if ((inlineJwks == null) == (jwksUri == null)) { + throw PublicKeyResolutionFailedException( + "JWT VC Issuer Metadata must contain exactly one of 'jwks' or 'jwks_uri'" + ) + } + + val jwks = inlineJwks + ?: sendHTTPRequest(validateRemoteJwksUri(jwksUri!!).toString(), GET) + ?: throw PublicKeyNotFoundException("JWKS response is null") + + return selectKeyFromJwks(jwks, keyId, algorithm) + } + + internal fun metadataUriFor(issuer: String): URI { + val uri = parseUri(issuer) { + "JWT 'iss' must be an HTTPS URL without userinfo, query, or fragment" + } + if (!uri.isHttps() || uri.userInfo != null || uri.query != null || uri.fragment != null) { + throw PublicKeyResolutionFailedException( + "JWT 'iss' must be an HTTPS URL without userinfo, query, or fragment" + ) + } + + val issuerPath = uri.rawPath.orEmpty().trimEnd('/') + val host = uri.host.lowercase() + val authority = if (uri.port == -1) host else "$host:${uri.port}" + return URI("https://$authority$WELL_KNOWN_PREFIX$issuerPath") + } + + private fun validateRemoteJwksUri(value: String): URI { + val uri = parseUri(value) { "'jwks_uri' must be an HTTPS URL without userinfo or fragment" } + if (!uri.isHttps() || uri.userInfo != null || uri.fragment != null) { + throw PublicKeyResolutionFailedException( + "'jwks_uri' must be an HTTPS URL without userinfo or fragment" + ) + } + return uri + } + + /** The scheme is case-insensitive per RFC 3986, and [URI] preserves the case it was given. */ + private fun URI.isHttps() = scheme.equals("https", ignoreCase = true) && host != null + + private fun parseUri(value: String, message: () -> String): URI = + try { + URI(value) + } catch (e: URISyntaxException) { + throw PublicKeyResolutionFailedException(message()).apply { initCause(e) } + } +} diff --git a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/CredentialsVerifierTest.kt b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/CredentialsVerifierTest.kt index c9fa2b14..4ce2bdf0 100644 --- a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/CredentialsVerifierTest.kt +++ b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/CredentialsVerifierTest.kt @@ -1,6 +1,7 @@ package io.mosip.vercred.vcverifier import io.mockk.mockkObject +import io.mosip.vercred.vcverifier.constants.CredentialFormat.DC_SD_JWT import io.mosip.vercred.vcverifier.constants.CredentialFormat.LDP_VC import io.mosip.vercred.vcverifier.constants.CredentialFormat.MSO_MDOC import io.mosip.vercred.vcverifier.constants.CredentialFormat.VC_SD_JWT @@ -329,4 +330,54 @@ class CredentialsVerifierTest { assertEquals("", result.verificationResult.verificationMessage) assertEquals("", result.verificationResult.verificationErrorCode) } -} \ No newline at end of file + + /** + * Credentials captured from live external issuers; see `sd-jwt_vc/FIXTURES.md` for provenance. + * Between them they cover both Issuer Signature Mechanisms the verifier supports — an `x5c` + * certificate across four certificate shapes, and a `kid` resolved against a DID in `iss`. + * None requires a network call: the key is either embedded in the certificate or derived from + * the self-certifying DID. + */ + private fun verifyRealCredential(name: String) = + CredentialsVerifier().verify(readClasspathFile("sd-jwt_vc/$name").trim(), DC_SD_JWT) + + @Test + fun `should verify a real credential whose x5c certificate has a SAN matching iss`() { + val result = verifyRealCredential("sdJwtVcWithX5cSanMatchingIss.txt") + + assertTrue(result.verificationStatus) + assertEquals("", result.verificationErrorCode) + } + + @Test + fun `should verify a real credential whose x5c certificate carries no SAN`() { + val result = verifyRealCredential("sdJwtVcWithX5cNoSan.txt") + + assertTrue(result.verificationStatus) + assertEquals("", result.verificationErrorCode) + } + + @Test + fun `should verify a real credential whose x5c certificate carries several SANs`() { + val result = verifyRealCredential("sdJwtVcWithX5cMultipleSans.txt") + + assertTrue(result.verificationStatus) + assertEquals("", result.verificationErrorCode) + } + + @Test + fun `should verify a real credential resolvable by either mechanism, through its x5c`() { + val result = verifyRealCredential("sdJwtVcResolvableByX5cAndKid.txt") + + assertTrue(result.verificationStatus) + assertEquals("", result.verificationErrorCode) + } + + @Test + fun `should verify a real credential whose issuer key comes from a did-key in iss`() { + val result = verifyRealCredential("sdJwtVcWithDidKeyIssuer.txt") + + assertTrue(result.verificationStatus) + assertEquals("", result.verificationErrorCode) + } +} diff --git a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/credentialverifier/verifier/SdJwtVerifierTest.kt b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/credentialverifier/verifier/SdJwtVerifierTest.kt index ec928ea5..5d9bf4f7 100644 --- a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/credentialverifier/verifier/SdJwtVerifierTest.kt +++ b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/credentialverifier/verifier/SdJwtVerifierTest.kt @@ -1,6 +1,11 @@ package io.mosip.vercred.vcverifier.credentialverifier.verifier import io.mosip.vercred.vcverifier.credentialverifier.types.msomdoc.MsoMdocVerifiableCredential +import io.mockk.* +import io.mosip.vercred.vcverifier.keyResolver.types.did.DidPublicKeyResolver +import io.mosip.vercred.vcverifier.exception.PublicKeyNotFoundException +import io.mosip.vercred.vcverifier.networkManager.NetworkManagerClient +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertThrows @@ -8,6 +13,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.springframework.util.ResourceUtils import java.nio.file.Files +import java.security.PublicKey import java.util.Base64 class SdJwtVerifierTest{ @@ -21,6 +27,22 @@ class SdJwtVerifierTest{ assertTrue( SdJwtVerifier().verify(vc)) } + @Test + fun `should verify a real dc+sd-jwt successfully with x5c`() { + val file = ResourceUtils.getFile(ResourceUtils.CLASSPATH_URL_PREFIX + "sd-jwt_vc/sdJwtVcWithX5cSanMatchingIss.txt") + val vc = String(Files.readAllBytes(file.toPath())).trim() + + assertTrue(SdJwtVerifier().verify(vc)) + } + + @Test + fun `should verify a real dc+sd-jwt whose kid is a did-key`() { + val file = ResourceUtils.getFile(ResourceUtils.CLASSPATH_URL_PREFIX + "sd-jwt_vc/sdJwtVcWithDidKeyIssuer.txt") + val vc = String(Files.readAllBytes(file.toPath())).trim() + + assertTrue(SdJwtVerifier().verify(vc)) + } + @Test fun `should return false for tampered sd-jwt`() { @@ -42,28 +64,218 @@ class SdJwtVerifierTest{ } @Test - fun `should throw exception for sd-jwt whose header carries no x5c`() { - val missingCertificateException = assertThrows(IllegalArgumentException::class.java) { - SdJwtVerifier().verify(sdJwtWithHeader("""{"alg":"ES256","typ":"vc+sd-jwt"}""")) + fun `should fall back to issuer resolution when the header carries no x5c`() { + val error = assertThrows(IllegalArgumentException::class.java) { + SdJwtVerifier().verify( + sdJwtWithHeader("""{"alg":"ES256","typ":"vc+sd-jwt"}""", """{"iss":"urn:issuer"}""") + ) } - assertEquals("No X.509 certificate found in JWT header", missingCertificateException.message) + assertEquals("JWT 'iss' must be a DID or an HTTPS URL to resolve the issuer key", error.message) } @Test - fun `should throw exception for sd-jwt whose x5c is an empty chain`() { - val emptyCertificateChainException = assertThrows(IllegalArgumentException::class.java) { - SdJwtVerifier().verify(sdJwtWithHeader("""{"alg":"ES256","typ":"vc+sd-jwt","x5c":[]}""")) + fun `should fall back to issuer resolution when x5c is an empty chain`() { + val error = assertThrows(IllegalArgumentException::class.java) { + SdJwtVerifier().verify( + sdJwtWithHeader( + """{"alg":"ES256","typ":"vc+sd-jwt","x5c":[]}""", + """{"iss":"urn:issuer"}""" + ) + ) } - assertEquals("No X.509 certificate found in JWT header", emptyCertificateChainException.message) + assertEquals("JWT 'iss' must be a DID or an HTTPS URL to resolve the issuer key", error.message) + } + + @Test + fun `should require an iss claim when the header carries no x5c`() { + val error = assertThrows(IllegalArgumentException::class.java) { + SdJwtVerifier().verify(sdJwtWithHeader("""{"alg":"ES256","typ":"vc+sd-jwt"}""", "{}")) + } + + assertEquals( + "JWT 'iss' claim is required when no 'x5c' is present in the JWT header", + error.message + ) + } + + @Test + fun `should resolve an https issuer key when the JWT carries no kid`() { + mockkObject(NetworkManagerClient.Companion) + every { + NetworkManagerClient.sendHTTPRequest( + "https://issuer.example/.well-known/jwt-vc-issuer", + any() + ) + } returns mapOf( + "issuer" to "https://issuer.example", + "jwks" to mapOf( + "keys" to listOf( + mapOf( + "kty" to "EC", + "crv" to "P-256", + "x" to "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", + "y" to "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM" + ) + ) + ) + ) + + assertEquals( + "EC", + SdJwtVerifier().resolvePublicKeyFromIssuer("https://issuer.example", null, "ES256").algorithm + ) + } + + @Test + fun `should require a kid when resolving the issuer key from a DID`() { + val error = assertThrows(IllegalArgumentException::class.java) { + SdJwtVerifier().resolvePublicKeyFromIssuer("did:web:issuer.example", null, "ES256") + } + + assertEquals("JWT 'kid' is required when resolving the issuer key from a DID", error.message) + } + + @Test + fun `should resolve relative DID kid only against issuer DID`() { + val publicKey = mockk() + mockkConstructor(DidPublicKeyResolver::class) + every { anyConstructed().resolve("did:web:issuer.example#key-1", null) } returns publicKey + + assertEquals( + publicKey, + SdJwtVerifier().resolvePublicKeyFromIssuer("did:web:issuer.example", "#key-1", "ES256") + ) + } + + @Test + fun `should reject DID kid controlled by a different issuer`() { + val error = assertThrows(IllegalArgumentException::class.java) { + SdJwtVerifier().resolvePublicKeyFromIssuer( + "did:web:issuer.example", + "did:web:attacker.example#key-1", + "ES256" + ) + } + + assertEquals( + "JWT 'kid' must be a fragment or an absolute DID URL controlled by JWT 'iss'", + error.message + ) + } + + @Test + fun `should resolve https issuer kid through JWT VC issuer metadata`() { + mockkObject(NetworkManagerClient.Companion) + every { + NetworkManagerClient.sendHTTPRequest( + "https://issuer.example/.well-known/jwt-vc-issuer", + any() + ) + } returns mapOf( + "issuer" to "https://issuer.example", + "jwks" to mapOf( + "keys" to listOf( + mapOf( + "kid" to "signing-key-1", + "kty" to "EC", + "crv" to "P-256", + "x" to "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", + "y" to "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM" + ) + ) + ) + ) + + assertEquals( + "EC", + SdJwtVerifier().resolvePublicKeyFromIssuer("https://issuer.example", "signing-key-1", "ES256").algorithm + ) + } + + @Test + fun `should not dereference a DID in kid when iss is an HTTPS URL`() { + mockkObject(NetworkManagerClient.Companion) + every { + NetworkManagerClient.sendHTTPRequest( + "https://issuer.example/.well-known/jwt-vc-issuer", any(), any(), any(), any(), any() + ) + } returns mapOf( + "issuer" to "https://issuer.example", + "jwks" to mapOf( + "keys" to listOf( + mapOf( + "kid" to "signing-key-1", + "kty" to "EC", "crv" to "P-256", + "x" to "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", + "y" to "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM" + ) + ) + ) + ) + mockkConstructor(DidPublicKeyResolver::class) + + // The mechanism is chosen by 'iss' alone, so a DID in 'kid' is only ever a JWK Set lookup + // string. Dereferencing it would let a credential pick the verification process for an + // issuer, which draft-ietf-oauth-sd-jwt-vc-10 10.2 forbids. + val error = assertThrows(PublicKeyNotFoundException::class.java) { + SdJwtVerifier().resolvePublicKeyFromIssuer( + "https://issuer.example", "did:web:attacker.example#key-1", "ES256" + ) + } + + assertEquals("No matching key found for kid=did:web:attacker.example#key-1", error.message) + verify(exactly = 0) { anyConstructed().resolve(any(), any()) } + } + + @Test + fun `should reject unknown DID methods for the issuer key`() { + val error = assertThrows(IllegalArgumentException::class.java) { + SdJwtVerifier().resolvePublicKeyFromIssuer("did:ion:EiClaZ", "#key-1", "ES256") + } + + assertEquals( + "JWT 'iss' DID method is not supported for issuer keys. " + + "Supported: did:web, did:key, did:jwk", + error.message + ) + } + + @Test + fun `should reject a kid whose iss is neither a DID nor an HTTPS URL`() { + listOf( + "urn:uuid:8d8ac610-566d-4ef0-9c22-186b2a5ed793", + "http://issuer.example", + "ftp://issuer.example", + "issuer.example", + "" + ).forEach { issuer -> + val error = assertThrows(IllegalArgumentException::class.java) { + SdJwtVerifier().resolvePublicKeyFromIssuer(issuer, "signing-key-1", "ES256") + } + + assertEquals( + "JWT 'iss' must be a DID or an HTTPS URL to resolve the issuer key", + error.message + ) + } + } + + @AfterEach + fun tearDown() { + clearAllMocks() + unmockkAll() } - private fun sdJwtWithHeader(header: String): String { + private fun sdJwtWithHeader( + header: String, + payloadJson: String = """{"iss":"https://issuer.example"}""" + ): String { val encoder = Base64.getUrlEncoder().withoutPadding() - val payload = encoder.encodeToString("""{"iss":"https://issuer.example"}""".toByteArray()) + val payload = encoder.encodeToString(payloadJson.toByteArray()) val signature = encoder.encodeToString(ByteArray(64)) return "${encoder.encodeToString(header.toByteArray())}.$payload.$signature~" } -} \ No newline at end of file +} diff --git a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/SdJwtVcIssuerMetadataResolverTest.kt b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/SdJwtVcIssuerMetadataResolverTest.kt new file mode 100644 index 00000000..b7d36571 --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/SdJwtVcIssuerMetadataResolverTest.kt @@ -0,0 +1,446 @@ +package io.mosip.vercred.vcverifier.keyResolver.types.jwks + +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkAll +import io.mosip.vercred.vcverifier.exception.PublicKeyNotFoundException +import io.mosip.vercred.vcverifier.exception.PublicKeyResolutionFailedException +import io.mosip.vercred.vcverifier.networkManager.NetworkManagerClient +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import io.mosip.vercred.vcverifier.utils.Util +import testutils.mapper +import testutils.readClasspathFile + +class SdJwtVcIssuerMetadataResolverTest { + + private val resolver = SdJwtVcIssuerMetadataResolver() + + private val publicJwk = mapOf( + "kid" to "signing-key-1", + "kty" to "EC", + "crv" to "P-256", + "alg" to "ES256", + "use" to "sig", + "key_ops" to listOf("verify"), + "x" to "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", + "y" to "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM" + ) + + @BeforeEach + fun setUp() { + mockkObject(NetworkManagerClient.Companion) + } + + @AfterEach + fun tearDown() { + clearAllMocks() + unmockkAll() + } + + private fun mockMetadata(url: String, response: Map?) { + every { NetworkManagerClient.sendHTTPRequest(url, any()) } returns response + } + + @Test + fun `constructs metadata URI by inserting well-known path before issuer path`() { + assertEquals( + "https://issuer.example/.well-known/jwt-vc-issuer/tenant/123", + resolver.metadataUriFor("https://issuer.example/tenant/123/").toString() + ) + } + + @Test + fun `constructs metadata URI for issuer without a path`() { + assertEquals( + "https://issuer.example/.well-known/jwt-vc-issuer", + resolver.metadataUriFor("https://issuer.example").toString() + ) + } + + @Test + fun `preserves explicit port and percent-encoded issuer path`() { + assertEquals( + "https://issuer.example:8443/.well-known/jwt-vc-issuer/a%20b", + resolver.metadataUriFor("https://issuer.example:8443/a%20b").toString() + ) + } + + @Test + fun `resolves kid from issuer-bound inline JWKS`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf("issuer" to "https://issuer.example", "jwks" to mapOf("keys" to listOf(publicJwk))) + ) + + assertEquals( + "EC", + resolver.resolve("https://issuer.example", "signing-key-1", "ES256").algorithm + ) + } + + @Test + fun `resolves kid by following jwks_uri`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf("issuer" to "https://issuer.example", "jwks_uri" to "https://issuer.example/keys") + ) + mockMetadata("https://issuer.example/keys", mapOf("keys" to listOf(publicJwk))) + + assertEquals( + "EC", + resolver.resolve("https://issuer.example", "signing-key-1", "ES256").algorithm + ) + } + + @Test + fun `rejects non-https jwks_uri`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf("issuer" to "https://issuer.example", "jwks_uri" to "http://issuer.example/keys") + ) + + val error = assertThrows(PublicKeyResolutionFailedException::class.java) { + resolver.resolve("https://issuer.example", "signing-key-1", "ES256") + } + assertTrue(error.message!!.contains("'jwks_uri' must be an HTTPS URL")) + } + + @Test + fun `rejects metadata carrying both jwks and jwks_uri`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf( + "issuer" to "https://issuer.example", + "jwks" to mapOf("keys" to listOf(publicJwk)), + "jwks_uri" to "https://issuer.example/keys" + ) + ) + + val error = assertThrows(PublicKeyResolutionFailedException::class.java) { + resolver.resolve("https://issuer.example", "signing-key-1", "ES256") + } + assertTrue(error.message!!.contains("exactly one of 'jwks' or 'jwks_uri'")) + } + + @Test + fun `rejects metadata issuer substitution`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf("issuer" to "https://attacker.example", "jwks" to mapOf("keys" to listOf(publicJwk))) + ) + + val error = assertThrows(PublicKeyResolutionFailedException::class.java) { + resolver.resolve("https://issuer.example", "signing-key-1", "ES256") + } + assertTrue(error.message!!.contains("exactly match")) + } + + @Test + fun `rejects ambiguous duplicate kid`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf( + "issuer" to "https://issuer.example", + "jwks" to mapOf("keys" to listOf(publicJwk, publicJwk)) + ) + ) + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve("https://issuer.example", "signing-key-1", "ES256") + } + assertTrue(error.message!!.contains("Multiple keys")) + } + + private val unlabelledEcJwk = publicJwk - "kid" + + private val unlabelledEd25519Jwk = mapOf( + "kty" to "OKP", + "crv" to "Ed25519", + "x" to "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + ) + + @Test + fun `resolves the sole unlabelled key when the JWT kid matches nothing published`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf("issuer" to "https://issuer.example", "jwks" to mapOf("keys" to listOf(unlabelledEcJwk))) + ) + + assertEquals( + "EC", + resolver.resolve("https://issuer.example", "signing-key-1", "ES256").algorithm + ) + } + + @Test + fun `disambiguates unlabelled keys by the JWT algorithm`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf( + "issuer" to "https://issuer.example", + "jwks" to mapOf("keys" to listOf(unlabelledEd25519Jwk, unlabelledEcJwk)) + ) + ) + + assertEquals( + "EC", + resolver.resolve("https://issuer.example", "signing-key-1", "ES256").algorithm + ) + } + + @Test + fun `rejects when several unlabelled keys suit the JWT algorithm`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf( + "issuer" to "https://issuer.example", + "jwks" to mapOf("keys" to listOf(unlabelledEcJwk, unlabelledEcJwk)) + ) + ) + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve("https://issuer.example", "signing-key-1", "ES256") + } + assertEquals( + "Cannot select between 2 usable keys in JWKS; " + + "the issuer should publish a 'kid' for each key", + error.message + ) + } + + @Test + fun `prefers an unlabelled key over one labelled with a different kid`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf( + "issuer" to "https://issuer.example", + "jwks" to mapOf( + "keys" to listOf(publicJwk + mapOf("kid" to "other-key"), unlabelledEcJwk) + ) + ) + ) + + assertEquals( + "EC", + resolver.resolve("https://issuer.example", "signing-key-1", "ES256").algorithm + ) + } + + @Test + fun `resolves a labelled key when the JWT carries no kid`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf("issuer" to "https://issuer.example", "jwks" to mapOf("keys" to listOf(publicJwk))) + ) + + assertEquals( + "EC", + resolver.resolve("https://issuer.example", null, "ES256").algorithm + ) + } + + @Test + fun `disambiguates labelled keys by algorithm when the JWT carries no kid`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf( + "issuer" to "https://issuer.example", + "jwks" to mapOf( + "keys" to listOf( + unlabelledEd25519Jwk + mapOf("kid" to "ed-key"), + publicJwk + ) + ) + ) + ) + + assertEquals( + "EC", + resolver.resolve("https://issuer.example", null, "ES256").algorithm + ) + } + + @Test + fun `rejects when the JWT carries no kid and several labelled keys suit the algorithm`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf( + "issuer" to "https://issuer.example", + "jwks" to mapOf( + "keys" to listOf(publicJwk, publicJwk + mapOf("kid" to "signing-key-2")) + ) + ) + ) + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve("https://issuer.example", null, "ES256") + } + assertEquals( + "Cannot select between 2 usable keys in JWKS; the JWT should carry a 'kid'", + error.message + ) + } + + @Test + fun `reports why a sole unlabelled candidate cannot be used`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf( + "issuer" to "https://issuer.example", + "jwks" to mapOf("keys" to listOf(unlabelledEcJwk + mapOf("use" to "enc"))) + ) + ) + + val error = assertThrows(PublicKeyResolutionFailedException::class.java) { + resolver.resolve("https://issuer.example", "signing-key-1", "ES256") + } + assertEquals("JWK 'use' must be 'sig'", error.message) + } + + @Test + fun `reports the algorithm mismatch for a sole candidate when the JWT carries no kid`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf( + "issuer" to "https://issuer.example", + "jwks" to mapOf("keys" to listOf(unlabelledEd25519Jwk)) + ) + ) + + val error = assertThrows(PublicKeyResolutionFailedException::class.java) { + resolver.resolve("https://issuer.example", null, "ES256") + } + assertEquals("JWK 'kty' must be 'EC' for alg=ES256", error.message) + } + + @Suppress("UNCHECKED_CAST") + private fun fixture(name: String) = + mapper.readValue( + readClasspathFile("sd-jwt_vc/issuer_metadata/$name"), Map::class.java + ) as Map + + @Test + fun `resolves against real inline-JWKS metadata captured from a live issuer`() { + val issuer = "https://demo.pid-issuer.bundesdruckerei.de/c" + mockMetadata( + "https://demo.pid-issuer.bundesdruckerei.de/.well-known/jwt-vc-issuer/c", + fixture("metadataWithInlineJwks.json") + ) + + assertEquals("EC", resolver.resolve(issuer, null, "ES256").algorithm) + } + + @Test + fun `resolves against real jwks_uri metadata captured from a live issuer`() { + val issuer = "https://trial.authlete.net" + mockMetadata("$issuer/.well-known/jwt-vc-issuer", fixture("metadataWithJwksUri.json")) + mockMetadata("$issuer/api/vci/jwks", fixture("jwksReferencedByJwksUri.json")) + + assertEquals( + "EC", + resolver.resolve(issuer, "ZYGIOHYuA9IpUijVwQNul3nE536x1JSWHiOfdS7sadg", "ES256").algorithm + ) + } + + @Test + fun `rejects unknown kid`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf("issuer" to "https://issuer.example", "jwks" to mapOf("keys" to listOf(publicJwk))) + ) + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve("https://issuer.example", "unknown-key", "ES256") + } + assertTrue(error.message!!.contains("No matching key found for kid=unknown-key")) + } + + @Test + fun `rejects algorithm confusion`() { + mockMetadata( + "https://issuer.example/.well-known/jwt-vc-issuer", + mapOf("issuer" to "https://issuer.example", "jwks" to mapOf("keys" to listOf(publicJwk))) + ) + + val error = assertThrows(PublicKeyResolutionFailedException::class.java) { + resolver.resolve("https://issuer.example", "signing-key-1", "RS256") + } + assertTrue(error.message!!.contains("does not match")) + } + + @Test + fun `throws when metadata response is null`() { + mockMetadata("https://issuer.example/.well-known/jwt-vc-issuer", null) + + assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve("https://issuer.example", "signing-key-1", "ES256") + } + } + + @Test + fun `accepts an uppercase scheme and host and normalises them`() { + assertEquals( + "https://issuer.example/.well-known/jwt-vc-issuer/x", + resolver.metadataUriFor("HTTPS://Issuer.Example/x").toString() + ) + } + + @Test + fun `rejects a malformed issuer URL without leaking URISyntaxException`() { + val error = assertThrows(PublicKeyResolutionFailedException::class.java) { + resolver.metadataUriFor("https://[bad") + } + assertTrue(error.message!!.contains("must be an HTTPS URL")) + } + + @Test + fun `rejects issuer URLs unsafe for metadata discovery`() { + listOf( + "http://issuer.example", + "https://user@issuer.example", + "https://issuer.example/path?query=value", + "https://issuer.example/path#fragment", + "urn:uuid:8d8ac610-566d-4ef0-9c22-186b2a5ed793", + "ftp://issuer.example", + "issuer.example" + ).forEach { issuer -> + assertThrows(PublicKeyResolutionFailedException::class.java) { + resolver.metadataUriFor(issuer) + } + } + } + + /** + * The credential in this fixture carries an `x5c` certificate whose public key is byte-identical + * to the key its issuer publishes as metadata, so the same signature verifies through either + * mechanism. That makes it the only cover for a metadata-resolved key against a signature we did + * not produce ourselves. + * + * It deliberately bypasses [io.mosip.vercred.vcverifier.credentialverifier.verifier.SdJwtVerifier], + * which resolves this credential through its certificate instead — `x5c` takes precedence in the + * dispatch. Verifying the metadata mechanism end to end needs a credential carrying `kid` and no + * `x5c`, which no issuer surveyed produces. Should the mechanism-precedence question in + * draft-ietf-oauth-sd-jwt-vc-10 10.2 be resolved in favour of the mechanism `iss` designates, + * this becomes that end-to-end test unchanged. + */ + @Test + fun `resolves a key that verifies a real issuer's signature`() { + val issuer = "https://demo-issuer.wwwallet.org/openid" + mockMetadata( + "https://demo-issuer.wwwallet.org/.well-known/jwt-vc-issuer/openid", + fixture("metadataMatchingCredentialX5c.json") + ) + val issuerSignedJwt = readClasspathFile("sd-jwt_vc/sdJwtVcResolvableByX5cAndKid.txt") + .trim().split("~").first() + + val publicKey = resolver.resolve(issuer, "8636af04-5796-4f46-a73e-d690d7d4e7f3", "ES256") + + assertTrue(Util.verifyJwt(issuerSignedJwt, publicKey, "ES256")) + } +} diff --git a/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/jwksReferencedByJwksUri.json b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/jwksReferencedByJwksUri.json new file mode 100644 index 00000000..87258564 --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/jwksReferencedByJwksUri.json @@ -0,0 +1,15 @@ +{ + "keys": [ + { + "kty": "EC", + "crv": "P-256", + "kid": "ZYGIOHYuA9IpUijVwQNul3nE536x1JSWHiOfdS7sadg", + "x5c": [ + "MIICSDCCAe6gAwIBAgIUKJS5GmQjnfcBk3Zb/eW+IhnQk8swCgYIKoZIzj0EAwIwZTELMAkGA1UEBhMCSlAxDjAMBgNVBAgMBVRva3lvMRAwDgYDVQQHDAdDaGl5b2RhMRcwFQYDVQQKDA5BdXRobGV0ZSwgSW5jLjEbMBkGA1UEAwwSdHJpYWwuYXV0aGxldGUubmV0MCAXDTI1MDIyNTA1MzI0OVoYDzIyOTgxMjExMDUzMjQ5WjBlMQswCQYDVQQGEwJKUDEOMAwGA1UECAwFVG9reW8xEDAOBgNVBAcMB0NoaXlvZGExFzAVBgNVBAoMDkF1dGhsZXRlLCBJbmMuMRswGQYDVQQDDBJ0cmlhbC5hdXRobGV0ZS5uZXQwWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQOd6TCwnpnmXqps8RQHq+K9NooFbrjb1yyFxJlHk7WfTVOrZDu9Nq+IOc9yro7y+G9rTf0zt8OWJ5i/WijjILTo3oweDAdBgNVHQ4EFgQUPMYa6fQJP6M6x4tYM1fbf3/E0xMwHwYDVR0jBBgwFoAUPMYa6fQJP6M6x4tYM1fbf3/E0xMwDwYDVR0TAQH/BAUwAwEB/zAlBgNVHREEHjAchhpodHRwczovL3RyaWFsLmF1dGhsZXRlLm5ldDAKBggqhkjOPQQDAgNIADBFAiEAyo3A/oQX/Me8myMWL025cTBw/keceHELfSQHmle5QAECIC5g/ynOmt/nuksfHAeMABf7to52zTRkRduqQCZHO3eY" + ], + "x": "DnekwsJ6Z5l6qbPEUB6vivTaKBW6429cshcSZR5O1n0", + "y": "NU6tkO702r4g5z3KujvL4b2tN_TO3w5YnmL9aKOMgtM", + "alg": "ES256" + } + ] +} diff --git a/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/metadataMatchingCredentialX5c.json b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/metadataMatchingCredentialX5c.json new file mode 100644 index 00000000..c1275337 --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/metadataMatchingCredentialX5c.json @@ -0,0 +1,15 @@ +{ + "issuer": "https://demo-issuer.wwwallet.org/openid", + "jwks": { + "keys": [ + { + "kid": "8636af04-5796-4f46-a73e-d690d7d4e7f3", + "kty": "EC", + "x": "i1OE5A6r2dwSOMTUJdx-0YrhQ0gbiA-UNvUEQaaqRfI", + "y": "AeZvKWT6qgJtL-0VldiKIKf3ixw3S33tbg6JUUKDZeU", + "crv": "P-256", + "alg": "ES256" + } + ] + } +} diff --git a/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/metadataWithInlineJwks.json b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/metadataWithInlineJwks.json new file mode 100644 index 00000000..16d429e3 --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/metadataWithInlineJwks.json @@ -0,0 +1,21 @@ +{ + "issuer": "https://demo.pid-issuer.bundesdruckerei.de/c", + "jwks": { + "keys": [ + { + "kty": "EC", + "x5t#S256": "JmkRlSN_v0bcqgQ7VMmE5FfZ1hcNF1VxkDCLk-fwTqg", + "nbf": 1785391200, + "use": "sig", + "crv": "P-256", + "kid": "MIGUMIGOpIGLMIGIMQswCQYDVQQGEwJERTEPMA0GA1UEBwwGQmVybGluMR0wGwYDVQQKDBRCdW5kZXNkcnVja2VyZWkgR21iSDERMA8GA1UECwwIVCBDUyBJREUxNjA0BgNVBAMMLVNQUklORCBGdW5rZSBFVURJIFdhbGxldCBQcm90b3R5cGUgSXNzdWluZyBDQQIBBA==", + "x5c": [ + "MIICdTCCAhugAwIBAgIBBDAKBggqhkjOPQQDAjCBiDELMAkGA1UEBhMCREUxDzANBgNVBAcMBkJlcmxpbjEdMBsGA1UECgwUQnVuZGVzZHJ1Y2tlcmVpIEdtYkgxETAPBgNVBAsMCFQgQ1MgSURFMTYwNAYDVQQDDC1TUFJJTkQgRnVua2UgRVVESSBXYWxsZXQgUHJvdG90eXBlIElzc3VpbmcgQ0EwHhcNMjYwNzMwMDYwMDAwWhcNMjcwOTAzMDYwMDAwWjBsMQswCQYDVQQGEwJERTEdMBsGA1UECgwUQnVuZGVzZHJ1Y2tlcmVpIEdtYkgxCjAIBgNVBAsMAUkxMjAwBgNVBAMMKVNQUklORCBGdW5rZSBFVURJIFdhbGxldCBQcm90b3R5cGUgSXNzdWVyMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAETce+F2OXzJpSwP6FtjORuuUO1/wQeCV4VOOQ4/OBQl8OEsVk/0pxZ0oI4FUEiGQlg236QVITkU0Mjg69/GC2YKOBkDCBjTAdBgNVHQ4EFgQUmNglnxbmuAnWWwUYj2ZnS7CKVZIwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwLQYDVR0RBCYwJIIiZGVtby5waWQtaXNzdWVyLmJ1bmRlc2RydWNrZXJlaS5kZTAfBgNVHSMEGDAWgBTUVhjAiTjoDliEGMl2Yr+ru8WQvjAKBggqhkjOPQQDAgNIADBFAiAMidSzycsqf/oBAU9WWFIF2emsl7lFll3q/jJh2x1DaAIhAKOtdzVpoXUVChn4XELOODjOJ9rx8ZkK6334c/F/YJKM" + ], + "x": "Tce-F2OXzJpSwP6FtjORuuUO1_wQeCV4VOOQ4_OBQl8", + "y": "DhLFZP9KcWdKCOBVBIhkJYNt-kFSE5FNDI4OvfxgtmA", + "exp": 1819951200 + } + ] + } +} diff --git a/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/metadataWithJwksUri.json b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/metadataWithJwksUri.json new file mode 100644 index 00000000..808890c6 --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/issuer_metadata/metadataWithJwksUri.json @@ -0,0 +1,4 @@ +{ + "issuer": "https://trial.authlete.net", + "jwks_uri": "https://trial.authlete.net/api/vci/jwks" +} diff --git a/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcResolvableByX5cAndKid.txt b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcResolvableByX5cAndKid.txt new file mode 100644 index 00000000..67f654b2 --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcResolvableByX5cAndKid.txt @@ -0,0 +1 @@ +eyJ0eXAiOiJkYytzZC1qd3QiLCJ4NWMiOlsiTUlJQzFUQ0NBbjJnQXdJQkFnSUpBTU1CU3dsR01zem1NQWtHQnlxR1NNNDlCQUV3UHpFTE1Ba0dBMVVFQmhNQ1JWVXhGVEFUQmdOVkJBb01ESGQzVjJGc2JHVjBMbTl5WnpFWk1CY0dBMVVFQXd3UWQzZFhZV3hzWlhRZ1VtOXZkQ0JEUVRBZUZ3MHlOakF5TWpBeE5UTTJNREphRncweU56QXlNakF4TlRNMk1ESmFNRU14Q3pBSkJnTlZCQVlUQWtWVk1SVXdFd1lEVlFRS0RBeDNkMWRoYkd4bGRDNXZjbWN4SFRBYkJnTlZCQU1NRkdWNFlXMXdiR1V1ZDNkM1lXeHNaWFF1YjNKbk1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRWkxT0U1QTZyMmR3U09NVFVKZHgrMFlyaFEwZ2JpQStVTnZVRVFhYXFSZklCNW04cFpQcXFBbTB2N1JXVjJJb2dwL2VMSERkTGZlMXVEb2xSUW9ObDVhT0NBVjB3Z2dGWk1COEdBMVVkSXdRWU1CYUFGS1V2RGZkL2liTVhaNGhjVWhwTjB6VTQ0Z2dWTUIwR0ExVWREZ1FXQkJRMkJQazZaRm5EbUI0SGlabnEzTHZpSFdCaEREQU9CZ05WSFE4QkFmOEVCQU1DQjRBd09nWURWUjBTQkRNd01ZRVJhVzVtYjBCM2QzZGhiR3hsZEM1dmNtZUdIR2gwZEhCek9pOHZaWGhoYlhCc1pTNTNkM2RoYkd4bGRDNXZjbWN3RWdZRFZSMGxCQXN3Q1FZSEtJR01YUVVCQmpBTUJnTlZIUk1CQWY4RUFqQUFNRXdHQTFVZEh3UkZNRU13UWFBL29EMkdPMmgwZEhCek9pOHZaWGhoYlhCc1pTNTNkM2RoYkd4bGRDNXZjbWN2YVdGallTOWpjbXd2ZDNkM1lXeHNaWFJmYjNKblgybGhZMkV1WTNKc01Gc0dBMVVkRVFSVU1GS0NGR1Y0WVcxd2JHVXVkM2QzWVd4c1pYUXViM0puZ2h0bGVHRnRjR3hsTFdsemMzVmxjaTUzZDNkaGJHeGxkQzV2Y21lQ0hXVjRZVzF3YkdVdGRtVnlhV1pwWlhJdWQzZDNZV3hzWlhRdWIzSm5NQWtHQnlxR1NNNDlCQUVEUndBd1JBSWdYZUU4TEFNeGU5T3J0Y1g1alBiK2NtU1ViSTBVUmZVKzdUTlJTMWE0d1JrQ0lGbTFCRm9ZYUZLWmdqQ3FxMU9uWjJOUmg5QVk1TVBDbGhhSzhTMjhyVTN4Il0sImFsZyI6IkVTMjU2In0.eyJzdWIiOiJ5OFlpdWh2ZzA1Smt4NDNOcVl0OWJ0ZVhZajJxYklYRVc1SGxVdEhPMWtBIiwidmN0IjoidXJuOmNyZWRlbnRpYWw6ZGlwbG9tYSIsImV4cGlyeV9kYXRlIjoiMjAzNS0wNC0yMSIsImVxZl9sZXZlbCI6NiwiYmlydGhfZGF0ZSI6IjE5OTAtMTAtMTUiLCJibHVlcHJpbnRfaWQiOjIsImNuZiI6eyJqd2siOnsia3R5IjoiRUMiLCJjcnYiOiJQLTI1NiIsIngiOiJ3ZW02SGJhTE9icUk3SlRQaEtTSGR5VkN1YlVMbkJvR0d4MTlSdTZzNWRnIiwieSI6ImJoTmlYejVFVzFBeHNNMG5jd3JpTjVwWmpOdjNkTGtocmZLU3NDYTEwOUUifX0sInZjdCNpbnRlZ3JpdHkiOiJzaGEyNTYtRTFjaXFjcGJKQTVvTnd5ZDlranloa0Y2c0JVU3RQb1oxZ1ZYYmNBVk95WT0iLCJpYXQiOjE3ODc2Njg2NDAsIm5iZiI6MTc4NzY2ODY0MCwiZXhwIjoxODE5MjA0NjQwLCJpc3MiOiJodHRwczovL2RlbW8taXNzdWVyLnd3d2FsbGV0Lm9yZy9vcGVuaWQiLCJfc2QiOlsiRVdERTIxakUxZmZtUDBZMHl2LWJWSWRxOElfbWRmMEo0czlZU2JaMU9lQSIsIlR3VEVLbGdwT3B2VkZOYVFVZGFORkNMVk4zV2RRRG9nWGNVN01kb1hqeHciLCJiSkYwc3FFNWN5aTRPalloeXJFZ3g1SGZ0U0lrOUdKWEx3ZFhSemRWVmNZIiwibnRtSXdnZUhILUhjdFNTWnBOM2xkLU5kWVNNbmx4empadUxlMGMxTWE1dyIsInJWRXR1NFlPTFZ1M1hyMmk2NGo4dndLX2lGamxXUjJ5VksxSHl5aURxcW8iXSwiX3NkX2FsZyI6InNoYS0yNTYifQ.8IwrwsVaBJLsQkyMnG-Mgag9-NxVjdpDPf84hvJf1nzzmEqeOZzDITpKZ0MyqnXVPcaiJbfgNfV4FgcEflByyA~WyJTQmFneXlnZzdhRTRUMzRnd3BJQjlRIiwidGl0bGUiLCJCYWNoZWxvciBpbiBQaHlzaWNzIl0~WyI3MjVDWWFCMmhKWEdET2ZFdU5FZ01RIiwiZ3JhZGUiLDhd~WyJ6cFlGWDdjc2pFVlh4dGZsd0NHdE9RIiwiZ3JhZHVhdGlvbl9kYXRlIiwiMjAwMC0wMS0wMSJd~WyJ1RFJaSzRDcWdFcUs3cHZFclBSV2lnIiwiZmFtaWx5X25hbWUiLCJEb2UiXQ~WyJTWUtzdGFqcFVRZE1ZLTdhTUhIdDBRIiwiZ2l2ZW5fbmFtZSIsIkpvaG4iXQ~ \ No newline at end of file diff --git a/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithDidKeyIssuer.txt b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithDidKeyIssuer.txt new file mode 100644 index 00000000..ca87a07b --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithDidKeyIssuer.txt @@ -0,0 +1 @@ +eyJ0eXAiOiJkYytzZC1qd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6IiN6RG5hZW1vSzhVaTRCcHZZQ05CWERDdENNN1V2VWlERHVwN29lTmN0eHJKMldTOGZuIn0.eyJ2Y3QiOiJ1cm46ZXVkaTpwaWQ6MSIsImNuZiI6eyJraWQiOiJkaWQ6andrOmV5SmpjbllpT2lKRlpESTFOVEU1SWl3aWEzUjVJam9pVDB0UUlpd2llQ0k2SWxwWk5VNVpZV1V0Y0haeGFYaFRNMU5yT0RjM2VUUlllRE5XTjA5YVF6WTNMVUpsUlV4cmVuWklaM01pZlEjMCJ9LCJpc3MiOiJkaWQ6a2V5OnpEbmFlbW9LOFVpNEJwdllDTkJYREN0Q003VXZVaUREdXA3b2VOY3R4ckoyV1M4Zm4iLCJpYXQiOjE3ODc2NTc1NjgsIl9zZCI6WyIxMFhmWkw4WE5mV1hRZXRqR29KRE12ZWRNUFcya25fWUR4eDBpZzA0SVowIiwiMWh3dEFCVVJIMGJla1VGdXBXQk1mQm9QYjdmRU5RNHpWYmcxT3pFWmtCTSIsIkFSa3pzS3ZjcElranZLUjh4S1ZkRG1kaGVQQUktVTFMSnhETkYyLXZqMGciLCJJNlJyd1c0c2xLQ250YWs0aW4xX0J5SWxUckNOejAtNFNtdmFJMGFDSmJRIiwiUUZPVkRueUdwSHY2V0hRa2pYTzZEQzdNclNHQnJ5X181WXRpcHJXVENlbyIsIlZnUEM2X19Ebk1Zc1E3XzJzcnVyQkhGRTh1YWdyZjFhRVhZLWgtR19BS3MiLCJXUTVGSlpTU1h4QWVaaU5wTENHLWxSNEt4SWpvNGZwRU8zajN1NnhUWmc0IiwiWElaMVlBN1RseDVoeWxtMXBvTzE1SU9xUTZlWE1ENTNObXJIbTYycHAtRSIsImNYNnZBUVljRW9KdWtDRC1aS0dRM0J3aFBLaThNZkVsU0ZzRGtUS1BMaU0iLCJjcEhuemM2T2FBTF8yMXNyZDY4OGJBb1VhcVh6ZDJaSHE2S0pjLWF3cHk4IiwibFhZOGY5eGxXNDlJbXFRTzA4eG5NN0hORXZxTDVHUVIwVU1vT3owclhkMCIsIm1ZZnotTUVQLVpkLWJPTWZuTGpOVkVmQmZ6UUtRX3RPSUNzZHF6bFY3Q3MiLCJxQmRkd0x0SWVjZExiUzlHOVdUbVk0ejZGRUtNZHZwVU1fZ05RNHRzN09nIiwidjQycWtjTFJQSFN3N014S29BdDlUbEQ1bElqSE5SeE9iUWg5MW5kM1RydyIsInZQLVVWUU1xUmFRWDdmSkdFNWdmNUZ6X1F2YWNyS0ZZdzN5Vk1Mam5YeFEiXSwiX3NkX2FsZyI6InNoYS0yNTYifQ.Itu-xwnUaZLbqIzvJc0aJ1Dc2w4p_YPuOkVHJstIorrRERxz2n_cwUSPpQg_PMx9u4_zSBTK2ReTJDeKJxRaHg~WyJ3cUlrYnNtdy0ydFFmU2U1IiwiZ2l2ZW5fbmFtZSIsIkp1aGEiXQ~WyJZVDFfeHRhN3hwNkxNdmdtIiwiZmFtaWx5X25hbWUiLCJLb3Job25lbiJd~WyJESV9DUWpDbXpsdXpYN214IiwiYmlydGhfZGF0ZSIsIjE5OTAtMDEtMDEiXQ~WyJzTkVjQWtET1lvTWJxQVE0IiwicGxhY2Vfb2ZfYmlydGgiLHsiY291bnRyeSI6IkZpbmxhbmQiLCJyZWdpb24iOiJIZWxzaW5raSIsImxvY2FsaXR5IjoiSGVsc2lua2kifV0~WyJLUmdlR1JadGR6SWZXTGY3IiwibmF0aW9uYWxpdGllcyIsWyJGaW5sYW5kIl1d~WyJBeEs0MWxRNnJJdUdGMTE5IiwiaXNzdWluZ19hdXRob3JpdHkiLCJGaW5sYW5kIl0~WyIxMVhrYi1ITGRCQUZ2aDc0IiwiaXNzdWluZ19jb3VudHJ5IiwiRmlubGFuZCJd~WyJITDEyMGRhQmdZWldTb1FMIiwic2V4IiwxXQ~WyIxV1d1UzZvdGU0MEo0OUVJIiwiZW1haWxfYWRkcmVzcyIsImp1aGEua29yaG9uZW5AZXhhbXBsZS5jb20iXQ~WyJ5bWtuVTRrWTJFakF2R2hnIiwibW9iaWxlX3Bob25lX251bWJlciIsIiszNTg0MDEyMzQ1NjciXQ~WyI1bEdNd0h0b3NHZEQyNm5JIiwiYWRkcmVzcyIseyJjb3VudHJ5IjoiRmlubGFuZCIsInJlZ2lvbiI6IkhlbHNpbmtpIiwibG9jYWxpdHkiOiJIZWxzaW5raSIsInN0cmVldF9hZGRyZXNzIjoiSWRhIEFsYmVyZ2luIGthdHUgMSIsInBvc3RhbF9jb2RlIjoiMDA0MDAiLCJob3VzZV9udW1iZXIiOiIxIn1d~WyJKTmplczRYRkktYzQwMVluIiwiZG9jdW1lbnRfbnVtYmVyIiwiMDEwMTE5OTAtNDg4QyJd~WyJqN1lYSXlGV2V5RXZ5R1pYIiwiaXNzdWluZ19qdXJpc2RpY3Rpb24iLCJGaW5sYW5kIl0~WyJWN1o1eWxEUzE4SzlTZ1JGIiwiaXNzdWFuY2VfZGF0ZSIsIjIwMjYtMDEtMDEiXQ~WyI4bGMwY1p5Qms4RWdGVW9BIiwiZXhwaXJ5X2RhdGUiLCIyMDMwLTAxLTAxIl0~ \ No newline at end of file diff --git a/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithX5cMultipleSans.txt b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithX5cMultipleSans.txt new file mode 100644 index 00000000..b11c8dc9 --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithX5cMultipleSans.txt @@ -0,0 +1 @@ +eyJhbGciOiJFUzI1NiIsInR5cCI6ImRjK3NkLWp3dCIsIng1YyI6WyJNSUlET1RDQ0FyNmdBd0lCQWdJVUN5UzY5NThoMkdxbEdoaVlrQW4ySkZnU0p0UXdDZ1lJS29aSXpqMEVBd0l3Z2JReEN6QUpCZ05WQkFZVEFrNU1NUll3RkFZRFZRUUlFdzFPYjI5eVpDMUliMnhzWVc1a01SSXdFQVlEVlFRSEV3bEJiWE4wWlhKa1lXMHhGekFWQmdOVkJBa1REa3R2YVhacGMzUnZhMkZrWlNBek1SQXdEZ1lEVlFRUkV3Y3hNREV6SUVGTk1SWXdGQVlEVlFRS0V3MVRkV0p6ZEM1cFpDQkNMbFl1TVJrd0Z3WURWUVFMRXhCRVpYWmxiRzl3YldWdWRDQjBaV0Z0TVJzd0dRWURWUVFERXhKV1pYSXVhVVFnUkdWMklGSnZiM1FnUTBFd0hoY05Nall3TXpJeU1URXlPVFUxV2hjTk1qY3dNekl5TVRFeU9UVTFXakF6TVFzd0NRWURWUVFHRXdKT1RERWtNQ0lHQTFVRUF3d2JWbVZ5TG1sRUlFUmxkbVZzYjNCdFpXNTBJRlpsY21sbWFXVnlNRmt3RXdZSEtvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUV0Q0pjak9KcGR1TGV4dkFmbUVVMFkySUN0dWE0YnVUTEdpNDNxeWNVRnJDV1lWYkEwNXB3N1JQd2pHUlJ3MVBoQlo3a0VtK05xcldKSkZjN3FLT2J3cU9DQVN3d2dnRW9NQWtHQTFVZEV3UUNNQUF3Q3dZRFZSMFBCQVFEQWdlQU1CTUdBMVVkSlFRTU1Bb0dDQ3NHQVFVRkJ3TUNNQk1HQTFVZElBUU1NQW93Q0FZR1o0RU1BUUlCTUIwR0ExVWREZ1FXQkJSUVRNcWlZdDdFMnZKM2ZxUTVPb2E4TUpnVlR6QWZCZ05WSFNNRUdEQVdnQlNPaUhnTksrK0VjQUU3QVJEKzB1TEdVWFcvT1RDQm93WURWUjBSQklHYk1JR1lnaDF2YVdRMGRtTXVkMkZzYkdWMGN5NWtaWFl1ZG1WeUxtZGhjbVJsYm9JaGIybGtOSFpqTG5kaGJHeGxkSE11YTNWc1pHVmxjQzUyWlhJdVoyRnlaR1Z1Z2g1dmFXUTBkbU11ZDJGc2JHVjBjeTV6ZEdWdUxuWmxjaTVuWVhKa1pXNkNIVzlwWkRSMll5NTNZV3hzWlhSekxuTjBZV2RwYm1jdWRtVnlMbWxrZ2hWdmFXUTBkbU11ZDJGc2JHVjBjeTUyWlhJdWFXUXdDZ1lJS29aSXpqMEVBd0lEYVFBd1pnSXhBTzhnUXM3ZEhmalRNcXY3R0VuYVRsTFFoZ0VxRTJScUhMRTZEeUk5SlJTSWFnTTBRaTVZT3NSV0tkYlNzWUlwK1FJeEFMeFFFdHFTa2RoTG1hYnAyR1F2c1I4c1JMV3NWYnFLeUxsNEliQkZKL2oyeWVpQ3liSUx3ajY1enZGV2JVRWlmUT09Il19.eyJpc3MiOiJodHRwczovL29pZDR2Yy53YWxsZXRzLnZlci5pZCIsImlhdCI6MTc4NzY2NTUzNywiZXhwIjoxNzkwMjU3NTM3LCJ2Y3QiOiJldS5ldXJvcGEuZWMuZXVkaS5waWQuMSIsIl9zZF9hbGciOiJzaGEtMjU2IiwiY25mIjp7Imp3ayI6eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2IiwieCI6IndlbTZIYmFMT2JxSTdKVFBoS1NIZHlWQ3ViVUxuQm9HR3gxOVJ1NnM1ZGciLCJ5IjoiYmhOaVh6NUVXMUF4c00wbmN3cmlONXBaak52M2RMa2hyZktTc0NhMTA5RSIsImFsZyI6IkVTMjU2In19LCJhdHRlc3RhdGlvbl9xdWFsaWZpY2F0aW9uIjoiRUFBIiwic3RhdHVzIjp7InN0YXR1c19saXN0Ijp7ImlkeCI6MTgsInVyaSI6Imh0dHBzOi8vcmVnaXN0cnkuZ3JhcGhxbC52ZXIuaWQvc3RhdHVzLWxpc3RzLzEyY2MxZmM3LWU5ZTEtNDhhZC1iMWZhLWEwYTQzYzNhMTMwMyJ9fSwiX3NkIjpbIlFOMkhiclh3RklHM3NXWlFmWGJJNklmMEMzY1BxdGE5ZWg2bEZsem5ySFEiLCI3UDVYX3JFdjdDUC13THBERXBReVctWDE1czB3QVhIeU9rdDlpeGlvZXJjIl19.w41N07CCeuPUMKC7qNrAMKdNPMYXSW-zBB6iIig2M4E_fGufu4uqayoAk5OEdIpKpDACXXR-SkPbM-L9HnJCBQ~WyI0MjdhMDM5MmI1ODNiMDAwMDAwMDAwMDAwMDAwMDAwMDAyNTNhZDU2NjdmNzNmYjYwNzUxNmVlYzI0YzNkNTI3IiwiZ2l2ZW5fbmFtZSIsIkphbiJd~WyI0MjdhMDM5MmI1ODNmMDAwM2ZmMDAwMDAwMDAwMDAwMDE2MDk3YTVhZmM3NGMwYzA5ZjE0MzEyMDY2ODQ4YWFiIiwiZmFtaWx5X25hbWUiLCJkZSBWcmllcyJd~ \ No newline at end of file diff --git a/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithX5cNoSan.txt b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithX5cNoSan.txt new file mode 100644 index 00000000..3f8bcdbc --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithX5cNoSan.txt @@ -0,0 +1 @@ +eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImRjK3NkLWp3dCIsICJ4NWMiOiBbIk1JSUROekNDQXQyZ0F3SUJBZ0lVU09OU3lHUmNrRTh0d3NzNW1Obk14QjhIUlpVd0NnWUlLb1pJemowRUF3SXdYREVlTUJ3R0ExVUVBd3dWVUVsRUlFbHpjM1ZsY2lCRFFTQXRJRlZVSURBeU1TMHdLd1lEVlFRS0RDUkZWVVJKSUZkaGJHeGxkQ0JTWldabGNtVnVZMlVnU1cxd2JHVnRaVzUwWVhScGIyNHhDekFKQmdOVkJBWVRBbFZVTUI0WERUSTJNRGN5TXpFeE1UQXdNMW9YRFRJM01UQXhOakV4TVRBd01sb3diVEVWTUJNR0ExVUVBd3dNVUVsRUlFUlRJQzBnTURBeU1SZ3dGZ1lEVlFSaERBOU1SVWxGVlMweE1qTTBOVFkzT0RreExUQXJCZ05WQkFvTUpFVlZSRWtnVjJGc2JHVjBJRkpsWm1WeVpXNWpaU0JKYlhCc1pXMWxiblJoZEdsdmJqRUxNQWtHQTFVRUJoTUNWVlF3V1RBVEJnY3Foa2pPUFFJQkJnZ3Foa2pPUFFNQkJ3TkNBQVJHTjZzd3Mvd1lQYUtZTXUvYW1CN25ub1lpb2MzQVE4bk5icDVNYW0wbmxGV2llQjcyc2MvYmNweHh3UE5DSjRDeFJHUzNMRXovWEtKMkJJbG11UWJDbzRJQmFqQ0NBV1l3REFZRFZSMFRBUUgvQkFJd0FEQWZCZ05WSFNNRUdEQVdnQlJpeDVSSEtMMFBvaFlncDVyQ1NaUkU4UUhUeHpCWkJnZ3JCZ0VGQlFjQkFRUk5NRXN3U1FZSUt3WUJCUVVITUFLR1BXaDBkSEJ6T2k4dmNISmxjSEp2WkM1d2Eya3VaWFZrYVhjdVpHVjJMMkZwWVM5UVNVUkpjM04xWlhKRFFUQXlMVlZVTG1OaFkyVnlkQzV3Wlcwd0xnWURWUjBnQkNjd0pUQWpCZ01xQXdRd0hEQWFCZ2dyQmdFRkJRY0NBUllPWlhoaGJYQnNaUzV3YjJ4cFkza3dHd1lEVlIwbEJCUXdFZ1lIS0lHTVhRVUJBZ1lIS0lHMU5BUUJBakJEQmdOVkhSOEVQREE2TURpZ05xQTBoakpvZEhSd2N6b3ZMM0J5WlhCeWIyUXVjR3RwTG1WMVpHbDNMbVJsZGk5amNtd3ZjR2xrWDBOQlgxVlVYekF5TG1OeWJEQWRCZ05WSFE0RUZnUVVpL24yWG5OTElQYndTcXVXdHhLM1FHUFVWcjB3RGdZRFZSMFBBUUgvQkFRREFnZUFNQmtHQ0NzR0FRVUZCd0VEQkEwd0N6QUpCZ2NFQUl2c1RnRUJNQW9HQ0NxR1NNNDlCQU1DQTBnQU1FVUNJRTJQM2Z3bHBvTmhmY2ovUldTV1pNUzBaWk1PcGJ3RmpvUEZCNVZZS3c5eUFpRUF5R25WZjQva3NFOUE5dnVXSlUyQ0pkZG4zR3lZdGIzNVduY0pCdWlUUm5rPSJdfQ.eyJpc3MiOiAiaHR0cHM6Ly9iYWNrZW5kLmlzc3Vlci5ldWRpdy5kZXYiLCAiaWF0IjogMTc4NzYxMjQwMCwgImV4cCI6IDE3OTUzOTIwMDAsICJ2Y3QiOiAidXJuOmV1LmV1cm9wYS5lYy5ldWRpOmRpcGxvbWE6MToxIiwgInN0YXR1cyI6IHsic3RhdHVzX2xpc3QiOiB7ImlkeCI6IDQyMiwgInVyaSI6ICJodHRwczovL2lzc3Vlci5ldWRpdy5kZXYvdG9rZW5fc3RhdHVzX2xpc3QvRkMvdXJuOmV1LmV1cm9wYS5lYy5ldWRpOmRpcGxvbWE6MToxL2JmNWI4ZTMxLWRhNzMtNGZlOS1iMDQzLWY2MTg4ZTJmMjM5MSJ9fSwgImlkZW50aWZpZXIiOiAiaWQxIiwgInNjb3BlZF9hZmZpbGlhdGlvbiI6ICJzZCIsICJjb21tb25fbmFtZSI6ICJBYmhpc2hlayIsICJfc2RfYWxnIjogInNoYS0yNTYiLCAiY25mIjogeyJqd2siOiB7Imt0eSI6ICJFQyIsICJjcnYiOiAiUC0yNTYiLCAieCI6ICJ3ZW02SGJhTE9icUk3SlRQaEtTSGR5VkN1YlVMbkJvR0d4MTlSdTZzNWRnIiwgInkiOiAiYmhOaVh6NUVXMUF4c00wbmN3cmlONXBaak52M2RMa2hyZktTc0NhMTA5RSJ9fX0.F1wrXGP8vgPK01I5DBNchI7f038xn6XNSeqzcbwiOPQWE9hfzfIxi0xV68t8_48dccLLeJo9UwdthvHaMjNkZQ~ \ No newline at end of file diff --git a/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithX5cSanMatchingIss.txt b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithX5cSanMatchingIss.txt new file mode 100644 index 00000000..c9ab4dce --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/test/resources/sd-jwt_vc/sdJwtVcWithX5cSanMatchingIss.txt @@ -0,0 +1 @@ +eyJ0eXAiOiJkYytzZC1qd3QiLCJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlDYWpDQ0FoR2dBd0lCQWdJVU94RDdKRmtLWWdGUGs4SjNabVNzRUMyQkh0UXdDZ1lJS29aSXpqMEVBd0l3S0RFTE1Ba0dBMVVFQmhNQ1JFVXhHVEFYQmdOVkJBTU1FRWRsY20xaGJpQlNaV2RwYzNSeVlYSXdIaGNOTWpZd05ESXhNVFl4TlRBMFdoY05NamN3TkRJeE1UWXhOVEEwV2pCS01Rc3dDUVlEVlFRR0V3SkVSVEVOTUFzR0ExVUVDZ3dFU0c5MmFURWRNQnNHQTFVRVlRd1VPVGcwTlRBd01rVXdNa0kxUkVJd1JUZEJPRGN4RFRBTEJnTlZCQU1NQkVodmRta3dXVEFUQmdjcWhrak9QUUlCQmdncWhrak9QUU1CQndOQ0FBVGNMRXlRNklZMXNuSk53Ui92Vm0yamlvaGVNY01YdkxUNVorTXQ4OVduVTBkWThOY2xhK204bVZyeDI4ME9icld5anphQ2lBY0dnQVFnb2k0elBURnRvNEgyTUlIek1Bd0dBMVVkRXdFQi93UUNNQUF3SFFZRFZSME9CQllFRlB0RWw0SndYRStoZTN1Q2FWSGJ5OVpuTTlVbE1COEdBMVVkSXdRWU1CYUFGS25DbzlvdmJheFU3czY1VHVnc3lTd0FnNEF6TUE0R0ExVWREd0VCL3dRRUF3SUhnREFTQmdOVkhTVUVDekFKQmdjb2dZeGRCUUVHTURNR0ExVWRFUVFzTUNxQ0VtTnZjbVV0WVdkbGJuUXVhRzkyYVM1cFpJSVVZMjl5WlMxaFoyVnVkQzV1WjNKdmF5NWhjSEF3U2dZRFZSMGZCRU13UVRBL29EMmdPNFk1YUhSMGNITTZMeTl6WVc1a1ltOTRMbVYxWkdrdGQyRnNiR1YwTG05eVp5OWhjR2t2YzNSaGRIVnpMVzFoYm1GblpXMWxiblF2WTNKc01Bb0dDQ3FHU000OUJBTUNBMGNBTUVRQ0lITEI5RkFnc0lrazZKZzFQdy9UVklYbTJKWEt5Ym9DMVcxQkhkQ1NoOWY2QWlCcDhaeG0yNXZiODBTUndROVRLd3NQNEJBTTJLaUZVUnp5SUNOdVNWUksxZz09Il19.eyJ2Y3QiOiJ1cm46ZXVkaTpwaWQ6MSIsImNuZiI6eyJqd2siOnsia3R5IjoiT0tQIiwiY3J2IjoiRWQyNTUxOSIsIngiOiJaWTVOWWFlLXB2cWl4UzNTazg3N3k0WHgzVjdPWkM2Ny1CZUVMa3p2SGdzIn19LCJpc3MiOiJodHRwczovL2NvcmUtYWdlbnQuaG92aS5pZCIsImlhdCI6MTc4NzY1NzE3MSwiX3NkIjpbIjlyWGpzbHhyUVVYRFNZcFZxNUsxN21Wem5wWFh4SWE5Q3pmaEIyWWJRUXMiLCI5eWppWlpVdkRXSy1KeXVFYldhNHJiUDIyN3NHYUczMDd4TTJxc19tMHdZIiwiQ1MyU1o1RXBhc0RWYU14RUgwYlhSNjRfUnpDVUVYNWxpUG1vbHdIRXV0QSIsIkV5REhTMVZoS2RSa3lEeUZLZTBFR3lwd0NwdUFqcWFteTlxRUl2enlWSEUiLCJHa2huSUJBd2U3UVh4MVJOaXJXM0R5bTRFLXUtMnctckpWUHJMUWVwVDVvIiwiTUM2WXJ5U1YwMWxHY2NQdnI4LXd0b3NwdUFRWVVsWktRY3NJekhkTWxiTSIsIlFhV3lpQy1fMk03T2Nxc3lfLUdaTjBNUHZ6RXVrNEROS0JBQ2FSeXk4T1EiLCJSUnU4OFlFcG54QTV4S2s5RnVkTnItSHBzYTR3dHVlM3JuakVfMElvT0owIiwiWF9rQVlyeExSMEFxZ05ENDVhd2hCWm1KLTVCR3pnSmpKRktzckU2NmdEQSIsIlloVXZ4Q3l1QVM3aS1zN1hZX2NyakJaalk5OVRFS0k1dFViVjd6UFJON0UiLCJaRnNtdWIyQ1ZrVW94Wk9YQmU2ZGVma1NLcXMwZlRwbUZBenNhRVZZMXI0IiwiZEVITl8tZkRzb25tZmgwbmp0ZllpODBxM2dHbnBwZFc1dEJEMHgxN1ctYyIsImtFd1dfUXhBZEVucHJJazR2V2NqTE41MTBCWk5JT2tLbUNXQmx6ZHVzX1EiLCJ1VjBxUXVWeUFXVWttOHNuUEFSbFZJTldEc09TaGNfQTNZQU0xRFNsVUlZIiwidlQtX0QxVFBfcU5Deld4S181VkJzVk9EZWo1NTR2VFJobWNjNDJYbmlJVSJdLCJfc2RfYWxnIjoic2hhLTI1NiJ9.4OOrQYnHSAcZtBxQz35SCp_G7H5cpIpllh2nxB49Xqa26pMNbbTRM_dzX4yEZsZ0jt85XGKkoiwGDNn3_PSOjQ~WyI2MF9YOGE2dXJXLTI5T0pRIiwiZ2l2ZW5fbmFtZSIsIkp1aGEiXQ~WyJJWmo1RG44T1ZpMXJWaVM3IiwiZmFtaWx5X25hbWUiLCJLb3Job25lbiJd~WyJ1MEQtNnp5TFJGYUw1MkV1IiwiYmlydGhfZGF0ZSIsIjE5OTAtMDEtMDEiXQ~WyJLVVJjaF84bXBQeTZaTEtaIiwicGxhY2Vfb2ZfYmlydGgiLHsiY291bnRyeSI6IkZpbmxhbmQiLCJyZWdpb24iOiJIZWxzaW5raSIsImxvY2FsaXR5IjoiSGVsc2lua2kifV0~WyJaeEFlUExoVXl6ckdFZzVSIiwibmF0aW9uYWxpdGllcyIsWyJGaW5sYW5kIl1d~WyJzdHh4Q1RKNUpCbEhWX0dXIiwiaXNzdWluZ19hdXRob3JpdHkiLCJGaW5sYW5kIl0~WyJmVmhYY1o5b0JlTEMtZUtZIiwiaXNzdWluZ19jb3VudHJ5IiwiRmlubGFuZCJd~WyJGUmg5ZlBhNUxVSjhTVmRUIiwic2V4IiwxXQ~WyI0V2RONGNraFo3QnRiSy1HIiwiZW1haWxfYWRkcmVzcyIsImp1aGEua29yaG9uZW5AZXhhbXBsZS5jb20iXQ~WyJSZWJjWU51MEE5RG9SSVI1IiwibW9iaWxlX3Bob25lX251bWJlciIsIiszNTg0MDEyMzQ1NjciXQ~WyJzVHZHNWowcTJmX1dfNW5YIiwiYWRkcmVzcyIseyJjb3VudHJ5IjoiRmlubGFuZCIsInJlZ2lvbiI6IkhlbHNpbmtpIiwibG9jYWxpdHkiOiJIZWxzaW5raSIsInN0cmVldF9hZGRyZXNzIjoiSWRhIEFsYmVyZ2luIGthdHUgMSIsInBvc3RhbF9jb2RlIjoiMDA0MDAiLCJob3VzZV9udW1iZXIiOiIxIn1d~WyJoMmdQekV5VnF4aGx4SjdSIiwiZG9jdW1lbnRfbnVtYmVyIiwiMDEwMTE5OTAtNDg4QyJd~WyJ6QW5sZWwxd1ItTzZjQ042IiwiaXNzdWluZ19qdXJpc2RpY3Rpb24iLCJGaW5sYW5kIl0~WyJ0MDNsNjZLSjltbmlBbGlCIiwiaXNzdWFuY2VfZGF0ZSIsIjIwMjYtMDEtMDEiXQ~WyJkY2lOajdIUk5PSWJyaW4zIiwiZXhwaXJ5X2RhdGUiLCIyMDMwLTAxLTAxIl0~ \ No newline at end of file From d758debd0d7eb8c61789debfbba1802aa964ebee Mon Sep 17 00:00:00 2001 From: abhip2565 Date: Tue, 25 Aug 2026 22:08:32 +0530 Subject: [PATCH 5/7] docs: describe kid-based issuer key resolution and the network policy The SD-JWT rows claimed JWT VC Issuer Metadata was unsupported. Record how the mechanism is chosen, that DID resolution is an ecosystem addition rather than part of the draft, and how NetworkPolicy relaxes the network restrictions. Signed-off-by: abhip2565 --- README.md | 4 ++-- doc/sdjwt-vc-verification-support.md | 25 ++++++++++++++++++++++--- vc-verifier/kotlin/README.md | 4 ++-- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7632b12d..0f41a6be 100644 --- a/README.md +++ b/README.md @@ -47,8 +47,8 @@ import io.mosip.vercred.vcverifier.keyResolver.types.http.HttpsPublicKeyResolver |---------------|------------------------------------------------------------------------|----------------------------------------------|-------------------------------------------------------------------------------------------| | `ldp_vc` | Linked Data Proof | PS256, RS256, EdDSA (Ed25519), ES256, ES256K | RsaSignature2018, Ed25519Signature2018, Ed25519Signature2020, EcdsaSecp256r1Signature2019, EcdsaSecp256k1Signature2019 | | `mso_mdoc` | COSE (CBOR Object Signing and Encryption) | ES256 | Uses COSE_Sign1 | -| `vc+sd-jwt` | X.509 Certificate (Currently, JWT VC Issuer Metadata is not supported) | PS256, RS256, EdDSA (Ed25519), ES256, ES256K | - | -| `dc+sd-jwt` | X.509 Certificate (Currently, JWT VC Issuer Metadata is not supported) | PS256, RS256, EdDSA (Ed25519), ES256, ES256K | - | +| `vc+sd-jwt` | X.509 Certificate, JWT VC Issuer Metadata/JWKS, DID `kid` | PS256, RS256, EdDSA (Ed25519), ES256, ES256K | - | +| `dc+sd-jwt` | X.509 Certificate, JWT VC Issuer Metadata/JWKS, DID `kid` | PS256, RS256, EdDSA (Ed25519), ES256, ES256K | - | | `cwt_vc` | COSE_Sign1 (CBOR Web Token – RFC 8392) | ES256, EdDSA (COSE alg based) | COSE_Sign1 | | `jwt_vc_json` | JSON Web Signature (JWS) with issuer-based key resolution | PS256, RS256, ES256, ES256K, EdDSA (Ed25519) | RFC 7515 JWS — key resolved via embedded JWK, `jku`, `kid`, or `iss` DID/HTTPS endpoint | diff --git a/doc/sdjwt-vc-verification-support.md b/doc/sdjwt-vc-verification-support.md index 96a11236..358371a6 100644 --- a/doc/sdjwt-vc-verification-support.md +++ b/doc/sdjwt-vc-verification-support.md @@ -3,8 +3,27 @@ This document provides a comprehensive overview of verifying `vc+sd-jwt` and `dc+sd-jwt` Verifiable Credentials (VCs). ### Public key resolution support -- X.509 Certificates - Retrieves Issuer's public key using `x5c header parameter` in SD-JWT header. -- DID Document - Retrieves Issuer's public key using `kid` in SD-JWT header. +The mechanism is chosen by the credential, not the verifier, and an `x5c` header takes precedence: + +- **X.509 Certificates** — retrieves the Issuer's public key from the `x5c` header parameter. +- **JWT VC Issuer Metadata** — when `iss` is an HTTPS URL, fetches the metadata at + `/.well-known/jwt-vc-issuer` (inserted between host and path of `iss`), requires its `issuer` to + match `iss` exactly, and selects a key from the inline `jwks` or the referenced `jwks_uri`. + A `kid` header selects among published keys; without one, the key is chosen by what the JWS + algorithm requires, and ambiguity is rejected rather than guessed at. +- **DID resolution** — when `iss` is a DID, resolves a relative fragment or absolute DID URL `kid`, + but only when that `kid` is controlled by the DID in `iss`. + +The first two are the Issuer Signature Mechanisms defined in draft-ietf-oauth-sd-jwt-vc-10 §3.5. +DID resolution is **not** part of that specification — it is an additional mechanism of the kind +§3.5 permits ecosystems to define, and is out of scope for the draft. + +> **Note on network hardening:** the metadata location is derived from the `iss` claim, which is +> attacker supplied until the signature has been checked. All outbound requests therefore run through +> a single hardened client: connect/read/call timeouts, a response size cap, no redirects, and hosts +> resolving to non-public addresses refused. The last two are configurable via `NetworkPolicy` for +> deployments that serve issuers internally or behind a redirecting load balancer; both default to +> the safe setting. ### Steps Involved @@ -128,4 +147,4 @@ sequenceDiagram SdJwtVerifier-->>SdJwtVerifiableCredential: Return Verification Result as True end end -``` \ No newline at end of file +``` diff --git a/vc-verifier/kotlin/README.md b/vc-verifier/kotlin/README.md index 99ee3d07..86ee1b10 100644 --- a/vc-verifier/kotlin/README.md +++ b/vc-verifier/kotlin/README.md @@ -251,8 +251,8 @@ and [IETF SD-JWT](https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-di |---------------|------------------------------------------------------------------------|----------------------------------------------|-------------------------------------------------------------------------------------------| | `ldp_vc` | Linked Data Proof | PS256, RS256, EdDSA (Ed25519), ES256, ES256K | RsaSignature2018, Ed25519Signature2018, Ed25519Signature2020, EcdsaSecp256k1Signature2019 | | `mso_mdoc` | COSE (CBOR Object Signing and Encryption) | ES256 | Uses COSE_Sign1 | -| `vc+sd-jwt` | X.509 Certificate (Currently, JWT VC Issuer Metadata is not supported) | PS256, RS256, EdDSA (Ed25519), ES256, ES256K | - | -| `dc+sd-jwt` | X.509 Certificate (Currently, JWT VC Issuer Metadata is not supported) | PS256, RS256, EdDSA (Ed25519), ES256, ES256K | - | +| `vc+sd-jwt` | X.509 Certificate, JWT VC Issuer Metadata/JWKS, DID `kid` | PS256, RS256, EdDSA (Ed25519), ES256, ES256K | - | +| `dc+sd-jwt` | X.509 Certificate, JWT VC Issuer Metadata/JWKS, DID `kid` | PS256, RS256, EdDSA (Ed25519), ES256, ES256K | - | | `cwt_vc` | COSE_Sign1 (CBOR Web Token – RFC 8392) | ES256, EdDSA (COSE alg based) | COSE_Sign1 | | `jwt_vc_json` | JSON Web Signature (JWS) with issuer-based key resolution | PS256, RS256, ES256, ES256K, EdDSA (Ed25519) | RFC 7515 JWS — key resolved via embedded JWK, `jku`, `kid`, or `iss` DID/HTTPS endpoint | From d1823788950b1d3be9fb802920f9ef035ca18895 Mon Sep 17 00:00:00 2001 From: abhip2565 Date: Wed, 26 Aug 2026 12:40:30 +0530 Subject: [PATCH 6/7] chore(deps): bump authlete sd-jwt to 1.9 1.5 ships Java 11 bytecode (class file major 55), which older Android toolchains cannot consume. 1.6 onward targets Java 8 (major 52). The Disclosure and SDJWT APIs this library uses are byte-identical across the bump. Signed-off-by: abhip2565 --- vc-verifier/kotlin/gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vc-verifier/kotlin/gradle/libs.versions.toml b/vc-verifier/kotlin/gradle/libs.versions.toml index 4a4a9f54..13468d5b 100644 --- a/vc-verifier/kotlin/gradle/libs.versions.toml +++ b/vc-verifier/kotlin/gradle/libs.versions.toml @@ -26,7 +26,7 @@ mockWebServer = "4.12.0" annotationJvm = "1.9.1" cbor = "0.9" identity = "20231002" -authleteSdJwt = "1.5" +authleteSdJwt = "1.9" cborLibrary = "4.5.6" authleteCbor = "1.19" From 4389ef4695838e756172fc8c60082523be7d81c5 Mon Sep 17 00:00:00 2001 From: abhip2565 Date: Wed, 26 Aug 2026 17:08:37 +0530 Subject: [PATCH 7/7] fix: close three gaps found in review Bound the decompressed status list. The response cap limits the compressed payload only, and a status list is a sparse bitstring that deflates enormously, so a compliant-looking response could still exhaust the heap. Reject malformed 'key_ops'. A scalar such as "encrypt" failed the cast to List and was treated as absent, skipping the verify check entirely and admitting the key. Bypass any system proxy while the address guard is enabled. Through an HTTP proxy only the proxy host is resolved by our Dns, so a public proxy could forward a private origin and the guard would never see it. Signed-off-by: abhip2565 --- .../statusChecker/LdpStatusChecker.kt | 10 ++++++ .../keyResolver/types/jwks/JwksKeySelector.kt | 11 +++++-- .../networkManager/NetworkManagerClient.kt | 16 ++++++++++ .../StatusListRevocationCheckerTest.kt | 22 +++++++++++++ .../types/jwks/JwksPublicKeyResolverTest.kt | 22 +++++++++++++ .../NetworkManagerClientTest.kt | 31 +++++++++++++++++++ 6 files changed, 109 insertions(+), 3 deletions(-) diff --git a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/LdpStatusChecker.kt b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/LdpStatusChecker.kt index 4d17c806..5e5a4565 100644 --- a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/LdpStatusChecker.kt +++ b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/LdpStatusChecker.kt @@ -40,6 +40,8 @@ import java.util.zip.GZIPInputStream private const val STATUS_LIST_MAX_RESPONSE_BYTES = 5L * 1024 * 1024 private const val STATUS_LIST_CALL_TIMEOUT_SECONDS = 30L +private const val STATUS_LIST_MAX_DECOMPRESSED_BYTES = 32L * 1024 * 1024 + /** * Generic StatusList2021 checker for LDP VCs. * Supports optional filtering by known statusPurposes. @@ -347,7 +349,15 @@ class LdpStatusChecker() { val baos = ByteArrayOutputStream() val buffer = ByteArray(8192) var bytesRead: Int + var total = 0L while (gzipIS.read(buffer).also { bytesRead = it } != -1) { + total += bytesRead + if (total > STATUS_LIST_MAX_DECOMPRESSED_BYTES) { + throw StatusCheckException( + "Status list exceeds the decompressed size limit", + GZIP_DECOMPRESS_FAILED + ) + } baos.write(buffer, 0, bytesRead) } return baos.toByteArray() diff --git a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksKeySelector.kt b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksKeySelector.kt index 799d82b3..422cb835 100644 --- a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksKeySelector.kt +++ b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksKeySelector.kt @@ -98,9 +98,14 @@ private fun validateVerificationKey(jwk: Map<*, *>, algorithm: String?): String? return "JWK 'use' must be '${JwkParams.USE_SIGNATURE}'" } - val keyOps = jwk[JwkParams.KEY_OPS] as? List<*> - if (keyOps != null && keyOps.none { it?.toString() == JwkParams.KEY_OP_VERIFY }) { - return "JWK 'key_ops' must permit '${JwkParams.KEY_OP_VERIFY}'" + val keyOps = jwk[JwkParams.KEY_OPS] + if (keyOps != null) { + if (keyOps !is List<*> || keyOps.any { it !is String }) { + return "JWK 'key_ops' must be an array of strings" + } + if (keyOps.none { it == JwkParams.KEY_OP_VERIFY }) { + return "JWK 'key_ops' must permit '${JwkParams.KEY_OP_VERIFY}'" + } } val constraint = algorithm?.let { ALGORITHM_KEY_CONSTRAINTS[it] } ?: return null diff --git a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClient.kt b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClient.kt index 8cee3c15..91ca1c0d 100644 --- a/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClient.kt +++ b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClient.kt @@ -8,10 +8,15 @@ import okhttp3.OkHttpClient import okhttp3.Request import java.io.ByteArrayOutputStream import java.io.InputStream +import java.io.IOException import java.io.InterruptedIOException import java.net.Inet4Address import java.net.Inet6Address import java.net.InetAddress +import java.net.Proxy +import java.net.ProxySelector +import java.net.SocketAddress +import java.net.URI import java.net.UnknownHostException import java.util.concurrent.TimeUnit @@ -41,6 +46,7 @@ class NetworkManagerClient { .followRedirects(false) .followSslRedirects(false) .dns(PublicAddressDns) + .proxySelector(PolicyAwareProxySelector) .build() } @@ -128,6 +134,16 @@ class NetworkManagerClient { private class ResponseTooLargeException(maxResponseBytes: Long) : Exception("Response exceeds the $maxResponseBytes byte limit") + private object PolicyAwareProxySelector : ProxySelector() { + override fun select(uri: URI): MutableList = + if (NetworkPolicy.restrictToPublicHosts) mutableListOf(Proxy.NO_PROXY) + else getDefault().select(uri) + + override fun connectFailed(uri: URI, sa: SocketAddress, ioe: IOException) { + if (!NetworkPolicy.restrictToPublicHosts) getDefault().connectFailed(uri, sa, ioe) + } + } + private object PublicAddressDns : Dns { override fun lookup(hostname: String): List { val addresses = Dns.SYSTEM.lookup(hostname) diff --git a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/StatusListRevocationCheckerTest.kt b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/StatusListRevocationCheckerTest.kt index ac9955f8..70dbac16 100644 --- a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/StatusListRevocationCheckerTest.kt +++ b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/StatusListRevocationCheckerTest.kt @@ -21,7 +21,9 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import org.springframework.util.ResourceUtils import java.nio.file.Files +import java.io.ByteArrayOutputStream import java.util.Base64 +import java.util.zip.GZIPOutputStream import java.util.regex.Matcher @ExtendWith(MockKExtension::class) @@ -210,6 +212,26 @@ class StatusListRevocationCheckerTest { } + @Test + fun `should return error when the status list decompresses beyond the limit`() { + val bomb = ByteArrayOutputStream().also { out -> + GZIPOutputStream(out).use { it.write(ByteArray(33 * 1024 * 1024)) } + }.toByteArray() + val encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(bomb) + assertTrue(bomb.size < 200 * 1024, "compressed payload should stay small: ${bomb.size}") + + val vcJson = readFile("classpath:ldp_vc/vcUnrevoked-https.json") + val statusListJson = readFile("classpath:ldp_vc/status-list-vc.json") + .replace(Regex(""""encodedList":\s*".*?""""), """"encodedList": "u$encoded"""") + + val (replacedVC, server) = prepareVCFromRaw(vcJson, statusListJson) + val result = checker.getStatuses(replacedVC).entries.first() + + assertFalse(result.value.isValid) + assertEquals(StatusCheckErrorCode.GZIP_DECOMPRESS_FAILED, result.value.error?.errorCode) + server.shutdown() + } + @Test fun `should return error on invalid GZIP data`() { val badData = Base64.getUrlEncoder().encodeToString("notGzipData".toByteArray()) diff --git a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksPublicKeyResolverTest.kt b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksPublicKeyResolverTest.kt index 6f2e309c..ec4bb929 100644 --- a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksPublicKeyResolverTest.kt +++ b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksPublicKeyResolverTest.kt @@ -151,6 +151,28 @@ class JwksPublicKeyResolverTest { assertTrue(error.message!!.contains("JWK 'key_ops' must permit 'verify'")) } + @Test + fun `rejects a key whose key_ops is a scalar rather than an array`() { + mockJwks(publicJwk + mapOf("key_ops" to "encrypt")) + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve(uri, "signing-key-1") + } + + assertTrue(error.message!!.contains("JWK 'key_ops' must be an array of strings")) + } + + @Test + fun `rejects a key whose key_ops array holds a non-string`() { + mockJwks(publicJwk + mapOf("key_ops" to listOf("verify", 42))) + + val error = assertThrows(PublicKeyNotFoundException::class.java) { + resolver.resolve(uri, "signing-key-1") + } + + assertTrue(error.message!!.contains("JWK 'key_ops' must be an array of strings")) + } + @Test fun `rejects a JWK carrying private key material`() { mockJwks(publicJwk + mapOf("d" to "870MB6gfuTJ4HtUnUvYMyJpr5eUZNP4Bk43bVdj3eAE")) diff --git a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClientTest.kt b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClientTest.kt index 9c2b9014..35bcdd48 100644 --- a/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClientTest.kt +++ b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClientTest.kt @@ -9,6 +9,12 @@ import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import java.net.InetSocketAddress +import java.net.Proxy +import java.net.ProxySelector +import java.net.SocketAddress +import java.net.URI +import java.io.IOException class NetworkManagerClientTest { @@ -49,6 +55,31 @@ class NetworkManagerClientTest { assertEquals(true, response!!["ok"]) } + @Test + fun `ignores a system proxy while the address guard is enabled`() { + val proxied = mutableListOf() + val original = ProxySelector.getDefault() + ProxySelector.setDefault(object : ProxySelector() { + override fun select(uri: URI): MutableList { + proxied += uri.toString() + return mutableListOf(Proxy(Proxy.Type.HTTP, InetSocketAddress("127.0.0.1", 1))) + } + override fun connectFailed(uri: URI, sa: SocketAddress, ioe: IOException) = Unit + }) + try { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{"ok":true}""")) + + val error = assertThrows(NetworkManagerClientExceptions.NetworkRequestFailed::class.java) { + NetworkManagerClient.sendHTTPRequest(url(), HttpMethod.GET) + } + + assertTrue(error.message!!.contains("Refusing non-public host")) + assertTrue(proxied.isEmpty(), "system proxy must not be consulted: $proxied") + } finally { + ProxySelector.setDefault(original) + } + } + @Test fun `refuses a response larger than the size limit`() { NetworkPolicy.restrictToPublicHosts = false