diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index aaab59a9..1329a1bd 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -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) + } + } } } diff --git a/shared/src/androidMain/kotlin/com/greenart7c3/nostrsigner/shared/SecureCryptoHelper.android.kt b/shared/src/androidMain/kotlin/com/greenart7c3/nostrsigner/shared/SecureCryptoHelper.android.kt new file mode 100644 index 00000000..32edc9fa --- /dev/null +++ b/shared/src/androidMain/kotlin/com/greenart7c3/nostrsigner/shared/SecureCryptoHelper.android.kt @@ -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() + } +} diff --git a/shared/src/commonMain/kotlin/com/greenart7c3/nostrsigner/shared/BunkerPermissionStore.kt b/shared/src/commonMain/kotlin/com/greenart7c3/nostrsigner/shared/BunkerPermissionStore.kt new file mode 100644 index 00000000..612f1891 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/greenart7c3/nostrsigner/shared/BunkerPermissionStore.kt @@ -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) +} diff --git a/shared/src/commonMain/kotlin/com/greenart7c3/nostrsigner/shared/BunkerSigner.kt b/shared/src/commonMain/kotlin/com/greenart7c3/nostrsigner/shared/BunkerSigner.kt index 3be4cb86..abb3e7bd 100644 --- a/shared/src/commonMain/kotlin/com/greenart7c3/nostrsigner/shared/BunkerSigner.kt +++ b/shared/src/commonMain/kotlin/com/greenart7c3/nostrsigner/shared/BunkerSigner.kt @@ -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 signSync( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T = signer.signerSync.sign(createdAt, kind, tags, content) } diff --git a/shared/src/commonMain/kotlin/com/greenart7c3/nostrsigner/shared/BunkerSigningEngine.kt b/shared/src/commonMain/kotlin/com/greenart7c3/nostrsigner/shared/BunkerSigningEngine.kt new file mode 100644 index 00000000..89c0056c --- /dev/null +++ b/shared/src/commonMain/kotlin/com/greenart7c3/nostrsigner/shared/BunkerSigningEngine.kt @@ -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(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, + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/greenart7c3/nostrsigner/shared/SecureCryptoHelper.kt b/shared/src/commonMain/kotlin/com/greenart7c3/nostrsigner/shared/SecureCryptoHelper.kt new file mode 100644 index 00000000..d9328526 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/greenart7c3/nostrsigner/shared/SecureCryptoHelper.kt @@ -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 +} diff --git a/shared/src/commonTest/kotlin/com/greenart7c3/nostrsigner/shared/BunkerSigningEngineTest.kt b/shared/src/commonTest/kotlin/com/greenart7c3/nostrsigner/shared/BunkerSigningEngineTest.kt new file mode 100644 index 00000000..ea8ba070 --- /dev/null +++ b/shared/src/commonTest/kotlin/com/greenart7c3/nostrsigner/shared/BunkerSigningEngineTest.kt @@ -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 = 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()) + } +} diff --git a/shared/src/desktopMain/kotlin/com/greenart7c3/nostrsigner/shared/SecureCryptoHelper.desktop.kt b/shared/src/desktopMain/kotlin/com/greenart7c3/nostrsigner/shared/SecureCryptoHelper.desktop.kt new file mode 100644 index 00000000..53cc57e1 --- /dev/null +++ b/shared/src/desktopMain/kotlin/com/greenart7c3/nostrsigner/shared/SecureCryptoHelper.desktop.kt @@ -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") + } +}