diff --git a/.run/Issuer.run.xml b/.run/Issuer.run.xml index 19dae54e8..d8678b283 100644 --- a/.run/Issuer.run.xml +++ b/.run/Issuer.run.xml @@ -1,11 +1,11 @@ - - - \ No newline at end of file + + + diff --git a/.run/Verifier.run.xml b/.run/Verifier.run.xml index 9e2776840..9a4ba8709 100644 --- a/.run/Verifier.run.xml +++ b/.run/Verifier.run.xml @@ -1,11 +1,11 @@ - - - \ No newline at end of file + + + diff --git a/README.md b/README.md index 06d51e38a..9c233833d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ ## Supported ### Platforms available: + - Java / JVM - JS / Node.js or WebCrypto - Native / libsodium & OpenSSL (todo) @@ -36,7 +37,9 @@ | RSA | RS256 | ### Compatibility matrix: + #### JWS (recommended) + | Algorithm | JVM provider | JS provider / platform | |:---------:|:------------:|:---------------------------:| | EdDSA | Nimbus JOSE | jose / Node.js | @@ -45,6 +48,7 @@ | RS256 | Nimbus JOSE | jose / Node.js & Web Crypto | #### LD Signatures (happy to add upon request - office@walt.id) + | Suite | JVM provider | JS provider | |:---------------------------:|:------------------:|:-----------------:| | Ed25519Signature2018 | ld-signatures-java | | @@ -53,13 +57,13 @@ | RsaSignature2018 | ld-signatures-java | | | JsonWebSignature2020 | ld-signatures-java | | - - ## Docker container builds: + ```shell docker build -t waltid/issuer -f docker/issuer.Dockerfile . docker run -p 7000:7000 waltid/issuer --webHost=0.0.0.0 --webPort=7000 --baseUrl=http://localhost:7000 ``` + ```shell docker build -t waltid/verifier -f docker/verifier.Dockerfile . docker run -p 7001:7001 waltid/verifier --webHost=0.0.0.0 --webPort=7001 --baseUrl=http://localhost:7001 @@ -68,9 +72,12 @@ docker run -p 7001:7001 waltid/verifier --webHost=0.0.0.0 --webPort=7001 --baseU ### Setup Vault #### Download + wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg -echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list +echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo +tee /etc/apt/sources.list.d/hashicorp.list sudo apt update && sudo apt install vault #### Run Vault in Dev mode + vault server -dev -dev-root-token-id="dev-only-token" diff --git a/build.gradle.kts b/build.gradle.kts index bc8a073c3..70c26d0a4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,7 +11,7 @@ plugins { val kotlinVersion = "1.9.20" kotlin("multiplatform") version kotlinVersion apply false kotlin("plugin.serialization") version kotlinVersion apply false - id("com.github.ben-manes.versions") version "0.48.0" apply false + id("com.github.ben-manes.versions") version "0.49.0" apply false kotlin("jvm") version "1.9.20" } dependencies { diff --git a/settings.gradle.kts b/settings.gradle.kts index 98470f974..e352ce3b5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,6 +15,7 @@ include( "waltid-crypto", "waltid-did", "waltid-verifiable-credentials", + "waltid-sdjwt", // Protocols "waltid-openid4vc", diff --git a/waltid-crypto/build.gradle.kts b/waltid-crypto/build.gradle.kts index d1bad199d..a6374a726 100644 --- a/waltid-crypto/build.gradle.kts +++ b/waltid-crypto/build.gradle.kts @@ -86,7 +86,7 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") } } - publishing { + publishing { repositories { maven { url = uri("https://maven.walt.id/repository/waltid/") diff --git a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/keys/Key.kt b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/keys/Key.kt index 81a57d5c7..e846b4c12 100644 --- a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/keys/Key.kt +++ b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/keys/Key.kt @@ -39,6 +39,7 @@ abstract class Key { * @return raw signature */ abstract suspend fun signRaw(plaintext: ByteArray): Any + /** * signs a message using this private key (with the algorithm this key is based on) * @exception IllegalArgumentException when this is not a private key diff --git a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/keys/KeySerialization.kt b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/keys/KeySerialization.kt index 463a75bb6..f497d10ac 100644 --- a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/keys/KeySerialization.kt +++ b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/keys/KeySerialization.kt @@ -17,8 +17,9 @@ object KeySerialization { } } - private val keySerializationJson = Json { serializersModule = - keySerializationModule + private val keySerializationJson = Json { + serializersModule = + keySerializationModule } fun serializeKey(key: Key): String = keySerializationJson.encodeToString(key) diff --git a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/keys/LocalKey.kt b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/keys/LocalKey.kt index 5e1e98f49..8fe1264a1 100644 --- a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/keys/LocalKey.kt +++ b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/keys/LocalKey.kt @@ -52,7 +52,6 @@ expect class LocalKey(jwk: String?) : Key { override val hasPrivateKey: Boolean - companion object : LocalKeyCreator { override suspend fun generate(type: KeyType, metadata: LocalKeyMetadata): LocalKey diff --git a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/Base64Utils.kt b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/Base64Utils.kt index af3466b6a..6874f8001 100644 --- a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/Base64Utils.kt +++ b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/Base64Utils.kt @@ -6,7 +6,7 @@ import kotlin.io.encoding.ExperimentalEncodingApi @OptIn(ExperimentalEncodingApi::class) object Base64Utils { - fun String.base64toBase64Url() = this.replace("+", "-").replace("/", "_").dropLastWhile { it == '=' } + fun String.base64toBase64Url() = this.replace("+", "-").replace("/", "_").dropLastWhile { it == '=' } fun String.base64UrlToBase64() = this.replace("-", "+").replace("_", "/") fun ByteArray.encodeToBase64Url() = Base64.UrlSafe.encode(this).dropLastWhile { it == '=' } diff --git a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/JsonUtils.kt b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/JsonUtils.kt index fde0d9e60..3c2992e1a 100644 --- a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/JsonUtils.kt +++ b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/JsonUtils.kt @@ -37,6 +37,8 @@ object JsonUtils { return JsonObject(map) } + fun Map<*, *>.toJsonObject() = this.toJsonElement().jsonObject + private fun toHexChar(i: Int): Char { val d = i and 0xf return if (d < 10) (d + '0'.code).toChar() diff --git a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/JwsUtils.kt b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/JwsUtils.kt index e0a677ac2..257a6d3db 100644 --- a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/JwsUtils.kt +++ b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/JwsUtils.kt @@ -21,17 +21,22 @@ object JwsUtils { fun String.decodeJwsPart(): JsonObject = Json.parseToJsonElement(Base64.decode(this.base64UrlToBase64()).decodeToString()).jsonObject - data class JwsParts(val header: JsonObject, val payload: JsonObject) + data class JwsParts(val header: JsonObject, val payload: JsonObject, val signature: String) - fun String.decodeJws(): JwsParts { + fun String.decodeJws(withSignature: Boolean = false): JwsParts { check(startsWith("ey")) { "String does not look like JWS: $this" } check(count { it == '.' } == 2) { "String does not have JWS part amount of 3 (= 2 dots): $this" } val splitted = split(".") - val header = runCatching { splitted[0].decodeJwsPart() }.getOrElse { throw IllegalArgumentException("Could not parse JWT header (base64/json issue): ${splitted[0]}", it) } - val payload = runCatching { splitted[1].decodeJwsPart() }.getOrElse { throw IllegalArgumentException("Could not parse JWT payload (base64/json issue): ${splitted[1]}", it) } - - return JwsParts(header, payload) + val header = runCatching { splitted[0].decodeJwsPart() }.getOrElse { ex -> + throw IllegalArgumentException("Could not parse JWT header (base64/json issue): ${splitted[0]}", ex) + } + val payload = runCatching { splitted[1].decodeJwsPart() }.getOrElse { ex -> + throw IllegalArgumentException("Could not parse JWT payload (base64/json issue): ${splitted[1]}", ex) + } + val signature = if (withSignature) splitted[2] else "" + + return JwsParts(header, payload, signature) } } diff --git a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/MultiCodecUtils.kt b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/MultiCodecUtils.kt index 96c5ff210..15a1a0bb8 100644 --- a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/MultiCodecUtils.kt +++ b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/MultiCodecUtils.kt @@ -20,7 +20,7 @@ object MultiCodecUtils { KeyType.RSA -> 0x1205u } - fun getKeyTypeFromKeyCode(code: UInt): KeyType = when (code){ + fun getKeyTypeFromKeyCode(code: UInt): KeyType = when (code) { 0xEDu -> KeyType.Ed25519 0xE7u -> KeyType.secp256k1 0x1205u -> KeyType.RSA @@ -74,4 +74,4 @@ object MultiCodecUtils { } } } -} \ No newline at end of file +} diff --git a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/TseUtilsTesting.kt b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/TseUtilsTesting.kt index 7f8b0321a..f478a6f61 100644 --- a/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/TseUtilsTesting.kt +++ b/waltid-crypto/src/commonMain/kotlin/id/walt/crypto/utils/TseUtilsTesting.kt @@ -29,7 +29,8 @@ fun main() { val privateKeyBase64Url = Base64.UrlSafe.encode(privateKey).dropLastWhile { it == '=' } val publicKeyBase64Url = Base64.UrlSafe.encode(publicKey).dropLastWhile { it == '=' } - val jwk = """{"kty":"OKP","d":"$privateKeyBase64Url","use":"sig","crv":"Ed25519","kid":"k1","x":"$publicKeyBase64Url","alg":"EdDSA"}""".trimIndent() + val jwk = + """{"kty":"OKP","d":"$privateKeyBase64Url","use":"sig","crv":"Ed25519","kid":"k1","x":"$publicKeyBase64Url","alg":"EdDSA"}""".trimIndent() println(jwk) } diff --git a/waltid-crypto/src/jvmMain/kotlin/id/walt/crypto/keys/LocalKey.jvm.kt b/waltid-crypto/src/jvmMain/kotlin/id/walt/crypto/keys/LocalKey.jvm.kt index e6a563f8a..15a4379b5 100644 --- a/waltid-crypto/src/jvmMain/kotlin/id/walt/crypto/keys/LocalKey.jvm.kt +++ b/waltid-crypto/src/jvmMain/kotlin/id/walt/crypto/keys/LocalKey.jvm.kt @@ -216,7 +216,9 @@ actual class LocalKey actual constructor( } actual companion object : LocalKeyCreator { - actual override suspend fun generate(type: KeyType, metadata: LocalKeyMetadata): LocalKey = JvmLocalKeyCreator.generate(type, metadata) + actual override suspend fun generate(type: KeyType, metadata: LocalKeyMetadata): LocalKey = + JvmLocalKeyCreator.generate(type, metadata) + actual override suspend fun importJWK(jwk: String): Result = JvmLocalKeyCreator.importJWK(jwk) actual override suspend fun importPEM(pem: String): Result = JvmLocalKeyCreator.importPEM(pem) actual override suspend fun importRawPublicKey( diff --git a/waltid-did/readme.md b/waltid-did/readme.md index a4e81d8e0..3bee0d80e 100644 --- a/waltid-did/readme.md +++ b/waltid-did/readme.md @@ -1,7 +1,7 @@

walt.id did-lib - Kotlin multiplatform library

