diff --git a/CLAUDE.md b/CLAUDE.md index 738e8926..ffa5a9eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,6 +23,11 @@ Git hooks are auto-installed via the root `build.gradle.kts` preBuild task — n | `free` (default) | Online variant with full networking (OkHttp, Coil, relay connectivity) | | `offline` | No network stack; use `BuildFlavorChecker.isOfflineFlavor()` to guard network code | +## Modules + +- `:app` — the Android app (everything below in Architecture refers to it) +- `:desktop` — Compose for Desktop (JVM) NIP-46 signer for Windows/macOS/Linux; standalone port that mirrors the Android permission model against `quartz-jvm` (no NIP-55). `./gradlew :desktop:run` to launch, `:desktop:packageDistributionForCurrentOs` to package. Its core mirrors `NotificationSubscription`/`EventNotificationConsumer`/`BunkerRequestUtils` in `desktop/.../core/BunkerEngine.kt` — behavior changes to the Android bunker flow should be ported there too. Keys are AES-encrypted via a PKCS12 Java KeyStore (`DesktopKeyStore`); state is JSON files per account (no Room). See `desktop/README.md`. + ## Architecture ### Request ingestion — three paths diff --git a/README.md b/README.md index 5707b5e2..5f649c8b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ Amber is a nostr event signer for Android. It allows users to keep their nsec se +# Desktop + +Amber also runs on Windows, macOS and Linux as a NIP-46 signer built with Compose for Desktop. It supports the same accounts, permissions and bunker connections as the Android app (minus NIP-55, which is Android-only IPC). See [desktop/README.md](desktop/README.md) for build and usage instructions. + # Current Features - [x] Offline diff --git a/build.gradle.kts b/build.gradle.kts index eeb5660e..f1d1b4b5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,8 @@ plugins { alias(libs.plugins.serialization) apply false alias(libs.plugins.kotlin.ksp) version libs.versions.ksp.get() apply false alias(libs.plugins.gradle.ktlint) version libs.versions.ktlint.get() apply false + alias(libs.plugins.kotlinJvm) apply false + alias(libs.plugins.jetbrainsCompose) apply false } tasks.register("installGitHook") { diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 00000000..7ecfb691 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,66 @@ +# Amber Desktop (Windows, macOS, Linux) + +A Compose for Desktop port of Amber that turns your computer into a NIP-46 +remote signer ("bunker"). It shares the same Nostr stack as the Android app +(the [Quartz](https://github.com/vitorpamplona/amethyst) library, published +for the JVM) and mirrors the mobile UI and permission model. + +## Features + +- Multiple accounts: create a new key (NIP-06 seed words) or import an + `nsec`, `ncryptsec` (NIP-49), raw hex key, or mnemonic +- NIP-46 signing over relays: `connect`, `sign_event`, `get_public_key`, + `ping`, `nip04_encrypt/decrypt`, `nip44_encrypt/decrypt`, + `nip44v3_encrypt/decrypt`, `decrypt_zap_event`, `sign_psbt`, + `switch_relays`, `logout` +- Connect applications with a `nostrconnect://` URI or by generating a + `bunker://` URI (with QR code) — each connection gets its own local key +- The same permission model as mobile: auto-accept / auto-reject rules per + request type and event kind, time-bound grants (5 minutes … always), and + per-application sign policies (basic / manual / sign everything) +- Per-application activity history and relay logs +- Default bunker relays management +- Light/dark theme following the mobile look + +Not included: NIP-55 (`nostrsigner:` intents and the content provider) — +that is Android IPC and does not exist on desktop. Web apps and other +clients connect through NIP-46 instead. + +## Key storage + +Private keys are encrypted at rest with AES-256-GCM. The AES key is held in +a Java KeyStore (PKCS12) file under the application data directory: + +- Windows: `%APPDATA%\Amber` +- macOS: `~/Library/Application Support/Amber` +- Linux: `$XDG_DATA_HOME/amber` (or `~/.local/share/amber`) + +Desktop platforms have no universal hardware-backed keystore, so the +keystore password is a per-install random secret stored next to the +keystore with owner-only permissions. Anyone with access to your OS user +account can read your keys — use full-disk encryption and OS login +protection. + +## Run and build + +```bash +./gradlew :desktop:run # run from source +./gradlew :desktop:createDistributable # runnable app image +./gradlew :desktop:packageDeb # Linux .deb +./gradlew :desktop:packageRpm # Linux .rpm +./gradlew :desktop:packageMsi # Windows .msi (build on Windows) +./gradlew :desktop:packageExe # Windows .exe (build on Windows) +./gradlew :desktop:packageDmg # macOS .dmg (build on macOS) +./gradlew :desktop:packageDistributionForCurrentOs # whatever fits the host +``` + +jpackage can only produce installers for the OS it runs on, so release +builds are made per-platform. Linux packaging needs `fakeroot` (deb) or +`rpm-build` (rpm) installed. + +## Tests + +```bash +./gradlew :desktop:test # unit tests +AMBER_E2E=1 ./gradlew :desktop:test # + a NIP-46 round-trip over a public relay +``` diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts new file mode 100644 index 00000000..0308b78a --- /dev/null +++ b/desktop/build.gradle.kts @@ -0,0 +1,57 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.jetbrainsComposeCompiler) + alias(libs.plugins.gradle.ktlint) version libs.versions.ktlint.get() +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + + implementation(libs.quartz.jvm) + // Native secp256k1 bindings for the JVM (Schnorr signatures + ECDH). + implementation(libs.secp256k1.jni.jvm) + implementation(libs.okhttp) + implementation(libs.kotlinx.collections.immutable) + + // QR code generation (pure Java) + implementation(libs.core) + + testImplementation(libs.junit) +} + +compose.desktop { + application { + mainClass = "com.greenart7c3.nostrsigner.desktop.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Exe, TargetFormat.Deb, TargetFormat.Rpm) + packageName = "Amber" + packageVersion = "6.2.3" + description = "Amber - Nostr event signer" + vendor = "greenart7c3" + copyright = "© greenart7c3. Distributed under the MIT license." + + linux { + iconFile.set(rootProject.file("assets/android-icon-hires.png")) + menuGroup = "Network" + } + macOS { + bundleID = "com.greenart7c3.nostrsigner" + } + } + + buildTypes.release.proguard { + // Reflection-heavy stack (Jackson, OkHttp, JNI) — ship unshrunk. + isEnabled.set(false) + } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt new file mode 100644 index 00000000..5b7800ea --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt @@ -0,0 +1,88 @@ +package com.greenart7c3.nostrsigner.desktop + +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import com.greenart7c3.nostrsigner.desktop.core.AccountManager +import com.greenart7c3.nostrsigner.desktop.core.AccountsStore +import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop +import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount +import com.greenart7c3.nostrsigner.desktop.core.SettingsStore +import com.greenart7c3.nostrsigner.desktop.ui.App +import com.greenart7c3.nostrsigner.desktop.ui.NostrSignerTheme +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch + +/** Desktop counterpart of `AccountStateViewModel`: which account is active. */ +object Session { + val account = MutableStateFlow(null) + val loading = MutableStateFlow(true) + val addingAccount = MutableStateFlow(false) + + fun boot() { + AmberDesktop.applicationIOScope.launch { + val saved = AmberDesktop.settings.currentAccount + val npub = saved.ifBlank { AccountsStore.accounts.value.firstOrNull()?.npub ?: "" } + if (npub.isNotBlank()) { + account.value = AmberDesktop.account(npub) + } + loading.value = false + AmberDesktop.engine.start() + } + } + + suspend fun switchTo(npub: String) { + SettingsStore.update { it.copy(currentAccount = npub) } + account.value = AmberDesktop.account(npub) + addingAccount.value = false + } + + fun onAccountAdded(newAccount: DesktopAccount) { + account.value = newAccount + addingAccount.value = false + AmberDesktop.applicationIOScope.launch { + AmberDesktop.engine.updateFilter() + AmberDesktop.engine.client.connect() + } + } + + suspend fun logout(npub: String) { + AmberDesktop.engine.pending.value = AmberDesktop.engine.pending.value.filter { it.account.npub != npub } + AmberDesktop.store(npub).deleteAllFiles() + AccountsStore.delete(npub) + AmberDesktop.evictAccount(npub) + val next = AccountsStore.accounts.value.firstOrNull()?.npub ?: "" + SettingsStore.update { it.copy(currentAccount = next) } + account.value = if (next.isBlank()) null else AmberDesktop.account(next) + AmberDesktop.engine.updateFilter() + } + + fun saveMeta(acc: DesktopAccount) { + AccountManager.saveAccountMeta(acc) + } +} + +fun main() { + Session.boot() + + application { + val windowState = rememberWindowState(size = DpSize(1100.dp, 780.dp)) + val pending by AmberDesktop.engine.pending.collectAsState() + + Window( + onCloseRequest = ::exitApplication, + state = windowState, + title = if (pending.isEmpty()) "Amber" else "Amber (${pending.size})", + icon = painterResource("icon.png"), + ) { + NostrSignerTheme { + App() + } + } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AccountManager.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AccountManager.kt new file mode 100644 index 00000000..b085b796 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AccountManager.kt @@ -0,0 +1,134 @@ +package com.greenart7c3.nostrsigner.desktop.core + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip06KeyDerivation.Bip39Mnemonics +import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import com.vitorpamplona.quartz.nip49PrivKeyEnc.Nip49 +import com.vitorpamplona.quartz.utils.Hex +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlinx.coroutines.flow.MutableStateFlow + +/** Desktop counterpart of the Android `Account`. */ +class DesktopAccount( + val signer: NostrSignerInternal, + val hexKey: String, + val npub: String, + val name: MutableStateFlow, + var signPolicy: Int, + var didBackup: Boolean, +) { + fun signSync( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T = signer.signerSync.sign(createdAt, kind, tags, content) + + suspend fun nip44Encrypt(plainText: String, toPublicKey: String): String = signer.nip44Encrypt(plainText, toPublicKey) + + suspend fun nip04Encrypt(plainText: String, toPublicKey: String): String = signer.nip04Encrypt(plainText, toPublicKey) + + suspend fun nip44Decrypt(cipherText: String, fromPublicKey: String): String = signer.nip44Decrypt(cipherText, fromPublicKey) + + suspend fun nip04Decrypt(cipherText: String, fromPublicKey: String): String = signer.nip04Decrypt(cipherText, fromPublicKey) + + suspend fun decrypt(encryptedContent: String, fromPublicKey: String): String = signer.decrypt(encryptedContent, fromPublicKey) + + fun getNsec(): String = signer.keyPair.privKey!!.toNsec() + + fun nip49Encrypt(password: String): String = Nip49().encrypt(signer.keyPair.privKey!!.toHexKey(), password) +} + +object AccountManager { + /** + * Parses a user-supplied key: nsec, ncryptsec (with [password]), + * BIP-39 mnemonic, or raw hex. Mirrors `AccountStateViewModel.isValidKey`. + */ + fun parseKey(key: String, password: String = ""): Result = runCatching { + val trimmed = key.trim() + if (trimmed.startsWith("ncryptsec")) { + val newKey = Nip49().decrypt(trimmed, password) + KeyPair(Hex.decode(newKey)) + } else if (trimmed.startsWith("nsec")) { + KeyPair(privKey = trimmed.bechToBytes()) + } else if (trimmed.contains(" ") && Nip06().isValidMnemonic(trimmed)) { + KeyPair(privKey = Nip06().privateKeyFromMnemonic(trimmed)) + } else { + KeyPair(Hex.decode(trimmed)) + } + } + + fun generateSeedWords(): List { + while (true) { + val entropy = RandomInstance.bytes(16) + val words = Bip39Mnemonics.toMnemonics(entropy) + if (words.toSet().size == 12) return words + } + } + + suspend fun addAccount( + keyPair: KeyPair, + name: String = "", + seedWords: String = "", + signPolicy: Int = 1, + didBackup: Boolean = true, + ): DesktopAccount { + val npub = keyPair.pubKey.toNpub() + AccountsStore.upsert( + AccountRecord( + npub = npub, + name = name, + encryptedPrivKey = DesktopKeyStore.encrypt(keyPair.privKey!!.toHexKey()), + encryptedSeedWords = if (seedWords.isBlank()) "" else DesktopKeyStore.encrypt(seedWords), + signPolicy = signPolicy, + didBackup = didBackup, + ), + ) + SettingsStore.update { it.copy(currentAccount = npub) } + return loadAccount(npub)!! + } + + suspend fun loadAccount(npub: String): DesktopAccount? { + val record = AccountsStore.get(npub) ?: return null + val privKeyHex = try { + DesktopKeyStore.decrypt(record.encryptedPrivKey) + } catch (e: Exception) { + AmberLogger.e("AccountManager", "Failed to decrypt key for $npub", e) + return null + } + val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray()) + return DesktopAccount( + signer = NostrSignerInternal(keyPair), + hexKey = keyPair.pubKey.toHexKey(), + npub = npub, + name = MutableStateFlow(record.name), + signPolicy = record.signPolicy, + didBackup = record.didBackup, + ) + } + + suspend fun seedWords(npub: String): String { + val record = AccountsStore.get(npub) ?: return "" + if (record.encryptedSeedWords.isBlank()) return "" + return runCatching { DesktopKeyStore.decrypt(record.encryptedSeedWords) }.getOrDefault("") + } + + fun saveAccountMeta(account: DesktopAccount) { + AccountsStore.get(account.npub)?.let { + AccountsStore.upsert( + it.copy( + name = account.name.value, + signPolicy = account.signPolicy, + didBackup = account.didBackup, + ), + ) + } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AmberDesktop.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AmberDesktop.kt new file mode 100644 index 00000000..6665a11c --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AmberDesktop.kt @@ -0,0 +1,85 @@ +package com.greenart7c3.nostrsigner.desktop.core + +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.auth.RelayAuthenticator +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import okhttp3.OkHttpClient + +/** + * Desktop counterpart of the Android `Amber` Application singleton: owns the + * shared coroutine scope, the Nostr relay client, per-account stores, and the + * NIP-46 engine. + */ +object AmberDesktop { + const val TAG = "Amber" + + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + AmberLogger.e("AmberCoroutine", "Caught exception: ${throwable.message}", throwable) + } + + val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler) + + private val httpClient: OkHttpClient by lazy { + OkHttpClient.Builder() + .pingInterval(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .connectTimeout(30, TimeUnit.SECONDS) + .build() + } + + private val socketBuilder = object : WebsocketBuilder { + override fun build(url: NormalizedRelayUrl, out: WebSocketListener) = BasicOkHttpWebSocket(url, { httpClient }, out) + } + + val client: NostrClient by lazy { NostrClient(socketBuilder, applicationIOScope) } + + // Authenticates with relays that request NIP-42 AUTH. + @Suppress("unused") + private val authCoordinator by lazy { + RelayAuthenticator(client, applicationIOScope) { event -> + accounts().map { it.signer.sign(event) } + } + } + + val engine: BunkerEngine by lazy { + authCoordinator + BunkerEngine(client, applicationIOScope) + } + + private val stores = ConcurrentHashMap() + private val accountCache = ConcurrentHashMap() + + fun store(npub: String): AccountStore = stores.computeIfAbsent(npub) { AccountStore(it) } + + suspend fun account(npub: String): DesktopAccount? { + accountCache[npub]?.let { return it } + val loaded = AccountManager.loadAccount(npub) ?: return null + return accountCache.putIfAbsent(npub, loaded) ?: loaded + } + + suspend fun accounts(): List = AccountsStore.accounts.value.mapNotNull { account(it.npub) } + + fun evictAccount(npub: String) { + accountCache.remove(npub) + stores.remove(npub) + } + + val settings: DesktopSettings get() = SettingsStore.settings.value + + fun defaultRelays(): List = settings.normalizedDefaultRelays() + + /** Union of every connection's relays, mirroring `Amber.getSavedRelays`. */ + fun savedRelays(npub: String): Set = buildSet { + store(npub).apps.value.forEach { addAll(it.app.normalizedRelays()) } + if (isEmpty()) addAll(defaultRelays()) + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AppDirs.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AppDirs.kt new file mode 100644 index 00000000..a6548997 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AppDirs.kt @@ -0,0 +1,42 @@ +package com.greenart7c3.nostrsigner.desktop.core + +import java.io.File + +/** + * Resolves the per-user application data directory following each + * platform's conventions: + * - Windows: %APPDATA%\Amber + * - macOS: ~/Library/Application Support/Amber + * - Linux: $XDG_DATA_HOME/amber (or ~/.local/share/amber) + */ +object AppDirs { + val dataDir: File by lazy { + val os = System.getProperty("os.name").lowercase() + val home = System.getProperty("user.home") + val dir = when { + os.contains("win") -> File(System.getenv("APPDATA") ?: "$home\\AppData\\Roaming", "Amber") + os.contains("mac") -> File(home, "Library/Application Support/Amber") + else -> File(System.getenv("XDG_DATA_HOME")?.takeIf { it.isNotBlank() } ?: "$home/.local/share", "amber") + } + dir.mkdirs() + restrictToOwner(dir) + dir + } + + fun accountDir(npub: String): File = File(dataDir, npub).apply { mkdirs() } + + /** + * Best-effort restriction of a file/directory to the current user. + * POSIX-only; on Windows the user profile ACLs already scope access. + */ + fun restrictToOwner(file: File) { + file.setReadable(false, false) + file.setWritable(false, false) + file.setExecutable(false, false) + file.setReadable(true, true) + file.setWritable(true, true) + if (file.isDirectory) { + file.setExecutable(true, true) + } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt new file mode 100644 index 00000000..3131ad66 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt @@ -0,0 +1,970 @@ +package com.greenart7c3.nostrsigner.desktop.core + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.tags.people.taggedUsers +import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.PrivateZapRequestBuilder +import com.vitorpamplona.quartz.utils.TimeUtils +import java.net.URLDecoder +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +fun generateBunkerPrivKey(): String = Nip01Crypto.privKeyCreate().toHexKey() + +fun localPubKeyFromPrivKey(privKeyHex: String): String = KeyPair(privKey = privKeyHex.hexToByteArray()).pubKey.toHexKey() + +/** + * A NIP-46 request waiting for the user's decision. The response payload is + * precomputed when the request arrives (mirroring the Android flow, which + * signs/encrypts up-front so the approval screen can preview the result). + */ +data class PendingBunkerRequest( + val request: BunkerRequest, + val type: SignerType, + val account: DesktopAccount, + val localKey: String, + val relays: List, + val nostrConnectSecret: String = "", + val appName: String = "", + val appUrl: String = "", + val requestedPermissions: List = emptyList(), + val kind: Int? = null, + val preview: String = "", + val result: String = "", + val encryptionType: EncryptionType = EncryptionType.NIP44, + val isNostrConnectUri: Boolean = false, + val signerPrivKey: String = "", +) + +/** + * Desktop port of `NotificationSubscription` + `EventNotificationConsumer` + + * `BunkerRequestUtils`: keeps the kind-24133 subscriptions alive, auto-accepts + * or auto-rejects per the stored permissions, and queues everything else in + * [pending] for the approval UI. + */ +class BunkerEngine( + val client: NostrClient, + val scope: CoroutineScope, +) : RelayConnectionListener { + val pending = kotlinx.coroutines.flow.MutableStateFlow>(emptyList()) + + private val subIds = mutableMapOf() + private val filterMutex = Mutex() + + /** localPubKey -> (npub, localPrivKey), mirrors `LocalKeyAccountIndex`. */ + private val localKeyIndex = ConcurrentHashMap>() + + /** Recently processed event ids -> createdAt, for dedup + `since` computation. */ + private val seenEvents = object : LinkedHashMap(64, 0.75f, false) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry?): Boolean = size > 512 + } + + init { + client.addConnectionListener(this) + } + + override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) { + if (msg is EventMessage) { + if (subIds.containsValue(msg.subId)) { + scope.launch { + consume(msg.event, relay.url) + } + } + } + super.onIncomingMessage(relay, msgStr, msg) + } + + fun start() { + scope.launch { + updateFilter() + client.connect() + } + } + + /** Mirrors `NotificationSubscription.updateFilter`. */ + suspend fun updateFilter() = filterMutex.withLock { + val activeSubKeys = mutableSetOf() + val indexEntries = mutableMapOf>() + + AmberDesktop.accounts().forEach { account -> + val since = computeSince() + val store = AmberDesktop.store(account.npub) + val allConnections = store.apps.value.map { it.app } + val connectionsWithLocalKey = allConnections.filter { it.localKey.isNotEmpty() } + val hasLegacyConnections = allConnections.any { it.localKey.isEmpty() && it.relays.isNotEmpty() } + + for (conn in connectionsWithLocalKey) { + val connPubKey = conn.localPubKey() + indexEntries[connPubKey] = account.npub to conn.localKey + val subKey = "${account.hexKey}_$connPubKey" + + val connRelays = conn.normalizedRelays().ifEmpty { AmberDesktop.savedRelays(account.npub).toList() } + if (connRelays.isEmpty()) continue + + activeSubKeys.add(subKey) + if (!subIds.containsKey(subKey)) { + subIds[subKey] = UUID.randomUUID().toString() + } + client.subscribe( + subIds[subKey]!!, + connRelays.associateWith { + listOf( + Filter( + kinds = listOf(NostrConnectEvent.KIND), + tags = mapOf("p" to listOf(connPubKey)), + limit = 1, + since = since, + ), + ) + }, + ) + } + + if (hasLegacyConnections) { + val relays = AmberDesktop.savedRelays(account.npub) + if (relays.isNotEmpty()) { + activeSubKeys.add(account.hexKey) + if (!subIds.containsKey(account.hexKey)) { + subIds[account.hexKey] = UUID.randomUUID().toString() + } + client.subscribe( + subIds[account.hexKey]!!, + relays.associateWith { + listOf( + Filter( + kinds = listOf(NostrConnectEvent.KIND), + tags = mapOf("p" to listOf(account.hexKey)), + limit = 1, + since = since, + ), + ) + }, + ) + } + } + } + + val staleSubKeys = subIds.keys.filter { it !in activeSubKeys } + for (subKey in staleSubKeys) { + subIds.remove(subKey)?.let { subId -> + client.unsubscribe(subId) + } + } + + localKeyIndex.clear() + localKeyIndex.putAll(indexEntries) + } + + private fun computeSince(): Long { + val latest = synchronized(seenEvents) { seenEvents.values.maxOrNull() ?: 0L } + return if (latest > 0) latest else TimeUtils.now() + } + + /** Mirrors `EventNotificationConsumer.consume` + `notify`. */ + suspend fun consume(event: Event, relay: NormalizedRelayUrl) { + if (event.kind != NostrConnectEvent.KIND) return + if (!event.verify()) return + if (event.content.isEmpty()) return + + val alreadySeen = synchronized(seenEvents) { + if (seenEvents.containsKey(event.id)) { + true + } else { + seenEvents[event.id] = event.createdAt + false + } + } + if (alreadySeen) return + + val taggedKey = event.taggedUsers().firstOrNull() ?: return + + var account = AmberDesktop.accounts().firstOrNull { it.npub == taggedKey.pubKey.hexToByteArray().toNpub() } + var connectionPrivKey = "" + + if (account == null) { + localKeyIndex[taggedKey.pubKey]?.let { (npub, privKey) -> + account = AmberDesktop.account(npub) + connectionPrivKey = privKey + } + } + + val acc = account ?: return + val store = AmberDesktop.store(acc.npub) + store.addLog(relay.url, "bunker", "New event ${event.id} from ${event.pubKey}") + + val encryptionType = if (EncryptedInfo.isNIP04(event.content)) EncryptionType.NIP04 else EncryptionType.NIP44 + + val decrypted = try { + if (connectionPrivKey.isNotEmpty()) { + NostrSignerInternal(KeyPair(privKey = connectionPrivKey.hexToByteArray())).decrypt(event.content, event.pubKey) + } else { + acc.decrypt(event.content, event.pubKey) + } + } catch (e: Exception) { + store.addLog(relay.url, "bunker", "Decryption failed for event ${event.id}: ${e.message}") + return + } + + val bunkerRequest = try { + JacksonMapper.mapper.readValue(decrypted, BunkerRequest::class.java) + } catch (e: Exception) { + store.addLog(relay.url, "bunker", "Failed to parse request: ${e.message}") + return + } + store.addLog(relay.url, "bunker", "Request ${bunkerRequest.id} method ${bunkerRequest.method}") + + handleRequest(bunkerRequest, event, acc, relay, encryptionType, connectionPrivKey) + } + + private suspend fun handleRequest( + bunkerRequest: BunkerRequest, + event: Event, + acc: DesktopAccount, + relay: NormalizedRelayUrl, + encryptionType: EncryptionType, + connectionPrivKey: String, + ) { + val store = AmberDesktop.store(acc.npub) + val responseRelay = listOf(relay) + val type = typeFromMethod(bunkerRequest.method) + + if (type == SignerType.INVALID) { + sendResponse( + acc, + connectionPrivKey, + event.pubKey, + encryptionType, + BunkerResponse(bunkerRequest.id, "", "Unrecognized method: ${bunkerRequest.method}"), + responseRelay.ifEmpty { AmberDesktop.defaultRelays() }, + ) + return + } + + val permission = store.getByKey(event.pubKey) + + // Already-connected client re-sending `connect`: ack silently. + if (permission != null && + ((permission.app.secret != permission.app.key && permission.app.useSecret) || permission.app.isConnected) && + type == SignerType.CONNECT + ) { + store.addHistory(HistoryRecord(permission.app.key, type.toString().lowercase(), null, TimeUtils.now(), true)) + sendResponse( + acc, + connectionPrivKey.ifEmpty { permission.app.localKey }, + event.pubKey, + encryptionType, + BunkerResponse(bunkerRequest.id, "ack", null), + permission.app.normalizedRelays().ifEmpty { responseRelay }, + ) + return + } + + var applicationWithSecret: AppWithPermissions? = null + if (bunkerRequest is BunkerRequestConnect) { + val secret = bunkerRequest.secret ?: UUID.randomUUID().toString() + applicationWithSecret = store.getBySecret(secret) + if (applicationWithSecret == null || secret.isBlank() || applicationWithSecret.app.isConnected || !applicationWithSecret.app.useSecret) { + val message = when { + applicationWithSecret == null -> "invalid secret" + secret.isBlank() -> "no secret" + applicationWithSecret.app.isConnected -> "already connected" + else -> "secret not in use" + } + store.addLog(relay.url, "bunker", "Connection rejected: $message") + sendResponse( + acc, + connectionPrivKey, + event.pubKey, + encryptionType, + BunkerResponse(bunkerRequest.id, "", message), + applicationWithSecret?.app?.normalizedRelays() ?: responseRelay, + ) + return + } + } + + val relays = permission?.app?.normalizedRelays() ?: applicationWithSecret?.app?.normalizedRelays() ?: responseRelay + if (permission == null && applicationWithSecret == null) { + store.addLog(relay.url, "bunker", "No permission found for ${event.pubKey}") + sendResponse( + acc, + connectionPrivKey, + event.pubKey, + encryptionType, + BunkerResponse(bunkerRequest.id, "", "no permission"), + relays, + ) + return + } + + val app = permission ?: applicationWithSecret + val effectivePrivKey = connectionPrivKey.ifEmpty { app?.app?.localKey ?: "" } + + if (type == SignerType.SWITCH_RELAYS) { + permission?.let { current -> + val defaultRelays = AmberDesktop.defaultRelays() + val result = if (defaultRelays.isEmpty()) null else "[${defaultRelays.joinToString(separator = ",") { "\"${it.url}\"" }}]" + val ok = sendResponse( + acc, + effectivePrivKey, + event.pubKey, + encryptionType, + BunkerResponse(bunkerRequest.id, result ?: "", null), + relays, + ) + if (ok) { + store.upsert(current.copy(app = current.app.copy(relays = defaultRelays.map { it.url }))) + updateFilter() + } + } + return + } + + if (type == SignerType.LOGOUT) { + permission?.let { current -> + val ok = sendResponse( + acc, + effectivePrivKey, + event.pubKey, + encryptionType, + BunkerResponse(bunkerRequest.id, "ack", null), + relays, + ) + if (ok) { + store.delete(current.app.key) + updateFilter() + } + } + return + } + + val kind = if (bunkerRequest is BunkerRequestSign) bunkerRequest.event.kind else null + val signPolicy = app?.app?.signPolicy + val permissionType = if (type == SignerType.SIGN_EVENT) { + store.getPermission(event.pubKey, type.toString(), kind) + } else { + store.getPermission(event.pubKey, type.toString()) + } + // A first-time `connect` always goes through the approval UI: approving + // is what migrates the bunker placeholder to the client key and grants + // the default permissions. Reconnects were already acked above. + val remembered = if (type == SignerType.CONNECT) null else isRemembered(signPolicy, permissionType) + + // Compute the response payload (also serves as the approval preview). + val computed = try { + computeResult(bunkerRequest, type, acc) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + store.addLog(relay.url, "bunker", "Rejecting request that cannot be fulfilled: ${e.message}") + sendResponse( + acc, + effectivePrivKey, + event.pubKey, + encryptionType, + BunkerResponse(bunkerRequest.id, "", "could not process the request"), + relays, + ) + return + } + + when (remembered) { + true -> { + store.addHistory(HistoryRecord(event.pubKey, type.toString(), kind, TimeUtils.now(), true)) + app?.let { + store.upsert(it.copy(app = it.app.copy(lastUsed = TimeUtils.now()))) + } + sendResponse( + acc, + effectivePrivKey, + event.pubKey, + encryptionType, + BunkerResponse(bunkerRequest.id, computed.result, null), + relays, + ) + } + + false -> { + store.addHistory(HistoryRecord(event.pubKey, type.toString(), kind, TimeUtils.now(), false)) + sendResponse( + acc, + effectivePrivKey, + event.pubKey, + encryptionType, + BunkerResponse(bunkerRequest.id, "", "user rejected"), + relays, + ) + } + + null -> { + val name = app?.app?.name?.ifBlank { null } ?: event.pubKey.toShortenHex() + addPending( + PendingBunkerRequest( + request = bunkerRequest, + type = type, + account = acc, + localKey = event.pubKey, + relays = relays, + appName = name, + requestedPermissions = if (bunkerRequest is BunkerRequestConnect) { + parsePermissionsParam(bunkerRequest.permissions) + } else { + emptyList() + }, + kind = kind, + preview = computed.preview, + result = computed.result, + encryptionType = encryptionType, + signerPrivKey = effectivePrivKey, + ), + ) + } + } + } + + private data class ComputedResult(val result: String, val preview: String) + + @OptIn(ExperimentalEncodingApi::class) + private suspend fun computeResult( + request: BunkerRequest, + type: SignerType, + acc: DesktopAccount, + ): ComputedResult = when (type) { + SignerType.CONNECT -> ComputedResult("ack", "") + SignerType.GET_PUBLIC_KEY -> ComputedResult(acc.hexKey, acc.npub) + SignerType.PING -> ComputedResult("pong", "") + SignerType.SIGN_EVENT -> { + val template = (request as BunkerRequestSign).event + val signed = acc.signer.sign(template) + ComputedResult(signed.toJson(), template.toJson()) + } + + SignerType.NIP04_ENCRYPT -> { + val pubKey = request.params.first() + val message = request.params.getOrElse(1) { "" } + ComputedResult(acc.nip04Encrypt(message, pubKey), message) + } + + SignerType.NIP44_ENCRYPT -> { + val pubKey = request.params.first() + val message = request.params.getOrElse(1) { "" } + ComputedResult(acc.nip44Encrypt(message, pubKey), message) + } + + SignerType.NIP04_DECRYPT -> { + val pubKey = request.params.first() + val ciphertext = request.params.getOrElse(1) { "" } + val plain = acc.nip04Decrypt(ciphertext, pubKey) + ComputedResult(plain, plain) + } + + SignerType.NIP44_DECRYPT -> { + val pubKey = request.params.first() + val ciphertext = request.params.getOrElse(1) { "" } + val plain = acc.nip44Decrypt(ciphertext, pubKey) + ComputedResult(plain, plain) + } + + SignerType.NIP44_V3_ENCRYPT -> { + // NIP-46 layout: [pubkey, kind, scope, base64 plaintext] + val pubKey = request.params.first() + val kind = request.params.getOrNull(1)?.toIntOrNull() ?: throw IllegalArgumentException("kind is required for nip44v3") + val scope = request.params.getOrElse(2) { "" } + val plainBytes = Base64.decode(request.params.getOrElse(3) { "" }) + val cipher = Nip44v3.encrypt(plainBytes, acc.signer.keyPair.privKey!!, pubKey.hexToByteArray(), kind, scope) + ComputedResult(cipher, plainBytes.toString(Charsets.UTF_8)) + } + + SignerType.NIP44_V3_DECRYPT -> { + val pubKey = request.params.first() + val kind = request.params.getOrNull(1)?.toIntOrNull() ?: throw IllegalArgumentException("kind is required for nip44v3") + val scope = request.params.getOrElse(2) { "" } + val plainBytes = Nip44v3.decrypt(request.params.getOrElse(3) { "" }, acc.signer.keyPair.privKey!!, pubKey.hexToByteArray(), kind, scope) + ComputedResult(Base64.encode(plainBytes), plainBytes.toString(Charsets.UTF_8)) + } + + SignerType.DECRYPT_ZAP_EVENT -> { + val eventJson = request.params.first() + val zapEvent = Event.fromJson(eventJson) as LnZapRequestEvent + val decrypted = PrivateZapRequestBuilder().decryptZapEvent(zapEvent, acc.signer.signerSync).toJson() + ComputedResult(decrypted, decrypted) + } + + SignerType.SIGN_PSBT -> { + val psbt = request.params.first() + ComputedResult(acc.signer.signPsbt(psbt), psbt) + } + + else -> throw IllegalArgumentException("Unsupported request type $type") + } + + private fun addPending(request: PendingBunkerRequest) { + pending.value = if (pending.value.any { it.request.id == request.request.id }) { + pending.value + } else { + pending.value + request + } + } + + fun removePending(id: String) { + pending.value = pending.value.filter { it.request.id != id } + } + + /** Mirrors `BunkerRequestUtils.sendResult` for the approval UI. */ + suspend fun approve( + req: PendingBunkerRequest, + rememberType: RememberType, + grantedPermissions: List = emptyList(), + signPolicy: Int? = null, + ) { + removePending(req.request.id) + val acc = req.account + val store = AmberDesktop.store(acc.npub) + val key = req.localKey + val defaultRelays = AmberDesktop.defaultRelays() + + var savedApplication = store.getByKey(key) + if (savedApplication == null && req.request is BunkerRequestConnect && !req.request.secret.isNullOrBlank()) { + // The bunker:// placeholder is stored under its secret; migrate it + // to the client's real pubkey on first connect. + store.getByKey(req.request.secret!!)?.let { placeholder -> + store.delete(placeholder.app.key) + savedApplication = placeholder.copy(app = placeholder.app.copy(key = key)) + store.upsert(savedApplication!!) + } + } + + val newConnectionRelays = if (req.isNostrConnectUri) { + req.relays.ifEmpty { defaultRelays } + } else { + defaultRelays + } + val relays = savedApplication?.app?.normalizedRelays()?.ifEmpty { defaultRelays } ?: newConnectionRelays + val secret = if (req.request is BunkerRequestConnect) req.request.secret ?: "" else "" + + var application = savedApplication ?: AppWithPermissions( + app = AppRecord( + key = key, + name = req.appName, + relays = relays.map { it.url }, + url = req.appUrl, + pubKey = acc.hexKey, + isConnected = true, + secret = secret, + useSecret = secret.isNotBlank(), + signPolicy = signPolicy ?: acc.signPolicy, + lastUsed = TimeUtils.now(), + ), + ) + + application = application.copy(app = application.app.copy(isConnected = true, lastUsed = TimeUtils.now())) + + // Ensure each connection has its own unique signing key. + if (application.app.localKey.isBlank() && req.request is BunkerRequestConnect && savedApplication == null) { + application = application.copy(app = application.app.copy(localKey = generateBunkerPrivKey())) + } + + if (req.type == SignerType.CONNECT) { + val effectivePolicy = signPolicy ?: acc.signPolicy + application = application.copy(app = application.app.copy(signPolicy = effectivePolicy)) + applySignPolicy(application, effectivePolicy, grantedPermissions) + if (application.permissions.none { it.type == SignerType.GET_PUBLIC_KEY.toString() }) { + application.permissions.add( + AppPermissionRecord(SignerType.GET_PUBLIC_KEY.toString(), null, true, RememberType.ALWAYS.screenCode, Long.MAX_VALUE / 1000, 0), + ) + } + if (application.permissions.none { it.type == SignerType.PING.toString() }) { + application.permissions.add( + AppPermissionRecord(SignerType.PING.toString(), null, true, RememberType.ALWAYS.screenCode, Long.MAX_VALUE / 1000, 0), + ) + } + } else if (rememberType != RememberType.NEVER) { + acceptOrRejectPermission(application, req.type, req.kind, true, rememberType) + } + + store.upsert(application) + store.addHistory(HistoryRecord(key, req.type.toString(), req.kind, TimeUtils.now(), true)) + + updateFilter() + client.connect() + + val response = if (req.type == SignerType.CONNECT) { + req.nostrConnectSecret.ifBlank { req.result } + } else { + req.result + } + val signerPrivKey = application.app.localKey.ifEmpty { req.signerPrivKey } + + sendResponse( + acc, + signerPrivKey, + key, + req.encryptionType, + BunkerResponse(req.request.id, response, null), + application.app.normalizedRelays().ifEmpty { relays }, + ) + } + + /** Mirrors `BunkerRequestUtils.sendRejection`. */ + suspend fun reject( + req: PendingBunkerRequest, + rememberType: RememberType, + ) { + removePending(req.request.id) + val acc = req.account + val store = AmberDesktop.store(acc.npub) + val key = req.localKey + + val savedApplication = store.getByKey(key) + val defaultRelays = AmberDesktop.defaultRelays() + val newConnectionRelays = if (req.isNostrConnectUri) { + req.relays.ifEmpty { defaultRelays } + } else { + defaultRelays + } + val relays = savedApplication?.app?.normalizedRelays()?.ifEmpty { defaultRelays } ?: newConnectionRelays + val secret = if (req.request is BunkerRequestConnect) req.request.secret ?: "" else "" + + val application = savedApplication ?: AppWithPermissions( + app = AppRecord( + key = key, + name = req.appName, + relays = relays.map { it.url }, + pubKey = acc.hexKey, + isConnected = true, + secret = secret, + useSecret = secret.isNotBlank(), + signPolicy = acc.signPolicy, + lastUsed = TimeUtils.now(), + ), + ) + + if (rememberType != RememberType.NEVER) { + acceptOrRejectPermission(application, req.type, req.kind, false, rememberType) + } + + if (req.request !is BunkerRequestConnect) { + store.upsert(application) + store.addHistory(HistoryRecord(key, req.type.toString(), req.kind, TimeUtils.now(), false)) + } + + val signerPrivKey = application.app.localKey.ifEmpty { req.signerPrivKey } + sendResponse( + acc, + signerPrivKey, + key, + req.encryptionType, + BunkerResponse(req.request.id, "", "user rejected"), + relays, + ) + } + + /** Mirrors `AmberUtils.configureSignPolicy`. */ + private fun applySignPolicy( + application: AppWithPermissions, + signPolicy: Int, + permissions: List, + ) { + when (signPolicy) { + 0 -> { + basicPermissions.forEach { perm -> + addAcceptedPermission(application, perm) + } + } + + 1 -> { + permissions.filter { it.checked }.forEach { perm -> + addAcceptedPermission(application, perm) + } + } + } + } + + private fun addAcceptedPermission(application: AppWithPermissions, perm: RequestedPermission) { + val type = normalizePermissionType(perm.type) + if (application.permissions.any { it.type == type && it.kind == perm.kind }) return + application.permissions.add( + AppPermissionRecord(type, perm.kind, true, RememberType.ALWAYS.screenCode, Long.MAX_VALUE / 1000, 0), + ) + } + + /** Mirrors `AmberUtils.acceptPermission` / rejection with ALL scope. */ + private fun acceptOrRejectPermission( + application: AppWithPermissions, + type: SignerType, + kind: Int?, + accepted: Boolean, + rememberType: RememberType, + ) { + val until = rememberType.acceptUntil() + val typeStr = type.toString() + + if (kind != null) { + application.permissions.removeIf { it.kind == kind && it.type == typeStr && it.relay.isEmpty() } + } else { + application.permissions.removeIf { it.type == typeStr && it.type != "SIGN_EVENT" } + } + + application.permissions.add( + AppPermissionRecord( + type = typeStr, + kind = kind, + acceptable = accepted, + rememberType = rememberType.screenCode, + acceptUntil = if (accepted) until else 0, + rejectUntil = if (accepted) 0 else until, + ), + ) + } + + private suspend fun sendResponse( + account: DesktopAccount, + signerPrivKey: String, + localKey: String, + encryptionType: EncryptionType, + bunkerResponse: BunkerResponse, + relays: List, + ): Boolean { + val store = AmberDesktop.store(account.npub) + if (relays.isEmpty()) { + store.addLog(localKey, "bunker response", "No relays to send the response to") + return false + } + + val connSigner = if (signerPrivKey.isNotEmpty()) { + NostrSignerInternal(KeyPair(privKey = signerPrivKey.hexToByteArray())) + } else { + null + } + + val plainText = JacksonMapper.mapper.writeValueAsString(bunkerResponse) + + suspend fun buildEvent(): Event { + val encryptedContent = if (encryptionType == EncryptionType.NIP44) { + connSigner?.nip44Encrypt(plainText, localKey) ?: account.nip44Encrypt(plainText, localKey) + } else { + connSigner?.nip04Encrypt(plainText, localKey) ?: account.nip04Encrypt(plainText, localKey) + } + return connSigner?.signerSync?.sign( + TimeUtils.now(), + NostrConnectEvent.KIND, + arrayOf(arrayOf("p", localKey)), + encryptedContent, + ) ?: account.signSync( + TimeUtils.now(), + NostrConnectEvent.KIND, + arrayOf(arrayOf("p", localKey)), + encryptedContent, + ) + } + + val success = retryWithBackoff { + client.publishAndConfirm( + event = buildEvent(), + relayList = relays.toSet(), + timeoutInSeconds = 5, + ) + } + + val sanitized = "id=${bunkerResponse.id} ${bunkerResponse.error?.let { "error=$it" } ?: "ok"} sent=$success" + relays.forEach { store.addLog(it.url, "bunker response", sanitized) } + return success + } + + private suspend fun retryWithBackoff( + maxRetries: Int = 5, + initialDelayMs: Long = 200L, + maxDelayMs: Long = 3_200L, + block: suspend () -> Boolean, + ): Boolean { + var currentDelay = initialDelayMs + repeat(maxRetries) { attempt -> + delay(currentDelay) + if (block()) return true + if (attempt < maxRetries - 1) { + currentDelay = (currentDelay * 2).coerceAtMost(maxDelayMs) + } + } + return false + } + + /** Parses a `nostrconnect://` URI and queues it for approval. */ + suspend fun addNostrConnect(uriString: String, account: DesktopAccount): String? { + try { + val data = uriString.trim().removePrefix("nostrconnect://") + val split = data.split("?") + val pubKey = split.first() + if (pubKey.length != 64) return "Invalid public key in URI" + + val relays = mutableListOf() + var name = "" + var url = "" + var nostrConnectSecret = "" + val permissions = mutableListOf() + + split.drop(1).joinToString("?").split("&").forEach { param -> + val parts = param.split("=") + if (parts.size < 2) return@forEach + val paramName = parts.first() + val value = URLDecoder.decode(parts.drop(1).joinToString("="), Charsets.UTF_8) + when (paramName) { + "relay" -> RelayUrlNormalizer.normalizeOrNull(value)?.let { relays.add(it) } + "name" -> name = value + "url" -> url = value + "secret" -> nostrConnectSecret = value + "perms" -> value.split(",").forEach { perm -> + if (perm.isBlank()) return@forEach + val permParts = perm.split(":") + permissions.add(RequestedPermission(permParts.first().trim(), permParts.getOrNull(1)?.toIntOrNull())) + } + "metadata" -> runCatching { + val node = JacksonMapper.mapper.readTree(value) + node.get("name")?.asText()?.let { if (it.isNotBlank()) name = it } + node.get("url")?.asText()?.let { if (it.isNotBlank()) url = it } + node.get("perms")?.asText()?.split(",")?.forEach { perm -> + if (perm.isBlank()) return@forEach + val permParts = perm.split(":") + permissions.add(RequestedPermission(permParts.first().trim(), permParts.getOrNull(1)?.toIntOrNull())) + } + } + } + } + permissions.removeIf { it.kind == null && it.type == "sign_event" } + + addPending( + PendingBunkerRequest( + request = BunkerRequestConnect( + id = UUID.randomUUID().toString().substring(0, 6), + remoteKey = pubKey, + secret = "", + permissions = permissions.joinToString(",") { if (it.kind != null) "${it.type}:${it.kind}" else it.type }.ifBlank { null }, + ), + type = SignerType.CONNECT, + account = account, + localKey = pubKey, + relays = relays, + nostrConnectSecret = nostrConnectSecret, + appName = name.ifBlank { pubKey.toShortenHex() }, + appUrl = url, + requestedPermissions = permissions, + result = "ack", + encryptionType = EncryptionType.NIP44, + isNostrConnectUri = true, + ), + ) + return null + } catch (e: Exception) { + AmberLogger.e(AmberDesktop.TAG, "Failed to parse nostrconnect uri", e) + return e.message ?: "Failed to parse the URI" + } + } + + /** + * Creates a bunker:// connection placeholder (key == secret until the + * client connects) and returns the URI to hand to the client app. + */ + suspend fun createBunkerConnection( + account: DesktopAccount, + name: String, + relays: List, + ): String { + val secret = UUID.randomUUID().toString() + val connPrivKey = generateBunkerPrivKey() + val app = AppWithPermissions( + app = AppRecord( + key = secret, + name = name, + relays = relays.map { it.url }, + pubKey = account.hexKey, + isConnected = false, + secret = secret, + useSecret = true, + signPolicy = account.signPolicy, + localKey = connPrivKey, + ), + ) + AmberDesktop.store(account.npub).upsert(app) + updateFilter() + client.connect() + val relayParams = relays.joinToString(separator = "&") { "relay=${it.url}" } + return "bunker://${localPubKeyFromPrivKey(connPrivKey)}?$relayParams&secret=$secret" + } + + private fun parsePermissionsParam(permissions: String?): List { + if (permissions.isNullOrBlank()) return emptyList() + return permissions.split(",").mapNotNull { perm -> + if (perm.isBlank()) return@mapNotNull null + val parts = perm.split(":") + RequestedPermission(parts.first().trim(), parts.getOrNull(1)?.toIntOrNull()) + } + } + + companion object { + fun typeFromMethod(method: String): SignerType = when (method) { + "connect" -> SignerType.CONNECT + "sign_event" -> SignerType.SIGN_EVENT + "get_public_key" -> SignerType.GET_PUBLIC_KEY + "nip04_encrypt" -> SignerType.NIP04_ENCRYPT + "nip04_decrypt" -> SignerType.NIP04_DECRYPT + "nip44_encrypt" -> SignerType.NIP44_ENCRYPT + "nip44_decrypt" -> SignerType.NIP44_DECRYPT + "nip44v3_encrypt" -> SignerType.NIP44_V3_ENCRYPT + "nip44v3_decrypt" -> SignerType.NIP44_V3_DECRYPT + "decrypt_zap_event" -> SignerType.DECRYPT_ZAP_EVENT + "ping" -> SignerType.PING + "switch_relays" -> SignerType.SWITCH_RELAYS + "sign_psbt" -> SignerType.SIGN_PSBT + "logout" -> SignerType.LOGOUT + else -> SignerType.INVALID + } + + fun normalizePermissionType(type: String): String = when (type.lowercase()) { + "connect" -> SignerType.CONNECT.toString() + "sign_event" -> SignerType.SIGN_EVENT.toString() + "get_public_key" -> SignerType.GET_PUBLIC_KEY.toString() + "nip04_encrypt", "encrypt_clear_text" -> SignerType.NIP04_ENCRYPT.toString() + "nip04_decrypt", "decrypt_clear_text" -> SignerType.NIP04_DECRYPT.toString() + "nip44_encrypt" -> SignerType.NIP44_ENCRYPT.toString() + "nip44_decrypt" -> SignerType.NIP44_DECRYPT.toString() + "nip44v3_encrypt" -> SignerType.NIP44_V3_ENCRYPT.toString() + "nip44v3_decrypt" -> SignerType.NIP44_V3_DECRYPT.toString() + "decrypt_zap_event" -> SignerType.DECRYPT_ZAP_EVENT.toString() + "ping" -> SignerType.PING.toString() + "sign_psbt" -> SignerType.SIGN_PSBT.toString() + else -> type.uppercase() + } + } +} + +/** Convenience accessor mirroring `ApplicationEntity.localPubKey`. */ +fun AppRecord.localPubKey(): String = if (localKey.isNotEmpty()) localPubKeyFromPrivKey(localKey) else "" diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/DesktopKeyStore.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/DesktopKeyStore.kt new file mode 100644 index 00000000..c7b68171 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/DesktopKeyStore.kt @@ -0,0 +1,101 @@ +package com.greenart7c3.nostrsigner.desktop.core + +import java.io.File +import java.nio.ByteBuffer +import java.security.KeyStore +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 kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Desktop counterpart of the Android `SecureCryptoHelper`: private keys are + * encrypted at rest with an AES-256 key held in a Java KeyStore (PKCS12) + * file. There is no OS-backed hardware keystore available across all three + * desktop platforms, so the keystore password is a per-install random secret + * stored next to the keystore with owner-only file permissions. + */ +object DesktopKeyStore { + private const val KEY_ALIAS = "AMBER_AES_KEY" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val IV_SIZE = 12 // 96 bits + private const val TAG_SIZE = 128 // bits + private val mutex = Mutex() + + private val keyStoreFile: File get() = File(AppDirs.dataDir, "amber.keystore") + private val passwordFile: File get() = File(AppDirs.dataDir, "keystore.pass") + + private var cachedKey: SecretKey? = null + + 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) + + return Base64.getEncoder().withoutPadding().encodeToString(combined.array()) + } + + suspend fun decrypt(encryptedText: String): String = mutex.withLock { + val key = getOrCreateSecretKey() + 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) + val spec = GCMParameterSpec(TAG_SIZE, iv) + cipher.init(Cipher.DECRYPT_MODE, key, spec) + + val plainBytes = cipher.doFinal(cipherText) + return String(plainBytes, Charsets.UTF_8) + } + + private fun keystorePassword(): CharArray { + if (!passwordFile.exists()) { + val bytes = ByteArray(32) + SecureRandom().nextBytes(bytes) + passwordFile.writeText(Base64.getEncoder().withoutPadding().encodeToString(bytes)) + AppDirs.restrictToOwner(passwordFile) + } + return passwordFile.readText().trim().toCharArray() + } + + private fun getOrCreateSecretKey(): SecretKey { + cachedKey?.let { return it } + + val password = keystorePassword() + val keyStore = KeyStore.getInstance("PKCS12") + if (keyStoreFile.exists()) { + keyStoreFile.inputStream().use { keyStore.load(it, password) } + val entry = keyStore.getEntry(KEY_ALIAS, KeyStore.PasswordProtection(password)) as? KeyStore.SecretKeyEntry + if (entry != null) { + cachedKey = entry.secretKey + return entry.secretKey + } + } else { + keyStore.load(null, password) + } + + val keyGenerator = KeyGenerator.getInstance("AES") + keyGenerator.init(256) + val key = keyGenerator.generateKey() + keyStore.setEntry(KEY_ALIAS, KeyStore.SecretKeyEntry(key), KeyStore.PasswordProtection(password)) + keyStoreFile.outputStream().use { keyStore.store(it, password) } + AppDirs.restrictToOwner(keyStoreFile) + cachedKey = key + return key + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt new file mode 100644 index 00000000..f0c621bf --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt @@ -0,0 +1,212 @@ +package com.greenart7c3.nostrsigner.desktop.core + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.TimeUtils + +enum class SignerType { + CONNECT, + SIGN_EVENT, + NIP04_ENCRYPT, + NIP04_DECRYPT, + NIP44_ENCRYPT, + NIP44_DECRYPT, + NIP44_V3_ENCRYPT, + NIP44_V3_DECRYPT, + GET_PUBLIC_KEY, + DECRYPT_ZAP_EVENT, + PING, + INVALID, + SWITCH_RELAYS, + SIGN_PSBT, + LOGOUT, +} + +enum class EncryptionType { + NIP44, + NIP04, +} + +/** Mirrors the Android `RememberType` screen codes so exports stay compatible. */ +enum class RememberType(val screenCode: Int, val label: String) { + NEVER(0, "Just once"), + ONE_MINUTE(1, "1 minute"), + FIVE_MINUTES(2, "5 minutes"), + TEN_MINUTES(3, "10 minutes"), + ALWAYS(4, "Always"), + ONE_HOUR(5, "1 hour"), + ONE_DAY(6, "1 day"), + ONE_WEEK(7, "1 week"), + ; + + fun acceptUntil(): Long = when (this) { + ALWAYS -> Long.MAX_VALUE / 1000 + ONE_MINUTE -> TimeUtils.now() + 60 + FIVE_MINUTES -> TimeUtils.now() + 300 + TEN_MINUTES -> TimeUtils.now() + 600 + ONE_HOUR -> TimeUtils.now() + 3600 + ONE_DAY -> TimeUtils.now() + 86400 + ONE_WEEK -> TimeUtils.now() + 604800 + NEVER -> 0L + } +} + +val rememberTypeDisplayOrder = listOf( + RememberType.NEVER, + RememberType.FIVE_MINUTES, + RememberType.TEN_MINUTES, + RememberType.ONE_HOUR, + RememberType.ONE_DAY, + RememberType.ONE_WEEK, + RememberType.ALWAYS, +) + +data class RequestedPermission( + val type: String, + val kind: Int?, + var checked: Boolean = true, +) + +val basicPermissions = listOf( + RequestedPermission("get_public_key", null), + RequestedPermission("nip04_encrypt", null), + RequestedPermission("nip04_decrypt", null), + RequestedPermission("nip44_encrypt", null), + RequestedPermission("nip44_decrypt", null), + RequestedPermission("decrypt_zap_event", null), + RequestedPermission("sign_event", 0), + RequestedPermission("sign_event", 1), + RequestedPermission("sign_event", 3), + RequestedPermission("sign_event", 4), + RequestedPermission("sign_event", 5), + RequestedPermission("sign_event", 6), + RequestedPermission("sign_event", 7), + RequestedPermission("sign_event", 9734), + RequestedPermission("sign_event", 9735), + RequestedPermission("sign_event", 10000), + RequestedPermission("sign_event", 10002), + RequestedPermission("sign_event", 10003), + RequestedPermission("sign_event", 10013), + RequestedPermission("sign_event", 31234), + RequestedPermission("sign_event", 30078), + RequestedPermission("sign_event", 22242), + RequestedPermission("sign_event", 27235), + RequestedPermission("sign_event", 30023), +) + +/** + * Persisted connection record. Mirrors the Android `ApplicationEntity` + * column-for-column so behavior (and future import/export) matches. + */ +data class AppRecord( + val key: String, + val name: String = "", + val relays: List = emptyList(), + val url: String = "", + val icon: String = "", + val description: String = "", + val pubKey: String = "", + val isConnected: Boolean = false, + val secret: String = "", + val useSecret: Boolean = false, + val signPolicy: Int = 0, + val deleteAfter: Long = 0L, + val lastUsed: Long = 0L, + val localKey: String = "", +) { + fun normalizedRelays(): List = relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + + fun displayName(): String = name.ifBlank { key.toShortenHex() } +} + +/** Mirrors the Android `ApplicationPermissionsEntity`. */ +data class AppPermissionRecord( + val type: String, + val kind: Int?, + val acceptable: Boolean, + val rememberType: Int, + val acceptUntil: Long, + val rejectUntil: Long, + val relay: String = "", +) + +data class AppWithPermissions( + val app: AppRecord, + val permissions: MutableList = mutableListOf(), +) + +data class HistoryRecord( + val appKey: String, + val type: String, + val kind: Int?, + val time: Long, + val accepted: Boolean, +) + +data class LogRecord( + val url: String, + val type: String, + val message: String, + val time: Long, +) + +data class DesktopSettings( + val defaultRelays: List = listOf( + "wss://nostr.oxtr.dev/", + "wss://theforest.nostr1.com/", + "wss://relay.primal.net/", + ), + val currentAccount: String = "", + val darkTheme: Boolean? = null, +) { + fun normalizedDefaultRelays(): List = defaultRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } +} + +data class AccountRecord( + val npub: String, + val name: String = "", + val encryptedPrivKey: String = "", + val encryptedSeedWords: String = "", + val signPolicy: Int = 1, + val didBackup: Boolean = true, +) + +fun String.toShortenHex(): String = if (length <= 16) this else "${take(8)}…${takeLast(8)}" + +/** + * Mirrors `IntentUtils.isRemembered`: true = auto-accept, false = auto-reject, + * null = ask the user. + */ +fun isRemembered(signPolicy: Int?, permission: AppPermissionRecord?): Boolean? { + val rejectUntil = permission?.rejectUntil ?: 0 + val acceptUntil = permission?.acceptUntil ?: 0 + if (signPolicy == 2) { + return true + } + if (rejectUntil == 0L && acceptUntil == 0L) return null + return if (rejectUntil > TimeUtils.now() && rejectUntil > 0 && permission?.acceptable == false) { + false + } else if (acceptUntil > TimeUtils.now() && acceptUntil > 0 && permission?.acceptable == true) { + true + } else { + null + } +} + +fun SignerType.describe(kind: Int?): String = when (this) { + SignerType.CONNECT -> "wants to connect" + SignerType.SIGN_EVENT -> "wants you to sign an event" + (kind?.let { " (kind $it)" } ?: "") + SignerType.NIP04_ENCRYPT -> "wants to encrypt a text with NIP-04" + SignerType.NIP04_DECRYPT -> "wants to read encrypted content (NIP-04)" + SignerType.NIP44_ENCRYPT -> "wants to encrypt a text with NIP-44" + SignerType.NIP44_DECRYPT -> "wants to read encrypted content (NIP-44)" + SignerType.NIP44_V3_ENCRYPT -> "wants to encrypt data with NIP-44 v3" + SignerType.NIP44_V3_DECRYPT -> "wants to read encrypted content (NIP-44 v3)" + SignerType.GET_PUBLIC_KEY -> "wants to read your public key" + SignerType.DECRYPT_ZAP_EVENT -> "wants to decrypt a private zap" + SignerType.PING -> "sent a ping" + SignerType.SWITCH_RELAYS -> "wants to switch relays" + SignerType.SIGN_PSBT -> "wants you to sign a PSBT" + SignerType.LOGOUT -> "wants to log out" + SignerType.INVALID -> "sent an invalid request" +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Nip44v3.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Nip44v3.kt new file mode 100644 index 00000000..d5fd9722 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Nip44v3.kt @@ -0,0 +1,262 @@ +package com.greenart7c3.nostrsigner.desktop.core + +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20 +import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import com.vitorpamplona.quartz.utils.mac.MacInstance +import kotlin.io.encoding.Base64 + +/** + * NIP-44 v3 cipher. + * + * Implements the asymmetric encryption scheme defined in the + * `nostr-land/nip44v3` draft: ECDH(secp256k1) → HKDF-SHA256 keyed with + * `"nip44-v3\x00" || nonce`, ChaCha20 with an all-zeroes 96-bit nonce, + * HMAC-SHA256 over `nonce || kind || scope_len || scope || ciphertext`, + * and a context (`kind` + `scope`) authenticated alongside the + * ciphertext to prevent cross-context replay. + */ +object Nip44v3 { + const val VERSION: Byte = 0x03 + private const val NONCE_SIZE = 32 + private const val MAC_SIZE = 32 + private const val MIN_DECODED_SIZE = 77 // 1 + 32 + 32 + 4 + 4 + 0 + 4 + private const val MIN_PADDING = 32 + private const val PAD_CHUNK_THRESHOLD = 32768 + private const val PAD_SUBDIVS_SMALL = 4 + private const val PAD_SUBDIVS_LARGE = 8 + + private val saltPrefix = "nip44-v3\u0000".encodeToByteArray() + private val infoEncryptionKey = "encryption_key".encodeToByteArray() + private val infoMacKey = "mac_key".encodeToByteArray() + private val zeroChaChaNonce = ByteArray(12) + + private val hkdf = Hkdf() + private val chaCha = ChaCha20() + + class Nip44v3Exception(message: String, cause: Throwable? = null) : RuntimeException(message, cause) + + fun encrypt( + plaintext: ByteArray, + privKey: ByteArray, + pubKey: ByteArray, + kind: Int, + scope: String, + ): String = encryptWithNonce(plaintext, privKey, pubKey, kind, scope, RandomInstance.bytes(NONCE_SIZE)) + + /** + * Test/library-internal overload that accepts a caller-supplied nonce. The + * NIP-44 v3 spec is explicit that production code must not let callers + * choose the nonce — use [encrypt] for that. + */ + fun encryptWithNonce( + plaintext: ByteArray, + privKey: ByteArray, + pubKey: ByteArray, + kind: Int, + scope: String, + nonce: ByteArray, + ): String { + require(nonce.size == NONCE_SIZE) { "nonce must be $NONCE_SIZE bytes, got ${nonce.size}" } + require(kind >= 0) { "kind must be non-negative, got $kind" } + + val scopeBytes = scope.encodeToByteArray() + val (encryptionKey, macKey) = deriveKeys(privKey, pubKey, nonce) + + val padded = pad(plaintext) + val cipherBytes = chaCha.encrypt(padded, zeroChaChaNonce, encryptionKey) + + val mac = computeMac(macKey, nonce, kind, scopeBytes, cipherBytes) + + val payload = ByteArray(1 + NONCE_SIZE + MAC_SIZE + 4 + 4 + scopeBytes.size + cipherBytes.size) + var off = 0 + payload[off++] = VERSION + nonce.copyInto(payload, off) + off += NONCE_SIZE + mac.copyInto(payload, off) + off += MAC_SIZE + writeU32BE(payload, off, kind) + off += 4 + writeU32BE(payload, off, scopeBytes.size) + off += 4 + scopeBytes.copyInto(payload, off) + off += scopeBytes.size + cipherBytes.copyInto(payload, off) + + return Base64.encode(payload) + } + + fun decrypt( + payload: String, + privKey: ByteArray, + pubKey: ByteArray, + expectedKind: Int, + expectedScope: String, + ): ByteArray { + if (payload.isEmpty()) throw Nip44v3Exception("empty payload") + if (payload[0] == '#') throw Nip44v3Exception("unsupported future version") + + val decoded = try { + Base64.decode(payload) + } catch (e: IllegalArgumentException) { + throw Nip44v3Exception("invalid base64", e) + } + + if (decoded.size < MIN_DECODED_SIZE) { + throw Nip44v3Exception("ciphertext too short: ${decoded.size} < $MIN_DECODED_SIZE") + } + if (decoded[0] != VERSION) { + throw Nip44v3Exception("unsupported version: ${decoded[0].toInt() and 0xff}") + } + + val nonce = decoded.copyOfRange(1, 1 + NONCE_SIZE) + val mac = decoded.copyOfRange(1 + NONCE_SIZE, 1 + NONCE_SIZE + MAC_SIZE) + val kind = readU32BE(decoded, 1 + NONCE_SIZE + MAC_SIZE) + val scopeLen = readU32BE(decoded, 1 + NONCE_SIZE + MAC_SIZE + 4) + + val scopeOff = 1 + NONCE_SIZE + MAC_SIZE + 4 + 4 + if (scopeLen < 0 || scopeLen > decoded.size - scopeOff) { + throw Nip44v3Exception("scope length out-of-bounds: $scopeLen") + } + val scope = decoded.copyOfRange(scopeOff, scopeOff + scopeLen) + val cipherBytes = decoded.copyOfRange(scopeOff + scopeLen, decoded.size) + if (cipherBytes.size < 4) { + throw Nip44v3Exception("ciphertext too short") + } + + if (kind != expectedKind) { + throw Nip44v3Exception("context mismatch (kind): got $kind expected $expectedKind") + } + val expectedScopeBytes = expectedScope.encodeToByteArray() + if (!scope.contentEquals(expectedScopeBytes)) { + throw Nip44v3Exception("context mismatch (scope)") + } + + val (encryptionKey, macKey) = deriveKeys(privKey, pubKey, nonce) + + val expectedMac = computeMac(macKey, nonce, kind, scope, cipherBytes) + if (!constantTimeEq(mac, expectedMac)) { + throw Nip44v3Exception("invalid MAC") + } + + val padded = chaCha.decrypt(cipherBytes, zeroChaChaNonce, encryptionKey) + return unpad(padded) + } + + fun deriveKeys(privKey: ByteArray, pubKey: ByteArray, nonce: ByteArray): Pair { + val sharedSecret = Secp256k1Instance.pubKeyTweakMulCompact(pubKey, privKey) + return deriveKeysFromSharedSecret(sharedSecret, nonce) + } + + fun deriveKeysFromSharedSecret(sharedSecret: ByteArray, nonce: ByteArray): Pair { + val prk = extract(sharedSecret, nonce) + val encryptionKey = hkdf.expand(prk, infoEncryptionKey, 32) + val macKey = hkdf.expand(prk, infoMacKey, 32) + return encryptionKey to macKey + } + + /** Exposed for test vectors that check the intermediate `prk`. */ + fun extract(sharedSecret: ByteArray, nonce: ByteArray): ByteArray { + val salt = ByteArray(saltPrefix.size + nonce.size) + saltPrefix.copyInto(salt, 0) + nonce.copyInto(salt, saltPrefix.size) + return hkdf.extract(sharedSecret, salt) + } + + fun pad(plaintext: ByteArray): ByteArray { + val prefixedLen = 4 + plaintext.size + val targetSize = targetSize(prefixedLen) + val out = ByteArray(targetSize) + writeU32BE(out, 0, plaintext.size) + plaintext.copyInto(out, 4) + return out + } + + fun unpad(padded: ByteArray): ByteArray { + if (padded.size < 4) throw Nip44v3Exception("padded buffer too short") + val plaintextLen = readU32BE(padded, 0) + if (plaintextLen < 0) throw Nip44v3Exception("invalid plaintext length: $plaintextLen") + if (plaintextLen.toLong() + 4L > padded.size.toLong()) { + throw Nip44v3Exception("invalid padding: declared $plaintextLen, available ${padded.size - 4}") + } + // The NIP-44 v3 spec deliberately does NOT mandate a canonical padding + // length: "implementations must not do any other checks on the padding + // length". The standard padding algorithm is only a SHOULD, so a peer + // may legitimately send more (or fewer) padding bytes than we would + // produce. Validating against our own target size would reject those + // otherwise-valid messages, so we only require that the padding region + // is all zeroes. + // Constant-time zero check over the padding region. + var diff = 0 + for (i in 4 + plaintextLen until padded.size) { + diff = diff or padded[i].toInt() + } + if (diff != 0) throw Nip44v3Exception("invalid padding: non-zero trailing bytes") + return padded.copyOfRange(4, 4 + plaintextLen) + } + + fun targetSize(len: Int): Int { + val t = targetSizeLong(len.toLong()) + if (t > Int.MAX_VALUE) throw Nip44v3Exception("padded length exceeds Int.MAX_VALUE: $t") + return t.toInt() + } + + private fun targetSizeLong(len: Long): Long { + require(len >= 0) { "negative length: $len" } + if (len == 0L) return MIN_PADDING.toLong() + // next_power = 2 ** ceil(log2(len)) + val nextPower = if (len == 1L) 1L else 1L shl ceilLog2Long(len) + val subdivs = if (nextPower >= PAD_CHUNK_THRESHOLD) PAD_SUBDIVS_LARGE.toLong() else PAD_SUBDIVS_SMALL.toLong() + val chunk = maxOf(MIN_PADDING.toLong(), nextPower / subdivs) + return chunk * ((len + chunk - 1) / chunk) + } + + private fun ceilLog2Long(n: Long): Int { + // For n >= 2; matches ceil(log2(n)). + if (n <= 1) return 0 + var v = n - 1 + var bits = 0 + while (v > 0) { + v = v ushr 1 + bits++ + } + return bits + } + + private fun computeMac(macKey: ByteArray, nonce: ByteArray, kind: Int, scope: ByteArray, ciphertext: ByteArray): ByteArray { + val mac = MacInstance("HmacSHA256", macKey) + mac.update(nonce) + val u32 = ByteArray(4) + writeU32BE(u32, 0, kind) + mac.update(u32) + writeU32BE(u32, 0, scope.size) + mac.update(u32) + if (scope.isNotEmpty()) mac.update(scope) + if (ciphertext.isNotEmpty()) mac.update(ciphertext) + return mac.doFinal() + } + + private fun writeU32BE(out: ByteArray, offset: Int, value: Int) { + out[offset] = (value ushr 24).toByte() + out[offset + 1] = (value ushr 16).toByte() + out[offset + 2] = (value ushr 8).toByte() + out[offset + 3] = value.toByte() + } + + private fun readU32BE(src: ByteArray, offset: Int): Int { + val v = ((src[offset].toLong() and 0xff) shl 24) or + ((src[offset + 1].toLong() and 0xff) shl 16) or + ((src[offset + 2].toLong() and 0xff) shl 8) or + (src[offset + 3].toLong() and 0xff) + if (v > Int.MAX_VALUE) throw Nip44v3Exception("u32 value exceeds Int.MAX_VALUE: $v") + return v.toInt() + } + + private fun constantTimeEq(a: ByteArray, b: ByteArray): Boolean { + if (a.size != b.size) return false + var diff = 0 + for (i in a.indices) diff = diff or (a[i].toInt() xor b[i].toInt()) + return diff == 0 + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Storage.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Storage.kt new file mode 100644 index 00000000..22740958 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Storage.kt @@ -0,0 +1,141 @@ +package com.greenart7c3.nostrsigner.desktop.core + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import java.io.File +import kotlinx.coroutines.flow.MutableStateFlow + +private val mapper = jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + +private inline fun readJson(file: File): T? = try { + if (file.exists()) mapper.readValue(file.readText()) else null +} catch (e: Exception) { + AmberLogger.e("Storage", "Failed to read ${file.name}", e) + null +} + +private fun writeJson(file: File, value: Any) { + val tmp = File(file.parentFile, "${file.name}.tmp") + tmp.writeText(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(value)) + if (!tmp.renameTo(file)) { + // Windows can refuse an atomic replace; fall back to copy + delete. + file.writeText(tmp.readText()) + tmp.delete() + } + AppDirs.restrictToOwner(file) +} + +object AmberLogger { + fun d(tag: String, message: String) { + if (System.getenv("AMBER_DEBUG") != null) println("D/$tag: $message") + } + + fun e(tag: String, message: String, e: Throwable? = null) { + System.err.println("E/$tag: $message") + e?.printStackTrace() + } +} + +/** + * Per-account persistence: connected applications with their permissions, + * request history, and relay/bunker logs. The desktop app keeps everything + * in memory as [MutableStateFlow]s (so Compose observes changes directly) + * and writes JSON files under the account's data directory on every change. + */ +class AccountStore(val npub: String) { + private val dir = AppDirs.accountDir(npub) + private val appsFile = File(dir, "applications.json") + private val historyFile = File(dir, "history.json") + private val logsFile = File(dir, "logs.json") + + val apps = MutableStateFlow(readJson>(appsFile) ?: emptyList()) + val history = MutableStateFlow(readJson>(historyFile) ?: emptyList()) + val logs = MutableStateFlow(readJson>(logsFile) ?: emptyList()) + + fun getByKey(key: String): AppWithPermissions? = apps.value.firstOrNull { it.app.key == key } + + fun getBySecret(secret: String): AppWithPermissions? = apps.value.firstOrNull { it.app.secret == secret && it.app.useSecret } + + fun getPermission(key: String, type: String, kind: Int? = null): AppPermissionRecord? = getByKey(key)?.permissions?.firstOrNull { + it.type == type && it.kind == kind + } + + @Synchronized + fun upsert(app: AppWithPermissions) { + apps.value = apps.value.filter { it.app.key != app.app.key } + app + writeJson(appsFile, apps.value) + } + + @Synchronized + fun delete(key: String) { + apps.value = apps.value.filter { it.app.key != key } + history.value = history.value.filter { it.appKey != key } + writeJson(appsFile, apps.value) + writeJson(historyFile, history.value) + } + + @Synchronized + fun addHistory(record: HistoryRecord) { + history.value = (history.value + record).takeLast(MAX_HISTORY) + writeJson(historyFile, history.value) + } + + @Synchronized + fun addLog(url: String, type: String, message: String) { + AmberLogger.d("Amber", "$url: $message") + logs.value = (logs.value + LogRecord(url, type, message, System.currentTimeMillis())).takeLast(MAX_LOGS) + writeJson(logsFile, logs.value) + } + + @Synchronized + fun clearLogs() { + logs.value = emptyList() + writeJson(logsFile, logs.value) + } + + fun deleteAllFiles() { + dir.deleteRecursively() + } + + companion object { + private const val MAX_HISTORY = 1000 + private const val MAX_LOGS = 1000 + } +} + +/** Global (account-independent) settings persisted as plain JSON. */ +object SettingsStore { + private val file = File(AppDirs.dataDir, "settings.json") + val settings = MutableStateFlow(readJson(file) ?: DesktopSettings()) + + @Synchronized + fun update(transform: (DesktopSettings) -> DesktopSettings) { + settings.value = transform(settings.value) + writeJson(file, settings.value) + } +} + +/** + * Account list persistence. Only the private key (and optional seed words) + * are sensitive; they are encrypted with the keystore-held AES key before + * touching disk (see [DesktopKeyStore]). + */ +object AccountsStore { + private val file = File(AppDirs.dataDir, "accounts.json") + val accounts = MutableStateFlow(readJson>(file) ?: emptyList()) + + @Synchronized + fun upsert(record: AccountRecord) { + accounts.value = accounts.value.filter { it.npub != record.npub } + record + writeJson(file, accounts.value) + } + + @Synchronized + fun delete(npub: String) { + accounts.value = accounts.value.filter { it.npub != npub } + writeJson(file, accounts.value) + } + + fun get(npub: String): AccountRecord? = accounts.value.firstOrNull { it.npub == npub } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt new file mode 100644 index 00000000..62c1ee67 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt @@ -0,0 +1,158 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Apps +import androidx.compose.material.icons.filled.CellTower +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.Session +import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop +import com.greenart7c3.nostrsigner.desktop.core.toShortenHex + +sealed class Route(val title: String, val icon: ImageVector) { + data object IncomingRequest : Route("Incoming request", Icons.Default.Notifications) + data object Applications : Route("Applications", Icons.Default.Apps) + data object Relays : Route("Relays", Icons.Default.CellTower) + data object Settings : Route("Settings", Icons.Default.Settings) +} + +val bottomRoutes = listOf(Route.IncomingRequest, Route.Applications, Route.Relays, Route.Settings) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun App() { + val loading by Session.loading.collectAsState() + val account by Session.account.collectAsState() + val addingAccount by Session.addingAccount.collectAsState() + val pending by AmberDesktop.engine.pending.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + + var currentRoute by remember { mutableStateOf(Route.Applications) } + // null = list; non-null = detail screen for that application key + var selectedApplication by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + Toaster.messages.collect { snackbarHostState.showSnackbar(it) } + } + + // Jump to the approval screen whenever a new request arrives. + LaunchedEffect(pending.size) { + if (pending.isNotEmpty()) { + currentRoute = Route.IncomingRequest + } + } + + val acc = account + if (loading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return + } + + if (acc == null || addingAccount) { + LoginScreen( + hasAccounts = acc != null, + snackbarHostState = snackbarHostState, + ) + return + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surface), + title = { + val name by acc.name.collectAsState() + Text( + if (selectedApplication != null) { + "Permissions" + } else { + "${currentRoute.title} — ${name.ifBlank { acc.npub.toShortenHex() }}" + }, + ) + }, + ) + }, + bottomBar = { + NavigationBar { + bottomRoutes.forEach { route -> + NavigationBarItem( + selected = currentRoute == route && selectedApplication == null, + onClick = { + selectedApplication = null + currentRoute = route + }, + icon = { + if (route == Route.IncomingRequest && pending.isNotEmpty()) { + BadgedBox(badge = { Badge { Text("${pending.size}") } }) { + Icon(route.icon, route.title) + } + } else { + Icon(route.icon, route.title) + } + }, + label = { Text(route.title) }, + ) + } + } + }, + ) { padding -> + Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.TopCenter, + ) { + Box(Modifier.widthIn(max = 900.dp).padding(horizontal = 16.dp)) { + val selectedApp = selectedApplication + if (selectedApp != null) { + ApplicationDetailScreen( + account = acc, + appKey = selectedApp, + onBack = { selectedApplication = null }, + ) + } else { + when (currentRoute) { + Route.IncomingRequest -> IncomingRequestsScreen(account = acc) + Route.Applications -> ApplicationsScreen( + account = acc, + onOpenApplication = { selectedApplication = it }, + ) + + Route.Relays -> RelaysScreen() + Route.Settings -> SettingsScreen(account = acc) + } + } + } + } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt new file mode 100644 index 00000000..6105334f --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt @@ -0,0 +1,205 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop +import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount +import com.greenart7c3.nostrsigner.desktop.core.RememberType +import com.greenart7c3.nostrsigner.desktop.core.toShortenHex +import com.vitorpamplona.quartz.utils.TimeUtils +import java.text.DateFormat +import java.util.Date +import kotlinx.coroutines.launch + +@Composable +fun ApplicationDetailScreen( + account: DesktopAccount, + appKey: String, + onBack: () -> Unit, +) { + val store = AmberDesktop.store(account.npub) + val apps by store.apps.collectAsState() + val history by store.history.collectAsState() + val app = apps.firstOrNull { it.app.key == appKey } + val scope = rememberCoroutineScope() + var tab by remember { mutableStateOf(0) } + + if (app == null) { + onBack() + return + } + + var name by remember { mutableStateOf(app.app.name) } + + Column(Modifier.fillMaxSize()) { + Spacer(Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") + } + Text( + app.app.displayName(), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } + Text("Key: ${app.app.key.toShortenHex()}", style = MaterialTheme.typography.bodySmall) + if (app.app.relays.isNotEmpty()) { + Text("Relays: ${app.app.relays.joinToString()}", style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(8.dp)) + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + modifier = Modifier.weight(1f), + ) + AmberOutlinedButton( + modifier = Modifier.weight(0.4f), + text = "Save", + onClick = { + store.upsert(app.copy(app = app.app.copy(name = name))) + Toaster.toast("Application updated") + }, + ) + } + Spacer(Modifier.height(8.dp)) + + TabRow(selectedTabIndex = tab) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Permissions") }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Activity") }) + } + Spacer(Modifier.height(8.dp)) + + if (tab == 0) { + LazyColumn( + Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(bottom = 8.dp), + ) { + items(app.permissions.size) { index -> + val permission = app.permissions[index] + Card(Modifier.fillMaxWidth()) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + permission.type + (permission.kind?.let { " (kind $it)" } ?: ""), + style = MaterialTheme.typography.bodyMedium, + ) + val until = if (permission.acceptable) permission.acceptUntil else permission.rejectUntil + val untilLabel = when { + until >= Long.MAX_VALUE / 1000 -> "always" + until > TimeUtils.now() -> "until ${DateFormat.getDateTimeInstance().format(Date(until * 1000))}" + else -> "expired" + } + Text( + (if (permission.acceptable) "Accept" else "Reject") + " · $untilLabel", + style = MaterialTheme.typography.bodySmall, + ) + } + Switch( + checked = permission.acceptable, + onCheckedChange = { accepted -> + val newPermissions = app.permissions.toMutableList() + newPermissions[index] = permission.copy( + acceptable = accepted, + acceptUntil = if (accepted) RememberType.ALWAYS.acceptUntil() else 0, + rejectUntil = if (accepted) 0 else RememberType.ALWAYS.acceptUntil(), + ) + store.upsert(app.copy(permissions = newPermissions)) + }, + ) + IconButton( + onClick = { + val newPermissions = app.permissions.toMutableList() + newPermissions.removeAt(index) + store.upsert(app.copy(permissions = newPermissions)) + }, + ) { + Icon(Icons.Default.Delete, "Delete permission") + } + } + } + } + } + } else { + val appHistory = history.filter { it.appKey == appKey }.sortedByDescending { it.time } + LazyColumn( + Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + contentPadding = PaddingValues(bottom = 8.dp), + ) { + if (appHistory.isEmpty()) { + item { + Text("No activity yet", style = MaterialTheme.typography.bodyMedium) + } + } + items(appHistory.size) { index -> + val entry = appHistory[index] + Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text( + entry.type + (entry.kind?.let { " (kind $it)" } ?: ""), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + "${DateFormat.getDateTimeInstance().format(Date(entry.time * 1000))} · " + + if (entry.accepted) "accepted" else "rejected", + style = MaterialTheme.typography.bodySmall, + ) + HorizontalDivider() + } + } + } + } + + AmberButton( + text = "Disconnect and delete application", + onClick = { + scope.launch { + store.delete(appKey) + AmberDesktop.engine.updateFilter() + Toaster.toast("Application removed") + onBack() + } + }, + ) + Spacer(Modifier.height(12.dp)) + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationsScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationsScreen.kt new file mode 100644 index 00000000..dcdb7b2c --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationsScreen.kt @@ -0,0 +1,223 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop +import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount +import java.text.DateFormat +import java.util.Date +import kotlinx.coroutines.launch + +@Composable +fun ApplicationsScreen( + account: DesktopAccount, + onOpenApplication: (String) -> Unit, +) { + val store = AmberDesktop.store(account.npub) + val apps by store.apps.collectAsState() + var showNostrConnectDialog by remember { mutableStateOf(false) } + var showBunkerDialog by remember { mutableStateOf(false) } + + Column(Modifier.fillMaxSize()) { + Spacer(Modifier.height(12.dp)) + AmberButton(text = "Add an application with nostrconnect://", onClick = { showNostrConnectDialog = true }) + AmberButton(text = "Add a nsecBunker (bunker:// URI)", onClick = { showBunkerDialog = true }) + Spacer(Modifier.height(12.dp)) + + if (apps.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + "No applications connected yet", + style = MaterialTheme.typography.titleMedium, + ) + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(bottom = 12.dp), + ) { + val sorted = apps.sortedByDescending { it.app.lastUsed } + items(sorted.size, key = { sorted[it].app.key }) { index -> + val app = sorted[index] + Card( + Modifier.fillMaxWidth().clickable { onOpenApplication(app.app.key) }, + ) { + Column(Modifier.padding(12.dp)) { + Text( + app.app.displayName(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + if (app.app.isConnected) "Connected" else "Waiting for the app to connect", + style = MaterialTheme.typography.bodySmall, + ) + if (app.app.lastUsed > 0) { + Text( + "Last used: ${DateFormat.getDateTimeInstance().format(Date(app.app.lastUsed * 1000))}", + style = MaterialTheme.typography.bodySmall, + ) + } + Text( + "${app.permissions.size} permissions", + style = MaterialTheme.typography.bodySmall, + ) + } + } + } + } + } + } + + if (showNostrConnectDialog) { + NostrConnectDialog(account) { showNostrConnectDialog = false } + } + if (showBunkerDialog) { + NewBunkerDialog(account) { showBunkerDialog = false } + } +} + +@Composable +private fun NostrConnectDialog( + account: DesktopAccount, + onDismiss: () -> Unit, +) { + var uri by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add an application") }, + text = { + Column { + Text("Paste the nostrconnect:// URI shown by the application.") + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = uri, + onValueChange = { uri = it }, + label = { Text("nostrconnect://…") }, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { + scope.launch { + if (!uri.trim().startsWith("nostrconnect://")) { + Toaster.toast("Invalid nostrconnect URI") + return@launch + } + val error = AmberDesktop.engine.addNostrConnect(uri, account) + if (error != null) { + Toaster.toast(error) + } else { + onDismiss() + } + } + }, + ) { Text("Add") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +@Composable +private fun NewBunkerDialog( + account: DesktopAccount, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf("") } + var bunkerUri by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val clipboard = LocalClipboardManager.current + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(if (bunkerUri == null) "Add a nsecBunker" else "Bunker connection created") }, + text = { + Column( + Modifier.verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val uri = bunkerUri + if (uri == null) { + Text("Creates a bunker:// URI you can paste (or scan) in the client application.") + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Application name") }, + modifier = Modifier.fillMaxWidth(), + ) + } else { + QrCodeImage(uri) + Spacer(Modifier.height(8.dp)) + Text(uri, style = MaterialTheme.typography.bodySmall) + } + } + }, + confirmButton = { + val uri = bunkerUri + if (uri == null) { + TextButton( + onClick = { + if (name.isBlank()) { + Toaster.toast("Name is required") + return@TextButton + } + scope.launch { + bunkerUri = AmberDesktop.engine.createBunkerConnection( + account, + name, + AmberDesktop.defaultRelays(), + ) + } + }, + ) { Text("Create") } + } else { + TextButton( + onClick = { + clipboard.setText(AnnotatedString(uri)) + Toaster.toast("Copied to the clipboard") + }, + ) { Text("Copy") } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(if (bunkerUri == null) "Cancel" else "Close") } + }, + ) +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Components.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Components.kt new file mode 100644 index 00000000..dbcbb967 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Components.kt @@ -0,0 +1,131 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.greenart7c3.nostrsigner.desktop.core.RememberType +import com.greenart7c3.nostrsigner.desktop.core.rememberTypeDisplayOrder +import java.awt.image.BufferedImage +import kotlinx.coroutines.flow.MutableSharedFlow + +/** Global snackbar feed, the desktop stand-in for the mobile ToastManager. */ +object Toaster { + val messages = MutableSharedFlow(extraBufferCapacity = 8) + + fun toast(message: String) { + messages.tryEmit(message) + } +} + +/** Mirrors the mobile AmberButton style. */ +@Composable +fun AmberButton( + modifier: Modifier = Modifier, + text: String, + enabled: Boolean = true, + onClick: () -> Unit, +) { + Button( + onClick = onClick, + shape = ButtonBorder, + enabled = enabled, + colors = ButtonDefaults.buttonColors(containerColor = orange, contentColor = Color.White), + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Text(text) + } +} + +@Composable +fun AmberOutlinedButton( + modifier: Modifier = Modifier, + text: String, + onClick: () -> Unit, +) { + OutlinedButton( + onClick = onClick, + shape = ButtonBorder, + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + ) { + Text(text) + } +} + +/** "Remember my choice" selector shared by the approval cards. */ +@Composable +fun RememberTypeSelector( + value: RememberType, + onValueChange: (RememberType) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + OutlinedButton( + onClick = { expanded = true }, + shape = ButtonBorder, + ) { + Text("Remember: ${value.label}", style = MaterialTheme.typography.bodySmall) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + ) { + rememberTypeDisplayOrder.forEach { type -> + DropdownMenuItem( + text = { Text(type.label) }, + onClick = { + onValueChange(type) + expanded = false + }, + ) + } + } +} + +@Composable +fun QrCodeImage( + content: String, + modifier: Modifier = Modifier, + size: Dp = 300.dp, +) { + val image = remember(content) { + val matrix = QRCodeWriter().encode( + content, + BarcodeFormat.QR_CODE, + 768, + 768, + mapOf(EncodeHintType.MARGIN to 1), + ) + val bufferedImage = BufferedImage(matrix.width, matrix.height, BufferedImage.TYPE_INT_RGB) + for (x in 0 until matrix.width) { + for (y in 0 until matrix.height) { + bufferedImage.setRGB(x, y, if (matrix.get(x, y)) 0x000000 else 0xFFFFFF) + } + } + bufferedImage.toComposeImageBitmap() + } + Image( + bitmap = image, + contentDescription = "QR code", + modifier = modifier.size(size), + ) +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/IncomingRequestsScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/IncomingRequestsScreen.kt new file mode 100644 index 00000000..e26738b8 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/IncomingRequestsScreen.kt @@ -0,0 +1,167 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop +import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount +import com.greenart7c3.nostrsigner.desktop.core.PendingBunkerRequest +import com.greenart7c3.nostrsigner.desktop.core.RememberType +import com.greenart7c3.nostrsigner.desktop.core.SignerType +import com.greenart7c3.nostrsigner.desktop.core.describe +import com.greenart7c3.nostrsigner.desktop.core.toShortenHex +import kotlinx.coroutines.launch + +@Composable +fun IncomingRequestsScreen(account: DesktopAccount) { + val pending by AmberDesktop.engine.pending.collectAsState() + + if (pending.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("No requests waiting for approval", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + Text( + "Connect an application from the Applications tab and its signing requests will show up here.", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + return + } + + LazyColumn( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp), + ) { + items(pending.size, key = { pending[it].request.id }) { index -> + RequestCard(pending[index]) + } + } +} + +@Composable +private fun RequestCard(req: PendingBunkerRequest) { + val scope = rememberCoroutineScope() + var rememberType by remember { mutableStateOf(RememberType.NEVER) } + var working by remember { mutableStateOf(false) } + val grantedPermissions = remember(req.request.id) { req.requestedPermissions.map { it.copy() } } + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text( + req.appName.ifBlank { req.localKey.toShortenHex() }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + if (req.appUrl.isNotBlank()) { + Text(req.appUrl, style = MaterialTheme.typography.bodySmall) + } + Spacer(Modifier.height(4.dp)) + Text(req.type.describe(req.kind), style = MaterialTheme.typography.bodyLarge) + Text( + "Account: ${req.account.npub.toShortenHex()}", + style = MaterialTheme.typography.bodySmall, + ) + + if (req.preview.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Card(Modifier.fillMaxWidth()) { + Text( + req.preview, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + modifier = Modifier + .heightIn(max = 200.dp) + .verticalScroll(rememberScrollState()) + .padding(8.dp), + ) + } + } + + if (req.type == SignerType.CONNECT && grantedPermissions.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + Text("Requested permissions", style = MaterialTheme.typography.titleSmall) + grantedPermissions.forEach { perm -> + var checked by remember { mutableStateOf(perm.checked) } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = checked, + onCheckedChange = { + checked = it + perm.checked = it + }, + ) + Text( + perm.type + (perm.kind?.let { " (kind $it)" } ?: ""), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + Spacer(Modifier.height(12.dp)) + if (req.type != SignerType.CONNECT) { + RememberTypeSelector(rememberType) { rememberType = it } + Spacer(Modifier.height(8.dp)) + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + AmberButton( + modifier = Modifier.weight(1f), + text = if (working) "Working…" else "Approve", + enabled = !working, + onClick = { + working = true + scope.launch { + AmberDesktop.engine.approve( + req, + if (req.type == SignerType.CONNECT) RememberType.ALWAYS else rememberType, + grantedPermissions, + ) + Toaster.toast("Request approved") + } + }, + ) + AmberOutlinedButton( + modifier = Modifier.weight(1f), + text = "Reject", + onClick = { + working = true + scope.launch { + AmberDesktop.engine.reject(req, rememberType) + Toaster.toast("Request rejected") + } + }, + ) + } + } + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/LoginScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/LoginScreen.kt new file mode 100644 index 00000000..7e57f131 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/LoginScreen.kt @@ -0,0 +1,211 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.Session +import com.greenart7c3.nostrsigner.desktop.core.AccountManager +import kotlinx.coroutines.launch + +@Composable +fun LoginScreen( + hasAccounts: Boolean, + snackbarHostState: SnackbarHostState, +) { + val scope = rememberCoroutineScope() + var tab by remember { mutableStateOf(0) } + + LaunchedEffect(Unit) { + Toaster.messages.collect { snackbarHostState.showSnackbar(it) } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.TopCenter, + ) { + Column( + Modifier.widthIn(max = 560.dp).padding(24.dp).verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Amber", + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + ) + Text( + "Nostr event signer", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + + TabRow(selectedTabIndex = tab) { + Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("Add a key") }) + Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("Create a new key") }) + } + Spacer(Modifier.height(24.dp)) + + if (tab == 0) { + ImportKeyPane(scope) + } else { + NewKeyPane(scope) + } + + if (hasAccounts) { + TextButton(onClick = { Session.addingAccount.value = false }) { + Text("Cancel") + } + } + } + } + } +} + +@Composable +private fun ImportKeyPane(scope: kotlinx.coroutines.CoroutineScope) { + var key by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var working by remember { mutableStateOf(false) } + + OutlinedTextField( + value = key, + onValueChange = { key = it }, + label = { Text("nsec, ncryptsec, hex key or seed words") }, + modifier = Modifier.fillMaxWidth(), + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + if (key.trim().startsWith("ncryptsec")) { + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + modifier = Modifier.fillMaxWidth(), + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + ) + } + Spacer(Modifier.height(16.dp)) + AmberButton( + text = if (working) "Adding…" else "Add key", + enabled = key.isNotBlank() && !working, + onClick = { + working = true + scope.launch { + AccountManager.parseKey(key, password).fold( + onSuccess = { keyPair -> + val account = AccountManager.addAccount( + keyPair = keyPair, + seedWords = if (key.trim().contains(" ")) key.trim() else "", + didBackup = true, + ) + Session.onAccountAdded(account) + }, + onFailure = { + Toaster.toast(it.message ?: "Invalid key") + }, + ) + working = false + } + }, + ) +} + +@Composable +private fun NewKeyPane(scope: kotlinx.coroutines.CoroutineScope) { + var name by remember { mutableStateOf("") } + var working by remember { mutableStateOf(false) } + val seedWords = remember { AccountManager.generateSeedWords() } + + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name (optional)") }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + Text("Your seed words", style = MaterialTheme.typography.titleMedium) + Text( + "Write them down and keep them safe. They are the only backup of your new key.", + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(8.dp)) + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + seedWords.chunked(4).forEachIndexed { rowIndex, row -> + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + row.forEachIndexed { index, word -> + Text( + "${rowIndex * 4 + index + 1}. $word", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + } + } + } + } + } + Spacer(Modifier.height(16.dp)) + AmberButton( + text = if (working) "Creating…" else "Create account", + enabled = !working, + onClick = { + working = true + scope.launch { + AccountManager.parseKey(seedWords.joinToString(" ")).fold( + onSuccess = { keyPair -> + val account = AccountManager.addAccount( + keyPair = keyPair, + name = name, + seedWords = seedWords.joinToString(" "), + didBackup = false, + ) + Session.onAccountAdded(account) + }, + onFailure = { + Toaster.toast(it.message ?: "Failed to create the key") + }, + ) + working = false + } + }, + ) +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/RelaysScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/RelaysScreen.kt new file mode 100644 index 00000000..b49a642f --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/RelaysScreen.kt @@ -0,0 +1,125 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop +import com.greenart7c3.nostrsigner.desktop.core.SettingsStore +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlinx.coroutines.launch + +@Composable +fun RelaysScreen() { + val settings by SettingsStore.settings.collectAsState() + var newRelay by remember { mutableStateOf("") } + val scope = rememberCoroutineScope() + + Column(Modifier.fillMaxSize()) { + Spacer(Modifier.height(12.dp)) + Text("Default bunker relays", style = MaterialTheme.typography.titleMedium) + Text( + "New bunker connections listen and respond on these relays.", + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(8.dp)) + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = newRelay, + onValueChange = { newRelay = it }, + label = { Text("wss://relay.example.com") }, + modifier = Modifier.weight(1f), + ) + AmberOutlinedButton( + modifier = Modifier.weight(0.3f), + text = "Add", + onClick = { + val normalized = RelayUrlNormalizer.normalizeOrNull(newRelay.trim()) + if (normalized == null) { + Toaster.toast("Invalid relay URL") + return@AmberOutlinedButton + } + SettingsStore.update { + it.copy(defaultRelays = (it.defaultRelays + normalized.url).distinct()) + } + newRelay = "" + scope.launch { + AmberDesktop.engine.updateFilter() + AmberDesktop.client.connect() + } + }, + ) + } + Spacer(Modifier.height(8.dp)) + + LazyColumn( + Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(bottom = 8.dp), + ) { + items(settings.defaultRelays.size) { index -> + val relay = settings.defaultRelays[index] + Card(Modifier.fillMaxWidth()) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(relay, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) + IconButton( + onClick = { + if (settings.defaultRelays.size == 1) { + Toaster.toast("At least one relay is required") + return@IconButton + } + SettingsStore.update { + it.copy(defaultRelays = it.defaultRelays.filter { url -> url != relay }) + } + scope.launch { AmberDesktop.engine.updateFilter() } + }, + ) { + Icon(Icons.Default.Delete, "Remove relay") + } + } + } + } + } + + AmberButton( + text = "Reconnect relays", + onClick = { + scope.launch { + AmberDesktop.engine.updateFilter() + AmberDesktop.client.connect() + AmberDesktop.client.reconnect(true) + Toaster.toast("Reconnecting…") + } + }, + ) + Spacer(Modifier.height(12.dp)) + } +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt new file mode 100644 index 00000000..77f757d7 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt @@ -0,0 +1,306 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.Session +import com.greenart7c3.nostrsigner.desktop.core.AccountManager +import com.greenart7c3.nostrsigner.desktop.core.AccountsStore +import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop +import com.greenart7c3.nostrsigner.desktop.core.DesktopAccount +import com.greenart7c3.nostrsigner.desktop.core.SettingsStore +import com.greenart7c3.nostrsigner.desktop.core.toShortenHex +import java.text.DateFormat +import java.util.Date +import kotlinx.coroutines.launch + +@Composable +fun SettingsScreen(account: DesktopAccount) { + val scope = rememberCoroutineScope() + val settings by SettingsStore.settings.collectAsState() + val accounts by AccountsStore.accounts.collectAsState() + var showBackupDialog by remember { mutableStateOf(false) } + var showLogsDialog by remember { mutableStateOf(false) } + var showLogoutConfirm by remember { mutableStateOf(null) } + var name by remember { mutableStateOf(account.name.value) } + + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + Spacer(Modifier.height(12.dp)) + + SectionTitle("Account") + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + modifier = Modifier.weight(1f), + ) + AmberOutlinedButton( + modifier = Modifier.weight(0.4f), + text = "Save", + onClick = { + account.name.value = name + Session.saveMeta(account) + Toaster.toast("Saved") + }, + ) + } + Text("Public key: ${account.npub}", style = MaterialTheme.typography.bodySmall) + Spacer(Modifier.height(8.dp)) + AmberButton(text = "Backup keys", onClick = { showBackupDialog = true }) + + Spacer(Modifier.height(16.dp)) + SectionTitle("Sign policy") + Text( + "Default policy applied to newly connected applications.", + style = MaterialTheme.typography.bodySmall, + ) + Spacer(Modifier.height(4.dp)) + val policies = listOf( + 0 to "Basic permissions", + 1 to "Approve requested permissions", + 2 to "Sign everything", + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + policies.forEach { (value, label) -> + FilterChip( + selected = account.signPolicy == value, + onClick = { + account.signPolicy = value + Session.saveMeta(account) + }, + label = { Text(label) }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + SectionTitle("Theme") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf>(null to "System", false to "Light", true to "Dark").forEach { (value, label) -> + FilterChip( + selected = settings.darkTheme == value, + onClick = { SettingsStore.update { it.copy(darkTheme = value) } }, + label = { Text(label) }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + SectionTitle("Accounts") + accounts.forEach { record -> + Card(Modifier.fillMaxWidth().padding(vertical = 2.dp)) { + Row( + Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + record.name.ifBlank { record.npub.toShortenHex() }, + style = MaterialTheme.typography.bodyMedium, + ) + if (record.npub == account.npub) { + Text("Active", style = MaterialTheme.typography.bodySmall) + } + } + if (record.npub != account.npub) { + TextButton(onClick = { scope.launch { Session.switchTo(record.npub) } }) { + Text("Switch") + } + } + TextButton(onClick = { showLogoutConfirm = record.npub }) { + Text("Log out") + } + } + } + } + AmberOutlinedButton(text = "Add an account", onClick = { Session.addingAccount.value = true }) + + Spacer(Modifier.height(16.dp)) + SectionTitle("Diagnostics") + AmberOutlinedButton(text = "View logs", onClick = { showLogsDialog = true }) + Spacer(Modifier.height(24.dp)) + } + + if (showBackupDialog) { + BackupDialog(account) { showBackupDialog = false } + } + if (showLogsDialog) { + LogsDialog(account) { showLogsDialog = false } + } + showLogoutConfirm?.let { npub -> + AlertDialog( + onDismissRequest = { showLogoutConfirm = null }, + title = { Text("Log out?") }, + text = { Text("This deletes the account key and its connections from this device. Make sure the key is backed up.") }, + confirmButton = { + TextButton( + onClick = { + showLogoutConfirm = null + scope.launch { Session.logout(npub) } + }, + ) { Text("Log out") } + }, + dismissButton = { + TextButton(onClick = { showLogoutConfirm = null }) { Text("Cancel") } + }, + ) + } +} + +@Composable +private fun SectionTitle(text: String) { + Text(text, style = MaterialTheme.typography.titleMedium) + HorizontalDivider(Modifier.padding(vertical = 4.dp)) +} + +@Composable +private fun BackupDialog( + account: DesktopAccount, + onDismiss: () -> Unit, +) { + val clipboard = LocalClipboardManager.current + val scope = rememberCoroutineScope() + var showSecret by remember { mutableStateOf(false) } + var password by remember { mutableStateOf("") } + var ncryptsec by remember { mutableStateOf("") } + var seedWords by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Backup keys") }, + text = { + Column(Modifier.verticalScroll(rememberScrollState())) { + Text("Secret key (nsec)", style = MaterialTheme.typography.titleSmall) + if (showSecret) { + Text(account.getNsec(), fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) + if (seedWords.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Text("Seed words", style = MaterialTheme.typography.titleSmall) + Text(seedWords, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall) + } + } else { + TextButton( + onClick = { + showSecret = true + scope.launch { seedWords = AccountManager.seedWords(account.npub) } + }, + ) { Text("Show") } + } + Row { + TextButton( + onClick = { + clipboard.setText(AnnotatedString(account.getNsec())) + account.didBackup = true + Session.saveMeta(account) + Toaster.toast("Secret key copied. Clear your clipboard after pasting it!") + }, + ) { Text("Copy nsec") } + } + + Spacer(Modifier.height(12.dp)) + Text("Encrypted backup (ncryptsec)", style = MaterialTheme.typography.titleSmall) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + if (ncryptsec.isNotBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + ncryptsec, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.heightIn(max = 90.dp).verticalScroll(rememberScrollState()), + ) + } + TextButton( + onClick = { + if (password.isBlank()) { + Toaster.toast("Password is required") + return@TextButton + } + ncryptsec = account.nip49Encrypt(password) + clipboard.setText(AnnotatedString(ncryptsec)) + account.didBackup = true + Session.saveMeta(account) + Toaster.toast("Encrypted key copied to the clipboard") + }, + ) { Text("Encrypt and copy") } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Close") } + }, + ) +} + +@Composable +private fun LogsDialog( + account: DesktopAccount, + onDismiss: () -> Unit, +) { + val store = AmberDesktop.store(account.npub) + val logs by store.logs.collectAsState() + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Logs") }, + text = { + Column(Modifier.heightIn(max = 420.dp).verticalScroll(rememberScrollState())) { + if (logs.isEmpty()) { + Text("No logs") + } + logs.sortedByDescending { it.time }.forEach { log -> + Text( + "${DateFormat.getDateTimeInstance().format(Date(log.time))} · ${log.type} · ${log.url}", + style = MaterialTheme.typography.bodySmall, + ) + Text(log.message, style = MaterialTheme.typography.bodyMedium) + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + } + } + }, + confirmButton = { + TextButton( + onClick = { + store.clearLogs() + }, + ) { Text("Clear") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Close") } + }, + ) +} diff --git a/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Theme.kt b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Theme.kt new file mode 100644 index 00000000..8a6bcb86 --- /dev/null +++ b/desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Theme.kt @@ -0,0 +1,60 @@ +package com.greenart7c3.nostrsigner.desktop.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.greenart7c3.nostrsigner.desktop.core.SettingsStore + +// Mirrors the mobile theme (app/ui/theme/Theme.kt). +val Shapes = Shapes( + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(4.dp), + large = RoundedCornerShape(0.dp), +) + +val ButtonBorder = RoundedCornerShape(20.dp) + +val primaryColor = Color(0xFFFFCA62) +val primaryVariant = Color(0xFFC8541A) +val secondaryColor = Color(0xFFFFCA62) +val orange = Color(0xFFFF6B00) + +private val DarkColorPalette = darkColorScheme( + primary = primaryColor, + onPrimary = Color.White, + secondary = primaryVariant, + tertiary = secondaryColor, + primaryContainer = secondaryColor, + secondaryContainer = secondaryColor, +) + +private val LightColorPalette = lightColorScheme( + primary = primaryColor, + secondary = primaryVariant, + tertiary = secondaryColor, + primaryContainer = secondaryColor, + secondaryContainer = secondaryColor, + surface = Color(0xFFFFDE9E), + surfaceContainer = Color(0xFFFFDE9E), +) + +@Composable +fun NostrSignerTheme(content: @Composable () -> Unit) { + val settings by SettingsStore.settings.collectAsState() + val darkTheme = settings.darkTheme ?: isSystemInDarkTheme() + val colors = if (darkTheme) DarkColorPalette else LightColorPalette + + MaterialTheme( + colorScheme = colors, + shapes = Shapes, + content = content, + ) +} diff --git a/desktop/src/main/resources/icon.png b/desktop/src/main/resources/icon.png new file mode 100644 index 00000000..0403d946 Binary files /dev/null and b/desktop/src/main/resources/icon.png differ diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/BunkerE2eTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/BunkerE2eTest.kt new file mode 100644 index 00000000..0da3edec --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/BunkerE2eTest.kt @@ -0,0 +1,170 @@ +package com.greenart7c3.nostrsigner.desktop + +import com.greenart7c3.nostrsigner.desktop.core.AccountManager +import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop +import com.greenart7c3.nostrsigner.desktop.core.RememberType +import com.greenart7c3.nostrsigner.desktop.core.SettingsStore +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener +import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import java.io.File +import java.util.UUID +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient +import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue +import org.junit.BeforeClass +import org.junit.Test + +/** + * Full NIP-46 round-trip over a real public relay: the desktop engine plays + * the signer role while this test plays the client app (connect via a + * bunker:// URI, then get_public_key auto-approved by the granted + * permissions). Network-dependent, so it only runs when AMBER_E2E is set. + */ +class BunkerE2eTest { + companion object { + @JvmStatic + @BeforeClass + fun isolateDataDir() { + val tmp = File.createTempFile("amber-e2e", "").apply { + delete() + mkdirs() + deleteOnExit() + } + System.setProperty("user.home", tmp.absolutePath) + } + } + + private class Client(relay: NormalizedRelayUrl, val keyPair: KeyPair) { + val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val signer = NostrSignerInternal(keyPair) + val responses = MutableStateFlow>(emptyList()) + + private val httpClient = OkHttpClient.Builder().pingInterval(10, TimeUnit.SECONDS).build() + val client = NostrClient( + object : WebsocketBuilder { + override fun build(url: NormalizedRelayUrl, out: WebSocketListener) = BasicOkHttpWebSocket(url, { httpClient }, out) + }, + scope, + ) + + init { + client.addConnectionListener( + object : RelayConnectionListener { + override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) { + if (msg is EventMessage && msg.event.kind == NostrConnectEvent.KIND) { + scope.launch { + runCatching { + val decrypted = signer.decrypt(msg.event.content, msg.event.pubKey) + val response = JacksonMapper.mapper.readValue(decrypted, BunkerResponse::class.java) + responses.value = responses.value + response + } + } + } + } + }, + ) + client.subscribe( + UUID.randomUUID().toString(), + mapOf( + relay to listOf( + Filter( + kinds = listOf(NostrConnectEvent.KIND), + tags = mapOf("p" to listOf(signer.keyPair.pubKey.toHexKey())), + since = TimeUtils.now() - 5, + ), + ), + ), + ) + client.connect() + } + + suspend fun send(signerPubKey: String, relay: NormalizedRelayUrl, requestJson: String): Boolean { + val encrypted = signer.nip44Encrypt(requestJson, signerPubKey) + val event = signer.signerSync.sign( + TimeUtils.now(), + NostrConnectEvent.KIND, + arrayOf(arrayOf("p", signerPubKey)), + encrypted, + ) + return client.publishAndConfirm(event, setOf(relay), timeoutInSeconds = 10) + } + + fun stop() { + client.disconnect() + scope.cancel() + } + } + + @Test + fun bunkerConnectAndGetPublicKeyOverRelay() = runBlocking { + assumeTrue("Set AMBER_E2E=1 to run the relay round-trip test", System.getenv("AMBER_E2E") != null) + + val relay = RelayUrlNormalizer.normalize("wss://nos.lol/") + SettingsStore.update { it.copy(defaultRelays = listOf(relay.url)) } + + // Signer side. + val account = AccountManager.addAccount(KeyPair(), name = "e2e") + val engine = AmberDesktop.engine + engine.start() + val bunkerUri = engine.createBunkerConnection(account, "e2e-app", listOf(relay)) + val signerPubKey = bunkerUri.removePrefix("bunker://").substringBefore("?") + val secret = bunkerUri.substringAfter("secret=") + + // Client side. + val client = Client(relay, KeyPair()) + delay(3000) // let both subscriptions settle + + // 1. connect — requires the user's approval in the UI. + val connectJson = """{"id":"e2e-connect","method":"connect","params":["$signerPubKey","$secret"]}""" + assertEquals(true, client.send(signerPubKey, relay, connectJson)) + + withTimeout(30_000) { engine.pending.first { it.isNotEmpty() } } + val pendingRequest = engine.pending.value.first() + assertEquals("e2e-connect", pendingRequest.request.id) + engine.approve(pendingRequest, RememberType.ALWAYS) + + val ack = withTimeout(30_000) { + client.responses.first { list -> list.any { it.id == "e2e-connect" } } + }.first { it.id == "e2e-connect" } + assertEquals("ack", ack.result) + + // 2. get_public_key — granted automatically on connect, no UI involved. + val gpkJson = """{"id":"e2e-gpk","method":"get_public_key","params":[]}""" + assertEquals(true, client.send(signerPubKey, relay, gpkJson)) + + val gpk = withTimeout(30_000) { + client.responses.first { list -> list.any { it.id == "e2e-gpk" } } + }.first { it.id == "e2e-gpk" } + assertEquals(account.hexKey, gpk.result) + + client.stop() + } +} diff --git a/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/DesktopCoreTest.kt b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/DesktopCoreTest.kt new file mode 100644 index 00000000..dc881e15 --- /dev/null +++ b/desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/DesktopCoreTest.kt @@ -0,0 +1,103 @@ +package com.greenart7c3.nostrsigner.desktop + +import com.greenart7c3.nostrsigner.desktop.core.AccountManager +import com.greenart7c3.nostrsigner.desktop.core.AppPermissionRecord +import com.greenart7c3.nostrsigner.desktop.core.BunkerEngine +import com.greenart7c3.nostrsigner.desktop.core.DesktopKeyStore +import com.greenart7c3.nostrsigner.desktop.core.RememberType +import com.greenart7c3.nostrsigner.desktop.core.SignerType +import com.greenart7c3.nostrsigner.desktop.core.generateBunkerPrivKey +import com.greenart7c3.nostrsigner.desktop.core.isRemembered +import com.greenart7c3.nostrsigner.desktop.core.localPubKeyFromPrivKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import com.vitorpamplona.quartz.utils.TimeUtils +import java.io.File +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.Test + +class DesktopCoreTest { + companion object { + @JvmStatic + @BeforeClass + fun isolateDataDir() { + // Point the app data dir at a scratch location so tests never touch + // a real install. + val tmp = File.createTempFile("amber-test", "").apply { + delete() + mkdirs() + deleteOnExit() + } + System.setProperty("user.home", tmp.absolutePath) + } + } + + @Test + fun keystoreEncryptsAndDecrypts() = runBlocking { + val secret = "nsec-super-secret-payload" + val encrypted = DesktopKeyStore.encrypt(secret) + assertTrue(encrypted != secret) + assertEquals(secret, DesktopKeyStore.decrypt(encrypted)) + } + + @Test + fun parsesHexAndNsecKeys() { + val hex = generateBunkerPrivKey() + val fromHex = AccountManager.parseKey(hex).getOrThrow() + assertEquals(hex, fromHex.privKey!!.toHexKey()) + + val nsec = fromHex.privKey!!.toNsec() + val fromNsec = AccountManager.parseKey(nsec).getOrThrow() + assertEquals(hex, fromNsec.privKey!!.toHexKey()) + } + + @Test + fun rejectsGarbageKeys() { + assertTrue(AccountManager.parseKey("not-a-key").isFailure) + } + + @Test + fun generatedSeedWordsProduceAKey() { + val words = AccountManager.generateSeedWords() + assertEquals(12, words.size) + val keyPair = AccountManager.parseKey(words.joinToString(" ")).getOrThrow() + assertNotNull(keyPair.privKey) + } + + @Test + fun localPubKeyDerivation() { + val priv = generateBunkerPrivKey() + val pub = localPubKeyFromPrivKey(priv) + assertEquals(64, pub.length) + } + + @Test + fun isRememberedMirrorsAndroidBehavior() { + // Sign policy 2 always auto-accepts. + assertEquals(true, isRemembered(2, null)) + // No stored permission -> ask the user. + assertNull(isRemembered(1, null)) + + val accepted = AppPermissionRecord("SIGN_EVENT", 1, true, RememberType.ALWAYS.screenCode, Long.MAX_VALUE / 1000, 0) + assertEquals(true, isRemembered(1, accepted)) + + val rejected = AppPermissionRecord("SIGN_EVENT", 1, false, RememberType.ALWAYS.screenCode, 0, Long.MAX_VALUE / 1000) + assertEquals(false, isRemembered(1, rejected)) + + val expired = AppPermissionRecord("SIGN_EVENT", 1, true, RememberType.FIVE_MINUTES.screenCode, TimeUtils.now() - 10, 0) + assertNull(isRemembered(1, expired)) + } + + @Test + fun bunkerMethodMapping() { + assertEquals(SignerType.CONNECT, BunkerEngine.typeFromMethod("connect")) + assertEquals(SignerType.SIGN_EVENT, BunkerEngine.typeFromMethod("sign_event")) + assertEquals(SignerType.NIP44_DECRYPT, BunkerEngine.typeFromMethod("nip44_decrypt")) + assertEquals(SignerType.INVALID, BunkerEngine.typeFromMethod("bogus")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 724e2dfb..5c13943b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,7 @@ kmpTor = "2.6.0" kmpTorResource = "409.5.0" secp256k1Jni = "0.24.0" leakcanary = "1.0.0" +composeMultiplatform = "1.11.1" [libraries] datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastorePreferences" } @@ -55,6 +56,7 @@ material-icons-extended = { module = "androidx.compose.material:material-icons-e material3 = { module = "androidx.compose.material3:material3", version.ref = "material3" } navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "nav_version" } quartz = { module = "com.vitorpamplona.quartz:quartz-android", version.ref = "quartz" } +quartz-jvm = { module = "com.vitorpamplona.quartz:quartz-jvm", version.ref = "quartz" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "collections" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomKtx" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomKtx" } @@ -95,3 +97,5 @@ gradle_ktlint = { id = "org.jlleitschuh.gradle.ktlint", version = "ktlint" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin_ksp = { id = "com.google.devtools.ksp", version = "ksp" } jetbrainsComposeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } diff --git a/settings.gradle.kts b/settings.gradle.kts index b5119ecd..89ab1bd4 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,3 +17,4 @@ dependencyResolutionManagement { } rootProject.name = "Nostr Signer" include(":app") +include(":desktop")