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 | 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" 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/credentialverifier/statusChecker/LdpStatusChecker.kt b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/LdpStatusChecker.kt index 26d5a6be..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 @@ -37,6 +37,10 @@ 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 + +private const val STATUS_LIST_MAX_DECOMPRESSED_BYTES = 32L * 1024 * 1024 /** * Generic StatusList2021 checker for LDP VCs. @@ -137,7 +141,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 @@ -340,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/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/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'") 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..422cb835 --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/main/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksKeySelector.kt @@ -0,0 +1,120 @@ +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] + 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 + 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/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/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..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 @@ -2,23 +2,63 @@ 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.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 +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) + .proxySelector(PolicyAwareProxySelector) + .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 +76,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 +105,148 @@ 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 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) + 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/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/statusChecker/StatusListRevocationCheckerTest.kt b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/credentialverifier/statusChecker/StatusListRevocationCheckerTest.kt index 0579d452..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 @@ -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 @@ -20,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) @@ -30,6 +33,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 +42,7 @@ class StatusListRevocationCheckerTest { @AfterEach fun teardown() { + NetworkPolicy.restrictToPublicHosts = true unmockkAll() } @@ -206,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/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/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..ec4bb929 --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/keyResolver/types/jwks/JwksPublicKeyResolverTest.kt @@ -0,0 +1,197 @@ +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 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")) + + 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) + } +} 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/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..35bcdd48 --- /dev/null +++ b/vc-verifier/kotlin/vcverifier/src/test/java/io/mosip/vercred/vcverifier/networkManager/NetworkManagerClientTest.kt @@ -0,0 +1,140 @@ +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 +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 { + + 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 `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 + 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 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