mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 19:08:23 +00:00
Add BunkerSigningEngine and SecureCryptoHelper expect/actual to :shared
BunkerSigningEngine decrypts an incoming kind-24133 event, checks a BunkerPermissionStore for an auto-accept/reject rule (falling back to a BunkerApprovalPort prompt), performs the requested sign/nip04/nip44 operation, and returns the signed response event ready to publish. It's new code focused on the desktop bunker use case rather than a port of :app's BunkerRequestUtils/EventNotificationConsumer pipeline, which stays untouched and keeps using its existing Room/Context-coupled implementation directly — rewiring the shipping Android signing path onto shared code carries far more regression risk than the desktop use case needs. SecureCryptoHelper is expect/actual: the Android actual mirrors :app's existing Keystore-backed helper (unused by :app for now, kept in sync so :shared's Android target builds and is available for future adoption); the desktop actual stores an AES-256 master key in the OS keychain via java-keyring (Windows Credential Manager / macOS Keychain / Linux Secret Service) and uses it for AES-GCM at-rest encryption, mirroring the same key-wrapping shape Android uses.
This commit is contained in:
@@ -7,6 +7,16 @@ plugins {
|
||||
}
|
||||
|
||||
kotlin {
|
||||
targets.configureEach {
|
||||
compilations.configureEach {
|
||||
compileTaskProvider.configure {
|
||||
compilerOptions {
|
||||
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.greenart7c3.nostrsigner.shared"
|
||||
compileSdk = 37
|
||||
@@ -33,5 +43,10 @@ kotlin {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
val desktopMain by getting {
|
||||
dependencies {
|
||||
implementation(libs.java.keyring)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.greenart7c3.nostrsigner.shared
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Android Keystore-backed implementation, mirroring the app module's own
|
||||
* `SecureCryptoHelper` (app/src/main/java/com/greenart7c3/nostrsigner/SecureCryptoHelper.kt).
|
||||
* Not currently wired into `:app` — the shipping Android app keeps using its existing
|
||||
* implementation directly. This exists so `:shared`'s Android target builds standalone
|
||||
* and is available if `:app` is migrated onto the shared bunker engine in the future.
|
||||
*/
|
||||
actual object SecureCryptoHelper {
|
||||
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||
private const val KEY_ALIAS = "AMBER_BUNKER_SHARED_AES_KEY"
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val IV_SIZE = 12
|
||||
private const val TAG_SIZE = 128
|
||||
private val mutex = Mutex()
|
||||
|
||||
actual suspend fun encrypt(plainText: String): String = mutex.withLock {
|
||||
val key = getOrCreateSecretKey()
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
val iv = cipher.iv
|
||||
val cipherText = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
|
||||
|
||||
val combined = ByteBuffer.allocate(iv.size + cipherText.size)
|
||||
combined.put(iv)
|
||||
combined.put(cipherText)
|
||||
Base64.encodeToString(combined.array(), Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
actual suspend fun decrypt(encryptedText: String): String = mutex.withLock {
|
||||
val key = getOrCreateSecretKey()
|
||||
val data = Base64.decode(encryptedText, Base64.NO_WRAP)
|
||||
val buffer = ByteBuffer.wrap(data)
|
||||
|
||||
val iv = ByteArray(IV_SIZE).also { buffer.get(it) }
|
||||
val cipherText = ByteArray(buffer.remaining()).also { buffer.get(it) }
|
||||
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_SIZE, iv))
|
||||
String(cipher.doFinal(cipherText), Charsets.UTF_8)
|
||||
}
|
||||
|
||||
private fun getOrCreateSecretKey(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||
(keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry)?.let { return it.secretKey }
|
||||
|
||||
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
|
||||
val spec = KeyGenParameterSpec.Builder(
|
||||
KEY_ALIAS,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.build()
|
||||
keyGenerator.init(spec)
|
||||
return keyGenerator.generateKey()
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.greenart7c3.nostrsigner.shared
|
||||
|
||||
/** Persisted auto-accept/auto-reject rules for a connected client app. Implemented per-platform. */
|
||||
interface BunkerPermissionStore {
|
||||
/** Null means "no stored rule, ask the user"; non-null is an auto accept/reject decision. */
|
||||
suspend fun isApproved(appPubKey: String, method: BunkerMethod, kind: Int?): Boolean?
|
||||
|
||||
suspend fun remember(appPubKey: String, method: BunkerMethod, kind: Int?, approved: Boolean)
|
||||
}
|
||||
|
||||
data class BunkerApprovalRequest(
|
||||
val appPubKey: String,
|
||||
val appName: String?,
|
||||
val method: BunkerMethod,
|
||||
val kind: Int?,
|
||||
val payloadPreview: String,
|
||||
)
|
||||
|
||||
data class BunkerApprovalDecision(
|
||||
val approved: Boolean,
|
||||
val remember: Boolean,
|
||||
)
|
||||
|
||||
/** Prompts the user when no stored permission rule covers a request. Implemented by the UI layer. */
|
||||
fun interface BunkerApprovalPort {
|
||||
suspend fun requestApproval(request: BunkerApprovalRequest): BunkerApprovalDecision
|
||||
}
|
||||
|
||||
data class BunkerHistoryEntry(
|
||||
val appPubKey: String,
|
||||
val method: BunkerMethod,
|
||||
val kind: Int?,
|
||||
val approved: Boolean,
|
||||
val time: Long,
|
||||
)
|
||||
|
||||
/** Records handled requests for the connected-apps/history UI. Implemented per-platform. */
|
||||
fun interface BunkerHistoryLogger {
|
||||
suspend fun log(entry: BunkerHistoryEntry)
|
||||
}
|
||||
@@ -16,4 +16,15 @@ class BunkerSigner(keyPair: KeyPair) {
|
||||
suspend fun nip04Encrypt(plainText: String, toPublicKey: String): String = signer.nip04Encrypt(plainText, toPublicKey)
|
||||
|
||||
suspend fun nip04Decrypt(cipherText: String, fromPublicKey: String): String = signer.nip04Decrypt(cipherText, fromPublicKey)
|
||||
|
||||
/** Decrypts a NIP-46 payload, auto-detecting NIP-04 vs NIP-44 encoding. */
|
||||
suspend fun decrypt(encryptedContent: String, fromPublicKey: String): String = signer.decrypt(encryptedContent, fromPublicKey)
|
||||
|
||||
/** Signs a raw event, mirroring [com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync.sign]. */
|
||||
fun <T : com.vitorpamplona.quartz.nip01Core.core.Event> signSync(
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
): T = signer.signerSync.sign(createdAt, kind, tags, content)
|
||||
}
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package com.greenart7c3.nostrsigner.shared
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/** A decrypted, parsed NIP-46 request together with the encoding it arrived in. */
|
||||
private data class DecodedRequest(val bunkerRequest: BunkerRequest, val nip04: Boolean)
|
||||
|
||||
/**
|
||||
* Handles the NIP-46 "bunker" request/response cycle for a single account: decrypt an
|
||||
* incoming kind-24133 event, check permissions, perform the requested signing/encryption
|
||||
* operation, and produce the signed response event ready to publish.
|
||||
*
|
||||
* This is new, desktop-focused code — it mirrors the request/response semantics of
|
||||
* [com.greenart7c3.nostrsigner] Android's `BunkerRequestUtils`/`EventNotificationConsumer`
|
||||
* pipeline but does not attempt to replicate its Android-specific bookkeeping
|
||||
* (notifications, WorkManager, per-app relay/history persistence details).
|
||||
*/
|
||||
class BunkerSigningEngine(
|
||||
private val account: BunkerSigner,
|
||||
private val permissionStore: BunkerPermissionStore,
|
||||
private val approvalPort: BunkerApprovalPort,
|
||||
private val historyLogger: BunkerHistoryLogger? = null,
|
||||
) {
|
||||
/** Decrypts, handles, and produces a signed response event for an incoming kind-24133 event; null if it should be ignored. */
|
||||
suspend fun handleIncomingEvent(senderPubKey: String, encryptedContent: String, appName: String? = null): Event? {
|
||||
if (encryptedContent.isEmpty()) return null
|
||||
|
||||
val decoded = decode(senderPubKey, encryptedContent) ?: return null
|
||||
val request = decoded.bunkerRequest
|
||||
val method = bunkerMethodOf(request.method)
|
||||
val kind = if (method == BunkerMethod.SIGN_EVENT) signEventKind(request) else null
|
||||
|
||||
val approved = resolveApproval(senderPubKey, appName, method, kind, request)
|
||||
historyLogger?.log(BunkerHistoryEntry(senderPubKey, method, kind, approved, TimeUtils.now()))
|
||||
|
||||
val response = if (!approved) {
|
||||
BunkerResponse(request.id, "", "user rejected")
|
||||
} else {
|
||||
runCatching { perform(method, request, senderPubKey) }
|
||||
.fold(
|
||||
onSuccess = { result -> BunkerResponse(request.id, result, null) },
|
||||
onFailure = { error -> BunkerResponse(request.id, "", error.message ?: "signing failed") },
|
||||
)
|
||||
}
|
||||
|
||||
return respond(senderPubKey, response, decoded.nip04)
|
||||
}
|
||||
|
||||
private suspend fun decode(senderPubKey: String, encryptedContent: String): DecodedRequest? {
|
||||
val nip04 = EncryptedInfo.isNIP04(encryptedContent)
|
||||
val plainText = runCatching { account.decrypt(encryptedContent, senderPubKey) }.getOrNull() ?: return null
|
||||
val bunkerRequest = runCatching {
|
||||
JacksonMapper.mapper.readValue(plainText, BunkerRequest::class.java)
|
||||
}.getOrNull() ?: return null
|
||||
return DecodedRequest(bunkerRequest, nip04)
|
||||
}
|
||||
|
||||
private suspend fun resolveApproval(
|
||||
senderPubKey: String,
|
||||
appName: String?,
|
||||
method: BunkerMethod,
|
||||
kind: Int?,
|
||||
request: BunkerRequest,
|
||||
): Boolean {
|
||||
permissionStore.isApproved(senderPubKey, method, kind)?.let { return it }
|
||||
|
||||
val decision = approvalPort.requestApproval(
|
||||
BunkerApprovalRequest(
|
||||
appPubKey = senderPubKey,
|
||||
appName = appName,
|
||||
method = method,
|
||||
kind = kind,
|
||||
payloadPreview = BunkerRequestPayload.payload(request),
|
||||
),
|
||||
)
|
||||
if (decision.remember) {
|
||||
permissionStore.remember(senderPubKey, method, kind, decision.approved)
|
||||
}
|
||||
return decision.approved
|
||||
}
|
||||
|
||||
private suspend fun perform(method: BunkerMethod, request: BunkerRequest, senderPubKey: String): String = when (method) {
|
||||
BunkerMethod.CONNECT -> "ack"
|
||||
BunkerMethod.GET_PUBLIC_KEY -> account.pubKey
|
||||
BunkerMethod.PING -> "pong"
|
||||
BunkerMethod.LOGOUT -> "ack"
|
||||
BunkerMethod.SIGN_EVENT -> signEvent(request)
|
||||
BunkerMethod.NIP04_ENCRYPT -> account.nip04Encrypt(BunkerRequestPayload.payload(request), counterparty(request, senderPubKey))
|
||||
BunkerMethod.NIP04_DECRYPT -> account.nip04Decrypt(BunkerRequestPayload.payload(request), counterparty(request, senderPubKey))
|
||||
BunkerMethod.NIP44_ENCRYPT -> account.nip44Encrypt(BunkerRequestPayload.payload(request), counterparty(request, senderPubKey))
|
||||
BunkerMethod.NIP44_DECRYPT -> account.nip44Decrypt(BunkerRequestPayload.payload(request), counterparty(request, senderPubKey))
|
||||
BunkerMethod.NIP44_V3_ENCRYPT, BunkerMethod.NIP44_V3_DECRYPT,
|
||||
BunkerMethod.DECRYPT_ZAP_EVENT, BunkerMethod.SWITCH_RELAYS, BunkerMethod.SIGN_PSBT,
|
||||
-> error("Unsupported method: ${request.method}")
|
||||
BunkerMethod.INVALID -> error("Unrecognized method: ${request.method}")
|
||||
}
|
||||
|
||||
private fun counterparty(request: BunkerRequest, senderPubKey: String) = BunkerRequestPayload.counterpartyPubKey(request) ?: senderPubKey
|
||||
|
||||
private fun signEventKind(request: BunkerRequest): Int? {
|
||||
val json = BunkerRequestPayload.payload(request)
|
||||
if (json.isEmpty()) return null
|
||||
return runCatching { JacksonMapper.mapper.readTree(json).get("kind")?.asInt() }.getOrNull()
|
||||
}
|
||||
|
||||
private fun signEvent(request: BunkerRequest): String {
|
||||
val json = BunkerRequestPayload.payload(request)
|
||||
val node = JacksonMapper.mapper.readTree(json)
|
||||
val kind = node.get("kind")?.asInt() ?: error("sign_event request missing kind")
|
||||
val content = node.get("content")?.asText() ?: ""
|
||||
val createdAt = node.get("created_at")?.asLong() ?: TimeUtils.now()
|
||||
val tags = node.get("tags")?.map { tagNode ->
|
||||
tagNode.map { it.asText() }.toTypedArray()
|
||||
}?.toTypedArray() ?: arrayOf()
|
||||
|
||||
val signed = account.signSync<Event>(createdAt, kind, tags, content)
|
||||
return signed.toJson()
|
||||
}
|
||||
|
||||
private suspend fun respond(recipientPubKey: String, response: BunkerResponse, nip04: Boolean): Event {
|
||||
val plainText = JacksonMapper.mapper.writeValueAsString(response)
|
||||
val encryptedContent = if (nip04) account.nip04Encrypt(plainText, recipientPubKey) else account.nip44Encrypt(plainText, recipientPubKey)
|
||||
return account.signSync(
|
||||
TimeUtils.now(),
|
||||
NostrConnectEvent.KIND,
|
||||
arrayOf(arrayOf("p", recipientPubKey)),
|
||||
encryptedContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.greenart7c3.nostrsigner.shared
|
||||
|
||||
/** Encrypts/decrypts local secrets (private keys, connection keys) at rest using a platform-backed key. */
|
||||
expect object SecureCryptoHelper {
|
||||
suspend fun encrypt(plainText: String): String
|
||||
|
||||
suspend fun decrypt(encryptedText: String): String
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package com.greenart7c3.nostrsigner.shared
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
|
||||
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
||||
private class FakePermissionStore(private val decisions: MutableMap<String, Boolean> = mutableMapOf()) : BunkerPermissionStore {
|
||||
override suspend fun isApproved(appPubKey: String, method: BunkerMethod, kind: Int?): Boolean? = decisions["$appPubKey:$method:$kind"]
|
||||
|
||||
override suspend fun remember(appPubKey: String, method: BunkerMethod, kind: Int?, approved: Boolean) {
|
||||
decisions["$appPubKey:$method:$kind"] = approved
|
||||
}
|
||||
}
|
||||
|
||||
class BunkerSigningEngineTest {
|
||||
private suspend fun sendRequest(client: BunkerSigner, account: BunkerSigner, request: BunkerRequest): String {
|
||||
val plainText = JacksonMapper.mapper.writeValueAsString(request)
|
||||
return client.nip44Encrypt(plainText, account.pubKey)
|
||||
}
|
||||
|
||||
private suspend fun decodeResponse(client: BunkerSigner, account: BunkerSigner, event: com.vitorpamplona.quartz.nip01Core.core.Event): BunkerResponse {
|
||||
val plainText = client.nip44Decrypt(event.content, account.pubKey)
|
||||
return JacksonMapper.mapper.readValue(plainText, BunkerResponse::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun autoApprovedGetPublicKeyReturnsAccountPubKey() = runTest {
|
||||
val account = BunkerSigner(KeyPair())
|
||||
val client = BunkerSigner(KeyPair())
|
||||
val permissionStore = FakePermissionStore(mutableMapOf("${client.pubKey}:GET_PUBLIC_KEY:null" to true))
|
||||
val engine = BunkerSigningEngine(
|
||||
account = account,
|
||||
permissionStore = permissionStore,
|
||||
approvalPort = BunkerApprovalPort { error("should not prompt when a rule already exists") },
|
||||
)
|
||||
|
||||
val request = BunkerRequest("req-1", "get_public_key", arrayOf())
|
||||
val encrypted = sendRequest(client, account, request)
|
||||
|
||||
val responseEvent = engine.handleIncomingEvent(client.pubKey, encrypted)
|
||||
assertTrue(responseEvent != null)
|
||||
|
||||
val response = decodeResponse(client, account, responseEvent)
|
||||
assertEquals("req-1", response.id)
|
||||
assertEquals(account.pubKey, response.result)
|
||||
assertNull(response.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectedRequestReturnsUserRejectedError() = runTest {
|
||||
val account = BunkerSigner(KeyPair())
|
||||
val client = BunkerSigner(KeyPair())
|
||||
val engine = BunkerSigningEngine(
|
||||
account = account,
|
||||
permissionStore = FakePermissionStore(),
|
||||
approvalPort = BunkerApprovalPort { BunkerApprovalDecision(approved = false, remember = false) },
|
||||
)
|
||||
|
||||
val request = BunkerRequest("req-2", "get_public_key", arrayOf())
|
||||
val encrypted = sendRequest(client, account, request)
|
||||
|
||||
val responseEvent = engine.handleIncomingEvent(client.pubKey, encrypted)!!
|
||||
val response = decodeResponse(client, account, responseEvent)
|
||||
|
||||
assertEquals("user rejected", response.error)
|
||||
assertTrue(response.result.isNullOrEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun signEventProducesEventSignedByAccount() = runTest {
|
||||
val account = BunkerSigner(KeyPair())
|
||||
val client = BunkerSigner(KeyPair())
|
||||
val engine = BunkerSigningEngine(
|
||||
account = account,
|
||||
permissionStore = FakePermissionStore(mutableMapOf("${client.pubKey}:SIGN_EVENT:1" to true)),
|
||||
approvalPort = BunkerApprovalPort { error("should not prompt when a rule already exists") },
|
||||
)
|
||||
|
||||
val unsignedEventJson = """{"kind":1,"created_at":1700000000,"tags":[],"content":"hello from bunker desktop"}"""
|
||||
val request = BunkerRequest("req-3", "sign_event", arrayOf(unsignedEventJson))
|
||||
val encrypted = sendRequest(client, account, request)
|
||||
|
||||
val responseEvent = engine.handleIncomingEvent(client.pubKey, encrypted)!!
|
||||
val response = decodeResponse(client, account, responseEvent)
|
||||
|
||||
assertNull(response.error)
|
||||
val signedEvent = JacksonMapper.mapper.readValue(response.result, com.vitorpamplona.quartz.nip01Core.core.Event::class.java)
|
||||
assertEquals(account.pubKey, signedEvent.pubKey)
|
||||
assertEquals("hello from bunker desktop", signedEvent.content)
|
||||
assertTrue(signedEvent.sig.isNotEmpty())
|
||||
assertTrue(signedEvent.id.isNotEmpty())
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.greenart7c3.nostrsigner.shared
|
||||
|
||||
import com.github.javakeyring.Keyring
|
||||
import java.nio.ByteBuffer
|
||||
import java.security.SecureRandom
|
||||
import java.util.Base64
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* OS-native-keychain-backed implementation: a generated AES-256 master key is stored in the
|
||||
* platform credential store (Windows Credential Manager, macOS Keychain, or Linux Secret
|
||||
* Service/libsecret via [Keyring]), and secrets are AES/GCM-encrypted at rest with it — the
|
||||
* desktop analogue of the Android target's Keystore-backed key.
|
||||
*
|
||||
* Requires a running Secret Service provider (e.g. gnome-keyring, KWallet) on Linux; throws
|
||||
* [com.github.javakeyring.BackendNotSupportedException] if none is available.
|
||||
*/
|
||||
actual object SecureCryptoHelper {
|
||||
private const val KEYRING_DOMAIN = "Amber Bunker"
|
||||
private const val KEYRING_ACCOUNT = "master-key"
|
||||
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
private const val IV_SIZE = 12
|
||||
private const val TAG_SIZE = 128
|
||||
private val mutex = Mutex()
|
||||
|
||||
actual suspend fun encrypt(plainText: String): String = mutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
val key = getOrCreateMasterKey()
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||
val iv = cipher.iv
|
||||
val cipherText = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
|
||||
|
||||
val combined = ByteBuffer.allocate(iv.size + cipherText.size)
|
||||
combined.put(iv)
|
||||
combined.put(cipherText)
|
||||
Base64.getEncoder().encodeToString(combined.array())
|
||||
}
|
||||
}
|
||||
|
||||
actual suspend fun decrypt(encryptedText: String): String = mutex.withLock {
|
||||
withContext(Dispatchers.IO) {
|
||||
val key = getOrCreateMasterKey()
|
||||
val data = Base64.getDecoder().decode(encryptedText)
|
||||
val buffer = ByteBuffer.wrap(data)
|
||||
|
||||
val iv = ByteArray(IV_SIZE).also { buffer.get(it) }
|
||||
val cipherText = ByteArray(buffer.remaining()).also { buffer.get(it) }
|
||||
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_SIZE, iv))
|
||||
String(cipher.doFinal(cipherText), Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOrCreateMasterKey(): SecretKey = Keyring.create().use { keyring ->
|
||||
val existing = runCatching { keyring.getPassword(KEYRING_DOMAIN, KEYRING_ACCOUNT) }.getOrNull()
|
||||
val keyBytes = if (existing != null) {
|
||||
Base64.getDecoder().decode(existing)
|
||||
} else {
|
||||
val generated = KeyGenerator.getInstance("AES").apply { init(256, SecureRandom()) }.generateKey()
|
||||
keyring.setPassword(KEYRING_DOMAIN, KEYRING_ACCOUNT, Base64.getEncoder().encodeToString(generated.encoded))
|
||||
generated.encoded
|
||||
}
|
||||
SecretKeySpec(keyBytes, "AES")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user