Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implemented KMS, JWKS generation and JWT sign #14

Merged
merged 16 commits into from
Aug 16, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 53 additions & 43 deletions modules/openid-federation-common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,17 @@ kotlin {

// wasmJs is not available yet for ktor until v3.x is released which is still in alpha

androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}

iosX64()
iosArm64()
iosSimulatorArm64()
// TODO Should be placed back at a later point in time: https://sphereon.atlassian.net/browse/OIDF-50
// androidTarget {
// @OptIn(ExperimentalKotlinGradlePluginApi::class)
// compilerOptions {
// jvmTarget.set(JvmTarget.JVM_11)
// }
// }
//
// iosX64()
// iosArm64()
// iosSimulatorArm64()

jvm()

Expand All @@ -63,6 +64,7 @@ kotlin {
val jvmMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core-jvm:$ktorVersion")
implementation("com.nimbusds:nimbus-jose-jwt:9.40")
}
}
val jvmTest by getting {
Expand All @@ -71,43 +73,49 @@ kotlin {
}
}

val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core-jvm:$ktorVersion")
}
}
val androidUnitTest by getting {
dependencies {
implementation(kotlin("test-junit"))
}
}

val iosMain by creating {
dependsOn(commonMain)
dependencies {
implementation("io.ktor:ktor-client-ios:$ktorVersion")
}
}
val iosX64Main by getting {
dependsOn(iosMain)
}
val iosArm64Main by getting {
dependsOn(iosMain)
}
val iosSimulatorArm64Main by getting {
dependsOn(iosMain)
}

val iosTest by creating {
dependsOn(commonTest)
dependencies {
implementation(kotlin("test"))
}
}
// TODO Should be placed back at a later point in time: https://sphereon.atlassian.net/browse/OIDF-50
// val androidMain by getting {
// dependencies {
// implementation("io.ktor:ktor-client-core-jvm:$ktorVersion")
// }
// }
// val androidUnitTest by getting {
// dependencies {
// implementation(kotlin("test-junit"))
// }
// }
//
// val iosMain by creating {
// dependsOn(commonMain)
// dependencies {
// implementation("io.ktor:ktor-client-ios:$ktorVersion")
// }
// }
// val iosX64Main by getting {
// dependsOn(iosMain)
// }
// val iosArm64Main by getting {
// dependsOn(iosMain)
// }
// val iosSimulatorArm64Main by getting {
// dependsOn(iosMain)
// }
//
// val iosTest by creating {
// dependsOn(commonTest)
// dependencies {
// implementation(kotlin("test"))
// }
// }

val jsMain by getting {
dependencies {
implementation("io.ktor:ktor-client-js:$ktorVersion")
implementation(npm("typescript", "5.5.3"))
implementation(npm("jose", "5.6.3"))
implementation(npm("uuid", "10.0.0"))
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0-RC")
}
}