by walt.id - + [![CI/CD Workflow for walt.id did]()]() Join community! @@ -17,14 +17,16 @@ _**walt.id did**_ library provides functionality for registering and resolving DIDs. There are 2 options offered for each function: + - universal - relies on the universal DID registrar / resolver, e.g.: - - uni-registrar - https://uniregistrar.io - - uni-resolver - https://dev.uniresolver.io + - uni-registrar - https://uniregistrar.io + - uni-resolver - https://dev.uniresolver.io - local - provides local implementations of DID methods For the cryptographic part, _**walt.id did**_ library relies on _**walt.id crypto**_ library. ## Class diagram + ![walt.id did class diagram](did-lib_class.drawio.png) The top-level interface to access the registrar / resolver functions is provided @@ -33,7 +35,9 @@ by the `DidService` singleton. ## Usage examples ### Register DID + Create the key and register the Did: + ```kotlin val options = DidWebCreateOptions( domain = "localhost:3000", @@ -44,6 +48,7 @@ val didResult = DidService.register(options = options) ``` Register the Did with the given key: + ```kotlin val key = LocalKey.generate(KeyType.Ed25519) val options = DidKeyCreateOptions( @@ -57,14 +62,16 @@ val didResult = DidService.register( ``` Both calls return a `DidResult` object: + ```kotlin data class DidResult( val did: String, val didDocument: DidDocument ) ``` + where `did` - is the Did url string, while `didDocument` is the corresponding -DidDocument represented as a key-value pair, having the key as a `String` and +DidDocument represented as a key-value pair, having the key as a `String` and value as a `JsonElement`. Currently available local did methods are: @@ -96,5 +103,5 @@ Both calls return the result using the _operation result pattern_, the data being wrapped by the `Result` object. This allows checking for a successful operation and handling the result accordingly. -The Did Document data is represented as `JsonObject`. The key data is -represented as **_walt.id crypto_** `Key`. \ No newline at end of file +The Did Document data is represented as `JsonObject`. The key data is +represented as **_walt.id crypto_** `Key`. diff --git a/waltid-did/src/commonMain/kotlin/id/walt/did/dids/DidService.kt b/waltid-did/src/commonMain/kotlin/id/walt/did/dids/DidService.kt index 1b0f65096..3ab2836b2 100644 --- a/waltid-did/src/commonMain/kotlin/id/walt/did/dids/DidService.kt +++ b/waltid-did/src/commonMain/kotlin/id/walt/did/dids/DidService.kt @@ -76,7 +76,11 @@ object DidService { registerRegistrarForMethod(method, registrar) } - else -> log.warn { "DID Registrar ${registrar.name} cannot be used, error: ${methods.exceptionOrNull().let { it?.message ?: it.toString() }}" } + else -> log.warn { + "DID Registrar ${registrar.name} cannot be used, error: ${ + methods.exceptionOrNull().let { it?.message ?: it.toString() } + }" + } } } } diff --git a/waltid-did/src/commonMain/kotlin/id/walt/did/dids/registrar/LocalRegistrar.kt b/waltid-did/src/commonMain/kotlin/id/walt/did/dids/registrar/LocalRegistrar.kt index 9ad80c8f2..fcfa1b6cf 100644 --- a/waltid-did/src/commonMain/kotlin/id/walt/did/dids/registrar/LocalRegistrar.kt +++ b/waltid-did/src/commonMain/kotlin/id/walt/did/dids/registrar/LocalRegistrar.kt @@ -25,6 +25,7 @@ class LocalRegistrar : DidRegistrar { override suspend fun create(options: DidCreateOptions): DidResult = getRegistrarForMethod(options.method).register(options) + override suspend fun createByKey(key: Key, options: DidCreateOptions): DidResult = getRegistrarForMethod(options.method).registerByKey(key, options) diff --git a/waltid-did/src/commonMain/kotlin/id/walt/did/dids/registrar/dids/DidCreateOptions.kt b/waltid-did/src/commonMain/kotlin/id/walt/did/dids/registrar/dids/DidCreateOptions.kt index 2ea1f951e..1e3882118 100644 --- a/waltid-did/src/commonMain/kotlin/id/walt/did/dids/registrar/dids/DidCreateOptions.kt +++ b/waltid-did/src/commonMain/kotlin/id/walt/did/dids/registrar/dids/DidCreateOptions.kt @@ -11,17 +11,18 @@ open class DidCreateOptions(val method: String, val options: JsonElement) { constructor(method: String, options: Map) : this(method, options.toJsonElement()) - inline operator fun get(name: String): T? = options.jsonObject["options"]?.jsonObject?.get(name)?.jsonPrimitive?.content?.let { - when (T::class) { - Boolean::class -> it.toBoolean() - Int::class -> it.toIntOrNull() - Long::class -> it.toLongOrNull() - Double::class -> it.toDoubleOrNull() - KeyType::class -> enumValueIgnoreCase(it) - String::class -> it - else -> null - } as? T - } + inline operator fun get(name: String): T? = + options.jsonObject["options"]?.jsonObject?.get(name)?.jsonPrimitive?.content?.let { + when (T::class) { + Boolean::class -> it.toBoolean() + Int::class -> it.toIntOrNull() + Long::class -> it.toLongOrNull() + Double::class -> it.toDoubleOrNull() + KeyType::class -> enumValueIgnoreCase(it) + String::class -> it + else -> null + } as? T + } } internal fun options(options: Map, secret: Map = emptyMap()) = mapOf( @@ -33,4 +34,5 @@ internal fun options(options: Map, secret: Map = empty ), "secret" to secret ) + internal fun options(vararg inlineOptions: Pair) = options(mapOf(*inlineOptions)) diff --git a/waltid-did/src/commonMain/kotlin/id/walt/did/dids/registrar/local/cheqd/DidCheqdRegistrar.kt b/waltid-did/src/commonMain/kotlin/id/walt/did/dids/registrar/local/cheqd/DidCheqdRegistrar.kt index 9c1817d57..318ec188d 100644 --- a/waltid-did/src/commonMain/kotlin/id/walt/did/dids/registrar/local/cheqd/DidCheqdRegistrar.kt +++ b/waltid-did/src/commonMain/kotlin/id/walt/did/dids/registrar/local/cheqd/DidCheqdRegistrar.kt @@ -56,6 +56,7 @@ class DidCheqdRegistrar() : LocalRegistrarMethod("cheqd") { isLenient = true explicitNulls = false } + //TODO: inject private val client = HttpClient(CIO) { install(ContentNegotiation) { diff --git a/waltid-did/src/jvmTest/kotlin/DidDocumentChecks.kt b/waltid-did/src/jvmTest/kotlin/DidDocumentChecks.kt index c8edaab48..ce7523cde 100644 --- a/waltid-did/src/jvmTest/kotlin/DidDocumentChecks.kt +++ b/waltid-did/src/jvmTest/kotlin/DidDocumentChecks.kt @@ -53,7 +53,7 @@ object DidDocumentChecks { * Checks [actual] and [expected] **y** is identical * @return True if all checks pass, False otherwise */ - fun secp256KeyChecks(actual: JsonObject, expected: JsonObject)= let{ + fun secp256KeyChecks(actual: JsonObject, expected: JsonObject) = let { actual["y"]!!.jsonPrimitive.content == expected["y"]!!.jsonPrimitive.content } @@ -75,4 +75,4 @@ object DidDocumentChecks { && actual["e"]!!.jsonPrimitive.content.equals(expected["e"]!!.jsonPrimitive.content, true) } //endregion -DidDocument vs. key- -} \ No newline at end of file +} diff --git a/waltid-did/src/jvmTest/kotlin/registrars/DidRegistrarTestBase.kt b/waltid-did/src/jvmTest/kotlin/registrars/DidRegistrarTestBase.kt index 124647b32..5538939de 100644 --- a/waltid-did/src/jvmTest/kotlin/registrars/DidRegistrarTestBase.kt +++ b/waltid-did/src/jvmTest/kotlin/registrars/DidRegistrarTestBase.kt @@ -23,7 +23,7 @@ abstract class DidRegistrarTestBase(private val registrar: LocalRegistrarMethod) open fun `given did options with no key when register then returns a valid did result`( options: DidCreateOptions, assert: registrarDidAssertion - ){ + ) { val docResult = runBlocking { registrar.register(options) } assert(docResult, options) } @@ -32,7 +32,7 @@ abstract class DidRegistrarTestBase(private val registrar: LocalRegistrarMethod) key: Key, options: DidCreateOptions, assert: registrarKeyAssertion - ){ + ) { val docResult = runBlocking { registrar.registerByKey(key, options) } val publicKey = runBlocking { key.getPublicKey() } assert(docResult, options, publicKey) diff --git a/waltid-did/src/jvmTest/kotlin/registrars/DidWebRegistrarTest.kt b/waltid-did/src/jvmTest/kotlin/registrars/DidWebRegistrarTest.kt index ab7ef1ea6..fcec88fc4 100644 --- a/waltid-did/src/jvmTest/kotlin/registrars/DidWebRegistrarTest.kt +++ b/waltid-did/src/jvmTest/kotlin/registrars/DidWebRegistrarTest.kt @@ -15,7 +15,7 @@ import org.junit.jupiter.params.provider.Arguments.arguments import org.junit.jupiter.params.provider.MethodSource import java.util.stream.Stream -class DidWebRegistrarTest : DidRegistrarTestBase(DidWebRegistrar()){ +class DidWebRegistrarTest : DidRegistrarTestBase(DidWebRegistrar()) { @ParameterizedTest @MethodSource override fun `given did options with no key when register then returns a valid did result`( diff --git a/waltid-did/src/jvmTest/kotlin/resolvers/DidResolverTestBase.kt b/waltid-did/src/jvmTest/kotlin/resolvers/DidResolverTestBase.kt index c04f938d2..a77104c7d 100644 --- a/waltid-did/src/jvmTest/kotlin/resolvers/DidResolverTestBase.kt +++ b/waltid-did/src/jvmTest/kotlin/resolvers/DidResolverTestBase.kt @@ -104,6 +104,7 @@ abstract class DidResolverTestBase { val publicKey = runBlocking { result.getOrNull()!!.getPublicKey().exportJWKObject() } assert(ed25519KeyChecks(publicKey, key)) } + /** * Runs tests against **secp256-k1/-r1** specific fields. * Inherits [ed25519KeyAssertions] @@ -113,6 +114,7 @@ abstract class DidResolverTestBase { val publicKey = runBlocking { result.getOrNull()!!.getPublicKey().exportJWKObject() } assert(secp256KeyChecks(publicKey, key)) } + /** * Runs tests against **rsa** specific fields. * Inherits [keyAssertions] diff --git a/waltid-did/src/jvmTest/resources/did-doc/ed25519.json b/waltid-did/src/jvmTest/resources/did-doc/ed25519.json index 558895cbd..ab4dccb30 100644 --- a/waltid-did/src/jvmTest/resources/did-doc/ed25519.json +++ b/waltid-did/src/jvmTest/resources/did-doc/ed25519.json @@ -1,25 +1,25 @@ { - "assertionMethod" : [ + "assertionMethod": [ "did:web:localhost%3A8080:ed25519#151df6ec01714883b812f26f2d63e584" ], - "authentication" : [ + "authentication": [ "did:web:localhost%3A8080:ed25519#151df6ec01714883b812f26f2d63e584" ], - "@context" : "https://www.w3.org/ns/did/v1", - "id" : "did:web:localhost%3A8080:ed25519", - "verificationMethod" : [ + "@context": "https://www.w3.org/ns/did/v1", + "id": "did:web:localhost%3A8080:ed25519", + "verificationMethod": [ { - "controller" : "did:web:localhost%3A8080:ed25519", - "id" : "did:web:localhost%3A8080:ed25519#151df6ec01714883b812f26f2d63e584", - "publicKeyJwk" : { - "alg" : "EdDSA", - "crv" : "Ed25519", - "kid" : "151df6ec01714883b812f26f2d63e584", - "kty" : "OKP", - "use" : "sig", - "x" : "qBDsYw3k62mUT8UmEx99Xz3yckiSRmTsL6aa21ZcAVM" + "controller": "did:web:localhost%3A8080:ed25519", + "id": "did:web:localhost%3A8080:ed25519#151df6ec01714883b812f26f2d63e584", + "publicKeyJwk": { + "alg": "EdDSA", + "crv": "Ed25519", + "kid": "151df6ec01714883b812f26f2d63e584", + "kty": "OKP", + "use": "sig", + "x": "qBDsYw3k62mUT8UmEx99Xz3yckiSRmTsL6aa21ZcAVM" }, - "type" : "Ed25519VerificationKey2019" + "type": "Ed25519VerificationKey2019" } ] -} \ No newline at end of file +} diff --git a/waltid-did/src/jvmTest/resources/did-doc/rsa.json b/waltid-did/src/jvmTest/resources/did-doc/rsa.json index 7443f3069..ef6cd3b8b 100644 --- a/waltid-did/src/jvmTest/resources/did-doc/rsa.json +++ b/waltid-did/src/jvmTest/resources/did-doc/rsa.json @@ -1,25 +1,25 @@ { - "assertionMethod" : [ + "assertionMethod": [ "did:web:localhost%3A8080:rsa#ab269ce10ce94b7c9565e30c034b5692" ], - "authentication" : [ + "authentication": [ "did:web:localhost%3A8080:rsa#ab269ce10ce94b7c9565e30c034b5692" ], - "@context" : "https://www.w3.org/ns/did/v1", - "id" : "did:web:localhost%3A8080:rsa", - "verificationMethod" : [ + "@context": "https://www.w3.org/ns/did/v1", + "id": "did:web:localhost%3A8080:rsa", + "verificationMethod": [ { - "controller" : "did:web:localhost%3A8080:rsa", - "id" : "did:web:localhost%3A8080:rsa#ab269ce10ce94b7c9565e30c034b5692", - "publicKeyJwk" : { - "alg" : "RS256", - "e" : "AQAB", - "kid" : "ab269ce10ce94b7c9565e30c034b5692", - "kty" : "RSA", - "n" : "0qbslQ5uMXL1Wk4dUD5ftrGWLhgaQENQn8AaPVREg12H_Mfr2GEL0IkBd7EQPeRFzRzngF2kWpij_nyueYKGQ3um_hione72pozP76etXNk4imTzmg3RsHcfPC5JBJAGpb5htnUQ5-VsuqbzlCUTOWNK4kIDWzbU0o-neglLAwU846_h6lTRI7xE1kh0iZyseAdx7sZ8Cd5eSYuvwQVxnNn0w-m9Bwd30g-s8xmqn9-7LBa0-UdumMLwtan4IGXltMJGYU9br1wsmz9vlG-TvfmxlgXzilJOJQMvlMKGXRmbUJRaNSYdrVJciEQEWK0tkaT45r3_LJw7dwx4DnNxzw", - "use" : "sig" + "controller": "did:web:localhost%3A8080:rsa", + "id": "did:web:localhost%3A8080:rsa#ab269ce10ce94b7c9565e30c034b5692", + "publicKeyJwk": { + "alg": "RS256", + "e": "AQAB", + "kid": "ab269ce10ce94b7c9565e30c034b5692", + "kty": "RSA", + "n": "0qbslQ5uMXL1Wk4dUD5ftrGWLhgaQENQn8AaPVREg12H_Mfr2GEL0IkBd7EQPeRFzRzngF2kWpij_nyueYKGQ3um_hione72pozP76etXNk4imTzmg3RsHcfPC5JBJAGpb5htnUQ5-VsuqbzlCUTOWNK4kIDWzbU0o-neglLAwU846_h6lTRI7xE1kh0iZyseAdx7sZ8Cd5eSYuvwQVxnNn0w-m9Bwd30g-s8xmqn9-7LBa0-UdumMLwtan4IGXltMJGYU9br1wsmz9vlG-TvfmxlgXzilJOJQMvlMKGXRmbUJRaNSYdrVJciEQEWK0tkaT45r3_LJw7dwx4DnNxzw", + "use": "sig" }, - "type" : "RsaVerificationKey2018" + "type": "RsaVerificationKey2018" } ] -} \ No newline at end of file +} diff --git a/waltid-did/src/jvmTest/resources/did-doc/secp256k1.json b/waltid-did/src/jvmTest/resources/did-doc/secp256k1.json index e03499b09..9bf33553a 100644 --- a/waltid-did/src/jvmTest/resources/did-doc/secp256k1.json +++ b/waltid-did/src/jvmTest/resources/did-doc/secp256k1.json @@ -1,26 +1,26 @@ { - "assertionMethod" : [ + "assertionMethod": [ "did:web:localhost%3A8080:secp256k1#d5c5048d0e7440a4830ea0b407174f83" ], - "authentication" : [ + "authentication": [ "did:web:localhost%3A8080:secp256k1#d5c5048d0e7440a4830ea0b407174f83" ], - "@context" : "https://www.w3.org/ns/did/v1", - "id" : "did:web:localhost%3A8080:secp256k1", - "verificationMethod" : [ + "@context": "https://www.w3.org/ns/did/v1", + "id": "did:web:localhost%3A8080:secp256k1", + "verificationMethod": [ { - "controller" : "did:web:localhost%3A8080:secp256k1", - "id" : "did:web:localhost%3A8080:secp256k1#d5c5048d0e7440a4830ea0b407174f83", - "publicKeyJwk" : { - "alg" : "ES256K", - "crv" : "secp256k1", - "kid" : "d5c5048d0e7440a4830ea0b407174f83", - "kty" : "EC", - "use" : "sig", - "x" : "eKx_FaLzPMT4ndwvImdV_pTv-JX1SQpJ8tDK6GLiYIE", - "y" : "-TzpGxGLPnXWMJWTqYvqn55Z8Xi-J_ZM40bjtjGaELs" + "controller": "did:web:localhost%3A8080:secp256k1", + "id": "did:web:localhost%3A8080:secp256k1#d5c5048d0e7440a4830ea0b407174f83", + "publicKeyJwk": { + "alg": "ES256K", + "crv": "secp256k1", + "kid": "d5c5048d0e7440a4830ea0b407174f83", + "kty": "EC", + "use": "sig", + "x": "eKx_FaLzPMT4ndwvImdV_pTv-JX1SQpJ8tDK6GLiYIE", + "y": "-TzpGxGLPnXWMJWTqYvqn55Z8Xi-J_ZM40bjtjGaELs" }, - "type" : "EcdsaSecp256k1VerificationKey2019" + "type": "EcdsaSecp256k1VerificationKey2019" } ] -} \ No newline at end of file +} diff --git a/waltid-did/src/jvmTest/resources/did-doc/secp256r1.json b/waltid-did/src/jvmTest/resources/did-doc/secp256r1.json index 4220e5fe7..fd4071893 100644 --- a/waltid-did/src/jvmTest/resources/did-doc/secp256r1.json +++ b/waltid-did/src/jvmTest/resources/did-doc/secp256r1.json @@ -1,26 +1,26 @@ { - "assertionMethod" : [ + "assertionMethod": [ "did:web:localhost%3A8080:secp256r1#292dfabd1f9d477eb5cef239909111a1" ], - "authentication" : [ + "authentication": [ "did:web:localhost%3A8080:secp256r1#292dfabd1f9d477eb5cef239909111a1" ], - "@context" : "https://www.w3.org/ns/did/v1", - "id" : "did:web:localhost%3A8080:secp256r1", - "verificationMethod" : [ + "@context": "https://www.w3.org/ns/did/v1", + "id": "did:web:localhost%3A8080:secp256r1", + "verificationMethod": [ { - "controller" : "did:web:localhost%3A8080:secp256r1", - "id" : "did:web:localhost%3A8080:secp256r1#292dfabd1f9d477eb5cef239909111a1", - "publicKeyJwk" : { - "alg" : "ES256", - "crv" : "P-256", - "kid" : "292dfabd1f9d477eb5cef239909111a1", - "kty" : "EC", - "use" : "sig", - "x" : "NN9jDT3CL9yxgYEjaEkP80CB8q1WBbUHIlVuPIBQhi8", - "y" : "op40OaekSUJynfo1hClZWu8SAYMTf1OcaCUr0YErNSc" + "controller": "did:web:localhost%3A8080:secp256r1", + "id": "did:web:localhost%3A8080:secp256r1#292dfabd1f9d477eb5cef239909111a1", + "publicKeyJwk": { + "alg": "ES256", + "crv": "P-256", + "kid": "292dfabd1f9d477eb5cef239909111a1", + "kty": "EC", + "use": "sig", + "x": "NN9jDT3CL9yxgYEjaEkP80CB8q1WBbUHIlVuPIBQhi8", + "y": "op40OaekSUJynfo1hClZWu8SAYMTf1OcaCUr0YErNSc" }, - "type" : "EcdsaSecp256r1VerificationKey2019" + "type": "EcdsaSecp256r1VerificationKey2019" } ] -} \ No newline at end of file +} diff --git a/waltid-issuer/build.gradle.kts b/waltid-issuer/build.gradle.kts index 37c5842f2..74ed8b964 100644 --- a/waltid-issuer/build.gradle.kts +++ b/waltid-issuer/build.gradle.kts @@ -6,7 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile object Versions { const val KOTLIN_VERSION = "1.9.10" // also change 2 plugins - const val KTOR_VERSION = "2.3.5" // also change 1 plugin + const val KTOR_VERSION = "2.3.6" // also change 1 plugin const val COROUTINES_VERSION = "1.7.3" const val EXPOSED_VERSION = "0.43.0" const val HOPLITE_VERSION = "2.8.0.RC2" @@ -119,6 +119,11 @@ dependencies { api(project(":waltid-did")) api(project(":waltid-verifiable-credentials")) + api(project(":waltid-sdjwt")) + + // TODO: REMOVE: + implementation("com.nimbusds:nimbus-jose-jwt:9.37") + //api(project(":waltid-mdocs")) //implementation("id.walt:waltid-ssikit2:1.0.8a-SNAPSHOT") // implementation id.walt:core-crypto -> provided by id.walt:waltid-ssikit2 diff --git a/waltid-issuer/src/main/kotlin/id/walt/issuer/CIProvider.kt b/waltid-issuer/src/main/kotlin/id/walt/issuer/CIProvider.kt index 841493f15..10358eab0 100644 --- a/waltid-issuer/src/main/kotlin/id/walt/issuer/CIProvider.kt +++ b/waltid-issuer/src/main/kotlin/id/walt/issuer/CIProvider.kt @@ -3,12 +3,15 @@ package id.walt.issuer -import id.walt.credentials.issuance.Issuer.mergingIssue +import id.walt.credentials.issuance.Issuer.mergingJwtIssue +import id.walt.credentials.issuance.Issuer.mergingSdJwtIssue import id.walt.credentials.vc.vcs.W3CVC import id.walt.crypto.keys.Key +import id.walt.crypto.keys.KeySerialization import id.walt.crypto.keys.KeyType import id.walt.crypto.keys.LocalKey import id.walt.did.dids.DidService +import id.walt.issuer.IssuanceExamples.openBadgeCredentialExample import id.walt.issuer.base.config.ConfigManager import id.walt.issuer.base.config.OIDCIssuerServiceConfig import id.walt.oid4vc.data.CredentialFormat @@ -27,6 +30,7 @@ import id.walt.oid4vc.responses.BatchCredentialResponse import id.walt.oid4vc.responses.CredentialErrorCode import id.walt.oid4vc.responses.CredentialResponse import id.walt.oid4vc.util.randomUUID +import id.walt.sdjwt.SDMap import kotlinx.coroutines.runBlocking import kotlinx.serialization.encodeToString import kotlinx.serialization.json.* @@ -190,7 +194,13 @@ open class CIProvider : OpenIDCredentialIssuer( } listOf( IssuanceSessionData( - exampleIssuerKey, exampleIssuerDid, IssuanceRequest(W3CVC(openBadgeCredentialExample)) + exampleIssuerKey, + exampleIssuerDid, + JwtIssuanceRequest( + Json.parseToJsonElement(KeySerialization.serializeKey(exampleIssuerKey)).jsonObject, + exampleIssuerDid, + W3CVC(openBadgeCredentialExample) + ) ) ) } else { @@ -203,15 +213,30 @@ open class CIProvider : OpenIDCredentialIssuer( val vc = data.request.vc data.run { - vc.mergingIssue( - key = issuerKey, - issuerDid = issuerDid, - subjectDid = holderKid, - mappings = request.mapping ?: JsonObject(emptyMap()), - additionalJwtHeader = emptyMap(), - additionalJwtOptions = emptyMap() - ) - } + when (data.request) { + is JwtIssuanceRequest -> vc.mergingJwtIssue( + issuerKey = issuerKey, + issuerDid = issuerDid, + subjectDid = holderKid, + mappings = request.mapping ?: JsonObject(emptyMap()), + additionalJwtHeader = emptyMap(), + additionalJwtOptions = emptyMap(), + ) + + is SdJwtIssuanceRequest -> vc.mergingSdJwtIssue( + issuerKey = issuerKey, + issuerDid = issuerDid, + subjectDid = holderKid, + mappings = request.mapping ?: JsonObject(emptyMap()), + additionalJwtHeader = emptyMap(), + additionalJwtOptions = emptyMap(), + disclosureMap = data.request.selectiveDisclosure ?: SDMap.Companion.generateSDMap( + JsonObject(emptyMap()), + JsonObject(emptyMap()) + ) + ) + } + }.also { println("Respond VC: $it") } })) } @@ -267,15 +292,31 @@ open class CIProvider : OpenIDCredentialIssuer( val vc = data.request.vc data.run { - vc.mergingIssue( - key = issuerKey, - issuerDid = issuerDid, - subjectDid = subjectDid, - mappings = request.mapping ?: JsonObject(emptyMap()), - additionalJwtHeader = emptyMap(), - additionalJwtOptions = emptyMap() - ) - } + when (data.request) { + is JwtIssuanceRequest -> vc.mergingJwtIssue( + issuerKey = issuerKey, + issuerDid = issuerDid, + subjectDid = subjectDid, + mappings = request.mapping ?: JsonObject(emptyMap()), + additionalJwtHeader = emptyMap(), + additionalJwtOptions = emptyMap(), + ) + + is SdJwtIssuanceRequest -> vc.mergingSdJwtIssue( + issuerKey = issuerKey, + issuerDid = issuerDid, + subjectDid = subjectDid, + mappings = request.mapping ?: JsonObject(emptyMap()), + additionalJwtHeader = emptyMap(), + additionalJwtOptions = emptyMap(), + disclosureMap = data.request.selectiveDisclosure ?: SDMap.Companion.generateSDMap( + JsonObject(emptyMap()), + JsonObject(emptyMap()) + ) + ) + } + + }.also { println("Respond VC: $it") } } ) ) @@ -287,7 +328,7 @@ open class CIProvider : OpenIDCredentialIssuer( data class IssuanceSessionData( - val issuerKey: Key, val issuerDid: String, val request: IssuanceRequest + val issuerKey: Key, val issuerDid: String, val request: BaseIssuanceRequest ) private val sessionCredentialPreMapping = HashMap>() // session id -> VC diff --git a/waltid-issuer/src/main/kotlin/id/walt/issuer/IssuanceExamples.kt b/waltid-issuer/src/main/kotlin/id/walt/issuer/IssuanceExamples.kt new file mode 100644 index 000000000..04cae0ccc --- /dev/null +++ b/waltid-issuer/src/main/kotlin/id/walt/issuer/IssuanceExamples.kt @@ -0,0 +1,330 @@ +package id.walt.issuer + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject + +object IssuanceExamples { + //language=json + val universityDegreeCredential = """ +{ + "issuanceKey": { + "type": "local", + "jwk": "{\"kty\":\"OKP\",\"d\":\"mDhpwaH6JYSrD2Bq7Cs-pzmsjlLj4EOhxyI-9DM1mFI\",\"crv\":\"Ed25519\",\"kid\":\"Vzx7l5fh56F3Pf9aR3DECU5BwfrY6ZJe05aiWYWzan8\",\"x\":\"T3T4-u1Xz3vAV2JwPNxWfs4pik_JLiArz_WTCvrCFUM\"}" + }, + "issuerDid": "did:key:z6MkjoRhq1jSNJdLiruSXrFFxagqrztZaXHqHGUTKJbcNywp", + "vc": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "id": "http://example.gov/credentials/3732", + "type": [ + "VerifiableCredential", + "UniversityDegreeCredential" + ], + "issuer": { + "id": "did:web:vc.transmute.world" + }, + "issuanceDate": "2020-03-10T04:24:12.164Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "degree": { + "type": "BachelorDegree", + "name": "Bachelor of Science and Arts" + } + } + }, + "mapping": { + "id": "\u003cuuid\u003e", + "issuer": { + "id": "\u003cissuerDid\u003e" + }, + "credentialSubject": { + "id": "\u003csubjectDid\u003e" + }, + "issuanceDate": "\u003ctimestamp\u003e", + "expirationDate": "\u003ctimestamp-in:365d\u003e" + } +} +""".trimIndent() + + //language=json + val openBadgeCredentialExampleJsonString = """ +{ + "issuanceKey": { + "type": "local", + "jwk": "{\"kty\":\"OKP\",\"d\":\"mDhpwaH6JYSrD2Bq7Cs-pzmsjlLj4EOhxyI-9DM1mFI\",\"crv\":\"Ed25519\",\"kid\":\"Vzx7l5fh56F3Pf9aR3DECU5BwfrY6ZJe05aiWYWzan8\",\"x\":\"T3T4-u1Xz3vAV2JwPNxWfs4pik_JLiArz_WTCvrCFUM\"}" + }, + "issuerDid": "did:key:z6MkjoRhq1jSNJdLiruSXrFFxagqrztZaXHqHGUTKJbcNywp", + "vc": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://purl.imsglobal.org/spec/ob/v3p0/context.json" + ], + "id": "urn:uuid:THIS WILL BE REPLACED WITH DYNAMIC DATA FUNCTION (see below)", + "type": [ + "VerifiableCredential", + "OpenBadgeCredential" + ], + "name": "JFF x vc-edu PlugFest 3 Interoperability", + "issuer": { + "type": [ + "Profile" + ], + "id": "did:key:THIS WILL BE REPLACED WITH DYNAMIC DATA FUNCTION FROM CONTEXT (see below)", + "name": "Jobs for the Future (JFF)", + "url": "https://www.jff.org/", + "image": "https://w3c-ccg.github.io/vc-ed/plugfest-1-2022/images/JFF_LogoLockup.png" + }, + "issuanceDate": "2023-07-20T07:05:44Z (THIS WILL BE REPLACED BY DYNAMIC DATA FUNCTION (see below))", + "expirationDate": "WILL BE MAPPED BY DYNAMIC DATA FUNCTION (see below)", + "credentialSubject": { + "id": "did:key:123 (THIS WILL BE REPLACED BY DYNAMIC DATA FUNCTION (see below))", + "type": [ + "AchievementSubject" + ], + "achievement": { + "id": "urn:uuid:ac254bd5-8fad-4bb1-9d29-efd938536926", + "type": [ + "Achievement" + ], + "name": "JFF x vc-edu PlugFest 3 Interoperability", + "description": "This wallet supports the use of W3C Verifiable Credentials and has demonstrated interoperability during the presentation request workflow during JFF x VC-EDU PlugFest 3.", + "criteria": { + "type": "Criteria", + "narrative": "Wallet solutions providers earned this badge by demonstrating interoperability during the presentation request workflow. This includes successfully receiving a presentation request, allowing the holder to select at least two types of verifiable credentials to create a verifiable presentation, returning the presentation to the requestor, and passing verification of the presentation and the included credentials." + }, + "image": { + "id": "https://w3c-ccg.github.io/vc-ed/plugfest-3-2023/images/JFF-VC-EDU-PLUGFEST3-badge-image.png", + "type": "Image" + } + } + } + }, + "mapping": { + "id": "\u003cuuid\u003e", + "issuer": { + "id": "\u003cissuerDid\u003e" + }, + "credentialSubject": { + "id": "\u003csubjectDid\u003e" + }, + "issuanceDate": "\u003ctimestamp\u003e", + "expirationDate": "\u003ctimestamp-in:365d\u003e" + } +} +""".trimIndent() + val openBadgeCredentialExample = Json.parseToJsonElement(openBadgeCredentialExampleJsonString).jsonObject.toMap() + + + val universityDegreeCredentialExample2 = mapOf( + "@context" to listOf( + "https://www.w3.org/2018/credentials/v1", "https://www.w3.org/2018/credentials/examples/v1" + ), + "id" to "http://example.gov/credentials/3732", + "type" to listOf( + "VerifiableCredential", "UniversityDegreeCredential" + ), + "issuer" to mapOf( + "id" to "did:web:vc.transmute.world" + ), + "issuanceDate" to "2020-03-10T04:24:12.164Z", + "credentialSubject" to mapOf( + "id" to "did:example:ebfeb1f712ebc6f1c276e12ec21", "degree" to mapOf( + "type" to "BachelorDegree", + "name" to "Bachelor of Science and Arts" + ) + ), + ) + + val universityDegreeCredentialSignedExample = universityDegreeCredentialExample2.plus( + mapOf( + "proof" to mapOf( + "type" to "JsonWebSignature2020", + "created" to "2020-03-21T17:51:48Z", + "verificationMethod" to "did:web:vc.transmute.world#_Qq0UL2Fq651Q0Fjd6TvnYE-faHiOpRlPVQcY_-tA4A", + "proofPurpose" to "assertionMethod", + "jws" to "eyJiNjQiOmZhbHNlLCJjcml0IjpbImI2NCJdLCJhbGciOiJFZERTQSJ9..OPxskX37SK0FhmYygDk-S4csY_gNhCUgSOAaXFXDTZx86CmI5nU9xkqtLWg-f4cqkigKDdMVdtIqWAvaYx2JBA" + ) + ) + ) + + //language=JSON + val batchExample = """ + [ + { + "issuanceKey": { + "type": "local", + "jwk": "{\"kty\":\"OKP\",\"d\":\"mDhpwaH6JYSrD2Bq7Cs-pzmsjlLj4EOhxyI-9DM1mFI\",\"crv\":\"Ed25519\",\"kid\":\"Vzx7l5fh56F3Pf9aR3DECU5BwfrY6ZJe05aiWYWzan8\",\"x\":\"T3T4-u1Xz3vAV2JwPNxWfs4pik_JLiArz_WTCvrCFUM\"}" + }, + "issuerDid": "did:key:z6MkjoRhq1jSNJdLiruSXrFFxagqrztZaXHqHGUTKJbcNywp", + "vc": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "id": "http://example.gov/credentials/3732", + "type": [ + "VerifiableCredential", + "UniversityDegreeCredential" + ], + "issuer": { + "id": "did:web:vc.transmute.world" + }, + "issuanceDate": "2020-03-10T04:24:12.164Z", + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "degree": { + "type": "BachelorDegree", + "name": "Bachelor of Science and Arts" + } + } + }, + "mapping": { + "id": "", + "issuer": { + "id": "" + }, + "credentialSubject": { + "id": "" + }, + "issuanceDate": "", + "expirationDate": "" + } + }, + { + "issuanceKey": { + "type": "local", + "jwk": "{\"kty\":\"OKP\",\"d\":\"mDhpwaH6JYSrD2Bq7Cs-pzmsjlLj4EOhxyI-9DM1mFI\",\"crv\":\"Ed25519\",\"kid\":\"Vzx7l5fh56F3Pf9aR3DECU5BwfrY6ZJe05aiWYWzan8\",\"x\":\"T3T4-u1Xz3vAV2JwPNxWfs4pik_JLiArz_WTCvrCFUM\"}" + }, + "issuerDid": "did:key:z6MkjoRhq1jSNJdLiruSXrFFxagqrztZaXHqHGUTKJbcNywp", + "vc": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://purl.imsglobal.org/spec/ob/v3p0/context.json" + ], + "id": "urn:uuid:THIS WILL BE REPLACED WITH DYNAMIC DATA FUNCTION (see below)", + "type": [ + "VerifiableCredential", + "OpenBadgeCredential" + ], + "name": "JFF x vc-edu PlugFest 3 Interoperability", + "issuer": { + "type": [ + "Profile" + ], + "id": "did:key:THIS WILL BE REPLACED WITH DYNAMIC DATA FUNCTION FROM CONTEXT (see below)", + "name": "Jobs for the Future (JFF)", + "url": "https://www.jff.org/", + "image": "https://w3c-ccg.github.io/vc-ed/plugfest-1-2022/images/JFF_LogoLockup.png" + }, + "issuanceDate": "2023-07-20T07:05:44Z (THIS WILL BE REPLACED BY DYNAMIC DATA FUNCTION (see below))", + "expirationDate": "WILL BE MAPPED BY DYNAMIC DATA FUNCTION (see below)", + "credentialSubject": { + "id": "did:key:123 (THIS WILL BE REPLACED BY DYNAMIC DATA FUNCTION (see below))", + "type": [ + "AchievementSubject" + ], + "achievement": { + "id": "urn:uuid:ac254bd5-8fad-4bb1-9d29-efd938536926", + "type": [ + "Achievement" + ], + "name": "JFF x vc-edu PlugFest 3 Interoperability", + "description": "This wallet supports the use of W3C Verifiable Credentials and has demonstrated interoperability during the presentation request workflow during JFF x VC-EDU PlugFest 3.", + "criteria": { + "type": "Criteria", + "narrative": "Wallet solutions providers earned this badge by demonstrating interoperability during the presentation request workflow. This includes successfully receiving a presentation request, allowing the holder to select at least two types of verifiable credentials to create a verifiable presentation, returning the presentation to the requestor, and passing verification of the presentation and the included credentials." + }, + "image": { + "id": "https://w3c-ccg.github.io/vc-ed/plugfest-3-2023/images/JFF-VC-EDU-PLUGFEST3-badge-image.png", + "type": "Image" + } + } + } + }, + "mapping": { + "id": "", + "issuer": { + "id": "" + }, + "credentialSubject": { + "id": "" + }, + "issuanceDate": "", + "expirationDate": "" + } + } + ] + """.trimIndent() + + //language=JSON + val sdJwtExample = """ + { + "issuanceKey": { + "type": "local", + "jwk": "{\"kty\":\"OKP\",\"d\":\"mDhpwaH6JYSrD2Bq7Cs-pzmsjlLj4EOhxyI-9DM1mFI\",\"crv\":\"Ed25519\",\"kid\":\"Vzx7l5fh56F3Pf9aR3DECU5BwfrY6ZJe05aiWYWzan8\",\"x\":\"T3T4-u1Xz3vAV2JwPNxWfs4pik_JLiArz_WTCvrCFUM\"}" + }, + "issuerDid": "did:key:z6MkjoRhq1jSNJdLiruSXrFFxagqrztZaXHqHGUTKJbcNywp", + "vc": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://purl.imsglobal.org/spec/ob/v3p0/context.json" + ], + "id": "urn:uuid:THIS WILL BE REPLACED WITH DYNAMIC DATA FUNCTION (see below)", + "type": [ + "VerifiableCredential", + "OpenBadgeCredential" + ], + "name": "JFF x vc-edu PlugFest 3 Interoperability", + "issuer": { + "type": [ + "Profile" + ], + "id": "did:key:THIS WILL BE REPLACED WITH DYNAMIC DATA FUNCTION FROM CONTEXT (see below)", + "name": "Jobs for the Future (JFF)", + "url": "https://www.jff.org/", + "image": "https://w3c-ccg.github.io/vc-ed/plugfest-1-2022/images/JFF_LogoLockup.png" + }, + "issuanceDate": "2023-07-20T07:05:44Z (THIS WILL BE REPLACED BY DYNAMIC DATA FUNCTION (see below))", + "expirationDate": "WILL BE MAPPED BY DYNAMIC DATA FUNCTION (see below)", + "credentialSubject": { + "id": "did:key:123 (THIS WILL BE REPLACED BY DYNAMIC DATA FUNCTION (see below))", + "type": [ + "AchievementSubject" + ], + "achievement": { + "id": "urn:uuid:ac254bd5-8fad-4bb1-9d29-efd938536926", + "type": [ + "Achievement" + ], + "name": "JFF x vc-edu PlugFest 3 Interoperability", + "description": "This wallet supports the use of W3C Verifiable Credentials and has demonstrated interoperability during the presentation request workflow during JFF x VC-EDU PlugFest 3.", + "criteria": { + "type": "Criteria", + "narrative": "Wallet solutions providers earned this badge by demonstrating interoperability during the presentation request workflow. This includes successfully receiving a presentation request, allowing the holder to select at least two types of verifiable credentials to create a verifiable presentation, returning the presentation to the requestor, and passing verification of the presentation and the included credentials." + }, + "image": { + "id": "https://w3c-ccg.github.io/vc-ed/plugfest-3-2023/images/JFF-VC-EDU-PLUGFEST3-badge-image.png", + "type": "Image" + } + } + } + }, + "mapping": { + "id": "", + "issuer": { + "id": "" + }, + "credentialSubject": { + "id": "" + }, + "issuanceDate": "", + "expirationDate": "" + }, + "selectiveDisclosure": { + "fields": {"name": {"sd": true}} + } + } + """.trimIndent() +} diff --git a/waltid-issuer/src/main/kotlin/id/walt/issuer/IssuanceRequests.kt b/waltid-issuer/src/main/kotlin/id/walt/issuer/IssuanceRequests.kt new file mode 100644 index 000000000..24aa9938a --- /dev/null +++ b/waltid-issuer/src/main/kotlin/id/walt/issuer/IssuanceRequests.kt @@ -0,0 +1,33 @@ +package id.walt.issuer + +import id.walt.credentials.vc.vcs.W3CVC +import id.walt.sdjwt.SDMap +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +sealed class BaseIssuanceRequest { + abstract val issuanceKey: JsonObject + abstract val issuerDid: String + + abstract val vc: W3CVC + abstract val mapping: JsonObject? +} + +@Serializable +data class JwtIssuanceRequest( + override val issuanceKey: JsonObject, + override val issuerDid: String, + + override val vc: W3CVC, + override val mapping: JsonObject? = null +) : BaseIssuanceRequest() + +@Serializable +data class SdJwtIssuanceRequest( + override val issuanceKey: JsonObject, + override val issuerDid: String, + + override val vc: W3CVC, + override val mapping: JsonObject? = null, + val selectiveDisclosure: SDMap? = null, +) : BaseIssuanceRequest() diff --git a/waltid-issuer/src/main/kotlin/id/walt/issuer/IssuerApi.kt b/waltid-issuer/src/main/kotlin/id/walt/issuer/IssuerApi.kt index 59db7303b..df7248d02 100644 --- a/waltid-issuer/src/main/kotlin/id/walt/issuer/IssuerApi.kt +++ b/waltid-issuer/src/main/kotlin/id/walt/issuer/IssuerApi.kt @@ -3,6 +3,12 @@ package id.walt.issuer import id.walt.credentials.vc.vcs.W3CVC import id.walt.crypto.keys.* import id.walt.did.dids.DidService +import id.walt.issuer.IssuanceExamples.batchExample +import id.walt.issuer.IssuanceExamples.openBadgeCredentialExampleJsonString +import id.walt.issuer.IssuanceExamples.sdJwtExample +import id.walt.issuer.IssuanceExamples.universityDegreeCredential +import id.walt.issuer.IssuanceExamples.universityDegreeCredentialExample2 +import id.walt.issuer.IssuanceExamples.universityDegreeCredentialSignedExample import id.walt.oid4vc.definitions.CROSS_DEVICE_CREDENTIAL_OFFER_URL import id.walt.oid4vc.requests.CredentialOfferRequest import io.github.smiley4.ktorswaggerui.dsl.get @@ -13,168 +19,10 @@ import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.* +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject import kotlin.time.Duration.Companion.minutes -@Serializable -data class IssuanceRequest( - val vc: W3CVC, - val mapping: JsonObject? = null -) { - companion object { - /** - * Return IssuanceRequest of W3CVC in `vc` and mapping in `mapping` if it has `vc`. Otherwise, - * return complete JSON as W3CVC and no mapping. - */ - fun fromJsonObject(jsonObj: JsonObject): IssuanceRequest { - val maybeHasVc = jsonObj["vc"]?.jsonObject - return when { - maybeHasVc != null -> IssuanceRequest(W3CVC(maybeHasVc), jsonObj["mapping"]?.jsonObject) - else -> IssuanceRequest(W3CVC(jsonObj), null) - } - } - } -} - -//language=json -val universityDegreeCredential = """ -{ - "vc": { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://www.w3.org/2018/credentials/examples/v1" - ], - "id": "http://example.gov/credentials/3732", - "type": [ - "VerifiableCredential", - "UniversityDegreeCredential" - ], - "issuer": { - "id": "did:web:vc.transmute.world" - }, - "issuanceDate": "2020-03-10T04:24:12.164Z", - "credentialSubject": { - "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", - "degree": { - "type": "BachelorDegree", - "name": "Bachelor of Science and Arts" - } - } - }, - "mapping": { - "id": "", - "issuer": {"id": "" }, - "credentialSubject": {"id": ""}, - "issuanceDate": "", - "expirationDate": "" - } -} -""".trimIndent() - -//language=json -val openBadgeCredentialExampleJsonString = """ -{ - "vc": { - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://purl.imsglobal.org/spec/ob/v3p0/context.json" - ], - "id": "urn:uuid:THIS WILL BE REPLACED WITH DYNAMIC DATA FUNCTION (see below)", - "type": [ - "VerifiableCredential", - "OpenBadgeCredential" - ], - "name": "JFF x vc-edu PlugFest 3 Interoperability", - "issuer": { - "type": [ - "Profile" - ], - "id": "did:key:THIS WILL BE REPLACED WITH DYNAMIC DATA FUNCTION FROM CONTEXT (see below)", - "name": "Jobs for the Future (JFF)", - "url": "https://www.jff.org/", - "image": "https://w3c-ccg.github.io/vc-ed/plugfest-1-2022/images/JFF_LogoLockup.png" - }, - "issuanceDate": "2023-07-20T07:05:44Z (THIS WILL BE REPLACED BY DYNAMIC DATA FUNCTION (see below))", - "expirationDate": "WILL BE MAPPED BY DYNAMIC DATA FUNCTION (see below)", - "credentialSubject": { - "id": "did:key:123 (THIS WILL BE REPLACED BY DYNAMIC DATA FUNCTION (see below))", - "type": [ - "AchievementSubject" - ], - "achievement": { - "id": "urn:uuid:ac254bd5-8fad-4bb1-9d29-efd938536926", - "type": [ - "Achievement" - ], - "name": "JFF x vc-edu PlugFest 3 Interoperability", - "description": "This wallet supports the use of W3C Verifiable Credentials and has demonstrated interoperability during the presentation request workflow during JFF x VC-EDU PlugFest 3.", - "criteria": { - "type": "Criteria", - "narrative": "Wallet solutions providers earned this badge by demonstrating interoperability during the presentation request workflow. This includes successfully receiving a presentation request, allowing the holder to select at least two types of verifiable credentials to create a verifiable presentation, returning the presentation to the requestor, and passing verification of the presentation and the included credentials." - }, - "image": { - "id": "https://w3c-ccg.github.io/vc-ed/plugfest-3-2023/images/JFF-VC-EDU-PLUGFEST3-badge-image.png", - "type": "Image" - } - } - } - }, - "mapping": { - "id": "", - "issuer": {"id": "" }, - "credentialSubject": {"id": ""}, - "issuanceDate": "", - "expirationDate": "" - } -} -""".trimIndent() -val openBadgeCredentialExample = Json.parseToJsonElement(openBadgeCredentialExampleJsonString).jsonObject.toMap() - - -val prettyJson = Json { prettyPrint = true } -val batchExample = prettyJson.encodeToString( - JsonArray( - listOf( - prettyJson.parseToJsonElement(universityDegreeCredential), - prettyJson.parseToJsonElement(openBadgeCredentialExampleJsonString), - ) - ) -) - -val universityDegreeCredentialExample2 = mapOf( - "@context" to listOf( - "https://www.w3.org/2018/credentials/v1", "https://www.w3.org/2018/credentials/examples/v1" - ), - "id" to "http://example.gov/credentials/3732", - "type" to listOf( - "VerifiableCredential", "UniversityDegreeCredential" - ), - "issuer" to mapOf( - "id" to "did:web:vc.transmute.world" - ), - "issuanceDate" to "2020-03-10T04:24:12.164Z", - "credentialSubject" to mapOf( - "id" to "did:example:ebfeb1f712ebc6f1c276e12ec21", "degree" to mapOf( - "type" to "BachelorDegree", - "name" to "Bachelor of Science and Arts" - ) - ), -) - -val universityDegreeCredentialSignedExample = universityDegreeCredentialExample2.plus( - mapOf( - "proof" to mapOf( - "type" to "JsonWebSignature2020", - "created" to "2020-03-21T17:51:48Z", - "verificationMethod" to "did:web:vc.transmute.world#_Qq0UL2Fq651Q0Fjd6TvnYE-faHiOpRlPVQcY_-tA4A", - "proofPurpose" to "assertionMethod", - "jws" to "eyJiNjQiOmZhbHNlLCJjcml0IjpbImI2NCJdLCJhbGciOiJFZERTQSJ9..OPxskX37SK0FhmYygDk-S4csY_gNhCUgSOAaXFXDTZx86CmI5nU9xkqtLWg-f4cqkigKDdMVdtIqWAvaYx2JBA" - ) - ) -) - fun Application.issuerApi() { routing { get("/example-key") { @@ -290,26 +138,11 @@ fun Application.issuerApi() { route("openid4vc") { route("jwt") { post("issue", { - summary = "Signs credential and starts an OIDC credential exchange flow." + summary = "Signs credential with JWT and starts an OIDC credential exchange flow." description = "This endpoint issues a W3C Verifiable Credential, and returns an issuance URL " request { - headerParameter("walt-key") { - description = - "Supply a core-crypto key representation to use to issue the credential, " + - "e.g. a local key (internal JWK) or a TSE key." - example = mapOf( - "type" to "local", "jwk" to "{ ... }" - ) - required = true - } - headerParameter("walt-issuerDid") { - description = "Optionally, supply a DID to use in the proof. If no DID is passed, " + - "a did:key of the supplied key will be used." - example = "did:ebsi:..." - required = false - } - body { + body { description = "Pass the unsigned credential that you intend to issue as the body of the request." example("OpenBadgeCredential example", openBadgeCredentialExampleJsonString) @@ -330,16 +163,12 @@ fun Application.issuerApi() { } } }) { - val keyJson = - context.request.header("walt-key") ?: throw IllegalArgumentException("No key was passed.") - val key = KeySerialization.deserializeKey(keyJson) + val issuanceRequest = context.receive() + val key = KeySerialization.deserializeKey(issuanceRequest.issuanceKey) .onFailure { throw IllegalArgumentException("Invalid key was supplied, error occurred is: $it") } .getOrThrow() val issuerDid = - context.request.header("walt-issuerDid") ?: DidService.registerByKey("key", key).did - - val body = context.receive() - val issuanceRequest = IssuanceRequest.fromJsonObject(body) + issuanceRequest.issuerDid ?: DidService.registerByKey("key", key).did val credentialOfferBuilder = OidcIssuance.issuanceRequestsToCredentialOfferBuilder(issuanceRequest) @@ -378,21 +207,7 @@ fun Application.issuerApi() { description = "This endpoint issues a list W3C Verifiable Credentials, and returns an issuance URL " request { - headerParameter("walt-key") { - description = "Supply a core-crypto key representation to use to issue the credential, " + - "e.g. a local key (internal JWK) or a TSE key." - example = mapOf( - "type" to "local", "jwk" to "{ ... }" - ) - required = true - } - headerParameter("walt-issuerDid") { - description = "Optionally, supply a DID to use in the proof. If no DID is passed, " + - "a did:key of the supplied key will be used." - example = "did:ebsi:..." - required = false - } - body { + body> { description = "Pass the unsigned credential that you intend to issue as the body of the request." example("Batch example", batchExample) @@ -412,15 +227,9 @@ fun Application.issuerApi() { } } }) { - val keyJson = - context.request.header("walt-key") ?: throw IllegalArgumentException("No key was passed.") - val key = KeySerialization.deserializeKey(keyJson) - .onFailure { throw IllegalArgumentException("Invalid key was supplied, error occurred is: $it") } - .getOrThrow() - val issuerDid = context.request.header("walt-issuerDid") ?: DidService.registerByKey("key", key).did - val body = context.receive() - val issuanceRequests = body.map { IssuanceRequest.fromJsonObject(it.jsonObject) } + + val issuanceRequests = context.receive>() val credentialOfferBuilder = OidcIssuance.issuanceRequestsToCredentialOfferBuilder(issuanceRequests) @@ -434,7 +243,11 @@ fun Application.issuerApi() { OidcApi.setIssuanceDataForIssuanceId( issuanceSession.id, - issuanceRequests.map { CIProvider.IssuanceSessionData(key, issuerDid, it) } + issuanceRequests.map { + CIProvider.IssuanceSessionData( + KeySerialization.deserializeKey(it.issuanceKey).getOrThrow(), it.issuerDid, it + ) + } ) println("issuanceSession: $issuanceSession") @@ -455,6 +268,80 @@ fun Application.issuerApi() { ) } } + route("sdjwt") { + post("issue", { + summary = "Signs credential and starts an OIDC credential exchange flow." + description = "This endpoint issues a W3C Verifiable Credential, and returns an issuance URL " + + request { + body { + description = + "Pass the unsigned credential that you intend to issue as the body of the request." + example("SD-JWT example", sdJwtExample) + //example("UniversityDegreeCredential example", universityDegreeCredential) + required = true + } + } + + response { + "200" to { + description = "Credential signed (with the *proof* attribute added)" + body { + example( + "Issuance URL URL", + "openid-credential-offer://localhost/?credential_offer=%7B%22credential_issuer%22%3A%22http%3A%2F%2Flocalhost%3A8000%22%2C%22credentials%22%3A%5B%22VerifiableId%22%5D%2C%22grants%22%3A%7B%22authorization_code%22%3A%7B%22issuer_state%22%3A%22501414a4-c461-43f0-84b2-c628730c7c02%22%7D%7D%7D" + ) + } + } + } + }) { + val req = context.receive() + + val key = KeySerialization.deserializeKey(req.issuanceKey) + .onFailure { throw IllegalArgumentException("Invalid key was supplied, error occurred is: $it") } + .getOrThrow() + val issuerDid = req.issuerDid ?: DidService.registerByKey("key", key).did + + val credentialOfferBuilder = + OidcIssuance.issuanceRequestsToCredentialOfferBuilder( + JwtIssuanceRequest( + req.issuanceKey, + issuerDid, + req.vc, + req.mapping + ) + ) + + val issuanceSession = OidcApi.initializeCredentialOffer( + credentialOfferBuilder = credentialOfferBuilder, + expiresIn = 5.minutes, + allowPreAuthorized = true + ) + + //val nonce = issuanceSession.cNonce ?: throw IllegalArgumentException("No cNonce set in issuanceSession?") + + OidcApi.setIssuanceDataForIssuanceId( + issuanceSession.id, + listOf(CIProvider.IssuanceSessionData(key, issuerDid, req)) + ) + println("issuanceSession: $issuanceSession") + + val offerRequest = CredentialOfferRequest(issuanceSession.credentialOffer!!) + println("offerRequest: $offerRequest") + + val offerUri = OidcApi.getCredentialOfferRequestUrl( + offerRequest, + CROSS_DEVICE_CREDENTIAL_OFFER_URL + OidcApi.baseUrl.removePrefix("https://") + .removePrefix("http://") + "/" + ) + println("Offer URI: $offerUri") + + context.respond( + HttpStatusCode.OK, + offerUri + ) + } + } route("mdoc") { post("issue", { summary = "Signs a credential based on the IEC/ISO18013-5 mdoc/mDL format." diff --git a/waltid-issuer/src/main/kotlin/id/walt/issuer/OidcApi.kt b/waltid-issuer/src/main/kotlin/id/walt/issuer/OidcApi.kt index 5e9eca46f..3bb31dc00 100644 --- a/waltid-issuer/src/main/kotlin/id/walt/issuer/OidcApi.kt +++ b/waltid-issuer/src/main/kotlin/id/walt/issuer/OidcApi.kt @@ -119,7 +119,13 @@ object OidcApi : CIProvider() { val tokenResp = processTokenRequest(tokenReq) println("/token tokenResp: $tokenResp") - val sessionId = Json.parseToJsonElement(Base64.decode((tokenResp.accessToken ?: throw IllegalArgumentException("No access token was responded with tokenResp?")).split(".")[1]).decodeToString()).jsonObject["sub"]?.jsonPrimitive?.contentOrNull ?: throw IllegalArgumentException("Could not get session ID from token response!") + val sessionId = Json.parseToJsonElement( + Base64.decode( + (tokenResp.accessToken + ?: throw IllegalArgumentException("No access token was responded with tokenResp?")).split(".")[1] + ).decodeToString() + ).jsonObject["sub"]?.jsonPrimitive?.contentOrNull + ?: throw IllegalArgumentException("Could not get session ID from token response!") val nonceToken = tokenResp.cNonce ?: throw IllegalArgumentException("No nonce token was responded with the tokenResp?") OidcApi.mapSessionIdToToken(sessionId, nonceToken) diff --git a/waltid-issuer/src/main/kotlin/id/walt/issuer/OidcIssuance.kt b/waltid-issuer/src/main/kotlin/id/walt/issuer/OidcIssuance.kt index efd684831..35c29c842 100644 --- a/waltid-issuer/src/main/kotlin/id/walt/issuer/OidcIssuance.kt +++ b/waltid-issuer/src/main/kotlin/id/walt/issuer/OidcIssuance.kt @@ -22,10 +22,10 @@ object OidcIssuance { } - fun issuanceRequestsToCredentialOfferBuilder(issuanceRequests: List) = + fun issuanceRequestsToCredentialOfferBuilder(issuanceRequests: List) = issuanceRequestsToCredentialOfferBuilder(*issuanceRequests.toTypedArray()) - fun issuanceRequestsToCredentialOfferBuilder(vararg issuanceRequests: IssuanceRequest): CredentialOffer.Builder { + fun issuanceRequestsToCredentialOfferBuilder(vararg issuanceRequests: BaseIssuanceRequest): CredentialOffer.Builder { val vcs = issuanceRequests.map { it.vc } var builder = CredentialOffer.Builder(OidcApi.baseUrl) diff --git a/waltid-issuer/src/main/kotlin/id/walt/issuer/base/config/OIDCIssuerServiceConfig.kt b/waltid-issuer/src/main/kotlin/id/walt/issuer/base/config/OIDCIssuerServiceConfig.kt index 811787745..fb3a0399c 100644 --- a/waltid-issuer/src/main/kotlin/id/walt/issuer/base/config/OIDCIssuerServiceConfig.kt +++ b/waltid-issuer/src/main/kotlin/id/walt/issuer/base/config/OIDCIssuerServiceConfig.kt @@ -1,5 +1,5 @@ package id.walt.issuer.base.config data class OIDCIssuerServiceConfig( - val baseUrl: String -): BaseConfig + val baseUrl: String +) : BaseConfig diff --git a/waltid-issuer/src/main/kotlin/id/walt/issuer/base/web/Exceptions.kt b/waltid-issuer/src/main/kotlin/id/walt/issuer/base/web/Exceptions.kt index 4e82e25b5..870ba5e93 100644 --- a/waltid-issuer/src/main/kotlin/id/walt/issuer/base/web/Exceptions.kt +++ b/waltid-issuer/src/main/kotlin/id/walt/issuer/base/web/Exceptions.kt @@ -2,6 +2,6 @@ package id.walt.issuer.base.web import io.ktor.http.* -open class WebException(val status: HttpStatusCode, message: String): Exception(message) +open class WebException(val status: HttpStatusCode, message: String) : Exception(message) -class UnauthorizedException(message: String): WebException(HttpStatusCode.Unauthorized, message) +class UnauthorizedException(message: String) : WebException(HttpStatusCode.Unauthorized, message) diff --git a/waltid-issuer/src/main/kotlin/id/walt/issuer/base/web/plugins/StatusPages.kt b/waltid-issuer/src/main/kotlin/id/walt/issuer/base/web/plugins/StatusPages.kt index c2cf59bbf..69583ca5b 100644 --- a/waltid-issuer/src/main/kotlin/id/walt/issuer/base/web/plugins/StatusPages.kt +++ b/waltid-issuer/src/main/kotlin/id/walt/issuer/base/web/plugins/StatusPages.kt @@ -26,12 +26,15 @@ fun Application.configureStatusPages() { cause.printStackTrace() call.respond( - statusCodeForException(cause), Json.encodeToString(mapOf( - "exception" to "true", - "status" to statusCodeForException(cause).description, - "code" to statusCodeForException(cause).value.toString(), - "message" to cause.message - ))) + statusCodeForException(cause), Json.encodeToString( + mapOf( + "exception" to "true", + "status" to statusCodeForException(cause).description, + "code" to statusCodeForException(cause).value.toString(), + "message" to cause.message + ) + ) + ) } } } diff --git a/waltid-issuer/src/main/resources/simplelogger.properties b/waltid-issuer/src/main/resources/simplelogger.properties index 7a8b78531..8d813bf2c 100644 --- a/waltid-issuer/src/main/resources/simplelogger.properties +++ b/waltid-issuer/src/main/resources/simplelogger.properties @@ -1,5 +1,4 @@ org.slf4j.simpleLogger.defaultLogLevel=info #org.slf4j.simpleLogger.log.id.walt=debug - org.slf4j.simpleLogger.log.com.github.victools.jsonschema.generator.impl.SchemaGenerationContextImpl=info diff --git a/waltid-issuer/src/test/resources/simplelogger.properties b/waltid-issuer/src/test/resources/simplelogger.properties index 87a0b0a4b..0a8d64ef8 100644 --- a/waltid-issuer/src/test/resources/simplelogger.properties +++ b/waltid-issuer/src/test/resources/simplelogger.properties @@ -1,5 +1,4 @@ org.slf4j.simpleLogger.defaultLogLevel=debug org.slf4j.simpleLogger.log.id.walt=debug - org.slf4j.simpleLogger.log.com.github.victools.jsonschema.generator.impl.SchemaGenerationContextImpl=info diff --git a/waltid-openid4vc/README.md b/waltid-openid4vc/README.md index 40b5b8dce..ae9db0701 100644 --- a/waltid-openid4vc/README.md +++ b/waltid-openid4vc/README.md @@ -16,9 +16,10 @@ ## Getting Started -### What it provides +### What it provides + * Request and response data objects - * Parse and serialize to/from HTTP URI query parameters and/or HTTP form data or JSON data from request bodies + * Parse and serialize to/from HTTP URI query parameters and/or HTTP form data or JSON data from request bodies * Data structures defined by OpenID and DIF specifications * Error handling * Interfaces for state management and cryptographic operations @@ -27,6 +28,7 @@ ### How to use it To use it, depending on the kind of service provider you want to implement, + * Implement the abstract base class of the type of service provider you want to create (Issuer, Verifier or Wallet) * Implement the interfaces for session management and cryptographic operations * Implement a REST API providing the HTTP endpoints defined by the respective specification @@ -37,9 +39,12 @@ To use it, depending on the kind of service provider you want to implement, ## Examples -The following examples show how to use the library, with simple, minimal implementations of Issuer, Verifier and Wallet REST endpoints and business logic, for processing the OpenID4VC protocols. +The following examples show how to use the library, with simple, minimal implementations of Issuer, Verifier and Wallet REST endpoints and +business logic, for processing the OpenID4VC protocols. -The examples are based on **JVM** and make use of [**ktor**](https://ktor.io/) for the HTTP server endpoints and client-side request handling, and the [**waltid-ssikit**](https://github.com/walt-id/waltid-ssikit) for the cryptographic operations and credential and presentation handling. +The examples are based on **JVM** and make use of [**ktor**](https://ktor.io/) for the HTTP server endpoints and client-side request +handling, and the [**waltid-ssikit**](https://github.com/walt-id/waltid-ssikit) for the cryptographic operations and credential and +presentation handling. ### Issuer @@ -52,27 +57,34 @@ For the OpenID4VCI issuance protocol, implement the following endpoints: **Well-defined endpoints:** This endpoints are well-defined, and need to be available under this exact path, relative to your issuer base URL: + * `GET /.well-known/openid-configuration` * `GET /.well-known/openid-credential-issuer` -Returns the issuer [provider metadata](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata). +Returns the +issuer [provider metadata](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata). https://github.com/walt-id/waltid-openid4vc/blob/bd9374826d7acbd0d77d15cd2a81098e643eb6fa/src/jvmTest/kotlin/id/walt/oid4vc/CITestProvider.kt#L115-L120 **Other required endpoints** -These endpoints can have any path, according to your requirements or preferences, but need to be referenced in the provider metadata, returned by the well-defined configuration endpoints listed above. +These endpoints can have any path, according to your requirements or preferences, but need to be referenced in the provider metadata, +returned by the well-defined configuration endpoints listed above. * `POST /par` -Endpoint to receive [pushed authorization requests](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-pushed-authorization-reques), referenced in the provider metadata as `pushed_authorization_request_endpoint`, see also [here](https://www.rfc-editor.org/rfc/rfc9126.html#name-authorization-server-metada). +Endpoint to +receive [pushed authorization requests](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-pushed-authorization-reques), +referenced in the provider metadata as `pushed_authorization_request_endpoint`, see +also [here](https://www.rfc-editor.org/rfc/rfc9126.html#name-authorization-server-metada). https://github.com/walt-id/waltid-openid4vc/blob/bd9374826d7acbd0d77d15cd2a81098e643eb6fa/src/jvmTest/kotlin/id/walt/oid4vc/CITestProvider.kt#L121-L129 * `GET /authorize` -[Authorization endpoint](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-authorization-request), referenced in provider metadata as `authorization_endpoint`, see [here](https://www.rfc-editor.org/rfc/rfc8414.html#section-2) +[Authorization endpoint](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-authorization-request), referenced +in provider metadata as `authorization_endpoint`, see [here](https://www.rfc-editor.org/rfc/rfc8414.html#section-2) Not required for the pre-authorized issuance flow. @@ -80,31 +92,39 @@ https://github.com/walt-id/waltid-openid4vc/blob/bd9374826d7acbd0d77d15cd2a81098 * `POST /token` -[Token endpoint](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-endpoint), referenced in provider metadata as `token_endpoint`, see [here](https://www.rfc-editor.org/rfc/rfc8414.html#section-2) +[Token endpoint](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-token-endpoint), referenced in provider +metadata as `token_endpoint`, see [here](https://www.rfc-editor.org/rfc/rfc8414.html#section-2) https://github.com/walt-id/waltid-openid4vc/blob/bd9374826d7acbd0d77d15cd2a81098e643eb6fa/src/jvmTest/kotlin/id/walt/oid4vc/CITestProvider.kt#L159-L168 * `POST /credential` -[Credential endpoint](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-endpoint) to fetch the issued credential, after authorization flow is completed. Referenced in provider metadata as `credential_endpoint`, as defined [here](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p. +[Credential endpoint](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-endpoint) to fetch the +issued credential, after authorization flow is completed. Referenced in provider metadata as `credential_endpoint`, as +defined [here](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p. https://github.com/walt-id/waltid-openid4vc/blob/bd9374826d7acbd0d77d15cd2a81098e643eb6fa/src/jvmTest/kotlin/id/walt/oid4vc/CITestProvider.kt#L169-L181 * `POST /credential_deferred` -[Deferred credential endpoint](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-deferred-credential-endpoin), to fetch issued credential if issuance is deferred. Referenced in provider metadata as `deferred_credential_endpoint` (missing in spec). +[Deferred credential endpoint](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-deferred-credential-endpoin), +to fetch issued credential if issuance is deferred. Referenced in provider metadata as `deferred_credential_endpoint` (missing in spec). https://github.com/walt-id/waltid-openid4vc/blob/bd9374826d7acbd0d77d15cd2a81098e643eb6fa/src/jvmTest/kotlin/id/walt/oid4vc/CITestProvider.kt#L182-L193 * `POST /batch_credential` -[Batch credential endpoint](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-batch-credential-endpoint) to fetch multiple issued credentials. Referenced in provider metadata as `batch_credential_endpoint`, as defined [here](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p. +[Batch credential endpoint](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-batch-credential-endpoint) to +fetch multiple issued credentials. Referenced in provider metadata as `batch_credential_endpoint`, as +defined [here](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html#name-credential-issuer-metadata-p. https://github.com/walt-id/waltid-openid4vc/blob/bd9374826d7acbd0d77d15cd2a81098e643eb6fa/src/jvmTest/kotlin/id/walt/oid4vc/CITestProvider.kt#L194-L205 #### Business logic -For the business logic, implement the abstract issuance provider in `src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialIssuer.kt`, providing session and cache management, as well, as cryptographic operations for issuing credentials. +For the business logic, implement the abstract issuance provider +in `src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialIssuer.kt`, providing session and cache management, as well, as +cryptographic operations for issuing credentials. * **Configuration of issuance provider** @@ -128,8 +148,6 @@ For the full demo verifier implementation, refer to `/src/jvmTest/kotlin/id/walt #### REST endpoints - - #### Business logic ### Wallet @@ -142,7 +160,6 @@ For the full demo verifier implementation, refer to `/src/jvmTest/kotlin/id/walt Licensed under the [Apache License, Version 2.0](https://github.com/walt-id/waltid-xyzkit/blob/master/LICENSE) - # Example flows: ## EBSI conformance test: Credential issuance: @@ -172,4 +189,4 @@ Wallet ->> Issuer: Fetch credentials from credential endpoint or batch-credentia Issuer -->> Wallet: Credential (and updated c_nonce) end -``` \ No newline at end of file +``` diff --git a/waltid-openid4vc/build.gradle.kts b/waltid-openid4vc/build.gradle.kts index 477ef81e9..468ee28ec 100644 --- a/waltid-openid4vc/build.gradle.kts +++ b/waltid-openid4vc/build.gradle.kts @@ -73,6 +73,7 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") implementation("io.ktor:ktor-http:$ktor_version") implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.1") + // TODO: set to project version: implementation(project(":waltid-sdjwt")) implementation("id.walt:waltid-sd-jwt:1.2310101347.0") } } @@ -97,6 +98,7 @@ kotlin { implementation("io.kotest:kotest-assertions-json:5.7.2") implementation("id.walt.servicematrix:WaltID-ServiceMatrix:1.1.3") + // TODO: current version implementation("id.walt:waltid-ssikit:1.2311131043.0") implementation("id.walt:waltid-ssikit:1.JWTTYP") implementation(project(":waltid-crypto")) diff --git a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/data/DurationInSecondsSerializer.kt b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/data/DurationInSecondsSerializer.kt index 8efa0e937..6b42ea5a9 100644 --- a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/data/DurationInSecondsSerializer.kt +++ b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/data/DurationInSecondsSerializer.kt @@ -8,16 +8,16 @@ import kotlinx.serialization.encoding.Encoder import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds -object DurationInSecondsSerializer: KSerializer { - override val descriptor: SerialDescriptor - get() = Duration.serializer().descriptor +object DurationInSecondsSerializer : KSerializer { + override val descriptor: SerialDescriptor + get() = Duration.serializer().descriptor - override fun deserialize(decoder: Decoder): Duration { - return decoder.decodeLong().seconds - } + override fun deserialize(decoder: Decoder): Duration { + return decoder.decodeLong().seconds + } - override fun serialize(encoder: Encoder, value: Duration) { - encoder.encodeLong(value.inWholeSeconds) - } + override fun serialize(encoder: Encoder, value: Duration) { + encoder.encodeLong(value.inWholeSeconds) + } } diff --git a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/data/JsonDataObject.kt b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/data/JsonDataObject.kt index 584a2f979..21a32168a 100644 --- a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/data/JsonDataObject.kt +++ b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/data/JsonDataObject.kt @@ -22,6 +22,7 @@ abstract class JsonDataObjectSerializer(serializer: KSeriali JsonTransformingSerializer(serializer) { private val customParametersName = "customParameters" + @OptIn(ExperimentalSerializationApi::class) private val knownElementNames get() = descriptor.elementNames.filter { it != customParametersName }.toSet() diff --git a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialError.kt b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialError.kt index 05264f5eb..62c2c94ae 100644 --- a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialError.kt +++ b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialError.kt @@ -8,7 +8,8 @@ import id.walt.oid4vc.responses.CredentialResponse import kotlin.time.Duration class CredentialError( - credentialRequest: CredentialRequest, val errorCode: CredentialErrorCode, + credentialRequest: CredentialRequest?, + val errorCode: CredentialErrorCode, val errorUri: String? = null, val cNonce: String? = null, val cNonceExpiresIn: Duration? = null, override val message: String? = null ) : Exception() { diff --git a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt index 54dd8c2ab..cc3a139aa 100644 --- a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt +++ b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/errors/CredentialOfferError.kt @@ -1,13 +1,16 @@ package id.walt.oid4vc.errors +import id.walt.oid4vc.data.CredentialOffer import id.walt.oid4vc.requests.CredentialOfferRequest class CredentialOfferError( - val credentialOfferRequest: CredentialOfferRequest, val errorCode: CredentialOfferErrorCode, override val message: String? = null -): Exception() { -} + val credentialOfferRequest: CredentialOfferRequest?, + val credentialOffer: CredentialOffer?, + val errorCode: CredentialOfferErrorCode, + override val message: String? = null +) : Exception() enum class CredentialOfferErrorCode { - invalid_request, - invalid_issuer + invalid_request, + invalid_issuer } diff --git a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/interfaces/IHttpClient.kt b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/interfaces/IHttpClient.kt index c0b14e9c4..dedf22ec4 100644 --- a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/interfaces/IHttpClient.kt +++ b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/interfaces/IHttpClient.kt @@ -3,13 +3,14 @@ package id.walt.oid4vc.interfaces import io.ktor.http.* import kotlinx.serialization.json.JsonObject -data class SimpleHttpResponse ( - val status: HttpStatusCode, - val headers: Headers, - val body: String? +data class SimpleHttpResponse( + val status: HttpStatusCode, + val headers: Headers, + val body: String? ) + interface IHttpClient { - fun httpGet(url: Url, headers: Headers? = null): SimpleHttpResponse - fun httpPostObject(url: Url, jsonObject: JsonObject, headers: Headers? = null): SimpleHttpResponse - fun httpSubmitForm(url: Url, formParameters: Parameters, headers: Headers? = null): SimpleHttpResponse + fun httpGet(url: Url, headers: Headers? = null): SimpleHttpResponse + fun httpPostObject(url: Url, jsonObject: JsonObject, headers: Headers? = null): SimpleHttpResponse + fun httpSubmitForm(url: Url, formParameters: Parameters, headers: Headers? = null): SimpleHttpResponse } diff --git a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/interfaces/IVPTokenProvider.kt b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/interfaces/IVPTokenProvider.kt index ccf2a17ae..2b7e5f7ac 100644 --- a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/interfaces/IVPTokenProvider.kt +++ b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/interfaces/IVPTokenProvider.kt @@ -6,7 +6,7 @@ import id.walt.oid4vc.providers.SIOPSession import id.walt.oid4vc.requests.TokenRequest import kotlinx.serialization.json.JsonElement -interface IVPTokenProvider { +interface IVPTokenProvider { /** * Generates and signs the verifiable presentation as requested in the presentation definition for the vp_token response of the given TokenRequest. diff --git a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialIssuer.kt b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialIssuer.kt index 02de22495..911613469 100644 --- a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialIssuer.kt +++ b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialIssuer.kt @@ -336,14 +336,14 @@ abstract class OpenIDCredentialIssuer( * Override, to use custom path, by default, the path will be: "$baseUrl/credential_offer/, e.g.: "https://issuer.myhost.com/api/credential_offer/1234-4567-8900" * @param issuanceSession The issuance session for which the credential offer uri is created */ - open protected fun getCredentialOfferByReferenceUri(issuanceSession: IssuanceSession): String { + protected open fun getCredentialOfferByReferenceUri(issuanceSession: IssuanceSession): String { return URLBuilder(baseUrl).appendPathSegments("credential_offer", issuanceSession.id).buildString() } open fun getCredentialOfferRequest( issuanceSession: IssuanceSession, byReference: Boolean = false ): CredentialOfferRequest { - return if(byReference) { + return if (byReference) { CredentialOfferRequest(null, getCredentialOfferByReferenceUri(issuanceSession)) } else { CredentialOfferRequest(issuanceSession.credentialOffer) diff --git a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt index 99445e3a1..7353ac200 100644 --- a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt +++ b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/providers/OpenIDCredentialWallet.kt @@ -30,7 +30,7 @@ import kotlin.time.Duration.Companion.minutes * in reply to OpenID4VP authorization requests. * e.g.: Verifiable Credentials holder wallets */ -abstract class OpenIDCredentialWallet( +abstract class OpenIDCredentialWallet( baseUrl: String, override val config: CredentialWalletConfig ) : OpenIDProvider(baseUrl), ITokenProvider, IVPTokenProvider, IHttpClient { @@ -160,23 +160,73 @@ abstract class OpenIDCredentialWallet( // ========================================================== // =============== issuance flow =========================== - open fun getCredentialOffer(credentialOfferRequest: CredentialOfferRequest): CredentialOffer { + open fun resolveCredentialOffer(credentialOfferRequest: CredentialOfferRequest): CredentialOffer { return credentialOfferRequest.credentialOffer ?: credentialOfferRequest.credentialOfferUri?.let { uri -> httpGetAsJson(Url(uri))?.jsonObject?.let { CredentialOffer.fromJSON(it) } - } ?: throw CredentialOfferError(credentialOfferRequest, CredentialOfferErrorCode.invalid_request, "No credential offer value found on request, and credential offer could not be fetched by reference from given credential_offer_uri") + } ?: throw CredentialOfferError( + credentialOfferRequest, + null, + CredentialOfferErrorCode.invalid_request, + "No credential offer value found on request, and credential offer could not be fetched by reference from given credential_offer_uri" + ) + } + + open fun executePreAuthorizedCodeFlow( + credentialOffer: CredentialOffer, + holderDid: String, + client: OpenIDClientConfig, + userPIN: String? + ): List { + if (!credentialOffer.grants.containsKey(GrantType.pre_authorized_code.value)) throw CredentialOfferError( + null, + credentialOffer, + CredentialOfferErrorCode.invalid_request, + "Pre-authorized code issuance flow executed, but no pre-authorized_code found on credential offer" + ) + val issuerMetadataUrl = getCIProviderMetadataUrl(credentialOffer.credentialIssuer) + val issuerMetadata = + httpGetAsJson(Url(issuerMetadataUrl))?.jsonObject?.let { OpenIDProviderMetadata.fromJSON(it) } ?: throw CredentialOfferError( + null, + credentialOffer, + CredentialOfferErrorCode.invalid_issuer, + "Could not resolve issuer provider metadata from $issuerMetadataUrl" + ) + val authorizationServerMetadata = issuerMetadata.authorizationServer?.let { authServer -> + httpGetAsJson(Url(getCommonProviderMetadataUrl(authServer)))?.jsonObject?.let { OpenIDProviderMetadata.fromJSON(it) } + } ?: issuerMetadata + val offeredCredentials = credentialOffer.resolveOfferedCredentials(issuerMetadata) + + return executeAuthorizedIssuanceCodeFlow( + authorizationServerMetadata, issuerMetadata, credentialOffer, GrantType.pre_authorized_code, + offeredCredentials, holderDid, client, null, null, userPIN + ) } @OptIn(ExperimentalEncodingApi::class) - open fun executeFullAuthIssuance(credentialOfferRequest: CredentialOfferRequest, holderDid: String, client: OpenIDClientConfig): List { - val credentialOffer = getCredentialOffer(credentialOfferRequest) - if(!credentialOffer.grants.containsKey(GrantType.authorization_code.value)) throw CredentialOfferError(credentialOfferRequest, CredentialOfferErrorCode.invalid_request, "Full authorization issuance flow executed, but no authorization_code found on credential offer") + open fun executeFullAuthIssuance( + credentialOffer: CredentialOffer, + holderDid: String, + client: OpenIDClientConfig + ): List { + if (!credentialOffer.grants.containsKey(GrantType.authorization_code.value)) throw CredentialOfferError( + null, + credentialOffer, + CredentialOfferErrorCode.invalid_request, + "Full authorization issuance flow executed, but no authorization_code found on credential offer" + ) val issuerMetadataUrl = getCIProviderMetadataUrl(credentialOffer.credentialIssuer) - val issuerMetadata = httpGetAsJson(Url(issuerMetadataUrl))?.jsonObject?.let { OpenIDProviderMetadata.fromJSON(it) } ?: throw CredentialOfferError(credentialOfferRequest, CredentialOfferErrorCode.invalid_issuer, "Could not resolve issuer provider metadata from $issuerMetadataUrl") + val issuerMetadata = + httpGetAsJson(Url(issuerMetadataUrl))?.jsonObject?.let { OpenIDProviderMetadata.fromJSON(it) } ?: throw CredentialOfferError( + null, + credentialOffer, + CredentialOfferErrorCode.invalid_issuer, + "Could not resolve issuer provider metadata from $issuerMetadataUrl" + ) val authorizationServerMetadata = issuerMetadata.authorizationServer?.let { authServer -> httpGetAsJson(Url(getCommonProviderMetadataUrl(authServer)))?.jsonObject?.let { OpenIDProviderMetadata.fromJSON(it) } } ?: issuerMetadata val offeredCredentials = credentialOffer.resolveOfferedCredentials(issuerMetadata) - val codeVerifier = if(client.useCodeChallenge) randomUUID() else null + val codeVerifier = if (client.useCodeChallenge) randomUUID() else null val codeChallenge = codeVerifier?.let { Base64.UrlSafe.encode(sha256(it.toByteArray(Charsets.UTF_8))).trimEnd('=') } val authReq = AuthorizationRequest( @@ -185,7 +235,12 @@ abstract class OpenIDCredentialWallet( redirectUri = config.redirectUri, scope = setOf("openid"), issuerState = credentialOffer.grants[GrantType.authorization_code.value]?.issuerState, - authorizationDetails = offeredCredentials.map { AuthorizationDetails.fromOfferedCredential(it, issuerMetadata.credentialIssuer) }, + authorizationDetails = offeredCredentials.map { + AuthorizationDetails.fromOfferedCredential( + it, + issuerMetadata.credentialIssuer + ) + }, codeChallenge = codeChallenge, codeChallengeMethod = codeChallenge?.let { "S256" } ).let { authReq -> @@ -218,30 +273,105 @@ abstract class OpenIDCredentialWallet( it.parameters.appendAll(parametersOf(authReq.toHttpParameters())) }.build()) println("authResp: $authResp") - if(authResp.status != HttpStatusCode.Found) throw AuthorizationError(authReq, AuthorizationErrorCode.server_error, "Got unexpected status code ${authResp.status.value} from issuer") + if (authResp.status != HttpStatusCode.Found) throw AuthorizationError( + authReq, + AuthorizationErrorCode.server_error, + "Got unexpected status code ${authResp.status.value} from issuer" + ) var location = Url(authResp.headers[HttpHeaders.Location]!!) println("location: $location") - location = if(location.parameters.contains("response_type") && location.parameters["response_type"] == ResponseType.id_token.name) { - executeIdTokenAuthorization(location, holderDid, client) - } else location + location = + if (location.parameters.contains("response_type") && location.parameters["response_type"] == ResponseType.id_token.name) { + executeIdTokenAuthorization(location, holderDid, client) + } else if (location.parameters.contains("response_type") && location.parameters["response_type"] == ResponseType.vp_token.name) { + executeVpTokenAuthorization(location, holderDid, client) + } else location + + val code = location.parameters["code"] ?: throw AuthorizationError( + authReq, + AuthorizationErrorCode.server_error, + "No authorization code received from server" + ) + + return executeAuthorizedIssuanceCodeFlow( + authorizationServerMetadata, issuerMetadata, credentialOffer, + GrantType.authorization_code, offeredCredentials, holderDid, client, code, codeVerifier + ) + } - val code = location.parameters["code"] ?: throw AuthorizationError(authReq, AuthorizationErrorCode.server_error, "No authorization code received from server") + open fun fetchDeferredCredential(credentialOffer: CredentialOffer, credentialResponse: CredentialResponse): CredentialResponse { + if (credentialResponse.acceptanceToken.isNullOrEmpty()) throw CredentialOfferError( + null, + credentialOffer, + CredentialOfferErrorCode.invalid_request, + "Credential offer has no acceptance token for fetching deferred credential" + ) + val issuerMetadataUrl = getCIProviderMetadataUrl(credentialOffer.credentialIssuer) + val issuerMetadata = + httpGetAsJson(Url(issuerMetadataUrl))?.jsonObject?.let { OpenIDProviderMetadata.fromJSON(it) } ?: throw CredentialOfferError( + null, + credentialOffer, + CredentialOfferErrorCode.invalid_issuer, + "Could not resolve issuer provider metadata from $issuerMetadataUrl" + ) + if (issuerMetadata.deferredCredentialEndpoint.isNullOrEmpty()) throw CredentialOfferError( + null, + credentialOffer, + CredentialOfferErrorCode.invalid_issuer, + "No deferred credential endpoint found in issuer metadata" + ) + val deferredCredResp = httpSubmitForm(Url(issuerMetadata.deferredCredentialEndpoint), parametersOf(), headers { + append(HttpHeaders.Authorization, "Bearer ${credentialResponse.acceptanceToken}") + }) + if (!deferredCredResp.status.isSuccess() || deferredCredResp.body.isNullOrEmpty()) throw CredentialError( + null, + CredentialErrorCode.server_error, + "No credential received from deferred credential endpoint, or server responded with error status ${deferredCredResp.status}" + ) + return CredentialResponse.fromJSONString(deferredCredResp.body) + } - val tokenReq = TokenRequest(GrantType.authorization_code, client.clientID, config.redirectUri, code, codeVerifier = codeVerifier) + protected open fun executeAuthorizedIssuanceCodeFlow( + authorizationServerMetadata: OpenIDProviderMetadata, issuerMetadata: OpenIDProviderMetadata, + credentialOffer: CredentialOffer, + grantType: GrantType, offeredCredentials: List, holderDid: String, + client: OpenIDClientConfig, authorizationCode: String? = null, codeVerifier: String? = null, userPIN: String? = null + ): List { + val tokenReq = TokenRequest( + grantType, + client.clientID, + config.redirectUri, + authorizationCode, + credentialOffer.grants[grantType.value]?.preAuthorizedCode, + userPIN, + codeVerifier + ) val tokenHttpResp = httpSubmitForm(Url(authorizationServerMetadata.tokenEndpoint!!), parametersOf(tokenReq.toHttpParameters())) - if(!tokenHttpResp.status.isSuccess() || tokenHttpResp.body == null) throw TokenError(tokenReq, TokenErrorCode.server_error, "Server returned error code ${tokenHttpResp.status}, or empty body") + if (!tokenHttpResp.status.isSuccess() || tokenHttpResp.body == null) throw TokenError( + tokenReq, + TokenErrorCode.server_error, + "Server returned error code ${tokenHttpResp.status}, or empty body" + ) val tokenResp = TokenResponse.fromJSONString(tokenHttpResp.body) - if(tokenResp.accessToken == null) throw TokenError(tokenReq, TokenErrorCode.server_error, "No access token returned by server") + if (tokenResp.accessToken == null) throw TokenError(tokenReq, TokenErrorCode.server_error, "No access token returned by server") var nonce = tokenResp.cNonce - return if(issuerMetadata.batchCredentialEndpoint.isNullOrEmpty() || offeredCredentials.size == 1) { + return if (issuerMetadata.batchCredentialEndpoint.isNullOrEmpty() || offeredCredentials.size == 1) { // execute credential requests individually offeredCredentials.map { offeredCredential -> - val credReq = CredentialRequest.forOfferedCredential(offeredCredential, generateDidProof(holderDid, credentialOffer.credentialIssuer, nonce, client)) + val credReq = CredentialRequest.forOfferedCredential( + offeredCredential, + generateDidProof(holderDid, credentialOffer.credentialIssuer, nonce, client) + ) executeCredentialRequest( - issuerMetadata.credentialEndpoint ?: throw CredentialError(credReq, CredentialErrorCode.server_error, "No credential endpoint specified in issuer metadata"), - tokenResp.accessToken, credReq).also { - nonce = it.cNonce ?: nonce + issuerMetadata.credentialEndpoint ?: throw CredentialError( + credReq, + CredentialErrorCode.server_error, + "No credential endpoint specified in issuer metadata" + ), + tokenResp.accessToken, credReq + ).also { + nonce = it.cNonce ?: nonce } } } else { @@ -252,26 +382,50 @@ abstract class OpenIDCredentialWallet( } } - protected open fun executeBatchCredentialRequest(batchEndpoint: String, accessToken: String, credentialRequests: List): List { + protected open fun executeBatchCredentialRequest( + batchEndpoint: String, + accessToken: String, + credentialRequests: List + ): List { val req = BatchCredentialRequest(credentialRequests) - val httpResp = httpPostObject(Url(batchEndpoint), req.toJSON(), Headers.build { set(HttpHeaders.Authorization, "Bearer $accessToken") }) - if(!httpResp.status.isSuccess() || httpResp.body == null) throw BatchCredentialError(req, CredentialErrorCode.server_error, "Batch credential endpoint returned error status ${httpResp.status}, or body is empty") + val httpResp = + httpPostObject(Url(batchEndpoint), req.toJSON(), Headers.build { set(HttpHeaders.Authorization, "Bearer $accessToken") }) + if (!httpResp.status.isSuccess() || httpResp.body == null) throw BatchCredentialError( + req, + CredentialErrorCode.server_error, + "Batch credential endpoint returned error status ${httpResp.status}, or body is empty" + ) return BatchCredentialResponse.fromJSONString(httpResp.body).credentialResponses ?: listOf() } - protected open fun executeCredentialRequest(credentialEndpoint: String, accessToken: String, credentialRequest: CredentialRequest): CredentialResponse { - val httpResp = httpPostObject(Url(credentialEndpoint), credentialRequest.toJSON(), Headers.build { set(HttpHeaders.Authorization, "Bearer $accessToken") }) - if(!httpResp.status.isSuccess() || httpResp.body == null) throw CredentialError(credentialRequest, CredentialErrorCode.server_error, "Credential error returned error status ${httpResp.status}, or body is empty") + protected open fun executeCredentialRequest( + credentialEndpoint: String, + accessToken: String, + credentialRequest: CredentialRequest + ): CredentialResponse { + val httpResp = httpPostObject( + Url(credentialEndpoint), + credentialRequest.toJSON(), + Headers.build { set(HttpHeaders.Authorization, "Bearer $accessToken") }) + if (!httpResp.status.isSuccess() || httpResp.body == null) throw CredentialError( + credentialRequest, + CredentialErrorCode.server_error, + "Credential error returned error status ${httpResp.status}, or body is empty" + ) return CredentialResponse.fromJSONString(httpResp.body) } - @OptIn(ExperimentalJsExport::class) protected open fun executeIdTokenAuthorization(idTokenRequestUri: Url, holderDid: String, client: OpenIDClientConfig): Url { var authReq = AuthorizationRequest.fromHttpQueryString(idTokenRequestUri.encodedQuery).let { authorizationRequest -> - authorizationRequest.customParameters["request"]?.let { AuthorizationJSONRequest.fromJSON(SDJwt.parse(it.first()).fullPayload) } ?: authorizationRequest + authorizationRequest.customParameters["request"]?.let { AuthorizationJSONRequest.fromJSON(SDJwt.parse(it.first()).fullPayload) } + ?: authorizationRequest } - if(authReq.responseMode != ResponseMode.direct_post || authReq.responseType != ResponseType.id_token.name || authReq.redirectUri.isNullOrEmpty()) - throw AuthorizationError(authReq, AuthorizationErrorCode.server_error, "Unexpected response_mode ${authReq.responseMode}, or response_type ${authReq.responseType} returned from server, or redirect_uri is missing") + if (authReq.responseMode != ResponseMode.direct_post || authReq.responseType != ResponseType.id_token.name || authReq.redirectUri.isNullOrEmpty()) + throw AuthorizationError( + authReq, + AuthorizationErrorCode.server_error, + "Unexpected response_mode ${authReq.responseMode}, or response_type ${authReq.responseType} returned from server, or redirect_uri is missing" + ) val keyId = resolveDID(holderDid) val idToken = signToken(TokenTarget.TOKEN, buildJsonObject { @@ -283,10 +437,36 @@ abstract class OpenIDCredentialWallet( put("state", authReq.state) put("nonce", authReq.nonce) }, keyId = keyId) - val httpResp = httpSubmitForm(Url(authReq.redirectUri!!), parametersOf(Pair("id_token", listOf(idToken)), Pair("state", listOf(authReq.state!!)))) - if(httpResp.status != HttpStatusCode.Found) throw AuthorizationError(authReq, AuthorizationErrorCode.server_error, "Unexpected status code ${httpResp.status} returned from server for id_token response") + val httpResp = httpSubmitForm( + Url(authReq.redirectUri!!), + parametersOf(Pair("id_token", listOf(idToken)), Pair("state", listOf(authReq.state!!))) + ) + if (httpResp.status != HttpStatusCode.Found) throw AuthorizationError( + authReq, + AuthorizationErrorCode.server_error, + "Unexpected status code ${httpResp.status} returned from server for id_token response" + ) return httpResp.headers[HttpHeaders.Location]?.let { Url(it) } - ?: throw AuthorizationError(authReq, AuthorizationErrorCode.server_error, "Location parameter missing on http response for id_token response") + ?: throw AuthorizationError( + authReq, + AuthorizationErrorCode.server_error, + "Location parameter missing on http response for id_token response" + ) + } + + open fun executeVpTokenAuthorization(vpTokenRequestUri: Url, holderDid: String, client: OpenIDClientConfig): Url { + val authReq = AuthorizationRequest.fromHttpQueryString(vpTokenRequestUri.encodedQuery) + val tokenResp = processImplicitFlowAuthorization( + authReq.copy( + clientId = client.clientID, + ) + ) + val httpResp = httpSubmitForm(Url(authReq.responseUri ?: authReq.redirectUri!!), parametersOf(tokenResp.toHttpParameters())) + return when (httpResp.status) { + HttpStatusCode.Found -> httpResp.headers[HttpHeaders.Location] + HttpStatusCode.OK -> httpResp.body?.let { AuthorizationDirectPostResponse.fromJSONString(it) }?.redirectUri + else -> null + }?.let { Url(it) } ?: throw AuthorizationError(authReq, AuthorizationErrorCode.invalid_request, "Request could not be executed") } } diff --git a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/requests/AuthorizationJSONRequest.kt b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/requests/AuthorizationJSONRequest.kt index a7983e19c..119ea1706 100644 --- a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/requests/AuthorizationJSONRequest.kt +++ b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/requests/AuthorizationJSONRequest.kt @@ -10,20 +10,20 @@ import kotlinx.serialization.json.jsonObject @Serializable data class AuthorizationJSONRequest( - @SerialName("response_type") override val responseType: String = ResponseType.getResponseTypeString(ResponseType.code), - @SerialName("client_id") override val clientId: String, - @SerialName("response_mode") override val responseMode: ResponseMode? = null, - @SerialName("redirect_uri") override val redirectUri: String? = null, - override val scope: Set = setOf(), - override val state: String? = null, - override val nonce: String? = null, - override val customParameters: Map = mapOf() -): JsonDataObject(), IAuthorizationRequest { - override fun toJSON() = Json.encodeToJsonElement(AuthorizationJSONRequestSerializer, this).jsonObject + @SerialName("response_type") override val responseType: String = ResponseType.getResponseTypeString(ResponseType.code), + @SerialName("client_id") override val clientId: String, + @SerialName("response_mode") override val responseMode: ResponseMode? = null, + @SerialName("redirect_uri") override val redirectUri: String? = null, + override val scope: Set = setOf(), + override val state: String? = null, + override val nonce: String? = null, + override val customParameters: Map = mapOf() +) : JsonDataObject(), IAuthorizationRequest { + override fun toJSON() = Json.encodeToJsonElement(AuthorizationJSONRequestSerializer, this).jsonObject - companion object: JsonDataObjectFactory() { - override fun fromJSON(jsonObject: JsonObject) = Json.decodeFromJsonElement(AuthorizationJSONRequestSerializer, jsonObject) - } + companion object : JsonDataObjectFactory() { + override fun fromJSON(jsonObject: JsonObject) = Json.decodeFromJsonElement(AuthorizationJSONRequestSerializer, jsonObject) + } } -object AuthorizationJSONRequestSerializer: JsonDataObjectSerializer(AuthorizationJSONRequest.serializer()) +object AuthorizationJSONRequestSerializer : JsonDataObjectSerializer(AuthorizationJSONRequest.serializer()) diff --git a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/responses/CredentialResponse.kt b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/responses/CredentialResponse.kt index 58387d9b3..47260a857 100644 --- a/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/responses/CredentialResponse.kt +++ b/waltid-openid4vc/src/commonMain/kotlin/id/walt/oid4vc/responses/CredentialResponse.kt @@ -26,8 +26,8 @@ data class CredentialResponse private constructor( @SerialName("error_uri") val errorUri: String? = null, override val customParameters: Map = mapOf() ) : JsonDataObject() { - val isSuccess get() = format != null - val isDeferred get() = isSuccess && credential == null + val isSuccess get() = (format != null && credential != null) || isDeferred + val isDeferred get() = acceptanceToken != null override fun toJSON() = Json.encodeToJsonElement(CredentialResponseSerializer, this).jsonObject companion object : JsonDataObjectFactory() { diff --git a/waltid-openid4vc/src/jvmMain/kotlin/id/walt/oid4vc/util/UUID.jvm.kt b/waltid-openid4vc/src/jvmMain/kotlin/id/walt/oid4vc/util/UUID.jvm.kt index e34a8ba2e..913fb32bb 100644 --- a/waltid-openid4vc/src/jvmMain/kotlin/id/walt/oid4vc/util/UUID.jvm.kt +++ b/waltid-openid4vc/src/jvmMain/kotlin/id/walt/oid4vc/util/UUID.jvm.kt @@ -7,5 +7,4 @@ actual fun randomUUID(): String { return UUID.randomUUID().toString() } -actual fun sha256(data: ByteArray): ByteArray - = MessageDigest.getInstance("SHA-256").digest(data) \ No newline at end of file +actual fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data) diff --git a/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/CITestProvider.kt b/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/CITestProvider.kt index 913a8cb1b..b66ae3013 100644 --- a/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/CITestProvider.kt +++ b/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/CITestProvider.kt @@ -41,13 +41,12 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonPrimitive -import kotlin.js.ExperimentalJsExport import kotlin.time.Duration.Companion.minutes const val CI_PROVIDER_PORT = 8000 const val CI_PROVIDER_BASE_URL = "http://localhost:$CI_PROVIDER_PORT" -class CITestProvider() : OpenIDCredentialIssuer( +class CITestProvider : OpenIDCredentialIssuer( baseUrl = CI_PROVIDER_BASE_URL, config = CredentialIssuerConfig( credentialsSupported = listOf( @@ -83,7 +82,6 @@ class CITestProvider() : OpenIDCredentialIssuer( override fun signToken(target: TokenTarget, payload: JsonObject, header: JsonObject?, keyId: String?) = JwtService.getService().sign(keyId ?: CI_TOKEN_KEY.id, payload.toString()) - @OptIn(ExperimentalJsExport::class) override fun verifyTokenSignature(target: TokenTarget, token: String) = JwtService.getService().verify(token).verified @@ -142,137 +140,133 @@ class CITestProvider() : OpenIDCredentialIssuer( fun start() { embeddedServer(Netty, port = CI_PROVIDER_PORT) { - webOidcApi() - }.start() - } - - private fun Application.webOidcApi() { - install(ContentNegotiation) { - json() - } - routing { - get("/.well-known/openid-configuration") { - call.respond(metadata.toJSON()) - } - get("/.well-known/openid-credential-issuer") { - call.respond(metadata.toJSON()) + install(ContentNegotiation) { + json() } - post("/par") { - val authReq = AuthorizationRequest.fromHttpParameters(call.receiveParameters().toMap()) - try { - val session = initializeAuthorization(authReq, 5.minutes) - call.respond(getPushedAuthorizationSuccessResponse(session).toJSON()) - } catch (exc: AuthorizationError) { - call.respond(HttpStatusCode.BadRequest, exc.toPushedAuthorizationErrorResponse().toJSON()) + routing { + get("/.well-known/openid-configuration") { + call.respond(metadata.toJSON()) } - } - get("/authorize") { - val authReq = AuthorizationRequest.fromHttpParameters(call.parameters.toMap()) - try { - val authResp = if (authReq.responseType == ResponseType.code.name) { - processCodeFlowAuthorization(authReq) - } else if (authReq.responseType.contains(ResponseType.token.name)) { - processImplicitFlowAuthorization(authReq) - } else { - throw AuthorizationError( - authReq, - AuthorizationErrorCode.unsupported_response_type, - "Response type not supported" - ) + get("/.well-known/openid-credential-issuer") { + call.respond(metadata.toJSON()) + } + post("/par") { + val authReq = AuthorizationRequest.fromHttpParameters(call.receiveParameters().toMap()) + try { + val session = initializeAuthorization(authReq, 5.minutes) + call.respond(getPushedAuthorizationSuccessResponse(session).toJSON()) + } catch (exc: AuthorizationError) { + call.respond(HttpStatusCode.BadRequest, exc.toPushedAuthorizationErrorResponse().toJSON()) } - val redirectUri = if (authReq.isReferenceToPAR) { - getPushedAuthorizationSession(authReq).authorizationRequest?.redirectUri - } else { - authReq.redirectUri - } ?: throw AuthorizationError( - authReq, - AuthorizationErrorCode.invalid_request, - "No redirect_uri found for this authorization request" - ) - call.response.apply { - status(HttpStatusCode.Found) - val defaultResponseMode = - if (authReq.responseType == ResponseType.code.name) ResponseMode.query else ResponseMode.fragment - header( - HttpHeaders.Location, - authResp.toRedirectUri(redirectUri, authReq.responseMode ?: defaultResponseMode) + } + get("/authorize") { + val authReq = AuthorizationRequest.fromHttpParameters(call.parameters.toMap()) + try { + val authResp = if (authReq.responseType == ResponseType.code.name) { + processCodeFlowAuthorization(authReq) + } else if (authReq.responseType.contains(ResponseType.token.name)) { + processImplicitFlowAuthorization(authReq) + } else { + throw AuthorizationError( + authReq, + AuthorizationErrorCode.unsupported_response_type, + "Response type not supported" + ) + } + val redirectUri = if (authReq.isReferenceToPAR) { + getPushedAuthorizationSession(authReq).authorizationRequest?.redirectUri + } else { + authReq.redirectUri + } ?: throw AuthorizationError( + authReq, + AuthorizationErrorCode.invalid_request, + "No redirect_uri found for this authorization request" ) - } - } catch (authExc: AuthorizationError) { - call.response.apply { - status(HttpStatusCode.Found) - header(HttpHeaders.Location, URLBuilder(authExc.authorizationRequest.redirectUri!!).apply { - parameters.appendAll( - parametersOf( - authExc.toAuthorizationErrorResponse().toHttpParameters() - ) + call.response.apply { + status(HttpStatusCode.Found) + val defaultResponseMode = + if (authReq.responseType == ResponseType.code.name) ResponseMode.query else ResponseMode.fragment + header( + HttpHeaders.Location, + authResp.toRedirectUri(redirectUri, authReq.responseMode ?: defaultResponseMode) ) - }.buildString()) + } + } catch (authExc: AuthorizationError) { + call.response.apply { + status(HttpStatusCode.Found) + header(HttpHeaders.Location, URLBuilder(authExc.authorizationRequest.redirectUri!!).apply { + parameters.appendAll( + parametersOf( + authExc.toAuthorizationErrorResponse().toHttpParameters() + ) + ) + }.buildString()) + } } } - } - post("/token") { - val params = call.receiveParameters().toMap() - val tokenReq = TokenRequest.fromHttpParameters(params) - try { - val tokenResp = processTokenRequest(tokenReq) - call.respond(tokenResp.toJSON()) - } catch (exc: TokenError) { - call.respond(HttpStatusCode.BadRequest, exc.toAuthorizationErrorResponse().toJSON()) - } - } - post("/credential") { - val accessToken = call.request.header(HttpHeaders.Authorization)?.substringAfter(" ") - if (accessToken.isNullOrEmpty() || !verifyTokenSignature(TokenTarget.ACCESS, accessToken)) { - call.respond(HttpStatusCode.Unauthorized) - } else { - val credReq = CredentialRequest.fromJSON(call.receive()) + post("/token") { + val params = call.receiveParameters().toMap() + val tokenReq = TokenRequest.fromHttpParameters(params) try { - call.respond(generateCredentialResponse(credReq, accessToken).toJSON()) - } catch (exc: CredentialError) { - call.respond(HttpStatusCode.BadRequest, exc.toCredentialErrorResponse().toJSON()) + val tokenResp = processTokenRequest(tokenReq) + call.respond(tokenResp.toJSON()) + } catch (exc: TokenError) { + call.respond(HttpStatusCode.BadRequest, exc.toAuthorizationErrorResponse().toJSON()) } } - } - post("/credential_deferred") { - val accessToken = call.request.header(HttpHeaders.Authorization)?.substringAfter(" ") - if (accessToken.isNullOrEmpty() || !verifyTokenSignature( - TokenTarget.DEFERRED_CREDENTIAL, - accessToken - ) - ) { - call.respond(HttpStatusCode.Unauthorized) - } else { - try { - call.respond(generateDeferredCredentialResponse(accessToken).toJSON()) - } catch (exc: DeferredCredentialError) { - call.respond(HttpStatusCode.BadRequest, exc.toCredentialErrorResponse().toJSON()) + post("/credential") { + val accessToken = call.request.header(HttpHeaders.Authorization)?.substringAfter(" ") + if (accessToken.isNullOrEmpty() || !verifyTokenSignature(TokenTarget.ACCESS, accessToken)) { + call.respond(HttpStatusCode.Unauthorized) + } else { + val credReq = CredentialRequest.fromJSON(call.receive()) + try { + call.respond(generateCredentialResponse(credReq, accessToken).toJSON()) + } catch (exc: CredentialError) { + call.respond(HttpStatusCode.BadRequest, exc.toCredentialErrorResponse().toJSON()) + } } } - } - post("/batch_credential") { - val accessToken = call.request.header(HttpHeaders.Authorization)?.substringAfter(" ") - if (accessToken.isNullOrEmpty() || !verifyTokenSignature(TokenTarget.ACCESS, accessToken)) { - call.respond(HttpStatusCode.Unauthorized) - } else { - val req = BatchCredentialRequest.fromJSON(call.receive()) - try { - call.respond(generateBatchCredentialResponse(req, accessToken).toJSON()) - } catch (exc: BatchCredentialError) { - call.respond(HttpStatusCode.BadRequest, exc.toBatchCredentialErrorResponse().toJSON()) + post("/credential_deferred") { + val accessToken = call.request.header(HttpHeaders.Authorization)?.substringAfter(" ") + if (accessToken.isNullOrEmpty() || !verifyTokenSignature( + TokenTarget.DEFERRED_CREDENTIAL, + accessToken + ) + ) { + call.respond(HttpStatusCode.Unauthorized) + } else { + try { + call.respond(generateDeferredCredentialResponse(accessToken).toJSON()) + } catch (exc: DeferredCredentialError) { + call.respond(HttpStatusCode.BadRequest, exc.toCredentialErrorResponse().toJSON()) + } } } - } - get("/credential_offer/{session_id}") { - val sessionId = call.parameters["session_id"]!! - val credentialOffer = getSession(sessionId)?.credentialOffer - if (credentialOffer != null) { - call.respond(HttpStatusCode.Created, credentialOffer.toJSON()) - } else { - call.respond(HttpStatusCode.NotFound, "Issuance session with given ID not found") + post("/batch_credential") { + val accessToken = call.request.header(HttpHeaders.Authorization)?.substringAfter(" ") + if (accessToken.isNullOrEmpty() || !verifyTokenSignature(TokenTarget.ACCESS, accessToken)) { + call.respond(HttpStatusCode.Unauthorized) + } else { + val req = BatchCredentialRequest.fromJSON(call.receive()) + try { + call.respond(generateBatchCredentialResponse(req, accessToken).toJSON()) + } catch (exc: BatchCredentialError) { + call.respond(HttpStatusCode.BadRequest, exc.toBatchCredentialErrorResponse().toJSON()) + } + } + } + get("/credential_offer/{session_id}") { + val sessionId = call.parameters["session_id"]!! + val credentialOffer = getSession(sessionId)?.credentialOffer + if (credentialOffer != null) { + call.respond(HttpStatusCode.Created, credentialOffer.toJSON()) + } else { + call.respond(HttpStatusCode.NotFound, "Issuance session with given ID not found") + } } } - } + }.start() } } diff --git a/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt b/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt index 0fc5d230c..a033649ff 100644 --- a/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt +++ b/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/CI_JVM_Test.kt @@ -943,7 +943,8 @@ class CI_JVM_Test : AnnotationSpec() { println("// as WALLET: receive credential offer, either being called via deeplink or by scanning QR code") println("// parse credential URI") - val credentialOffer = credentialWallet.getCredentialOffer(CredentialOfferRequest.fromHttpParameters(Url(offerUri).parameters.toMap())) + val credentialOffer = + credentialWallet.resolveCredentialOffer(CredentialOfferRequest.fromHttpParameters(Url(offerUri).parameters.toMap())) credentialOffer.credentialIssuer shouldNotBe null credentialOffer.grants.keys shouldContain GrantType.pre_authorized_code.value diff --git a/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt b/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt index efeb40fd5..2973a40ba 100644 --- a/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt +++ b/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/EBSITestWallet.kt @@ -32,8 +32,11 @@ import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.datetime.Instant +import kotlinx.datetime.toJavaInstant import kotlinx.serialization.json.* +import java.util.* import kotlin.js.ExperimentalJsExport +import kotlin.time.Duration.Companion.days const val EBSI_WALLET_PORT = 8011 const val EBSI_WALLET_BASE_URL = "http://localhost:${EBSI_WALLET_PORT}" @@ -42,8 +45,9 @@ const val EBSI_WALLET_TEST_KEY = const val EBSI_WALLET_TEST_DID = "did:key:z2dmzD81cgPx8Vki7JbuuMmFYrWPgYoytykUZ3eyqht1j9KbrksdXfcbvmhgF2h7YfpxWuywkXxDZ7ohTPNPTQpD39Rm9WiBWuEpvvgtfuPHtHi2wTEkZ95KC2ijUMUowyKMueaMhtA5bLYkt9k8Y8Gq4sm6PyTCHTxuyedMMrBKdRXNZS" -class EBSITestWallet(config: CredentialWalletConfig) : - OpenIDCredentialWallet(EBSI_WALLET_BASE_URL, config) { +class EBSITestWallet( + config: CredentialWalletConfig +) : OpenIDCredentialWallet(EBSI_WALLET_BASE_URL, config) { private val sessionCache = mutableMapOf() private val ktorClient = HttpClient(Java) { install(ContentNegotiation) { @@ -64,6 +68,7 @@ class EBSITestWallet(config: CredentialWalletConfig) : KeyService.getService().addAlias(keyId, EBSI_WALLET_TEST_DID) val didDoc = DidService.resolve(EBSI_WALLET_TEST_DID) KeyService.getService().addAlias(keyId, didDoc.verificationMethod!!.first().id) + DidService.importDid(EBSI_WALLET_TEST_DID) } } @@ -137,16 +142,10 @@ class EBSITestWallet(config: CredentialWalletConfig) : headers { headers?.let { appendAll(it) } } - //parameters { - // appendAll(formParameters) - //} - }.let { httpResponse -> - SimpleHttpResponse( - httpResponse.status, - httpResponse.headers, - httpResponse.bodyAsText() - ) - } + parameters { + appendAll(formParameters) + } + }.let { httpResponse -> SimpleHttpResponse(httpResponse.status, httpResponse.headers, httpResponse.bodyAsText()) } } } @@ -156,7 +155,10 @@ class EBSITestWallet(config: CredentialWalletConfig) : tokenRequest, session.presentationDefinition ) - val filterString = presentationDefinition.inputDescriptors.flatMap { it.constraints?.fields ?: listOf() } + + /* + * Pre-merge: + val filterString = presentationDefinition.inputDescriptors.flatMap { it.constraints?.fields ?: listOf() } .firstOrNull { field -> field.path.any { it.contains("type") } }?.filter?.jsonObject.toString() val presentationJwtStr = Custodian.getService() .createPresentation( @@ -169,43 +171,87 @@ class EBSITestWallet(config: CredentialWalletConfig) : }, TEST_DID, challenge = session.nonce ) + */ + + val credentialDescriptorMapping = mapCredentialTypes(presentationDefinition) + val presentationJwtStr = generatePresentationJwt(credentialDescriptorMapping.map { it.credential }, session) + println("================") println("PRESENTATION IS: $presentationJwtStr") println("================") val presentationJws = presentationJwtStr.decodeJws() - val jwtCredentials = - ((presentationJws.payload["vp"] - ?: throw IllegalArgumentException("VerifiablePresentation string does not contain `vp` attribute?")) - .jsonObject["verifiableCredential"] - ?: throw IllegalArgumentException("VerifiablePresentation does not contain verifiableCredential list?")) - .jsonArray.map { it.jsonPrimitive.content } + val jwtCredentials = ((presentationJws.payload["vp"] + ?: throw IllegalArgumentException("VerifiablePresentation string does not contain `vp` attribute?")).jsonObject["verifiableCredential"] + ?: throw IllegalArgumentException("VerifiablePresentation does not contain verifiableCredential list?")).jsonArray.map { it.jsonPrimitive.content } return PresentationResult( - listOf(JsonPrimitive(presentationJwtStr)), PresentationSubmission( - id = "submission 1", + presentations = listOf(JsonPrimitive(presentationJwtStr)), + presentationSubmission = PresentationSubmission( + id = UUID.randomUUID().toString(), definitionId = session.presentationDefinition!!.id, - descriptorMap = jwtCredentials.map { vcJwsStr -> - - val vcJws = vcJwsStr.decodeJws() - val type = - vcJws.payload["vc"]?.jsonObject?.get("type")?.jsonArray?.last()?.jsonPrimitive?.contentOrNull - ?: "VerifiableCredential" - - DescriptorMapping( - id = type, - format = VCFormat.jwt_vp, // jwt_vp_json - path = "$", - pathNested = DescriptorMapping( - format = VCFormat.jwt_vc, - path = "$.vp.verifiableCredential[0]", - ) - ) - } + descriptorMap = getDescriptorMap(jwtCredentials, credentialDescriptorMapping) ) ) } override fun putSession(id: String, session: SIOPSession): SIOPSession? = sessionCache.put(id, session) + private fun generatePresentationJwt(credentialTypes: List, session: SIOPSession): String = + let { + val presentationJwtStr = Custodian.getService().createPresentation( + vcs = Custodian.getService().listCredentials() + .filter { c -> credentialTypes.contains(c.type.last()) } + .map { + PresentableCredential( + verifiableCredential = it, + selectiveDisclosure = null, + discloseAll = false + ) + }, + holderDid = TEST_DID, + verifierDid = "https://api-conformance.ebsi.eu/conformance/v3/auth-mock", + expirationDate = kotlinx.datetime.Clock.System.now().plus(1.days).toJavaInstant(), + challenge = session.nonce, + ) + presentationJwtStr + } + + private fun mapCredentialTypes(presentationDefinition: PresentationDefinition) = + presentationDefinition.inputDescriptors.flatMap { descriptor -> + descriptor.constraints?.fields?.mapNotNull { field -> + field.takeIf { it.path.any { it.contains("type") } } + }?.mapNotNull { + it.filter?.jsonObject?.get("contains")?.jsonObject?.jsonObject?.get("const")?.jsonPrimitive?.content?.let { + CredentialDescriptorMapping(it, descriptor.id) + } + } ?: emptyList() + } + + private fun getDescriptorMap( + jwtCredentials: List, credentialDescriptor: List + ): List = jwtCredentials.mapIndexedNotNull { index, vc -> + vc.decodeJws().let { + it.payload["vc"]?.jsonObject?.get("type")?.jsonArray?.last()?.jsonPrimitive?.contentOrNull + ?: "VerifiableCredential" + }.let { c -> + credentialDescriptor.find { it.credential == c } + }?.let { + DescriptorMapping( + id = it.descriptor, + format = VCFormat.jwt_vp, // jwt_vp_json + path = "$", + pathNested = DescriptorMapping( + id = it.descriptor, + format = VCFormat.jwt_vc, + path = "$.vp.verifiableCredential[$index]", + ) + ) + } + } + + private data class CredentialDescriptorMapping( + val credential: String, + val descriptor: String, + ) } diff --git a/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/EBSI_Conformance_Test.kt b/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/EBSI_Conformance_Test.kt index 35f3dffe0..5b5434a73 100644 --- a/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/EBSI_Conformance_Test.kt +++ b/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/EBSI_Conformance_Test.kt @@ -1,12 +1,18 @@ package id.walt.oid4vc +import id.walt.credentials.w3c.VerifiableCredential +import id.walt.custodian.Custodian import id.walt.oid4vc.providers.CredentialWalletConfig import id.walt.oid4vc.providers.OpenIDClientConfig import id.walt.oid4vc.requests.CredentialOfferRequest +import id.walt.oid4vc.responses.CredentialResponse +import id.walt.oid4vc.util.randomUUID import id.walt.servicematrix.ServiceMatrix import io.kotest.common.runBlocking -import io.kotest.core.spec.style.AnnotationSpec -import io.kotest.core.spec.style.Test +import io.kotest.core.spec.style.StringSpec +import io.kotest.data.forAll +import io.kotest.data.row +import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.ktor.client.* import io.ktor.client.engine.java.* @@ -15,31 +21,155 @@ import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* +import io.ktor.util.* +import kotlinx.serialization.json.jsonPrimitive -class EBSI_Conformance_Test: AnnotationSpec() { +class EBSI_Conformance_Test : StringSpec({ - lateinit var credentialWallet: EBSITestWallet + val VcTestsEnabled = false + val VpTestsEnabled = false + val PRE_AUTHORIZED_ISSUANCE_PIN = "3818" - val ktorClient = HttpClient(Java) { - install(ContentNegotiation) { - json() + val credentialOfferUrl = "https://api-conformance.ebsi.eu/conformance/v3/issuer-mock/initiate-credential-offer?credential_type=" + + lateinit var credentialWallet: EBSITestWallet + lateinit var ebsiClientConfig: OpenIDClientConfig + + val ktorClient = HttpClient(Java) { + install(ContentNegotiation) { + json() + } + followRedirects = false } - followRedirects = false - } - @BeforeAll - fun init() { - ServiceMatrix("test-config/service-matrix.properties") - credentialWallet = EBSITestWallet(CredentialWalletConfig("https://blank/")) - } + val crossDeviceCredentialOfferRequestCaller: credentialOfferRequestCaller = { initCredentialOfferUrl -> + val inTimeCredentialOfferRequestUri = runBlocking { ktorClient.get(initCredentialOfferUrl).bodyAsText() } + CredentialOfferRequest.fromHttpQueryString(Url(inTimeCredentialOfferRequestUri).encodedQuery) + } - @Test - fun testReceiveCredential() { - val initCredentialOfferUrl = "https://api-conformance.ebsi.eu/conformance/v3/issuer-mock/initiate-credential-offer?credential_type=CTWalletCrossInTime&client_id=${credentialWallet.TEST_DID}&credential_offer_endpoint=openid-credential-offer://" - val inTimeCredentialOfferRequestUri = runBlocking { ktorClient.get(Url(initCredentialOfferUrl)).bodyAsText() } - val credentialOfferRequest = CredentialOfferRequest.fromHttpQueryString(Url(inTimeCredentialOfferRequestUri).encodedQuery) + val sameDeviceCredentialOfferRequestCaller: credentialOfferRequestCaller = { initCredentialOfferUrl -> + val httpResp = runBlocking { ktorClient.get(initCredentialOfferUrl) } + httpResp.status shouldBe HttpStatusCode.Found + val inTimeCredentialOfferRequestUri = httpResp.headers[HttpHeaders.Location]!! + CredentialOfferRequest.fromHttpQueryString(Url(inTimeCredentialOfferRequestUri).encodedQuery) + } - val credentialResponses = credentialWallet.executeFullAuthIssuance(credentialOfferRequest, credentialWallet.TEST_DID, OpenIDClientConfig(credentialWallet.TEST_DID, null, credentialWallet.config.redirectUri, useCodeChallenge = true)) - credentialResponses.size shouldNotBe 0 - } + beforeSpec { + ServiceMatrix("service-matrix.properties") + credentialWallet = EBSITestWallet(CredentialWalletConfig("https://blank/")) + ebsiClientConfig = OpenIDClientConfig(credentialWallet.TEST_DID, null, credentialWallet.config.redirectUri, useCodeChallenge = true) + VcTestsEnabled.takeIf { it }?.run { + Custodian.getService().listCredentialIds().forEach { + Custodian.getService().deleteCredential(it) + } + } + } + + /** + * CTWalletCrossInTime, CTWalletSameInTime + */ + "issue in-time credential".config(enabled = VcTestsEnabled) { + forAll( + row("${credentialOfferUrl}CTWalletSameInTime", credentialWallet.TEST_DID, sameDeviceCredentialOfferRequestCaller), + row("${credentialOfferUrl}CTWalletCrossInTime", credentialWallet.TEST_DID, crossDeviceCredentialOfferRequestCaller), + ) { url, clientId, credentialOfferRequestCall -> + val credentialOfferRequest = getCredentialOfferRequest(url, clientId, credentialOfferRequestCall) + val credentialOffer = credentialWallet.resolveCredentialOffer(credentialOfferRequest) + val credentialResponses = + credentialWallet.executeFullAuthIssuance(credentialOffer, credentialWallet.TEST_DID, ebsiClientConfig) + credentialResponses.size shouldBe 1 + credentialResponses[0].isDeferred shouldBe false + credentialResponses[0].credential shouldNotBe null + storeCredentials(credentialResponses[0]) + } + } + + /** + * CTWalletCrossDeferred, CTWalletSameDeferred + */ + "issue deferred credential".config(enabled = VcTestsEnabled) { + forAll( + row("${credentialOfferUrl}CTWalletSameDeferred", credentialWallet.TEST_DID, sameDeviceCredentialOfferRequestCaller), + row("${credentialOfferUrl}CTWalletCrossDeferred", credentialWallet.TEST_DID, crossDeviceCredentialOfferRequestCaller), + ) { url, clientId, credentialOfferRequestCall -> + val deferredCredentialOfferRequest = getCredentialOfferRequest(url, clientId, credentialOfferRequestCall) + val deferredCredentialOffer = credentialWallet.resolveCredentialOffer(deferredCredentialOfferRequest) + val deferredCredentialResponses = + credentialWallet.executeFullAuthIssuance(deferredCredentialOffer, credentialWallet.TEST_DID, ebsiClientConfig) + deferredCredentialResponses.size shouldBe 1 + deferredCredentialResponses[0].isDeferred shouldBe true + println("Waiting for deferred credential to be issued (5 seconds delay)") + Thread.sleep(5500) + println("Trying to fetch deferred credential") + val credentialResponse = + credentialWallet.fetchDeferredCredential(deferredCredentialOffer, deferredCredentialResponses[0]) + credentialResponse.isDeferred shouldBe false + credentialResponse.isSuccess shouldBe true + credentialResponse.credential shouldNotBe null + storeCredentials(credentialResponse) + } + } + + /** + * CTWalletCrossPreAuthorised, CTWalletSamePreAuthorised + */ + "issue pre-authorized code credential".config(enabled = VcTestsEnabled) { + forAll( + row("${credentialOfferUrl}CTWalletSamePreAuthorised", credentialWallet.TEST_DID, sameDeviceCredentialOfferRequestCaller), + row("${credentialOfferUrl}CTWalletCrossPreAuthorised", credentialWallet.TEST_DID, crossDeviceCredentialOfferRequestCaller), + ) { url, clientId, credentialOfferRequestCall -> + val preAuthCredentialOfferRequest = getCredentialOfferRequest(url, clientId, credentialOfferRequestCall) + val preAuthCredentialOffer = credentialWallet.resolveCredentialOffer(preAuthCredentialOfferRequest) + val preAuthCredentialResponses = credentialWallet.executePreAuthorizedCodeFlow( + preAuthCredentialOffer, credentialWallet.TEST_DID, ebsiClientConfig, PRE_AUTHORIZED_ISSUANCE_PIN + ) + preAuthCredentialResponses.size shouldBe 1 + preAuthCredentialResponses[0].isSuccess shouldBe true + preAuthCredentialResponses[0].credential shouldNotBe null + storeCredentials(preAuthCredentialResponses[0]) + } + } + + /** + * CTWalletQualificationCredential + * Requires all VCs from above + */ + "issue credential using presentation exchange".config(enabled = VpTestsEnabled) { + val initIssuanceWithPresentationExchangeUrl = + URLBuilder("https://api-conformance.ebsi.eu/conformance/v3/issuer-mock/initiate-credential-offer?credential_type=CTWalletQualificationCredential").run { + parameters.appendAll(StringValues.build { + append("client_id", credentialWallet.TEST_DID) + append("credential_offer_endpoint", "openid-credential-offer://") + }) + build() + } + val credentialOfferRequestUri = runBlocking { ktorClient.get(initIssuanceWithPresentationExchangeUrl).bodyAsText() } + val credentialOfferRequest = CredentialOfferRequest.fromHttpQueryString(Url(credentialOfferRequestUri).encodedQuery) + val credentialOffer = credentialWallet.resolveCredentialOffer(credentialOfferRequest) + val credentialResponses = credentialWallet.executeFullAuthIssuance(credentialOffer, credentialWallet.TEST_DID, ebsiClientConfig) + credentialResponses.size shouldBe 1 + credentialResponses[0].isDeferred shouldBe false + credentialResponses[0].credential shouldNotBe null + storeCredentials(credentialResponses[0]) + } +}) + +internal typealias credentialOfferRequestCaller = (initCredentialOfferUrl: Url) -> CredentialOfferRequest + +internal fun storeCredentials(vararg credentialResponses: CredentialResponse) = credentialResponses.forEach { + val cred = VerifiableCredential.fromString(it.credential!!.jsonPrimitive.content) + Custodian.getService().storeCredential(cred.id ?: randomUUID(), cred) } + +internal fun getCredentialOfferRequest( + url: String, clientId: String? = null, credentialOfferRequestCall: credentialOfferRequestCaller? = null +) = clientId?.let { + val initCredentialOfferUrl = URLBuilder(url).run { + parameters.appendAll(StringValues.build { + append("client_id", clientId) + append("credential_offer_endpoint", "openid-credential-offer://") + }) + build() + } + credentialOfferRequestCall!!(initCredentialOfferUrl) +} ?: CredentialOfferRequest(credentialOfferUri = url) diff --git a/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt b/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt index 5360807bb..1bcb75f11 100644 --- a/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt +++ b/waltid-openid4vc/src/jvmTest/kotlin/id/walt/oid4vc/TestCredentialWallet.kt @@ -74,39 +74,49 @@ class TestCredentialWallet( JwtService.getService().verify(token).verified override fun httpGet(url: Url, headers: Headers?): SimpleHttpResponse { - return runBlocking { ktorClient.get(url) { - headers { - headers?.let { appendAll(it) } - } - }.let { httpResponse -> SimpleHttpResponse(httpResponse.status, httpResponse.headers, httpResponse.bodyAsText()) } } + return runBlocking { + ktorClient.get(url) { + headers { + headers?.let { appendAll(it) } + } + }.let { httpResponse -> SimpleHttpResponse(httpResponse.status, httpResponse.headers, httpResponse.bodyAsText()) } + } } override fun httpPostObject(url: Url, jsonObject: JsonObject, headers: Headers?): SimpleHttpResponse { - return runBlocking { ktorClient.post(url) { - headers { - headers?.let { appendAll(it) } - } - contentType(ContentType.Application.Json) - setBody(jsonObject) - }.let { httpResponse -> SimpleHttpResponse(httpResponse.status, httpResponse.headers, httpResponse.bodyAsText()) } } + return runBlocking { + ktorClient.post(url) { + headers { + headers?.let { appendAll(it) } + } + contentType(ContentType.Application.Json) + setBody(jsonObject) + }.let { httpResponse -> SimpleHttpResponse(httpResponse.status, httpResponse.headers, httpResponse.bodyAsText()) } + } } override fun httpSubmitForm(url: Url, formParameters: Parameters, headers: Headers?): SimpleHttpResponse { - return runBlocking { ktorClient.submitForm { - url(url) - headers { - headers?.let { appendAll(it) } - } - parameters { - appendAll(formParameters) - } - }.let { httpResponse -> SimpleHttpResponse(httpResponse.status, httpResponse.headers, httpResponse.bodyAsText()) } } + return runBlocking { + ktorClient.submitForm { + url(url) + headers { + headers?.let { appendAll(it) } + } + parameters { + appendAll(formParameters) + } + }.let { httpResponse -> SimpleHttpResponse(httpResponse.status, httpResponse.headers, httpResponse.bodyAsText()) } + } } override fun generatePresentationForVPToken(session: SIOPSession, tokenRequest: TokenRequest): PresentationResult { // find credential(s) matching the presentation definition // for this test wallet implementation, present all credentials in the wallet - val presentationDefinition = session.presentationDefinition ?: throw PresentationError(TokenErrorCode.invalid_request, tokenRequest, session.presentationDefinition) + val presentationDefinition = session.presentationDefinition ?: throw PresentationError( + TokenErrorCode.invalid_request, + tokenRequest, + session.presentationDefinition + ) val filterString = presentationDefinition.inputDescriptors.flatMap { it.constraints?.fields ?: listOf() } .firstOrNull { field -> field.path.any { it.contains("type") } }?.filter?.jsonObject.toString() val presentationJwtStr = Custodian.getService() diff --git a/waltid-openid4vc/src/nativeMain/kotlin/id/walt/oid4vc/util/UUID.native.kt b/waltid-openid4vc/src/nativeMain/kotlin/id/walt/oid4vc/util/UUID.native.kt index 4cab81e11..95c097b8b 100644 --- a/waltid-openid4vc/src/nativeMain/kotlin/id/walt/oid4vc/util/UUID.native.kt +++ b/waltid-openid4vc/src/nativeMain/kotlin/id/walt/oid4vc/util/UUID.native.kt @@ -5,5 +5,5 @@ actual fun randomUUID(): String { } actual fun sha256(data: ByteArray): ByteArray { - TODO("Not yet implemented") -} \ No newline at end of file + TODO("Not yet implemented") +} diff --git a/waltid-openid4vc/test-config/signatory.conf b/waltid-openid4vc/test-config/signatory.conf index ef0508af0..2ef8f58f8 100644 --- a/waltid-openid4vc/test-config/signatory.conf +++ b/waltid-openid4vc/test-config/signatory.conf @@ -1,9 +1,9 @@ proofConfig { - issuerDid="todo" - issuerVerificationMethod="todo" - proofType="LD_PROOF" - domain="todo" - nonce="todo" - credentialsEndpoint="http://127.0.0.1:7001/v1/credentials" + issuerDid = "todo" + issuerVerificationMethod = "todo" + proofType = "LD_PROOF" + domain = "todo" + nonce = "todo" + credentialsEndpoint = "http://127.0.0.1:7001/v1/credentials" } templatesFolder: "vc-templates-runtime" diff --git a/waltid-reporting/build.gradle.kts b/waltid-reporting/build.gradle.kts index 719d1ea04..d800f7d5a 100644 --- a/waltid-reporting/build.gradle.kts +++ b/waltid-reporting/build.gradle.kts @@ -86,7 +86,7 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") } } - publishing { + publishing { repositories { maven { url = uri("https://maven.walt.id/repository/waltid/") diff --git a/waltid-reporting/src/commonMain/kotlin/id/walt/reporting/ReportingClient.kt b/waltid-reporting/src/commonMain/kotlin/id/walt/reporting/ReportingClient.kt index 5c80a86ba..667d915cb 100644 --- a/waltid-reporting/src/commonMain/kotlin/id/walt/reporting/ReportingClient.kt +++ b/waltid-reporting/src/commonMain/kotlin/id/walt/reporting/ReportingClient.kt @@ -1,9 +1,8 @@ package id.walt.reporting + import it.justwrote.kjob.InMem import it.justwrote.kjob.kjob -import it.justwrote.kjob.kron.Kron import it.justwrote.kjob.kron.KronModule -import java.time.Instant object ReportingClient { diff --git a/waltid-sdjwt/.github/workflows/build.yml b/waltid-sdjwt/.github/workflows/build.yml new file mode 100644 index 000000000..325275968 --- /dev/null +++ b/waltid-sdjwt/.github/workflows/build.yml @@ -0,0 +1,49 @@ +name: Build on every push + +on: + push: + branches: + - '*' + - '*/*' + - '**' + - '!main' + + +jobs: + build: + name: "Build" + runs-on: "ubuntu-latest" + steps: + - uses: actions/checkout@v3 + - name: Calculate release version + run: | + echo "release_version=1.$(date +'%g%m%d%H%M').$(echo ${{ github.ref_name }} | tr / -)" >> $GITHUB_ENV + - name: Set version + run: | + sed -i "s/1.SNAPSHOT/${{ env.release_version }}/g" build.gradle.kts src/commonMain/kotlin/id/walt/Values.kt + - run: | + git tag v${{ env.release_version }} + git push --tags + - name: Setup java + uses: actions/setup-java@v2.1.0 + with: + distribution: 'adopt-hotspot' + java-version: '16' + - name: Setup cache + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: Gradle wrapper validation + uses: gradle/wrapper-validation-action@v1 + - name: Running gradle build + uses: eskatos/gradle-command-action@v1.3.3 + env: + MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + with: + arguments: build publish --no-daemon \ No newline at end of file diff --git a/waltid-sdjwt/.github/workflows/lint-pr.yml b/waltid-sdjwt/.github/workflows/lint-pr.yml new file mode 100644 index 000000000..8420f4c26 --- /dev/null +++ b/waltid-sdjwt/.github/workflows/lint-pr.yml @@ -0,0 +1,66 @@ +name: "Lint PR" + +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +jobs: + main: + name: Validate PR + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: walt-id/commitlint-github-action@v4 + with: + noMerges: true + id: lint_commit_messages + continue-on-error: true + - name: semantic-pull-request + if: always() && !contains(fromJSON(steps.lint_commit_messages.outputs.results).*.valid, true) + id: lint_pr_title + uses: amannn/action-semantic-pull-request@v5.2.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - uses: marocchino/sticky-pull-request-comment@v2 + # When the previous steps fails, the workflow would stop. By adding this + # condition you can continue the execution with the populated error message. + if: always() && (steps.lint_pr_title.outputs.error_message != null || contains(fromJSON(steps.lint_commit_messages.outputs.results).*.valid, false)) + with: + header: pr-title-lint-error + message: | + Hey there and thank you for opening this pull request! 👋🏼 + + Some or all of your commit messages do not seem to follow the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/). + To allow the merge of this pull request, we require that at least one commit message or the PR title follow that convention. + + You have the following possibilities to proceed with this PR: + 1) Make sure at least one commit message complies with the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/). + Note, if not all messages comply, the merge will be allowed, but you will still see this warning. + 2) Update the title of this pull request to comply with the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/). + + Further information: + The commit messages and PR title will be parsed according to the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/), to automatically populate the release notes for the next official release. + Therefore, make sure the commit messages or PR title contain the necessary and relevant information describing the changes of this pull request. + + Commit message details: + + ``` + ${{ toJSON(fromJSON(steps.lint_commit_messages.outputs.results)) }} + ``` + + PR title details: + ``` + ${{ steps.lint_pr_title.outputs.error_message }} + ``` + + # Delete a previous comment when the issue has been resolved + - if: ${{ steps.lint_pr_title.outputs.error_message == null && !contains(fromJSON(steps.lint_commit_messages.outputs.results).*.valid, false) }} + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: pr-title-lint-error + delete: true diff --git a/waltid-sdjwt/.github/workflows/release.yml b/waltid-sdjwt/.github/workflows/release.yml new file mode 100644 index 000000000..6309c597a --- /dev/null +++ b/waltid-sdjwt/.github/workflows/release.yml @@ -0,0 +1,76 @@ +name: Release on push to main + +on: + push: + branches: + - main + +jobs: + release: + name: "Release" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Calculate release version + run: | + echo "release_version=1.$(date +'%g%m%d%H%M').0" >> $GITHUB_ENV + - name: Set version + run: | + sed -i "s/1.SNAPSHOT/${{ env.release_version }}/g" build.gradle.kts src/commonMain/kotlin/id/walt/Values.kt + - run: | + git tag v${{ env.release_version }} + git push --tags + - name: Setup cache + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: Gradle wrapper validation + uses: gradle/wrapper-validation-action@v1 + - name: Running gradle build + uses: eskatos/gradle-command-action@v1.3.3 + env: + MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + with: + arguments: build publish --no-daemon + - name: Changelog + uses: ardalanamini/auto-changelog@v3 + id: changelog + with: + github-token: ${{ github.token }} + commit-types: | + breaking: Breaking Changes + feat: New Features + fix: Bug Fixes + revert: Reverts + perf: Performance Improvements + refactor: Refactors + deps: Dependencies + docs: Documentation Changes + style: Code Style Changes + build: Build System + ci: Continuous Integration + test: Tests + chore: Chores + other: Other Changes + default-commit-type: Other Changes + release-name: v${{ env.release_version }} + mention-authors: true + mention-new-contributors: true + include-compare: true + semver: true + - name: Create Release + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: v${{ env.release_version }} + body: | + ${{ steps.changelog.outputs.changelog }} + prerelease: ${{ steps.changelog.outputs.prerelease }} diff --git a/waltid-sdjwt/.gitignore b/waltid-sdjwt/.gitignore new file mode 100644 index 000000000..1b8f8812c --- /dev/null +++ b/waltid-sdjwt/.gitignore @@ -0,0 +1,41 @@ +# Intellij +.idea/ +*.iml +*.iws +out + +# Gradle +.gradle +build/ + +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files # +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* +replay_pid* + +yarn.lock +/secret_maven_password.txt +/secret_maven_username.txt +/secret_npm_token.txt +/examples/js/node_modules/ +/examples/js/package-lock.json diff --git a/waltid-sdjwt/LICENSE b/waltid-sdjwt/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/waltid-sdjwt/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/waltid-sdjwt/README.md b/waltid-sdjwt/README.md new file mode 100644 index 000000000..4a0e44c7f --- /dev/null +++ b/waltid-sdjwt/README.md @@ -0,0 +1,393 @@ + + +## Getting Started + +* [Usage with Maven or Gradle (JVM)](#usage-with-maven-or-gradle-jvm) +* [Usage with NPM/NodeJs (JavaScript)](#usage-with-npmnodejs-javascript) +* [Sign SD-JWT tokens](#create-and-sign-an-sd-jwt-using-the-nimbusds-based-jwt-crypto-provider) +* [Present SD-JWT tokens with selection of disclosed and undisclosed payload fields](#present-an-sd-jwt) +* [Parse and verify SD-JWT tokens, resolving original payload with disclosed fields](#parse-and-verify-an-sd-jwt-using-the-nimbusds-based-jwt-crypto-provider) +* [Integrate with your choice of framework or library, for cryptography and key management, on your platform](#integrate-with-custom-jwt-crypto-provider) + +### Further information + +Checkout the [documentation regarding SD-JWTs](https://docs.walt.id/v/ssikit/concepts/selective-disclosure), to find out more. + +## What is the SD-JWT library? + +This libary implements the **Selective Disclosure JWT (SD-JWT)** +specification: [draft-ietf-oauth-selective-disclosure-jwt-04](https://datatracker.ietf.org/doc/draft-ietf-oauth-selective-disclosure-jwt/04/). + +### Features + +* **Create and sign** SD-JWT tokens + * Choose selectively disclosable payload fields (SD fields) + * Create digests for SD fields and insert into JWT body payload + * Create and append encoded disclosure strings for SD fields to JWT token + * Add random or fixed number of **decoy digests** on each nested object property +* **Present** SD-JWT tokens + * Selection of fields to be disclosed + * Support for appending optional holder binding +* Full support for **nested SD fields** and **recursive disclosures** +* **Parse** SD-JWT tokens and restore original payload with disclosed fields +* **Verify** SD-JWT token + * Signature verification + * Hash comparison and tamper check of the appended disclosures +* Support for **integration** with various crypto libraries and frameworks, to perform the cryptographic operations and key management +* **Multiplatform support**: + * Java/JVM + * JavaScript + * Native + +## Usage with Maven or Gradle (JVM) + +**Maven / Gradle repository**: + +`https://maven.walt.id/repository/waltid-ssi-kit/` + +**Maven** + +```xml +[...] + + + waltid-ssikit + waltid-ssikit + https://maven.walt.id/repository/waltid-ssi-kit/ + + + [...] + +id.walt +waltid-sd-jwt-jvm +[ version ] + +``` + +**Gradle** + +_Kotlin DSL_ + +```kotlin +[...] +repositories { + maven("https://maven.walt.id/repository/waltid-ssi-kit/") +} +[...] +val sdJwtVersion = "1.2306071235.0" +[...] +dependencies { + implementation("id.walt:waltid-sd-jwt-jvm:$sdJwtVersion") +} +``` + +## Usage with NPM/NodeJs (JavaScript) + +**Install NPM package:** + +`npm install waltid-sd-jwt` + +**Manual build from source:** + +`./gradlew jsNodeProductionLibraryPrepare jsNodeProductionLibraryDistribution` + +Then include in your NodeJS project like this: + +`npm install /path/to/waltid-sd-jwt/build/productionLibrary` + +**NodeJS example** + +Example script in: + +`examples/js` + +Execute like: + +```bash +npm install +node index.js +``` + +## Examples + +### Kotlin / JVM + +#### Create and sign an SD-JWT using the NimbusDS-based JWT crypto provider + +This example creates and signs an SD-JWT, using the SimpleJWTCryptoProvider implementation, that's shipped with the waltid-sd-jwt library, +which uses the `nimbus-jose-jwt` library for cryptographic operations. + +In this example we sign the JWT with the HS256 algorithm, and a UUID as a shared secret. + +Here we generate the SD payload, by comparing the full payload and the undisclosed payload (with selective fields removed). + +Alternatively, we can create the SD payload by specifying the SDMap, which indicates the selective disclosure for each field. +This approach also allows more fine-grained control, particularly in regard to recursive disclosures and nested payload fields. + +```kotlin +// Shared secret for HMAC crypto algorithm +val sharedSecret = "ef23f749-7238-481a-815c-f0c2157dfa8e" + +fun main() { + + // Create SimpleJWTCryptoProvider with MACSigner and MACVerifier + val cryptoProvider = SimpleJWTCryptoProvider(JWSAlgorithm.HS256, MACSigner(sharedSecret), MACVerifier(sharedSecret)) + + // Create original JWT claims set, using nimbusds claims set builder + val originalClaimsSet = JWTClaimsSet.Builder() + .subject("123") + .audience("456") + .build() + + // Create undisclosed claims set, by removing e.g. subject property from original claims set + val undisclosedClaimsSet = JWTClaimsSet.Builder(originalClaimsSet) + .subject(null) + .build() + + // Create SD payload by comparing original claims set with undisclosed claims set + val sdPayload = SDPayload.createSDPayload(originalClaimsSet, undisclosedClaimsSet) + + // Create and sign SD-JWT using the generated SD payload and the previously configured crypto provider + val sdJwt = SDJwt.sign(sdPayload, cryptoProvider) + // Print SD-JWT + println(sdJwt) +} +``` + +_Example output_ + +`eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~WyJ4RFk5VjBtOG43am82ZURIUGtNZ1J3Iiwic3ViIiwiMTIzIl0` + +_Parsed JWT body_ + +```json +{ + "aud": "456", + "_sd": [ + "hlzfjf04o5ZsLR25ha4c-Y-9HW2DUlxcgiMYd324NgY" + ] +} +``` + +#### Present an SD-JWT + +In this example we parse the SD-JWT generated in the previous example, and present it by disclosing all, none or selective fields. + +In the next example we will show how to parse and verify the presented SD-JWTs. + +```kotlin +fun presentSDJwt() { + // parse previously created SD-JWT + val sdJwt = SDJwt.parse("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~WyJ4RFk5VjBtOG43am82ZURIUGtNZ1J3Iiwic3ViIiwiMTIzIl0") + + // present without disclosing SD fields + val presentedUndisclosedJwt = sdJwt.present(discloseAll = false) + println(presentedUndisclosedJwt) + + // present disclosing all SD fields + val presentedDisclosedJwt = sdJwt.present(discloseAll = true) + println(presentedDisclosedJwt) + + // present disclosing selective fields, using SDMap + val presentedSelectiveJwt = sdJwt.present(mapOf( + "sub" to SDField(true) + ).toSDMap()) + println(presentedSelectiveJwt) + + // present disclosing fields, using JSON paths + val presentedSelectiveJwt2 = sdJwt.present( + SDMap.generateSDMap(listOf("sub")) + ) + println(presentedSelectiveJwt2) + +} +``` + +_Example output_ + +```text +eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~ +eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~WyJ4RFk5VjBtOG43am82ZURIUGtNZ1J3Iiwic3ViIiwiMTIzIl0~ +eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~WyJ4RFk5VjBtOG43am82ZURIUGtNZ1J3Iiwic3ViIiwiMTIzIl0~ +eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~WyJ4RFk5VjBtOG43am82ZURIUGtNZ1J3Iiwic3ViIiwiMTIzIl0~ +``` + +#### Parse and verify an SD-JWT using the NimbusDS-based JWT crypto provider + +This example shows how to parse and verify the SD-JWT, created and presented in the previous examples, and how to restore its original +payload, with the disclosed payload fields only. + +For verification, we use the same shared secret as before and a `MACVerifier` with the `SimpleJWTCryptoProvider`. + +The parsing and verification can be done in one step using the `SDJwt.verifyAndParse()` method, throwing an exception if verification fails, +or in two steps using the `SDJwt.parse()` method followed by the member method `SDJwt.verify()`, which returns true or false. + +The output below shows the restored JWT body payloads, with the selectively disclosable field `sub` disclosed or undisclosed. + +```kotlin +// Shared secret for HMAC crypto algorithm +private val sharedSecret = "ef23f749-7238-481a-815c-f0c2157dfa8e" + +fun parseAndVerify() { + // Create SimpleJWTCryptoProvider with MACSigner and MACVerifier + val cryptoProvider = SimpleJWTCryptoProvider(JWSAlgorithm.HS256, jwsSigner = null, jwsVerifier = MACVerifier(sharedSecret)) + + val undisclosedJwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~" + + // verify and parse presented SD-JWT with all fields undisclosed, throws Exception if verification fails! + val parsedVerifiedUndisclosedJwt = SDJwt.verifyAndParse(undisclosedJwt, cryptoProvider) + + // print full payload with disclosed fields only + println("Undisclosed JWT payload:") + println(parsedVerifiedUndisclosedJwt.sdPayload.fullPayload.toString()) + + // alternatively parse and verify in 2 steps: + val parsedUndisclosedJwt = SDJwt.parse(undisclosedJwt) + val isValid = parsedUndisclosedJwt.verify(cryptoProvider) + println("Undisclosed SD-JWT verified: $isValid") + + val parsedVerifiedDisclosedJwt = SDJwt.verifyAndParse( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~WyJ4RFk5VjBtOG43am82ZURIUGtNZ1J3Iiwic3ViIiwiMTIzIl0~", + cryptoProvider + ) + // print full payload with disclosed fields + println("Disclosed JWT payload:") + println(parsedVerifiedDisclosedJwt.sdPayload.fullPayload.toString()) +} + +``` + +_Example output_ + +```text +Undisclosed JWT payload: +{"aud":"456"} +Undisclosed SD-JWT verified: true +Disclosed JWT payload: +{"aud":"456","sub":"123"} +``` + +#### Integrate with custom JWT crypto provider + +To integrate with your custom JWT crypto provider, on your platform, you need to override and implement the `JWTCryptoProvider` interface, +which has two interface methods to sign and verify standard JWT tokens. + +In this example, you see how I made use of this interface to implement the JWT crypto provider based on the NimbusDS Jose/JWT library for +JVM: + +```kotlin +import com.nimbusds.jose.* +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import kotlinx.serialization.json.JsonObject + +class SimpleJWTCryptoProvider( + val jwsAlgorithm: JWSAlgorithm, + private val jwsSigner: JWSSigner?, + private val jwsVerifier: JWSVerifier? +) : JWTCryptoProvider { + + /** + * Interface method to create a signed JWT for the given JSON payload object, with an optional keyID. + * @param payload The JSON payload of the JWT to be signed + * @param keyID Optional keyID of the signing key to be used, if required by crypto provider + */ + override fun sign(payload: JsonObject, keyID: String?): String { + if(jwsSigner == null) { + throw Exception("No signer available") + } + return SignedJWT( + JWSHeader.Builder(jwsAlgorithm).type(JOSEObjectType.JWT).keyID(keyID).build(), + JWTClaimsSet.parse(payload.toString()) + ).also { + it.sign(jwsSigner) + }.serialize() + } + + /** + * Interface method for verifying a JWT signature + * @param jwt A signed JWT token to be verified + */ + override fun verify(jwt: String): Boolean { + if(jwsVerifier == null) { + throw Exception("No verifier available") + } + return SignedJWT.parse(jwt).verify(jwsVerifier) + } +} +``` + +The custom JWT crypto provider can now be used like shown in the examples above, +for [signing](#create-and-sign-an-sd-jwt-using-the-nimbusds-based-jwt-crypto-provider) +and [verifying](#parse-and-verify-an-sd-jwt-using-the-nimbusds-based-jwt-crypto-provider) SD-JWTs. + +### JavaScript / NodeJS + +See also example project in `examples/js` + +**Build payload, sign and present examples** + +```javascript +import sdlib from "waltid-sd-jwt" + +const sharedSecret = "ef23f749-7238-481a-815c-f0c2157dfa8e" +const cryptoProvider = new sdlib.id.walt.sdjwt.SimpleAsyncJWTCryptoProvider("HS256", new TextEncoder().encode(sharedSecret)) + +const sdMap = new sdlib.id.walt.sdjwt.SDMapBuilder(sdlib.id.walt.sdjwt.DecoyMode.FIXED.name, 2).addField("sub", true, + new sdlib.id.walt.sdjwt.SDMapBuilder().addField("child", true).build() +).build() + +console.log(sdMap, JSON.stringify(sdMap)) + +const sdPayload = new sdlib.id.walt.sdjwt.SDPayloadBuilder({"sub": "123", "aud": "345"}).buildForUndisclosedPayload({"aud": "345"}) +const sdPayload2 = new sdlib.id.walt.sdjwt.SDPayloadBuilder({"sub": "123", "aud": "345"}).buildForSDMap(sdMap) + +const jwt = await sdlib.id.walt.sdjwt.SDJwtJS.Companion.signAsync( + sdPayload, cryptoProvider) +console.log(jwt.toString()) + +const jwt2 = await sdlib.id.walt.sdjwt.SDJwtJS.Companion.signAsync( + sdPayload2, cryptoProvider) +console.log(jwt2.toString()) + +console.log("Verified:", (await jwt.verifyAsync(cryptoProvider)).verified) +console.log("Verified:", (await jwt2.verifyAsync(cryptoProvider)).verified) + +const presentedJwt = await jwt.presentAllAsync(false) +console.log("Presented undisclosed SD-JWT:", presentedJwt.toString()) +console.log("Verified: ", (await presentedJwt.verifyAsync(cryptoProvider)).verified) + +const sdMap2 = new sdlib.id.walt.sdjwt.SDMapBuilder().buildFromJsonPaths(["sub"]) +console.log("SDMap2:", sdMap2) +const presentedJwt2 = await jwt.presentAsync(sdMap2) +console.log("Presented disclosed SD-JWT:", presentedJwt2.toString()) +const verificationResultPresentedJwt2 = await presentedJwt2.verifyAsync(cryptoProvider) +console.log("Presented payload", verificationResultPresentedJwt2.sdJwt.fullPayload) +console.log("Presented disclosures", verificationResultPresentedJwt2.sdJwt.disclosureObjects) +console.log("Presented disclosure strings", verificationResultPresentedJwt2.sdJwt.disclosures) +console.log("Verified: ", verificationResultPresentedJwt2.verified) +console.log("SDMap reconstructed", presentedJwt2.sdMap) + +``` + +## Join the community + +* Connect and get the latest + updates: [Discord](https://discord.gg/AW8AgqJthZ) | [Newsletter](https://walt.id/newsletter) | [YouTube](https://www.youtube.com/channel/UCXfOzrv3PIvmur_CmwwmdLA) | [Twitter](https://mobile.twitter.com/walt_id) +* Get help, request features and report bugs: [GitHub Discussions](https://github.com/walt-id/.github/discussions) + +## License + +Licensed under the [Apache License, Version 2.0](https://github.com/walt-id/waltid-nftkit/blob/main/LICENSE) diff --git a/waltid-sdjwt/build.gradle.kts b/waltid-sdjwt/build.gradle.kts new file mode 100644 index 000000000..7e8f0a282 --- /dev/null +++ b/waltid-sdjwt/build.gradle.kts @@ -0,0 +1,197 @@ +plugins { + kotlin("multiplatform") version "1.9.20" + + id("org.jetbrains.kotlin.plugin.serialization") + + id("dev.petuska.npm.publish") version "3.4.1" + `maven-publish` + id("com.github.ben-manes.versions") version "0.49.0" +} + +group = "id.walt" +version = "1.SNAPSHOT" + +repositories { + mavenCentral() +} + +@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) +kotlin { + jvm { + jvmToolchain(18) // 16 possible? + withJava() + testRuns["test"].executionTask.configure { + useJUnitPlatform() + } + } + + js(IR) { + browser { + commonWebpackConfig(Action { + cssSupport { + enabled.set(true) + } + }) + + testTask(Action { + useKarma { + useChromiumHeadless() + } + }) + } + nodejs { + generateTypeScriptDefinitions() + } + binaries.library() + } + val hostOs = System.getProperty("os.name") + val isMingwX64 = hostOs.startsWith("Windows") + val nativeTarget = when { + hostOs == "Mac OS X" -> macosX64("native") + hostOs == "Linux" -> linuxX64("native") + isMingwX64 -> mingwX64("native") + else -> throw GradleException("Host OS is not supported in Kotlin/Native.") + } + when(hostOs) { + "Mac OS X" -> listOf ( + iosArm64(), + iosX64(), + iosSimulatorArm64() + ) + else -> listOf() + }.forEach { + val platform = when (it.name) { + "iosArm64" -> "iphoneos" + else -> "iphonesimulator" + } + + it.binaries.framework { + baseName = "shared" + } + + it.compilations.getByName("main") { + cinterops.create("id.walt.sdjwt.cinterop.ios") { + val interopTask = tasks[interopProcessingTaskName] + interopTask.dependsOn(":waltid-sd-jwt-ios:build${platform.capitalize()}") + + defFile("$projectDir/src/nativeInterop/cinterop/waltid-sd-jwt-ios.def") + packageName("id.walt.sdjwt.cinterop.ios") + includeDirs("$projectDir/waltid-sd-jwt-ios/build/Release-$platform/include/") + + headers("$projectDir/waltid-sd-jwt-ios/build/Release-$platform/include/waltid_sd_jwt_ios/waltid_sd_jwt_ios-Swift.h") + } + } + } + + val kryptoVersion = "4.0.10" + + + sourceSets { + val commonMain by getting { + dependencies { + implementation("dev.whyoleg.cryptography:cryptography-random:0.2.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0") + implementation("com.soywiz.korlibs.krypto:krypto:$kryptoVersion") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + implementation("io.kotest:kotest-assertions-core:5.7.2") + + implementation("io.kotest:kotest-assertions-json:5.7.2") + } + } + val jvmMain by getting { + dependencies { + implementation("com.nimbusds:nimbus-jose-jwt:9.37") + } + } + val jvmTest by getting { + dependencies { +// implementation("io.mockk:mockk:1.13.2") + + implementation("io.kotest:kotest-runner-junit5:5.7.2") + implementation("io.kotest:kotest-assertions-core:5.7.2") + implementation("io.kotest:kotest-assertions-json:5.7.2") + } + } + val jsMain by getting { + dependencies { + implementation(npm("jose", "~4.14.4")) + } + } + val jsTest by getting { + + } + val nativeMain by getting + val nativeTest by getting + + if (hostOs == "Mac OS X") { + val iosArm64Main by getting + val iosSimulatorArm64Main by getting + val iosX64Main by getting + val iosMain by creating { + dependsOn(commonMain) + iosArm64Main.dependsOn(this) + iosSimulatorArm64Main.dependsOn(this) + iosX64Main.dependsOn(this) + } + val iosArm64Test by getting + val iosSimulatorArm64Test by getting + val iosX64Test by getting + val iosTest by creating { + dependsOn(commonTest) + iosArm64Test.dependsOn(this) + iosSimulatorArm64Test.dependsOn(this) + iosX64Test.dependsOn(this) + } + } + } + + publishing { + repositories { + maven { + url = uri("https://maven.walt.id/repository/waltid-ssi-kit/") + val envUsername = System.getenv("MAVEN_USERNAME") + val envPassword = System.getenv("MAVEN_PASSWORD") + + val usernameFile = File("secret_maven_username.txt") + val passwordFile = File("secret_maven_password.txt") + + val secretMavenUsername = envUsername ?: usernameFile.let { if (it.isFile) it.readLines().first() else "" } + //println("Deploy username length: ${secretMavenUsername.length}") + val secretMavenPassword = envPassword ?: passwordFile.let { if (it.isFile) it.readLines().first() else "" } + + //if (secretMavenPassword.isBlank()) { + // println("WARNING: Password is blank!") + //} + + credentials { + username = secretMavenUsername + password = secretMavenPassword + } + } + } + } +} + +npmPublish { + registries { + val envToken = System.getenv("NPM_TOKEN") + val npmTokenFile = File("secret_npm_token.txt") + val secretNpmToken = envToken ?: npmTokenFile.let { if (it.isFile) it.readLines().first() else "" } + val hasNPMToken = secretNpmToken.isNotEmpty() + val isReleaseBuild = Regex("\\d+.\\d+.\\d+").matches(version.get()) + println("NPM token: ${hasNPMToken}") + println("Release build: ${isReleaseBuild}") + if (isReleaseBuild && hasNPMToken) { + readme.set(File("NPM_README.md")) + register("npmjs") { + uri.set(uri("https://registry.npmjs.org")) + authToken.set(secretNpmToken) + } + } + } +} diff --git a/waltid-sdjwt/examples/js/index.js b/waltid-sdjwt/examples/js/index.js new file mode 100644 index 000000000..706e291ba --- /dev/null +++ b/waltid-sdjwt/examples/js/index.js @@ -0,0 +1,39 @@ +import sdlib from "waltid-sd-jwt" + +const sharedSecret = "ef23f749-7238-481a-815c-f0c2157dfa8e" +const cryptoProvider = new sdlib.id.walt.sdjwt.SimpleAsyncJWTCryptoProvider("HS256", new TextEncoder().encode(sharedSecret)) + +const sdMap = new sdlib.id.walt.sdjwt.SDMapBuilder(sdlib.id.walt.sdjwt.DecoyMode.FIXED.name, 2).addField("sub", true, + new sdlib.id.walt.sdjwt.SDMapBuilder().addField("child", true).build() +).build() + +console.log(sdMap, JSON.stringify(sdMap)) + +const sdPayload = new sdlib.id.walt.sdjwt.SDPayloadBuilder({"sub": "123", "aud": "345"}).buildForUndisclosedPayload({"aud": "345"}) +const sdPayload2 = new sdlib.id.walt.sdjwt.SDPayloadBuilder({"sub": "123", "aud": "345"}).buildForSDMap(sdMap) + +const jwt = await sdlib.id.walt.sdjwt.SDJwtJS.Companion.signAsync( + sdPayload, cryptoProvider) +console.log(jwt.toString()) + +const jwt2 = await sdlib.id.walt.sdjwt.SDJwtJS.Companion.signAsync( + sdPayload2, cryptoProvider) +console.log(jwt2.toString()) + +console.log("Verified:", (await jwt.verifyAsync(cryptoProvider)).verified) +console.log("Verified:", (await jwt2.verifyAsync(cryptoProvider)).verified) + +const presentedJwt = await jwt.presentAllAsync(false) +console.log("Presented undisclosed SD-JWT:", presentedJwt.toString()) +console.log("Verified: ", (await presentedJwt.verifyAsync(cryptoProvider)).verified) + +const sdMap2 = new sdlib.id.walt.sdjwt.SDMapBuilder().buildFromJsonPaths(["sub"]) +console.log("SDMap2:", sdMap2) +const presentedJwt2 = await jwt.presentAsync(sdMap2) +console.log("Presented disclosed SD-JWT:", presentedJwt2.toString()) +const verificationResultPresentedJwt2 = await presentedJwt2.verifyAsync(cryptoProvider) +console.log("Presented payload", verificationResultPresentedJwt2.sdJwt.fullPayload) +console.log("Presented disclosures", verificationResultPresentedJwt2.sdJwt.disclosureObjects) +console.log("Presented disclosure strings", verificationResultPresentedJwt2.sdJwt.disclosures) +console.log("Verified: ", verificationResultPresentedJwt2.verified) +console.log("SDMap reconstructed", presentedJwt2.sdMap) diff --git a/waltid-sdjwt/examples/js/package.json b/waltid-sdjwt/examples/js/package.json new file mode 100644 index 000000000..76ae0bd30 --- /dev/null +++ b/waltid-sdjwt/examples/js/package.json @@ -0,0 +1,16 @@ +{ + "name": "sd-jwt-example", + "version": "1.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "", + "license": "ISC", + "dependencies": { + "jose": "^4.14.4", + "waltid-sd-jwt": "^1.2306191208.0" + } +} diff --git a/waltid-sdjwt/gradle.properties b/waltid-sdjwt/gradle.properties new file mode 100644 index 000000000..9dcc3a765 --- /dev/null +++ b/waltid-sdjwt/gradle.properties @@ -0,0 +1,2 @@ +kotlin.code.style=official +kotlin.js.compiler=ir diff --git a/waltid-sdjwt/gradle/wrapper/gradle-wrapper.properties b/waltid-sdjwt/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..ae04661ee --- /dev/null +++ b/waltid-sdjwt/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/waltid-sdjwt/gradlew b/waltid-sdjwt/gradlew new file mode 100755 index 000000000..a69d9cb6c --- /dev/null +++ b/waltid-sdjwt/gradlew @@ -0,0 +1,240 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/waltid-sdjwt/gradlew.bat b/waltid-sdjwt/gradlew.bat new file mode 100644 index 000000000..f127cfd49 --- /dev/null +++ b/waltid-sdjwt/gradlew.bat @@ -0,0 +1,91 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/waltid-sdjwt/src/commonMain/kotlin/id/walt/Values.kt b/waltid-sdjwt/src/commonMain/kotlin/id/walt/Values.kt new file mode 100644 index 000000000..aca4c7a8e --- /dev/null +++ b/waltid-sdjwt/src/commonMain/kotlin/id/walt/Values.kt @@ -0,0 +1,7 @@ +package id.walt + +object Values { + const val version = "1.SNAPSHOT" + val isSnapshot: Boolean + get() = version.contains("SNAPSHOT") +} diff --git a/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/AsyncJWTCryptoProvider.kt b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/AsyncJWTCryptoProvider.kt new file mode 100644 index 000000000..21641bc6a --- /dev/null +++ b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/AsyncJWTCryptoProvider.kt @@ -0,0 +1,31 @@ +package id.walt.sdjwt + +import kotlinx.serialization.json.JsonObject +import kotlin.js.ExperimentalJsExport +import kotlin.js.JsExport + +/** + * Crypto provider, that provides signing and verifying of standard JWTs on the target platform + * Can be implemented by library user, to integrate their own or custom JWT crypto library + * Default implementations exist for some platforms. + * **Note for JavaScript**: Implement _JSAsyncJWTCryptoProvider_ instead. + * @see SimpleJWTCryptoProvider + */ +@ExperimentalJsExport +@JsExport.Ignore +interface AsyncJWTCryptoProvider { + /** + * Interface method to create a signed JWT for the given JSON payload object, with an optional keyID. + * @param payload The JSON payload of the JWT to be signed + * @param keyID Optional keyID of the signing key to be used, if required by crypto provider + */ + @JsExport.Ignore + suspend fun sign(payload: JsonObject, keyID: String? = null): String + + /** + * Interface method for verifying a JWT signature + * @param jwt A signed JWT token to be verified + */ + @JsExport.Ignore + suspend fun verify(jwt: String): JwtVerificationResult +} diff --git a/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/DecoyMode.kt b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/DecoyMode.kt new file mode 100644 index 000000000..438440f6c --- /dev/null +++ b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/DecoyMode.kt @@ -0,0 +1,38 @@ +package id.walt.sdjwt + +import id.walt.sdjwt.DecoyMode.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.js.ExperimentalJsExport +import kotlin.js.JsExport + +/** + * Mode for adding decoy digests on SD-JWT issuance + * @property NONE: no decoy digests to be added (or mode is unknown, e.g. when parsing SD-JWTs) + * @property FIXED: Fixed number of decoy digests to be added + * @property RANDOM: Random number of decoy digests to be added + */ +@ExperimentalJsExport +@JsExport +@Serializable +enum class DecoyMode { + NONE, + FIXED, + RANDOM; + + @JsExport.Ignore + companion object { + @JsExport.Ignore + fun fromJSON(json: JsonElement): DecoyMode { + println("Parsing DecoyMode from $json") + return (if (json is JsonObject) { + json.jsonObject["name"]?.jsonPrimitive?.content + } else { + json.jsonPrimitive.content + })?.let { valueOf(it) } ?: throw Exception("Error parsing DecoyMode from JSON value") + } + } +} diff --git a/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/JWTClaimsSet.kt b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/JWTClaimsSet.kt new file mode 100644 index 000000000..79a7e4be6 --- /dev/null +++ b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/JWTClaimsSet.kt @@ -0,0 +1,8 @@ +package id.walt.sdjwt + +/** + * Expected class for JWT claim set in platform specific implementation. Not necessarily required. + */ +expect class JWTClaimsSet { + override fun toString(): String +} diff --git a/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/JWTCryptoProvider.kt b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/JWTCryptoProvider.kt new file mode 100644 index 000000000..a764a9026 --- /dev/null +++ b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/JWTCryptoProvider.kt @@ -0,0 +1,28 @@ +package id.walt.sdjwt + +import kotlinx.serialization.json.JsonObject +import kotlin.js.ExperimentalJsExport +import kotlin.js.JsExport + +/** + * Crypto provider, that provides signing and verifying of standard JWTs on the target platform + * Can be implemented by library user, to integrate their own or custom JWT crypto library + * Default implementations exist for some platforms. + * @see SimpleJWTCryptoProvider + */ +@ExperimentalJsExport +@JsExport +interface JWTCryptoProvider { + /** + * Interface method to create a signed JWT for the given JSON payload object, with an optional keyID. + * @param payload The JSON payload of the JWT to be signed + * @param keyID Optional keyID of the signing key to be used, if required by crypto provider + */ + fun sign(payload: JsonObject, keyID: String? = null, typ: String = "JWT"): String + + /** + * Interface method for verifying a JWT signature + * @param jwt A signed JWT token to be verified + */ + fun verify(jwt: String): JwtVerificationResult +} diff --git a/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDField.kt b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDField.kt new file mode 100644 index 000000000..db189adce --- /dev/null +++ b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDField.kt @@ -0,0 +1,51 @@ +package id.walt.sdjwt + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.* +import kotlin.js.ExperimentalJsExport +import kotlin.js.JsExport + +/** + * Selective disclosure information for a given payload field + * @param sd **Issuance:** field is made selectively disclosable if *true*, **Presentation:** field should be _disclosed_ if *true*, or _undisclosed_ if *false* + * @param children Not null, if field is an object. Contains SDMap for the properties of the object + * @see SDMap + */ +@ExperimentalJsExport +@JsExport +@Serializable +data class SDField( + val sd: Boolean, + val children: SDMap? = null +) { + + @JsExport.Ignore + fun toJSON(): JsonObject { + return buildJsonObject { + put("sd", sd) + children?.also { + put("children", it.toJSON()) + } + } + } + + @JsExport.Ignore + companion object { + @JsExport.Ignore + fun fromJSON(json: JsonElement): SDField { + println("Parsing SDField from $json") + return SDField( + sd = json.jsonObject["sd"]?.jsonPrimitive?.boolean ?: throw Exception("Error parsing SDField.sd from JSON element"), + children = json.jsonObject["children"]?.let { children -> + if (children is JsonObject) { + children.jsonObject.let { SDMap.fromJSON(it) } + } else if (children is JsonNull) { + null + } else { + throw Exception("Error parsing SDField.children from JSON element") + } + } + ) + } + } +} diff --git a/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDJwt.kt b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDJwt.kt new file mode 100644 index 000000000..7904c517f --- /dev/null +++ b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDJwt.kt @@ -0,0 +1,239 @@ +package id.walt.sdjwt + +import korlibs.crypto.encoding.Base64 +import kotlinx.serialization.json.* +import kotlin.js.ExperimentalJsExport +import kotlin.js.JsExport +import kotlin.js.JsName + +/** + * SD-JWT object, providing signed JWT token, header and payload with disclosures, as well as optional holder binding + */ +@ExperimentalJsExport +@JsExport +open class SDJwt internal constructor( + val jwt: String, + protected val header: JsonObject, + protected val sdPayload: SDPayload, + val holderJwt: String? = null, + protected val isPresentation: Boolean = false +) { + internal constructor(sdJwt: SDJwt) : this(sdJwt.jwt, sdJwt.header, sdJwt.sdPayload, sdJwt.holderJwt, sdJwt.isPresentation) + + /** + * Encoded disclosures, included in this SD-JWT + */ + @JsName("zzz_unused_disclosures") // redefined in SDJwtJS + val disclosures + get() = sdPayload.sDisclosures.map { it.disclosure }.toSet() + + @JsName("zzz_unused_disclosureObjects") // redefined in SDJwtJS + val disclosureObjects + get() = sdPayload.sDisclosures + + @JsName("zzz_unused_undisclosedPayload") // redefined in SDJwtJS + val undisclosedPayload + get() = sdPayload.undisclosedPayload + + @JsName("zzz_unused_fullPayload") // redefined in SDJwtJS + val fullPayload + get() = sdPayload.fullPayload + + @JsName("zzz_unused_digestedDisclosures") + val digestedDisclosures + get() = sdPayload.digestedDisclosures + + @JsName("zzz_unused_sdMap") + val sdMap + get() = sdPayload.sdMap + + /** + * Signature algorithm from JWT header + */ + val algorithm + get() = header["alg"]?.jsonPrimitive?.contentOrNull + + /** + * Signature key ID from JWT header, if present + */ + val keyID + get() = header["kid"]?.jsonPrimitive?.contentOrNull + + /** + * Signature key in JWK format, from JWT header, if present + */ + val jwk + get() = header["jwk"]?.jsonPrimitive?.contentOrNull + + override fun toString() = toString(isPresentation) + + @JsName("toFormattedString") + open fun toString(formatForPresentation: Boolean): String { + return listOf(jwt) + .plus(disclosures) + .plus(holderJwt?.let { listOf(it) } ?: (if (formatForPresentation) listOf("") else listOf())) + .joinToString(SEPARATOR_STR) + } + + /** + * Present SD-JWT with selection of disclosures + * @param sdMap Selective disclosure map, indicating for each field (recursively) whether it should be disclosed or undisclosed in the presentation + * @param withHolderJwt Optionally, adds the provided JWT as holder binding to the presented SD-JWT token + */ + @JsName("present") + fun present(sdMap: SDMap?, withHolderJwt: String? = null): SDJwt { + return SDJwt( + jwt, + header, + sdMap?.let { sdPayload.withSelectiveDisclosures(it) } ?: sdPayload.withoutDisclosures(), + withHolderJwt ?: holderJwt, isPresentation = true) + } + + /** + * Shortcut to presenting the SD-JWT, with all disclosures selected or unselected + * @param discloseAll true: disclose all selective disclosures, false: all selective disclosures remain undisclosed + * @param withHolderJwt Optionally, adds the provided JWT as holder binding to the presented SD-JWT token + */ + @JsName("presentAll") + fun present(discloseAll: Boolean, withHolderJwt: String? = null): SDJwt { + return SDJwt( + jwt, + header, + if (discloseAll) { + sdPayload + } else { + sdPayload.withoutDisclosures() + }, + withHolderJwt ?: holderJwt, isPresentation = true + ) + } + + /** + * Verify the SD-JWT by checking the signature, using the given JWT crypto provider, and matching the disclosures against the digests in the JWT payload + * @param jwtCryptoProvider JWT crypto provider, that implements standard JWT token verification on the target platform + */ + fun verify(jwtCryptoProvider: JWTCryptoProvider): VerificationResult { + return jwtCryptoProvider.verify(jwt).let { + VerificationResult( + sdJwt = this, + signatureVerified = it.verified, + disclosuresVerified = sdPayload.verifyDisclosures(), + message = it.message + ) + } + } + + /** + * Verify the SD-JWT by checking the signature, using the given JWT crypto provider, and matching the disclosures against the digests in the JWT payload + * @param jwtCryptoProvider JWT crypto provider, that implements standard JWT token verification on the target platform + */ + @JsExport.Ignore + suspend fun verifyAsync(jwtCryptoProvider: AsyncJWTCryptoProvider): VerificationResult { + return jwtCryptoProvider.verify(jwt).let { + VerificationResult( + sdJwt = this, + signatureVerified = it.verified, + disclosuresVerified = sdPayload.verifyDisclosures(), + message = it.message + ) + } + } + + companion object { + const val DIGESTS_KEY = "_sd" + const val SEPARATOR = '~' + const val SEPARATOR_STR = SEPARATOR.toString() + const val SD_JWT_PATTERN = + "^(?(?
[A-Za-z0-9-_]+)\\.(?[A-Za-z0-9-_]+)\\.(?[A-Za-z0-9-_]+))(?(~([A-Za-z0-9-_]+))+)?(~(?([A-Za-z0-9-_]+)\\.([A-Za-z0-9-_]+)\\.([A-Za-z0-9-_]+))?)?\$" + + /** + * Parse SD-JWT from a token string + */ + fun parse(sdJwt: String): SDJwt { + val matchResult = Regex(SD_JWT_PATTERN).matchEntire(sdJwt) ?: throw Exception("Invalid SD-JWT format") + val matchedGroups = matchResult.groups as MatchNamedGroupCollection + val disclosures = matchedGroups["disclosures"]?.value?.trim(SEPARATOR)?.split(SEPARATOR)?.toSet() ?: setOf() + return SDJwt( + matchedGroups["sdjwt"]!!.value, + Json.parseToJsonElement(Base64.decode(matchedGroups["header"]!!.value, true).decodeToString()).jsonObject, + SDPayload.parse( + matchedGroups["body"]!!.value, + disclosures + ), + matchedGroups["holderjwt"]?.value + ) + } + + /** + * Parse SD-JWT from a token string and verify it + * @return parsed SD-JWT, if token has been verified + * @throws Exception if SD-JWT cannot be parsed + */ + fun verifyAndParse(sdJwt: String, jwtCryptoProvider: JWTCryptoProvider): VerificationResult { + return parse(sdJwt).verify(jwtCryptoProvider) + } + + /** + * Parse SD-JWT from a token string and verify it + * @return parsed SD-JWT, if token has been verified + * @throws Exception if SD-JWT cannot be parsed + */ + @JsExport.Ignore + suspend fun verifyAndParseAsync(sdJwt: String, jwtCryptoProvider: AsyncJWTCryptoProvider): VerificationResult { + return parse(sdJwt).verifyAsync(jwtCryptoProvider) + } + + fun createFromSignedJwt(signedJwt: String, sdPayload: SDPayload, withHolderJwt: String? = null): SDJwt { + val sdJwt = parse(signedJwt) + return SDJwt( + jwt = sdJwt.jwt, + header = sdJwt.header, + sdPayload = sdPayload, + holderJwt = withHolderJwt + ) + } + + /** + * Sign the given payload as SD-JWT token, using the given JWT crypto provider, optionally with the specified key ID and holder binding + * @param sdPayload Payload with selective disclosures to be signed + * @param jwtCryptoProvider Crypto provider implementation, that supports JWT creation on the target platform + * @param keyID Optional key ID, if the crypto provider implementation requires it + * @param withHolderJwt Optionally, append the given holder binding JWT to the signed SD-JWT token + * @return The signed SDJwt object + */ + fun sign( + sdPayload: SDPayload, + jwtCryptoProvider: JWTCryptoProvider, + keyID: String? = null, + withHolderJwt: String? = null, + typ: String = "JWT" + ): SDJwt = createFromSignedJwt( + jwtCryptoProvider.sign(sdPayload.undisclosedPayload, keyID, typ), sdPayload, withHolderJwt + ) + + /** + * Sign the given payload as SD-JWT token, using the given JWT crypto provider, optionally with the specified key ID and holder binding + * @param sdPayload Payload with selective disclosures to be signed + * @param jwtCryptoProvider Crypto provider implementation, that supports JWT creation on the target platform + * @param keyID Optional key ID, if the crypto provider implementation requires it + * @param withHolderJwt Optionally, append the given holder binding JWT to the signed SD-JWT token + * @return The signed SDJwt object + */ + @JsExport.Ignore + suspend fun signAsync( + sdPayload: SDPayload, + jwtCryptoProvider: AsyncJWTCryptoProvider, + keyID: String? = null, + withHolderJwt: String? = null + ): SDJwt = createFromSignedJwt( + jwtCryptoProvider.sign(sdPayload.undisclosedPayload, keyID), sdPayload, withHolderJwt + ) + + /** + * Check the given string, whether it matches the pattern of an SD-JWT + */ + fun isSDJwt(value: String): Boolean { + return Regex(SD_JWT_PATTERN).matches(value) + } + } +} diff --git a/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDMap.kt b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDMap.kt new file mode 100644 index 000000000..f5b3d6dd0 --- /dev/null +++ b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDMap.kt @@ -0,0 +1,161 @@ +package id.walt.sdjwt + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.* + +/** + * Selective disclosure map, that describes for each payload field recursively, whether it should be selectively disclosable / selected for disclosure. + * @param fields map of field keys to SD field descriptors + * @param decoyMode **For SD-JWT issuance:** Generate decoy digests for this hierarchical level randomly or fixed, set to NONE for parsed SD-JWTs, **for presentation:** _unused_ + * @param decoys **For SD-JWT issuance:** Num (fixed mode) or max num (random mode) of decoy digests to add for this hierarchical level. 0 if NONE, **for presentation:** _unused_ + */ +@Serializable +class SDMap internal constructor( + val fields: Map, + val decoyMode: DecoyMode = DecoyMode.NONE, + val decoys: Int = 0 +) : Map { + override val entries: Set> + get() = fields.entries + override val keys: Set + get() = fields.keys + override val size: Int + get() = fields.size + override val values: Collection + get() = fields.values + + override fun isEmpty() = fields.isEmpty() + + override fun get(key: String) = fields[key] + + override fun containsValue(value: SDField) = fields.containsValue(value) + + override fun containsKey(key: String) = fields.containsKey(key) + + fun prettyPrint(indentBy: Int = 0): String { + val indentation = (0).rangeTo(indentBy).joinToString(" ") { "" } + return "${indentation} + with decoys: ${decoyMode} (${decoys})\n" + keys.flatMap { key -> + listOfNotNull( + "${indentation}- $key: ${fields[key]?.sd == true}", + fields[key]?.children?.prettyPrint(indentBy + 2) + ) + }.joinToString("\n") + } + + fun toJSON(): JsonObject { + return buildJsonObject { + put("fields", buildJsonObject { + fields.forEach { entry -> + put(entry.key, entry.value.toJSON()) + } + }) + put("decoyMode", decoyMode.name) + put("decoys", decoys) + } + } + + companion object { + + /** + * Generate SDMap by comparing the fully disclosed payload with the undisclosed payload + * @param fullPayload Full payload containing all fields + * @param undisclosedPayload Payload with selectively disclosable fields removed + * @param decoyMode **For SD-JWT issuance:** Generate decoy digests for this hierarchical level randomly or fixed, set to NONE for parsed SD-JWTs, **for presentation:** _unused_ + * @param decoys **For SD-JWT issuance:** Num (fixed mode) or max num (random mode) of decoy digests to add for this hierarchical level. 0 if NONE, **for presentation:** _unused_. + */ + fun generateSDMap( + fullPayload: JsonObject, + undisclosedPayload: JsonObject, + decoyMode: DecoyMode = DecoyMode.NONE, + decoys: Int = 0 + ): SDMap { + return fullPayload.mapValues { entry -> + if (!undisclosedPayload.containsKey(entry.key)) + SDField(true) + else if (entry.value is JsonObject && undisclosedPayload[entry.key] is JsonObject) { + SDField(false, generateSDMap(entry.value.jsonObject, undisclosedPayload[entry.key]!!.jsonObject, decoyMode, decoys)) + } else { + SDField(false) + } + }.toSDMap(decoyMode, decoys) + } + + /** + * Generate SDMap based on set of simplified JSON paths + * @param jsonPaths Simplified JSON paths, of fields that should be selectively disclosable. e.g.: "credentialSubject.firstName", "credentialSubject.dateOfBirth" + * @param decoyMode **For SD-JWT issuance:** Generate decoy digests for this hierarchical level randomly or fixed, set to NONE for parsed SD-JWTs, **for presentation:** _unused_ + * @param decoys **For SD-JWT issuance:** Num (fixed mode) or max num (random mode) of decoy digests to add for this hierarchical level. 0 if NONE, **for presentation:** _unused_. + */ + fun generateSDMap(jsonPaths: Collection, decoyMode: DecoyMode = DecoyMode.NONE, decoys: Int = 0) = + doGenerateSDMap(jsonPaths, decoyMode, decoys, jsonPaths.toSet(), "") + + private fun doGenerateSDMap( + jsonPaths: Collection, + decoyMode: DecoyMode = DecoyMode.NONE, + decoys: Int, + sdPaths: Set, + parent: String + ): SDMap { + val pathMap = jsonPaths.map { path -> Pair(path.substringBefore("."), path.substringAfter(".", "")) } + .groupBy({ p -> p.first }, { p -> p.second }).mapValues { entry -> entry.value.filterNot { it.isEmpty() } } + return pathMap.mapValues { + val currentPath = listOf(parent, it.key).filter { it.isNotEmpty() }.joinToString(".") + SDField( + sdPaths.contains(currentPath), if (it.value.isNotEmpty()) { + doGenerateSDMap(it.value, decoyMode, decoys, sdPaths, currentPath) + } else null + ) + }.toSDMap(decoyMode, decoys) + } + + private fun regenerateSDField(sd: Boolean, value: JsonElement, digestedDisclosure: Map): SDField { + return SDField( + sd, if (value is JsonObject) { + regenerateSDMap(value.jsonObject, digestedDisclosure) + } else null + ) + } + + /** + * Regenerate SDMap recursively, from undisclosed payload and digested disclosures map. Used for parsing SD-JWTs. + * @param undisclosedPayload Undisclosed payload as contained in the JWT body of the SD-JWT token. + * @param digestedDisclosures Map of digests to disclosures appended to the JWT in the SD-JWT token + */ + internal fun regenerateSDMap(undisclosedPayload: JsonObject, digestedDisclosures: Map): SDMap { + return (undisclosedPayload[SDJwt.DIGESTS_KEY]?.jsonArray?.filter { digestedDisclosures.containsKey(it.jsonPrimitive.content) } + ?.map { sdEntry -> + digestedDisclosures[sdEntry.jsonPrimitive.content]!! + }?.associateBy({ it.key }, { regenerateSDField(true, it.value, digestedDisclosures) }) ?: mapOf()) + .plus( + undisclosedPayload.filterNot { it.key == SDJwt.DIGESTS_KEY }.mapValues { + regenerateSDField(false, it.value, digestedDisclosures) + } + ).toSDMap() + } + + fun fromJSON(json: JsonObject): SDMap { + println("Parsing SDMap from: $json") + return SDMap( + fields = json["fields"]?.jsonObject?.entries?.associate { entry -> + Pair(entry.key, SDField.fromJSON(entry.value)) + } ?: mapOf(), + decoyMode = json["decoyMode"]?.let { DecoyMode.fromJSON(it) } ?: DecoyMode.NONE, + decoys = json["decoys"]?.jsonPrimitive?.int ?: 0 + ) + } + + fun fromJSON(json: String): SDMap { + return fromJSON(Json.parseToJsonElement(json).jsonObject) + } + } + +} + +/** + * Convert a Map to SDMap object, with the given optional decoy parameters + * @param decoyMode **For SD-JWT issuance:** Generate decoy digests for this hierarchical level randomly or fixed, set to NONE for parsed SD-JWTs, **for presentation:** _unused_ + * @param decoys **For SD-JWT issuance:** Num (fixed mode) or max num (random mode) of decoy digests to add for this hierarchical level. 0 if NONE, **for presentation:** _unused_. + */ +fun Map.toSDMap(decoyMode: DecoyMode = DecoyMode.NONE, decoys: Int = 0): SDMap { + return SDMap(this, decoyMode, decoys) +} diff --git a/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDMapBuilder.kt b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDMapBuilder.kt new file mode 100644 index 000000000..ccef9917b --- /dev/null +++ b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDMapBuilder.kt @@ -0,0 +1,25 @@ +package id.walt.sdjwt + +import kotlin.js.JsExport +import kotlin.js.JsName + +@JsExport.Ignore +@JsName("zzz_unused_SDMapBuilder") +class SDMapBuilder( + private val decoyMode: DecoyMode = DecoyMode.NONE, + private val numDecoys: Int = 0 +) { + private val fields = mutableMapOf() + + fun addField(key: String, sd: Boolean, children: SDMap? = null): SDMapBuilder { + fields.put(key, SDField(sd, children)) + return this + } + + fun build(): SDMap { + return SDMap( + fields.toMap(), + decoyMode = decoyMode, decoys = numDecoys + ) + } +} diff --git a/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDPayload.kt b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDPayload.kt new file mode 100644 index 000000000..ada7a0710 --- /dev/null +++ b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDPayload.kt @@ -0,0 +1,278 @@ +package id.walt.sdjwt + +import dev.whyoleg.cryptography.random.CryptographyRandom +import korlibs.crypto.SecureRandom +import korlibs.crypto.encoding.Base64 +import korlibs.crypto.sha256 +import kotlinx.serialization.json.* +import kotlin.js.ExperimentalJsExport +import kotlin.js.JsExport + +/** + * Payload object of the SD-JWT, representing the undisclosed payload from the JWT body and the selective disclosures, appended to the JWT token + * @param undisclosedPayload Undisclosed payload JSON object, as contained in the JWT body + * @param digestedDisclosures Map of digests to parsed disclosures, which are appended to the JWT token + */ +@ExperimentalJsExport +@JsExport +data class SDPayload internal constructor( + val undisclosedPayload: JsonObject, + val digestedDisclosures: Map = mapOf(), +) { + /** + * Flat list of parsed disclosures, appended to the JWT token + */ + val sDisclosures + get() = digestedDisclosures.values + + /** + * Full payload, with all (selected) disclosures resolved recursively + */ + val fullPayload + get() = disclosePayloadRecursively(undisclosedPayload, null) + + /** + * SDMap regenerated from undisclosed payload and disclosures. + */ + val sdMap + get() = SDMap.regenerateSDMap(undisclosedPayload, digestedDisclosures) + + private fun disclosePayloadRecursively(payload: JsonObject, verificationDisclosureMap: MutableMap?): JsonObject { + return buildJsonObject { + payload.forEach { entry -> + if (entry.key == SDJwt.DIGESTS_KEY) { + if (entry.value !is JsonArray) throw Exception("SD-JWT contains invalid ${SDJwt.DIGESTS_KEY} element") + entry.value.jsonArray.forEach { + unveilDisclosureIfPresent(it.jsonPrimitive.content, this, verificationDisclosureMap) + } + } else if (entry.value is JsonObject) { + put(entry.key, disclosePayloadRecursively(entry.value.jsonObject, verificationDisclosureMap)) + } else { + put(entry.key, entry.value) + } + } + } + } + + private fun unveilDisclosureIfPresent( + digest: String, + objectBuilder: JsonObjectBuilder, + verificationDisclosureMap: MutableMap? + ) { + val sDisclosure = verificationDisclosureMap?.remove(digest) ?: digestedDisclosures[digest] + if (sDisclosure != null) { + objectBuilder.put( + sDisclosure.key, + if (sDisclosure.value is JsonObject) { + disclosePayloadRecursively(sDisclosure.value.jsonObject, verificationDisclosureMap) + } else sDisclosure.value + ) + } + } + + private fun filterDisclosures(currPayloadObject: JsonObject, sdMap: Map): Set { + if (currPayloadObject.containsKey(SDJwt.DIGESTS_KEY) && currPayloadObject[SDJwt.DIGESTS_KEY] !is JsonArray) { + throw Exception("Invalid ${SDJwt.DIGESTS_KEY} format found") + } + + return currPayloadObject.filter { entry -> entry.value is JsonObject && !sdMap[entry.key]?.children.isNullOrEmpty() } + .flatMap { entry -> + filterDisclosures(entry.value.jsonObject, sdMap[entry.key]!!.children!!) + }.plus( + currPayloadObject[SDJwt.DIGESTS_KEY]?.jsonArray + ?.map { it.jsonPrimitive.content } + ?.filter { digest -> digestedDisclosures.containsKey(digest) } + ?.map { digest -> digestedDisclosures[digest]!! } + ?.filter { sd -> sdMap[sd.key]?.sd == true } + ?.flatMap { sd -> + listOf(sd.disclosure).plus( + if (sd.value is JsonObject && !sdMap[sd.key]?.children.isNullOrEmpty()) { + filterDisclosures(sd.value, sdMap[sd.key]!!.children!!) + } else listOf() + ) + } ?: listOf() + ).toSet() + } + + /** + * Payload with selectively disclosed fields and undisclosed fields filtered out. + * @param sdMap Map indicating per field (recursively) whether they are selected for disclosure + */ + fun withSelectiveDisclosures(sdMap: Map): SDPayload { + val selectedDisclosures = filterDisclosures(undisclosedPayload, sdMap) + return SDPayload(undisclosedPayload, digestedDisclosures.filterValues { selectedDisclosures.contains(it.disclosure) }) + } + + /** + * Payload with all selectively dislosable fields filtered out (all fields undisclosed) + */ + fun withoutDisclosures(): SDPayload { + return SDPayload(undisclosedPayload, mapOf()) + } + + /** + * Verify digests in JWT payload match with disclosures appended to JWT token. + */ + fun verifyDisclosures() = digestedDisclosures.toMutableMap().also { + disclosePayloadRecursively(undisclosedPayload, it) + }.isEmpty() + + @JsExport.Ignore // see SDPayloadBuilder for JS support + companion object { + + private fun digest(value: String): String { + val messageDigest = value.encodeToByteArray().sha256() + return messageDigest.base64Url + } + + private fun generateSalt(): String { + val randomness = CryptographyRandom.nextBytes(16) + return Base64.encode(randomness, url = true) + } + + private fun generateDisclosure(key: String, value: JsonElement): SDisclosure { + val salt = generateSalt() + return Base64.encode(buildJsonArray { + add(salt) + add(key) + add(value) + }.toString().encodeToByteArray(), url = true).let { disclosure -> + SDisclosure(disclosure, salt, key, value) + } + } + + private fun digestSDClaim(key: String, value: JsonElement, digests2disclosures: MutableMap): String { + val disclosure = generateDisclosure(key, value) + return digest(disclosure.disclosure).also { + digests2disclosures[it] = disclosure + } + } + + private fun removeSDFields(payload: JsonObject, sdMap: Map): JsonObject { + return JsonObject(payload.filterKeys { key -> sdMap[key]?.sd != true }.mapValues { entry -> + if (entry.value is JsonObject && !sdMap[entry.key]?.children.isNullOrEmpty()) { + removeSDFields(entry.value.jsonObject, sdMap[entry.key]?.children ?: mapOf()) + } else { + entry.value + } + }) + } + + private fun generateSDPayload(payload: JsonObject, sdMap: SDMap, digests2disclosures: MutableMap): JsonObject { + val sdPayload = removeSDFields(payload, sdMap).toMutableMap() + val digests = payload.filterKeys { key -> + // iterate over all fields that are selectively disclosable AND/OR have nested fields that might be: + sdMap[key]?.sd == true || !sdMap[key]?.children.isNullOrEmpty() + }.map { entry -> + if (entry.value !is JsonObject || sdMap[entry.key]?.children.isNullOrEmpty()) { + // this field has no nested elements and/or is selectively disclosable only as a whole: + digestSDClaim(entry.key, entry.value, digests2disclosures) + } else { + // the nested properties could be selectively disclosable individually + // recursively generate SD payload for nested object: + val nestedSDPayload = generateSDPayload(entry.value.jsonObject, sdMap[entry.key]!!.children!!, digests2disclosures) + if (sdMap[entry.key]?.sd == true) { + // this nested object is also selectively disclosable as a whole + // so let's compute the digest and disclosure for the nested SD payload: + digestSDClaim(entry.key, nestedSDPayload, digests2disclosures) + } else { + // this nested object is not selectively disclosable as a whole, add the nested SD payload as it is: + sdPayload[entry.key] = nestedSDPayload + // no digest/disclosure is added for this field (though the nested properties may have generated digests and disclosures) + null + } + } + }.filterNotNull().toSet() + + if (digests.isNotEmpty()) { + sdPayload.put(SDJwt.DIGESTS_KEY, buildJsonArray { + digests.forEach { add(it) } + if (sdMap.decoyMode != DecoyMode.NONE && sdMap.decoys > 0) { + val numDecoys = when (sdMap.decoyMode) { + // NOTE: SecureRandom.nextInt always returns 0! Use nextDouble instead + DecoyMode.RANDOM -> SecureRandom.nextDouble(1.0, sdMap.decoys + 1.0).toInt() + DecoyMode.FIXED -> sdMap.decoys + else -> 0 + } + repeat(numDecoys) { + add(digest(generateSalt())) + } + } + }) + } + return JsonObject(sdPayload) + } + + /** + * Create SD payload object, based on full payload and disclosure map. + * **Not supported on JavaScript**, use _SDPayloadBuilder_ instead. + * @param fullPayload Full payload with all fields contained + * @param disclosureMap SDMap indicating selective disclosure for each payload field recursively, and decoy properties for issuance + */ + @JsExport.Ignore + fun createSDPayload(fullPayload: JsonObject, disclosureMap: SDMap): SDPayload { + val digestedDisclosures = mutableMapOf() + return SDPayload( + undisclosedPayload = generateSDPayload(fullPayload, disclosureMap, digestedDisclosures), + digestedDisclosures = digestedDisclosures + ) + } + + /** + * Create SD payload with JWT claims set (from platform dependent claims set object) and disclosure map. + * **Not supported on JavaScript**, use _SDPayloadBuilder_ instead. + * @param jwtClaimsSet Full payload with all fields contained + * @param disclosureMap SDMap indicating selective disclosure for each payload field recursively, and decoy properties for issuance + */ + @JsExport.Ignore + fun createSDPayload(jwtClaimsSet: JWTClaimsSet, disclosureMap: SDMap) = + createSDPayload(Json.parseToJsonElement(jwtClaimsSet.toString()).jsonObject, disclosureMap) + + /** + * Create SD payload based on full payload and undisclosed payload. + * **Not supported on JavaScript**, use _SDPayloadBuilder_ instead. + * @param fullPayload Full payload containing all fields + * @param undisclosedPayload Payload with selectively disclosable fields removed + * @param decoyMode **For SD-JWT issuance:** Generate decoy digests for this hierarchical level randomly or fixed, set to NONE for parsed SD-JWTs, **for presentation:** _unused_ + * @param decoys **For SD-JWT issuance:** Num (fixed mode) or max num (random mode) of decoy digests to add for this hierarchical level. 0 if NONE, **for presentation:** _unused_. + */ + @JsExport.Ignore + fun createSDPayload( + fullPayload: JsonObject, + undisclosedPayload: JsonObject, + decoyMode: DecoyMode = DecoyMode.NONE, + decoys: Int = 0 + ) = createSDPayload(fullPayload, SDMap.generateSDMap(fullPayload, undisclosedPayload, decoyMode, decoys)) + + /** + * Create SD payload based on full payload as JWT claims set and undisclosed payload. + * **Not supported on JavaScript**, use _SDPayloadBuilder_ instead. + * @param fullJWTClaimsSet Full payload containing all fields + * @param undisclosedPayload Payload with selectively disclosable fields removed + * @param decoyMode **For SD-JWT issuance:** Generate decoy digests for this hierarchical level randomly or fixed, set to NONE for parsed SD-JWTs, **for presentation:** _unused_ + * @param decoys **For SD-JWT issuance:** Num (fixed mode) or max num (random mode) of decoy digests to add for this hierarchical level. 0 if NONE, **for presentation:** _unused_. + */ + @JsExport.Ignore + fun createSDPayload( + fullJWTClaimsSet: JWTClaimsSet, + undisclosedJWTClaimsSet: JWTClaimsSet, + decoyMode: DecoyMode = DecoyMode.NONE, + decoys: Int = 0 + ) = createSDPayload( + Json.parseToJsonElement(fullJWTClaimsSet.toString()).jsonObject, + Json.parseToJsonElement(undisclosedJWTClaimsSet.toString()).jsonObject, + decoyMode, decoys + ) + + /** + * Parse SD payload from JWT body and disclosure strings appended to JWT token + * @param jwtBody Undisclosed JWT body payload + * @param disclosures Encoded disclosure string, as appended to JWT token + */ + fun parse(jwtBody: String, disclosures: Set): SDPayload { + return SDPayload( + Json.parseToJsonElement(Base64.decode(jwtBody, url = true).decodeToString()).jsonObject, + disclosures.associate { Pair(digest(it), SDisclosure.parse(it)) }) + } + } +} diff --git a/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDisclosure.kt b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDisclosure.kt new file mode 100644 index 000000000..d3f072b16 --- /dev/null +++ b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/SDisclosure.kt @@ -0,0 +1,37 @@ +package id.walt.sdjwt + +import korlibs.crypto.encoding.Base64 +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import kotlin.js.ExperimentalJsExport +import kotlin.js.JsExport + +/** + * Selective Disclosure for a given payload field. Contains salt, field key and field value. + * @param disclosure The encoded disclosure, as given in the SD-JWT token. + * @param salt Salt value + * @param key Field key + * @param value Field value + */ +@ExperimentalJsExport +@JsExport +data class SDisclosure internal constructor( + val disclosure: String, + val salt: String, + val key: String, + val value: JsonElement +) { + companion object { + /** + * Parse an encoded disclosure string + */ + fun parse(disclosure: String) = Json.parseToJsonElement(Base64.decode(disclosure, url = true).decodeToString()).jsonArray.let { + if (it.size != 3) { + throw Exception("Invalid selective disclosure") + } + SDisclosure(disclosure, it[0].jsonPrimitive.content, it[1].jsonPrimitive.content, it[2]) + } + } +} diff --git a/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/VerificationResult.kt b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/VerificationResult.kt new file mode 100644 index 000000000..bc6c652dd --- /dev/null +++ b/waltid-sdjwt/src/commonMain/kotlin/id/walt/sdjwt/VerificationResult.kt @@ -0,0 +1,23 @@ +package id.walt.sdjwt + +import kotlin.js.ExperimentalJsExport +import kotlin.js.JsExport + +@ExperimentalJsExport +@JsExport +data class JwtVerificationResult( + val verified: Boolean, + val message: String? = null +) + +@ExperimentalJsExport +@JsExport +data class VerificationResult( + val sdJwt: T, + val signatureVerified: Boolean, + val disclosuresVerified: Boolean, + val message: String? = null +) { + val verified + get() = signatureVerified && disclosuresVerified +} diff --git a/waltid-sdjwt/src/commonTest/kotlin/id/walt/sdjwt/SDJwtTest.kt b/waltid-sdjwt/src/commonTest/kotlin/id/walt/sdjwt/SDJwtTest.kt new file mode 100644 index 000000000..f55514c67 --- /dev/null +++ b/waltid-sdjwt/src/commonTest/kotlin/id/walt/sdjwt/SDJwtTest.kt @@ -0,0 +1,138 @@ +package id.walt.sdjwt + +import io.kotest.assertions.json.shouldEqualJson +import io.kotest.matchers.collections.shouldNotContainAnyOf +import io.kotest.matchers.ints.shouldBeInRange +import io.kotest.matchers.maps.shouldContainKey +import io.kotest.matchers.maps.shouldNotContainKey +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import kotlinx.serialization.json.* +import kotlin.test.Test + +class SDJwtTest { + @Test + fun testParseSdJwt() { + val sdJwtString = + "eyJraWQiOiJkaWQ6a2V5Ono2TWtuM2dWOFY2M2JScEJNdEFwbm5BaWhDTXZEYVBlcno2aWFyMURwZE5LZTNrMSN6Nk1rbjNnVjhWNjNiUnBCTXRBcG5uQWloQ012RGFQZXJ6NmlhcjFEcGROS2UzazEiLCJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9.eyJzdWIiOiJkaWQ6a2V5Ono2TWtuM2dWOFY2M2JScEJNdEFwbm5BaWhDTXZEYVBlcno2aWFyMURwZE5LZTNrMSIsIm5iZiI6MTY4NDkxOTg2NCwiaXNzIjoiZGlkOmtleTp6Nk1rbjNnVjhWNjNiUnBCTXRBcG5uQWloQ012RGFQZXJ6NmlhcjFEcGROS2UzazEiLCJpYXQiOjE2ODQ5MTk4NjQsInZjIjp7InR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJWZXJpZmlhYmxlQXR0ZXN0YXRpb24iLCJWZXJpZmlhYmxlSWQiXSwiQGNvbnRleHQiOlsiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiXSwiaWQiOiJ1cm46dXVpZDplMDNjMDY2OC0yMDhmLTRkNzctYTBjNi02ZDBkZjAxYWRmYWQiLCJpc3N1ZXIiOiJkaWQ6a2V5Ono2TWtuM2dWOFY2M2JScEJNdEFwbm5BaWhDTXZEYVBlcno2aWFyMURwZE5LZTNrMSIsImlzc3VhbmNlRGF0ZSI6IjIwMjMtMDUtMjRUMDk6MTc6NDRaIiwiaXNzdWVkIjoiMjAyMy0wNS0yNFQwOToxNzo0NFoiLCJ2YWxpZEZyb20iOiIyMDIzLTA1LTI0VDA5OjE3OjQ0WiIsImNyZWRlbnRpYWxTY2hlbWEiOnsiaWQiOiJodHRwczovL3Jhdy5naXRodWJ1c2VyY29udGVudC5jb20vd2FsdC1pZC93YWx0aWQtc3Npa2l0LXZjbGliL21hc3Rlci9zcmMvdGVzdC9yZXNvdXJjZXMvc2NoZW1hcy9WZXJpZmlhYmxlSWQuanNvbiIsInR5cGUiOiJGdWxsSnNvblNjaGVtYVZhbGlkYXRvcjIwMjEifSwiZXZpZGVuY2UiOlt7ImRvY3VtZW50UHJlc2VuY2UiOlsiUGh5c2ljYWwiXSwiZXZpZGVuY2VEb2N1bWVudCI6WyJQYXNzcG9ydCJdLCJzdWJqZWN0UHJlc2VuY2UiOiJQaHlzaWNhbCIsInR5cGUiOlsiRG9jdW1lbnRWZXJpZmljYXRpb24iXSwidmVyaWZpZXIiOiJkaWQ6ZWJzaToyQTlCWjlTVWU2QmF0YWNTcHZzMVY1Q2RqSHZMcFE3YkVzaTJKYjZMZEhLblF4YU4ifV0sIl9zZCI6WyJzRkoxY1BOR2d5NktrRzAxOW9tdFBvVm5GYXR6clRXQkV0Si1yQmpzQU1VIl19LCJqdGkiOiJ1cm46dXVpZDplMDNjMDY2OC0yMDhmLTRkNzctYTBjNi02ZDBkZjAxYWRmYWQifQ.XqhNYYB9CITa0KCMOj1v1tbQvj3sfuDmGaKL3sDDJndQPGEa_QosbunSnBef5O4optTslUOSaplR7BiTiY2cCQ~WyJfZ2RWc3dIS2U2b3ZVMElYT3NXQ1Z3IiwiZGF0ZU9mQmlydGgiLCIxOTgzLTA3LTA1Il0~WyJsYVpHcU5rczE1YU5USEFvYnFfWEx3IiwiZmlyc3ROYW1lIiwiU2V2ZXJpbiJd~WyJDc1FfVkFaeUVzVmxSWWQ1YjJPNWhRIiwiY3JlZGVudGlhbFN1YmplY3QiLHsiaWQiOiJkaWQ6a2V5Ono2TWtuM2dWOFY2M2JScEJNdEFwbm5BaWhDTXZEYVBlcno2aWFyMURwZE5LZTNrMSIsImN1cnJlbnRBZGRyZXNzIjpbIlZpZW5uYSJdLCJmYW1pbHlOYW1lIjoiU3RhbXBsZXIiLCJnZW5kZXIiOiJtYWxlIiwibmFtZUFuZEZhbWlseU5hbWVBdEJpcnRoIjoiSmFuZSBET0UiLCJwZXJzb25hbElkZW50aWZpZXIiOiIwOTA0MDA4MDg0SCIsInBsYWNlT2ZCaXJ0aCI6IkdyYXoiLCJfc2QiOlsiUXh4ZXlNbHk3dU9feWNRaThuU2Zmb2VUb2JQSDRVZ0JfVGh3LUMyMTJDNCIsImNJeXVGWVNBUjAtZ2JpMGxQbmZTWVQySExEdlpsTlBOSGoxUGY0am9uek0iXX1d" + val sdJwt = SDJwt.parse(sdJwtString) + sdJwt.undisclosedPayload shouldContainKey "sub" + sdJwt.undisclosedPayload shouldContainKey "vc" + sdJwt.undisclosedPayload["vc"]!!.jsonObject shouldContainKey SDJwt.DIGESTS_KEY + sdJwt.undisclosedPayload["vc"]!!.jsonObject shouldNotContainKey "credentialSubject" + + sdJwt.fullPayload["vc"]!!.jsonObject shouldContainKey "credentialSubject" + + println(sdJwt.fullPayload.toString()) + } + + @Test + fun testSDPayloadGeneration() { + val fullPayload = buildJsonObject { + put("sub", "1234") + put("nestedObject", buildJsonObject { + put("arrProp", buildJsonArray { + add("item 1") + add("item 2") + }) + }) + } + + val sdPayload_1 = SDPayload.createSDPayload(fullPayload, buildJsonObject { }) + + sdPayload_1.undisclosedPayload shouldContainKey SDJwt.DIGESTS_KEY + sdPayload_1.undisclosedPayload.keys shouldNotContainAnyOf setOf("sub", "nestedObject") + sdPayload_1.fullPayload.toString() shouldEqualJson fullPayload.toString() + sdPayload_1.undisclosedPayload[SDJwt.DIGESTS_KEY]!!.jsonArray.size shouldBe sdPayload_1.digestedDisclosures.size + + + val sdPayload_2 = SDPayload.createSDPayload(fullPayload, buildJsonObject { + put("nestedObject", buildJsonObject { }) + }) + + sdPayload_2.undisclosedPayload shouldContainKey SDJwt.DIGESTS_KEY + sdPayload_2.undisclosedPayload shouldContainKey "nestedObject" + sdPayload_2.undisclosedPayload shouldNotContainKey "sub" + sdPayload_2.undisclosedPayload["nestedObject"]!!.jsonObject shouldContainKey SDJwt.DIGESTS_KEY + sdPayload_2.undisclosedPayload["nestedObject"]!!.jsonObject shouldNotContainKey "arrProp" + sdPayload_2.fullPayload.toString() shouldEqualJson fullPayload.toString() + (sdPayload_2.undisclosedPayload[SDJwt.DIGESTS_KEY]!!.jsonArray.size + + sdPayload_2.undisclosedPayload["nestedObject"]!!.jsonObject[SDJwt.DIGESTS_KEY]!!.jsonArray.size) shouldBe sdPayload_2.digestedDisclosures.size + + + val sdPayload_3 = SDPayload.createSDPayload( + fullPayload, mapOf( + "sub" to SDField(true), + "nestedObject" to SDField( + true, mapOf( + "arrProp" to SDField(true) + ).toSDMap() + ) + ).toSDMap() + ) + + sdPayload_3.undisclosedPayload shouldContainKey SDJwt.DIGESTS_KEY + sdPayload_3.undisclosedPayload.keys shouldNotContainAnyOf setOf("sub", "nestedObject") + val nestedDisclosure = sdPayload_3.sDisclosures.firstOrNull() { sd -> sd.key == "nestedObject" && sd.value is JsonObject } + nestedDisclosure shouldNotBe null + nestedDisclosure!!.value.jsonObject shouldContainKey SDJwt.DIGESTS_KEY + nestedDisclosure.value.jsonObject shouldNotContainKey "arrProp" + sdPayload_3.fullPayload.toString() shouldEqualJson fullPayload.toString() + sdPayload_3.undisclosedPayload[SDJwt.DIGESTS_KEY]!!.jsonArray.size + + nestedDisclosure.value.jsonObject[SDJwt.DIGESTS_KEY]!!.jsonArray.size shouldBe sdPayload_3.digestedDisclosures.size + } + + @Test + fun testSDPayloadGenerationWithDecoys() { + val fullPayload = buildJsonObject { + put("sub", "1234") + put("nestedObject", buildJsonObject { + put("arrProp", buildJsonArray { + add("item 1") + add("item 2") + }) + }) + } + + val sdPayload_4 = SDPayload.createSDPayload( + fullPayload, mapOf( + "sub" to SDField(true), + "nestedObject" to SDField( + true, mapOf( + "arrProp" to SDField(true) + ).toSDMap(decoyMode = DecoyMode.FIXED, decoys = 5) + ) + ).toSDMap(decoyMode = DecoyMode.RANDOM, decoys = 5) + ) + + sdPayload_4.undisclosedPayload shouldContainKey SDJwt.DIGESTS_KEY + sdPayload_4.undisclosedPayload.keys shouldNotContainAnyOf setOf("sub", "nestedObject") + val nestedDisclosure = sdPayload_4.sDisclosures.firstOrNull() { sd -> sd.key == "nestedObject" && sd.value is JsonObject } + nestedDisclosure shouldNotBe null + nestedDisclosure!!.value.jsonObject shouldContainKey SDJwt.DIGESTS_KEY + nestedDisclosure.value.jsonObject shouldNotContainKey "arrProp" + val numSdFieldsLevel1 = sdPayload_4.sdMap.count { it.value.sd } + sdPayload_4.undisclosedPayload[SDJwt.DIGESTS_KEY]!!.jsonArray.size shouldBeInRange IntRange( + numSdFieldsLevel1 + 1, + numSdFieldsLevel1 + 5 + ) + val numSdFieldsLevel2 = sdPayload_4.sdMap["nestedObject"]!!.children!!.count { it.value.sd } + nestedDisclosure.value.jsonObject[SDJwt.DIGESTS_KEY]!!.jsonArray.size shouldBe numSdFieldsLevel2 + 5 + } + + @Test + fun testSdMapFromJsonPaths() { + val sdmap1 = SDMap.generateSDMap(listOf("credentialSubject", "credentialSubject.firstName")) + sdmap1 shouldContainKey "credentialSubject" + sdmap1["credentialSubject"]!!.sd shouldBe true + sdmap1["credentialSubject"]!!.children!! shouldContainKey "firstName" + sdmap1["credentialSubject"]!!.children!!["firstName"]!!.sd shouldBe true + + val sdmap2 = SDMap.generateSDMap(listOf("credentialSubject.firstName")) + sdmap2 shouldContainKey "credentialSubject" + sdmap2["credentialSubject"]!!.sd shouldBe false + sdmap2["credentialSubject"]!!.children!! shouldContainKey "firstName" + sdmap2["credentialSubject"]!!.children!!["firstName"]!!.sd shouldBe true + + } +} diff --git a/waltid-sdjwt/src/iosMain/kotlin/id/walt/sdjwt/ByteArray+NSDAta.kt b/waltid-sdjwt/src/iosMain/kotlin/id/walt/sdjwt/ByteArray+NSDAta.kt new file mode 100644 index 000000000..9d8e1723b --- /dev/null +++ b/waltid-sdjwt/src/iosMain/kotlin/id/walt/sdjwt/ByteArray+NSDAta.kt @@ -0,0 +1,15 @@ +package id.walt.sdjwt + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.pin +import platform.Foundation.NSData +import platform.Foundation.create + +@OptIn(ExperimentalForeignApi::class) +internal inline fun ByteArray.toData(offset: Int = 0, length: Int = size - offset): NSData { + require(offset + length <= size) { "offset + length > size" } + if (isEmpty()) return NSData() + val pinned = pin() + return NSData.create(pinned.addressOf(offset), length.toULong()) { _, _ -> pinned.unpin() } +} \ No newline at end of file diff --git a/waltid-sdjwt/src/iosMain/kotlin/id/walt/sdjwt/DigitalSignaturesJWTCryptoProvider.kt b/waltid-sdjwt/src/iosMain/kotlin/id/walt/sdjwt/DigitalSignaturesJWTCryptoProvider.kt new file mode 100644 index 000000000..1d8d41466 --- /dev/null +++ b/waltid-sdjwt/src/iosMain/kotlin/id/walt/sdjwt/DigitalSignaturesJWTCryptoProvider.kt @@ -0,0 +1,37 @@ +import id.walt.sdjwt.JWTCryptoProvider +import id.walt.sdjwt.JwtVerificationResult +import id.walt.sdjwt.cinterop.ios.DS_Operations +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.serialization.json.JsonObject +import platform.Security.* +import kotlin.js.ExperimentalJsExport + +@ExperimentalJsExport +@OptIn(ExperimentalForeignApi::class) +class DigitalSignaturesJWTCryptoProvider(private val algorithm: String, private val key: SecKeyRef) : + JWTCryptoProvider { + override fun sign(payload: JsonObject, keyID: String?, typ: String): String { + + val result = DS_Operations.signWithBody( + body = payload.toString(), + alg = algorithm, + key = key, + typ = typ, + keyId = keyID + ) + + return when { + result.success() -> result.data()!! + else -> result.errorMessage() ?: "" + } + } + + override fun verify(jwt: String): JwtVerificationResult { + val result = DS_Operations.verifyWithJws(jws = jwt, key = key) + + return when { + result.success() -> JwtVerificationResult(result.success()!!) + else -> JwtVerificationResult(false, message = result.errorMessage() ?: "") + } + } +} \ No newline at end of file diff --git a/waltid-sdjwt/src/iosMain/kotlin/id/walt/sdjwt/HMACJWTCryptoProvider.kt b/waltid-sdjwt/src/iosMain/kotlin/id/walt/sdjwt/HMACJWTCryptoProvider.kt new file mode 100644 index 000000000..7f2ebe64e --- /dev/null +++ b/waltid-sdjwt/src/iosMain/kotlin/id/walt/sdjwt/HMACJWTCryptoProvider.kt @@ -0,0 +1,40 @@ +package id.walt.sdjwt + +import id.walt.sdjwt.cinterop.ios.* +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.pin +import kotlinx.serialization.json.JsonObject +import platform.Foundation.NSData +import platform.Foundation.create +import kotlin.js.ExperimentalJsExport + +@ExperimentalJsExport +@OptIn(ExperimentalForeignApi::class) +class HMACJWTCryptoProvider(private val algorithm: String, private val key: ByteArray) : + JWTCryptoProvider { + override fun sign(payload: JsonObject, keyID: String?, typ: String): String { + + val result = HMAC_Operations.signWithBody( + body = payload.toString(), + alg = algorithm, + key = key.toData(), + typ = typ, + keyId = keyID + ) + + return when { + result.success() -> result.data()!! + else -> result.errorMessage() ?: "" + } + } + + override fun verify(jwt: String): JwtVerificationResult { + val result = HMAC_Operations.verifyWithJws(jws = jwt, key = key.toData()) + + return when { + result.success() -> JwtVerificationResult(result.success()!!) + else -> JwtVerificationResult(false, message = result.errorMessage() ?: "") + } + } +} \ No newline at end of file diff --git a/waltid-sdjwt/src/iosMain/kotlin/id/walt/sdjwt/JWTClaimsSet.kt b/waltid-sdjwt/src/iosMain/kotlin/id/walt/sdjwt/JWTClaimsSet.kt new file mode 100644 index 000000000..74c7eb659 --- /dev/null +++ b/waltid-sdjwt/src/iosMain/kotlin/id/walt/sdjwt/JWTClaimsSet.kt @@ -0,0 +1,9 @@ +package id.walt.sdjwt + +/** + * Expected class for JWT claim set in platform specific implementation. Not necessarily required. + */ +actual class JWTClaimsSet { + actual override fun toString(): String = "" + +} diff --git a/waltid-sdjwt/src/iosTest/kotlin/id.walt.sdjwt/SDJwtTestIOS.kt b/waltid-sdjwt/src/iosTest/kotlin/id.walt.sdjwt/SDJwtTestIOS.kt new file mode 100644 index 000000000..74d97858b --- /dev/null +++ b/waltid-sdjwt/src/iosTest/kotlin/id.walt.sdjwt/SDJwtTestIOS.kt @@ -0,0 +1,104 @@ +package id.walt.sdjwt + +import io.kotest.assertions.json.shouldMatchJson +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.maps.shouldContainKey +import io.kotest.matchers.maps.shouldNotContainKey +import io.kotest.matchers.shouldBe +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test + +class SDJwtTestIOS { + private val sharedSecret = "ef23f749-7238-481a-815c-f0c2157dfa8e" + + @Test + fun testSignJwt() { + val cryptoProvider = HMACJWTCryptoProvider("HS256", sharedSecret.encodeToByteArray()) + + val originalSet = mutableMapOf ( + "sub" to JsonPrimitive("123"), + "aud" to JsonPrimitive("456") + ) + + val originalClaimsSet = JsonObject(originalSet) + + // Create undisclosed claims set, by removing e.g. subject property from original claims set + val undisclosedSet = mutableMapOf ( + "aud" to JsonPrimitive("456") + ) + + val undisclosedClaimsSet = JsonObject(undisclosedSet) + + // Create SD payload by comparing original claims set with undisclosed claims set + val sdPayload = SDPayload.createSDPayload(originalClaimsSet, undisclosedClaimsSet) + + // Create and sign SD-JWT using the generated SD payload and the previously configured crypto provider + val sdJwt = SDJwt.sign(sdPayload, cryptoProvider) + // Print SD-JWT + println(sdJwt) + + sdJwt.undisclosedPayload shouldNotContainKey "sub" + sdJwt.undisclosedPayload shouldContainKey SDJwt.DIGESTS_KEY + sdJwt.undisclosedPayload shouldContainKey "aud" + sdJwt.disclosures shouldHaveSize 1 + sdJwt.digestedDisclosures[sdJwt.undisclosedPayload[SDJwt.DIGESTS_KEY]!!.jsonArray[0].jsonPrimitive.content]!!.key shouldBe "sub" + sdJwt.fullPayload.toString() shouldMatchJson originalClaimsSet.toString() + + sdJwt.verify(cryptoProvider).verified shouldBe true + } + + @Test + fun presentSDJwt() { + // parse previously created SD-JWT + val sdJwt = + SDJwt.parse("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~WyJ4RFk5VjBtOG43am82ZURIUGtNZ1J3Iiwic3ViIiwiMTIzIl0") + + // present without disclosing SD fields + val presentedUndisclosedJwt = sdJwt.present(discloseAll = false) + println(presentedUndisclosedJwt) + + // present disclosing all SD fields + val presentedDisclosedJwt = sdJwt.present(discloseAll = true) + println(presentedDisclosedJwt) + + // present disclosing selective fields, using SDMap + val presentedSelectiveJwt = sdJwt.present(SDMapBuilder().addField("sub", true).build()) + println(presentedSelectiveJwt) + + // present disclosing fields, using JSON paths + val presentedSelectiveJwt2 = sdJwt.present(SDMap.generateSDMap(listOf("sub"))) + println(presentedSelectiveJwt2) + + } + + @Test + fun parseAndVerify() { + // Create SimpleJWTCryptoProvider with MACSigner and MACVerifier + val cryptoProvider = HMACJWTCryptoProvider("HS256", sharedSecret.encodeToByteArray()) + val undisclosedJwt = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~" + + // verify and parse presented SD-JWT with all fields undisclosed, throws Exception if verification fails! + val parseAndVerifyResult = SDJwt.verifyAndParse(undisclosedJwt, cryptoProvider) + + // print full payload with disclosed fields only + println("Undisclosed JWT payload:") + println(parseAndVerifyResult.sdJwt.fullPayload.toString()) + + // alternatively parse and verify in 2 steps: + val parsedUndisclosedJwt = SDJwt.parse(undisclosedJwt) + val isValid = parsedUndisclosedJwt.verify(cryptoProvider).verified + println("Undisclosed SD-JWT verified: $isValid") + + val parsedDisclosedJwtVerifyResult = SDJwt.verifyAndParse( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~WyJ4RFk5VjBtOG43am82ZURIUGtNZ1J3Iiwic3ViIiwiMTIzIl0~", + cryptoProvider + ) + // print full payload with disclosed fields + println("Disclosed JWT payload:") + println(parsedDisclosedJwtVerifyResult.sdJwt.fullPayload.toString()) + } +} \ No newline at end of file diff --git a/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/JSAsyncJWTCryptoProvider.kt b/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/JSAsyncJWTCryptoProvider.kt new file mode 100644 index 000000000..ba22c5e15 --- /dev/null +++ b/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/JSAsyncJWTCryptoProvider.kt @@ -0,0 +1,10 @@ +package id.walt.sdjwt + +import kotlin.js.Promise + +@ExperimentalJsExport +@JsExport +interface JSAsyncJWTCryptoProvider : AsyncJWTCryptoProvider { + fun signAsync(payload: dynamic, keyID: String?): Promise + fun verifyAsync(jwt: String): Promise +} diff --git a/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/JWTClaimsSet.kt b/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/JWTClaimsSet.kt new file mode 100644 index 000000000..1baee9df2 --- /dev/null +++ b/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/JWTClaimsSet.kt @@ -0,0 +1,3 @@ +package id.walt.sdjwt + +actual typealias JWTClaimsSet = jose.JWTPayload \ No newline at end of file diff --git a/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/SDJwtJS.kt b/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/SDJwtJS.kt new file mode 100644 index 000000000..7c3c313f3 --- /dev/null +++ b/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/SDJwtJS.kt @@ -0,0 +1,94 @@ +package id.walt.sdjwt + +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.promise +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import kotlin.js.Promise + +@ExperimentalJsExport +@JsExport +class SDJwtJS( + sdJwt: SDJwt +) : SDJwt(sdJwt) { + + @JsName("disclosures") + val disclosuresJS + get() = sdPayload.sDisclosures.map { it.disclosure }.toTypedArray() + + @JsName("disclosureObjects") + val disclosureObjectsJS + get() = sdPayload.sDisclosures.map { + JSON.parse(buildJsonObject { + put("disclosure", it.disclosure) + put("salt", it.salt) + put("key", it.key) + put("value", it.value) + }.toString()) + }.toTypedArray() + + @JsName("undisclosedPayload") + val undisclosedPayloadJS + get() = JSON.parse(sdPayload.undisclosedPayload.toString()) + + @JsName("fullPayload") + val fullPayloadJS + get() = JSON.parse(sdPayload.fullPayload.toString()) + + @JsName("sdMap") + val sdMapJS + get() = JSON.parse(sdPayload.sdMap.toJSON().toString()) + + @OptIn(DelicateCoroutinesApi::class) + @JsName("verifyAsync") + fun verifyAsyncJs(jwtCryptoProvider: JSAsyncJWTCryptoProvider): Promise> = GlobalScope.promise { + verifyAsync(jwtCryptoProvider).let { + VerificationResult(SDJwtJS(it.sdJwt), it.signatureVerified, it.disclosuresVerified, it.message) + } + } + + @OptIn(DelicateCoroutinesApi::class) + fun presentAllAsync(discloseAll: Boolean, withHolderJwt: String? = null): Promise = GlobalScope.promise { + SDJwtJS( + present(discloseAll, withHolderJwt) + ) + } + + @OptIn(DelicateCoroutinesApi::class) + fun presentAsync(sdMap: dynamic, withHolderJwt: String? = null): Promise = GlobalScope.promise { + SDJwtJS( + present(SDMap.fromJSON(JSON.stringify(sdMap)), withHolderJwt) + ) + } + + override fun toString(formatForPresentation: Boolean): String { + println("Formatting SD_JWT: ${disclosuresJS.joinToString(",")}") + return listOf(jwt) + .plus(disclosuresJS) + .plus(holderJwt?.let { listOf(it) } ?: (if (formatForPresentation) listOf("") else listOf())) + .joinToString(SEPARATOR_STR) + } + + companion object { + @OptIn(DelicateCoroutinesApi::class) + fun verifyAndParseAsync(sdJwt: String, jwtCryptoProvider: JSAsyncJWTCryptoProvider): Promise> = + GlobalScope.promise { + SDJwt.verifyAndParseAsync(sdJwt, jwtCryptoProvider).let { + VerificationResult(SDJwtJS(it.sdJwt), it.signatureVerified, it.disclosuresVerified, it.message) + } + } + + @OptIn(DelicateCoroutinesApi::class) + fun signAsync( + sdPayload: SDPayload, + jwtCryptoProvider: JSAsyncJWTCryptoProvider, + keyID: String? = null, + withHolderJwt: String? = null + ): Promise = GlobalScope.promise { + SDJwtJS( + SDJwt.signAsync(sdPayload, jwtCryptoProvider) + ) + } + } +} diff --git a/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/SDMapBuilderJS.kt b/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/SDMapBuilderJS.kt new file mode 100644 index 000000000..fbb4293f0 --- /dev/null +++ b/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/SDMapBuilderJS.kt @@ -0,0 +1,31 @@ +package id.walt.sdjwt + +@ExperimentalJsExport +@JsExport +@JsName("SDMapBuilder") +class SDMapBuilderJS( + private val decoyMode: String = DecoyMode.NONE.name, + private val numDecoys: Int = 0 +) { + private val fields = mutableMapOf() + + fun addField(key: String, sd: Boolean, children: dynamic = null): SDMapBuilderJS { + val childrenSdMap = if (children != null) { + SDMap.fromJSON(JSON.stringify(children)) + } else null + fields[key] = SDField(sd, childrenSdMap) + return this + } + + fun build(): dynamic { + return JSON.parse( + SDMap( + fields, DecoyMode.valueOf(decoyMode), numDecoys + ).toJSON().toString() + ) + } + + fun buildFromJsonPaths(jsonPaths: Array): dynamic { + return JSON.parse(SDMap.generateSDMap(jsonPaths.toList(), decoyMode = DecoyMode.valueOf(decoyMode), numDecoys).toJSON().toString()) + } +} diff --git a/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/SDPayloadBuilder.kt b/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/SDPayloadBuilder.kt new file mode 100644 index 000000000..beb9b0f73 --- /dev/null +++ b/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/SDPayloadBuilder.kt @@ -0,0 +1,24 @@ +package id.walt.sdjwt + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject + +@ExperimentalJsExport +@JsExport +class SDPayloadBuilder( + val fullPayload: dynamic +) { + fun buildForUndisclosedPayload(undisclosedSDPayload: dynamic): SDPayload { + return SDPayload.createSDPayload( + Json.parseToJsonElement(JSON.stringify(fullPayload)).jsonObject, + Json.parseToJsonElement(JSON.stringify(undisclosedSDPayload)).jsonObject + ) + } + + fun buildForSDMap(sdMap: dynamic): SDPayload { + return SDPayload.createSDPayload( + Json.parseToJsonElement(JSON.stringify(fullPayload)).jsonObject, + SDMap.Companion.fromJSON(JSON.stringify(sdMap)) + ) + } +} diff --git a/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/SimpleAsyncJWTCryptoProvider.kt b/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/SimpleAsyncJWTCryptoProvider.kt new file mode 100644 index 000000000..3494cc1e0 --- /dev/null +++ b/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/SimpleAsyncJWTCryptoProvider.kt @@ -0,0 +1,63 @@ +package id.walt.sdjwt + +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.promise +import kotlinx.serialization.json.* +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * Expected default implementation for JWTCryptoProvider on each platform + * Implemented in platform specific modules + * @see JWTCryptoProvider + */ +@ExperimentalJsExport +@JsExport +open class SimpleAsyncJWTCryptoProvider( + private val algorithm: String, + private val keyParam: dynamic, + private val options: dynamic +) : JSAsyncJWTCryptoProvider { + @JsExport.Ignore + override suspend fun sign(payload: JsonObject, keyID: String?): String = suspendCoroutine { continuation -> + console.log("SIGNING", payload.toString()) + jose.SignJWT(JSON.parse(payload.toString())).setProtectedHeader(buildJsonObject { + put("alg", algorithm) + put("typ", "JWT") + //put("cty", "credential-claims-set+json") + //put("typ", "vc+sd-jwt") + keyID?.also { put("kid", it) } + }.let { JSON.parse(it.toString()) }).sign(keyParam, options).then({ + console.log("SIGNED") + continuation.resume(it) + }, { + console.log("ERROR SIGNING", it.message) + }) + } + + @JsExport.Ignore + override suspend fun verify(jwt: String): JwtVerificationResult = suspendCoroutine { continuation -> + console.log("Verifying JWT: $jwt") + jose.jwtVerify(jwt, keyParam, options ?: js("{}")).then( + { + console.log("Verified.") + continuation.resume(JwtVerificationResult(true)) + }, + { + console.log("Verification failed: ${it.message}") + continuation.resume(JwtVerificationResult(false, it.message)) + } + ) + } + + @OptIn(DelicateCoroutinesApi::class) + override fun signAsync(payload: dynamic, keyID: String?) = GlobalScope.promise { + sign(Json.parseToJsonElement(JSON.stringify(payload)).jsonObject, keyID) + } + + @OptIn(DelicateCoroutinesApi::class) + override fun verifyAsync(jwt: String) = GlobalScope.promise { + verify(jwt) + } +} diff --git a/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/jose.kt b/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/jose.kt new file mode 100644 index 000000000..03b6be153 --- /dev/null +++ b/waltid-sdjwt/src/jsMain/kotlin/id/walt/sdjwt/jose.kt @@ -0,0 +1,40 @@ +package id.walt.sdjwt + +import kotlin.js.Promise + +@JsModule("jose") +@JsNonModule +external object jose { + class SignJWT(payload: dynamic) { + fun setProtectedHeader(protectedHeader: dynamic): SignJWT + fun setIssuer(issuer: String?): SignJWT + fun setSubject(subject: String?): SignJWT + fun setAudience(audience: String?): SignJWT + fun setAudience(audience: Array?): SignJWT + fun setJti(jwtId: String?): SignJWT + fun setNotBefore(input: Number?): SignJWT + fun setNotBefore(input: String?): SignJWT + fun setExpirationTime(input: Number?): SignJWT + fun setExpirationTime(input: String?): SignJWT + fun setIssuedAt(input: Number?): SignJWT + + fun sign(key: dynamic, options: dynamic): Promise + } + + class JWTPayload { + val sub: String + val aud: String + val exp: Number + val iat: Number + val iss: String + val nbf: Number + val jti: String + } + + class JWTVerifyResult { + val payload: JWTPayload + val protectedHeader: dynamic + } + + fun jwtVerify(jwt: String, key: dynamic, options: dynamic): Promise +} diff --git a/waltid-sdjwt/src/jsTest/kotlin/id.walt.sdjwt/SDJwtTestJS.kt b/waltid-sdjwt/src/jsTest/kotlin/id.walt.sdjwt/SDJwtTestJS.kt new file mode 100644 index 000000000..d740254e3 --- /dev/null +++ b/waltid-sdjwt/src/jsTest/kotlin/id.walt.sdjwt/SDJwtTestJS.kt @@ -0,0 +1,73 @@ +package id.walt.sdjwt + +import io.kotest.assertions.json.shouldEqualJson +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.maps.shouldContainKey +import io.kotest.matchers.maps.shouldNotContainKey +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.await +import kotlinx.coroutines.promise +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import org.khronos.webgl.Uint8Array +import kotlin.test.Test + +class SDJwtTestJS { + + // Generate shared secret for HMAC crypto algorithm + private val sharedSecret = "ef23f749-7238-481a-815c-f0c2157dfa8e" + + @OptIn(DelicateCoroutinesApi::class, ExperimentalJsExport::class) + @Test + fun test1() = GlobalScope.promise { + val cryptoProvider = SimpleAsyncJWTCryptoProvider( + "HS256", + Uint8Array(sharedSecret.encodeToByteArray().toTypedArray()), null + ) + // Create original JWT claims set, using nimbusds claims set builder + val originalClaimsSet = js("{ \"sub\": \"123\", \"aud\": \"456\" }") + + // Create undisclosed claims set, by removing e.g. subject property from original claims set + val undisclosedClaimsSet = js("{ \"aud\": \"456\" }") + + // Create SD payload by comparing original claims set with undisclosed claims set + val sdPayload = SDPayloadBuilder(originalClaimsSet).buildForUndisclosedPayload(undisclosedClaimsSet) + + // Create and sign SD-JWT using the generated SD payload and the previously configured crypto provider + val sdJwt = SDJwtJS.signAsync(sdPayload, cryptoProvider).await() + // Print SD-JWT + println(sdJwt) + + sdJwt.undisclosedPayload shouldNotContainKey "sub" + sdJwt.undisclosedPayload shouldContainKey SDJwt.DIGESTS_KEY + sdJwt.undisclosedPayload shouldContainKey "aud" + sdJwt.disclosures shouldHaveSize 1 + sdJwt.digestedDisclosures[sdJwt.undisclosedPayload[SDJwt.DIGESTS_KEY]!!.jsonArray[0].jsonPrimitive.content]!!.key shouldBe "sub" + println("BLA") + sdJwt.fullPayload.toString() shouldEqualJson JSON.stringify(originalClaimsSet) + println("ASDASD") + + sdJwt.verifyAsync(cryptoProvider).verified shouldBe true + SDJwt.parse("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiI0NTciLCJfc2QiOlsibTcyQ0tyVHhYckhlWUJSQTFBVFQ1S0t4NGdFWExlOVhqVFROakdRWkVQNCJdfQ.Tltz2SGxmdIpD_ny1XSTn89rQSmYsl9EcsXxsfJE0wo") + .verifyAsync(cryptoProvider).verified shouldBe false + } + + @Test + fun testSdMap() { + val originalClaimsSet = js("{ \"sub\": \"123\", \"aud\": \"456\" }") + + val sdMap = SDMapBuilderJS().addField( + "sub", true, + SDMapBuilderJS().addField("child", true).build() + ).build() + + //val sdMap = js("{\"fields\":{\"sub\":{\"sd\":true,\"children\":{\"fields\":{\"child\":{\"sd\":true,\"children\":null}},\"decoyMode\":\"NONE\",\"decoys\":0}}},\"decoyMode\":\"FIXED\",\"decoys\":2}") + val sdPayload = SDPayloadBuilder(originalClaimsSet).buildForSDMap(sdMap) + sdPayload.undisclosedPayload.keys shouldNotContain "sub" + sdPayload.undisclosedPayload.keys shouldContain SDJwt.DIGESTS_KEY + } +} diff --git a/waltid-sdjwt/src/jvmMain/kotlin/id/walt/sdjwt/JWTClaimsSet.kt b/waltid-sdjwt/src/jvmMain/kotlin/id/walt/sdjwt/JWTClaimsSet.kt new file mode 100644 index 000000000..a2acbd021 --- /dev/null +++ b/waltid-sdjwt/src/jvmMain/kotlin/id/walt/sdjwt/JWTClaimsSet.kt @@ -0,0 +1,5 @@ +package id.walt.sdjwt + +import com.nimbusds.jwt.JWTClaimsSet + +actual typealias JWTClaimsSet = JWTClaimsSet \ No newline at end of file diff --git a/waltid-sdjwt/src/jvmMain/kotlin/id/walt/sdjwt/SimpleJWTCryptoProvider.kt b/waltid-sdjwt/src/jvmMain/kotlin/id/walt/sdjwt/SimpleJWTCryptoProvider.kt new file mode 100644 index 000000000..6ed2bc9a4 --- /dev/null +++ b/waltid-sdjwt/src/jvmMain/kotlin/id/walt/sdjwt/SimpleJWTCryptoProvider.kt @@ -0,0 +1,35 @@ +package id.walt.sdjwt + +import com.nimbusds.jose.* +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import kotlinx.serialization.json.JsonObject + +class SimpleJWTCryptoProvider( + val jwsAlgorithm: JWSAlgorithm, + private val jwsSigner: JWSSigner?, + private val jwsVerifier: JWSVerifier? +) : JWTCryptoProvider { + + override fun sign(payload: JsonObject, keyID: String?, typ: String): String { + if (jwsSigner == null) { + throw Exception("No signer available") + } + return SignedJWT( + JWSHeader.Builder(jwsAlgorithm).type(JOSEObjectType.JWT).keyID(keyID).build(), + JWTClaimsSet.parse(payload.toString()) + ).also { + it.sign(jwsSigner) + }.serialize() + } + + override fun verify(jwt: String): JwtVerificationResult { + if (jwsVerifier == null) { + throw Exception("No verifier available") + } + return JwtVerificationResult( + SignedJWT.parse(jwt).verify(jwsVerifier) + ) + } +} + diff --git a/waltid-sdjwt/src/jvmTest/kotlin/id/walt/sdjwt/SDJwtTestJVM.kt b/waltid-sdjwt/src/jvmTest/kotlin/id/walt/sdjwt/SDJwtTestJVM.kt new file mode 100644 index 000000000..345e05d0e --- /dev/null +++ b/waltid-sdjwt/src/jvmTest/kotlin/id/walt/sdjwt/SDJwtTestJVM.kt @@ -0,0 +1,107 @@ +package id.walt.sdjwt + +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.crypto.MACSigner +import com.nimbusds.jose.crypto.MACVerifier +import com.nimbusds.jwt.JWTClaimsSet +import io.kotest.assertions.json.shouldEqualJson +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.maps.shouldContainKey +import io.kotest.matchers.maps.shouldNotContainKey +import io.kotest.matchers.shouldBe +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test + +class SDJwtTestJVM { + // Generate shared secret for HMAC crypto algorithm + private val sharedSecret = "ef23f749-7238-481a-815c-f0c2157dfa8e" + + @Test + fun testSignJwt() { + + // Create SimpleJWTCryptoProvider with MACSigner and MACVerifier + val cryptoProvider = SimpleJWTCryptoProvider(JWSAlgorithm.HS256, MACSigner(sharedSecret), MACVerifier(sharedSecret)) + + // Create original JWT claims set, using nimbusds claims set builder + val originalClaimsSet = JWTClaimsSet.Builder() + .subject("123") + .audience("456") + .build() + + // Create undisclosed claims set, by removing e.g. subject property from original claims set + val undisclosedClaimsSet = JWTClaimsSet.Builder(originalClaimsSet) + .subject(null) + .build() + + // Create SD payload by comparing original claims set with undisclosed claims set + val sdPayload = SDPayload.createSDPayload(originalClaimsSet, undisclosedClaimsSet) + + // Create and sign SD-JWT using the generated SD payload and the previously configured crypto provider + val sdJwt = SDJwt.sign(sdPayload, cryptoProvider) + // Print SD-JWT + println(sdJwt) + + sdJwt.undisclosedPayload shouldNotContainKey "sub" + sdJwt.undisclosedPayload shouldContainKey SDJwt.DIGESTS_KEY + sdJwt.undisclosedPayload shouldContainKey "aud" + sdJwt.disclosures shouldHaveSize 1 + sdJwt.digestedDisclosures[sdJwt.undisclosedPayload[SDJwt.DIGESTS_KEY]!!.jsonArray[0].jsonPrimitive.content]!!.key shouldBe "sub" + sdJwt.fullPayload.toString() shouldEqualJson originalClaimsSet.toString() + + sdJwt.verify(cryptoProvider).verified shouldBe true + } + + @Test + fun presentSDJwt() { + // parse previously created SD-JWT + val sdJwt = + SDJwt.parse("eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~WyJ4RFk5VjBtOG43am82ZURIUGtNZ1J3Iiwic3ViIiwiMTIzIl0") + + // present without disclosing SD fields + val presentedUndisclosedJwt = sdJwt.present(discloseAll = false) + println("present without disclosing SD fields: " + presentedUndisclosedJwt) + + // present disclosing all SD fields + val presentedDisclosedJwt = sdJwt.present(discloseAll = true) + println("present disclosing all SD fields: " + presentedDisclosedJwt) + + // present disclosing selective fields, using SDMap + val presentedSelectiveJwt = sdJwt.present(SDMapBuilder().addField("sub", true).build()) + println("present disclosing selective fields, using SDMap: " + presentedSelectiveJwt) + + // present disclosing fields, using JSON paths + val presentedSelectiveJwt2 = sdJwt.present(SDMap.generateSDMap(listOf("sub"))) + println("present disclosing fields, using JSON paths: " + presentedSelectiveJwt2) + + } + + @Test + fun parseAndVerify() { + // Create SimpleJWTCryptoProvider with MACSigner and MACVerifier + val cryptoProvider = SimpleJWTCryptoProvider(JWSAlgorithm.HS256, jwsSigner = null, jwsVerifier = MACVerifier(sharedSecret)) + + val undisclosedJwt = + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~" + + // verify and parse presented SD-JWT with all fields undisclosed, throws Exception if verification fails! + val parseAndVerifyResult = SDJwt.verifyAndParse(undisclosedJwt, cryptoProvider) + + // print full payload with disclosed fields only + println("Undisclosed JWT payload:") + println(parseAndVerifyResult.sdJwt.fullPayload.toString()) + + // alternatively parse and verify in 2 steps: + val parsedUndisclosedJwt = SDJwt.parse(undisclosedJwt) + val isValid = parsedUndisclosedJwt.verify(cryptoProvider).verified + println("Undisclosed SD-JWT verified: $isValid") + + val parsedDisclosedJwtVerifyResult = SDJwt.verifyAndParse( + "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJhdWQiOiI0NTYiLCJfc2QiOlsiaGx6ZmpmMDRvNVpzTFIyNWhhNGMtWS05SFcyRFVseGNnaU1ZZDMyNE5nWSJdfQ.2fsLqzujWt0hS0peLS8JLHyyo3D5KCDkNnHcBYqQwVo~WyJ4RFk5VjBtOG43am82ZURIUGtNZ1J3Iiwic3ViIiwiMTIzIl0~", + cryptoProvider + ) + // print full payload with disclosed fields + println("Disclosed JWT payload:") + println(parsedDisclosedJwtVerifyResult.sdJwt.fullPayload.toString()) + } +} diff --git a/waltid-sdjwt/src/nativeInterop/cinterop/waltid-sd-jwt-ios.def b/waltid-sdjwt/src/nativeInterop/cinterop/waltid-sd-jwt-ios.def new file mode 100644 index 000000000..b7823a8b9 --- /dev/null +++ b/waltid-sdjwt/src/nativeInterop/cinterop/waltid-sd-jwt-ios.def @@ -0,0 +1,10 @@ +language = Objective-C +staticLibraries = libwaltid-sd-jwt-ios.a +libraryPaths.ios_simulator_arm64 = waltid-sd-jwt-ios/build/Release-iphonesimulator/ +libraryPaths.ios_x64 = waltid-sd-jwt-ios/build/Release-iphonesimulator/ +libraryPaths.ios_arm64 = waltid-sd-jwt-ios/build/Release-iphoneos/ + +linkerOpts = -L/usr/lib/swift +linkerOpts.ios_arm64 = -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphoneos/ +linkerOpts.ios_simulator_arm64 = -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator/ +linkerOpts.ios_x64 = -L/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/iphonesimulator/ diff --git a/waltid-sdjwt/src/nativeMain/kotlin/id/walt/sdjwt/JWTClaimsSet.kt b/waltid-sdjwt/src/nativeMain/kotlin/id/walt/sdjwt/JWTClaimsSet.kt new file mode 100644 index 000000000..f69596575 --- /dev/null +++ b/waltid-sdjwt/src/nativeMain/kotlin/id/walt/sdjwt/JWTClaimsSet.kt @@ -0,0 +1,7 @@ +package id.walt.sdjwt + +actual class JWTClaimsSet { + actual override fun toString(): String { + TODO("Not yet implemented") + } +} diff --git a/waltid-verifiable-credentials/build.gradle.kts b/waltid-verifiable-credentials/build.gradle.kts index cd91dc26e..ca6847489 100644 --- a/waltid-verifiable-credentials/build.gradle.kts +++ b/waltid-verifiable-credentials/build.gradle.kts @@ -58,6 +58,7 @@ kotlin { // walt.id api(project(":waltid-crypto")) + api(project(":waltid-sdjwt")) api(project(":waltid-did")) } } diff --git a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/CredentialBuilder.kt b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/CredentialBuilder.kt new file mode 100644 index 000000000..522337cc6 --- /dev/null +++ b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/CredentialBuilder.kt @@ -0,0 +1,111 @@ +package id.walt.credentials + +import id.walt.credentials.vc.vcs.W3CV2DataModel +import id.walt.credentials.vc.vcs.W3CVC +import id.walt.did.utils.randomUUID +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.json.* +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +enum class CredentialBuilderType { + W3CV2CredentialBuilder, + MdocsCredentialBuilder +} + +class CredentialBuilder( + builderType: CredentialBuilderType +) { + + var context: List = listOf("https://www.w3.org/ns/credentials/v2") + var type: List = listOf("VerifiableCredential") + fun addType(addType: String) { + type = type.toMutableList().apply { add(addType) } + } + + fun addContext(addContext: String) { + context = context.toMutableList().apply { add(addContext) } + } + + var credentialId: String? = "urn:uuid:${randomUUID()}" + fun randomCredentialSubjectUUID() { + credentialId = "urn:uuid:${randomUUID()}" + } + + var issuerDid: String? = null + var subjectDid: String? = null + var validFrom: Instant? = Clock.System.now() + fun validFromNow() { + validFrom = Clock.System.now() - (1.5.minutes) + } + + var validUntil: Instant? = null + fun validFor(duration: Duration) { + validUntil = Clock.System.now() + duration + } + + var credentialStatus: W3CV2DataModel.CredentialStatus? = null + + fun useStatusList2021Revocation(statusListCredential: String, listIndex: Int) { + credentialStatus = W3CV2DataModel.CredentialStatus( + id = "$statusListCredential#$listIndex", + type = "StatusList2021Entry", + statusPurpose = "revocation", + statusListIndex = listIndex.toString(), + statusListCredential = statusListCredential // + ) + } + + var termsOfUse: W3CV2DataModel.TermsOfUse? = null + + var _customCredentialSubjectData: JsonObject? = null + fun useCredentialSubject(data: JsonObject) { + _customCredentialSubjectData = data + } + + var _extraCustomData: MutableMap = HashMap() + fun useData(key: String, data: JsonElement) { + _extraCustomData[key] = data + } + fun useData(pair: Pair) = useData(pair.first, pair.second) + + infix fun String.set(data: JsonElement) { + useData(this, data) + } + + fun buildW3CV2DataModel(): W3CV2DataModel { + + val buildSubject = _customCredentialSubjectData?.let { + JsonObject( + _customCredentialSubjectData!!.toMutableMap() + .apply { + subjectDid?.let { + put("id", JsonPrimitive(subjectDid)) + } + }) + } + + // val buildSubject = JsonObject(mapOf()) + return W3CV2DataModel( + context = context, + type = type, + credentialSubject = buildSubject ?: JsonObject(mapOf("id" to JsonPrimitive(subjectDid))), + id = credentialId, + issuer = issuerDid, + validFrom = validFrom.toString(), + validUntil = validUntil.toString(), + credentialStatus = credentialStatus, + termsOfUse = termsOfUse + ) + } + + fun buildW3C(): W3CVC { + return W3CVC( + Json.encodeToJsonElement(buildW3CV2DataModel()).jsonObject.toMutableMap() + .apply { + putAll(_extraCustomData) + } + ) + } +} diff --git a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/issuance/DataFunctions.kt b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/issuance/DataFunctions.kt new file mode 100644 index 000000000..80ffb9253 --- /dev/null +++ b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/issuance/DataFunctions.kt @@ -0,0 +1,37 @@ +package id.walt.credentials.issuance + +import id.walt.credentials.utils.W3CDataMergeUtils +import id.walt.did.utils.randomUUID +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import kotlinx.datetime.Clock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlin.time.Duration + +val dataFunctions = mapOf JsonElement>( + "subjectDid" to { it.fromContext() }, + "issuerDid" to { it.fromContext() }, + + "context" to { it.context[it.args!!]!! }, + + "timestamp" to { JsonPrimitive(Clock.System.now().toString()) }, + "timestamp-seconds" to { JsonPrimitive(Clock.System.now().epochSeconds) }, + + "timestamp-in" to { JsonPrimitive((Clock.System.now() + Duration.parse(it.args!!)).toString()) }, + "timestamp-in-seconds" to { JsonPrimitive((Clock.System.now() + Duration.parse(it.args!!)).epochSeconds) }, + + "timestamp-before" to { JsonPrimitive((Clock.System.now() - Duration.parse(it.args!!)).toString()) }, + "timestamp-before-seconds" to { JsonPrimitive((Clock.System.now() - Duration.parse(it.args!!)).epochSeconds) }, + + "uuid" to { JsonPrimitive("urn:uuid:${randomUUID()}") }, + "webhook" to { JsonPrimitive(HttpClient().get(it.args!!).bodyAsText()) }, + "webhook-json" to { Json.parseToJsonElement(HttpClient().get(it.args!!).bodyAsText()) }, + + "last" to { + it.history?.get(it.args!!) + ?: throw IllegalArgumentException("No such function in history or no history: ${it.args}") + } +) diff --git a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/issuance/Issuer.kt b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/issuance/Issuer.kt index f16f00d35..991fc454a 100644 --- a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/issuance/Issuer.kt +++ b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/issuance/Issuer.kt @@ -1,34 +1,17 @@ package id.walt.credentials.issuance -import id.walt.credentials.utils.W3CDataMergeUtils import id.walt.credentials.utils.W3CDataMergeUtils.mergeWithMapping import id.walt.credentials.utils.W3CVcUtils.overwrite import id.walt.credentials.utils.W3CVcUtils.update import id.walt.credentials.vc.vcs.W3CVC import id.walt.crypto.keys.Key -import id.walt.did.utils.randomUUID -import io.ktor.client.* -import io.ktor.client.request.* -import io.ktor.client.statement.* -import kotlinx.datetime.Clock +import id.walt.sdjwt.SDMap import kotlinx.datetime.Instant -import kotlinx.serialization.Serializable import kotlinx.serialization.json.* -import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds object Issuer { - /** - * @param id: id - */ - @Serializable - data class ExtraData( - val idLocation: String = "id", - - - ) - /** * Manually set data and issue credential */ @@ -55,56 +38,84 @@ object Issuer { ) } + suspend fun W3CVC.mergingJwtIssue( + issuerKey: Key, + issuerDid: String, + subjectDid: String, - val dataFunctions = mapOf JsonElement>( - "subjectDid" to { it.fromContext() }, - "issuerDid" to { it.fromContext() }, + mappings: JsonObject, - "context" to { it.context[it.args!!]!! }, + additionalJwtHeader: Map, + additionalJwtOptions: Map, - "timestamp" to { JsonPrimitive(Clock.System.now().toString()) }, - "timestamp-seconds" to { JsonPrimitive(Clock.System.now().epochSeconds) }, + completeJwtWithDefaultCredentialData: Boolean = true, + ) = mergingToVc( + issuerDid = issuerDid, + subjectDid = subjectDid, + mappings = mappings, + completeJwtWithDefaultCredentialData + ).run { + w3cVc.signJws( + issuerKey = issuerKey, + issuerDid = issuerDid, + subjectDid = subjectDid, + additionalJwtHeader = additionalJwtHeader.toMutableMap().apply { + put("typ", "JWT") + }, + additionalJwtOptions = additionalJwtOptions.toMutableMap().apply { + putAll(jwtOptions) + } + ) + } - "timestamp-in" to { JsonPrimitive((Clock.System.now() + Duration.parse(it.args!!)).toString()) }, - "timestamp-in-seconds" to { JsonPrimitive((Clock.System.now() + Duration.parse(it.args!!)).epochSeconds) }, + suspend fun W3CVC.mergingSdJwtIssue( + issuerKey: Key, + issuerDid: String, + subjectDid: String, - "timestamp-before" to { JsonPrimitive((Clock.System.now() - Duration.parse(it.args!!)).toString()) }, - "timestamp-before-seconds" to { JsonPrimitive((Clock.System.now() - Duration.parse(it.args!!)).epochSeconds) }, + mappings: JsonObject, - "uuid" to { JsonPrimitive("urn:uuid:${randomUUID()}") }, - "webhook" to { JsonPrimitive(HttpClient().get(it.args!!).bodyAsText()) }, - "webhook-json" to { Json.parseToJsonElement(HttpClient().get(it.args!!).bodyAsText()) }, + additionalJwtHeader: Map, + additionalJwtOptions: Map, - "last" to { - it.history?.get(it.args!!) - ?: throw IllegalArgumentException("No such function in history or no history: ${it.args}") - } + completeJwtWithDefaultCredentialData: Boolean = true, + disclosureMap: SDMap + ) = mergingToVc( + issuerDid = issuerDid, + subjectDid = subjectDid, + mappings = mappings, + completeJwtWithDefaultCredentialData + ).run { + w3cVc.signSdJwt( + issuerKey = issuerKey, + issuerDid = issuerDid, + subjectDid = subjectDid, + disclosureMap = disclosureMap, + additionalJwtHeader = additionalJwtHeader.toMutableMap().apply { + put("typ", "JWT") + }, + additionalJwtOptions = additionalJwtOptions.toMutableMap().apply { + putAll(jwtOptions) + } + ) + } + + data class IssuanceInformation( + val w3cVc: W3CVC, + val jwtOptions: Map ) /** * Merge data with mappings and issue */ - suspend fun W3CVC.mergingIssue( - key: Key, + suspend fun W3CVC.mergingToVc( issuerDid: String, subjectDid: String, mappings: JsonObject, -// -// dataOverwrites: Map, -// dataUpdates: Map>, - additionalJwtHeader: Map, - additionalJwtOptions: Map, - - completeJwtWithDefaultCredentialData: Boolean = true - ): String { - - /*val jwtMappings = mappings.filterKeys { it.startsWith("jwt:") }.mapKeys { it.key.removePrefix("jwt:") } - println("JWT MAPPINGS: $jwtMappings") - - val dataMappings = JsonObject(mappings.filterKeys { !it.startsWith("jwt") })*/ - + completeJwtWithDefaultCredentialData: Boolean = true, + ): IssuanceInformation { val context = mapOf( "issuerDid" to JsonPrimitive(issuerDid), "subjectDid" to JsonPrimitive(subjectDid) @@ -141,16 +152,6 @@ object Issuer { } } - return vc.signJws( - issuerKey = key, - issuerDid = issuerDid, - subjectDid = subjectDid, - additionalJwtHeader = additionalJwtHeader.toMutableMap().apply { - put("typ", "JWT") - }, - additionalJwtOptions = additionalJwtOptions.toMutableMap().apply { - putAll(jwtRes) - } - ) + return IssuanceInformation(vc, jwtRes) } } diff --git a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/utils/W3CDataMergeUtils.kt b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/utils/W3CDataMergeUtils.kt index 5fc29dd7f..447144bcb 100644 --- a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/utils/W3CDataMergeUtils.kt +++ b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/utils/W3CDataMergeUtils.kt @@ -71,7 +71,12 @@ object W3CDataMergeUtils { //println("Sub-patching for $k: (current is: ${this[k]})") - val kJson = runCatching { this[k]?.jsonObject }.getOrElse { ex -> throw IllegalArgumentException("Invalid mapping for credential, when processing \"$k\": ${ex.message}", ex) } + val kJson = runCatching { this[k]?.jsonObject }.getOrElse { ex -> + throw IllegalArgumentException( + "Invalid mapping for credential, when processing \"$k\": ${ex.message}", + ex + ) + } ?: throw IllegalArgumentException("This key does not exist to map to: $k") this[k] = diff --git a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/vc/vcs/MdocsVC.kt b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/vc/vcs/MdocsVC.kt index 3a3919dba..07f41f23f 100644 --- a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/vc/vcs/MdocsVC.kt +++ b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/vc/vcs/MdocsVC.kt @@ -1,4 +1,5 @@ package id.walt.credentials.vc.vcs +// TODO: Relevance in waltid-verifiable-credentials? Can be provided with waltid-mdocs? class MdocsVC { } diff --git a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/vc/vcs/W3CV2DataModel.kt b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/vc/vcs/W3CV2DataModel.kt new file mode 100644 index 000000000..01420e1e9 --- /dev/null +++ b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/vc/vcs/W3CV2DataModel.kt @@ -0,0 +1,45 @@ +package id.walt.credentials.vc.vcs + + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject + +/** + * W3C V2.0 + * https://www.w3.org/TR/vc-data-model-2.0/ + */ +@Serializable +data class W3CV2DataModel( + @SerialName("@context") + val context: List = listOf("https://www.w3.org/ns/credentials/v2"), // [https://www.w3.org/ns/credentials/v2, https://www.w3.org/ns/credentials/examples/v2] + val type: List = listOf("VerifiableCredential"), // [VerifiableCredential, ExampleAlumniCredential] + val credentialSubject: JsonObject, + val id: String? = null, // http://university.example/credentials/1872 + val issuer: String? = null, // https://university.example/issuers/565049 + val validFrom: String? = null, // 2010-01-01T19:23:24Z + val validUntil: String? = null, + val credentialStatus: CredentialStatus? = null, + val termsOfUse: TermsOfUse? = null, +) { + + @Serializable + data class CredentialSubject( + val id: String // did:example:ebfeb1f712ebc6f1c276e12ec21 + ) + + @Serializable + data class TermsOfUse( + val id: String, // "https://api-test.ebsi.eu/trusted-issuers-registry/v4/issuers/did:ebsi:zz7XsC9ixAXuZecoD9sZEM1/attributes/7201d95fef05f72667f5454c2192da2aa30d9e052eeddea7651b47718d6f31b0 + val type: String // "IssuanceCertificate" + ) + + @Serializable + data class CredentialStatus( + val id: String, // https://university.example/credentials/status/3#94567 + val type: String, // StatusList2021Entry + val statusPurpose: String, // revocation + val statusListIndex: String, // 94567 + val statusListCredential: String // https://university.example/credentials/status/3 + ) +} diff --git a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/vc/vcs/W3CVC.kt b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/vc/vcs/W3CVC.kt index f1170f235..ccd6a75fa 100644 --- a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/vc/vcs/W3CVC.kt +++ b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/vc/vcs/W3CVC.kt @@ -5,16 +5,29 @@ import id.walt.credentials.schemes.JwsSignatureScheme.JwsHeader import id.walt.credentials.schemes.JwsSignatureScheme.JwsOption import id.walt.crypto.keys.Key import id.walt.crypto.utils.JsonUtils.toJsonElement +import id.walt.sdjwt.SDJwt +import id.walt.sdjwt.SDMap +import id.walt.sdjwt.SDPayload +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encodeToString +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -@Serializable +class W3CVCSerializer : KSerializer { + override val descriptor: SerialDescriptor = JsonObject.serializer().descriptor + override fun deserialize(decoder: Decoder): W3CVC = W3CVC(decoder.decodeSerializableValue(JsonObject.serializer())) + override fun serialize(encoder: Encoder, value: W3CVC) = encoder.encodeSerializableValue(JsonObject.serializer(), value.toJsonObject()) +} + +@Serializable(with = W3CVCSerializer::class) data class W3CVC( - private val content: Map + private val content: Map = emptyMap() ) : Map by content { @@ -23,6 +36,32 @@ data class W3CVC( fun toPrettyJson(): String = prettyJson.encodeToString(content) + suspend fun signSdJwt( + issuerKey: Key, + issuerDid: String, + subjectDid: String, + disclosureMap: SDMap, + /** Set additional options in the JWT header */ + additionalJwtHeader: Map = emptyMap(), + /** Set additional options in the JWT payload */ + additionalJwtOptions: Map = emptyMap() + ): String { + val vc = this.toJsonObject() + + val sdPayload = SDPayload.createSDPayload(vc, disclosureMap) + val signable = Json.encodeToString(sdPayload.undisclosedPayload).toByteArray() + + val signed = issuerKey.signJws( + signable, mapOf( + "typ" to "vc+sd-jwt", + "cty" to "credential-claims-set+json", + "kid" to issuerDid + ) + ) + + return SDJwt.createFromSignedJwt(signed, sdPayload).toString() + } + suspend fun signJws( issuerKey: Key, issuerDid: String, @@ -61,6 +100,7 @@ data class W3CVC( ) } + fun fromJson(json: String) = W3CVC(Json.decodeFromString>(json)) diff --git a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/Exceptions.kt b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/Exceptions.kt index ae2d011d8..2b51ac97b 100644 --- a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/Exceptions.kt +++ b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/Exceptions.kt @@ -76,3 +76,17 @@ class MaximumCredentialsException( val total: Int, val exceeded: Int ) : SerializableRuntimeException() + +@Serializable +@SerialName("HolderBindingException") +class HolderBindingException( + val presenterDid: String, + val credentialDids: List +) : SerializableRuntimeException() + +@Serializable +@SerialName("NotAllowedIssuerException") +class NotAllowedIssuerException( + val issuer: String, + val allowedIssuers: List +) : SerializableRuntimeException() diff --git a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/JwtVerificationPolicy.kt b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/JwtVerificationPolicy.kt index f05a77d33..795cec582 100644 --- a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/JwtVerificationPolicy.kt +++ b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/JwtVerificationPolicy.kt @@ -1,6 +1,7 @@ package id.walt.credentials.verification -abstract class JwtVerificationPolicy(override val name: String, override val description: String? = null) : VerificationPolicy(name, description) { +abstract class JwtVerificationPolicy(override val name: String, override val description: String? = null) : + VerificationPolicy(name, description) { abstract suspend fun verify(credential: String, args: Any? = null, context: Map): Result diff --git a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/PolicyManager.kt b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/PolicyManager.kt index c45b72bf9..262a57fa3 100644 --- a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/PolicyManager.kt +++ b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/PolicyManager.kt @@ -1,6 +1,7 @@ package id.walt.credentials.verification import id.walt.credentials.verification.policies.* +import id.walt.credentials.verification.policies.vp.HolderBindingPolicy import id.walt.credentials.verification.policies.vp.MaximumCredentialsPolicy import id.walt.credentials.verification.policies.vp.MinimumCredentialsPolicy @@ -38,7 +39,9 @@ object PolicyManager { NotBeforeDatePolicy(), WebhookPolicy(), MinimumCredentialsPolicy(), - MaximumCredentialsPolicy() + MaximumCredentialsPolicy(), + HolderBindingPolicy(), + AllowedIssuerPolicy() ) } @@ -46,5 +49,4 @@ object PolicyManager { mappedPolicies[name] ?: throw IllegalArgumentException("No policy found by name: $name") - } diff --git a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/Verifier.kt b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/Verifier.kt index b1b589c8f..18b5a91bd 100644 --- a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/Verifier.kt +++ b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/Verifier.kt @@ -3,5 +3,4 @@ package id.walt.credentials.verification object Verifier { - } diff --git a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/policies/AllowedIssuerPolicy.kt b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/policies/AllowedIssuerPolicy.kt new file mode 100644 index 000000000..8003e3e55 --- /dev/null +++ b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/policies/AllowedIssuerPolicy.kt @@ -0,0 +1,35 @@ +package id.walt.credentials.verification.policies + +import id.walt.credentials.schemes.JwsSignatureScheme.JwsOption +import id.walt.credentials.verification.CredentialWrapperValidatorPolicy +import id.walt.credentials.verification.NotAllowedIssuerException +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive + +class AllowedIssuerPolicy : CredentialWrapperValidatorPolicy( + "allowed-issuer", + "Checks that the issuer of the credential is present in the supplied list." +) { + override suspend fun verify(data: JsonObject, args: Any?, context: Map): Result { + val allowedIssuers = when (args) { + is JsonPrimitive -> listOf(args.content) + is JsonArray -> args.map { it.jsonPrimitive.content } + else -> throw IllegalArgumentException("Invalid argument, please provide a single allowed issuer, or an list of allowed issuers.") + } + + val issuer = + data[JwsOption.ISSUER]?.jsonPrimitive?.content ?: throw IllegalArgumentException("No issuer found in credential: \"iss\"") + + return when (issuer) { + in allowedIssuers -> Result.success(issuer) + else -> Result.failure( + NotAllowedIssuerException( + issuer = issuer, + allowedIssuers = allowedIssuers + ) + ) + } + } +} diff --git a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/policies/vp/HolderBindingPolicy.kt b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/policies/vp/HolderBindingPolicy.kt new file mode 100644 index 000000000..e33f1b1f4 --- /dev/null +++ b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/policies/vp/HolderBindingPolicy.kt @@ -0,0 +1,38 @@ +package id.walt.credentials.verification.policies.vp + +import id.walt.credentials.schemes.JwsSignatureScheme.JwsOption +import id.walt.credentials.verification.CredentialWrapperValidatorPolicy +import id.walt.credentials.verification.HolderBindingException +import id.walt.crypto.utils.JwsUtils.decodeJws +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +class HolderBindingPolicy : CredentialWrapperValidatorPolicy( + "holder-binding", + "Verifies that issuer of the Verifiable Presentation (presenter) is also the subject of all Verifiable Credentials contained within." +) { + override suspend fun verify(data: JsonObject, args: Any?, context: Map): Result { + val presenterDid = data[JwsOption.ISSUER]!!.jsonPrimitive.content + + val vp = data["vp"]?.jsonObject ?: throw IllegalArgumentException("No \"vp\" field in VP!") + + val credentials = + vp["verifiableCredential"]?.jsonArray ?: throw IllegalArgumentException("No \"verifiableCredential\" field in \"vp\"!") + + val credentialSubjects = credentials.map { + it.jsonPrimitive.content.decodeJws().payload["sub"]!!.jsonPrimitive.content.split("#").first() + } + + return when { + credentialSubjects.all { it == presenterDid } -> Result.success(presenterDid) + else -> Result.failure( + HolderBindingException( + presenterDid = presenterDid, + credentialDids = credentialSubjects + ) + ) + } + } +} diff --git a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/policies/vp/MaximumCredentialsPolicy.kt b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/policies/vp/MaximumCredentialsPolicy.kt index c489729c9..fbf7a55ce 100644 --- a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/policies/vp/MaximumCredentialsPolicy.kt +++ b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/policies/vp/MaximumCredentialsPolicy.kt @@ -7,7 +7,7 @@ import kotlinx.serialization.json.* class MaximumCredentialsPolicy : CredentialWrapperValidatorPolicy( name = "maximum-credentials", description = "Verifies that a maximum number of credentials in the Verifiable Presentation is not exceeded" -){ +) { override suspend fun verify(data: JsonObject, args: Any?, context: Map): Result { val n = (args as JsonPrimitive).int val presentedCount = data["vp"]!!.jsonObject["verifiableCredential"]?.jsonArray?.count() @@ -16,10 +16,14 @@ class MaximumCredentialsPolicy : CredentialWrapperValidatorPolicy( val success = presentedCount <= n return if (success) - Result.success(JsonObject(mapOf( - "total" to JsonPrimitive(presentedCount), - "remaining" to JsonPrimitive(n - presentedCount) - ))) + Result.success( + JsonObject( + mapOf( + "total" to JsonPrimitive(presentedCount), + "remaining" to JsonPrimitive(n - presentedCount) + ) + ) + ) else { Result.failure( MaximumCredentialsException( diff --git a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/policies/vp/MinimumCredentialsPolicy.kt b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/policies/vp/MinimumCredentialsPolicy.kt index 8e55c3da6..8b1149afe 100644 --- a/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/policies/vp/MinimumCredentialsPolicy.kt +++ b/waltid-verifiable-credentials/src/commonMain/kotlin/id/walt/credentials/verification/policies/vp/MinimumCredentialsPolicy.kt @@ -2,14 +2,12 @@ package id.walt.credentials.verification.policies.vp import id.walt.credentials.verification.CredentialWrapperValidatorPolicy import id.walt.credentials.verification.MinimumCredentialsException -import id.walt.credentials.verification.PresentationDefinitionException -import id.walt.crypto.utils.JwsUtils.decodeJws import kotlinx.serialization.json.* class MinimumCredentialsPolicy : CredentialWrapperValidatorPolicy( name = "minimum-credentials", description = "Verifies that a minimum number of credentials are included in the Verifiable Presentation" -){ +) { override suspend fun verify(data: JsonObject, args: Any?, context: Map): Result { val n = (args as JsonPrimitive).int val presentedCount = data["vp"]!!.jsonObject["verifiableCredential"]?.jsonArray?.count() @@ -18,10 +16,14 @@ class MinimumCredentialsPolicy : CredentialWrapperValidatorPolicy( val success = presentedCount >= n return if (success) - Result.success(JsonObject(mapOf( - "total" to JsonPrimitive(presentedCount), - "extra" to JsonPrimitive(presentedCount - n) - ))) + Result.success( + JsonObject( + mapOf( + "total" to JsonPrimitive(presentedCount), + "extra" to JsonPrimitive(presentedCount - n) + ) + ) + ) else { Result.failure( MinimumCredentialsException( diff --git a/waltid-verifiable-credentials/src/commonTest/kotlin/DataBuilderTest.kt b/waltid-verifiable-credentials/src/commonTest/kotlin/DataBuilderTest.kt new file mode 100644 index 000000000..2e03116c0 --- /dev/null +++ b/waltid-verifiable-credentials/src/commonTest/kotlin/DataBuilderTest.kt @@ -0,0 +1,48 @@ +import id.walt.credentials.CredentialBuilder +import id.walt.credentials.CredentialBuilderType.W3CV2CredentialBuilder +import id.walt.crypto.utils.JsonUtils.toJsonObject +import kotlin.test.Test +import kotlin.time.Duration.Companion.days + +class DataBuilderTest { + + val entityIdentificationNumber = "12345" + val issuingAuthorityId = "abc" + + val proofType = "document" + val proofLocation = "Berlin-Brandenburg" + + @Test + fun testDataBuilder() { + val myCustomData = mapOf( + "entityIdentification" to entityIdentificationNumber, + "issuingAuthority" to issuingAuthorityId, + "issuingCircumstances" to mapOf( + "proofType" to proofType, + "locationType" to "physicalLocation", + "location" to proofLocation + ) + ).toJsonObject() + + // build a W3C V2.0 credential + val vc = CredentialBuilder(W3CV2CredentialBuilder).apply { + addContext("https://www.w3.org/ns/credentials/examples/v2") // [W3CV2 VC context, custom context] + addType("MyCustomCredential") // [VerifiableCredential, MyCustomCredential] + + // credentialId = "123" + randomCredentialSubjectUUID() // automatically generate + issuerDid = "did:key:abc" // possibly later overridden by data mapping during issuance + subjectDid = "did:key:xyz" // possibly later overridden by data mapping during issuance + + validFromNow() // set validFrom per current time - 1.5 min + validFor(90.days) // set expiration date to now + 3 months + + useStatusList2021Revocation("https://university.example/credentials/status/3", 94567) + + useCredentialSubject(myCustomData) + // "custom" set myCustomData + }.buildW3C() + + print(vc.toPrettyJson()) + } +} diff --git a/waltid-verifier/build.gradle.kts b/waltid-verifier/build.gradle.kts index a9a908a02..65fd9c3e7 100644 --- a/waltid-verifier/build.gradle.kts +++ b/waltid-verifier/build.gradle.kts @@ -6,7 +6,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile object Versions { const val KOTLIN_VERSION = "1.9.10" // also change 2 plugins - const val KTOR_VERSION = "2.3.5" // also change 1 plugin + const val KTOR_VERSION = "2.3.6" // also change 1 plugin const val COROUTINES_VERSION = "1.7.3" const val EXPOSED_VERSION = "0.43.0" const val HOPLITE_VERSION = "2.8.0.RC3" diff --git a/waltid-verifier/src/main/kotlin/id/walt/verifier/VerifierApi.kt b/waltid-verifier/src/main/kotlin/id/walt/verifier/VerifierApi.kt index 96f3c3a24..ff618ab63 100644 --- a/waltid-verifier/src/main/kotlin/id/walt/verifier/VerifierApi.kt +++ b/waltid-verifier/src/main/kotlin/id/walt/verifier/VerifierApi.kt @@ -215,7 +215,8 @@ fun Application.verfierApi() { ) val tokenResponse = TokenResponse.fromHttpParameters(context.request.call.receiveParameters().toMap()) - val sessionVerificationInfo = OIDCVerifierService.sessionVerificationInfos[session.id] ?: throw IllegalStateException("No session verification information found for session id!") + val sessionVerificationInfo = OIDCVerifierService.sessionVerificationInfos[session.id] + ?: throw IllegalStateException("No session verification information found for session id!") val maybePresentationSessionResult = runCatching { OIDCVerifierService.verify(tokenResponse, session) } diff --git a/waltid-verifier/src/main/kotlin/id/walt/verifier/policies/PresentationDefinitionPolicy.kt b/waltid-verifier/src/main/kotlin/id/walt/verifier/policies/PresentationDefinitionPolicy.kt index 605b7081e..06ab43ddb 100644 --- a/waltid-verifier/src/main/kotlin/id/walt/verifier/policies/PresentationDefinitionPolicy.kt +++ b/waltid-verifier/src/main/kotlin/id/walt/verifier/policies/PresentationDefinitionPolicy.kt @@ -20,9 +20,9 @@ class PresentationDefinitionPolicy : CredentialWrapperValidatorPolicy( println(data) val presentedTypes = data["vp"]!!.jsonObject["verifiableCredential"]?.jsonArray?.mapNotNull { - it.jsonPrimitive.contentOrNull?.decodeJws()?.payload - ?.jsonObject?.get("vc")?.jsonObject?.get("type")?.jsonArray?.last()?.jsonPrimitive?.contentOrNull - }?.filterNotNull() ?: emptyList() + it.jsonPrimitive.contentOrNull?.decodeJws()?.payload + ?.jsonObject?.get("vc")?.jsonObject?.get("type")?.jsonArray?.last()?.jsonPrimitive?.contentOrNull + }?.filterNotNull() ?: emptyList() val success = presentedTypes.containsAll(requestedTypes) diff --git a/waltid-verifier/src/main/resources/simplelogger.properties b/waltid-verifier/src/main/resources/simplelogger.properties index d1cbfec09..420b379cd 100644 --- a/waltid-verifier/src/main/resources/simplelogger.properties +++ b/waltid-verifier/src/main/resources/simplelogger.properties @@ -1,5 +1,4 @@ org.slf4j.simpleLogger.defaultLogLevel=debug org.slf4j.simpleLogger.log.id.walt=debug - org.slf4j.simpleLogger.log.com.github.victools.jsonschema.generator.impl.SchemaGenerationContextImpl=info org.slf4j.simpleLogger.log.com.zaxxer.hikari=info diff --git a/waltid-verifier/src/test/resources/simplelogger.properties b/waltid-verifier/src/test/resources/simplelogger.properties index 87a0b0a4b..0a8d64ef8 100644 --- a/waltid-verifier/src/test/resources/simplelogger.properties +++ b/waltid-verifier/src/test/resources/simplelogger.properties @@ -1,5 +1,4 @@ org.slf4j.simpleLogger.defaultLogLevel=debug org.slf4j.simpleLogger.log.id.walt=debug - org.slf4j.simpleLogger.log.com.github.victools.jsonschema.generator.impl.SchemaGenerationContextImpl=info
+

Kotlin Multiplatform SD-JWT library

+ by
walt.id +

Create JSON Web Tokens (JWTs) that support Selective Disclosure

+ + +Join community! + + +Follow @walt_id + + +