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 {
|
kotlin {
|
||||||
|
targets.configureEach {
|
||||||
|
compilations.configureEach {
|
||||||
|
compileTaskProvider.configure {
|
||||||
|
compilerOptions {
|
||||||
|
freeCompilerArgs.add("-Xexpect-actual-classes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.greenart7c3.nostrsigner.shared"
|
namespace = "com.greenart7c3.nostrsigner.shared"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
@@ -33,5 +43,10 @@ kotlin {
|
|||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
implementation(libs.kotlinx.coroutines.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 nip04Encrypt(plainText: String, toPublicKey: String): String = signer.nip04Encrypt(plainText, toPublicKey)
|
||||||
|
|
||||||
suspend fun nip04Decrypt(cipherText: String, fromPublicKey: String): String = signer.nip04Decrypt(cipherText, fromPublicKey)
|
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