Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
25 changes: 22 additions & 3 deletions doc/sdjwt-vc-verification-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -128,4 +147,4 @@ sequenceDiagram
SdJwtVerifier-->>SdJwtVerifiableCredential: Return Verification Result as True
end
end
```
```
4 changes: 2 additions & 2 deletions vc-verifier/kotlin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion vc-verifier/kotlin/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
?: throw StatusCheckException(
"Failed to retrieve status list VC",
STATUS_RETRIEVAL_ERROR
Expand Down Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -100,7 +100,7 @@ fun getPublicKeyObjectFromPemPublicKey(publicKeyPem: String, keyType: String): P
fun getPublicKeyFromJWK(jwk: Map<String, Any>, 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")
}
Expand All @@ -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")
}
Expand All @@ -134,9 +134,9 @@ private fun getRSAPublicKey(jwk: Map<String, Any>): PublicKey {


internal fun getEdPublicKey(jwk: Map<String, Any>): 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 =
Expand All @@ -152,7 +152,7 @@ internal fun getEdPublicKey(jwk: Map<String, Any>): PublicKey {


private fun getECPublicKey(jwk: Map<String, Any>): 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'")
Expand Down
Loading
Loading