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:
Claude
2026-07-01 17:54:46 +00:00
parent e6c81fabad
commit ee6bfa6d6d
8 changed files with 456 additions and 0 deletions
+15
View File
@@ -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)
}
}
} }
} }
@@ -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()
}
}
@@ -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)
} }
@@ -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
}
@@ -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())
}
}
@@ -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")
}
}