From 5af0afdd45ae80dda4ee89524f9f0f62e97bc483 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 16:42:32 +0000 Subject: [PATCH] Add desktop app (Windows/macOS/Linux) as Compose for Desktop module New :desktop Gradle module turns a computer into a NIP-46 signer with the same accounts, permission model and bunker flows as the Android app, built on the quartz-jvm artifact and Compose Multiplatform. - BunkerEngine ports NotificationSubscription + EventNotificationConsumer + BunkerRequestUtils: per-connection localKey subscriptions (kind 24133), auto-accept/auto-reject via the rememberType/acceptUntil rules, approval queue for everything else, and relay responses with retry/backoff - All NIP-46 methods supported: connect, sign_event, get_public_key, ping, nip04/nip44 encrypt/decrypt, nip44v3 encrypt/decrypt, decrypt_zap_event, sign_psbt, switch_relays, logout (NIP-55 is Android-only IPC and is intentionally out of scope) - Keys are AES-256-GCM encrypted at rest with the key held in a PKCS12 Java KeyStore; keystore + password files are owner-only permissions - Connections via pasted nostrconnect:// URIs or generated bunker:// URIs with QR code; per-app permissions, activity history and logs persisted as JSON per account - UI mirrors mobile: same theme/colors, bottom navigation (Incoming request / Applications / Relays / Settings), login/create-key with NIP-06 seed words, ncryptsec backup, multi-account switching - Packaging via compose nativeDistributions: dmg, msi, exe, deb, rpm - Tests: unit tests plus an opt-in (AMBER_E2E=1) end-to-end NIP-46 round-trip against a public relay Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YQTVwy8RBj7spdEK3aEc3i --- CLAUDE.md | 5 + README.md | 4 + build.gradle.kts | 2 + desktop/README.md | 66 ++ desktop/build.gradle.kts | 57 + .../greenart7c3/nostrsigner/desktop/Main.kt | 88 ++ .../desktop/core/AccountManager.kt | 134 +++ .../nostrsigner/desktop/core/AmberDesktop.kt | 85 ++ .../nostrsigner/desktop/core/AppDirs.kt | 42 + .../nostrsigner/desktop/core/BunkerEngine.kt | 970 ++++++++++++++++++ .../desktop/core/DesktopKeyStore.kt | 101 ++ .../nostrsigner/desktop/core/Models.kt | 212 ++++ .../nostrsigner/desktop/core/Nip44v3.kt | 262 +++++ .../nostrsigner/desktop/core/Storage.kt | 141 +++ .../greenart7c3/nostrsigner/desktop/ui/App.kt | 158 +++ .../desktop/ui/ApplicationDetailScreen.kt | 205 ++++ .../desktop/ui/ApplicationsScreen.kt | 223 ++++ .../nostrsigner/desktop/ui/Components.kt | 131 +++ .../desktop/ui/IncomingRequestsScreen.kt | 167 +++ .../nostrsigner/desktop/ui/LoginScreen.kt | 211 ++++ .../nostrsigner/desktop/ui/RelaysScreen.kt | 125 +++ .../nostrsigner/desktop/ui/SettingsScreen.kt | 306 ++++++ .../nostrsigner/desktop/ui/Theme.kt | 60 ++ desktop/src/main/resources/icon.png | Bin 0 -> 161422 bytes .../nostrsigner/desktop/BunkerE2eTest.kt | 170 +++ .../nostrsigner/desktop/DesktopCoreTest.kt | 103 ++ gradle/libs.versions.toml | 4 + settings.gradle.kts | 1 + 28 files changed, 4033 insertions(+) create mode 100644 desktop/README.md create mode 100644 desktop/build.gradle.kts create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/Main.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AccountManager.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AmberDesktop.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/AppDirs.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/BunkerEngine.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/DesktopKeyStore.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Models.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Nip44v3.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/core/Storage.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/App.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationDetailScreen.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/ApplicationsScreen.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Components.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/IncomingRequestsScreen.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/LoginScreen.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/RelaysScreen.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/SettingsScreen.kt create mode 100644 desktop/src/main/kotlin/com/greenart7c3/nostrsigner/desktop/ui/Theme.kt create mode 100644 desktop/src/main/resources/icon.png create mode 100644 desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/BunkerE2eTest.kt create mode 100644 desktop/src/test/kotlin/com/greenart7c3/nostrsigner/desktop/DesktopCoreTest.kt 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 0000000000000000000000000000000000000000..0403d946eaec7c99dd72d8b51044ece7fb81de86 GIT binary patch literal 161422 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

}BFn5azG%dt!Q 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

kDasUzt+*IUVF&#@~QPzq}C( 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%0A5z+d`CW@_`-9UFH5}m6#P)KRK}o znY~rFY$0To(0i|xd#Z!c-((?@KO@1NCcyDxKf%rnQ?pSKc@I3pW~o~hUAn1FP{5JOe2;E=V+V;~B5NSlP^ zN&8D@4Y@OF5`y#=@6YC7bj@ASYB*`S1#ekm?_-%=gUjx`I|1Uf6Zb}XT2*{35x?@y zbKgEI$AdGg(Ugh4TjBx$Oo%*C@lQnd^l)joT+$rV0mk;%?a)jryh|7UKmZhn*pA)R+;;|F>2=uIHQyqU;ZRLEmRF|ytpbR+9-~E$d4h&(& zx_L?7kY!?u$`J*Z6XpYCz68X#|F-mH*ddXHe&h>GQx{vfW&{6x^7ne30M4!!X!QFE zw?LH?)J!wu<2l+usU-$QbZa{LI0Gm69Aw$1xK%^-ZVN-QD&LwIVj=_xE$5%Y; z(sk66h^X-{9gCyhv>+`;p|CSVRX=+?{@Nw0uv*II+VW?*ki$XI%&FmQ9X3)enN!P> zf|Kq$QU3b)9k9G5mS7R>Oa(6?PkyLwU9cVUfmwQOhT$HU&mMwR+g;iY7P}Y)QAd-q z%ud7jZXDorNWB}Q-fGAe;D%0o;rpLB?C4d{QRsi`q;Q2OMN85HySP5O;-VGMv?Kap zLzys;7sEgpR5oFa>SwF+MN@WGRm3=NQs)a`;t z#RGI~C-a!EF|}0YOd!bE8`F`fxtFIuT*9F?At;hQl-h^=ZfG;h2LgxF535MMur_PD zo13g!e_7x+Bp2T1`VRlOM(47xke|GuzeHI2%9#jO6SWG(frB& z>Y?h*Km%*>%f|FFQjgh_SnlhEA~Ae+trunp-5~}m4+b>9V`DSp8!FO0*_-1JV!Oqm z*c++xlFw^Tdt}8O2qj?MVody5Pu{usp_sVk{bDYEBep87>wgt5P&{k=e8L*fBE9Y& zg-Tg_r3q9S=V%5lx`M`!Om-5gX1Q=Cg_gA;_@^-es`^8 z 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 zEl{s<>(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")