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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,20 @@ object KeyManager {
return function.invoke(generationRequest)
}

/**
* Type-safe overload of [createKey] for [TypedKeyGenerationRequest].
*
* Dispatches directly to the appropriate backend without any stringly-typed
* discriminator or [kotlinx.serialization.json.JsonObject] config encoding.
*/
suspend fun createKey(request: TypedKeyGenerationRequest): Key = when (request) {
is TypedKeyGenerationRequest.Jwk -> JWKKey.generate(request.keyType)
is TypedKeyGenerationRequest.Tse -> TSEKey.generate(request.keyType, request.config)
is TypedKeyGenerationRequest.Azure -> AzureKeyRestApi.generate(request.keyType, request.config)
is TypedKeyGenerationRequest.Oci -> OCIKeyRestApi.generateKey(request.keyType, request.config)
is TypedKeyGenerationRequest.Aws -> AWSKeyRestAPI.generate(request.keyType, request.config)
}

suspend fun resolveSerializedKey(jsonString: String): Key =
resolveSerializedKey(json = Json.parseToJsonElement(jsonString).jsonObject)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package id.walt.crypto.keys

import id.walt.crypto.keys.aws.AWSKeyMetadata
import id.walt.crypto.keys.azure.AzureKeyMetadata
import id.walt.crypto.keys.oci.OCIKeyMetadata
import id.walt.crypto.keys.tse.TSEKeyMetadata
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonClassDiscriminator

