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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQTVwy8RBj7spdEK3aEc3i
This commit is contained in:
Claude
2026-09-28 10:12:09 -03:00
committed by greenart7c3
parent fc2e0de5dd
commit 5af0afdd45
28 changed files with 4033 additions and 0 deletions
+5
View File
@@ -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
+4
View File
@@ -11,6 +11,10 @@ Amber is a nostr event signer for Android. It allows users to keep their nsec se
</div>
# 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
+2
View File
@@ -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<Copy>("installGitHook") {
+66
View File
@@ -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
```
+57
View File
@@ -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)
}
}
}
@@ -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<DesktopAccount?>(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()
}
}
}
}
@@ -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<String>,
var signPolicy: Int,
var didBackup: Boolean,
) {
fun <T : Event> signSync(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
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<KeyPair> = 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<String> {
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,
),
)
}
}
}
@@ -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<String, AccountStore>()
private val accountCache = ConcurrentHashMap<String, DesktopAccount>()
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<DesktopAccount> = 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<NormalizedRelayUrl> = settings.normalizedDefaultRelays()
/** Union of every connection's relays, mirroring `Amber.getSavedRelays`. */
fun savedRelays(npub: String): Set<NormalizedRelayUrl> = buildSet {
store(npub).apps.value.forEach { addAll(it.app.normalizedRelays()) }
if (isEmpty()) addAll(defaultRelays())
}
}
@@ -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)
}
}
}
@@ -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<NormalizedRelayUrl>,
val nostrConnectSecret: String = "",
val appName: String = "",
val appUrl: String = "",
val requestedPermissions: List<RequestedPermission> = 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<List<PendingBunkerRequest>>(emptyList())
private val subIds = mutableMapOf<String, String>()
private val filterMutex = Mutex()
/** localPubKey -> (npub, localPrivKey), mirrors `LocalKeyAccountIndex`. */
private val localKeyIndex = ConcurrentHashMap<String, Pair<String, String>>()
/** Recently processed event ids -> createdAt, for dedup + `since` computation. */
private val seenEvents = object : LinkedHashMap<String, Long>(64, 0.75f, false) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Long>?): 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<String>()
val indexEntries = mutableMapOf<String, Pair<String, String>>()
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<RequestedPermission> = 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<RequestedPermission>,
) {
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<NormalizedRelayUrl>,
): 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<NormalizedRelayUrl>()
var name = ""
var url = ""
var nostrConnectSecret = ""
val permissions = mutableListOf<RequestedPermission>()
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<NormalizedRelayUrl>,
): 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<RequestedPermission> {
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 ""
@@ -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
}
}
@@ -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<String> = 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<NormalizedRelayUrl> = 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<AppPermissionRecord> = 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<String> = listOf(
"wss://nostr.oxtr.dev/",
"wss://theforest.nostr1.com/",
"wss://relay.primal.net/",
),
val currentAccount: String = "",
val darkTheme: Boolean? = null,
) {
fun normalizedDefaultRelays(): List<NormalizedRelayUrl> = 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"
}
@@ -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<ByteArray, ByteArray> {
val sharedSecret = Secp256k1Instance.pubKeyTweakMulCompact(pubKey, privKey)
return deriveKeysFromSharedSecret(sharedSecret, nonce)
}
fun deriveKeysFromSharedSecret(sharedSecret: ByteArray, nonce: ByteArray): Pair<ByteArray, ByteArray> {
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
}
}
@@ -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 <reified T> readJson(file: File): T? = try {
if (file.exists()) mapper.readValue<T>(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<List<AppWithPermissions>>(appsFile) ?: emptyList())
val history = MutableStateFlow(readJson<List<HistoryRecord>>(historyFile) ?: emptyList())
val logs = MutableStateFlow(readJson<List<LogRecord>>(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<DesktopSettings>(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<List<AccountRecord>>(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 }
}
@@ -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>(Route.Applications) }
// null = list; non-null = detail screen for that application key
var selectedApplication by remember { mutableStateOf<String?>(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)
}
}
}
}
}
}
@@ -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))
}
}
@@ -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<String?>(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") }
},
)
}
@@ -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<String>(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),
)
}
@@ -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")
}
},
)
}
}
}
}
@@ -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
}
},
)
}
@@ -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))
}
}
@@ -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<String?>(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<Pair<Boolean?, String>>(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") }
},
)
}
@@ -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,
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

@@ -0,0 +1,170 @@
package com.greenart7c3.nostrsigner.desktop
import com.greenart7c3.nostrsigner.desktop.core.AccountManager
import com.greenart7c3.nostrsigner.desktop.core.AmberDesktop
import com.greenart7c3.nostrsigner.desktop.core.RememberType
import com.greenart7c3.nostrsigner.desktop.core.SettingsStore
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebSocketListener
import com.vitorpamplona.quartz.nip01Core.relay.sockets.WebsocketBuilder
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import java.io.File
import java.util.UUID
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals
import org.junit.Assume.assumeTrue
import org.junit.BeforeClass
import org.junit.Test
/**
* Full NIP-46 round-trip over a real public relay: the desktop engine plays
* the signer role while this test plays the client app (connect via a
* bunker:// URI, then get_public_key auto-approved by the granted
* permissions). Network-dependent, so it only runs when AMBER_E2E is set.
*/
class BunkerE2eTest {
companion object {
@JvmStatic
@BeforeClass
fun isolateDataDir() {
val tmp = File.createTempFile("amber-e2e", "").apply {
delete()
mkdirs()
deleteOnExit()
}
System.setProperty("user.home", tmp.absolutePath)
}
}
private class Client(relay: NormalizedRelayUrl, val keyPair: KeyPair) {
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val signer = NostrSignerInternal(keyPair)
val responses = MutableStateFlow<List<BunkerResponse>>(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<com.vitorpamplona.quartz.nip01Core.core.Event>(
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()
}
}
@@ -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"))
}
}
+4
View File
@@ -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" }
+1
View File
@@ -17,3 +17,4 @@ dependencyResolutionManagement {
}
rootProject.name = "Nostr Signer"
include(":app")
include(":desktop")