Expand All @@ -116,6 +124,8 @@ kotlin {
dependencies {
implementation(kotlin("test-js"))
implementation(kotlin("test-annotations-common"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0-RC")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0-RC")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.sphereon.oid.fed.common.jwt

expect fun sign(payload: String, opts: MutableMap<String, Any>?): String
expect fun verify(jwt: String, key: Any, opts: MutableMap<String, Any>? = mutableMapOf()): Boolean
sanderPostma marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.sphereon.oid.fed.common.jwt

@JsModule("jose")
@JsNonModule
external object Jose {
class SignJWT {
constructor(payload: dynamic) {
definedExternally
}
fun setProtectedHeader(protectedHeader: dynamic): SignJWT {
definedExternally
}
fun sign(key: Any?, signOptions: Any?): String {
definedExternally
}
}
fun generateKeyPair(alg: String, options: dynamic = definedExternally): dynamic
fun jwtVerify(jwt: String, key: Any, options: dynamic = definedExternally): dynamic
}

@JsModule("uuid")
@JsNonModule
external object Uuid {
fun v4(): String
}

@ExperimentalJsExport
@JsExport
actual fun sign(
payload: String,
opts: MutableMap<String, Any>?
): String {
val privateKey = opts?.get("privateKey") ?: throw IllegalArgumentException("JWK private key is required")
val header = opts["jwtHeader"] as String? ?: "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"${Uuid.v4()}\"}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should not just create a header, let alone use a random kid value.
Although "kid" is officially not defined, normally they are the JWK thumbprint in case a JWK is used, or a DID VM in case DIDs are used.
So please make a header a first class citizen like the payload. Require it to be passed in. 2nd don't create random kids.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made header the second parameter of the sign function, the key should be passed in for both js and jvm now.

return Jose.SignJWT(JSON.parse<Any>(payload).asDynamic())
.setProtectedHeader(JSON.parse<Any>(header).asDynamic())
.sign(key = privateKey, signOptions = opts)
}

@ExperimentalJsExport
@JsExport
actual fun verify(
jwt: String,
key: Any,
opts: MutableMap<String, Any>?
): Boolean {
return Jose.jwtVerify(jwt, key, opts)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.sphereon.oid.fed.common.jwt

import com.sphereon.oid.fed.common.jwt.Jose.generateKeyPair
import kotlinx.coroutines.async
import kotlinx.coroutines.await
import kotlinx.coroutines.test.runTest
import kotlin.js.Promise
import kotlin.test.Test
import kotlin.test.assertTrue

class JoseJwtTest {
@OptIn(ExperimentalJsExport::class)
@Test
fun signTest() = runTest {
val keyPair = (generateKeyPair("RS256") as Promise<dynamic>).await()
val result = async { sign("{ \"iss\": \"test\" }", mutableMapOf("privateKey" to keyPair.privateKey)) }
assertTrue((result.await() as Promise<String>).await().startsWith("ey"))
}

@OptIn(ExperimentalJsExport::class)
@Test
fun verifyTest() = runTest {
val keyPair = (generateKeyPair("RS256") as Promise<dynamic>).await()
val signed = (sign("{ \"iss\": \"test\" }", mutableMapOf("privateKey" to keyPair.privateKey)) as Promise<dynamic>).await()
val result = async { verify(signed, keyPair.publicKey) }
assertTrue((result.await() as Promise<Boolean>).await())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.sphereon.oid.fed.common.jwt

import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
import com.nimbusds.jose.JWSSigner
import com.nimbusds.jose.JWSVerifier
import com.nimbusds.jose.crypto.RSASSASigner
import com.nimbusds.jose.crypto.RSASSAVerifier
import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator
import com.nimbusds.jwt.JWTClaimsSet
import com.nimbusds.jwt.SignedJWT
import java.util.*

actual fun sign(
payload: String,
opts: MutableMap<String, Any>?
): String {
var rsaJWK = opts?.get("key") as RSAKey?
val kid = rsaJWK?.keyID ?: UUID.randomUUID().toString()
val header: JWSHeader?
if (opts?.get("jwtHeader") != null) {
header = JWSHeader.parse(opts["jwtHeader"] as String?)
} else {
header = JWSHeader.Builder(JWSAlgorithm.RS256).keyID(kid).build()
}

if (rsaJWK == null) {
rsaJWK = RSAKeyGenerator(2048)
.keyID(kid)
.generate()
}
sanderPostma marked this conversation as resolved.
Show resolved Hide resolved

val signer: JWSSigner = RSASSASigner(rsaJWK)

val claimsSet = JWTClaimsSet.parse(payload)

val signedJWT = SignedJWT(
header,
claimsSet
)

signedJWT.sign(signer)
return signedJWT.serialize()
}

actual fun verify(
jwt: String,
key: Any,
opts: MutableMap<String, Any>?
): Boolean {
try {
val rsaKey = key as RSAKey
val verifier: JWSVerifier = RSASSAVerifier(rsaKey)
val signedJWT = SignedJWT.parse(jwt)
val verified = signedJWT.verify(verifier)
return verified
} catch (e: Exception) {
throw Exception("Couldn't verify the JWT Signature: ${e.message}")
sanderPostma marked this conversation as resolved.
Show resolved Hide resolved
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.sphereon.oid.fed.common.jwt

import com.nimbusds.jose.jwk.RSAKey
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator
import kotlin.test.Test
import kotlin.test.assertTrue

class JoseJwtTest {

@Test
fun signTest() {
val signature = sign("{ \"iss\": \"test\" }", mutableMapOf())
assertTrue { signature.startsWith("ey") }
}

@Test
fun verifyTest() {
val kid = "key1"
val key: RSAKey = RSAKeyGenerator(2048).keyID(kid).generate()
val signature = sign("{ \"iss\": \"test\" }", mutableMapOf(
"key" to key,
"jwtHeader" to "{\"typ\":\"JWT\",\"alg\":\"RS256\",\"kid\":\"${key.keyID}\"}"
))
assertTrue { verify(signature, key) }
}
}
Loading