/**
* Type-safe alternative to [KeyGenerationRequest] for use in APIs and UIs.
*
* Each subclass carries exactly the metadata required by its backend, with no
* stringly-typed discriminator field and no untyped [kotlinx.serialization.json.JsonObject]
* for config. Swagger/OpenAPI renders distinct schemas for each backend variant.
*
* [KeyManager.createKey] accepts both this type and the legacy [KeyGenerationRequest].
*
* [keyType] defaults to [KeyType.secp256r1] (ES256) - supported by all credential
* formats including mdoc/ISO 18013-5, which does not support Ed25519.
*
* The JSON discriminator field is `"backend"`, matching the backend identifier used
* by [KeyManager]. Example:
* ```json
* {"backend":"jwk","keyType":"secp256r1"}
* {"backend":"tse","keyType":"secp256r1","config":{"server":"...","auth":{...}}}
* ```
*/
@OptIn(ExperimentalSerializationApi::class)
@JsonClassDiscriminator("backend")
@Serializable
sealed class TypedKeyGenerationRequest {
abstract val keyType: KeyType

/** Software JWK key. No external KMS required. */
@SerialName("jwk")
@Serializable
data class Jwk(
override val keyType: KeyType = KeyType.secp256r1,
) : TypedKeyGenerationRequest()

/** HashiCorp Vault Transit Secrets Engine. */
@SerialName("tse")
@Serializable
data class Tse(
override val keyType: KeyType = KeyType.secp256r1,
val config: TSEKeyMetadata,
) : TypedKeyGenerationRequest()

/** Azure Key Vault. */
@SerialName("azure-rest-api")
@Serializable
data class Azure(
override val keyType: KeyType = KeyType.secp256r1,
val config: AzureKeyMetadata,
) : TypedKeyGenerationRequest()

/** Oracle Cloud Infrastructure (OCI) KMS. */
@SerialName("oci-rest-api")
@Serializable
data class Oci(
override val keyType: KeyType = KeyType.secp256r1,
val config: OCIKeyMetadata,
) : TypedKeyGenerationRequest()

/** AWS KMS. */
@SerialName("aws-rest-api")
@Serializable
data class Aws(
override val keyType: KeyType = KeyType.secp256r1,
val config: AWSKeyMetadata,
) : TypedKeyGenerationRequest()
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ dependencies {

// JDBC drivers — SQLite (default) and Postgres (optional)
implementation(identityLibs.sqlite.jdbc)
compileOnly(identityLibs.postgresql)
implementation(identityLibs.postgresql)

// Connection pooling
implementation(identityLibs.hikaricp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import org.jetbrains.exposed.v1.jdbc.deleteWhere
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.suspendTransaction
import org.jetbrains.exposed.v1.jdbc.upsert
import java.time.Instant
import kotlin.time.Clock
import kotlin.time.toJavaInstant
import kotlin.time.toKotlinInstant

Expand Down Expand Up @@ -55,7 +55,7 @@ class ExposedCredentialStore(
json.encodeToString(DigitalCredential.serializer(), entry.credential)
it[Wallet2Tables.Credentials.label] = entry.label
it[Wallet2Tables.Credentials.addedAt] =
entry.addedAt?.toJavaInstant() ?: Instant.now()
(entry.addedAt ?: Clock.System.now()).toJavaInstant()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,11 @@ class ExposedDidStore(

private fun rowToEntry(row: ResultRow): WalletDidEntry? =
runCatching {
val did = row[Wallet2Tables.Dids.did]
WalletDidEntry(
did = row[Wallet2Tables.Dids.did],
document = Json.parseToJsonElement(row[Wallet2Tables.Dids.document]) as JsonObject
did = did,
document = Json.parseToJsonElement(row[Wallet2Tables.Dids.document]) as? JsonObject
?: error("DID document for '$did' is not a JSON object")
)
}.getOrNull()
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ class ExposedWalletStore(private val db: Database) : WalletStore {
credentialStoreIds = credentialStoreIds,
didStoreId = didStoreId,
serializedStaticKey = walletRow[Wallet2Tables.Wallets.serializedStaticKey],
staticDid = walletRow[Wallet2Tables.Wallets.staticDid]
staticDid = walletRow[Wallet2Tables.Wallets.staticDid],
defaultKeyId = walletRow[Wallet2Tables.Wallets.defaultKeyId],
defaultDidId = walletRow[Wallet2Tables.Wallets.defaultDidId],
)
}

Expand All @@ -54,6 +56,8 @@ class ExposedWalletStore(private val db: Database) : WalletStore {
it[Wallet2Tables.Wallets.id] = descriptor.id
it[Wallet2Tables.Wallets.serializedStaticKey] = descriptor.serializedStaticKey
it[Wallet2Tables.Wallets.staticDid] = descriptor.staticDid
it[Wallet2Tables.Wallets.defaultKeyId] = descriptor.defaultKeyId
it[Wallet2Tables.Wallets.defaultDidId] = descriptor.defaultDidId
}

// Ensure named store rows exist
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ object Wallet2Tables {
val id = varchar("id", 128)
val serializedStaticKey = text("static_key").nullable()
val staticDid = varchar("static_did", 1024).nullable()
val defaultKeyId = varchar("default_key_id", 512).nullable()
val defaultDidId = varchar("default_did_id", 1024).nullable()
override val primaryKey = PrimaryKey(id)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,47 @@ import id.walt.wallet2.data.WalletDescriptor
import id.walt.wallet2.data.WalletDidStore
import id.walt.wallet2.data.WalletKeyStore
import id.walt.wallet2.stores.WalletStore
import id.walt.wallet2.stores.inmemory.InMemoryWalletStore
import id.walt.wallet2.stores.inmemory.InMemoryCredentialStore
import id.walt.wallet2.stores.inmemory.InMemoryDidStore
import id.walt.wallet2.stores.inmemory.InMemoryKeyStore
import io.ktor.http.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow

/**
* Factory for creating named store instances.
*
* The default implementations create in-memory stores. When persistence is enabled,
* replace these with factories that produce [id.walt.wallet2.persistence.ExposedKeyStore] etc.
* backed by the same database.
*
* Declaring as a `fun interface` allows concise lambda syntax:
* ```kotlin
* keyStoreFactory = { id -> ExposedKeyStore(id, db) }
* ```
*/
/**
* Factory for creating named store instances, expressed as a plain function type.
*
* Using a typealias keeps the API surface minimal - callers use standard lambda syntax
* `{ id -> ExposedKeyStore(id, db) }` and invoke it with `factory(storeId)`.
*/
typealias StoreFactory<T> = (storeId: String) -> T

/**
* Storage-backend abstraction used by [Wallet2RouteHandler].
*
* Bridges the [WalletStore] (descriptor-based persistence) with the live [Wallet]
* runtime object (store instances). All wallet CRUD in [Wallet2RouteHandler] calls
* through here, keeping route logic decoupled from storage details.
*
* For the [InMemoryWalletStore] default, wallet objects are kept in memory directly.
* For in-memory stores, wallet objects are kept in memory directly.
* For persistent stores, [resolveWallet] assembles a [Wallet] from the persisted
* [WalletDescriptor] by resolving each named store ID via [resolveKeyStore] etc.
*
* The three [StoreFactory] properties control what kind of store is auto-created when
* a new wallet is created without explicit store IDs, and when a named store is created
* via the `/stores/{storeId}` routes. Swap them for Exposed-backed factories to get persistence.
*/
interface WalletResolver {

Expand All @@ -35,41 +61,47 @@ interface WalletResolver {
*/
val walletStore: WalletStore

/** Factory for key stores. Default: in-memory. Replace with an Exposed-backed factory for persistence. */
val keyStoreFactory: StoreFactory<WalletKeyStore>
get() = { InMemoryKeyStore() }

/** Factory for credential stores. Default: in-memory. */
val credentialStoreFactory: StoreFactory<WalletCredentialStore>
get() = { InMemoryCredentialStore() }

/** Factory for DID stores. Default: in-memory. */
val didStoreFactory: StoreFactory<WalletDidStore>
get() = { InMemoryDidStore() }

/**
* Resolves a [Wallet] by ID.
*
* For [InMemoryWalletStore]: returns the live wallet object directly.
* For persistent stores: loads the [WalletDescriptor] and assembles the
* If the store keeps live [Wallet] objects (e.g. in-memory), returns one directly via
* [WalletStore.loadWallet]. Otherwise loads the [WalletDescriptor] and assembles the
* [Wallet] by resolving each store ID via [resolveKeyStore]/[resolveCredentialStore]/[resolveDidStore].
*/
suspend fun resolveWallet(walletId: String): Wallet? {
val inMemory = walletStore as? InMemoryWalletStore
if (inMemory != null) return inMemory.getWallet(walletId)

val descriptor = walletStore.loadDescriptor(walletId) ?: return null
return assembleWallet(descriptor)
}
suspend fun resolveWallet(walletId: String): Wallet? =
walletStore.loadWallet(walletId) ?: walletStore.loadDescriptor(walletId)?.let { assembleWallet(it) }

/**
* Persists a newly created [Wallet].
*
* For [InMemoryWalletStore]: stores the live object directly.
* For persistent stores: saves the [WalletDescriptor] derived from the wallet.
* Delegates to [WalletStore.saveWallet] first. If the store handles it (returns true, e.g.
* in-memory), we are done. Otherwise serializes the wallet to a [WalletDescriptor] and saves
* that via [WalletStore.saveDescriptor] (persistent stores).
*/
suspend fun storeWallet(wallet: Wallet) {
val inMemory = walletStore as? InMemoryWalletStore
if (inMemory != null) {
inMemory.putWallet(wallet)
return
}
if (walletStore.saveWallet(wallet)) return
val serializedStaticKey = wallet.staticKey?.let { KeySerialization.serializeKey(it) }
val descriptor = WalletDescriptor(
id = wallet.id,
keyStoreIds = wallet.keyStores.mapNotNull { resolveStoreId(it) },
credentialStoreIds = wallet.credentialStores.mapNotNull { resolveStoreId(it) },
didStoreId = wallet.didStore?.let { resolveStoreId(it) },
serializedStaticKey = serializedStaticKey,
staticDid = wallet.staticDid
staticDid = wallet.staticDid,
defaultKeyId = wallet.defaultKeyId,
defaultDidId = wallet.defaultDidId,
)
walletStore.saveDescriptor(descriptor)
}
Expand All @@ -82,10 +114,10 @@ interface WalletResolver {
walletStore.getWalletIdsForAccount(accountId)

// ---------------------------------------------------------------------------
// Named store management needed when POST /wallet references storeIds,
// or when POST /stores/* creates named stores.
// Named store management - needed when POST /wallet references storeIds,
// or when POST /stores/{storeId} creates named stores.
// Persistent implementations provide their own registry (e.g. a stores table).
// Default: no-op / null (simple deployments that auto-create in-memory stores).
// Default: no-op / null (simple deployments that auto-create stores via the factories).
// ---------------------------------------------------------------------------

suspend fun resolveKeyStore(storeId: String): WalletKeyStore? = null
Expand All @@ -106,13 +138,10 @@ interface WalletResolver {

/**
* Assembles a live [Wallet] from a [WalletDescriptor] by resolving each store ID.
* Falls back to a fresh in-memory store for any store ID that cannot be resolved.
*/
suspend fun assembleWallet(descriptor: WalletDescriptor): Wallet {
val keyStores = descriptor.keyStoreIds.mapNotNull { resolveKeyStore(it) }
.ifEmpty { emptyList() }
val credentialStores = descriptor.credentialStoreIds.mapNotNull { resolveCredentialStore(it) }
.ifEmpty { emptyList() }
val didStore = descriptor.didStoreId?.let { resolveDidStore(it) }
val staticKey = descriptor.serializedStaticKey?.let {
runCatching { KeyManager.resolveSerializedKey(it) }.getOrNull()
Expand All @@ -123,10 +152,35 @@ interface WalletResolver {
credentialStores = credentialStores,
didStore = didStore,
staticKey = staticKey,
staticDid = descriptor.staticDid
staticDid = descriptor.staticDid,
defaultKeyId = descriptor.defaultKeyId,
defaultDidId = descriptor.defaultDidId,
)
}

/** Resolves the registered storeId for a given store instance, if any. */
suspend fun resolveStoreId(store: Any): String? = null

/**
* Persists updated [defaultKeyId] / [defaultDidId] for an existing wallet.
*
* For in-memory stores the live [Wallet] object is replaced with a copy containing the new
* defaults (via [WalletStore.loadWallet]/[WalletStore.saveWallet]). For persistent stores the
* descriptor is loaded, updated, and saved.
*/
suspend fun setWalletDefaults(walletId: String, defaultKeyId: String?, defaultDidId: String?) {
val liveWallet = walletStore.loadWallet(walletId)
if (liveWallet != null) {
walletStore.saveWallet(liveWallet.copy(
defaultKeyId = defaultKeyId ?: liveWallet.defaultKeyId,
defaultDidId = defaultDidId ?: liveWallet.defaultDidId,
))
return
}
val descriptor = walletStore.loadDescriptor(walletId) ?: return
walletStore.saveDescriptor(descriptor.copy(
defaultKeyId = defaultKeyId ?: descriptor.defaultKeyId,
defaultDidId = defaultDidId ?: descriptor.defaultDidId,
))
}
}
Loading
Loading