mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 19:08:23 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+205
@@ -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),
|
||||
)
|
||||
}
|
||||
+167
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -17,3 +17,4 @@ dependencyResolutionManagement {
|
||||
}
|
||||
rootProject.name = "Nostr Signer"
|
||||
include(":app")
|
||||
include(":desktop")
|
||||
|
||||
Reference in New Issue
Block a user