diff --git a/sdjwt/build.gradle.kts b/sdjwt/build.gradle.kts new file mode 100644 index 000000000..04f669290 --- /dev/null +++ b/sdjwt/build.gradle.kts @@ -0,0 +1,22 @@ +repositories { + mavenCentral() + maven { + url = uri("https://repo.danubetech.com/repository/maven-public") + } + maven("https://jitpack.io") + maven("https://repository.jboss.org/nexus/content/repositories/thirdparty-releases/") +} + +dependencies { + implementation("com.nimbusds:nimbus-jose-jwt:9.34") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.3") + implementation("org.bouncycastle:bcprov-jdk15to18:1.77") + + testImplementation(project(":dids")) + testImplementation(project(":crypto")) + testImplementation("io.github.erdtman:java-json-canonicalization:1.1") +} + +tasks.test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/sdjwt/src/README.md b/sdjwt/src/README.md new file mode 100644 index 000000000..c85cd853f --- /dev/null +++ b/sdjwt/src/README.md @@ -0,0 +1,44 @@ +# SD-JWT support in Kotlin + +`sdjwt` is a library that implements the IETF draft +for [Selective Disclosure for JWTs](https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html). +This library facilitates creating SD-JWT structures for issuance and presentation with arbitrary payloads, and +performing +verification from the holder or from the verifier's perspective. + +## Table of Contents + +- [Installation](#installation) +- [Quick Start](#quick-start) +- [API REFERENCE](#api-reference) + +## Installation + +Add the following to your `gradle.build.kts` file: + +```kotlin +repositories { + maven(url = "https://jitpack.io") +} + +dependencies { + implementation("com.github.TBD54566975.web5-kt:sd-jwt:main-SNAPSHOT") +} +``` + +## Quick Start + +See the test named `whole flow from issuer to holder to verifier` +from [this file](./test/kotlin/web5/security/SdJwtTest.kt). +You can run it by cloning this repo and running gradle as shown below: + +```shell +go clone github.com/TBD54566975/web5-kt.git +cd web5-kt/ +./gradlew sdjwt:test +``` + +## API Reference + +See our [oficial kotlin docs](https://tbd54566975.github.io/web5-kt/docs/htmlMultiModule/sdjwt/index.html). + diff --git a/sdjwt/src/main/kotlin/web5/security/Disclosures.kt b/sdjwt/src/main/kotlin/web5/security/Disclosures.kt new file mode 100644 index 000000000..3570e4210 --- /dev/null +++ b/sdjwt/src/main/kotlin/web5/security/Disclosures.kt @@ -0,0 +1,116 @@ +package web5.security + +import com.fasterxml.jackson.databind.ObjectMapper +import com.nimbusds.jose.util.Base64URL + +/** + * Represents a disclosure for an Object Property as defined in https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-disclosures-for-object-prop. + */ +public class ObjectDisclosure( + public val salt: String, + public val claimName: String, + public val claimValue: Any, + raw: String? = null, + mapper: ObjectMapper? = null) : Disclosure() { + + override val raw: String = raw ?: serialize(mapper!!) + + + override fun serialize(mapper: ObjectMapper): String { + val value = mapper.writeValueAsString(claimValue) + val jsonEncoded = """["$salt", "$claimName", $value]""" + + return Base64URL.encode(jsonEncoded).toString() + } +} + +/** + * Generalization of Disclosures. + */ +public sealed class Disclosure { + public abstract val raw: String + + /** + * Returns the base64url encoding of the bytes in the JSON encoded array that represents this disclosure. [mapper] is + * used to do the JSON encoding. + */ + public abstract fun serialize(mapper: ObjectMapper): String + + /** + * Returns the result of hashing this disclosure as described in https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-hashing-disclosures. + */ + public fun digest(hashAlg: HashFunc): String { + return Base64URL.encode(hashAlg(raw.toByteArray())).toString() + } + + public companion object { + /** + * Returns a [Disclosure] given the base64url encoding of the json encoded byte representation. This operation + * is the reverse of [serialize]. + * + * @throws DisclosureClaimNameNotStringException if the second element of the 3-element disclosure is not a string. + * @throws DisclosureSizeNotValidException if the disclosure does not have exactly 2 or 3 elements. + */ + @Throws( + DisclosureClaimNameNotStringException::class, + DisclosureSizeNotValidException::class, + ) + @JvmStatic + public fun parse(encodedDisclosure: String): Disclosure { + // Decode the base64-encoded disclosure + val disclosureJson = Base64URL(encodedDisclosure).decodeToString() + + // Parse the disclosure JSON into a list of elements + val disclosureElems = defaultMapper.readValue(disclosureJson, List::class.java) + + // Ensure that the disclosure is object or array disclosure + when (disclosureElems.size) { + 2 -> { + // Create a Disclosure instance + return ArrayDisclosure( + salt = disclosureElems[0] as String, + claimValue = disclosureElems[1] as Any, + raw = encodedDisclosure + ) + } + + 3 -> { + // Extract the elements + val disclosureClaimName = disclosureElems[1] as? String + ?: throw DisclosureClaimNameNotStringException("Second element of disclosure must be a string") + + // Create a Disclosure instance + return ObjectDisclosure( + salt = disclosureElems[0] as String, + claimName = disclosureClaimName, + claimValue = disclosureElems[2] as Any, + raw = encodedDisclosure + ) + } + + else -> throw DisclosureSizeNotValidException( + "Disclosure \"$encodedDisclosure\" must have exactly 2 or 3 elements" + ) + } + } + } +} + +/** + * Represents the disclosure of an Array Element as described in https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-disclosures-for-array-eleme. + */ +public class ArrayDisclosure( + public val salt: String, + public val claimValue: Any, + raw: String? = null, + mapper: ObjectMapper? = null +) : Disclosure() { + override val raw: String = raw ?: serialize(mapper!!) + + override fun serialize(mapper: ObjectMapper): String { + val value = mapper.writeValueAsString(claimValue) + val jsonEncoded = """["$salt", $value]""" + + return Base64URL.encode(jsonEncoded).toString() + } +} \ No newline at end of file diff --git a/sdjwt/src/main/kotlin/web5/security/ExceptionDeclarations.kt b/sdjwt/src/main/kotlin/web5/security/ExceptionDeclarations.kt new file mode 100644 index 000000000..d877fd251 --- /dev/null +++ b/sdjwt/src/main/kotlin/web5/security/ExceptionDeclarations.kt @@ -0,0 +1,76 @@ +package web5.security + +/** + * Exception thrown when a "_sd" key does not refer to an array as required. + */ +public class SdKeyNotArrayException(message: String) : IllegalArgumentException(message) + +/** + * Exception thrown when a value in "_sd" is not a string as required. + */ +public class SdValueNotStringException(message: String) : IllegalArgumentException(message) + +/** + * Exception thrown when the value of "..." is not a string as required. + */ +public class EllipsisValueNotStringException(message: String) : IllegalArgumentException(message) + +/** + * Exception thrown when the parent of an array element digest is not an array as required. + */ +public class ParentNotArrayException(message: String) : IllegalArgumentException(message) + +/** + * Exception thrown when a digest is found more than once, which is not allowed. + */ +public class DuplicateDigestException(message: String) : IllegalArgumentException(message) + +/** + * Exception thrown when the insertion point for an object disclosure is not a map as required. + */ +public class InvalidObjectDisclosureInsertionPointException(message: String) : IllegalArgumentException(message) + +/** + * Exception thrown when the insertion point for an array disclosure is not an array as required. + */ +public class InvalidArrayDisclosureInsertionPointException(message: String) : IllegalArgumentException(message) + +/** + * Exception thrown when a claim name already exists in the claims set. + */ +public class ClaimNameAlreadyExistsException(message: String) : Exception(message) + +/** + * Exception thrown when the "_sd_alg" claim value is not a string as required. + */ +public class SdAlgValueNotStringException(message: String) : IllegalArgumentException(message) + +/** + * Exception thrown when the "_sd_alg" claim value represents a hash algorithm name that's not supported. + */ +public class HashNameNotSupportedException(message: String) : IllegalArgumentException(message) + +/** + * Exception thrown when the blind option is not valid. + */ +public class BlindOptionNotValidException(message: String) : IllegalArgumentException(message) + +/** + * Exception thrown when the claim value is not an array as required. + */ +public class ClaimValueIsNotArrayException(message: String) : IllegalArgumentException(message) + +/** + * Exception thrown when the "kty" value of a JWK is neither of `EC` nor `OKP` as required. + */ +public class InvalidJwkException(message: String) : RuntimeException(message) + +/** + * Exception thrown when the second element of a 3-element disclosure JSON array is not a string. + */ +public class DisclosureClaimNameNotStringException(message: String) : IllegalArgumentException(message) + +/** + * Exception thrown when the size of a disclosure JSON array is neither 2 nor 3. + */ +public class DisclosureSizeNotValidException(message: String) : IllegalArgumentException(message) diff --git a/sdjwt/src/main/kotlin/web5/security/SdJwt.kt b/sdjwt/src/main/kotlin/web5/security/SdJwt.kt new file mode 100644 index 000000000..da872ecda --- /dev/null +++ b/sdjwt/src/main/kotlin/web5/security/SdJwt.kt @@ -0,0 +1,277 @@ +package web5.security + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.JWSObject.State +import com.nimbusds.jose.JWSSigner +import com.nimbusds.jose.JWSVerifier +import com.nimbusds.jose.crypto.bc.BouncyCastleProviderSingleton +import com.nimbusds.jose.crypto.factories.DefaultJWSVerifierFactory +import com.nimbusds.jose.jwk.ECKey +import com.nimbusds.jose.jwk.JWK +import com.nimbusds.jose.jwk.OctetKeyPair +import com.nimbusds.jose.proc.SecurityContext +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier +import java.security.Key +import java.security.Provider +import java.security.SignatureException + +private const val separator = "~" + +internal const val blindedArrayKey = "..." + +/** + * Represents a Selective Disclosure JWT as defined in https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-terms-and-definitions. + * A more detailed overview is available in https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-sd-jwt-structure. + * + * Note: While [issuerJwt] and [keyBindingJwt] are of type [SignedJWT], they may or may not be signed. + */ +public class SdJwt( + public val issuerJwt: SignedJWT, + public val disclosures: Iterable, + public val keyBindingJwt: SignedJWT? = null, + private val serializedSdJwt: String? = null) { + + /** Builder class for constructing a [SdJwt]. */ + public class Builder(public var issuerHeader: JWSHeader? = null, + public var jwtClaimsSet: JWTClaimsSet? = null, + public var disclosures: Iterable? = null, + public var keyBindingJwt: SignedJWT? = null) { + + /** Returns an [SdJwt], throwing errors when there are missing values which are required. */ + public fun build(): SdJwt { + + return SdJwt( + SignedJWT(issuerHeader, jwtClaimsSet), + disclosures!!, + keyBindingJwt, + ) + } + } + + /** + * Serializes this sd-jwt to the serialization format described in https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-sd-jwt-structure + * + * [issuerJwt] must have been previously signed. + * [keyBindingJwt], if present, must have been previously signed. + */ + @JvmOverloads + public fun serialize(mapper: ObjectMapper = defaultMapper): String { + if (serializedSdJwt != null) { + return serializedSdJwt + } + require(issuerJwt.state == State.SIGNED) { + "issuerJwt is not signed" + } + if (keyBindingJwt != null) { + require(keyBindingJwt.state == State.SIGNED) { + "keyBindingJwt is not signed" + } + } + return buildList { + add(issuerJwt.serialize()) + addAll(disclosures.map { it.serialize(mapper) }) + add(keyBindingJwt?.serialize() ?: "") + }.joinToString(separator) + } + + /** Signs the [issuerJwt] with [signer]. */ + @Synchronized + public fun signAsIssuer(signer: JWSSigner) { + issuerJwt.sign(signer) + } + + /** + * Signs the [keyBindingJwt] with [signer]. + */ + @Synchronized + public fun signKeyBinding(signer: JWSSigner) { + keyBindingJwt?.sign(signer) + } + + /** + * TODO: only accept a subset of algos for verification. + * + * Verifies this SD-JWT according to https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-verification + * Options for verification are provided via [verificationOptions]. + * + * Any issues found during verification will throw an [Exception], with a message describing the issue + * encountered. + * + * ## Example + * ```kt + * val issuerPublicJwk = JWK.parse("""{"kty":"EC","use":"sig","crv":"secp256k1","kid":"IgOoELyADeleZ2aRFpbGSujatncrPgK1NtTeTJGjhJQ","x":"br1bYYLXTV-FpxnORwLOP80i_5ewwhZcIL6B5-fYL34","y":"CYiM_DhgdXsZQZXMHtt2o9LgPUPuZZz8EcPmCTZb7-U","alg":"ES256K"}""") + * val sdJwt = "eyJraWQiOiJJZ09vRUx5QURlbGVaMmFSRnBiR1N1amF0bmNyUGdLMU50VGVUSkdqaEpRIiwidHlwIjoiSldUIiwiYWxnIjoiRVMyNTZLIn0.eyJfc2QiOlsiUXpZeE9GeUUxQmpaQjZfTGJINjlyZHVOSzAybk5lRzNNcnh0em9lWkltcyIsIjE2QlBXeHVGdEZ1cFBKTzdMWENKMmlhOW80U0RHanJ4SU81R0N5NThRbjQiLCJiT1M5TGgyNVlOaEQxelhON3lxeVdVNy1ScUdBQ2ZnazMxYlVZLUlYNGk0IiwicjNPNE13MXNwT0g5UWpFcDFtNURTcGxTTWtKVHJMR3FuZms3c3RQd2xOVSJdLCJuYXRpb25hbGl0aWVzIjpbeyIuLi4iOiJ5UDNWNXQ5SjdPTDR3WExYNzVPbWVPMTB3VEJ4WDg5cDh0OGxfeUFPNUxjIn0seyIuLi4iOiJyXzF4aXliWUJPUldCU0hUTGdVR2ZPNjV4SUl3TF9uUzQ4aGJidU9TaV80In0seyIuLi4iOiIyaWV6NERiSkxBZUQzT1QwNmc1bGJvQmh0QlNwUm5GSEg4V1kzbklROFNzIn0seyIuLi4iOiJZLUJKV2pScjRhamNFZlRoazNQOVFOOTNfTGZYOVlhRDYycGdYcUFUUXM0In1dLCJhZGRyZXNzIjp7Il9zZCI6WyJwVFJKM0hxcEZOODdZX3hScTBfMENwNVpHOHlnOGJqZHM3ZXhSS0lWekNZIiwidFoxZXFkcXNjVHBjeVNhVmd5OFF3MzBVaTZZXzJPSW10QzJNVm5fenJ5YyIsInM2UThiXy1XaC1kNGp5cm5meDlrazlZMTBoY05XaXRjWFpoWnMtallOWmciLCJmcjloN3RNX0RoWGI3MWplMlYtc0NBWG11dFVlb0ZxZ3l6UnljaVFycVVrIl19LCJfc2RfYWxnIjoic2hhLTI1NiJ9.efUzQxOAZT5x5PzPmehMoRrR7quxfa6pgqQc1r6kDjwhuC8sFX3zgsZJOC_4zbQONURpPnwFHYdtTC9IRsJyTw~WyJfUjlmMTBVOGVkaVR6ejZZZ2pNeXpBIiwgImdpdmVuX25hbWUiLCAiTWlzdGVyIl0~WyJKSURWXzV2dHBtbzB5UkUtN1lUZGtBIiwgImZhbWlseV9uYW1lIiwgIlRlZSJd~WyJVY09Icnp5SGpsWVdXUlFWQlNiVDN3IiwgImJpcnRoZGF0ZSIsICIxOTQwLTEwLTMxIl0~WyJLdnJuTlpXVUVUWDF1dEY5NDgwX2lRIiwgIlVTIl0~WyJkU0hJVVhuRDJSUTF2Mi1SZnA5REt3IiwgIkRFIl0~WyJBeFhsNW5kN1BhZ2lqQkxSdUZmTXNRIiwgIlBPIl0~WyJReGFDTVNaX1IzM0o4aWJNNGpybGNRIiwgInN0cmVldCIsICJoYXBweSBzdHJlZXQgMTIzIl0~WyJaS0loU0dGcUF3Nm9mWm81cHI5NDd3IiwgInppcF9jb2RlIiwgIjEyMzQ1Il0~" + * sdJwt.verify( + * VerificationOptions( + * issuerPublicJwk = issuerPublicJwk, + * supportedAlgorithms = setOf(JWSAlgorithm.ES256K), + * holderBindingOption = HolderBindingOption.SkipVerifyHolderBinding, + * ) + * ) + * ``` + */ + @Throws(Exception::class) + public fun verify( + verificationOptions: VerificationOptions + ) { + // Validate the SD-JWT: + require(verificationOptions.supportedAlgorithms.contains(issuerJwt.header.algorithm)) { + "the algorithm of issuerJwt (${issuerJwt.header.algorithm}) is not part of the declared list of supported " + + "algorithms (${verificationOptions.supportedAlgorithms})" + } + // The none algorithm MUST NOT be accepted. + require(issuerJwt.header.algorithm != JWSAlgorithm.NONE) { + "algorithm in issuerJwt must not be `none`" + } + // Validate the signature over the SD-JWT. + // Validate the Issuer of the SD-JWT and that the signing key belongs to this Issuer. + val verifier = issuerVerifier(verificationOptions) + if (!issuerJwt.verify(verifier)) { + throw SignatureException("Verifying the issuerJwt failed: ${issuerJwt.serialize()}") + } + + // Check that the SD-JWT is valid using nbf, iat, and exp claims, if provided in the SD-JWT, and not selectively disclosed. + val claimsVerifier = DefaultJWTClaimsVerifier(null, null) + claimsVerifier.verify(issuerJwt.jwtClaimsSet, null) + + if (verificationOptions.holderBindingOption == HolderBindingOption.VerifyHolderBinding) { + // If Holder Binding JWT is not provided, the Verifier MUST reject the Presentation. + require(keyBindingJwt != null) { + "Holder binding required, but holder binding JWT not found" + } + + // Ensure that a signing algorithm was used that was deemed secure for the application. Refer to [RFC8725], Sections 3.1 + // and 3.2 for details. + require(verificationOptions.supportedAlgorithms.contains(keyBindingJwt.header.algorithm)) { + "the algorithm of keyBindingJwt (${keyBindingJwt.header.algorithm}) is not part of the declared list of " + + "supported algorithms (${verificationOptions.supportedAlgorithms})" + } + // The none algorithm MUST NOT be accepted. + require(keyBindingJwt.header.algorithm != JWSAlgorithm.NONE) { + "algorithm in keyBindingJwt must not be `none`" + } + + // Validate the signature over the Holder Binding JWT. + // Check that the Holder Binding JWT is valid using nbf, iat, and exp claims, if provided in the Holder Binding JWT. + // Determine that the Holder Binding JWT is bound to the current transaction and was created for this Verifier (replay + // protection). This is usually achieved by a nonce and aud field within the Holder Binding JWT. + val holderVerifier = keyBindingVerifier(verificationOptions) + require(keyBindingJwt.verify(holderVerifier)) { + throw SignatureException("Verifying the issuerJwt failed: ${keyBindingJwt.serialize()}") + } + + val holderClaimsVerifier = DefaultJWTClaimsVerifier( + JWTClaimsSet.Builder() + .audience(verificationOptions.desiredAudience) + .claim("nonce", verificationOptions.desiredNonce) + .build(), + null + ) + holderClaimsVerifier.verify(keyBindingJwt.jwtClaimsSet, null) + } + } + + private fun issuerVerifier(verificationOptions: VerificationOptions): JWSVerifier { + val verifier = DefaultJWSVerifierFactory().createJWSVerifier( + issuerJwt.header, + jwkToKey(verificationOptions.issuerPublicJwk) + ) + verifier.jcaContext.provider = BouncyCastleProviderSingleton.getInstance() as Provider + return verifier + } + + private fun keyBindingVerifier(verificationOptions: VerificationOptions): JWSVerifier { + val verifier = DefaultJWSVerifierFactory().createJWSVerifier( + keyBindingJwt!!.header, + jwkToKey(verificationOptions.keyBindingPublicJwk!!) + ) + verifier.jcaContext.provider = BouncyCastleProviderSingleton.getInstance() as Provider + return verifier + } + + private fun jwkToKey(jwk: JWK): Key { + return when (jwk) { + is ECKey -> jwk.toPublicKey() + is OctetKeyPair -> jwk.toPublicKey() + else -> throw InvalidJwkException("jwk not supported for value: $jwk") + } + } + + /** + * Returns a set of indices for disclosures contained within this SD-JWT. The + * indices are selected such that the disclosure's digest is contained inside the [digests] map. + */ + public fun selectDisclosures(digests: Set): Set { + val hashAlg = issuerJwt.jwtClaimsSet.getHashAlg() + return buildSet { + for (disclosureAndIndex in disclosures.withIndex()) { + val disclosure = disclosureAndIndex.value + if (digests.contains(disclosure.digest(hashAlg))) { + add(disclosureAndIndex.index) + } + } + } + } + + /** + * Returns the digest for the disclosure that matches [name]. + */ + @JvmOverloads + public fun digestsOf(name: String, disclosedValue: Any? = null): String? { + val hashAlg = issuerJwt.jwtClaimsSet.getHashAlg() + val objectDisclosure = disclosures.map { it as? ObjectDisclosure }.firstOrNull { it?.claimName == name } + if (objectDisclosure != null) { + return objectDisclosure.digest(hashAlg) + } + val claim = issuerJwt.jwtClaimsSet.getClaim(name) + val disclosuresByDigest = disclosures.associateBy { it.digest(hashAlg) } + if (claim != null && claim is List<*>) { + for (value in claim) { + require(value is Map<*, *>) + val digest = value[blindedArrayKey] + if (digest is String && (disclosuresByDigest[digest] as ArrayDisclosure?)?.claimValue == disclosedValue) { + return digest + } + } + } + return null + } + + /** Same as [SdJwtUnblinder.unblind]. */ + public fun unblind(): JWTClaimsSet = SdJwtUnblinder().unblind(this.serialize()) + + public companion object { + /** + * The reverse of the [serialize] operation. Given the serialized format of an SD-JWT, returns a [SdJwt]. + * Verification of the signature of each JWT is left to the caller. + */ + @JvmStatic + public fun parse(input: String): SdJwt { + val parts = input.split(separator) + require(parts.isNotEmpty()) { + "input must not be empty" + } + val keyBindingInput = parts[parts.size - 1] + val keyBindingJwt = keyBindingInput.takeUnless { it.isEmpty() }?.run(SignedJWT::parse) + return SdJwt( + SignedJWT.parse(parts[0]), + parts.subList(1, parts.size - 1).map { Disclosure.parse(it) }, + keyBindingJwt, + input, + ) + } + } +} + +/** + * The hash algorithm as described in https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-hash-function-claim + */ +public typealias HashFunc = (ByteArray) -> ByteArray + +internal val defaultMapper = jacksonObjectMapper() + diff --git a/sdjwt/src/main/kotlin/web5/security/SdJwtBlinder.kt b/sdjwt/src/main/kotlin/web5/security/SdJwtBlinder.kt new file mode 100644 index 000000000..3e8acd3c1 --- /dev/null +++ b/sdjwt/src/main/kotlin/web5/security/SdJwtBlinder.kt @@ -0,0 +1,318 @@ +package web5.security + +import com.fasterxml.jackson.core.type.TypeReference +import com.fasterxml.jackson.databind.ObjectMapper +import com.nimbusds.jose.util.Base64URL +import com.nimbusds.jwt.JWTClaimsSet +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.Collections +import kotlin.math.ceil +import kotlin.math.log2 +import kotlin.math.pow + +/** + * Hash implementations supported by this library. + */ +public enum class Hash( + public val hashFunc: HashFunc, + public val ianaName: String, +) { + /** The sha-256 hash algorithm as registered in https://www.iana.org/assignments/named-information/named-information.xhtml */ + SHA_256({ hashString("SHA-256", it) }, "sha-256"), + + /** The sha-512 hash algorithm as registered in https://www.iana.org/assignments/named-information/named-information.xhtml */ + SHA_512({ hashString("SHA-512", it) }, "sha-512") +} + +private fun hashString(type: String, input: ByteArray): ByteArray { + return MessageDigest.getInstance(type) + .digest(input) +} + +/** Base class to be used for any blinding strategies. */ +public sealed class BlindOption + +/** FlatBlindOption implements https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-04.html#name-option-1-flat-sd-jwt */ +public object FlatBlindOption : BlindOption() + +/** SubClaimBlindOption implements https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-04.html#name-option-2-structured-sd-jwt */ +public class SubClaimBlindOption( + public val claimsToBlind: Map +) : BlindOption() + +/** RecursiveBlindOption implements https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-04.html#name-option-3-sd-jwt-with-recurs */ +public object RecursiveBlindOption : BlindOption() + +/** Implements https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-array-elements */ +public object ArrayBlindOption : BlindOption() + + +/** Interface for generating salt values as described in https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#disclosures_for_object_properties */ +public interface ISaltGenerator { + /** Returns a base64url encoded string over cryptographically secure pseudo random data. */ + public fun generate(claim: String): String +} + +/** Simple implementation of [ISaltGenerator]. */ +public class SaltGenerator(private val numBytes: Int = 128 / 8) : ISaltGenerator { + override fun generate(claim: String): String { + val data = ByteArray(numBytes) + SecureRandom().nextBytes(data) + return Base64URL.encode(data).toString() + } +} + +private class ClaimSetBlinder( + private val sdAlg: HashFunc, + private val disclosureFactory: DisclosureFactory, + private val totalDigests: (Int) -> Int, + private val shuffle: (List) -> Unit, +) { + fun blindElementsRecursively(elems: List): Pair, List> { + val blinded = mutableListOf() + val allDisclosures = mutableListOf() + for (elem in elems) { + val claimsToBlind = mutableMapOf() + + when (elem) { + is Map<*, *> -> { + elem.keys.forEach { k -> + claimsToBlind[k as String] = RecursiveBlindOption + } + + @Suppress("UNCHECKED_CAST") + val elemMap = elem as Map + + val (blindedValue, ds) = toBlindedClaimsAndDisclosures(elemMap, claimsToBlind) + blinded.add(blindedValue) + allDisclosures.addAll(ds) + } + + is List<*> -> { + @Suppress("UNCHECKED_CAST") + val elemList = elem as List + + val (blindedValue, ds) = blindElementsRecursively(elemList) + blinded.add(blindedValue) + allDisclosures.addAll(ds) + } + + else -> blinded.add(elem) + } + } + return Pair(blinded, allDisclosures) + } + + /** + * Given a map of [claims] and a map of [claimsToBlind], returns a pair of: blinded claims and a list of disclosures + * that contain the original claims that were blinded. + * + * The method used to blind each claim is specified in the [claimsToBlind] object. See the subclasses of [BlindOption] + * for the different options on how a claim can be blinded. + */ + @Throws( + BlindOptionNotValidException::class, + ClaimValueIsNotArrayException::class, + ) + fun toBlindedClaimsAndDisclosures( + claims: Map, + claimsToBlind: Map + ): Pair, List> { + val blindedClaims = mutableMapOf() + val allDisclosures = mutableListOf() + val hashedDisclosures = mutableListOf() + + for ((claimName, claimValue) in claims) { + val blindOption = claimsToBlind[claimName] + if (blindOption == null) { + blindedClaims[claimName] = claimValue + continue + } + + when (blindOption) { + is FlatBlindOption -> { + val disclosure = disclosureFactory.fromClaimAndValue(claimName, claimValue) + allDisclosures.add(disclosure) + hashedDisclosures.add(disclosure.digest(sdAlg)) + } + + is SubClaimBlindOption -> { + when (claimValue) { + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + val claimValueMap = claimValue as Map + val (blindedSubClaim, subClaimDisclosures) = toBlindedClaimsAndDisclosures( + claimValueMap, + blindOption.claimsToBlind + ) + blindedClaims[claimName] = blindedSubClaim + allDisclosures.addAll(subClaimDisclosures) + } + + else -> throw BlindOptionNotValidException("blind option not applicable to non-object types") + } + } + + is RecursiveBlindOption -> { + val disclosure = processRecursiveDisclosure(claimValue, allDisclosures, claimName) + allDisclosures.add(disclosure) + hashedDisclosures.add(disclosure.digest(sdAlg)) + } + + is ArrayBlindOption -> { + when (claimValue) { + is List<*> -> { + val disclosures = claimValue.indices.map { + disclosureFactory.fromArrayValue(it, claimName, claimValue[it]!!) + } + allDisclosures.addAll(disclosures) + val arrayDisclosures = disclosures.map { + mapOf( + blindedArrayKey to it.digest(sdAlg) + ) + }.toMutableList() + + repeat(totalDigests(arrayDisclosures.size) - arrayDisclosures.size) { + val randBytes = ByteArray(32) + SecureRandom().nextBytes(randBytes) + + arrayDisclosures.add(mapOf(blindedArrayKey to Base64URL.encode(sdAlg(randBytes)).toString())) + } + + blindedClaims[claimName] = arrayDisclosures + } + + else -> throw ClaimValueIsNotArrayException("When choosing ArrayBlindOption, $claimValue must be an array") + } + } + } + } + + // Add some decoy hashed disclosures + val totalToHash = totalDigests(hashedDisclosures.size) + repeat(totalToHash - hashedDisclosures.size) { + val randBytes = ByteArray(32) + SecureRandom().nextBytes(randBytes) + hashedDisclosures.add(Base64URL.encode(sdAlg(randBytes)).toString()) + } + + // Shuffle to prevent disclosure of ordering + shuffle(hashedDisclosures) + + if (hashedDisclosures.isNotEmpty()) { + blindedClaims[sdClaimName] = hashedDisclosures + } + return Pair(blindedClaims, allDisclosures) + } + + private fun processRecursiveDisclosure( + claimValue: Any, + allDisclosures: MutableList, + claimName: String): Disclosure { + val disclosure: Disclosure + + when (claimValue) { + is List<*> -> { + @Suppress("UNCHECKED_CAST") + val claimValueList = claimValue as List + val (blindedSubClaims, subClaimDisclosures) = blindElementsRecursively( + claimValueList + ) + allDisclosures.addAll(subClaimDisclosures) + + disclosure = disclosureFactory.fromClaimAndValue(claimName, blindedSubClaims) + } + + is Map<*, *> -> { + @Suppress("UNCHECKED_CAST") + val claimValueMap = claimValue as Map + val subClaimsToBlind = + claimValueMap + .keys.associateWith { RecursiveBlindOption } + val (blindedSubClaims, subClaimDisclosures) = toBlindedClaimsAndDisclosures( + claimValue, + subClaimsToBlind + ) + allDisclosures.addAll(subClaimDisclosures) + + disclosure = disclosureFactory.fromClaimAndValue(claimName, blindedSubClaims) + } + + else -> { + disclosure = disclosureFactory.fromClaimAndValue(claimName, claimValue) + } + } + return disclosure + } +} + +public const val sdClaimName: String = "_sd" + +/** The _sd_alg claim name. */ +public const val sdAlgClaimName: String = "_sd_alg" + +/** + * A signer that is capable of producing [SdJwt] given some parameters. */ +public class SdJwtBlinder( + saltGenerator: ISaltGenerator = SaltGenerator(), + private val hash: Hash = Hash.SHA_256, + private val shuffle: (List) -> Unit = Collections::shuffle, + private val totalDigests: (Int) -> Int = ::getNextPowerOfTwo, + mapper: ObjectMapper = defaultMapper, +) { + private val disclosureFactory: DisclosureFactory = DisclosureFactory(saltGenerator, mapper) + + /** + * Returns an [SdJwt.Builder] with the [SdJwt.Builder.jwtClaimsSet] blinded and [SdJwt.Builder.disclosures] fields + * set. The [SdJwt.Builder.jwtClaimsSet] field is also known as the SD-JWT Payload in https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-sd-jwt-payload + * + * Note: You must set the [SdJwt.Builder.issuerHeader] before calling [SdJwt.Builder.build]. + */ + public fun blind( + claimsData: String, + claimsToBlind: Map): SdJwt.Builder { + val typeRef = object : TypeReference>() {} + val claimsMap = defaultMapper.readValue(claimsData, typeRef) + + val csb = ClaimSetBlinder( + sdAlg = hash.hashFunc, + disclosureFactory = disclosureFactory, + totalDigests = totalDigests, + shuffle = shuffle, + ) + + val (blindedClaims, disclosures) = csb.toBlindedClaimsAndDisclosures(claimsMap, claimsToBlind) + + val blindedClaimsMap = blindedClaims.toMutableMap() + blindedClaimsMap[sdAlgClaimName] = hash.ianaName + + val payload = JWTClaimsSet.parse(blindedClaimsMap) + + return SdJwt.Builder(jwtClaimsSet = payload, disclosures = disclosures) + } + +} + +private fun getNextPowerOfTwo(n: Int): Int { + if (n <= 0) { + return 1 + } + + // calculates the smallest power of 2 that is greater than or equal to n + 1 + return 2.0.pow(ceil(log2((n + 1).toDouble())).toInt()).toInt() +} + +private class DisclosureFactory( + private val saltGen: ISaltGenerator, + private val mapper: ObjectMapper) { + fun fromClaimAndValue(claim: String, claimValue: Any): Disclosure { + val saltValue = saltGen.generate(claim) + return ObjectDisclosure(saltValue, claim, claimValue, mapper = mapper) + } + + fun fromArrayValue(index: Int, claim: String, value: Any): Disclosure { + val saltValue = saltGen.generate("$claim[$index]") + return ArrayDisclosure(saltValue, value, mapper = mapper) + } +} diff --git a/sdjwt/src/main/kotlin/web5/security/SdJwtUnblinder.kt b/sdjwt/src/main/kotlin/web5/security/SdJwtUnblinder.kt new file mode 100644 index 000000000..4825cdf7e --- /dev/null +++ b/sdjwt/src/main/kotlin/web5/security/SdJwtUnblinder.kt @@ -0,0 +1,238 @@ +package web5.security + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.jwk.JWK +import com.nimbusds.jwt.JWTClaimsSet +import java.util.Stack + +/** + * Options for verification of an [SdJwt]. + * + * [issuerPublicJwk] is the public key of the issuer of the SD-JWT. Discovery of this key is out of scope for this + * library. It must be provided by the caller. + * + * Callers MUST set the [supportedAlgorithms] to declare which set of algorithms they explicitly support. This follows + * the guidance from https://www.rfc-editor.org/rfc/rfc8725.html#name-use-appropriate-algorithms + * + * [holderBindingOption] is used to tell whether holder binding should be checked. When [HolderBindingOption.VerifyHolderBinding] + * is selected, then [desiredNonce], [desiredAudience], and [keyBindingPublicJwk] are required. + */ +public class VerificationOptions( + public val issuerPublicJwk: JWK, + + public val supportedAlgorithms: Set, + + public val holderBindingOption: HolderBindingOption, + + // The nonce and audience to check for when doing holder binding verification. + // Needed only when holderBindingOption == VerifyHolderBinding. + public val desiredNonce: String? = null, + public val desiredAudience: String? = null, + public val keyBindingPublicJwk: JWK? = null, +) + +/** Options for holder binding processing. */ +public enum class HolderBindingOption(public val value: Boolean) { + VerifyHolderBinding(true), + SkipVerifyHolderBinding(false) +} + +internal fun JWTClaimsSet.getHashAlg(): HashFunc { + val hashName = when (val hashNameValue = this.getClaim(sdAlgClaimName)) { + null -> { + Hash.SHA_256.ianaName + } + + is String -> { + hashNameValue + } + + else -> { + throw SdAlgValueNotStringException("Converting _sd_alg claim value to string") + } + } + + return when (hashName) { + Hash.SHA_256.ianaName -> Hash.SHA_256.hashFunc + Hash.SHA_512.ianaName -> Hash.SHA_512.hashFunc + else -> throw HashNameNotSupportedException("Unsupported hash name $hashName") + } +} + +/** Responsible for taking an SD-JWT in serialization format and unblinding it. */ +public class SdJwtUnblinder { + /** + * Unblinds [serializedSdJwt]. Follows the algorithm specified in https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#section-6.1 + * + * An unblinded [JWTClaimsSet], where the hidden values in the `issuerJwt` are replaced with values from the + * `disclosures` presented. + * + * @throws SdKeyNotArrayException if the `_sd` key does not refer to an array as required. + * @throws SdValueNotStringException if a value in `_sd` is not a string as required. + * @throws EllipsisValueNotStringException if the value of `...` is not a string as required. + * @throws ParentNotArrayException if the parent of an array element digest is not an array as required. + * @throws DuplicateDigestException if a digest is found more than once, which is not allowed. + * @throws InvalidObjectDisclosureInsertionPointException if the insertion point for an object disclosure is not a map as required. + * @throws InvalidArrayDisclosureInsertionPointException if the insertion point for an array disclosure is not an array as required. + * @throws ClaimNameAlreadyExistsException if a claim name already exists in the claims set. + * @throws SdAlgValueNotStringException if the `_sd_alg` claim value is not a string as required. + * @throws HashNameNotSupportedException if the `_sd_alg` claim value represents a hash algorithm name that's not supported. + */ + @Throws( + SdKeyNotArrayException::class, + SdValueNotStringException::class, + EllipsisValueNotStringException::class, + ParentNotArrayException::class, + DuplicateDigestException::class, + InvalidObjectDisclosureInsertionPointException::class, + InvalidArrayDisclosureInsertionPointException::class, + ClaimNameAlreadyExistsException::class, + SdAlgValueNotStringException::class, + HashNameNotSupportedException::class, + ) + public fun unblind( + serializedSdJwt: String + ): JWTClaimsSet { + // Separate the Presentation into the SD-JWT, the Disclosures (if any), and the Holder Binding JWT (if provided). + val sdJwt = SdJwt.parse(serializedSdJwt) + + // Check that the _sd_alg claim value is understood and the hash algorithm is deemed secure. + val hashAlg = sdJwt.issuerJwt.jwtClaimsSet.getHashAlg() + + // For each Disclosure provided, calculate the digest over the base64url-encoded string as described in Section 5.1.1.2. + val disclosuresByDigest = sdJwt.disclosures.associateBy { it.digest(hashAlg) } + + // Process the Disclosures and _sd keys in the SD-JWT as follows: + // Create a copy of the SD-JWT payload, if required for further processing. + val tokenClaims = sdJwt.issuerJwt.jwtClaimsSet.toJSONObject().toMutableMap() + processPayload(tokenClaims, disclosuresByDigest) + + return JWTClaimsSet.parse(tokenClaims) + } + + + /** + * ProcessPayload will recursively remove all _sd fields from the claims object, and replace it with the information found + * inside disclosuresByDigest. + */ + @Throws(Exception::class) + private fun processPayload( + claims: MutableMap, + disclosuresByDigest: Map + ) { + val workLeft = createWorkFrom(claims, null) + unblind(workLeft, disclosuresByDigest, claims) + + claims.remove(sdAlgClaimName) + } + + private class Work( + val insertionPoint: Any, + val disclosureDigest: String, + ) + + private fun createWorkFrom(claims: Any, parent: Any?): Stack { + val result: Stack = Stack() + when (claims) { + is Map<*, *> -> { + val sdClaimValue = claims[sdClaimName] + if (sdClaimValue != null) { + if (sdClaimValue !is List<*>) { + throw SdKeyNotArrayException("\"_sd\" key MUST refer to an array") + } + for (digest in sdClaimValue) { + if (digest !is String) { + throw SdValueNotStringException("all values in \"_sd\" MUST be strings") + } + result.add(Work(claims, digest)) + } + @Suppress("UNCHECKED_CAST") + (claims as MutableMap).remove(sdClaimName) + } + + val arrayElementDigest = claims[blindedArrayKey] + if (arrayElementDigest != null) { + if (arrayElementDigest !is String) { + throw EllipsisValueNotStringException("Value of \"...\" MUST be a string") + } + if (parent == null || parent !is List<*>) { + throw ParentNotArrayException("Parent must be an array") + } + result.add(Work(parent, arrayElementDigest)) + } + for (value in claims.values) { + result.addAll(createWorkFrom(value as Any, claims)) + } + } + + is List<*> -> { + for (claim in claims.reversed()) { + result.addAll(createWorkFrom(claim!!, claims)) + } + @Suppress("UNCHECKED_CAST") + (claims as MutableList).removeAll { true } + } + } + + return result + } + + private fun unblind( + workLeft: Stack, + disclosuresByDigest: Map, + claims: MutableMap) { + val digestsFound = HashSet() + while (workLeft.isNotEmpty()) { + val work = workLeft.pop() + val digestValue = work.disclosureDigest + val insertionPoint = work.insertionPoint + + // Compare the value with the digests calculated previously and find the matching Disclosure. If no such Disclosure can be + // found, the digest MUST be ignored. + val disclosure = disclosuresByDigest[digestValue] ?: continue + + // If any digests were found more than once, the Verifier MUST reject the Presentation. + if (!digestsFound.add(digestValue)) { + throw DuplicateDigestException("Digest \"$digestValue\" found more than once") + } + digestsFound.add(digestValue) + + when (disclosure) { + is ObjectDisclosure -> { + if (insertionPoint !is MutableMap<*, *>) { + throw InvalidObjectDisclosureInsertionPointException("Insertion point for object disclosure must be a map") + } + + if (insertionPoint.containsKey(disclosure.claimName) || claims.containsKey(disclosure.claimName)) { + throw ClaimNameAlreadyExistsException("Claim name \"${disclosure.claimName}\" already exists") + } + fun insert(m: MutableMap) { + m.put(disclosure.claimName, disclosure.claimValue) + } + @Suppress("UNCHECKED_CAST") + insert(insertionPoint as MutableMap) + + // If the decoded value contains an _sd key in an object, recursively process the key using the steps described in (*). + if (disclosure.claimValue is Map<*, *>) { + workLeft.addAll(createWorkFrom(disclosure.claimValue, insertionPoint)) + } + } + + is ArrayDisclosure -> { + if (insertionPoint !is MutableList<*>) { + throw InvalidArrayDisclosureInsertionPointException("Insertion point for array disclosure must be an array") + } + + // find and then replace insertion point + @Suppress("UNCHECKED_CAST") + (insertionPoint as MutableList).add(disclosure.claimValue) + + // If the decoded value contains an _sd key in an object, recursively process the key using the steps described in (*). + if (disclosure.claimValue is Map<*, *>) { + workLeft.addAll(createWorkFrom(disclosure.claimValue, insertionPoint)) + } + } + } + } + } +} \ No newline at end of file diff --git a/sdjwt/src/test/kotlin/web5/security/SdJwtBlinderTest.kt b/sdjwt/src/test/kotlin/web5/security/SdJwtBlinderTest.kt new file mode 100644 index 000000000..08e435a3b --- /dev/null +++ b/sdjwt/src/test/kotlin/web5/security/SdJwtBlinderTest.kt @@ -0,0 +1,330 @@ +package web5.security + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.erdtman.jcs.JsonCanonicalizer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SdJwtBlinderTest { + + private val mapper = jacksonObjectMapper().apply { + enable(SerializationFeature.INDENT_OUTPUT) + setSerializationInclusion(JsonInclude.Include.NON_NULL) + setDefaultPrettyPrinter(CustomPrettyPrinter()) + } + + @Test + fun `blinding a set of claims returns the expected sd-jwt`() { + val claims = """{ + "iss": "https://example.com/issuer", + "iat": 1683000000, + "exp": 1883000000, + "sub": "user_42", + + "cnf": { + "jwk": { + "kty": "EC", + "crv": "P-256", + "x": "TCAER19Zvu3OHF4j4W4vfSVoHIP1ILilDls7vCeGemc", + "y": "ZxjiWWbZMQGHVWKVQ4hbSIirsVfuecCE6t4jT9F2HZQ" + } + }, + + "updated_at": 1570000000, + "email": "johndoe@example.com", + "phone_number": "+1-202-555-0101", + "family_name": "Doe", + "phone_number_verified": true, + "address": { + "street_address": "123 Main St", + "locality": "Anytown", + "region": "Anystate", + "country": "US" + }, + "birthdate": "1940-01-01", + "given_name": "John", + "nationalities": [ + "US", + "DE" + ] + }""".trimIndent() + + val signer = SdJwtBlinder( + saltGenerator = MockMapGenerator( + mapOf( + "given_name" to "2GLC42sKQveCfGfryNRN9w", + "family_name" to "eluV5Og3gSNII8EYnsxA_A", + "email" to "6Ij7tM-a5iVPGboS5tmvVA", + "phone_number" to "eI8ZWm9QnKPpNPeNenHdhQ", + "phone_number_verified" to "Qg_O64zqAxe412a108iroA", + "address" to "AJx-095VPrpTtN4QMOqROA", + "birthdate" to "Pc33JM2LchcU_lHggv_ufQ", + "updated_at" to "G02NSrQfjFXQ7Io09syajA", + "nationalities[0]" to "lklxF5jMYlGTPUovMNIvCA", + "nationalities[1]" to "nPuoQnkRFq3BIeAm7AnXFA", + ) + ), + shuffle = {}, + totalDigests = { i -> i }, + mapper = mapper, + ) + // Define claims to blind + // The nationalities array is always visible, but its contents are selectively disclosable. + // The sub element and essential verification data (iss, iat, cnf, etc.) are always visible. + // All other End-User claims are selectively disclosable. + // For address, the Issuer is using a flat structure, i.e., all of the claims in the address claim can only be disclosed in full. Other options are discussed in Section 5.7. + val claimsToBlind = mapOf( + "given_name" to FlatBlindOption, + "family_name" to FlatBlindOption, + "email" to FlatBlindOption, + "phone_number" to FlatBlindOption, + "phone_number_verified" to FlatBlindOption, + "address" to FlatBlindOption, + "birthdate" to FlatBlindOption, + "updated_at" to FlatBlindOption, + "nationalities" to ArrayBlindOption, + ) + + val sdJwt = signer.blind(claims, claimsToBlind) + + val expected = """{ + "_sd": [ + "CrQe7S5kqBAHt-nMYXgc6bdt2SH5aTY1sU_M-PgkjPI", + "JzYjH4svliH0R3PyEMfeZu6Jt69u5qehZo7F7EPYlSE", + "PorFbpKuVu6xymJagvkFsFXAbRoc2JGlAUA2BA4o7cI", + "TGf4oLbgwd5JQaHyKVQZU9UdGE0w5rtDsrZzfUaomLo", + "XQ_3kPKt1XyX7KANkqVR6yZ2Va5NrPIvPYbyMvRKBMM", + "XzFrzwscM6Gn6CJDc6vVK8BkMnfG8vOSKfpPIZdAfdE", + "gbOsI4Edq2x2Kw-w5wPEzakob9hV1cRD0ATN3oQL9JM", + "jsu9yVulwQQlhFlM_3JlzMaSFzglhQG0DpfayQwLUK4" + ], + "iss": "https://example.com/issuer", + "iat": 1683000000, + "exp": 1883000000, + "sub": "user_42", + "nationalities": [ + { + "...": "pFndjkZ_VCzmyTa6UjlZo3dh-ko8aIKQc9DlGzhaVYo" + }, + { + "...": "7Cf6JkPudry3lcbwHgeZ8khAv1U1OSlerP0VkBJrWZ0" + } + ], + "_sd_alg": "sha-256", + "cnf": { + "jwk": { + "kty": "EC", + "crv": "P-256", + "x": "TCAER19Zvu3OHF4j4W4vfSVoHIP1ILilDls7vCeGemc", + "y": "ZxjiWWbZMQGHVWKVQ4hbSIirsVfuecCE6t4jT9F2HZQ" + } + } + }""".trimIndent() + + val givenNameActualDisclosure = sdJwt.disclosures?.find { disclosure -> + (disclosure as? ObjectDisclosure)?.let { it.claimName == "given_name" } ?: false + } as ObjectDisclosure + assertEquals("2GLC42sKQveCfGfryNRN9w", givenNameActualDisclosure.salt) + assertEquals("John", givenNameActualDisclosure.claimValue) + assertEquals( + JsonCanonicalizer(expected).encodedString, + JsonCanonicalizer(mapper.writeValueAsString(sdJwt.jwtClaimsSet?.toJSONObject())).encodedString + ) + } + + @Test + // Test comes from https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-option-1-flat-sd-jwt + fun `blinding address with flat option returns the expected claimset`() { + val signer = SdJwtBlinder( + saltGenerator = MockMapGenerator( + mapOf( + "address" to "2GLC42sKQveCfGfryNRN9w" + ) + ), + totalDigests = { i -> i }, + mapper = mapper, + ) + + // Define claims as a JSON string + val claims = """{ + "sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c", + "address": { + "street_address": "Schulstr. 12", + "locality": "Schulpforta", + "region": "Sachsen-Anhalt", + "country": "DE" + }, + "iss": "https://example.com/issuer", + "iat": 1683000000, + "exp": 1883000000 + }""".trimIndent() + + // Define claims to blind + val claimsToBlind = mapOf( + "address" to FlatBlindOption, + ) + + val sdJwt = signer.blind(claims, claimsToBlind) + + val expected = """{ + "_sd": [ + "fOBUSQvo46yQO-wRwXBcGqvnbKIueISEL961_Sjd4do" + ], + "iss": "https://example.com/issuer", + "iat": 1683000000, + "exp": 1883000000, + "sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c", + "_sd_alg": "sha-256" + }""".trimIndent() + + assertEquals( + JsonCanonicalizer(expected).encodedString, + JsonCanonicalizer(mapper.writeValueAsString(sdJwt.jwtClaimsSet?.toJSONObject())).encodedString + ) + } + + @Test + // Test comes from https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-option-2-structured-sd-jwt + fun `blinding nested objects with flat option returns the expected claimset`() { + val signer = SdJwtBlinder( + saltGenerator = MockMapGenerator( + mapOf( + "street_address" to "2GLC42sKQveCfGfryNRN9w", + "locality" to "eluV5Og3gSNII8EYnsxA_A", + "region" to "6Ij7tM-a5iVPGboS5tmvVA", + "country" to "eI8ZWm9QnKPpNPeNenHdhQ" + ) + ), + shuffle = {}, + totalDigests = { i -> i }, + mapper = mapper, + ) + + // Define claims as a JSON string + val claims = """{ + "sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c", + "address": { + "locality": "Schulpforta", + "street_address": "Schulstr. 12", + "region": "Sachsen-Anhalt", + "country": "DE" + }, + "iss": "https://example.com/issuer", + "iat": 1683000000, + "exp": 1883000000 + }""".trimIndent() + + // Define claims to blind + val claimsToBlind = mapOf( + "address" to SubClaimBlindOption( + mapOf( + "street_address" to FlatBlindOption, + "locality" to FlatBlindOption, + "region" to FlatBlindOption, + "country" to FlatBlindOption, + ) + ), + ) + + val sdJwt = signer.blind(claims, claimsToBlind) + + val expected = """{ + "iss": "https://example.com/issuer", + "iat": 1683000000, + "exp": 1883000000, + "sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c", + "address": { + "_sd": [ + "6vh9bq-zS4GKM_7GpggVbYzzu6oOGXrmNVGPHP75Ud0", + "9gjVuXtdFROCgRrtNcGUXmF65rdezi_6Er_j76kmYyM", + "KURDPh4ZC19-3tiz-Df39V8eidy1oV3a3H1Da2N0g88", + "WN9r9dCBJ8HTCsS2jKASxTjEyW5m5x65_Z_2ro2jfXM" + ] + }, + "_sd_alg": "sha-256" + }""".trimIndent() + + assertEquals( + JsonCanonicalizer(expected).encodedString, + JsonCanonicalizer(mapper.writeValueAsString(sdJwt.jwtClaimsSet?.toJSONObject())).encodedString + ) + } + + @Test + // Test comes from https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#name-option-3-sd-jwt-with-recurs + fun `blinding address recursively return the expected claimset`() { + val signer = SdJwtBlinder( + saltGenerator = MockMapGenerator( + mapOf( + "street_address" to "2GLC42sKQveCfGfryNRN9w", + "locality" to "eluV5Og3gSNII8EYnsxA_A", + "region" to "6Ij7tM-a5iVPGboS5tmvVA", + "country" to "eI8ZWm9QnKPpNPeNenHdhQ", + "address" to "Qg_O64zqAxe412a108iroA", + ) + ), + shuffle = {}, + totalDigests = { i -> i }, + mapper = mapper, + ) + + // Define claims as a JSON string + val claims = """{ + "sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c", + "address": { + "street_address": "Schulstr. 12", + "locality": "Schulpforta", + "region": "Sachsen-Anhalt", + "country": "DE" + }, + "iss": "https://example.com/issuer", + "iat": 1683000000, + "exp": 1883000000 + }""".trimIndent() + + // Define claims to blind + val claimsToBlind = mapOf( + "address" to RecursiveBlindOption + ) + + val sdJwt = signer.blind(claims, claimsToBlind) + + val expected = """{ + "_sd": [ + "dQ8wNyUukwFtQFG1LpY4_P4Vfy6Mnk9PUa2YC2C2Fvw" + ], + "iss": "https://example.com/issuer", + "iat": 1683000000, + "exp": 1883000000, + "sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c", + "_sd_alg": "sha-256" + }""".trimIndent() + + assertEquals( + setOf( + "WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgInN0cmVldF9hZGRyZXNzIiwgIlNjaHVsc3RyLiAxMiJd", + "WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwgImxvY2FsaXR5IiwgIlNjaHVscGZvcnRhIl0", + "WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwgInJlZ2lvbiIsICJTYWNoc2VuLUFuaGFsdCJd", + "WyJlSThaV205UW5LUHBOUGVOZW5IZGhRIiwgImNvdW50cnkiLCAiREUiXQ", + "WyJRZ19PNjR6cUF4ZTQxMmExMDhpcm9BIiwgImFkZHJlc3MiLCB7Il9zZCI6IFsgIjlnalZ1WHRkRlJPQ2dScnROY0dVWG1GNjVyZ" + + "GV6aV82RXJfajc2a21ZeU0iLCAgIjZ2aDlicS16UzRHS01fN0dwZ2dWYll6enU2b09HWHJtTlZHUEhQNzVVZDAiLCAgIktVUkRQ" + + "aDRaQzE5LTN0aXotRGYzOVY4ZWlkeTFvVjNhM0gxRGEyTjBnODgiLCAgIldOOXI5ZENCSjhIVENzUzJqS0FTeFRqRXlXNW01eDY" + + "1X1pfMnJvMmpmWE0iIF19XQ" + ), + sdJwt.disclosures?.map { it.serialize(mapper) }?.toSet() + ) + assertEquals( + JsonCanonicalizer(expected).encodedString, + JsonCanonicalizer(mapper.writeValueAsString(sdJwt.jwtClaimsSet?.toJSONObject())).encodedString + ) + } +} + +class MockMapGenerator(private val values: Map = emptyMap()) : ISaltGenerator { + override fun generate(claim: String): String { + return values[claim] ?: "_26bc4LT-ac6q2KI6cBW5es" + } + +} \ No newline at end of file diff --git a/sdjwt/src/test/kotlin/web5/security/SdJwtTest.kt b/sdjwt/src/test/kotlin/web5/security/SdJwtTest.kt new file mode 100644 index 000000000..fb30bece6 --- /dev/null +++ b/sdjwt/src/test/kotlin/web5/security/SdJwtTest.kt @@ -0,0 +1,196 @@ +package web5.security + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.nimbusds.jose.JOSEObjectType +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.JWSHeader +import com.nimbusds.jose.JWSSigner +import com.nimbusds.jose.crypto.impl.ECDSAProvider +import com.nimbusds.jose.jwk.JWK +import com.nimbusds.jose.util.Base64URL +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import web5.sdk.crypto.InMemoryKeyManager +import web5.sdk.crypto.KeyManager +import web5.sdk.dids.methods.key.DidKey +import java.io.File +import java.io.IOException + +val example1 = File("src/test/resources/example1.sdjwt").readText() + +class SdJwtTest { + private val mapper = jacksonObjectMapper().apply { + enable(SerializationFeature.INDENT_OUTPUT) + setSerializationInclusion(JsonInclude.Include.NON_NULL) + setDefaultPrettyPrinter(CustomPrettyPrinter()) + } + + @Test + fun `parse and serialize are inverse functions`() { + val sdJwt = SdJwt.parse(example1) + val serialized = sdJwt.serialize(mapper) + assertEquals(example1, serialized) + } + + @Test + fun `test the mapper serializes objects as expected`() { + val result = mapper.writeValueAsString( + mapOf( + "street_address" to "123 Main St", + "locality" to "Anytown", + ) + ) + assertEquals("""{"street_address": "123 Main St", "locality": "Anytown"}""", result) + } + + @Test + fun `whole flow from issuer to holder to verifier`() { + // First issue an SD-JWT + val blinder = SdJwtBlinder() + + val claimsData = """{ + "given_name": "Mister", + "family_name": "Tee", + "birthdate": "1940-10-31", + "nationalities": ["US", "DE", "PO"], + "address": { + "street": "happy street 123", + "zip_code": "12345" + } + }""".trimIndent() + val claimsToBlind = mapOf( + "given_name" to FlatBlindOption, + "family_name" to FlatBlindOption, + "birthdate" to FlatBlindOption, + "nationalities" to ArrayBlindOption, + "address" to SubClaimBlindOption( + mapOf( + "street" to FlatBlindOption, + "zip_code" to FlatBlindOption, + ) + ), + ) + val builder = blinder.blind( + claimsData, + claimsToBlind = claimsToBlind + ) + val jwsAlgorithm = JWSAlgorithm.ES256K + + val keyManager = InMemoryKeyManager() + val did = DidKey.create(keyManager) + val issuerPublicJwk = getPublicKey(did) + val alias = keyManager.getDeterministicAlias(issuerPublicJwk) + val issuerSigner = KeyManagerSigner(keyManager, alias) + + builder.issuerHeader = JWSHeader.Builder(jwsAlgorithm) + .type(JOSEObjectType.JWT) + .keyID(alias) + .build() + val sdJwt = builder.build() + + sdJwt.signAsIssuer(issuerSigner) + val serializedSdJwt = sdJwt.serialize() + + // Phew! That was quite a bit of work. Now let's assume that this got to the holder. + // The holder only wants to reveal their birthdate, a couple of nationalities, and only the zip_code. + val holderSdJwt = SdJwt.parse(serializedSdJwt) + + val idsOfDisclosures: Set = holderSdJwt.selectDisclosures( + setOf( + holderSdJwt.digestsOf("birthdate"), + holderSdJwt.digestsOf("nationalities", "PO"), + holderSdJwt.digestsOf("nationalities", "US"), + holderSdJwt.digestsOf("address"), + holderSdJwt.digestsOf("zip_code"), + ).filterNotNull().toSet() + ) + val sdJwtToPresent = SdJwt( + issuerJwt = holderSdJwt.issuerJwt, + disclosures = holderSdJwt.disclosures.filterIndexed { index, _ -> idsOfDisclosures.contains(index) }, + ) + val serializedPresentedSdJwt = sdJwtToPresent.serialize() + + // Optionally, the holder can choose to sign key binding stuff, but we're skipping that step. + + // Now the verifier wants to make sure stuff looks ok. + val receivedSdJwt = SdJwt.parse(serializedPresentedSdJwt) + // ... make sure you always verify! + receivedSdJwt.verify( + VerificationOptions( + issuerPublicJwk = issuerPublicJwk, + supportedAlgorithms = setOf(JWSAlgorithm.ES256K), + holderBindingOption = HolderBindingOption.SkipVerifyHolderBinding, + ) + ) + + + //... and then you can process the received information. + val claimSet = receivedSdJwt.unblind() + + // the verifier only received birthdate! + assertEquals( + mapOf( + "birthdate" to "1940-10-31", + "nationalities" to listOf("US", "PO"), + "address" to mapOf( + "zip_code" to "12345" + ) + ), + claimSet.toJSONObject(), + ) + } + + private fun getPublicKey(did: DidKey): JWK { + val resolutionResult = DidKey.resolve(did.uri) + return JWK.parse(resolutionResult.didDocument.assertionMethodVerificationMethodsDereferenced.first().publicKeyJwk) + } +} + +/** + * A custom printer used for tests. + */ +internal class CustomPrettyPrinter : DefaultPrettyPrinter( + DefaultPrettyPrinter().withSpacesInObjectEntries().withObjectIndenter( + NopIndenter.instance + ) +) { + init { + this._objectFieldValueSeparatorWithSpaces = this._objectFieldValueSeparatorWithSpaces.substring(1) + } + + override fun createInstance(): CustomPrettyPrinter { + check(javaClass == CustomPrettyPrinter::class.java) { // since 2.10 + ("Failed `createInstance()`: " + javaClass.name + + " does not override method; it has to") + } + return CustomPrettyPrinter() + } + + @Throws(IOException::class) + override fun writeArrayValueSeparator(g: JsonGenerator) { + g.writeRaw(_separators.arrayValueSeparator) + g.writeRaw(' ') + _arrayIndenter.writeIndentation(g, _nesting) + } + + @Throws(IOException::class) + override fun writeObjectEntrySeparator(g: JsonGenerator) { + g.writeRaw(_separators.objectEntrySeparator) + g.writeRaw(' ') + _objectIndenter.writeIndentation(g, _nesting) + } +} + +class KeyManagerSigner(private val keyManager: KeyManager, private val keyAlias: String) : ECDSAProvider( + JWSAlgorithm.ES256K +), JWSSigner { + + override fun sign(header: JWSHeader, signingInput: ByteArray): Base64URL { + return Base64URL.encode(keyManager.sign(keyAlias, signingInput)) + } + +} \ No newline at end of file diff --git a/sdjwt/src/test/kotlin/web5/security/SdJwtUnblinderTest.kt b/sdjwt/src/test/kotlin/web5/security/SdJwtUnblinderTest.kt new file mode 100644 index 000000000..22f0cf5a8 --- /dev/null +++ b/sdjwt/src/test/kotlin/web5/security/SdJwtUnblinderTest.kt @@ -0,0 +1,63 @@ +package web5.security + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.erdtman.jcs.JsonCanonicalizer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SdJwtUnblinderTest { + private val mapper = jacksonObjectMapper().apply { + enable(SerializationFeature.INDENT_OUTPUT) + setSerializationInclusion(JsonInclude.Include.NON_NULL) + setDefaultPrettyPrinter(CustomPrettyPrinter()) + } + + @Test + fun unblind() { + val jwtClaimSet = SdJwtUnblinder().unblind(example1) + + val expected = """ + { + "iss": "https://example.com/issuer", + "iat": 1683000000, + "exp": 1883000000, + "sub": "user_42", + + "cnf": { + "jwk": { + "kty": "EC", + "crv": "P-256", + "x": "TCAER19Zvu3OHF4j4W4vfSVoHIP1ILilDls7vCeGemc", + "y": "ZxjiWWbZMQGHVWKVQ4hbSIirsVfuecCE6t4jT9F2HZQ" + } + }, + + "given_name": "John", + "family_name": "Doe", + "email": "johndoe@example.com", + "phone_number": "+1-202-555-0101", + "phone_number_verified": true, + "address": { + "street_address": "123 Main St", + "locality": "Anytown", + "region": "Anystate", + "country": "US" + }, + "birthdate": "1940-01-01", + "updated_at": 1570000000, + "nationalities": [ + "US", + "DE" + ] + } + """.trimIndent() + assertEquals( + JsonCanonicalizer(expected).encodedString, + JsonCanonicalizer(mapper.writeValueAsString(jwtClaimSet.toJSONObject())).encodedString + ) + } +} + + diff --git a/sdjwt/src/test/resources/example1.sdjwt b/sdjwt/src/test/resources/example1.sdjwt new file mode 100644 index 000000000..398f53da1 --- /dev/null +++ b/sdjwt/src/test/resources/example1.sdjwt @@ -0,0 +1 @@ +eyJhbGciOiAiRVMyNTYifQ.eyJfc2QiOiBbIkNyUWU3UzVrcUJBSHQtbk1ZWGdjNmJkdDJTSDVhVFkxc1VfTS1QZ2tqUEkiLCAiSnpZakg0c3ZsaUgwUjNQeUVNZmVadTZKdDY5dTVxZWhabzdGN0VQWWxTRSIsICJQb3JGYnBLdVZ1Nnh5bUphZ3ZrRnNGWEFiUm9jMkpHbEFVQTJCQTRvN2NJIiwgIlRHZjRvTGJnd2Q1SlFhSHlLVlFaVTlVZEdFMHc1cnREc3JaemZVYW9tTG8iLCAiWFFfM2tQS3QxWHlYN0tBTmtxVlI2eVoyVmE1TnJQSXZQWWJ5TXZSS0JNTSIsICJYekZyendzY002R242Q0pEYzZ2Vks4QmtNbmZHOHZPU0tmcFBJWmRBZmRFIiwgImdiT3NJNEVkcTJ4Mkt3LXc1d1BFemFrb2I5aFYxY1JEMEFUTjNvUUw5Sk0iLCAianN1OXlWdWx3UVFsaEZsTV8zSmx6TWFTRnpnbGhRRzBEcGZheVF3TFVLNCJdLCAiaXNzIjogImh0dHBzOi8vZXhhbXBsZS5jb20vaXNzdWVyIiwgImlhdCI6IDE2ODMwMDAwMDAsICJleHAiOiAxODgzMDAwMDAwLCAic3ViIjogInVzZXJfNDIiLCAibmF0aW9uYWxpdGllcyI6IFt7Ii4uLiI6ICJwRm5kamtaX1ZDem15VGE2VWpsWm8zZGgta284YUlLUWM5RGxHemhhVllvIn0sIHsiLi4uIjogIjdDZjZKa1B1ZHJ5M2xjYndIZ2VaOGtoQXYxVTFPU2xlclAwVmtCSnJXWjAifV0sICJfc2RfYWxnIjogInNoYS0yNTYiLCAiY25mIjogeyJqd2siOiB7Imt0eSI6ICJFQyIsICJjcnYiOiAiUC0yNTYiLCAieCI6ICJUQ0FFUjE5WnZ1M09IRjRqNFc0dmZTVm9ISVAxSUxpbERsczd2Q2VHZW1jIiwgInkiOiAiWnhqaVdXYlpNUUdIVldLVlE0aGJTSWlyc1ZmdWVjQ0U2dDRqVDlGMkhaUSJ9fX0.kmx687kUBiIDvKWgo2Dub-TpdCCRLZwtD7TOj4RoLsUbtFBI8sMrtH2BejXtm_P6fOAjKAVc_7LRNJFgm3PJhg~WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9obiJd~WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwgImZhbWlseV9uYW1lIiwgIkRvZSJd~WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwgImVtYWlsIiwgImpvaG5kb2VAZXhhbXBsZS5jb20iXQ~WyJlSThaV205UW5LUHBOUGVOZW5IZGhRIiwgInBob25lX251bWJlciIsICIrMS0yMDItNTU1LTAxMDEiXQ~WyJRZ19PNjR6cUF4ZTQxMmExMDhpcm9BIiwgInBob25lX251bWJlcl92ZXJpZmllZCIsIHRydWVd~WyJBSngtMDk1VlBycFR0TjRRTU9xUk9BIiwgImFkZHJlc3MiLCB7InN0cmVldF9hZGRyZXNzIjogIjEyMyBNYWluIFN0IiwgImxvY2FsaXR5IjogIkFueXRvd24iLCAicmVnaW9uIjogIkFueXN0YXRlIiwgImNvdW50cnkiOiAiVVMifV0~WyJQYzMzSk0yTGNoY1VfbEhnZ3ZfdWZRIiwgImJpcnRoZGF0ZSIsICIxOTQwLTAxLTAxIl0~WyJHMDJOU3JRZmpGWFE3SW8wOXN5YWpBIiwgInVwZGF0ZWRfYXQiLCAxNTcwMDAwMDAwXQ~WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgIlVTIl0~WyJuUHVvUW5rUkZxM0JJZUFtN0FuWEZBIiwgIkRFIl0~ \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index dbfd46180..0156e8ba1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,7 @@ rootProject.name = "web5" -include("common", "crypto", "dids", "credentials") +include("common") +include("credentials") +include("crypto") +include("dids") include("testing") +include("sdjwt")