diff --git a/.env b/.env index adb8b036..15eeba69 100644 --- a/.env +++ b/.env @@ -1,6 +1,15 @@ +APP_KEY=Nit5tWts42QeCynT1Q476LyStDeSd4xb + +ROOT_IDENTIFIER=http://localhost:8080 + DATASOURCE_URL=jdbc:postgresql://db:5432/openid-federation-db DATASOURCE_USER=openid-federation-db-user DATASOURCE_PASSWORD=openid-federation-db-password DATASOURCE_DB=openid-federation-db -APP_KEY=Nit5tWts42QeCynT1Q476LyStDeSd4xb -ROOT_IDENTIFIER=http://localhost:8080 + +KMS_PROVIDER=local + +LOCAL_KMS_DATASOURCE_URL=jdbc:postgresql://local-kms-db:5432/openid-federation-local-kms-db +LOCAL_KMS_DATASOURCE_USER=openid-federation-local-kms-db-user +LOCAL_KMS_DATASOURCE_PASSWORD=openid-federation-local-kms-db-password +LOCAL_KMS_DATASOURCE_DB=openid-federation-local-kms-db diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92c81164..b10af5ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,13 +18,20 @@ jobs: distribution: adopt-hotspot java-version: 17 - - name: Build the stack + - name: Run database run: docker compose -f docker-compose.yaml up db -d env: DATASOURCE_USER: ${{ secrets.DATASOURCE_USER }} DATASOURCE_PASSWORD: ${{ secrets.DATASOURCE_PASSWORD }} DATASOURCE_URL: ${{ secrets.DATASOURCE_URL }} + - name: Run local KMS database + run: docker compose -f docker-compose.yaml up local-kms-db -d + env: + DATASOURCE_USER: ${{ secrets.LOCAL_KMS_DATASOURCE_USER }} + DATASOURCE_PASSWORD: ${{ secrets.LOCAL_KMS_DATASOURCE_PASSWORD }} + DATASOURCE_URL: ${{ secrets.LOCAL_KMS_DATASOURCE_URL }} + - name: Setup Gradle uses: gradle/gradle-build-action@v3 @@ -35,6 +42,11 @@ jobs: - name: Execute Gradle build run: ./gradlew build env: + APP_KEY: ${{ secrets.APP_KEY }} DATASOURCE_USER: ${{ secrets.DATASOURCE_USER }} DATASOURCE_PASSWORD: ${{ secrets.DATASOURCE_PASSWORD }} DATASOURCE_URL: ${{ secrets.DATASOURCE_URL }} + LOCAL_KMS_DATASOURCE_USER: ${{ secrets.LOCAL_KMS_DATASOURCE_USER }} + LOCAL_KMS_DATASOURCE_PASSWORD: ${{ secrets.LOCAL_KMS_DATASOURCE_PASSWORD }} + LOCAL_KMS_DATASOURCE_URL: ${{ secrets.LOCAL_KMS_DATASOURCE_URL }} + KMS_PROVIDER: local diff --git a/docker-compose.yaml b/docker-compose.yaml index ce0cc8bf..36223302 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -18,6 +18,25 @@ services: timeout: 5s retries: 20 + local-kms-db: + image: postgres:latest + container_name: openid-federation-local-kms-datastore + environment: + POSTGRES_USER: ${LOCAL_KMS_DATASOURCE_USER} + POSTGRES_PASSWORD: ${LOCAL_KMS_DATASOURCE_PASSWORD} + POSTGRES_DB: ${LOCAL_KMS_DATASOURCE_DB} + ports: + - "5433:5432" + volumes: + - local_kms_data:/var/lib/postgresql/data + networks: + - openid_network + healthcheck: + test: [ "CMD-SHELL", "pg_isready -d ${LOCAL_KMS_DATASOURCE_DB} -U ${LOCAL_KMS_DATASOURCE_USER}" ] + interval: 3s + timeout: 5s + retries: 20 + federation-server: build: context: . @@ -49,9 +68,17 @@ services: DATASOURCE_USER: ${DATASOURCE_USER} DATASOURCE_PASSWORD: ${DATASOURCE_PASSWORD} APP_KEY: ${APP_KEY} + KMS_PROVIDER: ${KMS_PROVIDER} + LOCAL_KMS_DATASOURCE_URL: ${LOCAL_KMS_DATASOURCE_URL} + LOCAL_KMS_DATASOURCE_USER: ${LOCAL_KMS_DATASOURCE_USER} + LOCAL_KMS_DATASOURCE_PASSWORD: ${LOCAL_KMS_DATASOURCE_PASSWORD} + LOCAL_KMS_DATASOURCE_DB: ${LOCAL_KMS_DATASOURCE_DB} + depends_on: db: condition: service_healthy + local-kms-db: + condition: service_healthy networks: - openid_network @@ -61,3 +88,4 @@ networks: volumes: postgres_data: + local_kms_data: diff --git a/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/EntityConfigurationMetadataController.kt b/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/EntityConfigurationMetadataController.kt index 2fa9dccd..a7ff56ce 100644 --- a/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/EntityConfigurationMetadataController.kt +++ b/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/EntityConfigurationMetadataController.kt @@ -3,7 +3,6 @@ package com.sphereon.oid.fed.server.admin.controllers import com.sphereon.oid.fed.openapi.models.CreateMetadataDTO import com.sphereon.oid.fed.openapi.models.EntityConfigurationMetadataDTO import com.sphereon.oid.fed.services.EntityConfigurationMetadataService -import com.sphereon.oid.fed.services.extensions.toAdminDTO import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -21,8 +20,7 @@ class EntityConfigurationMetadataController { fun get( @PathVariable accountUsername: String ): Array { - return entityConfigurationMetadataService.findByAccountUsername(accountUsername).map { it.toAdminDTO() } - .toTypedArray() + return entityConfigurationMetadataService.findByAccountUsername(accountUsername) } @PostMapping @@ -34,7 +32,7 @@ class EntityConfigurationMetadataController { accountUsername, body.key, body.metadata - ).toAdminDTO() + ) } @DeleteMapping("/{id}") @@ -42,6 +40,6 @@ class EntityConfigurationMetadataController { @PathVariable accountUsername: String, @PathVariable id: Int ): EntityConfigurationMetadataDTO { - return entityConfigurationMetadataService.deleteEntityConfigurationMetadata(accountUsername, id).toAdminDTO() + return entityConfigurationMetadataService.deleteEntityConfigurationMetadata(accountUsername, id) } } diff --git a/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/EntityStatementController.kt b/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/EntityStatementController.kt index cf444b49..668bc76b 100644 --- a/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/EntityStatementController.kt +++ b/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/EntityStatementController.kt @@ -1,10 +1,12 @@ package com.sphereon.oid.fed.server.admin.controllers import com.sphereon.oid.fed.openapi.models.EntityConfigurationStatement +import com.sphereon.oid.fed.openapi.models.PublishEntityStatementDTO import com.sphereon.oid.fed.services.EntityConfigurationStatementService import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -19,7 +21,10 @@ class EntityStatementController { } @PostMapping - fun publishEntityStatement(@PathVariable accountUsername: String): EntityConfigurationStatement { - return entityConfigurationStatementService.publishByUsername(accountUsername) + fun publishEntityStatement( + @PathVariable accountUsername: String, + @RequestBody body: PublishEntityStatementDTO? + ): String { + return entityConfigurationStatementService.publishByUsername(accountUsername, body?.dryRun ?: false) } } diff --git a/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/KeyController.kt b/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/KeyController.kt index 9bc819b2..4ceb636a 100644 --- a/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/KeyController.kt +++ b/modules/admin-server/src/main/kotlin/com/sphereon/oid/fed/server/admin/controllers/KeyController.kt @@ -2,8 +2,13 @@ package com.sphereon.oid.fed.server.admin.controllers import com.sphereon.oid.fed.openapi.models.JwkAdminDTO import com.sphereon.oid.fed.services.KeyService -import com.sphereon.oid.fed.services.extensions.toJwkAdminDTO -import org.springframework.web.bind.annotation.* +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/accounts/{accountUsername}/keys") @@ -13,13 +18,13 @@ class KeyController { @PostMapping fun create(@PathVariable accountUsername: String): JwkAdminDTO { val key = keyService.create(accountUsername) - return key.toJwkAdminDTO() + return key } @GetMapping - fun getKeys(@PathVariable accountUsername: String): List { + fun getKeys(@PathVariable accountUsername: String): Array { val keys = keyService.getKeys(accountUsername) - return keys.map { it.toJwkAdminDTO() } + return keys } @DeleteMapping("/{keyId}") @@ -30,4 +35,4 @@ class KeyController { ): JwkAdminDTO { return keyService.revokeKey(accountUsername, keyId, reason) } -} \ No newline at end of file +} diff --git a/modules/local-kms/build.gradle.kts b/modules/local-kms/build.gradle.kts new file mode 100644 index 00000000..c771ff60 --- /dev/null +++ b/modules/local-kms/build.gradle.kts @@ -0,0 +1,66 @@ +plugins { + kotlin("multiplatform") version "2.0.0" + id("app.cash.sqldelight") version "2.0.2" +} + +group = "com.sphereon.oid.fed.kms.local" +version = "0.1.0" + +repositories { + mavenCentral() + mavenLocal() + google() +} + +sqldelight { + databases { + create("Database") { + packageName = "com.sphereon.oid.fed.kms.local" + dialect("app.cash.sqldelight:postgresql-dialect:2.0.2") + schemaOutputDirectory = file("src/commonMain/resources/db/migration") + migrationOutputDirectory = file("src/commonMain/resources/db/migration") + deriveSchemaFromMigrations = true + migrationOutputFileFormat = ".sql" + srcDirs.from( + "src/commonMain/sqldelight" + ) + } + } +} + +kotlin { + jvm() + + sourceSets { + commonMain { + dependencies { + api(projects.modules.openapi) + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.1") + } + } + + jvmMain { + dependencies { + implementation("app.cash.sqldelight:jdbc-driver:2.0.2") + implementation("com.zaxxer:HikariCP:5.1.0") + implementation("org.postgresql:postgresql:42.7.3") + implementation("com.nimbusds:nimbus-jose-jwt:9.40") + } + } + +// jsMain { +// dependencies { +// implementation(npm("typescript", "5.5.3")) +// implementation(npm("jose", "5.6.3")) +// implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") +// } +// } + + jvmTest { + dependencies { + implementation(kotlin("test-junit")) + } + } + } +} \ No newline at end of file diff --git a/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/Constants.kt b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/Constants.kt new file mode 100644 index 00000000..928a9356 --- /dev/null +++ b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/Constants.kt @@ -0,0 +1,10 @@ +package com.sphereon.oid.fed.kms.local + +class Constants { + companion object { + const val LOCAL_KMS_DATASOURCE_URL = "LOCAL_KMS_DATASOURCE_URL" + const val LOCAL_KMS_DATASOURCE_USER = "LOCAL_KMS_DATASOURCE_USER" + const val LOCAL_KMS_DATASOURCE_PASSWORD = "LOCAL_KMS_DATASOURCE_PASSWORD" + const val SQLITE_IS_NOT_SUPPORTED_IN_JVM = "SQLite is not supported in JVM" + } +} diff --git a/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/LocalKms.kt b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/LocalKms.kt new file mode 100644 index 00000000..eae176d6 --- /dev/null +++ b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/LocalKms.kt @@ -0,0 +1,44 @@ +package com.sphereon.oid.fed.kms.local + +import com.sphereon.oid.fed.kms.local.database.LocalKmsDatabase +import com.sphereon.oid.fed.kms.local.encryption.AesEncryption +import com.sphereon.oid.fed.kms.local.extensions.toJwkAdminDto +import com.sphereon.oid.fed.kms.local.jwk.generateKeyPair +import com.sphereon.oid.fed.kms.local.jwt.sign +import com.sphereon.oid.fed.kms.local.jwt.verify +import com.sphereon.oid.fed.openapi.models.JWTHeader +import com.sphereon.oid.fed.openapi.models.Jwk +import com.sphereon.oid.fed.openapi.models.JwkAdminDTO +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject + +class LocalKms { + + private val database: LocalKmsDatabase = LocalKmsDatabase() + private val aesEncryption: AesEncryption = AesEncryption() + + fun generateKey(): JwkAdminDTO { + val jwk = generateKeyPair() + + database.insertKey( + keyId = jwk.kid!!, + key = aesEncryption.encrypt(Json.encodeToString(Jwk.serializer(), jwk)) + ) + + return jwk.toJwkAdminDto() + } + + fun sign(header: JWTHeader, payload: JsonObject, keyId: String): String { + val jwk = database.getKey(keyId) + + val jwkObject: Jwk = Json.decodeFromString(aesEncryption.decrypt(jwk.key)) + + val mHeader = header.copy(alg = jwkObject.alg, kid = jwkObject.kid) + + return sign(header = mHeader, payload = payload, key = jwkObject) + } + + fun verify(token: String, jwk: Jwk): Boolean { + return verify(jwt = token, key = jwk) + } +} diff --git a/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/database/LocalKmsDatabase.kt b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/database/LocalKmsDatabase.kt new file mode 100644 index 00000000..c698c30f --- /dev/null +++ b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/database/LocalKmsDatabase.kt @@ -0,0 +1,11 @@ +package com.sphereon.oid.fed.kms.local.database + +import com.sphereon.oid.fed.kms.local.models.Keys + +expect class LocalKmsDatabase() { + fun getKey(keyId: String): Keys + fun insertKey(keyId: String, key: String) + fun deleteKey(keyId: String) +} + +class KeyNotFoundException(message: String) : Exception(message) diff --git a/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/database/PlatformSqlDriver.kt b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/database/PlatformSqlDriver.kt new file mode 100644 index 00000000..fefec3c7 --- /dev/null +++ b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/database/PlatformSqlDriver.kt @@ -0,0 +1,8 @@ +package com.sphereon.oid.fed.kms.local.database + +import app.cash.sqldelight.db.SqlDriver + +expect class PlatformSqlDriver { + fun createPostgresDriver(url: String, username: String, password: String): SqlDriver + fun createSqliteDriver(path: String): SqlDriver +} \ No newline at end of file diff --git a/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/encryption/AesEncryption.kt b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/encryption/AesEncryption.kt new file mode 100644 index 00000000..36f03f78 --- /dev/null +++ b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/encryption/AesEncryption.kt @@ -0,0 +1,31 @@ +package com.sphereon.oid.fed.kms.local.encryption + +import java.util.* +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +private const val KEY_SIZE = 32 +private const val ALGORITHM = "AES" + +class AesEncryption { + + private val secretKey: SecretKeySpec = + SecretKeySpec(System.getenv("APP_KEY").padEnd(KEY_SIZE, '0').toByteArray(Charsets.UTF_8), ALGORITHM) + + fun encrypt(data: String): String { + val cipher = Cipher.getInstance(ALGORITHM) + cipher.init(Cipher.ENCRYPT_MODE, secretKey) + + val encryptedValue = cipher.doFinal(data.toByteArray(Charsets.UTF_8)) + return Base64.getEncoder().encodeToString(encryptedValue) + } + + fun decrypt(data: String): String { + val cipher = Cipher.getInstance(ALGORITHM) + cipher.init(Cipher.DECRYPT_MODE, secretKey) + + val decodedValue = Base64.getDecoder().decode(data) + val decryptedValue = cipher.doFinal(decodedValue) + return String(decryptedValue, Charsets.UTF_8) + } +} diff --git a/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/extensions/JwkExtension.kt b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/extensions/JwkExtension.kt new file mode 100644 index 00000000..4876609d --- /dev/null +++ b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/extensions/JwkExtension.kt @@ -0,0 +1,20 @@ +package com.sphereon.oid.fed.kms.local.extensions + +import com.sphereon.oid.fed.openapi.models.Jwk +import com.sphereon.oid.fed.openapi.models.JwkAdminDTO + +fun Jwk.toJwkAdminDto(): JwkAdminDTO = JwkAdminDTO( + kid = this.kid, + use = this.use, + crv = this.crv, + n = this.n, + e = this.e, + x = this.x, + y = this.y, + kty = this.kty, + alg = this.alg, + x5u = this.x5u, + x5t = this.x5t, + x5c = this.x5c, + x5tHashS256 = this.x5tS256 +) diff --git a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwk/Jwk.kt b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/jwk/Jwk.kt similarity index 65% rename from modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwk/Jwk.kt rename to modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/jwk/Jwk.kt index 03cbaee8..93f65ed3 100644 --- a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwk/Jwk.kt +++ b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/jwk/Jwk.kt @@ -1,6 +1,5 @@ -package com.sphereon.oid.fed.common.jwk +package com.sphereon.oid.fed.kms.local.jwk import com.sphereon.oid.fed.openapi.models.Jwk expect fun generateKeyPair(): Jwk - diff --git a/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/jwt/JoseJwt.kt b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/jwt/JoseJwt.kt new file mode 100644 index 00000000..a4032967 --- /dev/null +++ b/modules/local-kms/src/commonMain/kotlin/com/sphereon/oid/fed/kms/local/jwt/JoseJwt.kt @@ -0,0 +1,8 @@ +package com.sphereon.oid.fed.kms.local.jwt + +import com.sphereon.oid.fed.openapi.models.JWTHeader +import com.sphereon.oid.fed.openapi.models.Jwk +import kotlinx.serialization.json.JsonObject + +expect fun sign(payload: JsonObject, header: JWTHeader, key: Jwk): String +expect fun verify(jwt: String, key: Jwk): Boolean diff --git a/modules/local-kms/src/commonMain/sqldelight/com/sphereon/oid/fed/kms/local/models/1.sqm b/modules/local-kms/src/commonMain/sqldelight/com/sphereon/oid/fed/kms/local/models/1.sqm new file mode 100644 index 00000000..aaee9711 --- /dev/null +++ b/modules/local-kms/src/commonMain/sqldelight/com/sphereon/oid/fed/kms/local/models/1.sqm @@ -0,0 +1,5 @@ +CREATE TABLE Keys ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL, + deleted_at TIMESTAMP +); diff --git a/modules/local-kms/src/commonMain/sqldelight/com/sphereon/oid/fed/kms/local/models/Keys.sq b/modules/local-kms/src/commonMain/sqldelight/com/sphereon/oid/fed/kms/local/models/Keys.sq new file mode 100644 index 00000000..d9677f09 --- /dev/null +++ b/modules/local-kms/src/commonMain/sqldelight/com/sphereon/oid/fed/kms/local/models/Keys.sq @@ -0,0 +1,11 @@ +findAll: +SELECT * FROM Keys; + +create: +INSERT INTO Keys (id, key) VALUES (?, ?) RETURNING *; + +findById: +SELECT * FROM Keys WHERE id = ?; + +delete: +UPDATE Keys SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?; diff --git a/modules/openid-federation-common/src/jsMain/kotlin/com.sphereon.oid.fed.common.jwk/Jwk.kt b/modules/local-kms/src/jsMain/kotlin/com/sphereon/oid/fed/kms/local/jwk/Jwk.kt similarity index 79% rename from modules/openid-federation-common/src/jsMain/kotlin/com.sphereon.oid.fed.common.jwk/Jwk.kt rename to modules/local-kms/src/jsMain/kotlin/com/sphereon/oid/fed/kms/local/jwk/Jwk.kt index f9c5208c..71f7aa93 100644 --- a/modules/openid-federation-common/src/jsMain/kotlin/com.sphereon.oid.fed.common.jwk/Jwk.kt +++ b/modules/local-kms/src/jsMain/kotlin/com/sphereon/oid/fed/kms/local/jwk/Jwk.kt @@ -1,6 +1,6 @@ -package com.sphereon.oid.fed.common.jwk +package com.sphereon.oid.fed.kms.local.jwk -import com.sphereon.oid.fed.common.jwt.Jose +import com.sphereon.oid.fed.kms.local.jwt.Jose import com.sphereon.oid.fed.openapi.models.Jwk @ExperimentalJsExport diff --git a/modules/openid-federation-common/src/jsMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.js.kt b/modules/local-kms/src/jsMain/kotlin/com/sphereon/oid/fed/kms/local/jwt/JoseJwt.js.kt similarity index 78% rename from modules/openid-federation-common/src/jsMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.js.kt rename to modules/local-kms/src/jsMain/kotlin/com/sphereon/oid/fed/kms/local/jwt/JoseJwt.js.kt index 5429b9b5..aa502766 100644 --- a/modules/openid-federation-common/src/jsMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.js.kt +++ b/modules/local-kms/src/jsMain/kotlin/com/sphereon/oid/fed/kms/local/jwt/JoseJwt.js.kt @@ -1,7 +1,8 @@ -package com.sphereon.oid.fed.common.jwt +package com.sphereon.oid.fed.kms.local.jwt import com.sphereon.oid.fed.openapi.models.EntityConfigurationStatement import com.sphereon.oid.fed.openapi.models.JWTHeader +import com.sphereon.oid.fed.openapi.models.Jwk import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -26,17 +27,12 @@ external object Jose { fun jwtVerify(jwt: String, key: Any, options: dynamic = definedExternally): dynamic } -actual typealias JwtPayload = EntityConfigurationStatement -actual typealias JwtHeader = JWTHeader - @ExperimentalJsExport @JsExport actual fun sign( - payload: JwtPayload, - header: JwtHeader, - opts: Map + payload: JsonObject, header: JWTHeader, key: Jwk ): String { - val privateKey = opts["privateKey"] ?: throw IllegalArgumentException("JWK private key is required") + val privateKey = key.privateKey ?: throw IllegalArgumentException("JWK private key is required") return Jose.SignJWT(JSON.parse(Json.encodeToString(payload))) .setProtectedHeader(JSON.parse(Json.encodeToString(header))) diff --git a/modules/local-kms/src/jvmMain/kotlin/com/sphereon/oid/fed/kms/local/database/LocalKmsDatabase.jvm.kt b/modules/local-kms/src/jvmMain/kotlin/com/sphereon/oid/fed/kms/local/database/LocalKmsDatabase.jvm.kt new file mode 100644 index 00000000..7aab0e68 --- /dev/null +++ b/modules/local-kms/src/jvmMain/kotlin/com/sphereon/oid/fed/kms/local/database/LocalKmsDatabase.jvm.kt @@ -0,0 +1,75 @@ +package com.sphereon.oid.fed.kms.local.database + +import app.cash.sqldelight.db.QueryResult +import app.cash.sqldelight.db.SqlCursor +import app.cash.sqldelight.db.SqlDriver +import com.sphereon.oid.fed.kms.local.Constants +import com.sphereon.oid.fed.kms.local.Database +import com.sphereon.oid.fed.kms.local.models.Keys + + +actual class LocalKmsDatabase { + + private var database: Database + + init { + val driver = getDriver() + runMigrations(driver) + + database = Database(driver) + } + + private fun getDriver(): SqlDriver { + return PlatformSqlDriver().createPostgresDriver( + System.getenv(Constants.LOCAL_KMS_DATASOURCE_URL), + System.getenv(Constants.LOCAL_KMS_DATASOURCE_USER), + System.getenv(Constants.LOCAL_KMS_DATASOURCE_PASSWORD) + ) + } + + private fun runMigrations(driver: SqlDriver) { + setupSchemaVersioningTable(driver) + + val currentVersion = getCurrentDatabaseVersion(driver) + val newVersion = Database.Schema.version + + if (currentVersion < newVersion) { + Database.Schema.migrate(driver, currentVersion, newVersion) + updateDatabaseVersion(driver, newVersion) + } + } + + private fun setupSchemaVersioningTable(driver: SqlDriver) { + driver.execute(null, "CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)", 0) + } + + private fun getCurrentDatabaseVersion(driver: SqlDriver): Long { + val versionQuery = "SELECT version FROM schema_version ORDER BY version DESC LIMIT 1" + + val version = driver.executeQuery(null, versionQuery, parameters = 0, mapper = { cursor: SqlCursor -> + QueryResult.Value(if (cursor.next().value) cursor.getLong(0) else null) + }) + + return version.value ?: 0 + } + + private fun updateDatabaseVersion(driver: SqlDriver, newVersion: Long) { + val updateQuery = "INSERT INTO schema_version (version) VALUES (?)" + driver.execute(null, updateQuery, 1) { + bindLong(0, newVersion) + } + } + + actual fun getKey(keyId: String): Keys { + return database.keysQueries.findById(keyId).executeAsOneOrNull() + ?: throw KeyNotFoundException("$keyId not found") + } + + actual fun insertKey(keyId: String, key: String) { + database.keysQueries.create(keyId, key).executeAsOneOrNull() + } + + actual fun deleteKey(keyId: String) { + database.keysQueries.delete(keyId) + } +} diff --git a/modules/local-kms/src/jvmMain/kotlin/com/sphereon/oid/fed/kms/local/database/PlatformSqlDriver.kt b/modules/local-kms/src/jvmMain/kotlin/com/sphereon/oid/fed/kms/local/database/PlatformSqlDriver.kt new file mode 100644 index 00000000..6a76e099 --- /dev/null +++ b/modules/local-kms/src/jvmMain/kotlin/com/sphereon/oid/fed/kms/local/database/PlatformSqlDriver.kt @@ -0,0 +1,23 @@ +package com.sphereon.oid.fed.kms.local.database + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.asJdbcDriver +import com.sphereon.oid.fed.kms.local.Constants +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource + +actual class PlatformSqlDriver { + actual fun createPostgresDriver(url: String, username: String, password: String): SqlDriver { + val config = HikariConfig() + config.jdbcUrl = url + config.username = username + config.password = password + + val dataSource = HikariDataSource(config) + return dataSource.asJdbcDriver() + } + + actual fun createSqliteDriver(path: String): SqlDriver { + throw UnsupportedOperationException(Constants.SQLITE_IS_NOT_SUPPORTED_IN_JVM) + } +} \ No newline at end of file diff --git a/modules/openid-federation-common/src/jvmMain/kotlin/com/sphereon/oid/fed/common/jwk/Jwk.jvm.kt b/modules/local-kms/src/jvmMain/kotlin/com/sphereon/oid/fed/kms/local/jwk/Jwk.jvm.kt similarity index 90% rename from modules/openid-federation-common/src/jvmMain/kotlin/com/sphereon/oid/fed/common/jwk/Jwk.jvm.kt rename to modules/local-kms/src/jvmMain/kotlin/com/sphereon/oid/fed/kms/local/jwk/Jwk.jvm.kt index 873ddaba..78e9442d 100644 --- a/modules/openid-federation-common/src/jvmMain/kotlin/com/sphereon/oid/fed/common/jwk/Jwk.jvm.kt +++ b/modules/local-kms/src/jvmMain/kotlin/com/sphereon/oid/fed/kms/local/jwk/Jwk.jvm.kt @@ -1,4 +1,4 @@ -package com.sphereon.oid.fed.common.jwk +package com.sphereon.oid.fed.kms.local.jwk import com.nimbusds.jose.Algorithm import com.nimbusds.jose.jwk.Curve @@ -7,11 +7,12 @@ import com.nimbusds.jose.jwk.gen.ECKeyGenerator import com.sphereon.oid.fed.openapi.models.Jwk import java.util.* + actual fun generateKeyPair(): Jwk { try { val ecKey: ECKey = ECKeyGenerator(Curve.P_256) .keyIDFromThumbprint(true) - .algorithm(Algorithm("EC")) + .algorithm(Algorithm("ES256")) .issueTime(Date()) .generate() diff --git a/modules/local-kms/src/jvmMain/kotlin/com/sphereon/oid/fed/kms/local/jwt/JoseJwt.jvm.kt b/modules/local-kms/src/jvmMain/kotlin/com/sphereon/oid/fed/kms/local/jwt/JoseJwt.jvm.kt new file mode 100644 index 00000000..a6d4ed96 --- /dev/null +++ b/modules/local-kms/src/jvmMain/kotlin/com/sphereon/oid/fed/kms/local/jwt/JoseJwt.jvm.kt @@ -0,0 +1,52 @@ +package com.sphereon.oid.fed.kms.local.jwt + +import com.nimbusds.jose.* +import com.nimbusds.jose.crypto.ECDSASigner +import com.nimbusds.jose.crypto.ECDSAVerifier +import com.nimbusds.jose.jwk.ECKey +import com.nimbusds.jwt.JWTClaimsSet +import com.nimbusds.jwt.SignedJWT +import com.sphereon.oid.fed.openapi.models.JWTHeader +import com.sphereon.oid.fed.openapi.models.Jwk +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject + +actual fun sign( + payload: JsonObject, header: JWTHeader, key: Jwk +): String { + val jwkJsonString = Json.encodeToString(key) + val ecJWK = ECKey.parse(jwkJsonString) + val signer: JWSSigner = ECDSASigner(ecJWK) + val jwsHeader = header.toJWSHeader() + + val signedJWT = SignedJWT( + jwsHeader, JWTClaimsSet.parse(payload.toString()) + ) + + signedJWT.sign(signer) + return signedJWT.serialize() +} + +actual fun verify( + jwt: String, key: Jwk +): Boolean { + try { + val jwkJsonString = Json.encodeToString(key) + val ecKey = ECKey.parse(jwkJsonString) + val verifier: JWSVerifier = ECDSAVerifier(ecKey) + 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}", e) + } +} + +fun JWTHeader.toJWSHeader(): JWSHeader { + val type = typ + return JWSHeader.Builder(JWSAlgorithm.parse(alg)).apply { + type(JOSEObjectType(type)) + keyID(kid) + }.build() +} diff --git a/modules/local-kms/src/jvmTest/kotlin/com/sphereon/oid/fed/kms/local/jwt/JoseJwtTest.jvm.kt b/modules/local-kms/src/jvmTest/kotlin/com/sphereon/oid/fed/kms/local/jwt/JoseJwtTest.jvm.kt new file mode 100644 index 00000000..8e92a1b8 --- /dev/null +++ b/modules/local-kms/src/jvmTest/kotlin/com/sphereon/oid/fed/kms/local/jwt/JoseJwtTest.jvm.kt @@ -0,0 +1,49 @@ +package com.sphereon.oid.fed.kms.local.jwt + +import com.nimbusds.jose.Algorithm +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.jwk.Curve +import com.nimbusds.jose.jwk.gen.ECKeyGenerator +import com.sphereon.oid.fed.openapi.models.EntityConfigurationStatement +import com.sphereon.oid.fed.openapi.models.JWTHeader +import com.sphereon.oid.fed.openapi.models.Jwk +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlin.test.Test +import kotlin.test.assertTrue + +class JoseJwtTest { + + @Test + fun signTest() { + val key = ECKeyGenerator(Curve.P_256).keyID("key1").algorithm(Algorithm("ES256")).generate() + val jwk = key.toString() + val entityStatement = EntityConfigurationStatement( + iss = "test", sub = "test", exp = 111111, iat = 111111, jwks = JsonObject(mapOf()) + ) + val payload: JsonObject = Json.encodeToJsonElement(entityStatement) as JsonObject + val signature = sign( + payload, + JWTHeader(alg = JWSAlgorithm.ES256.toString(), typ = "JWT", kid = key.keyID), + Json.decodeFromString(jwk) + ) + assertTrue { signature.startsWith("ey") } + } + + @Test + fun verifyTest() { + val key = ECKeyGenerator(Curve.P_256).keyID("key1").algorithm(Algorithm("ES256")).generate() + val jwk = key.toString() + val entityStatement = EntityConfigurationStatement( + iss = "test", sub = "test", exp = 111111, iat = 111111, jwks = JsonObject(mapOf()) + ) + val payload: JsonObject = Json.encodeToJsonElement(entityStatement) as JsonObject + val signature = sign( + payload, + JWTHeader(alg = JWSAlgorithm.ES256.toString(), typ = "JWT", kid = key.keyID), + Json.decodeFromString(jwk) + ) + assertTrue { verify(signature, Json.decodeFromString(jwk)) } + } +} diff --git a/modules/openapi/src/commonMain/kotlin/com/sphereon/oid/fed/openapi/openapi.yaml b/modules/openapi/src/commonMain/kotlin/com/sphereon/oid/fed/openapi/openapi.yaml index ed9aa27e..1f4af3a5 100644 --- a/modules/openapi/src/commonMain/kotlin/com/sphereon/oid/fed/openapi/openapi.yaml +++ b/modules/openapi/src/commonMain/kotlin/com/sphereon/oid/fed/openapi/openapi.yaml @@ -9,7 +9,7 @@ info: license: name: Apache 2.0 url: http://www.apache.org/licenses/LICENSE-2.0.html - version: 1.0.0-d36 + version: 1.0.0-d38 tags: - name: federation @@ -60,19 +60,6 @@ paths: - federation summary: Fetch Entity Statement description: Fetch an Entity Statement for a specified issuer and optional subject. - parameters: - - name: iss - in: query - description: The Entity Identifier of the issuer from which the Entity Statement is issued. Because of the normalization of the URL, multiple issuers MAY resolve to a shared fetch endpoint. This parameter makes it explicit exactly which issuer the Entity Statement must come from. - required: true - schema: - type: string - - name: sub - in: query - description: The Entity Identifier of the subject for which the Entity Statement is being requested. If this parameter is omitted, it is considered to be the same as the issuer and indicates a request for a self-signed Entity Configuration. - required: false - schema: - type: string responses: '200': description: Successful fetch of Entity Statement @@ -2545,6 +2532,16 @@ components: - key - metadata + PublishEntityStatementDTO: + type: object + x-tags: + - federation + properties: + dry-run: + type: boolean + description: If true, the request will be validated but not persisted. + example: false + CreateAuthorityHintDTO: type: object properties: diff --git a/modules/openid-federation-common/build.gradle.kts b/modules/openid-federation-common/build.gradle.kts index 09aab985..a411df92 100644 --- a/modules/openid-federation-common/build.gradle.kts +++ b/modules/openid-federation-common/build.gradle.kts @@ -73,7 +73,6 @@ kotlin { dependencies { implementation("io.ktor:ktor-client-core-jvm:$ktorVersion") runtimeOnly("io.ktor:ktor-client-cio-jvm:$ktorVersion") - implementation("com.nimbusds:nimbus-jose-jwt:9.40") } } val jvmTest by getting { @@ -130,8 +129,6 @@ kotlin { dependencies { runtimeOnly("io.ktor:ktor-client-core-js:$ktorVersion") runtimeOnly("io.ktor:ktor-client-js:$ktorVersion") - implementation(npm("typescript", "5.5.3")) - implementation(npm("jose", "5.6.3")) implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0-RC") } diff --git a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/builder/EntityConfigurationStatementBuilder.kt b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/builder/EntityConfigurationStatementBuilder.kt index c07eb52d..3e60d8e0 100644 --- a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/builder/EntityConfigurationStatementBuilder.kt +++ b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/builder/EntityConfigurationStatementBuilder.kt @@ -37,7 +37,8 @@ class EntityConfigurationStatementBuilder { @OptIn(ExperimentalSerializationApi::class) private fun createJwks(jwks: Array): JsonObject { - val jsonArray: JsonArray = Json.encodeToJsonElement(ArraySerializer(JwkDTO.serializer()), jwks) as JsonArray + val jsonArray: JsonArray = + Json.encodeToJsonElement(ArraySerializer(JwkDTO.serializer()), jwks) as JsonArray return buildJsonObject { put("keys", jsonArray) diff --git a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.kt b/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.kt deleted file mode 100644 index a6ccd627..00000000 --- a/modules/openid-federation-common/src/commonMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.sphereon.oid.fed.common.jwt - -expect class JwtHeader -expect class JwtPayload - -expect fun sign(payload: JwtPayload, header: JwtHeader, opts: Map): String -expect fun verify(jwt: String, key: Any, opts: Map): Boolean diff --git a/modules/openid-federation-common/src/jvmMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.jvm.kt b/modules/openid-federation-common/src/jvmMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.jvm.kt deleted file mode 100644 index 377697ad..00000000 --- a/modules/openid-federation-common/src/jvmMain/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwt.jvm.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.sphereon.oid.fed.common.jwt - -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.jwt.JWTClaimsSet -import com.nimbusds.jwt.SignedJWT - -actual typealias JwtPayload = JWTClaimsSet -actual typealias JwtHeader = JWSHeader - -actual fun sign( - payload: JwtPayload, - header: JwtHeader, - opts: Map -): String { - val rsaJWK = opts["key"] as RSAKey? ?: throw IllegalArgumentException("The RSA key pair is required") - - val signer: JWSSigner = RSASSASigner(rsaJWK) - - val signedJWT = SignedJWT( - header, - payload - ) - - signedJWT.sign(signer) - return signedJWT.serialize() -} - -actual fun verify( - jwt: String, - key: Any, - opts: Map -): 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}", e) - } -} \ No newline at end of file diff --git a/modules/openid-federation-common/src/jvmTest/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwtTest.jvm.kt b/modules/openid-federation-common/src/jvmTest/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwtTest.jvm.kt deleted file mode 100644 index 54e8ddc3..00000000 --- a/modules/openid-federation-common/src/jvmTest/kotlin/com/sphereon/oid/fed/common/jwt/JoseJwtTest.jvm.kt +++ /dev/null @@ -1,44 +0,0 @@ -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 key = RSAKeyGenerator(2048).keyID("key1").generate() - val signature = sign( - JwtPayload.parse( - mutableMapOf( - "iss" to "test" - ) - ), - JwtHeader.parse(mutableMapOf( - "typ" to "JWT", - "alg" to "RS256", - "kid" to key.keyID)), - mutableMapOf("key" to key) - ) - assertTrue { signature.startsWith("ey") } - } - - @Test - fun verifyTest() { - val kid = "key1" - val key: RSAKey = RSAKeyGenerator(2048).keyID(kid).generate() - val signature = sign( - JwtPayload.parse( - mutableMapOf("iss" to "test") - ), - JwtHeader.parse(mutableMapOf( - "typ" to "JWT", - "alg" to "RS256", - "kid" to key.keyID)), - mutableMapOf("key" to key) - ) - assertTrue { verify(signature, key, emptyMap()) } - } -} diff --git a/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/2.sqm b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/2.sqm index 61d5198e..4cdb7165 100644 --- a/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/2.sqm +++ b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/2.sqm @@ -1,26 +1,8 @@ CREATE TABLE Jwk ( id SERIAL PRIMARY KEY, - uuid UUID DEFAULT gen_random_uuid(), account_id INT NOT NULL, - kty VARCHAR(10) NOT NULL, - crv VARCHAR(10), kid VARCHAR(255) UNIQUE, - x TEXT, - y TEXT, - d TEXT, - n TEXT, - e TEXT, - p TEXT, - q TEXT, - dp TEXT, - dq TEXT, - qi TEXT, - x5u TEXT, - x5c TEXT[], - x5t TEXT, - x5t_s256 TEXT, - alg VARCHAR(10), - use VARCHAR(10) NULL, + key TEXT NOT NULL, revoked_at TIMESTAMP, revoked_reason TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, diff --git a/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Key.sq b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Key.sq index 1db776f8..108a8dc0 100644 --- a/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Key.sq +++ b/modules/persistence/src/commonMain/sqldelight/com/sphereon/oid/fed/persistence/models/Key.sq @@ -1,33 +1,15 @@ create: INSERT INTO Jwk ( account_id, - kty, - crv, kid, - x, - y, - d, - n, - e, - p, - q, - dp, - dq, - qi, - x5u, - x5c, - x5t, - x5t_s256, - alg, - use -) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; + key +) VALUES (?, ?, ?) RETURNING *; revoke: UPDATE Jwk SET (revoked_at, revoked_reason) = (CURRENT_TIMESTAMP, ?) WHERE id = ?; findByAccountId: -SELECT * FROM Jwk WHERE account_id = ?; +SELECT * FROM Jwk WHERE account_id = ? AND revoked_at IS NULL ORDER BY created_at DESC; findById: SELECT * FROM Jwk WHERE id = ?; diff --git a/modules/services/build.gradle.kts b/modules/services/build.gradle.kts index aa45dd97..56eeb580 100644 --- a/modules/services/build.gradle.kts +++ b/modules/services/build.gradle.kts @@ -21,6 +21,7 @@ kotlin { api(projects.modules.openapi) api(projects.modules.persistence) api(projects.modules.openidFederationCommon) + api(projects.modules.localKms) implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.11") } } diff --git a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/Constants.kt b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/Constants.kt index 562f8116..eb7e22c0 100644 --- a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/Constants.kt +++ b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/Constants.kt @@ -18,5 +18,6 @@ class Constants { const val FAILED_TO_CREATE_CRIT = "Failed to create crit" const val CRIT_NOT_FOUND = "Crit not found" const val FAILED_TO_DELETE_CRIT = "Failed to delete crit" + const val NO_KEYS_FOUND = "No keys found" } } diff --git a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/EntityConfigurationMetadataService.kt b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/EntityConfigurationMetadataService.kt index d5e26837..e902afcd 100644 --- a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/EntityConfigurationMetadataService.kt +++ b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/EntityConfigurationMetadataService.kt @@ -1,7 +1,8 @@ package com.sphereon.oid.fed.services +import com.sphereon.oid.fed.openapi.models.EntityConfigurationMetadataDTO import com.sphereon.oid.fed.persistence.Persistence -import com.sphereon.oid.fed.persistence.models.EntityConfigurationMetadata +import com.sphereon.oid.fed.services.extensions.toEntityConfigurationMetadataDTO import kotlinx.serialization.json.JsonObject class EntityConfigurationMetadataService { @@ -9,7 +10,7 @@ class EntityConfigurationMetadataService { accountUsername: String, key: String, metadata: JsonObject - ): EntityConfigurationMetadata { + ): EntityConfigurationMetadataDTO { val account = Persistence.accountQueries.findByUsername(accountUsername).executeAsOneOrNull() ?: throw IllegalArgumentException(Constants.ACCOUNT_NOT_FOUND) @@ -20,22 +21,22 @@ class EntityConfigurationMetadataService { throw IllegalStateException(Constants.ENTITY_CONFIGURATION_METADATA_ALREADY_EXISTS) } - return Persistence.entityConfigurationMetadataQueries.create(account.id, key, metadata.toString()) - .executeAsOneOrNull() - ?: throw IllegalStateException(Constants.FAILED_TO_CREATE_ENTITY_CONFIGURATION_METADATA) - } + val createdMetadata = + Persistence.entityConfigurationMetadataQueries.create(account.id, key, metadata.toString()) + .executeAsOneOrNull() + ?: throw IllegalStateException(Constants.FAILED_TO_CREATE_ENTITY_CONFIGURATION_METADATA) - fun findByAccountId(accountId: Int): Array { - return Persistence.entityConfigurationMetadataQueries.findByAccountId(accountId).executeAsList().toTypedArray() + return createdMetadata.toEntityConfigurationMetadataDTO() } - fun findByAccountUsername(accountUsername: String): Array { + fun findByAccountUsername(accountUsername: String): Array { val account = Persistence.accountQueries.findByUsername(accountUsername).executeAsOneOrNull() ?: throw IllegalArgumentException(Constants.ACCOUNT_NOT_FOUND) - return Persistence.entityConfigurationMetadataQueries.findByAccountId(account.id).executeAsList().toTypedArray() + return Persistence.entityConfigurationMetadataQueries.findByAccountId(account.id).executeAsList() + .map { it.toEntityConfigurationMetadataDTO() }.toTypedArray() } - fun deleteEntityConfigurationMetadata(accountUsername: String, id: Int): EntityConfigurationMetadata { + fun deleteEntityConfigurationMetadata(accountUsername: String, id: Int): EntityConfigurationMetadataDTO { val account = Persistence.accountQueries.findByUsername(accountUsername).executeAsOneOrNull() ?: throw IllegalArgumentException(Constants.ACCOUNT_NOT_FOUND) @@ -47,7 +48,9 @@ class EntityConfigurationMetadataService { throw IllegalArgumentException(Constants.ENTITY_CONFIGURATION_METADATA_NOT_FOUND) } - return Persistence.entityConfigurationMetadataQueries.delete(id).executeAsOneOrNull() + val deletedMetadata = Persistence.entityConfigurationMetadataQueries.delete(id).executeAsOneOrNull() ?: throw IllegalArgumentException(Constants.ENTITY_CONFIGURATION_METADATA_NOT_FOUND) + + return deletedMetadata.toEntityConfigurationMetadataDTO() } } diff --git a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/EntityConfigurationStatementService.kt b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/EntityConfigurationStatementService.kt index fee6eb9c..2ac61587 100644 --- a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/EntityConfigurationStatementService.kt +++ b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/EntityConfigurationStatementService.kt @@ -4,14 +4,16 @@ import com.sphereon.oid.fed.common.builder.EntityConfigurationStatementBuilder import com.sphereon.oid.fed.common.builder.FederationEntityMetadataBuilder import com.sphereon.oid.fed.openapi.models.EntityConfigurationStatement import com.sphereon.oid.fed.openapi.models.FederationEntityMetadata +import com.sphereon.oid.fed.openapi.models.JWTHeader import com.sphereon.oid.fed.persistence.Persistence -import com.sphereon.oid.fed.services.extensions.toJwkDTO +import com.sphereon.oid.fed.services.extensions.toJwkDto import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject class EntityConfigurationStatementService { private val accountService = AccountService() private val keyService = KeyService() + private val kmsClient = KmsService.getKmsClient() private val entityConfigurationStatementQueries = Persistence.entityConfigurationStatementQueries private val accountQueries = Persistence.accountQueries private val subordinateQueries = Persistence.subordinateQueries @@ -21,7 +23,7 @@ class EntityConfigurationStatementService { val account = accountQueries.findByUsername(accountUsername).executeAsOneOrNull() ?: throw IllegalArgumentException(Constants.ACCOUNT_NOT_FOUND) val identifier = accountService.getAccountIdentifier(account.username) - val keys = keyService.getKeys(accountUsername).map { it.toJwkDTO() }.toTypedArray() + val keys = keyService.getKeys(accountUsername) val hasSubordinates = subordinateQueries.findByAccountId(account.id).executeAsList().isNotEmpty() val authorityHints = authorityHintQueries.findByAccountId(account.id).executeAsList().map { it.identifier }.toTypedArray() @@ -32,7 +34,7 @@ class EntityConfigurationStatementService { .iss(identifier) .iat((System.currentTimeMillis() / 1000).toInt()) .exp((System.currentTimeMillis() / 1000 + 3600 * 24 * 365).toInt()) - .jwks(keys) + .jwks(keys.map { it.toJwkDto() }.toTypedArray()) if (hasSubordinates) { val federationEntityMetadata = FederationEntityMetadataBuilder() @@ -64,19 +66,38 @@ class EntityConfigurationStatementService { return entityConfigurationStatement.build() } - fun publishByUsername(accountUsername: String): EntityConfigurationStatement { + fun publishByUsername(accountUsername: String, dryRun: Boolean? = false): String { val account = accountService.getAccountByUsername(accountUsername) val entityConfigurationStatement = findByUsername(accountUsername) - // @TO-DO JWT creation and signing + val keys = keyService.getKeys(accountUsername) + + if (keys.isEmpty()) { + throw IllegalArgumentException(Constants.NO_KEYS_FOUND) + } + + val key = keys[0].kid + + val jwt = kmsClient.sign( + payload = Json.encodeToJsonElement( + EntityConfigurationStatement.serializer(), + entityConfigurationStatement + ).jsonObject, + header = JWTHeader(typ = "entity-statement+jwt"), + keyId = key!! + ) + + if (dryRun == true) { + return jwt + } entityConfigurationStatementQueries.create( account_id = account.id, expires_at = entityConfigurationStatement.exp.toLong(), - statement = Json.encodeToString(EntityConfigurationStatement.serializer(), entityConfigurationStatement) + statement = jwt ).executeAsOne() - return entityConfigurationStatement + return jwt } } diff --git a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/KeyService.kt b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/KeyService.kt index 9aac7a58..32abaf7f 100644 --- a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/KeyService.kt +++ b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/KeyService.kt @@ -1,58 +1,34 @@ package com.sphereon.oid.fed.services -import com.sphereon.oid.fed.common.jwk.generateKeyPair import com.sphereon.oid.fed.openapi.models.JwkAdminDTO import com.sphereon.oid.fed.persistence.Persistence -import com.sphereon.oid.fed.persistence.models.Jwk -import com.sphereon.oid.fed.services.extensions.decrypt -import com.sphereon.oid.fed.services.extensions.encrypt import com.sphereon.oid.fed.services.extensions.toJwkAdminDTO +import kotlinx.serialization.json.Json class KeyService { + private val kmsClient = KmsService.getKmsClient() private val accountQueries = Persistence.accountQueries private val keyQueries = Persistence.keyQueries - fun create(accountUsername: String): Jwk { + fun create(accountUsername: String): JwkAdminDTO { val account = accountQueries.findByUsername(accountUsername).executeAsOne() - val encryptedKeyPair = generateKeyPair().encrypt() + val jwk = kmsClient.generateKeyPair() - val key = keyQueries.create( - account.id, - y = encryptedKeyPair.y, - x = encryptedKeyPair.x, - d = encryptedKeyPair.d, - crv = encryptedKeyPair.crv, - kty = encryptedKeyPair.kty, - use = encryptedKeyPair.use, - alg = encryptedKeyPair.alg, - kid = encryptedKeyPair.kid, - e = encryptedKeyPair.e, - n = encryptedKeyPair.n, - p = encryptedKeyPair.p, - x5c = encryptedKeyPair.x5c, - dp = encryptedKeyPair.dp, - x5t_s256 = encryptedKeyPair.x5tS256, - q = encryptedKeyPair.q, - qi = encryptedKeyPair.qi, - dq = encryptedKeyPair.dq, - x5u = encryptedKeyPair.x5u, - x5t = encryptedKeyPair.x5t, + keyQueries.create( + account_id = account.id, + kid = jwk.kid!!, + key = Json.encodeToString(JwkAdminDTO.serializer(), jwk), ).executeAsOne() - return key + return jwk } - fun getDecryptedKey(keyId: Int): Jwk { - var key = keyQueries.findById(keyId).executeAsOne() - return key.decrypt() - } - - fun getKeys(accountUsername: String): Array { + fun getKeys(accountUsername: String): Array { val account = accountQueries.findByUsername(accountUsername).executeAsOne() - return keyQueries.findByAccountId(account.id).executeAsList().toTypedArray() + return keyQueries.findByAccountId(account.id).executeAsList().map { it.toJwkAdminDTO() }.toTypedArray() } fun revokeKey(accountUsername: String, keyId: Int, reason: String?): JwkAdminDTO { diff --git a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/KmsService.kt b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/KmsService.kt new file mode 100644 index 00000000..5a95d04d --- /dev/null +++ b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/KmsService.kt @@ -0,0 +1,23 @@ +package com.sphereon.oid.fed.services + +import com.sphereon.oid.fed.openapi.models.JWTHeader +import com.sphereon.oid.fed.openapi.models.Jwk +import com.sphereon.oid.fed.openapi.models.JwkAdminDTO +import kotlinx.serialization.json.JsonObject + +object KmsService { + private val provider: String = System.getenv("KMS_PROVIDER") ?: "local" + + private val kmsClient: KmsClient = when (provider) { + "local" -> LocalKmsClient() + else -> throw IllegalArgumentException("Unsupported KMS provider: $provider") + } + + fun getKmsClient(): KmsClient = kmsClient +} + +interface KmsClient { + fun generateKeyPair(): JwkAdminDTO + fun sign(header: JWTHeader, payload: JsonObject, keyId: String): String + fun verify(token: String, jwk: Jwk): Boolean +} diff --git a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/LocalKmsClient.kt b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/LocalKmsClient.kt new file mode 100644 index 00000000..64edca2f --- /dev/null +++ b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/LocalKmsClient.kt @@ -0,0 +1,26 @@ +package com.sphereon.oid.fed.services + +import com.sphereon.oid.fed.kms.local.LocalKms +import com.sphereon.oid.fed.openapi.models.JWTHeader +import com.sphereon.oid.fed.openapi.models.Jwk +import com.sphereon.oid.fed.openapi.models.JwkAdminDTO + + +import kotlinx.serialization.json.JsonObject + +class LocalKmsClient : KmsClient { + + private val localKms = LocalKms() + + override fun generateKeyPair(): JwkAdminDTO { + return localKms.generateKey() + } + + override fun sign(header: JWTHeader, payload: JsonObject, keyId: String): String { + return localKms.sign(header, payload, keyId) + } + + override fun verify(token: String, jwk: Jwk): Boolean { + return localKms.verify(token, jwk) + } +} diff --git a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/extensions/EntityConfigurationMetadataExtension.kt b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/extensions/EntityConfigurationMetadataExtension.kt new file mode 100644 index 00000000..e1ab4aec --- /dev/null +++ b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/extensions/EntityConfigurationMetadataExtension.kt @@ -0,0 +1,17 @@ +package com.sphereon.oid.fed.services.extensions + +import com.sphereon.oid.fed.openapi.models.EntityConfigurationMetadataDTO +import com.sphereon.oid.fed.persistence.models.EntityConfigurationMetadata +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject + + +fun EntityConfigurationMetadata.toEntityConfigurationMetadataDTO(): EntityConfigurationMetadataDTO { + return EntityConfigurationMetadataDTO( + id = this.id, + key = this.key, + metadata = Json.parseToJsonElement(this.metadata).jsonObject, + createdAt = this.created_at.toString(), + accountId = this.account_id + ) +} diff --git a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.kt b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.kt index e70fb2df..1011e0d9 100644 --- a/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.kt +++ b/modules/services/src/commonMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.kt @@ -3,75 +3,45 @@ package com.sphereon.oid.fed.services.extensions import com.sphereon.oid.fed.openapi.models.Jwk import com.sphereon.oid.fed.openapi.models.JwkAdminDTO import com.sphereon.oid.fed.openapi.models.JwkDTO +import kotlinx.serialization.json.Json import com.sphereon.oid.fed.persistence.models.Jwk as JwkPersistence -fun JwkPersistence.toJwkAdminDTO(): JwkAdminDTO = JwkAdminDTO( - id = id, - accountId = account_id, - uuid = uuid.toString(), - e = e, - n = n, - x = x, - y = y, - alg = alg, - crv = crv, - kid = kid, - kty = kty, - use = use, - x5c = x5c, - x5t = x5t, - x5u = x5u, - x5tHashS256 = x5t_s256, - createdAt = created_at.toString(), - revokedAt = revoked_at.toString(), - revokedReason = revoked_reason -) - -fun JwkPersistence.toJwkDTO(): JwkDTO = JwkDTO( - e = e, - n = n, - x = x, - y = y, - alg = alg, - crv = crv, - kid = kid, - kty = kty, - use = use, - x5c = x5c, - x5t = x5t, - x5u = x5u, -) - -fun Jwk.encrypt(): Jwk { - if (System.getenv("APP_KEY") == null) return this - - fun String?.encryptOrNull() = this?.let { aesEncrypt(it, System.getenv("APP_KEY")) } - - return copy( - d = d.encryptOrNull(), - dq = dq.encryptOrNull(), - qi = qi.encryptOrNull(), - dp = dp.encryptOrNull(), - p = p.encryptOrNull(), - q = q.encryptOrNull() +fun JwkPersistence.toJwkAdminDTO(): JwkAdminDTO { + val key = Json.decodeFromString(this.key) + + return JwkAdminDTO( + id = id, + accountId = account_id, + e = key.e, + x = key.x, + y = key.y, + n = key.n, + alg = key.alg, + crv = key.crv, + kid = key.kid, + kty = key.kty, + use = key.use, + x5c = key.x5c, + x5t = key.x5t, + x5u = key.x5u, + x5tHashS256 = key.x5tS256, ) } -fun JwkPersistence.decrypt(): JwkPersistence { - if (System.getenv("APP_KEY") == null) return this - - fun String?.decryptOrNull() = this?.let { aesDecrypt(it, System.getenv("APP_KEY")) } - - return copy( - d = d.decryptOrNull(), - dq = dq.decryptOrNull(), - qi = qi.decryptOrNull(), - dp = dp.decryptOrNull(), - p = p.decryptOrNull(), - q = q.decryptOrNull() +fun JwkAdminDTO.toJwkDto(): JwkDTO { + return JwkDTO( + crv = crv, + e = e, + x = x, + y = y, + n = n, + alg = alg, + kid = kid, + kty = kty!!, + use = use, + x5c = x5c, + x5t = x5t, + x5u = x5u, + x5tS256 = x5tHashS256, ) } - -expect fun aesEncrypt(data: String, key: String): String -expect fun aesDecrypt(data: String, key: String): String - diff --git a/modules/services/src/jvmMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.jvm.kt b/modules/services/src/jvmMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.jvm.kt deleted file mode 100644 index 9aa632c6..00000000 --- a/modules/services/src/jvmMain/kotlin/com/sphereon/oid/fed/services/extensions/KeyExtensions.jvm.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.sphereon.oid.fed.services.extensions - -import java.util.* -import javax.crypto.Cipher -import javax.crypto.spec.SecretKeySpec - -private const val ALGORITHM = "AES" -private const val KEY_SIZE = 32 - -actual fun aesEncrypt(data: String, key: String): String { - val secretKey = SecretKeySpec(key.padEnd(KEY_SIZE, '0').toByteArray(Charsets.UTF_8), ALGORITHM) - - val cipher = Cipher.getInstance(ALGORITHM) - cipher.init(Cipher.ENCRYPT_MODE, secretKey) - - val encryptedValue = cipher.doFinal(data.toByteArray(Charsets.UTF_8)) - return Base64.getEncoder().encodeToString(encryptedValue) -} - -actual fun aesDecrypt(data: String, key: String): String { - val secretKey = SecretKeySpec(key.padEnd(KEY_SIZE, '0').toByteArray(Charsets.UTF_8), ALGORITHM) - - val cipher = Cipher.getInstance(ALGORITHM) - cipher.init(Cipher.DECRYPT_MODE, secretKey) - - val decodedValue = Base64.getDecoder().decode(data) - val decryptedValue = cipher.doFinal(decodedValue) - return String(decryptedValue, Charsets.UTF_8) -} diff --git a/modules/services/src/jvmTest/kotlin/com/sphereon/oid/fed/services/KeyServiceTest.jvm.kt b/modules/services/src/jvmTest/kotlin/com/sphereon/oid/fed/services/KeyServiceTest.jvm.kt deleted file mode 100644 index a5668d1c..00000000 --- a/modules/services/src/jvmTest/kotlin/com/sphereon/oid/fed/services/KeyServiceTest.jvm.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.sphereon.oid.fed.services - -import com.sphereon.oid.fed.common.jwk.generateKeyPair -import com.sphereon.oid.fed.services.extensions.decrypt -import com.sphereon.oid.fed.services.extensions.encrypt -import org.junit.Test -import java.time.LocalDateTime -import java.util.* -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import com.sphereon.oid.fed.persistence.models.Jwk as JwkPersistence - -class KeyServiceTest { - @Test - fun testEncryption() { - val key = generateKeyPair() - val encryptedKey = key.encrypt() - - if (System.getenv("APP_KEY") == null) { - assertEquals(key.d, encryptedKey.d) - } else { - assertNotEquals(key.d, encryptedKey.d) - } - - val persistenceJwk = JwkPersistence( - id = 1, - account_id = 1, - d = encryptedKey.d, - e = encryptedKey.e, - n = encryptedKey.n, - x = encryptedKey.x, - y = encryptedKey.y, - alg = encryptedKey.alg, - crv = encryptedKey.crv, - p = encryptedKey.p, - q = encryptedKey.q, - dp = encryptedKey.dp, - qi = encryptedKey.qi, - dq = encryptedKey.dq, - x5t = encryptedKey.x5t, - x5t_s256 = encryptedKey.x5tS256, - x5u = encryptedKey.x5u, - kid = encryptedKey.kid, - kty = encryptedKey.kty, - x5c = encryptedKey.x5c, - created_at = LocalDateTime.now(), - revoked_reason = null, - revoked_at = null, - uuid = UUID.randomUUID(), - use = encryptedKey.use - ) - - val decryptedPersistenceJwk = persistenceJwk.decrypt() - - assertEquals(key.d, decryptedPersistenceJwk.d) - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index bff086b8..90d48f4c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -48,3 +48,4 @@ include(":modules:federation-server") include(":modules:openapi") include(":modules:persistence") include(":modules:services") +include("modules:local-kms")