From b1227fa7cb3787106abe7b77eb00a84351426e25 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:25:11 +0000 Subject: [PATCH 01/52] feat(quartz): add signer-side NIP-46 processor, service and URI codec Adds the bunker/signer half of NIP-46 as reusable, signer-agnostic quartz components so Amethyst can act as a remote signer for other apps: - BunkerRequestProcessor: turns a decrypted BunkerRequest into the BunkerResponse the client expects, performing the work through whatever NostrSigner the account uses (local keypair or NIP-55 external app). Signing/encryption/decryption are gated through a Nip46RequestAuthorizer; public reads (get_public_key/ping/get_relays) are not. - Nip46RequestAuthorizer: the permission boundary the host app plugs its own trust model into (connect validation + per-op authorization). - NostrConnectSignerService: subscribes to kind-24133 requests on a relay set, decrypts, dispatches to the processor, and publishes the reply. - NostrConnectURI: KMP-safe parse/build for bunker:// and nostrconnect:// pairing URIs (percent-encoded), shared by CLI/desktop/Android. Unit tests cover the dispatch/authorization matrix with a fake signer (no crypto) and the URI round-trips. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../nip46RemoteSigner/NostrConnectURI.kt | 193 +++++++++++ .../server/BunkerRequestProcessor.kt | 147 ++++++++ .../server/Nip46RequestAuthorizer.kt | 81 +++++ .../server/NostrConnectSignerService.kt | 122 +++++++ .../nip46RemoteSigner/NostrConnectURITest.kt | 99 ++++++ .../server/BunkerRequestProcessorTest.kt | 313 ++++++++++++++++++ 6 files changed, 955 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURI.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURITest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURI.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURI.kt new file mode 100644 index 0000000000..004fdcb4ff --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURI.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip46RemoteSigner + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.Hex + +/** + * KMP-safe parsing and building of the two NIP-46 pairing URIs: + * + * - `bunker://?relay=…&secret=…` — the **signer** + * advertises how to reach it (Amethyst mints this when it acts as a bunker). + * - `nostrconnect://?relay=…&secret=…&perms=…&name=…&url=…&image=…` + * — the **client** offers to connect and the signer answers with a connect + * ack that echoes the secret (Amethyst parses this when a user pastes an + * offer to pair a new app). + * + * Values are percent-encoded per the spec/nak convention (`relay=wss%3A%2F%2F…`); + * this object encodes/decodes without any JVM-only `java.net.URLEncoder`, so it + * lives in `commonMain` and is shared by the CLI, desktop and Android. + */ +object NostrConnectURI { + const val BUNKER_SCHEME = "bunker://" + const val NOSTRCONNECT_SCHEME = "nostrconnect://" + + /** A parsed `bunker://` advertisement. */ + data class Bunker( + val remoteSignerPubKey: HexKey, + val relays: Set, + val secret: String?, + ) + + /** A parsed `nostrconnect://` client offer. */ + data class NostrConnect( + val clientPubKey: HexKey, + val relays: Set, + val secret: String, + val perms: String? = null, + val name: String? = null, + val url: String? = null, + val image: String? = null, + ) + + /** Parse a `bunker://?relay=…&secret=…` URI, or `null` if malformed. */ + fun parseBunker(uri: String): Bunker? { + if (!uri.startsWith(BUNKER_SCHEME)) return null + val (pubkey, params) = splitAuthority(uri.removePrefix(BUNKER_SCHEME)) ?: return null + if (!isValidPubKey(pubkey)) return null + val relays = mutableSetOf() + var secret: String? = null + forEachParam(params) { key, value -> + when (key) { + "relay" -> RelayUrlNormalizer.normalizeOrNull(value)?.let { relays.add(it) } + "secret" -> secret = value + } + } + return Bunker(pubkey, relays, secret) + } + + /** Build a `bunker://?relay=…&secret=…` advertisement URI. */ + fun buildBunker( + remoteSignerPubKey: HexKey, + relays: Collection, + secret: String?, + ): String = + buildString { + append(BUNKER_SCHEME).append(remoteSignerPubKey) + append('?').append(relays.joinToString("&") { "relay=${encode(it.url)}" }) + if (secret != null) append("&secret=").append(encode(secret)) + } + + /** Parse a `nostrconnect://?relay=…&secret=…&…` offer, or `null` if malformed. */ + fun parseNostrConnect(uri: String): NostrConnect? { + if (!uri.startsWith(NOSTRCONNECT_SCHEME)) return null + val (pubkey, params) = splitAuthority(uri.removePrefix(NOSTRCONNECT_SCHEME)) ?: return null + if (!isValidPubKey(pubkey)) return null + val relays = mutableSetOf() + var secret: String? = null + var perms: String? = null + var name: String? = null + var url: String? = null + var image: String? = null + forEachParam(params) { key, value -> + when (key) { + "relay" -> RelayUrlNormalizer.normalizeOrNull(value)?.let { relays.add(it) } + "secret" -> secret = value + "perms" -> perms = value + "name" -> name = value + "url" -> url = value + "image" -> image = value + } + } + val validSecret = secret ?: return null + return NostrConnect(pubkey.lowercase(), relays, validSecret, perms, name, url, image) + } + + /** Build a `nostrconnect://?relay=…&secret=…&…` offer URI. */ + fun buildNostrConnect( + clientPubKey: HexKey, + relays: Collection, + secret: String, + perms: String? = null, + name: String? = null, + url: String? = null, + image: String? = null, + ): String = + buildString { + append(NOSTRCONNECT_SCHEME).append(clientPubKey) + append('?').append(relays.joinToString("&") { "relay=${encode(it.url)}" }) + append("&secret=").append(encode(secret)) + if (perms != null) append("&perms=").append(encode(perms)) + if (name != null) append("&name=").append(encode(name)) + if (url != null) append("&url=").append(encode(url)) + if (image != null) append("&image=").append(encode(image)) + } + + private fun isValidPubKey(pubkey: String): Boolean = pubkey.length == 64 && Hex.isHex(pubkey) + + /** Splits `?` into the authority and the raw query (empty when no `?`). */ + private fun splitAuthority(rest: String): Pair? { + val parts = rest.split("?", limit = 2) + val authority = parts[0] + if (authority.isEmpty()) return null + return authority to (parts.getOrNull(1) ?: "") + } + + private inline fun forEachParam( + query: String, + action: (key: String, value: String) -> Unit, + ) { + if (query.isEmpty()) return + for (param in query.split("&")) { + val kv = param.split("=", limit = 2) + if (kv.size < 2) continue + action(kv[0], decode(kv[1])) + } + } + + /** Percent-decode a query value (e.g. `wss%3A%2F%2F…` → `wss://…`). */ + fun decode(input: String): String { + if ('%' !in input) return input + val bytes = ArrayList(input.length) + var i = 0 + while (i < input.length) { + val c = input[i] + if (c == '%' && i + 2 < input.length) { + val code = input.substring(i + 1, i + 3).toIntOrNull(16) + if (code != null) { + bytes.add(code.toByte()) + i += 3 + continue + } + } + for (b in c.toString().encodeToByteArray()) bytes.add(b) + i++ + } + return bytes.toByteArray().decodeToString() + } + + /** Percent-encode a query value; only unreserved `A-Za-z0-9-._~` pass through. */ + fun encode(input: String): String { + val sb = StringBuilder(input.length) + for (b in input.encodeToByteArray()) { + val c = (b.toInt() and 0xFF).toChar() + if (c.isLetterOrDigit() && c.code < 128 || c in "-._~") { + sb.append(c) + } else { + sb.append('%').append((b.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase()) + } + } + return sb.toString() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt new file mode 100644 index 0000000000..f14967e354 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip46RemoteSigner.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.ReadWrite + +/** + * The signer/bunker side of NIP-46: turns a decrypted [BunkerRequest] from a + * client into the [BunkerResponse] the client expects, performing the actual + * crypto with the user's own [signer]. + * + * This is the "NIP-46 processor" — it is deliberately signer-agnostic: [signer] + * can be a local keypair ([NostrSignerInternal]) or an external NIP-55 app + * ([NostrSignerExternal]), and every request is fulfilled through the same + * [NostrSigner] surface (`sign`, `nip04/44Encrypt/Decrypt`). Whichever signer + * the user logged in with is the one that ultimately performs the work. + * + * Authorization is delegated to [authorizer]: signing/encryption/decryption are + * gated, while public/harmless reads (`get_public_key`, `ping`, `get_relays`) + * always succeed. All failures — decryption, authorization, an unsupported + * method, or an exception from the signer — are turned into a + * [BunkerResponseError] carrying the request id, so the client always gets a + * reply it can correlate. + * + * Pairs with [NostrConnectSignerService], which subscribes to the relays, + * decrypts each kind-24133 request, calls [process], and publishes the reply. + */ +class BunkerRequestProcessor( + val signer: NostrSigner, + val relays: suspend () -> Set, + val authorizer: Nip46RequestAuthorizer, +) { + /** + * Fulfils a single decrypted [request] sent by [clientPubKey], returning the + * response to encrypt and send back. Never throws — signer/authorizer errors + * are captured as [BunkerResponseError]. + */ + suspend fun process( + clientPubKey: HexKey, + request: BunkerRequest, + ): BunkerResponse = + try { + when (request) { + is BunkerRequestConnect -> + when (val decision = authorizer.onConnect(clientPubKey, request)) { + is Nip46ConnectDecision.Accept -> BunkerResponse(request.id, decision.ackSecret, null) + is Nip46ConnectDecision.Reject -> BunkerResponseError(request.id, decision.reason) + } + + is BunkerRequestGetPublicKey -> BunkerResponsePublicKey(request.id, signer.pubKey) + + is BunkerRequestPing -> BunkerResponsePong(request.id) + + is BunkerRequestGetRelays -> + BunkerResponseGetRelays(request.id, relays().associate { it.url to ReadWrite(read = true, write = true) }) + + is BunkerRequestSign -> + ifAuthorized(clientPubKey, request) { + val signed = signer.sign(request.event.createdAt, request.event.kind, request.event.tags, request.event.content) + BunkerResponseEvent(request.id, signed) + } + + is BunkerRequestNip04Encrypt -> + ifAuthorized(clientPubKey, request) { + BunkerResponseEncrypt(request.id, signer.nip04Encrypt(request.message, request.pubKey)) + } + + is BunkerRequestNip04Decrypt -> + ifAuthorized(clientPubKey, request) { + BunkerResponseDecrypt(request.id, signer.nip04Decrypt(request.ciphertext, request.pubKey)) + } + + is BunkerRequestNip44Encrypt -> + ifAuthorized(clientPubKey, request) { + BunkerResponseEncrypt(request.id, signer.nip44Encrypt(request.message, request.pubKey)) + } + + is BunkerRequestNip44Decrypt -> + ifAuthorized(clientPubKey, request) { + BunkerResponseDecrypt(request.id, signer.nip44Decrypt(request.ciphertext, request.pubKey)) + } + + else -> BunkerResponseError(request.id, "unsupported method: ${request.method}") + } + } catch (e: Exception) { + BunkerResponseError(request.id, "${e::class.simpleName}: ${e.message}") + } + + private suspend inline fun ifAuthorized( + clientPubKey: HexKey, + request: BunkerRequest, + block: () -> BunkerResponse, + ): BunkerResponse = + if (authorizer.authorize(clientPubKey, request)) { + block() + } else { + BunkerResponseError(request.id, ERROR_UNAUTHORIZED) + } + + companion object { + /** Ack result for a `connect` request with no secret to echo (mirrors [BunkerResponseAck.RESULT]). */ + const val ACK: String = "ack" + + /** Error result returned when [Nip46RequestAuthorizer.authorize] denies a request. */ + const val ERROR_UNAUTHORIZED: String = "unauthorized" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt new file mode 100644 index 0000000000..126093bdc8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip46RemoteSigner.server + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect + +/** + * The authorization boundary a NIP-46 signer (a "bunker") consults before it + * performs a request on the user's behalf. It is the protocol-agnostic hook the + * host app uses to plug in *its* permission model — in Amethyst that is the + * shared "Connected Apps" ledger, so the same trust levels that gate napplets + * and web apps also gate remote NIP-46 clients. + * + * [BunkerRequestProcessor] owns the wire logic; this interface owns "may this + * client do this?". Everything here is `suspend` so an implementation may block + * on disk, a live user prompt, or IPC without changing the processor. + * + * Public, harmless requests (`get_public_key`, `ping`, `get_relays`) are NOT + * routed through [authorize]; only signing, encryption and decryption are. + */ +interface Nip46RequestAuthorizer { + /** + * Called when a client sends a `connect` request. The implementation + * validates the offered secret (the `bunker://…?secret=…` pairing token), + * registers the client as a connected app if it accepts, and returns the + * decision. On accept the returned [Nip46ConnectDecision.Accept.ackSecret] + * is echoed to the client (the offered secret, or `"ack"` when none was set). + */ + suspend fun onConnect( + clientPubKey: HexKey, + request: BunkerRequestConnect, + ): Nip46ConnectDecision + + /** + * Called before every signing/encryption/decryption request. Return `true` + * to perform it, `false` to reject the client with an "unauthorized" error. + * Implementations typically map [request] to a per-app permission and read + * the standing grant/deny decision for [clientPubKey]. + */ + suspend fun authorize( + clientPubKey: HexKey, + request: BunkerRequest, + ): Boolean +} + +/** The verdict for a NIP-46 `connect` request. */ +sealed class Nip46ConnectDecision { + /** + * Accept the connection. [ackSecret] is echoed back to the client as the + * connect result — the offered secret when one was set (so the client can + * validate it), otherwise `"ack"`. + */ + data class Accept( + val ackSecret: String, + ) : Nip46ConnectDecision() + + /** Reject the connection; [reason] is returned to the client as an error. */ + data class Reject( + val reason: String, + ) : Nip46ConnectDecision() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt new file mode 100644 index 0000000000..a9ed466a9a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip46RemoteSigner.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED + +/** + * Runs a NIP-46 remote signer ("bunker") for one account: subscribes to the + * given [relays] for kind-24133 requests addressed to the user, decrypts each + * one with the user's [signer], hands it to [processor], and publishes the + * encrypted reply back to the requesting client. + * + * The signer is whatever the account logged in with — a local keypair or a + * NIP-55 external app — so the same [NostrConnectEvent.create] transport + * encryption and the same [BunkerRequestProcessor] dispatch serve both. + * + * [run] is a long-running suspend loop: it services requests until the calling + * coroutine is cancelled, then tears the subscription down. Callers who need to + * follow a changing relay set (e.g. the user editing their inbox relays) should + * cancel and relaunch [run] with the new set. + */ +class NostrConnectSignerService( + val client: INostrClient, + val signer: NostrSigner, + val processor: BunkerRequestProcessor, + val relays: Set, + /** Optional hook, invoked with each serviced request's method + client, for logging/metrics. */ + val onServiced: ((method: String, clientPubKey: String, error: String?) -> Unit)? = null, +) { + /** + * Subscribes and services requests until cancelled. Duplicate events (the + * same request seen on more than one relay) are handled once. Never returns + * normally — it loops until the coroutine is cancelled. + */ + suspend fun run() { + if (relays.isEmpty()) { + Log.w("NIP46Signer") { "no relays to listen on; signer service is idle" } + return + } + + val self = signer.pubKey + val events = Channel(UNLIMITED) + val seen = mutableSetOf() + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is NostrConnectEvent && event.verifiedRecipientPubKey() == self && seen.add(event.id)) { + events.trySend(event) + } + } + } + + val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self))) + client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) + try { + while (true) { + handle(events.receive()) + } + } finally { + client.unsubscribe(subId) + events.close() + } + } + + private suspend fun handle(event: NostrConnectEvent) { + val client = event.talkingWith(signer.pubKey) + val request = + try { + event.decryptMessage(signer) as? BunkerRequest ?: return + } catch (e: Exception) { + Log.w("NIP46Signer") { "could not decrypt request ${event.id.take(8)}: ${e.message}" } + return + } + + val response = processor.process(client, request) + val error = (response as? BunkerResponseError)?.error + onServiced?.invoke(request.method, client, error) + + try { + val reply = NostrConnectEvent.create(response, client, signer) + this.client.publish(reply, relays) + } catch (e: Exception) { + Log.w("NIP46Signer") { "failed to send reply for ${request.method}: ${e.message}" } + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURITest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURITest.kt new file mode 100644 index 0000000000..30499d4276 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURITest.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip46RemoteSigner + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NostrConnectURITest { + private val pubkey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + + @Test + fun parseBunkerWithPercentEncodedRelay() { + val uri = "bunker://$pubkey?relay=wss%3A%2F%2Frelay.example.com&secret=abc123" + val parsed = NostrConnectURI.parseBunker(uri)!! + assertEquals(pubkey, parsed.remoteSignerPubKey) + assertEquals("abc123", parsed.secret) + assertTrue(parsed.relays.contains(relay)) + } + + @Test + fun parseBunkerRejectsWrongScheme() { + assertNull(NostrConnectURI.parseBunker("nostrconnect://$pubkey?secret=x")) + } + + @Test + fun parseBunkerRejectsBadPubkey() { + assertNull(NostrConnectURI.parseBunker("bunker://not-a-key?secret=x")) + } + + @Test + fun buildBunkerRoundTrips() { + val uri = NostrConnectURI.buildBunker(pubkey, setOf(relay), "s3cr3t") + val parsed = NostrConnectURI.parseBunker(uri)!! + assertEquals(pubkey, parsed.remoteSignerPubKey) + assertEquals("s3cr3t", parsed.secret) + assertTrue(parsed.relays.contains(relay)) + // relay must be percent-encoded in the built URI + assertTrue(uri.contains("relay=wss%3A%2F%2F")) + } + + @Test + fun parseNostrConnectFull() { + val uri = + "nostrconnect://$pubkey?relay=wss%3A%2F%2Frelay.example.com&secret=xyz&perms=sign_event%3A1%2Cnip44_encrypt&name=My%20App" + val parsed = NostrConnectURI.parseNostrConnect(uri)!! + assertEquals(pubkey, parsed.clientPubKey) + assertEquals("xyz", parsed.secret) + assertEquals("sign_event:1,nip44_encrypt", parsed.perms) + assertEquals("My App", parsed.name) + assertTrue(parsed.relays.contains(relay)) + } + + @Test + fun parseNostrConnectRequiresSecret() { + assertNull(NostrConnectURI.parseNostrConnect("nostrconnect://$pubkey?relay=wss%3A%2F%2Frelay.example.com")) + } + + @Test + fun nostrConnectRoundTrips() { + val uri = NostrConnectURI.buildNostrConnect(pubkey, setOf(relay), "sec", perms = "sign_event:1", name = "Amethyst") + val parsed = NostrConnectURI.parseNostrConnect(uri)!! + assertEquals(pubkey, parsed.clientPubKey) + assertEquals("sec", parsed.secret) + assertEquals("sign_event:1", parsed.perms) + assertEquals("Amethyst", parsed.name) + } + + @Test + fun decodeHandlesUtf8() { + assertEquals("café", NostrConnectURI.decode("caf%C3%A9")) + } + + @Test + fun encodeLeavesUnreservedUntouched() { + assertEquals("abcXYZ-._~", NostrConnectURI.encode("abcXYZ-._~")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt new file mode 100644 index 0000000000..ceffdd2f65 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip46RemoteSigner.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pure dispatch/authorization tests for the NIP-46 signer core. Uses a canned + * [FakeSigner] so no secp256k1/NIP-44 crypto is required — these run in + * commonTest on every platform. + */ +class BunkerRequestProcessorTest { + private val userPubKey = "a".repeat(64) + private val clientPubKey = "c".repeat(64) + + /** Records what the signer was asked to do and returns fixed values. */ + private class FakeSigner( + pubKey: HexKey, + ) : NostrSigner(pubKey) { + var signCount = 0 + var nip44EncryptCount = 0 + var nip44DecryptCount = 0 + + val cannedEvent = + Event( + id = "e".repeat(64), + pubKey = pubKey, + createdAt = 1L, + kind = 1, + tags = emptyArray(), + content = "signed", + sig = "f".repeat(128), + ) + + override fun isWriteable() = true + + @Suppress("UNCHECKED_CAST") + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T { + signCount++ + return cannedEvent as T + } + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "nip04:$plaintext" + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "nip04dec:$ciphertext" + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String { + nip44EncryptCount++ + return "nip44:$plaintext" + } + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String { + nip44DecryptCount++ + return "nip44dec:$ciphertext" + } + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw NotImplementedError() + + override suspend fun deriveKey(nonce: HexKey): HexKey = throw NotImplementedError() + + override suspend fun signPsbt(psbtHex: String): String = throw NotImplementedError() + + override fun hasForegroundSupport() = false + } + + /** Configurable authorizer for the tests. */ + private class FakeAuthorizer( + val connectDecision: Nip46ConnectDecision, + val allow: Boolean, + ) : Nip46RequestAuthorizer { + var connectCalls = 0 + var authorizeCalls = 0 + + override suspend fun onConnect( + clientPubKey: HexKey, + request: BunkerRequestConnect, + ): Nip46ConnectDecision { + connectCalls++ + return connectDecision + } + + override suspend fun authorize( + clientPubKey: HexKey, + request: BunkerRequest, + ): Boolean { + authorizeCalls++ + return allow + } + } + + private val relay: NormalizedRelayUrl = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + + private fun processor( + signer: FakeSigner = FakeSigner(userPubKey), + authorizer: FakeAuthorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = true), + ) = BunkerRequestProcessor(signer, { setOf(relay) }, authorizer) + + @Test + fun getPublicKeyReturnsUserPubKeyWithoutAuthorization() = + runTest { + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = false) + val res = processor(authorizer = authorizer).process(clientPubKey, BunkerRequestGetPublicKey("1")) + + assertTrue(res is BunkerResponsePublicKey) + assertEquals(userPubKey, res.pubkey) + assertEquals("1", res.id) + // public reads are never gated + assertEquals(0, authorizer.authorizeCalls) + } + + @Test + fun pingReturnsPong() = + runTest { + val res = processor().process(clientPubKey, BunkerRequestPing("2")) + assertTrue(res is BunkerResponsePong) + assertEquals("2", res.id) + } + + @Test + fun getRelaysReturnsConfiguredRelays() = + runTest { + val res = processor().process(clientPubKey, BunkerRequestGetRelays("3")) + assertTrue(res is BunkerResponseGetRelays) + assertTrue(res.relays.containsKey(relay.url)) + } + + @Test + fun connectAcceptEchoesSecret() = + runTest { + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("s3cr3t"), allow = true) + val res = processor(authorizer = authorizer).process(clientPubKey, BunkerRequestConnect(id = "4", remoteKey = userPubKey, secret = "s3cr3t")) + + assertEquals(1, authorizer.connectCalls) + assertEquals("4", res.id) + assertEquals("s3cr3t", res.result) + } + + @Test + fun connectRejectReturnsError() = + runTest { + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Reject("invalid secret"), allow = true) + val res = processor(authorizer = authorizer).process(clientPubKey, BunkerRequestConnect(id = "5", remoteKey = userPubKey, secret = "wrong")) + + assertTrue(res is BunkerResponseError) + assertEquals("invalid secret", res.error) + } + + @Test + fun signAuthorizedSignsWithUserSigner() = + runTest { + val signer = FakeSigner(userPubKey) + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hi") + val res = processor(signer = signer).process(clientPubKey, BunkerRequestSign("6", template)) + + assertTrue(res is BunkerResponseEvent) + assertEquals(1, signer.signCount) + assertEquals(signer.cannedEvent.id, res.event.id) + } + + @Test + fun signDeniedReturnsUnauthorized() = + runTest { + val signer = FakeSigner(userPubKey) + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = false) + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hi") + val res = processor(signer = signer, authorizer = authorizer).process(clientPubKey, BunkerRequestSign("7", template)) + + assertTrue(res is BunkerResponseError) + assertEquals(BunkerRequestProcessor.ERROR_UNAUTHORIZED, res.error) + assertEquals(0, signer.signCount) + } + + @Test + fun nip44EncryptAuthorized() = + runTest { + val signer = FakeSigner(userPubKey) + val res = processor(signer = signer).process(clientPubKey, BunkerRequestNip44Encrypt("8", clientPubKey, "hello")) + + assertTrue(res is BunkerResponseEncrypt) + assertEquals("nip44:hello", res.ciphertext) + assertEquals(1, signer.nip44EncryptCount) + } + + @Test + fun nip44DecryptDeniedDoesNotCallSigner() = + runTest { + val signer = FakeSigner(userPubKey) + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = false) + val res = processor(signer = signer, authorizer = authorizer).process(clientPubKey, BunkerRequestNip44Decrypt("9", clientPubKey, "ct")) + + assertTrue(res is BunkerResponseError) + assertEquals(0, signer.nip44DecryptCount) + } + + @Test + fun unsupportedMethodReturnsError() = + runTest { + val res = processor().process(clientPubKey, BunkerRequest("10", "made_up_method", emptyArray())) + assertTrue(res is BunkerResponseError) + assertTrue(res.error!!.contains("made_up_method")) + } + + @Test + fun signerExceptionBecomesErrorResponse() = + runTest { + val throwing = + object : NostrSigner(userPubKey) { + override fun isWriteable() = true + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T = throw IllegalStateException("boom") + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "" + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "" + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "" + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "" + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw NotImplementedError() + + override suspend fun deriveKey(nonce: HexKey): HexKey = throw NotImplementedError() + + override suspend fun signPsbt(psbtHex: String): String = throw NotImplementedError() + + override fun hasForegroundSupport() = false + } + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hi") + val res = + BunkerRequestProcessor(throwing, { setOf(relay) }, FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = true)) + .process(clientPubKey, BunkerRequestSign("11", template)) + + assertTrue(res is BunkerResponseError) + assertTrue(res.error!!.contains("boom")) + } +} From dc0840264dc6f4d1f760652777a3403f3659a500 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:25:31 +0000 Subject: [PATCH 02/52] refactor(cli): drive `amy bunker` through the shared quartz signer core Replace BunkerCommand's hand-rolled subscribe/decrypt/dispatch/publish loop with NostrConnectSignerService + BunkerRequestProcessor, and route bunker:// / nostrconnect:// URI parsing+building through NostrConnectURI. Behaviour is unchanged (the CLI bunker still hosts the operator's own key and auto-approves every request via a small CliAuthorizer that only checks the pairing secret); this removes the duplicated protocol logic now that it lives in quartz. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../amethyst/cli/commands/BunkerCommand.kt | 166 ++++++------------ .../amethyst/cli/commands/NostrConnect.kt | 35 +--- 2 files changed, 59 insertions(+), 142 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt index 12f68685d7..e8a682c6dd 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -24,36 +24,20 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output -import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseGetRelays -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent -import com.vitorpamplona.quartz.nip46RemoteSigner.ReadWrite -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI +import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor +import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46ConnectDecision +import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46RequestAuthorizer +import com.vitorpamplona.quartz.nip46RemoteSigner.server.NostrConnectSignerService import kotlinx.coroutines.withTimeoutOrNull /** @@ -69,11 +53,37 @@ import kotlinx.coroutines.withTimeoutOrNull * remotely through this bunker. Long-running — stops at `--timeout` SECS or on * interrupt. * - * Thin assembly only: every request/response type + the encrypted wrapper - * live in quartz (`BunkerRequest*`, `BunkerResponse*`, `NostrConnectEvent`); - * this file dispatches to `ctx.signer`. + * Thin assembly only: the request dispatch, the encrypted wrapper and the + * subscribe/serve loop all live in quartz (`BunkerRequestProcessor`, + * `NostrConnectSignerService`, `NostrConnectEvent`); this file just wires the + * CLI account's `ctx.signer` into them and auto-approves every request (a + * headless bunker for the operator's own local key). */ object BunkerCommand { + /** + * A headless bunker authorizer: validate the connect [secret] and then + * approve every operation. The CLI bunker hosts the operator's OWN key, so + * there is no separate user to prompt — the pairing secret is the gate. + */ + private class CliAuthorizer( + val secret: String, + ) : Nip46RequestAuthorizer { + override suspend fun onConnect( + clientPubKey: HexKey, + request: BunkerRequestConnect, + ): Nip46ConnectDecision = + if (request.secret == secret) { + Nip46ConnectDecision.Accept(BunkerRequestProcessor.ACK) + } else { + Nip46ConnectDecision.Reject("invalid secret") + } + + override suspend fun authorize( + clientPubKey: HexKey, + request: BunkerRequest, + ): Boolean = true + } + suspend fun run( dataDir: DataDir, rest: Array, @@ -105,14 +115,8 @@ object BunkerCommand { val secret = args.flag("secret") ?: KeyPair().privKey!!.toHexKey().take(32) val self = ctx.identity.pubKeyHex - // Percent-encode params (spec/nak convention: relay=wss%3A%2F%2F…). - val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") } - val uri = - buildString { - append("bunker://").append(self) - append("?").append(relays.joinToString("&") { "relay=${enc(it.url)}" }) - append("&secret=").append(enc(secret)) - } + // Percent-encoded per the spec/nak convention (relay=wss%3A%2F%2F…). + val uri = NostrConnectURI.buildBunker(self, relays, secret) Output.emit( mapOf( "bunker_uri" to uri, @@ -185,84 +189,24 @@ object BunkerCommand { secret: String, timeoutMs: Long?, ) { - val self = ctx.identity.pubKeyHex - val events = Channel(UNLIMITED) - val seen = mutableSetOf() - val subId = newSubId() - val listener = - object : SubscriptionListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - if (event is NostrConnectEvent && seen.add(event.id)) events.trySend(event) - } - } + val processor = + BunkerRequestProcessor( + signer = ctx.signer, + relays = { relays }, + authorizer = CliAuthorizer(secret), + ) + val service = + NostrConnectSignerService( + client = ctx.client, + signer = ctx.signer, + processor = processor, + relays = relays, + onServiced = { method, client, error -> + val outcome = if (error != null) "error: $error" else "ok" + System.err.println("[bunker] $method from ${client.take(8)}… → $outcome") + }, + ) - val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self))) - ctx.client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) - try { - val loop: suspend () -> Unit = { - while (true) handle(ctx, events.receive(), secret, relays) - } - if (timeoutMs != null) withTimeoutOrNull(timeoutMs) { loop() } else loop() - } finally { - ctx.client.unsubscribe(subId) - events.close() - } - } - - private suspend fun handle( - ctx: Context, - event: NostrConnectEvent, - secret: String, - relays: Set, - ) { - val signer = ctx.signer - val client = event.talkingWith(signer.pubKey) - val request = - try { - event.decryptMessage(signer) as? BunkerRequest ?: return - } catch (e: Exception) { - System.err.println("[bunker] could not decrypt request ${event.id.take(8)}: ${e.message}") - return - } - - val response: BunkerResponse = - try { - when (request) { - is BunkerRequestConnect -> - if (request.secret == secret) { - BunkerResponseAck(request.id) - } else { - BunkerResponseError(request.id, "invalid secret") - } - is BunkerRequestGetPublicKey -> BunkerResponsePublicKey(request.id, signer.pubKey) - is BunkerRequestGetRelays -> BunkerResponseGetRelays(request.id, relays.associate { it.url to ReadWrite(read = true, write = true) }) - is BunkerRequestPing -> BunkerResponsePong(request.id) - is BunkerRequestSign -> { - val signed = signer.sign(request.event.createdAt, request.event.kind, request.event.tags, request.event.content) - BunkerResponseEvent(request.id, signed) - } - is BunkerRequestNip04Encrypt -> BunkerResponseEncrypt(request.id, signer.nip04Encrypt(request.message, request.pubKey)) - is BunkerRequestNip04Decrypt -> BunkerResponseDecrypt(request.id, signer.nip04Decrypt(request.ciphertext, request.pubKey)) - is BunkerRequestNip44Encrypt -> BunkerResponseEncrypt(request.id, signer.nip44Encrypt(request.message, request.pubKey)) - is BunkerRequestNip44Decrypt -> BunkerResponseDecrypt(request.id, signer.nip44Decrypt(request.ciphertext, request.pubKey)) - else -> BunkerResponseError(request.id, "unsupported method: ${request.method}") - } - } catch (e: Exception) { - BunkerResponseError(request.id, "${e::class.simpleName}: ${e.message}") - } - - System.err.println("[bunker] ${request.method} from ${client.take(8)}… → ${if (response is BunkerResponseError) "error: ${response.error}" else "ok"}") - - try { - val reply = NostrConnectEvent.create(response, client, signer) - ctx.client.publish(reply, relays) - } catch (e: Exception) { - System.err.println("[bunker] failed to send reply for ${request.method}: ${e.message}") - } + if (timeoutMs != null) withTimeoutOrNull(timeoutMs) { service.run() } else service.run() } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt index a24ded7865..6fda83bd5d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt @@ -33,12 +33,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId 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.okhttp.BasicOkHttpWebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.TcpNoDelaySocketFactory import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.withTimeoutOrNull @@ -52,8 +52,6 @@ import okhttp3.OkHttpClient * which only parses). The signer side lives in [BunkerCommand]. */ object NostrConnect { - private const val NOSTRCONNECT_SCHEME = "nostrconnect://" - data class Offer( val clientPubkey: String, val relays: Set, @@ -63,25 +61,8 @@ object NostrConnect { /** Parse `nostrconnect://?relay=…&secret=…&name=…` (percent-decoded). */ fun parseOffer(uri: String): Offer? { - if (!uri.startsWith(NOSTRCONNECT_SCHEME)) return null - val parts = uri.removePrefix(NOSTRCONNECT_SCHEME).split("?", limit = 2) - val clientPubkey = parts[0].lowercase() - if (clientPubkey.length != 64 || clientPubkey.any { it !in "0123456789abcdef" }) return null - val relays = mutableSetOf() - var secret: String? = null - var name: String? = null - parts.getOrNull(1)?.split("&")?.forEach { param -> - val kv = param.split("=", limit = 2) - if (kv.size < 2) return@forEach - val value = java.net.URLDecoder.decode(kv[1], "UTF-8") - when (kv[0]) { - "relay" -> RelayUrlNormalizer.normalizeOrNull(value)?.let { relays.add(it) } - "secret" -> secret = value - "name" -> name = value - } - } - if (secret == null) return null - return Offer(clientPubkey, relays, secret, name) + val parsed = NostrConnectURI.parseNostrConnect(uri) ?: return null + return Offer(parsed.clientPubKey, parsed.relays, parsed.secret, parsed.name) } private fun buildOffer( @@ -89,15 +70,7 @@ object NostrConnect { relays: Set, secret: String, name: String?, - ): String { - val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") } - return buildString { - append(NOSTRCONNECT_SCHEME).append(clientPubkey) - append("?").append(relays.joinToString("&") { "relay=${enc(it.url)}" }) - append("&secret=").append(enc(secret)) - if (name != null) append("&name=").append(enc(name)) - } - } + ): String = NostrConnectURI.buildNostrConnect(clientPubkey, relays, secret, name = name) /** * `amy login --nostrconnect [--relay URL[,URL…]] [--name N] [--timeout SECS]` From 81aea57ddb5016814e52860cd5ed6d5b8dc25c9c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:25:34 +0000 Subject: [PATCH 03/52] feat(commons): ledger-backed NIP-46 authorizer for Connected Apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nip46PermissionAuthorizer implements the quartz Nip46RequestAuthorizer by routing every remote-signer request through the shared Connected Apps permission ledger (NostrSignerPermissionLedger). A NIP-46 client becomes a connected app under the coordinate `nip46:`, so it reuses the same per-app trust levels and per-op overrides as napplets and web origins: - sign/encrypt/decrypt requests map to NostrSignerOp and are allowed only when the ledger's standing decision is ALLOW (ASK/DENY are refused — a background signer cannot prompt, so access is granted ahead of time in the UI). - connect validates the pairing secret, then registers the app at a default REASONABLE policy (never downgrading a level the user already set) and echoes the secret back. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../signers/Nip46PermissionAuthorizer.kt | 125 ++++++++++++++++++ .../signers/Nip46PermissionAuthorizerTest.kt | 110 +++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizerTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt new file mode 100644 index 0000000000..37fdca977a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet.signers + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46ConnectDecision +import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46RequestAuthorizer + +/** + * Bridges the NIP-46 signer core to Amethyst's shared "Connected Apps" trust + * model: a remote client that signs through Amethyst is a connected app just + * like a napplet or a sandboxed web origin, gated by the same + * [NostrSignerPermissionLedger] (per-app [AppSignerPolicy] + per-op + * [NostrOpDecision]) and surfaced on the same management screen. + * + * A NIP-46 client is identified by its transport pubkey, mapped to the ledger + * coordinate `nip46:` (see [coordinateFor]) so it lives in its + * own namespace next to `browser:` and napplet `:` keys. + * + * Authorization mirrors the napplet path: + * - each signing/encryption/decryption request maps to a [NostrSignerOp] + * ([sign:kind][NostrSignerOp.SignKind] / [encrypt][NostrSignerOp.Encrypt] / + * [decrypt][NostrSignerOp.Decrypt]) and is allowed only when the ledger's + * standing decision is [NostrOpDecision.ALLOW]. `ASK`/`DENY` are refused — + * a background signer cannot raise an interactive prompt, so the user grants + * access ahead of time by choosing a trust level (or a per-op override) in + * Connected Apps. + * - a `connect` request first validates the pairing secret via + * [validateSecret]; on success the app is registered at + * [defaultPolicyOnConnect] (only if it has no policy yet — never downgrading + * a level the user already chose), then [onConnected] runs so the host can + * record display metadata. + */ +class Nip46PermissionAuthorizer( + val ledger: NostrSignerPermissionLedger, + /** Validates the connect secret for a client (bunker secret, or the offer secret in the nostrconnect flow). */ + val validateSecret: suspend (clientPubKey: HexKey, offeredSecret: String?) -> Boolean, + /** Trust level assigned to a freshly paired app that has no policy yet. */ + val defaultPolicyOnConnect: AppSignerPolicy = AppSignerPolicy.REASONABLE, + /** Invoked after a successful connect so the host can persist display metadata (name/url/image). */ + val onConnected: (suspend (clientPubKey: HexKey, request: BunkerRequestConnect) -> Unit)? = null, +) : Nip46RequestAuthorizer { + override suspend fun onConnect( + clientPubKey: HexKey, + request: BunkerRequestConnect, + ): Nip46ConnectDecision { + if (!validateSecret(clientPubKey, request.secret)) { + return Nip46ConnectDecision.Reject("invalid secret") + } + + val coordinate = coordinateFor(clientPubKey) + if (!ledger.hasPolicy(coordinate)) { + ledger.setPolicy(coordinate, defaultPolicyOnConnect) + } + ledger.updateLastUsed(coordinate) + onConnected?.invoke(clientPubKey, request) + + // Echo the offered secret when present (the client validates it); otherwise ack. + val echo = request.secret?.takeIf { it.isNotEmpty() } ?: ACK + return Nip46ConnectDecision.Accept(echo) + } + + override suspend fun authorize( + clientPubKey: HexKey, + request: BunkerRequest, + ): Boolean { + // Requests the core routes here always map to an op; if a future method + // is added that does not, default to allow (the core only gates + // sign/encrypt/decrypt, so this branch is a safety net). + val op = request.toSignerOp() ?: return true + val coordinate = coordinateFor(clientPubKey) + val allowed = ledger.decide(coordinate, op) == NostrOpDecision.ALLOW + if (allowed) ledger.updateLastUsed(coordinate) + return allowed + } + + companion object { + /** Ledger coordinate namespace for NIP-46 remote-signer clients. */ + const val COORDINATE_PREFIX = "nip46" + + private const val ACK = "ack" + + /** The Connected-Apps ledger coordinate for a NIP-46 client, e.g. `nip46:`. */ + fun coordinateFor(clientPubKey: HexKey): String = "$COORDINATE_PREFIX:$clientPubKey" + + /** The client pubkey of a `nip46:` coordinate, or `null` if it is not one. */ + fun clientPubKeyOf(coordinate: String): HexKey? = if (coordinate.startsWith("$COORDINATE_PREFIX:")) coordinate.substringAfter(':') else null + + /** Maps a signing/encryption/decryption [BunkerRequest] to the [NostrSignerOp] it needs. */ + fun BunkerRequest.toSignerOp(): NostrSignerOp? = + when (this) { + is BunkerRequestSign -> NostrSignerOp.SignKind(event.kind) + is BunkerRequestNip04Encrypt -> NostrSignerOp.Encrypt + is BunkerRequestNip44Encrypt -> NostrSignerOp.Encrypt + is BunkerRequestNip04Decrypt -> NostrSignerOp.Decrypt + is BunkerRequestNip44Decrypt -> NostrSignerOp.Decrypt + else -> null + } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizerTest.kt new file mode 100644 index 0000000000..c0e1e89eb5 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizerTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet.signers + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46ConnectDecision +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class Nip46PermissionAuthorizerTest { + private val client = "c".repeat(64) + private val coordinate = Nip46PermissionAuthorizer.coordinateFor(client) + + private fun ledger() = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) + + private fun signRequest(kind: Int) = BunkerRequestSign("1", EventTemplate(createdAt = 1L, kind = kind, tags = emptyArray(), content = "x")) + + @Test + fun connectWithValidSecretRegistersReasonablePolicyAndEchoesSecret() = + runTest { + val ledger = ledger() + val authorizer = Nip46PermissionAuthorizer(ledger, validateSecret = { _, s -> s == "good" }) + + val decision = authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "good")) + + assertTrue(decision is Nip46ConnectDecision.Accept) + assertEquals("good", decision.ackSecret) + assertEquals(AppSignerPolicy.REASONABLE, ledger.store.loadPolicy(coordinate)) + } + + @Test + fun connectWithBadSecretRejectsAndDoesNotRegister() = + runTest { + val ledger = ledger() + val authorizer = Nip46PermissionAuthorizer(ledger, validateSecret = { _, s -> s == "good" }) + + val decision = authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "bad")) + + assertTrue(decision is Nip46ConnectDecision.Reject) + assertEquals(null, ledger.store.loadPolicy(coordinate)) + } + + @Test + fun connectDoesNotDowngradeAnExistingPolicy() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.FULL_TRUST) + val authorizer = Nip46PermissionAuthorizer(ledger, validateSecret = { _, _ -> true }) + + authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "x")) + + assertEquals(AppSignerPolicy.FULL_TRUST, ledger.store.loadPolicy(coordinate)) + } + + @Test + fun reasonableAppAllowsTextNoteButRefusesDecrypt() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) + val authorizer = Nip46PermissionAuthorizer(ledger, validateSecret = { _, _ -> true }) + + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertFalse(authorizer.authorize(client, BunkerRequestNip44Decrypt("2", client, "ct"))) + } + + @Test + fun paranoidAppRefusesEverythingUntilPerOpGrant() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + val authorizer = Nip46PermissionAuthorizer(ledger, validateSecret = { _, _ -> true }) + + assertFalse(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + + ledger.setOpDecision(coordinate, NostrSignerOp.SignKind(TextNoteEvent.KIND), NostrOpDecision.ALLOW) + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + } + + @Test + fun coordinateRoundTrips() { + assertEquals(client, Nip46PermissionAuthorizer.clientPubKeyOf(coordinate)) + assertEquals(null, Nip46PermissionAuthorizer.clientPubKeyOf("browser:https://x.com")) + } +} From c3a1762637f471f4174421b925dcffc2f4e3fac5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:25:38 +0000 Subject: [PATCH 04/52] feat(amethyst): act as a NIP-46 signer for other apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the "Nostr Signer" feature: Amethyst can now be a remote signer (a "bunker") for other apps, listening on the user's inbox relays and signing through whatever signer the account uses — a local key or a NIP-55 external app — gated by the shared Connected Apps trust ledger. - Nip46SignerState: account-scoped host that runs the quartz NostrConnectSignerService on the inbox relays whenever the feature is on, restarting on relay/toggle changes. Builds the bunker:// advertisement, handles nostrconnect:// paste pairing, and manages the pairing secret. Requests are authorized through Nip46PermissionAuthorizer (the Connected Apps ledger), so a remote client is a connected app under nip46:. - AccountSettings: persisted nip46SignerEnabled toggle + nip46BunkerSecret, wired through LocalPreferences. - Account/AccountCacheState/AppModules: build the signer ledger from the app-global Connected Apps store and construct the host per account. - UI: a Nostr Signer settings screen (enable toggle, listening status, copyable bunker address with secret regeneration, nostrconnect:// connect box, link to Connected Apps) + settings-catalog entry + route. Connected Apps renders nip46: clients as remote-signer cards with their trust chip. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../com/vitorpamplona/amethyst/AppModules.kt | 1 + .../amethyst/LocalPreferences.kt | 8 + .../vitorpamplona/amethyst/model/Account.kt | 23 ++ .../amethyst/model/AccountSettings.kt | 27 ++ .../model/accountsCache/AccountCacheState.kt | 5 + .../model/nip46Signer/Nip46SignerState.kt | 197 ++++++++++++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/napplets/ConnectedAppsScreen.kt | 82 ++++- .../settings/SettingsCatalogBuilder.kt | 1 + .../settings/nip46/Nip46SignerScreen.kt | 304 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 28 ++ 12 files changed, 673 insertions(+), 7 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 6dac151043..44ddf72ae9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -810,6 +810,7 @@ class AppModules( rootFilesDir = { appContext.filesDir }, powQueue = { powPublishQueue }, meterSigner = { MeteringNostrSigner(it, resourceUsage) }, + signerPermissionStore = signerPermissionStore, ) val sessionManager = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 3f17440e3b..fa6bfd9bc0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -109,6 +109,8 @@ private object PrefKeys { const val USE_LOCAL_BLOSSOM_CACHE = "useLocalBlossomCache" const val LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY = "localBlossomCacheProfilePicturesOnly" const val HIDE_COMMUNITY_RULES_VIOLATIONS = "hideCommunityRulesViolations" + const val NIP46_SIGNER_ENABLED = "nip46SignerEnabled" + const val NIP46_BUNKER_SECRET = "nip46BunkerSecret" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" @@ -465,6 +467,8 @@ object LocalPreferences { putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value) putBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, settings.localBlossomCacheProfilePicturesOnly.value) putBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, settings.hideCommunityRulesViolations.value) + putBoolean(PrefKeys.NIP46_SIGNER_ENABLED, settings.nip46SignerEnabled.value) + putString(PrefKeys.NIP46_BUNKER_SECRET, settings.nip46BunkerSecret.value) putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value)) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value)) @@ -675,6 +679,8 @@ object LocalPreferences { val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true) val localBlossomCacheProfilePicturesOnly = getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false) val hideCommunityRulesViolations = getBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, false) + val nip46SignerEnabled = getBoolean(PrefKeys.NIP46_SIGNER_ENABLED, false) + val nip46BunkerSecret = getString(PrefKeys.NIP46_BUNKER_SECRET, "") ?: "" val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) @@ -854,6 +860,8 @@ object LocalPreferences { useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache), localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly), hideCommunityRulesViolations = MutableStateFlow(hideCommunityRulesViolations), + nip46SignerEnabled = MutableStateFlow(nip46SignerEnabled), + nip46BunkerSecret = MutableStateFlow(nip46BunkerSecret), defaultHomeFollowList = MutableStateFlow(followListPrefs.home), defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories), defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index f94e7e73b2..29c9e0fc72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -54,6 +54,9 @@ import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDe import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache +import com.vitorpamplona.amethyst.commons.napplet.signers.InMemoryNostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage @@ -90,6 +93,7 @@ import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsState import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState +import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.GitRepositoryListState @@ -384,6 +388,7 @@ class Account( val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null, val powQueue: () -> PoWPublishQueue? = { null }, relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), + signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(), ) : IAccount { private var userProfileCache: User? = null @@ -438,6 +443,24 @@ class Account( val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings) val localRelayList = LocalRelayListState(signer, cache, scope, settings) + /** Connected-Apps signer permission ledger, shared by napplets and the NIP-46 bunker. */ + val signerPermissionLedger = NostrSignerPermissionLedger(signerPermissionStore) + + /** + * Runs this account as a NIP-46 remote signer for other apps when + * [AccountSettings.nip46SignerEnabled] is on, listening on the inbox relays + * and dispatching to [signer] (see [Nip46SignerState]). + */ + val nip46Signer = + Nip46SignerState( + signer = signer, + client = client, + ledger = signerPermissionLedger, + inboxRelays = nip65RelayList.inboxFlow, + scope = scope, + settings = settings, + ) + val forwardKind0ToLocalRelay = ForwardKind0ToLocalRelayState(client, localRelayList, settings) val dmRelayList = DmRelayListState(signer, cache, scope, settings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 70f5e57625..38eca3aa6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -185,6 +185,19 @@ class AccountSettings( var stripLocationOnUpload: Boolean = true, val useLocalBlossomCache: MutableStateFlow = MutableStateFlow(true), val localBlossomCacheProfilePicturesOnly: MutableStateFlow = MutableStateFlow(false), + /** + * NIP-46: when true, this account acts as a remote signer (a "bunker") for + * other apps, listening on the user's inbox relays for kind:24133 requests. + * See [com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState]. + */ + val nip46SignerEnabled: MutableStateFlow = MutableStateFlow(false), + /** + * The active pairing secret advertised in this account's `bunker://` URI. An + * app that connects with this secret is accepted and registered as a + * connected app; regenerating it revokes the ability of not-yet-connected + * apps to pair with an old string. + */ + val nip46BunkerSecret: MutableStateFlow = MutableStateFlow(""), /** * NIP-9B opt-in: when true, community feeds drop events whose latest cached * `kind:34551` rules document fails [com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator]. @@ -582,6 +595,20 @@ class AccountSettings( } } + fun changeNip46SignerEnabled(enabled: Boolean) { + if (nip46SignerEnabled.value != enabled) { + nip46SignerEnabled.tryEmit(enabled) + saveAccountSettings() + } + } + + fun changeNip46BunkerSecret(secret: String) { + if (nip46BunkerSecret.value != secret) { + nip46BunkerSecret.tryEmit(secret) + saveAccountSettings() + } + } + fun changeLocalBlossomCacheProfilePicturesOnly(enabled: Boolean) { if (localBlossomCacheProfilePicturesOnly.value != enabled) { localBlossomCacheProfilePicturesOnly.tryEmit(enabled) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 2fd320e03c..752e661140 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.model.accountsCache import android.content.ContentResolver import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.commons.napplet.signers.InMemoryNostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings @@ -66,6 +68,8 @@ class AccountCacheState( val powQueue: () -> PoWPublishQueue? = { null }, /** Optional resource-ledger wrapper applied to every account signer (see MeteringNostrSigner). */ val meterSigner: (NostrSigner) -> NostrSigner = { it }, + /** App-global Connected-Apps signer permission store (shared with napplets), gating the NIP-46 bunker. */ + val signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(), ) { val accounts = MutableStateFlow>(emptyMap()) @@ -258,6 +262,7 @@ class AccountCacheState( marmotKeyPackageStore = marmotKeyPackageStore, powQueue = powQueue, relayAuthPermissionStore = relayAuthPermissionStore, + signerPermissionStore = signerPermissionStore, ).also { newAccount -> accounts.update { existingAccounts -> existingAccounts.plus(Pair(signer.pubKey, newAccount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt new file mode 100644 index 0000000000..c91bc3ded3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip46Signer + +import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46PermissionAuthorizer +import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI +import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor +import com.vitorpamplona.quartz.nip46RemoteSigner.server.NostrConnectSignerService +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Runs Amethyst as a NIP-46 remote signer ("bunker") for the account, so other + * apps can sign through it. While [AccountSettings.nip46SignerEnabled] is on, a + * [NostrConnectSignerService] listens on the user's inbox relays (plus any relays + * pulled in by a pasted `nostrconnect://` offer) for kind:24133 requests, decrypts + * them with the account's own [signer] — a local key or a NIP-55 external app, + * whichever the user logged in with — and answers each one. + * + * Every request is gated by [Nip46PermissionAuthorizer], i.e. the same + * "Connected Apps" trust ledger that governs napplets and web origins: a remote + * client is a connected app under the coordinate `nip46:`. + * + * The listener restarts whenever the enabled flag or the relay set changes + * ([collectLatest] cancels the previous run), so editing inbox relays or toggling + * the feature takes effect immediately. + */ +class Nip46SignerState( + val signer: NostrSigner, + val client: INostrClient, + val ledger: NostrSignerPermissionLedger, + val inboxRelays: StateFlow>, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + /** Relays contributed by pasted `nostrconnect://` offers this session, unioned with the inbox set. */ + private val extraRelays = MutableStateFlow>(emptySet()) + + /** All relays the signer listens on: the account inbox plus any nostrconnect offer relays. */ + val listeningRelays: StateFlow> = + combine(inboxRelays, extraRelays) { inbox, extra -> inbox + extra } + .stateIn(scope, SharingStarted.Eagerly, inboxRelays.value) + + private val authorizer = + Nip46PermissionAuthorizer( + ledger = ledger, + validateSecret = { clientPubKey, offered -> + // A new app pairs with the current bunker secret; an already-connected app + // re-authenticates by identity (it already holds a trust level in the ledger). + val secret = settings.nip46BunkerSecret.value + (secret.isNotEmpty() && offered == secret) || + ledger.hasPolicy(Nip46PermissionAuthorizer.coordinateFor(clientPubKey)) + }, + ) + + init { + scope.launch(Dispatchers.IO) { + combine(settings.nip46SignerEnabled, listeningRelays) { enabled, relays -> enabled to relays } + .collectLatest { (enabled, relays) -> + if (!enabled) return@collectLatest + if (!signer.isWriteable()) { + Log.w("NIP46Signer") { "signer not writeable; cannot host a bunker" } + return@collectLatest + } + if (relays.isEmpty()) return@collectLatest + + val processor = BunkerRequestProcessor(signer, { listeningRelays.value }, authorizer) + val service = + NostrConnectSignerService( + client = client, + signer = signer, + processor = processor, + relays = relays, + onServiced = { method, clientPubKey, error -> + Log.d("NIP46Signer") { "$method from ${clientPubKey.take(8)}… → ${error ?: "ok"}" } + }, + ) + service.run() + } + } + } + + /** Whether the account is currently advertising itself as a signer. */ + val enabled: StateFlow get() = settings.nip46SignerEnabled + + fun setEnabled(enabled: Boolean) { + if (enabled) ensureSecret() + settings.changeNip46SignerEnabled(enabled) + } + + /** The `bunker://?relay=…&secret=…` string to paste into another app. Generates a secret if needed. */ + fun bunkerUri(): String { + val secret = ensureSecret() + return NostrConnectURI.buildBunker(signer.pubKey, inboxRelays.value, secret) + } + + /** Replaces the pairing secret with a fresh one, revoking the ability of not-yet-connected apps to use the old one. */ + fun regenerateSecret(): String { + val fresh = RandomInstance.randomChars(32) + settings.changeNip46BunkerSecret(fresh) + return fresh + } + + /** Returns the current pairing secret, generating and persisting one the first time. */ + private fun ensureSecret(): String { + val current = settings.nip46BunkerSecret.value + if (current.isNotEmpty()) return current + val fresh = RandomInstance.randomChars(32) + settings.changeNip46BunkerSecret(fresh) + return fresh + } + + /** + * The client-initiated (`nostrconnect://`) pairing flow: parse a client's + * offer, send the connect ack that echoes its secret (so the client learns + * our signer pubkey), register it as a connected app, and start listening on + * its relays. Enables the signer if it was off. + */ + suspend fun connectViaNostrConnect(uri: String): ConnectResult { + val offer = NostrConnectURI.parseNostrConnect(uri) ?: return ConnectResult.InvalidUri + if (offer.relays.isEmpty()) return ConnectResult.NoRelays + if (!signer.isWriteable()) return ConnectResult.NotWriteable + + return try { + // Echo the offer secret back to the client on its relays. + val ack = BunkerResponse(newSubId(), offer.secret, null) + val reply = NostrConnectEvent.create(ack, offer.clientPubKey, signer) + client.publish(reply, offer.relays) + + // Register the app (the paste is the user's consent) and listen on its relays. + val coordinate = Nip46PermissionAuthorizer.coordinateFor(offer.clientPubKey) + if (!ledger.hasPolicy(coordinate)) { + ledger.setPolicy(coordinate, authorizer.defaultPolicyOnConnect) + } + ledger.updateLastUsed(coordinate) + extraRelays.value = extraRelays.value + offer.relays + + setEnabled(true) + ConnectResult.Connected(offer.clientPubKey, offer.name) + } catch (e: Exception) { + Log.w("NIP46Signer") { "nostrconnect pairing failed: ${e.message}" } + ConnectResult.Failed(e.message ?: "unknown error") + } + } + + sealed interface ConnectResult { + data class Connected( + val clientPubKey: String, + val name: String?, + ) : ConnectResult + + data object InvalidUri : ConnectResult + + data object NoRelays : ConnectResult + + data object NotWriteable : ConnectResult + + data class Failed( + val reason: String, + ) : ConnectResult + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 78b2b0d7d3..5ab0cc7d03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -258,6 +258,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScree import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46SignerScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppDetailScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppsScreen @@ -423,6 +424,7 @@ fun BuildNavigation( composableFromEndArgs(capWidth = false) { NostrAppScreen(it.coordinate, accountViewModel, nav) } composableFromEnd { ConnectedAppsScreen(accountViewModel, nav) } composableFromEndArgs { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) } + composableFromEnd { Nip46SignerScreen(accountViewModel, nav) } composableFromEnd { RelayAuthSettingsScreen(accountViewModel, nav) } composableFromEndArgs { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { CalendarsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index e7c0a69b71..952668dd60 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -107,6 +107,8 @@ sealed class Route { @Serializable object ConnectedApps : Route() + @Serializable object Nip46Signer : Route() + @Serializable object RelayAuthSettings : Route() @Serializable data class ConnectedAppDetail( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index e32255cd30..7c9f310a73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46PermissionAuthorizer import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel @@ -107,11 +108,12 @@ fun ConnectedAppsScreen( loadConnectedApps(capabilityLedger, signerLedger) } items = initial - // Only include real pubkeys (not the "browser" sentinel) in the relay subscription. + // Only include real napplet authors in the manifest subscription — skip the "browser" + // sentinel and NIP-46 remote-signer clients (whose author segment is the "nip46" prefix). nappletAuthors = initial .map { it.coordinate.substringBefore(':') } - .filter { it != BROWSER_AUTHOR } + .filter { it != BROWSER_AUTHOR && it != Nip46PermissionAuthorizer.COORDINATE_PREFIX } .toSet() } @@ -201,11 +203,77 @@ private fun ConnectedAppCard( onClick: () -> Unit, ) { val author = remember(entry.coordinate) { entry.coordinate.substringBefore(':') } - if (author == BROWSER_AUTHOR) { - val url = remember(entry.coordinate) { entry.coordinate.substringAfter(':', "") } - BrowserAppCard(url = url, entry = entry, onClick = onClick) - } else { - NappletAppCard(author = author, entry = entry, untitled = untitled, onClick = onClick) + val nip46Client = remember(entry.coordinate) { Nip46PermissionAuthorizer.clientPubKeyOf(entry.coordinate) } + when { + author == BROWSER_AUTHOR -> { + val url = remember(entry.coordinate) { entry.coordinate.substringAfter(':', "") } + BrowserAppCard(url = url, entry = entry, onClick = onClick) + } + nip46Client != null -> RemoteSignerAppCard(clientPubKey = nip46Client, entry = entry, onClick = onClick) + else -> NappletAppCard(author = author, entry = entry, untitled = untitled, onClick = onClick) + } +} + +/** Card for a NIP-46 remote-signer client — an app that signs through Amethyst over relays. */ +@Composable +private fun RemoteSignerAppCard( + clientPubKey: HexKey, + entry: ConnectedAppEntry, + onClick: () -> Unit, +) { + val npub = remember(clientPubKey) { runCatching { NPub.create(clientPubKey) }.getOrDefault(clientPubKey.take(12) + "…") } + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + MaterialSymbols.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + stringResource(R.string.nip46_signer_remote_app), + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + npub, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (entry.signerPolicy != null) { + SuggestionChip( + onClick = {}, + label = { Text(entry.signerPolicy.shortLabel(), style = MaterialTheme.typography.labelSmall) }, + ) + } + Icon( + MaterialSymbols.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 5e85e88c94..fcebc84a5d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -75,6 +75,7 @@ fun buildSettingsCatalog( symEntry(R.string.profile_badges_title, MaterialSymbols.MilitaryTech, R.string.profile_badges_search_keywords, Route.ProfileBadges), symEntry(R.string.payment_targets, MaterialSymbols.Payment, R.string.payment_targets_search_keywords, Route.EditPaymentTargets), symEntry(R.string.napplet_permissions_title, MaterialSymbols.Apps, R.string.napplet_connected_apps_search_keywords, Route.ConnectedApps), + symEntry(R.string.nip46_signer_title, MaterialSymbols.Key, R.string.nip46_signer_search_keywords, Route.Nip46Signer), symEntry(R.string.relay_auth_settings_title, MaterialSymbols.Lock, R.string.relay_auth_search_keywords, Route.RelayAuthSettings), symEntry(R.string.security_filters, MaterialSymbols.Security, R.string.security_filters_search_keywords, Route.SecurityFilters), symEntry(R.string.call_settings, MaterialSymbols.Phone, R.string.call_settings_search_keywords, Route.CallSettings), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt new file mode 100644 index 0000000000..f4accf2d8c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -0,0 +1,304 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46 + +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +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.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import kotlinx.coroutines.launch + +@Composable +fun Nip46SignerScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + val signer = account.nip46Signer + val scope = rememberCoroutineScope() + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + + val enabled by account.settings.nip46SignerEnabled.collectAsStateWithLifecycle() + val secret by account.settings.nip46BunkerSecret.collectAsStateWithLifecycle() + val relays by signer.listeningRelays.collectAsStateWithLifecycle() + val writeable = remember { account.signer.isWriteable() } + + var bunkerUri by remember { mutableStateOf(null) } + // Recompute the advertised bunker URI whenever it is being shown or its inputs change. + LaunchedEffect(enabled, secret, relays) { + bunkerUri = if (enabled && writeable && relays.isNotEmpty()) signer.bunkerUri() else null + } + + Scaffold( + topBar = { TopBarWithBackButton(stringResource(R.string.nip46_signer_title), nav) }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + HeaderCard() + + if (!writeable) { + Text( + stringResource(R.string.nip46_signer_readonly), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + return@Column + } + + EnableRow(enabled = enabled, onToggle = { signer.setEnabled(it) }) + + if (enabled) { + if (relays.isEmpty()) { + Text( + stringResource(R.string.nip46_signer_status_no_relays), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } else { + Text( + pluralStringResource(R.plurals.nip46_signer_status_listening, relays.size, relays.size), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + bunkerUri?.let { uri -> + BunkerAddressCard( + uri = uri, + onCopy = { + clipboard.setText(AnnotatedString(uri)) + Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show() + }, + onRegenerate = { + signer.regenerateSecret() + Toast.makeText(context, R.string.nip46_signer_regenerated, Toast.LENGTH_SHORT).show() + }, + ) + } + + HorizontalDivider() + + ConnectAppSection( + onConnect = { uri -> + scope.launch { + val result = signer.connectViaNostrConnect(uri.trim()) + Toast.makeText(context, describe(context, result), Toast.LENGTH_SHORT).show() + } + }, + ) + + OutlinedButton( + onClick = { nav.nav(Route.ConnectedApps) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.nip46_signer_manage_apps)) + } + } + } + } +} + +@Composable +private fun HeaderCard() { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + MaterialSymbols.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(36.dp), + ) + Text( + stringResource(R.string.nip46_signer_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun EnableRow( + enabled: Boolean, + onToggle: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.nip46_signer_enable), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + Switch(checked = enabled, onCheckedChange = onToggle) + } +} + +@Composable +private fun BunkerAddressCard( + uri: String, + onCopy: () -> Unit, + onRegenerate: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + stringResource(R.string.nip46_signer_bunker_uri_label), + style = MaterialTheme.typography.titleSmall, + ) + Text( + uri, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Text( + stringResource(R.string.nip46_signer_bunker_uri_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button(onClick = onCopy, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.nip46_signer_copy)) + } + FilledTonalButton(onClick = onRegenerate, modifier = Modifier.weight(1f)) { + Text(stringResource(R.string.nip46_signer_regenerate)) + } + } + } + } +} + +@Composable +private fun ConnectAppSection(onConnect: (String) -> Unit) { + var input by remember { mutableStateOf("") } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.nip46_signer_connect_label), + style = MaterialTheme.typography.titleSmall, + ) + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text(stringResource(R.string.nip46_signer_connect_hint)) }, + ) + Button( + onClick = { + if (input.isNotBlank()) { + onConnect(input) + input = "" + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = input.isNotBlank(), + ) { + Text(stringResource(R.string.nip46_signer_connect_button)) + } + } +} + +private fun describe( + context: Context, + result: Nip46SignerState.ConnectResult, +): String = + when (result) { + is Nip46SignerState.ConnectResult.Connected -> + result.name?.let { context.getString(R.string.nip46_signer_connected_named, it) } + ?: context.getString(R.string.nip46_signer_connected_ok) + Nip46SignerState.ConnectResult.InvalidUri -> context.getString(R.string.nip46_signer_connect_invalid) + Nip46SignerState.ConnectResult.NoRelays -> context.getString(R.string.nip46_signer_connect_no_relays) + Nip46SignerState.ConnectResult.NotWriteable -> context.getString(R.string.nip46_signer_readonly) + is Nip46SignerState.ConnectResult.Failed -> context.getString(R.string.nip46_signer_connect_failed, result.reason) + } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d42255f6d0..105f369644 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -898,6 +898,34 @@ Ask Deny apps permissions signer napplet nsite webapp trust connected + + + Nostr Signer + signer bunker nip46 nostr connect remote sign + Let other apps sign with your key. Amethyst listens on your inbox relays and checks each app\'s permissions before signing — using the same trust levels as Connected Apps. + Act as a remote signer + + Listening on %1$d relay + Listening on %1$d relays + + No inbox relays configured. Add inbox relays so apps can reach your signer. + Your bunker address + Paste this into another app to connect it to your key. + Copy + Bunker address copied + New secret + Generated a new pairing secret + Connect an app + Paste a nostrconnect:// link + Connect + Manage connected apps + App connected + Connected %1$s + Not a valid nostrconnect:// link + That link carries no relay to connect on + Could not connect: %1$s + This is a read-only account and cannot sign. + Remote signer app Signing trust level Capabilities Forget this app From 9f32f72a4158ff3bf091faf993ac3edc104c12ae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 19:40:09 +0000 Subject: [PATCH 05/52] feat(amethyst): redesign the Nostr Signer screen around a scannable hero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the signer settings screen from a plain toggle list into a purpose- built pairing surface: - A disabled-state hero (key medallion, headline, one big "Turn on signer" call to action) that reads as a feature intro rather than a setting. - A live status card with an animated pulsing dot summarising "signing for N apps · listening on M relays". - A QR hero: the bunker:// address rendered as a large scannable QR (QrCodeDrawer) on a white surface — pairing is scan-first, with copy and new-secret as tonal actions and the raw string kept as a caption. - Scan-to-connect: a primary "Scan a code" button opening the QR scanner for nostrconnect:// offers, with paste-a-link as a revealable fallback. - A connected-apps row showing the live count and linking into Connected Apps. Reuses the existing QrCodeDrawer / SimpleQrCodeScanner composables. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../settings/nip46/Nip46SignerScreen.kt | 451 +++++++++++++----- amethyst/src/main/res/values/strings.xml | 10 + 2 files changed, 347 insertions(+), 114 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index f4accf2d8c..a609bcf60f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -22,54 +22,80 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46 import android.content.Context import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +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.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.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Switch 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.mutableIntStateOf 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.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46PermissionAuthorizer import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner import kotlinx.coroutines.launch +private val LiveGreen = Color(0xFF3DDC84) + @Composable fun Nip46SignerScreen( accountViewModel: AccountViewModel, @@ -86,11 +112,36 @@ fun Nip46SignerScreen( val relays by signer.listeningRelays.collectAsStateWithLifecycle() val writeable = remember { account.signer.isWriteable() } + var connectedCount by remember { mutableIntStateOf(0) } + var refreshKey by remember { mutableIntStateOf(0) } + var scanning by remember { mutableStateOf(false) } + var bunkerUri by remember { mutableStateOf(null) } - // Recompute the advertised bunker URI whenever it is being shown or its inputs change. LaunchedEffect(enabled, secret, relays) { bunkerUri = if (enabled && writeable && relays.isNotEmpty()) signer.bunkerUri() else null } + LaunchedEffect(enabled, refreshKey) { + connectedCount = + account.signerPermissionLedger.store + .allPolicies() + .keys + .count { it.startsWith("${Nip46PermissionAuthorizer.COORDINATE_PREFIX}:") } + } + + fun onConnect(uri: String) { + scope.launch { + val result = signer.connectViaNostrConnect(uri.trim()) + Toast.makeText(context, describe(context, result), Toast.LENGTH_LONG).show() + refreshKey++ + } + } + + if (scanning) { + SimpleQrCodeScanner { contents -> + scanning = false + contents?.let { onConnect(it) } + } + } Scaffold( topBar = { TopBarWithBackButton(stringResource(R.string.nip46_signer_title), nav) }, @@ -101,157 +152,219 @@ fun Nip46SignerScreen( .fillMaxSize() .padding(padding) .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), ) { - HeaderCard() - if (!writeable) { - Text( - stringResource(R.string.nip46_signer_readonly), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) + ReadOnlyNotice() return@Column } - EnableRow(enabled = enabled, onToggle = { signer.setEnabled(it) }) + if (!enabled) { + DisabledHero(onEnable = { signer.setEnabled(true) }) + return@Column + } - if (enabled) { - if (relays.isEmpty()) { - Text( - stringResource(R.string.nip46_signer_status_no_relays), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - } else { - Text( - pluralStringResource(R.plurals.nip46_signer_status_listening, relays.size, relays.size), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + LiveStatusCard( + relayCount = relays.size, + connectedCount = connectedCount, + onToggleOff = { signer.setEnabled(false) }, + ) - bunkerUri?.let { uri -> - BunkerAddressCard( - uri = uri, - onCopy = { - clipboard.setText(AnnotatedString(uri)) - Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show() - }, - onRegenerate = { - signer.regenerateSecret() - Toast.makeText(context, R.string.nip46_signer_regenerated, Toast.LENGTH_SHORT).show() - }, - ) - } + if (relays.isEmpty()) { + WarningCard(stringResource(R.string.nip46_signer_status_no_relays)) + } - HorizontalDivider() - - ConnectAppSection( - onConnect = { uri -> - scope.launch { - val result = signer.connectViaNostrConnect(uri.trim()) - Toast.makeText(context, describe(context, result), Toast.LENGTH_SHORT).show() - } + bunkerUri?.let { uri -> + QrHeroCard( + uri = uri, + onCopy = { + clipboard.setText(AnnotatedString(uri)) + Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show() + }, + onRegenerate = { + signer.regenerateSecret() + Toast.makeText(context, R.string.nip46_signer_regenerated, Toast.LENGTH_SHORT).show() }, ) - - OutlinedButton( - onClick = { nav.nav(Route.ConnectedApps) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.nip46_signer_manage_apps)) - } } + + ConnectSection( + onScan = { scanning = true }, + onPaste = { onConnect(it) }, + ) + + ConnectedAppsRow( + count = connectedCount, + onClick = { nav.nav(Route.ConnectedApps) }, + ) } } } +// ---------------------------------------------------------------------------- + @Composable -private fun HeaderCard() { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), +private fun DisabledHero(onEnable: () -> Unit) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), ) { - Row( - modifier = Modifier.padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), - verticalAlignment = Alignment.CenterVertically, + Box( + modifier = + Modifier + .size(104.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, ) { Icon( MaterialSymbols.Key, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(36.dp), - ) - Text( - stringResource(R.string.nip46_signer_explainer), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(52.dp), ) } + Text( + stringResource(R.string.nip46_signer_hero_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Text( + stringResource(R.string.nip46_signer_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Button( + onClick = onEnable, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon(MaterialSymbols.Key, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.nip46_signer_turn_on), fontWeight = FontWeight.SemiBold) + } } } @Composable -private fun EnableRow( - enabled: Boolean, - onToggle: (Boolean) -> Unit, +private fun LiveStatusCard( + relayCount: Int, + connectedCount: Int, + onToggleOff: () -> Unit, ) { - Row( + Card( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), ) { - Text( - stringResource(R.string.nip46_signer_enable), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.weight(1f), - ) - Switch(checked = enabled, onCheckedChange = onToggle) + Row( + modifier = Modifier.padding(18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + LiveDot() + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + stringResource(R.string.nip46_signer_live), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + Text( + buildString { + append(pluralStringResource(R.plurals.nip46_signer_connected_count, connectedCount, connectedCount)) + append(" · ") + append(pluralStringResource(R.plurals.nip46_signer_status_listening, relayCount, relayCount)) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + ) + } + Switch(checked = true, onCheckedChange = { onToggleOff() }) + } } } @Composable -private fun BunkerAddressCard( +private fun LiveDot() { + val transition = rememberInfiniteTransition(label = "live") + val alpha by transition.animateFloat( + initialValue = 0.25f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), + label = "pulse", + ) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(18.dp)) { + Canvas(Modifier.size(18.dp)) { drawCircle(color = LiveGreen, alpha = alpha * 0.35f) } + Canvas(Modifier.size(9.dp)) { drawCircle(color = LiveGreen) } + } +} + +@Composable +private fun QrHeroCard( uri: String, onCopy: () -> Unit, onRegenerate: () -> Unit, ) { Card( modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), ) { + Surface( + shape = RoundedCornerShape(20.dp), + color = Color.White, + ) { + QrCodeDrawer( + contents = uri, + modifier = + Modifier + .padding(16.dp) + .size(232.dp), + ) + } Text( - stringResource(R.string.nip46_signer_bunker_uri_label), - style = MaterialTheme.typography.titleSmall, + stringResource(R.string.nip46_signer_scan_caption), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, ) Text( uri, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.labelSmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 3, + maxLines = 2, overflow = TextOverflow.Ellipsis, - ) - Text( - stringResource(R.string.nip46_signer_bunker_uri_hint), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, ) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Button(onClick = onCopy, modifier = Modifier.weight(1f)) { + FilledTonalButton(onClick = onCopy, modifier = Modifier.weight(1f)) { + Icon(MaterialSymbols.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.nip46_signer_copy)) } FilledTonalButton(onClick = onRegenerate, modifier = Modifier.weight(1f)) { + Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) Text(stringResource(R.string.nip46_signer_regenerate)) } } @@ -260,31 +373,141 @@ private fun BunkerAddressCard( } @Composable -private fun ConnectAppSection(onConnect: (String) -> Unit) { +private fun ConnectSection( + onScan: () -> Unit, + onPaste: (String) -> Unit, +) { + var showPaste by remember { mutableStateOf(false) } var input by remember { mutableStateOf("") } - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( stringResource(R.string.nip46_signer_connect_label), style = MaterialTheme.typography.titleSmall, - ) - OutlinedTextField( - value = input, - onValueChange = { input = it }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - placeholder = { Text(stringResource(R.string.nip46_signer_connect_hint)) }, + fontWeight = FontWeight.SemiBold, ) Button( - onClick = { - if (input.isNotBlank()) { - onConnect(input) - input = "" - } - }, - modifier = Modifier.fillMaxWidth(), - enabled = input.isNotBlank(), + onClick = onScan, + modifier = + Modifier + .fillMaxWidth() + .height(54.dp), + shape = RoundedCornerShape(16.dp), ) { - Text(stringResource(R.string.nip46_signer_connect_button)) + Icon(MaterialSymbols.CameraAlt, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.nip46_signer_scan_connect), fontWeight = FontWeight.SemiBold) + } + + TextButton(onClick = { showPaste = !showPaste }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.nip46_signer_paste_link)) + } + + AnimatedVisibility(visible = showPaste) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + placeholder = { Text(stringResource(R.string.nip46_signer_connect_hint)) }, + ) + Button( + onClick = { + if (input.isNotBlank()) { + onPaste(input) + input = "" + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = input.isNotBlank(), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.filledTonalButtonColors(), + ) { + Text(stringResource(R.string.nip46_signer_connect_button)) + } + } + } + } +} + +@Composable +private fun ConnectedAppsRow( + count: Int, + onClick: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Icon( + MaterialSymbols.Apps, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.nip46_signer_manage_apps), + style = MaterialTheme.typography.titleSmall, + ) + Text( + pluralStringResource(R.plurals.nip46_signer_connected_count, count, count), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + MaterialSymbols.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + } +} + +@Composable +private fun WarningCard(message: String) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + ) { + Text( + message, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } +} + +@Composable +private fun ReadOnlyNotice() { + Card( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(18.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(MaterialSymbols.Key, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(28.dp)) + Text( + stringResource(R.string.nip46_signer_readonly), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 105f369644..5b43f12ee2 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -926,6 +926,16 @@ Could not connect: %1$s This is a read-only account and cannot sign. Remote signer app + Sign for other apps + Turn on signer + Live + Scan to connect an app to your key + Scan a code + Paste a link instead + + %1$d connected app + %1$d connected apps + Signing trust level Capabilities Forget this app From d46ac987e899380308f9af4a8cd9266597d4bcdc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 21:15:27 +0000 Subject: [PATCH 06/52] feat(quartz): harden signer service, add logout + end-to-end loopback test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NostrConnectSignerService now bounds its request-id dedup set (LinkedHashSet with an evicting cap) so a long-lived bunker can't leak memory on the ids it has seen. - Add NIP-46 `logout`: the processor recognises the method, acks it, and calls a new Nip46RequestAuthorizer.onLogout hook (default no-op) so a host can revoke the app's grant when it disconnects. - New NostrConnectSignerServiceTest drives full request→reply round trips (connect/sign/logout + drop-if-not-addressed) through the service over a fake relay client with passthrough signers — headless proof of the subscribe → decrypt → dispatch → publish wiring. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../server/BunkerRequestProcessor.kt | 13 +- .../server/Nip46RequestAuthorizer.kt | 7 + .../server/NostrConnectSignerService.kt | 15 +- .../server/NostrConnectSignerServiceTest.kt | 239 ++++++++++++++++++ 4 files changed, 272 insertions(+), 2 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt index f14967e354..efb7454bd3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError @@ -120,7 +121,14 @@ class BunkerRequestProcessor( BunkerResponseDecrypt(request.id, signer.nip44Decrypt(request.ciphertext, request.pubKey)) } - else -> BunkerResponseError(request.id, "unsupported method: ${request.method}") + else -> + when (request.method) { + METHOD_LOGOUT -> { + authorizer.onLogout(clientPubKey) + BunkerResponseAck(request.id) + } + else -> BunkerResponseError(request.id, "unsupported method: ${request.method}") + } } } catch (e: Exception) { BunkerResponseError(request.id, "${e::class.simpleName}: ${e.message}") @@ -143,5 +151,8 @@ class BunkerRequestProcessor( /** Error result returned when [Nip46RequestAuthorizer.authorize] denies a request. */ const val ERROR_UNAUTHORIZED: String = "unauthorized" + + /** NIP-46 `logout` method name — the client asks to be disconnected. */ + const val METHOD_LOGOUT: String = "logout" } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt index 126093bdc8..670208581b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt @@ -61,6 +61,13 @@ interface Nip46RequestAuthorizer { clientPubKey: HexKey, request: BunkerRequest, ): Boolean + + /** + * Called when a client sends a `logout` request — the client is asking to be + * disconnected. Implementations typically revoke the app's stored grant so a + * later request has to pair again. The default is a no-op. + */ + suspend fun onLogout(clientPubKey: HexKey) {} } /** The verdict for a NIP-46 `connect` request. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index a9ed466a9a..41317dad27 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -56,6 +56,12 @@ class NostrConnectSignerService( val relays: Set, /** Optional hook, invoked with each serviced request's method + client, for logging/metrics. */ val onServiced: ((method: String, clientPubKey: String, error: String?) -> Unit)? = null, + /** + * Upper bound on the request-id dedup set. A long-lived signer would otherwise + * accumulate every request id it ever saw; past this many, the oldest ids are + * evicted (they are far past any realistic same-request retry window). + */ + val seenCap: Int = 4096, ) { /** * Subscribes and services requests until cancelled. Duplicate events (the @@ -70,7 +76,8 @@ class NostrConnectSignerService( val self = signer.pubKey val events = Channel(UNLIMITED) - val seen = mutableSetOf() + // Insertion-ordered so the oldest id can be evicted once the cap is hit. + val seen = LinkedHashSet() val subId = newSubId() val listener = object : SubscriptionListener { @@ -81,6 +88,12 @@ class NostrConnectSignerService( forFilters: List?, ) { if (event is NostrConnectEvent && event.verifiedRecipientPubKey() == self && seen.add(event.id)) { + if (seen.size > seenCap) { + seen.iterator().let { + it.next() + it.remove() + } + } events.trySend(event) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt new file mode 100644 index 0000000000..f18ff2f48f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip46RemoteSigner.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +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.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +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.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * End-to-end wiring test for the signer service: a client crafts a real + * [NostrConnectEvent], the service (over a fake relay client) decrypts it, + * dispatches through the processor, and publishes a reply the client can read. + * + * Crypto is stubbed with a passthrough signer (NIP-44 is unavailable in + * commonTest), so this exercises the subscribe → decrypt → dispatch → publish + * plumbing and the JSON round-trip, not the cipher itself. + */ +class NostrConnectSignerServiceTest { + private val serverKey = "a".repeat(64) + private val clientKey = "b".repeat(64) + private val relay: NormalizedRelayUrl = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + + /** Passthrough crypto + real event construction, so NostrConnectEvent.create/decryptMessage round-trip. */ + private class PassthroughSigner( + pubKey: HexKey, + ) : NostrSigner(pubKey) { + override fun isWriteable() = true + + @Suppress("UNCHECKED_CAST") + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T { + val id = RandomInstance.randomChars(64) + val sig = "0".repeat(128) + // Transport events must stay NostrConnectEvent (kind 24133 is baked in); + // any other kind is the actual event a sign_event request asked us to sign. + val event = + if (kind == NostrConnectEvent.KIND) { + NostrConnectEvent(id, pubKey, createdAt, tags, content, sig) + } else { + Event(id, pubKey, createdAt, kind, tags, content, sig) + } + return event as T + } + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = plaintext + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = ciphertext + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = plaintext + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = ciphertext + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw NotImplementedError() + + override suspend fun deriveKey(nonce: HexKey): HexKey = throw NotImplementedError() + + override suspend fun signPsbt(psbtHex: String): String = throw NotImplementedError() + + override fun hasForegroundSupport() = false + } + + /** Captures the service's subscription listener and records published replies. */ + private class LoopbackClient : INostrClient by EmptyNostrClient() { + var listener: SubscriptionListener? = null + val published = mutableListOf() + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + this.listener = listener + } + + override fun publish( + event: Event, + relayList: Set, + ) { + published.add(event) + } + + fun deliver(event: Event) { + listener?.onEvent(event, isLive = true, relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!, forFilters = null) + } + } + + private class AllowAuthorizer : Nip46RequestAuthorizer { + var logoutCalls = 0 + + override suspend fun onConnect( + clientPubKey: HexKey, + request: BunkerRequestConnect, + ) = Nip46ConnectDecision.Accept(request.secret ?: "ack") + + override suspend fun authorize( + clientPubKey: HexKey, + request: BunkerRequest, + ) = true + + override suspend fun onLogout(clientPubKey: HexKey) { + logoutCalls++ + } + } + + private fun serverSigner() = PassthroughSigner(serverKey) + + private fun clientSigner() = PassthroughSigner(clientKey) + + /** Builds the encrypted kind-24133 request the client would publish to the bunker. */ + private suspend fun request(message: BunkerRequest): NostrConnectEvent = NostrConnectEvent.create(message, remoteKey = serverKey, signer = clientSigner()) + + @Test + fun connectRequestGetsAckReply() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + client.deliver(request(BunkerRequestConnect(id = "req1", remoteKey = serverKey, secret = "s3cr3t"))) + + assertEquals(1, client.published.size) + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) as BunkerResponse + assertEquals("req1", reply.id) + assertEquals("s3cr3t", reply.result) + } + + @Test + fun signRequestGetsSignedEventReply() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hello") + client.deliver(request(BunkerRequestSign(id = "req2", event = template))) + + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) + assertTrue(reply is BunkerResponseEvent) + assertEquals("req2", reply.id) + assertEquals(1, reply.event.kind) + } + + @Test + fun logoutRequestInvokesAuthorizerAndAcks() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val authorizer = AllowAuthorizer() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, authorizer) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + client.deliver(request(BunkerRequest(id = "req3", method = BunkerRequestProcessor.METHOD_LOGOUT))) + + assertEquals(1, authorizer.logoutCalls) + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) as BunkerResponse + assertEquals("ack", reply.result) + } + + @Test + fun requestNotAddressedToUsIsIgnored() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + // p-tagged to someone else → the listener drops it before decryption. + val strayRecipient = "c".repeat(64) + client.deliver(NostrConnectEvent.create(BunkerRequestConnect(id = "x", remoteKey = strayRecipient), remoteKey = strayRecipient, signer = clientSigner())) + + assertTrue(client.published.isEmpty()) + } +} From dea711596eb365b8ba301e73713430aa64cab2cd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 21:21:58 +0000 Subject: [PATCH 07/52] feat: namespace NIP-46 grants by signer + revoke on logout The Connected Apps signer store is app-global, so a remote client keyed only by its own pubkey would share one trust level across every local account. Namespace the coordinate as `nip46::` so the same client paired with two accounts on one device gets independent grants. - Nip46PermissionAuthorizer takes the user's signerPubKey; coordinateFor/belongsTo encode + match the namespace; clientPubKeyOf reads the trailing segment. - onLogout now revokes the client's grant (wired through the new quartz hook). - Connected Apps lists only the active account's remote clients (napplet/browser grants stay app-global); the signer screen counts the same way. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../model/nip46Signer/Nip46SignerState.kt | 5 +-- .../loggedIn/napplets/ConnectedAppsScreen.kt | 10 ++++-- .../settings/nip46/Nip46SignerScreen.kt | 2 +- .../signers/Nip46PermissionAuthorizer.kt | 31 +++++++++++++--- .../signers/Nip46PermissionAuthorizerTest.kt | 35 +++++++++++++++---- 5 files changed, 68 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index c91bc3ded3..fad4f4e7f1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -79,12 +79,13 @@ class Nip46SignerState( private val authorizer = Nip46PermissionAuthorizer( ledger = ledger, + signerPubKey = signer.pubKey, validateSecret = { clientPubKey, offered -> // A new app pairs with the current bunker secret; an already-connected app // re-authenticates by identity (it already holds a trust level in the ledger). val secret = settings.nip46BunkerSecret.value (secret.isNotEmpty() && offered == secret) || - ledger.hasPolicy(Nip46PermissionAuthorizer.coordinateFor(clientPubKey)) + ledger.hasPolicy(Nip46PermissionAuthorizer.coordinateFor(signer.pubKey, clientPubKey)) }, ) @@ -163,7 +164,7 @@ class Nip46SignerState( client.publish(reply, offer.relays) // Register the app (the paste is the user's consent) and listen on its relays. - val coordinate = Nip46PermissionAuthorizer.coordinateFor(offer.clientPubKey) + val coordinate = authorizer.coordinateFor(offer.clientPubKey) if (!ledger.hasPolicy(coordinate)) { ledger.setPolicy(coordinate, authorizer.defaultPolicyOnConnect) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 7c9f310a73..4fd4fc0fea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -105,7 +105,7 @@ fun ConnectedAppsScreen( LaunchedEffect(Unit) { val initial = withContext(Dispatchers.Default) { - loadConnectedApps(capabilityLedger, signerLedger) + loadConnectedApps(capabilityLedger, signerLedger, accountViewModel.account.signer.pubKey) } items = initial // Only include real napplet authors in the manifest subscription — skip the "browser" @@ -442,12 +442,18 @@ private fun AppSignerPolicy.shortLabel(): String = private suspend fun loadConnectedApps( capabilityLedger: NappletPermissionLedger, signerLedger: NostrSignerPermissionLedger, + signerPubKey: HexKey, ): List { val capGrants = capabilityLedger.allPersistedGrants() val signerPolicies = signerLedger.store.allPolicies() val allCoordinates = (capGrants.keys + signerPolicies.keys).toSet() return allCoordinates - .map { coordinate -> + // NIP-46 grants are namespaced by signer, so only surface this account's remote clients; + // napplet/browser grants stay app-global and are shown for every account as before. + .filter { coordinate -> + coordinate.substringBefore(':') != Nip46PermissionAuthorizer.COORDINATE_PREFIX || + Nip46PermissionAuthorizer.belongsTo(coordinate, signerPubKey) + }.map { coordinate -> ConnectedAppEntry( coordinate = coordinate, signerPolicy = signerPolicies[coordinate], diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index a609bcf60f..c15648de23 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -125,7 +125,7 @@ fun Nip46SignerScreen( account.signerPermissionLedger.store .allPolicies() .keys - .count { it.startsWith("${Nip46PermissionAuthorizer.COORDINATE_PREFIX}:") } + .count { Nip46PermissionAuthorizer.belongsTo(it, account.signer.pubKey) } } fun onConnect(uri: String) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt index 37fdca977a..bf7cc7c6f7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt @@ -58,6 +58,8 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46RequestAuthorizer */ class Nip46PermissionAuthorizer( val ledger: NostrSignerPermissionLedger, + /** The user's own signer pubkey — namespaces this account's grants in the app-global store. */ + val signerPubKey: HexKey, /** Validates the connect secret for a client (bunker secret, or the offer secret in the nostrconnect flow). */ val validateSecret: suspend (clientPubKey: HexKey, offeredSecret: String?) -> Boolean, /** Trust level assigned to a freshly paired app that has no policy yet. */ @@ -65,6 +67,9 @@ class Nip46PermissionAuthorizer( /** Invoked after a successful connect so the host can persist display metadata (name/url/image). */ val onConnected: (suspend (clientPubKey: HexKey, request: BunkerRequestConnect) -> Unit)? = null, ) : Nip46RequestAuthorizer { + /** The ledger coordinate for [clientPubKey] under this account. */ + fun coordinateFor(clientPubKey: HexKey): String = coordinateFor(signerPubKey, clientPubKey) + override suspend fun onConnect( clientPubKey: HexKey, request: BunkerRequestConnect, @@ -99,17 +104,35 @@ class Nip46PermissionAuthorizer( return allowed } + override suspend fun onLogout(clientPubKey: HexKey) { + // The client asked to disconnect — drop its standing grant so it must pair again. + ledger.revokeAll(coordinateFor(clientPubKey)) + } + companion object { /** Ledger coordinate namespace for NIP-46 remote-signer clients. */ const val COORDINATE_PREFIX = "nip46" private const val ACK = "ack" - /** The Connected-Apps ledger coordinate for a NIP-46 client, e.g. `nip46:`. */ - fun coordinateFor(clientPubKey: HexKey): String = "$COORDINATE_PREFIX:$clientPubKey" + /** + * The Connected-Apps ledger coordinate for a NIP-46 client, namespaced by + * the user's signer so the same client paired with two accounts on one + * device gets independent grants: `nip46::`. + */ + fun coordinateFor( + signerPubKey: HexKey, + clientPubKey: HexKey, + ): String = "$COORDINATE_PREFIX:$signerPubKey:$clientPubKey" - /** The client pubkey of a `nip46:` coordinate, or `null` if it is not one. */ - fun clientPubKeyOf(coordinate: String): HexKey? = if (coordinate.startsWith("$COORDINATE_PREFIX:")) coordinate.substringAfter(':') else null + /** True when [coordinate] is a NIP-46 grant belonging to [signerPubKey]. */ + fun belongsTo( + coordinate: String, + signerPubKey: HexKey, + ): Boolean = coordinate.startsWith("$COORDINATE_PREFIX:$signerPubKey:") + + /** The client pubkey of a `nip46::` coordinate, or `null` if it is not one. */ + fun clientPubKeyOf(coordinate: String): HexKey? = if (coordinate.startsWith("$COORDINATE_PREFIX:")) coordinate.substringAfterLast(':') else null /** Maps a signing/encryption/decryption [BunkerRequest] to the [NostrSignerOp] it needs. */ fun BunkerRequest.toSignerOp(): NostrSignerOp? = diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizerTest.kt index c0e1e89eb5..ff4d2f02f8 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizerTest.kt @@ -34,8 +34,9 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class Nip46PermissionAuthorizerTest { + private val signer = "a".repeat(64) private val client = "c".repeat(64) - private val coordinate = Nip46PermissionAuthorizer.coordinateFor(client) + private val coordinate = Nip46PermissionAuthorizer.coordinateFor(signer, client) private fun ledger() = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) @@ -45,7 +46,7 @@ class Nip46PermissionAuthorizerTest { fun connectWithValidSecretRegistersReasonablePolicyAndEchoesSecret() = runTest { val ledger = ledger() - val authorizer = Nip46PermissionAuthorizer(ledger, validateSecret = { _, s -> s == "good" }) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, s -> s == "good" }) val decision = authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "good")) @@ -58,7 +59,7 @@ class Nip46PermissionAuthorizerTest { fun connectWithBadSecretRejectsAndDoesNotRegister() = runTest { val ledger = ledger() - val authorizer = Nip46PermissionAuthorizer(ledger, validateSecret = { _, s -> s == "good" }) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, s -> s == "good" }) val decision = authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "bad")) @@ -71,7 +72,7 @@ class Nip46PermissionAuthorizerTest { runTest { val ledger = ledger() ledger.setPolicy(coordinate, AppSignerPolicy.FULL_TRUST) - val authorizer = Nip46PermissionAuthorizer(ledger, validateSecret = { _, _ -> true }) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "x")) @@ -83,7 +84,7 @@ class Nip46PermissionAuthorizerTest { runTest { val ledger = ledger() ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) - val authorizer = Nip46PermissionAuthorizer(ledger, validateSecret = { _, _ -> true }) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) assertFalse(authorizer.authorize(client, BunkerRequestNip44Decrypt("2", client, "ct"))) @@ -94,7 +95,7 @@ class Nip46PermissionAuthorizerTest { runTest { val ledger = ledger() ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) - val authorizer = Nip46PermissionAuthorizer(ledger, validateSecret = { _, _ -> true }) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) assertFalse(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) @@ -107,4 +108,26 @@ class Nip46PermissionAuthorizerTest { assertEquals(client, Nip46PermissionAuthorizer.clientPubKeyOf(coordinate)) assertEquals(null, Nip46PermissionAuthorizer.clientPubKeyOf("browser:https://x.com")) } + + @Test + fun sameClientOnTwoAccountsGetsIndependentCoordinates() { + val otherSigner = "d".repeat(64) + val a = Nip46PermissionAuthorizer.coordinateFor(signer, client) + val b = Nip46PermissionAuthorizer.coordinateFor(otherSigner, client) + assertTrue(a != b) + assertTrue(Nip46PermissionAuthorizer.belongsTo(a, signer)) + assertFalse(Nip46PermissionAuthorizer.belongsTo(a, otherSigner)) + } + + @Test + fun logoutRevokesTheClientsGrant() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.FULL_TRUST) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) + + authorizer.onLogout(client) + + assertEquals(null, ledger.store.loadPolicy(coordinate)) + } } From 9667168a6a285a805db5b4c3dbae46dabfe8585f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 21:28:51 +0000 Subject: [PATCH 08/52] feat: persist connected NIP-46 client metadata + relays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Nip46ClientStore (commons interface + InMemory + a single-file Android DataStore) keyed by the same signer-namespaced coordinate as the permission ledger, holding each connected client's self-declared name/url/image and the relays it reaches us on. - The host persists metadata on connect (bunker + nostrconnect) and, for the nostrconnect flow, the app's own relays. On startup it re-adds those relays to the listen set, so a nostrconnect-paired app stays reachable across app restarts instead of silently going dark until it re-pairs. - Connected Apps now shows the app's real name (falling back to the generic label + npub) for remote-signer clients. - Wired the store through AppModules → AccountCacheState → Account → host. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../com/vitorpamplona/amethyst/AppModules.kt | 5 + .../vitorpamplona/amethyst/model/Account.kt | 4 + .../model/accountsCache/AccountCacheState.kt | 5 + .../model/nip46Signer/Nip46SignerState.kt | 34 +++++ .../napplet/DataStoreNip46ClientStore.kt | 128 ++++++++++++++++++ .../loggedIn/napplets/ConnectedAppsScreen.kt | 8 +- .../napplet/signers/Nip46ClientStore.kt | 82 +++++++++++ 7 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNip46ClientStore.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46ClientStore.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 44ddf72ae9..1e77048fcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector import com.vitorpamplona.amethyst.model.torState.TorRelayState import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore +import com.vitorpamplona.amethyst.napplet.DataStoreNip46ClientStore import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker @@ -694,6 +695,9 @@ class AppModules( val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) } val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) } + // Display + relay info for connected NIP-46 remote-signer clients. + val nip46ClientStore by lazy { DataStoreNip46ClientStore(appContext) } + // Authenticates with relays. val authCoordinator = AuthCoordinator(client, applicationIOScope) @@ -811,6 +815,7 @@ class AppModules( powQueue = { powPublishQueue }, meterSigner = { MeteringNostrSigner(it, resourceUsage) }, signerPermissionStore = signerPermissionStore, + nip46ClientStore = nip46ClientStore, ) val sessionManager = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 29c9e0fc72..a3b1693983 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -54,7 +54,9 @@ import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDe import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache +import com.vitorpamplona.amethyst.commons.napplet.signers.InMemoryNip46ClientStore import com.vitorpamplona.amethyst.commons.napplet.signers.InMemoryNostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46ClientStore import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError @@ -389,6 +391,7 @@ class Account( val powQueue: () -> PoWPublishQueue? = { null }, relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(), + nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(), ) : IAccount { private var userProfileCache: User? = null @@ -456,6 +459,7 @@ class Account( signer = signer, client = client, ledger = signerPermissionLedger, + clientStore = nip46ClientStore, inboxRelays = nip65RelayList.inboxFlow, scope = scope, settings = settings, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 752e661140..54c78f286b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -22,7 +22,9 @@ package com.vitorpamplona.amethyst.model.accountsCache import android.content.ContentResolver import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.commons.napplet.signers.InMemoryNip46ClientStore import com.vitorpamplona.amethyst.commons.napplet.signers.InMemoryNostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46ClientStore import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.model.Account @@ -70,6 +72,8 @@ class AccountCacheState( val meterSigner: (NostrSigner) -> NostrSigner = { it }, /** App-global Connected-Apps signer permission store (shared with napplets), gating the NIP-46 bunker. */ val signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(), + /** App-global store of connected NIP-46 client display + relay info. */ + val nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(), ) { val accounts = MutableStateFlow>(emptyMap()) @@ -263,6 +267,7 @@ class AccountCacheState( powQueue = powQueue, relayAuthPermissionStore = relayAuthPermissionStore, signerPermissionStore = signerPermissionStore, + nip46ClientStore = nip46ClientStore, ).also { newAccount -> accounts.update { existingAccounts -> existingAccounts.plus(Pair(signer.pubKey, newAccount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index fad4f4e7f1..234e93821d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -20,12 +20,15 @@ */ package com.vitorpamplona.amethyst.model.nip46Signer +import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46ClientInfo +import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46ClientStore import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46PermissionAuthorizer import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent @@ -64,6 +67,7 @@ class Nip46SignerState( val signer: NostrSigner, val client: INostrClient, val ledger: NostrSignerPermissionLedger, + val clientStore: Nip46ClientStore, val inboxRelays: StateFlow>, val scope: CoroutineScope, val settings: AccountSettings, @@ -87,9 +91,34 @@ class Nip46SignerState( (secret.isNotEmpty() && offered == secret) || ledger.hasPolicy(Nip46PermissionAuthorizer.coordinateFor(signer.pubKey, clientPubKey)) }, + onConnected = { clientPubKey, request -> + // A bunker-flow client talks to us on the inbox relays we always listen on, so we + // only persist its self-declared display metadata (never as authorization — just a label). + val meta = request.clientMetadata + if (meta != null && !meta.isEmpty()) { + clientStore.store( + Nip46PermissionAuthorizer.coordinateFor(signer.pubKey, clientPubKey), + Nip46ClientInfo(name = meta.name, url = meta.url, image = meta.image), + ) + } + }, ) init { + // Recover the relays of `nostrconnect://`-paired apps so they stay reachable across restarts + // (bunker-flow apps use the inbox relays, which are already in the listen set). + scope.launch(Dispatchers.IO) { + val recovered = + clientStore + .all() + .filterKeys { Nip46PermissionAuthorizer.belongsTo(it, signer.pubKey) } + .values + .flatMap { it.relays } + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + if (recovered.isNotEmpty()) extraRelays.value = extraRelays.value + recovered + } + scope.launch(Dispatchers.IO) { combine(settings.nip46SignerEnabled, listeningRelays) { enabled, relays -> enabled to relays } .collectLatest { (enabled, relays) -> @@ -169,6 +198,11 @@ class Nip46SignerState( ledger.setPolicy(coordinate, authorizer.defaultPolicyOnConnect) } ledger.updateLastUsed(coordinate) + // Persist the app's label + its relays so it survives a restart, then start listening now. + clientStore.store( + coordinate, + Nip46ClientInfo(name = offer.name, url = offer.url, image = offer.image, relays = offer.relays.map { it.url }.toSet()), + ) extraRelays.value = extraRelays.value + offer.relays setEnabled(true) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNip46ClientStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNip46ClientStore.kt new file mode 100644 index 0000000000..90ddacb231 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNip46ClientStore.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46ClientInfo +import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46ClientStore +import kotlinx.coroutines.flow.first +import java.io.File +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap + +/** + * Single-file DataStore-backed [Nip46ClientStore]. Every connected client's + * display + relay info lives in one `datastore/nip46_clients.preferences_pb` + * file; a SHA-256 prefix of the coordinate is the key so the (already public) + * coordinate is kept alongside for [all]'s reverse lookup. Fields are stored + * individually so no serialization library is needed; [relays] is newline-joined. + */ +class DataStoreNip46ClientStore( + private val filesDir: File, +) : Nip46ClientStore { + constructor(context: Context) : this(context.applicationContext.filesDir) + + private val store: DataStore get() = dataStoreFor(File(filesDir, "datastore/nip46_clients.preferences_pb")) + + override suspend fun load(coordinate: String): Nip46ClientInfo? { + val prefs = store.data.first() + if (prefs[coordKey(coordinate)] == null) return null + return Nip46ClientInfo( + name = prefs[nameKey(coordinate)], + url = prefs[urlKey(coordinate)], + image = prefs[imageKey(coordinate)], + relays = prefs[relaysKey(coordinate)].toRelaySet(), + ) + } + + override suspend fun store( + coordinate: String, + info: Nip46ClientInfo, + ) { + store.edit { prefs -> + prefs[coordKey(coordinate)] = coordinate + info.name?.let { prefs[nameKey(coordinate)] = it } ?: prefs.remove(nameKey(coordinate)) + info.url?.let { prefs[urlKey(coordinate)] = it } ?: prefs.remove(urlKey(coordinate)) + info.image?.let { prefs[imageKey(coordinate)] = it } ?: prefs.remove(imageKey(coordinate)) + if (info.relays.isNotEmpty()) prefs[relaysKey(coordinate)] = info.relays.joinToString("\n") else prefs.remove(relaysKey(coordinate)) + } + } + + override suspend fun remove(coordinate: String) { + store.edit { prefs -> + prefs.remove(coordKey(coordinate)) + prefs.remove(nameKey(coordinate)) + prefs.remove(urlKey(coordinate)) + prefs.remove(imageKey(coordinate)) + prefs.remove(relaysKey(coordinate)) + } + } + + override suspend fun all(): Map { + val prefs = store.data.first() + val result = mutableMapOf() + for ((key, value) in prefs.asMap()) { + if (!key.name.startsWith(COORD_PREFIX)) continue + val coordinate = value as? String ?: continue + result[coordinate] = + Nip46ClientInfo( + name = prefs[nameKey(coordinate)], + url = prefs[urlKey(coordinate)], + image = prefs[imageKey(coordinate)], + relays = prefs[relaysKey(coordinate)].toRelaySet(), + ) + } + return result + } + + private fun String?.toRelaySet(): Set = this?.split("\n")?.filterTo(mutableSetOf()) { it.isNotEmpty() } ?: emptySet() + + private fun coordKey(coordinate: String) = stringPreferencesKey("$COORD_PREFIX${hash(coordinate)}") + + private fun nameKey(coordinate: String) = stringPreferencesKey("name:${hash(coordinate)}") + + private fun urlKey(coordinate: String) = stringPreferencesKey("url:${hash(coordinate)}") + + private fun imageKey(coordinate: String) = stringPreferencesKey("img:${hash(coordinate)}") + + private fun relaysKey(coordinate: String) = stringPreferencesKey("relays:${hash(coordinate)}") + + companion object { + private val stores = ConcurrentHashMap>() + + private fun dataStoreFor(file: File): DataStore = + stores.computeIfAbsent(file.absolutePath) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + + private const val COORD_PREFIX = "coord:" + + private fun hash(coordinate: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray()) + return digest.take(8).joinToString("") { "%02x".format(it) } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 4fd4fc0fea..31e65ccb47 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -61,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46ClientInfo import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46PermissionAuthorizer import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel @@ -222,6 +223,11 @@ private fun RemoteSignerAppCard( onClick: () -> Unit, ) { val npub = remember(clientPubKey) { runCatching { NPub.create(clientPubKey) }.getOrDefault(clientPubKey.take(12) + "…") } + var info by remember(entry.coordinate) { mutableStateOf(null) } + LaunchedEffect(entry.coordinate) { + info = withContext(Dispatchers.Default) { Amethyst.instance.nip46ClientStore.load(entry.coordinate) } + } + val title = info?.name?.ifBlank { null } ?: stringResource(R.string.nip46_signer_remote_app) Card( modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), @@ -242,7 +248,7 @@ private fun RemoteSignerAppCard( verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( - stringResource(R.string.nip46_signer_remote_app), + title, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46ClientStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46ClientStore.kt new file mode 100644 index 0000000000..f9d8c603a3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46ClientStore.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet.signers + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock + +/** + * Display + transport info a NIP-46 remote-signer client sent us when it paired: + * its self-declared [name]/[url]/[image] (shown in Connected Apps) and the + * [relays] it talks to us on. The relays matter for the `nostrconnect://` flow — + * an app that offered its own relays (not the user's inbox) is only reachable + * there, so they are persisted and re-added to the listen set on the next launch. + */ +data class Nip46ClientInfo( + val name: String? = null, + val url: String? = null, + val image: String? = null, + val relays: Set = emptySet(), +) { + fun isEmpty() = name == null && url == null && image == null && relays.isEmpty() +} + +/** + * Persists [Nip46ClientInfo] per connected client, keyed by the same + * `nip46::` coordinate the permission ledger uses, so + * a client's metadata and its trust grant live under one key and are namespaced + * per account. Unlike the permission ledger this is display/transport data, not a + * security decision — a hostile value can only mislabel a card, never grant access. + */ +interface Nip46ClientStore { + suspend fun load(coordinate: String): Nip46ClientInfo? + + suspend fun store( + coordinate: String, + info: Nip46ClientInfo, + ) + + suspend fun remove(coordinate: String) + + /** All stored client info, keyed by coordinate — for startup relay recovery + the management screen. */ + suspend fun all(): Map +} + +/** A thread-safe in-memory [Nip46ClientStore] for tests and ephemeral sessions. */ +class InMemoryNip46ClientStore : Nip46ClientStore { + private val lock = KmpLock() + private val map = mutableMapOf() + + override suspend fun load(coordinate: String): Nip46ClientInfo? = lock.withLock { map[coordinate] } + + override suspend fun store( + coordinate: String, + info: Nip46ClientInfo, + ) = lock.withLock { map[coordinate] = info } + + override suspend fun remove(coordinate: String) = + lock.withLock { + map.remove(coordinate) + Unit + } + + override suspend fun all(): Map = lock.withLock { map.toMap() } +} From 994bdae2d14c131d95f422bd5cd669d452cb71af Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 21:31:34 +0000 Subject: [PATCH 09/52] feat(amethyst): keep the signer answering in the background The signer's relay subscription lives in the account scope and needs the shared NostrClient connected and the process alive to keep working while Amethyst is backgrounded or closed. Hook it into the existing always-on foreground service (and its five restart layers) instead of building a new one: - AlwaysOnNotificationServiceManager now starts/stops the layers when EITHER the notification service or nip46SignerEnabled is on (combined flow). - NotificationRelayService.isEnabled (the auto-restart guard) honors the signer flag too, so START_STICKY / watchdog / boot restart keep the signer up. - The signer screen notes that a background connection (ongoing notification) is what lets it answer requests while closed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../AlwaysOnNotificationServiceManager.kt | 21 +++++++++++++++---- .../notifications/NotificationRelayService.kt | 4 +++- .../settings/nip46/Nip46SignerScreen.kt | 6 ++++++ amethyst/src/main/res/values/strings.xml | 1 + 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt index 58c11797f9..4af3a66589 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt @@ -53,8 +53,10 @@ import kotlinx.coroutines.launch * this is the battery-saver "airplane mode". Persisted, so an explicit off survives * restarts and crashes. * - The **per-account participation** flag ([com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService], - * "Keep this account active in the background"). While the master is on, the service - * runs as long as **at least one** writable account participates. + * "Keep this account active in the background") **or** its NIP-46 signer toggle + * ([com.vitorpamplona.amethyst.model.AccountSettings.nip46SignerEnabled]). While the master is + * on, the service runs as long as **at least one** writable account has either flag on — the + * background signer relies on the same foreground service to keep answering requests. * * While the master is on, every saved writable account is kept loaded in * [AccountCacheState] so (a) its participation flag is observable and (b) GiftWraps @@ -83,6 +85,11 @@ class AlwaysOnNotificationServiceManager( * loaded writable account. The service layers run while the master is on AND at * least one account participates; the master overrides everything when off. * + * An account "participates" when either its always-on setting **or** its NIP-46 + * signer toggle is on: the foreground service (and its restart layers) keep the + * process + shared relay client alive, which is exactly what the background signer + * needs to keep answering requests, so the signer toggle keeps the layers up too. + * * Idempotent: safe to call again on account switch/login — it restarts the watch. */ @OptIn(ExperimentalCoroutinesApi::class) @@ -105,11 +112,17 @@ class AlwaysOnNotificationServiceManager( // Master on: keep every writable account loaded so its participation // flag is observable and its gift wraps can decrypt, then run the - // service only while at least one account is participating. + // service only while at least one account is participating. An account + // participates when its always-on setting OR its NIP-46 signer toggle is + // on — the background signer needs the same foreground service alive. startMultiAccountPreload() accountsCache.accounts .flatMapLatest { accounts -> - val flags = accounts.values.map { it.settings.alwaysOnNotificationService } + val flags = + accounts.values.map { account -> + account.settings.alwaysOnNotificationService + .combine(account.settings.nip46SignerEnabled) { alwaysOn, signer -> alwaysOn || signer } + } if (flags.isEmpty()) { flowOf(false) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index 2f48e5466b..c2fa2eab2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -126,9 +126,11 @@ class NotificationRelayService : Service() { // the service itself once a participant exists. fun isEnabled(context: Context): Boolean = try { + // The service also backs the NIP-46 signer, so an account with the signer on + // participates just like one with always-on notifications on. LocalPreferences.isNotificationServiceEnabled() && Amethyst.instance.accountsCache.accounts.value.values.any { - it.settings.alwaysOnNotificationService.value + it.settings.alwaysOnNotificationService.value || it.settings.nip46SignerEnabled.value } } catch (e: Exception) { false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index c15648de23..15ef1b7b98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -198,6 +198,12 @@ fun Nip46SignerScreen( count = connectedCount, onClick = { nav.nav(Route.ConnectedApps) }, ) + + Text( + stringResource(R.string.nip46_signer_background_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 5b43f12ee2..6b81a1e108 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -926,6 +926,7 @@ Could not connect: %1$s This is a read-only account and cannot sign. Remote signer app + Amethyst keeps a background connection (shown as an ongoing notification) so it can answer signing requests while closed. Sign for other apps Turn on signer Live From fe4e881df65d7e1b716a73b1adc0e4a8893ac89d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 22:03:49 +0000 Subject: [PATCH 10/52] =?UTF-8?q?fix(nip46):=20audit=20fixes=20=E2=80=94?= =?UTF-8?q?=20data=20race,=20cancellation,=20write=20amplification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from an audit of the signer, all verified against the code: - Data race: NostrConnectSignerService deduped request ids inside onEvent, which the relay pool invokes CONCURRENTLY from each relay's socket thread (PoolRequests dispatches listeners outside its lock). Two relays delivering the same subscription could mutate the LinkedHashSet at once → race / CME. Move dedup into the single consumer coroutine; onEvent now only does the thread-safe channel send. - Swallowed cancellation: broad `catch (Exception)` around suspend calls in the processor, the service's decrypt + publish, and connectViaNostrConnect caught CancellationException too, breaking structured cancellation when the service restarts. Rethrow it first (matching the AccountCacheState convention). - Write amplification: the ledger wrote last-used to that client's DataStore file on EVERY authorized request (unthrottled, unlike the relay-auth store). Coalesce to at most one write per client per 60s in the authorizer. - Redundant resubscribe: the enable/relays collector lacked distinctUntilChanged, so a duplicate inbox-relay emission tore the subscription down and re-opened it on every relay for nothing. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../model/nip46Signer/Nip46SignerState.kt | 7 +++++ .../signers/Nip46PermissionAuthorizer.kt | 31 +++++++++++++++++-- .../server/BunkerRequestProcessor.kt | 3 ++ .../server/NostrConnectSignerService.kt | 31 +++++++++++++------ 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index 234e93821d..651af1abeb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor import com.vitorpamplona.quartz.nip46RemoteSigner.server.NostrConnectSignerService import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -44,6 +45,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -121,6 +123,9 @@ class Nip46SignerState( scope.launch(Dispatchers.IO) { combine(settings.nip46SignerEnabled, listeningRelays) { enabled, relays -> enabled to relays } + // Inbox/relay StateFlows can re-emit an identical set; without this every duplicate + // would tear the subscription down and re-open it on every relay for no reason. + .distinctUntilChanged() .collectLatest { (enabled, relays) -> if (!enabled) return@collectLatest if (!signer.isWriteable()) { @@ -207,6 +212,8 @@ class Nip46SignerState( setEnabled(true) ConnectResult.Connected(offer.clientPubKey, offer.name) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.w("NIP46Signer") { "nostrconnect pairing failed: ${e.message}" } ConnectResult.Failed(e.message ?: "unknown error") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt index bf7cc7c6f7..e83e07eb66 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.commons.napplet.signers +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect @@ -30,6 +32,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46ConnectDecision import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46RequestAuthorizer +import com.vitorpamplona.quartz.utils.TimeUtils /** * Bridges the NIP-46 signer core to Amethyst's shared "Connected Apps" trust @@ -67,9 +70,30 @@ class Nip46PermissionAuthorizer( /** Invoked after a successful connect so the host can persist display metadata (name/url/image). */ val onConnected: (suspend (clientPubKey: HexKey, request: BunkerRequestConnect) -> Unit)? = null, ) : Nip46RequestAuthorizer { + // A high-throughput client can authorize many signs per second; last-used is display-only, + // so coalesce the DataStore write to at most one per client per LAST_USED_THROTTLE_SECS + // instead of writing the whole per-client preferences file on every request. + private val lastUsedThrottle = mutableMapOf() + private val throttleLock = KmpLock() + /** The ledger coordinate for [clientPubKey] under this account. */ fun coordinateFor(clientPubKey: HexKey): String = coordinateFor(signerPubKey, clientPubKey) + private suspend fun touchLastUsed(coordinate: String) { + val now = TimeUtils.now() + val shouldWrite = + throttleLock.withLock { + val previous = lastUsedThrottle[coordinate] ?: 0L + if (now - previous >= LAST_USED_THROTTLE_SECS) { + lastUsedThrottle[coordinate] = now + true + } else { + false + } + } + if (shouldWrite) ledger.updateLastUsed(coordinate, now) + } + override suspend fun onConnect( clientPubKey: HexKey, request: BunkerRequestConnect, @@ -82,7 +106,7 @@ class Nip46PermissionAuthorizer( if (!ledger.hasPolicy(coordinate)) { ledger.setPolicy(coordinate, defaultPolicyOnConnect) } - ledger.updateLastUsed(coordinate) + touchLastUsed(coordinate) onConnected?.invoke(clientPubKey, request) // Echo the offered secret when present (the client validates it); otherwise ack. @@ -100,7 +124,7 @@ class Nip46PermissionAuthorizer( val op = request.toSignerOp() ?: return true val coordinate = coordinateFor(clientPubKey) val allowed = ledger.decide(coordinate, op) == NostrOpDecision.ALLOW - if (allowed) ledger.updateLastUsed(coordinate) + if (allowed) touchLastUsed(coordinate) return allowed } @@ -115,6 +139,9 @@ class Nip46PermissionAuthorizer( private const val ACK = "ack" + /** Minimum seconds between persisted last-used updates for one client (write-coalescing). */ + private const val LAST_USED_THROTTLE_SECS = 60L + /** * The Connected-Apps ledger coordinate for a NIP-46 client, namespaced by * the user's signer so the same client paired with two accounts on one diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt index efb7454bd3..f86e3c984c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseGetRelays import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey import com.vitorpamplona.quartz.nip46RemoteSigner.ReadWrite +import kotlinx.coroutines.CancellationException /** * The signer/bunker side of NIP-46: turns a decrypted [BunkerRequest] from a @@ -130,6 +131,8 @@ class BunkerRequestProcessor( else -> BunkerResponseError(request.id, "unsupported method: ${request.method}") } } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { BunkerResponseError(request.id, "${e::class.simpleName}: ${e.message}") } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index 41317dad27..af42c53a26 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED @@ -76,8 +77,6 @@ class NostrConnectSignerService( val self = signer.pubKey val events = Channel(UNLIMITED) - // Insertion-ordered so the oldest id can be evicted once the cap is hit. - val seen = LinkedHashSet() val subId = newSubId() val listener = object : SubscriptionListener { @@ -87,23 +86,31 @@ class NostrConnectSignerService( relay: NormalizedRelayUrl, forFilters: List?, ) { - if (event is NostrConnectEvent && event.verifiedRecipientPubKey() == self && seen.add(event.id)) { - if (seen.size > seenCap) { - seen.iterator().let { - it.next() - it.remove() - } - } + // onEvent is invoked CONCURRENTLY from each relay's socket thread, so it must + // touch no shared mutable state here — the (thread-safe) channel send is all it + // does; dedup happens in the single-threaded consumer below. + if (event is NostrConnectEvent && event.verifiedRecipientPubKey() == self) { events.trySend(event) } } } + // Insertion-ordered dedup, confined to this one consumer coroutine (never the relay threads); + // evicts the oldest id past the cap so a long-lived signer can't grow it without bound. + val seen = LinkedHashSet() val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self))) client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) try { while (true) { - handle(events.receive()) + val event = events.receive() + if (!seen.add(event.id)) continue // same request already handled (arrived on another relay) + if (seen.size > seenCap) { + seen.iterator().let { + it.next() + it.remove() + } + } + handle(event) } } finally { client.unsubscribe(subId) @@ -116,6 +123,8 @@ class NostrConnectSignerService( val request = try { event.decryptMessage(signer) as? BunkerRequest ?: return + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.w("NIP46Signer") { "could not decrypt request ${event.id.take(8)}: ${e.message}" } return @@ -128,6 +137,8 @@ class NostrConnectSignerService( try { val reply = NostrConnectEvent.create(response, client, signer) this.client.publish(reply, relays) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.w("NIP46Signer") { "failed to send reply for ${request.method}: ${e.message}" } } From d06b1c7f2c020e9dcc4c0f11141c0551d9a05508 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 22:14:17 +0000 Subject: [PATCH 11/52] feat(nip46): rate-limit flooding clients + clean up on logout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups to the audit: - Abuse protection: the signer service now bounds its event queue (DROP_LATEST) and rate-limits per author BEFORE decrypting — decryption can be an external-signer (NIP-55) IPC round-trip, so a looping or hostile client can no longer force one per event or grow the queue without limit. Fixed window (default 40 requests / 10s per author, oldest authors evicted). The limiter is touched only by the single consumer coroutine, so it needs no locking. Covered by a headless test. - logout now clears the client's persisted metadata/relays too (not just the ledger grant), so a disconnected app stops being listened for after restart. Not changed: get_public_key/ping stay ungated — gating them behind a prior connect risks breaking clients that discover the pubkey at connect time, and the pubkey is already public, so the enumeration leak is negligible. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../model/nip46Signer/Nip46SignerState.kt | 1 + .../signers/Nip46PermissionAuthorizer.kt | 9 ++- .../server/NostrConnectSignerService.kt | 63 ++++++++++++++++++- .../server/NostrConnectSignerServiceTest.kt | 26 ++++++++ 4 files changed, 95 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index 651af1abeb..3ea21063b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -104,6 +104,7 @@ class Nip46SignerState( ) } }, + clientStore = clientStore, ) init { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt index e83e07eb66..4b18746a63 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt @@ -69,6 +69,8 @@ class Nip46PermissionAuthorizer( val defaultPolicyOnConnect: AppSignerPolicy = AppSignerPolicy.REASONABLE, /** Invoked after a successful connect so the host can persist display metadata (name/url/image). */ val onConnected: (suspend (clientPubKey: HexKey, request: BunkerRequestConnect) -> Unit)? = null, + /** Persisted client metadata/relays; cleared on logout so a disconnected app leaves nothing behind. */ + val clientStore: Nip46ClientStore? = null, ) : Nip46RequestAuthorizer { // A high-throughput client can authorize many signs per second; last-used is display-only, // so coalesce the DataStore write to at most one per client per LAST_USED_THROTTLE_SECS @@ -129,8 +131,11 @@ class Nip46PermissionAuthorizer( } override suspend fun onLogout(clientPubKey: HexKey) { - // The client asked to disconnect — drop its standing grant so it must pair again. - ledger.revokeAll(coordinateFor(clientPubKey)) + // The client asked to disconnect — drop its standing grant (so it must pair again) and its + // persisted metadata/relays (so we stop listening on its relays after the next restart). + val coordinate = coordinateFor(clientPubKey) + ledger.revokeAll(coordinate) + clientStore?.remove(coordinate) } companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index af42c53a26..4a34f72ab6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -31,9 +31,10 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED /** * Runs a NIP-46 remote signer ("bunker") for one account: subscribes to the @@ -63,7 +64,57 @@ class NostrConnectSignerService( * evicted (they are far past any realistic same-request retry window). */ val seenCap: Int = 4096, + /** + * Bound on events buffered between the relay threads and the single consumer. + * Under a flood (a looping client, or a hostile peer p-tagging us) the newest + * events past this many are dropped instead of growing memory without limit — + * the transport [signer] can be an external NIP-55 app where each decrypt is an + * IPC round-trip, so the queue must not run away. + */ + val maxQueue: Int = 256, + /** Max requests decrypted per author within [rateWindowSeconds] before further ones are dropped. */ + val maxRequestsPerWindow: Int = 40, + val rateWindowSeconds: Long = 10, + /** Cap on distinct authors tracked for rate-limiting (evicts oldest) so key-rotation can't grow it. */ + val maxTrackedAuthors: Int = 512, ) { + /** + * Fixed-window per-author rate limit. Touched only by the single consumer + * coroutine (never the relay threads), so a plain map needs no synchronization. + */ + private class RateLimiter( + val maxPerWindow: Int, + val windowSeconds: Long, + val maxAuthors: Int, + ) { + private class Window( + var start: Long, + var count: Int, + ) + + private val windows = LinkedHashMap() + + fun allow( + author: String, + now: Long, + ): Boolean { + val window = windows.getOrPut(author) { Window(now, 0) } + if (now - window.start >= windowSeconds) { + window.start = now + window.count = 0 + } + if (windows.size > maxAuthors) { + windows.iterator().let { + it.next() + it.remove() + } + } + if (window.count >= maxPerWindow) return false + window.count++ + return true + } + } + /** * Subscribes and services requests until cancelled. Duplicate events (the * same request seen on more than one relay) are handled once. Never returns @@ -76,7 +127,9 @@ class NostrConnectSignerService( } val self = signer.pubKey - val events = Channel(UNLIMITED) + // Bounded + DROP_LATEST so a flood bounds memory instead of growing an unlimited queue. + val events = Channel(capacity = maxQueue, onBufferOverflow = BufferOverflow.DROP_LATEST) + val rateLimiter = RateLimiter(maxRequestsPerWindow, rateWindowSeconds, maxTrackedAuthors) val subId = newSubId() val listener = object : SubscriptionListener { @@ -110,6 +163,12 @@ class NostrConnectSignerService( it.remove() } } + // Rate-limit per author BEFORE decrypting — decryption can be an external-signer + // round-trip, so a flooding client must not force one per event. + if (!rateLimiter.allow(event.pubKey, TimeUtils.now())) { + Log.w("NIP46Signer") { "rate-limited request from ${event.pubKey.take(8)}…" } + continue + } handle(event) } } finally { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt index f18ff2f48f..67f89ecc14 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt @@ -220,6 +220,32 @@ class NostrConnectSignerServiceTest { assertEquals("ack", reply.result) } + @Test + fun floodingClientIsRateLimited() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = + NostrConnectSignerService( + client, + signer, + processor, + setOf(relay), + maxRequestsPerWindow = 2, + rateWindowSeconds = 3600, + ) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + // Five distinct requests from the same author within one window → only 2 are serviced. + repeat(5) { i -> + client.deliver(request(BunkerRequestConnect(id = "req$i", remoteKey = serverKey, secret = "s"))) + } + + assertEquals(2, client.published.size) + } + @Test fun requestNotAddressedToUsIsIgnored() = runTest { From c20cc2b514dce320ae0fe886ddbd4252709ece1a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 23:53:50 +0000 Subject: [PATCH 12/52] refactor: move the generic signer-permission layer out of napplet/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-app signing-authorization plumbing was named/located under `napplet/` for historical reasons, but it is not napplet-specific — it already gates napplets, the sandboxed browser, and (now) NIP-46 remote clients through one shared ledger. The package name mislabelled what the code is, so: - commons: `napplet/signers/` (generic) → `connectedApps/signers/` (AppSignerPolicy, NostrOpDecision, NostrSignerOp, NostrSignerConsentPrompt, NostrSignerPermissionLedger/Store). The NIP-46-specific bridge moves to `connectedApps/nip46/` (Nip46PermissionAuthorizer, Nip46ClientStore), so the feature is no longer split across unrelated packages. - The `NappletRequest.toSignerOp()` extension — napplet protocol leaking into the generic layer — moves back to `napplet/protocol/`. - amethyst: `napplet/DataStoreNostrSignerPermissionStore` → `connectedApps/`, `napplet/DataStoreNip46ClientStore` → `connectedApps/nip46/`. Pure move + repackage: all 27 import sites updated, no behaviour change. Napplet-specific code (broker, capabilities, consent, :nappletHost) and the Connected Apps UI folder are untouched — those really are napplet/UI. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../com/vitorpamplona/amethyst/AppModules.kt | 4 +- .../DataStoreNostrSignerPermissionStore.kt | 10 ++--- .../nip46}/DataStoreNip46ClientStore.kt | 6 +-- .../vitorpamplona/amethyst/model/Account.kt | 10 ++--- .../model/accountsCache/AccountCacheState.kt | 8 ++-- .../model/nip46Signer/Nip46SignerState.kt | 8 ++-- .../amethyst/napplet/NappletBrokerService.kt | 2 +- .../napplet/NappletConnectActivity.kt | 4 +- .../napplet/NappletConnectCoordinator.kt | 2 +- .../napplet/NappletSignerConsentActivity.kt | 2 +- .../NappletSignerConsentCoordinator.kt | 4 +- .../amethyst/napplet/NostrSignerOpLabels.kt | 2 +- .../gateways/AccountNappletGateways.kt | 6 +-- .../napplets/ConnectedAppDetailScreen.kt | 8 ++-- .../loggedIn/napplets/ConnectedAppsScreen.kt | 8 ++-- .../settings/nip46/Nip46SignerScreen.kt | 2 +- .../nip46}/Nip46ClientStore.kt | 2 +- .../nip46}/Nip46PermissionAuthorizer.kt | 6 ++- .../signers/AppSignerPolicy.kt | 2 +- .../signers/NostrOpDecision.kt | 2 +- .../signers/NostrSignerConsentPrompt.kt | 2 +- .../signers/NostrSignerOp.kt | 16 +------- .../signers/NostrSignerPermissionLedger.kt | 2 +- .../signers/NostrSignerPermissionStore.kt | 2 +- .../amethyst/commons/napplet/NappletBroker.kt | 18 ++++----- .../protocol/NappletRequestSignerOp.kt | 38 +++++++++++++++++++ .../nip46}/Nip46PermissionAuthorizerTest.kt | 7 +++- .../NostrSignerPermissionLedgerTest.kt | 2 +- .../commons/napplet/NappletBrokerTest.kt | 10 ++--- 29 files changed, 114 insertions(+), 81 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/{napplet => connectedApps}/DataStoreNostrSignerPermissionStore.kt (95%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/{napplet => connectedApps/nip46}/DataStoreNip46ClientStore.kt (96%) rename commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/{napplet/signers => connectedApps/nip46}/Nip46ClientStore.kt (98%) rename commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/{napplet/signers => connectedApps/nip46}/Nip46PermissionAuthorizer.kt (95%) rename commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/{napplet => connectedApps}/signers/AppSignerPolicy.kt (97%) rename commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/{napplet => connectedApps}/signers/NostrOpDecision.kt (95%) rename commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/{napplet => connectedApps}/signers/NostrSignerConsentPrompt.kt (98%) rename commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/{napplet => connectedApps}/signers/NostrSignerOp.kt (79%) rename commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/{napplet => connectedApps}/signers/NostrSignerPermissionLedger.kt (99%) rename commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/{napplet => connectedApps}/signers/NostrSignerPermissionStore.kt (99%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt rename commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/{napplet/signers => connectedApps/nip46}/Nip46PermissionAuthorizerTest.kt (92%) rename commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/{napplet => connectedApps}/signers/NostrSignerPermissionLedgerTest.kt (98%) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 1e77048fcc..72d8c52e0f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -35,6 +35,8 @@ import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResol import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.commons.tor.TorSettings +import com.vitorpamplona.amethyst.connectedApps.DataStoreNostrSignerPermissionStore +import com.vitorpamplona.amethyst.connectedApps.nip46.DataStoreNip46ClientStore import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.UiSettings @@ -51,8 +53,6 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector import com.vitorpamplona.amethyst.model.torState.TorRelayState import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore -import com.vitorpamplona.amethyst.napplet.DataStoreNip46ClientStore -import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker import com.vitorpamplona.amethyst.service.cast.CastRegistry diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/DataStoreNostrSignerPermissionStore.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/DataStoreNostrSignerPermissionStore.kt index fa64ed6378..a0f232e8bd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNostrSignerPermissionStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/DataStoreNostrSignerPermissionStore.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.napplet +package com.vitorpamplona.amethyst.connectedApps import android.content.Context import androidx.datastore.core.DataStore @@ -26,10 +26,10 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore import com.vitorpamplona.quartz.utils.cache.LargeCache import kotlinx.coroutines.flow.first import java.io.File diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNip46ClientStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/nip46/DataStoreNip46ClientStore.kt similarity index 96% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNip46ClientStore.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/nip46/DataStoreNip46ClientStore.kt index 90ddacb231..eaa68c4031 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNip46ClientStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/nip46/DataStoreNip46ClientStore.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.napplet +package com.vitorpamplona.amethyst.connectedApps.nip46 import android.content.Context import androidx.datastore.core.DataStore @@ -26,8 +26,8 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey -import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46ClientInfo -import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46ClientStore +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore import kotlinx.coroutines.flow.first import java.io.File import java.security.MessageDigest diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index a3b1693983..040429e1f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -27,6 +27,11 @@ import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore +import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel @@ -54,11 +59,6 @@ import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDe import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache -import com.vitorpamplona.amethyst.commons.napplet.signers.InMemoryNip46ClientStore -import com.vitorpamplona.amethyst.commons.napplet.signers.InMemoryNostrSignerPermissionStore -import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46ClientStore -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 54c78f286b..31f0d14194 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -22,10 +22,10 @@ package com.vitorpamplona.amethyst.model.accountsCache import android.content.ContentResolver import com.vitorpamplona.amethyst.LocalPreferences -import com.vitorpamplona.amethyst.commons.napplet.signers.InMemoryNip46ClientStore -import com.vitorpamplona.amethyst.commons.napplet.signers.InMemoryNostrSignerPermissionStore -import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46ClientStore -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore +import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index 3ea21063b8..3bd46428d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.model.nip46Signer -import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46ClientInfo -import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46ClientStore -import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46PermissionAuthorizer -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index c08d211060..0eb2a39323 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -34,6 +34,7 @@ import android.os.SystemClock import android.util.Log import androidx.core.net.toUri import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.napplet.NappletBroker import com.vitorpamplona.amethyst.commons.napplet.NappletCapability @@ -42,7 +43,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt index a5c2f15811..230b5e6cf7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt @@ -58,12 +58,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult -import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy import com.vitorpamplona.amethyst.ui.theme.AmethystTheme class NappletConnectActivity : ComponentActivity() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt index 0467578f2e..af56fe83ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context import android.content.Intent -import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult import kotlinx.coroutines.CompletableDeferred import java.util.UUID import java.util.concurrent.ConcurrentHashMap diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt index 6fa8bad403..35c4d688e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt @@ -61,11 +61,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt index 9febb3b0f2..8e884cffa5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt @@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context import android.content.Intent -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp -import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant import kotlinx.coroutines.CompletableDeferred import java.util.UUID import java.util.concurrent.ConcurrentHashMap diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt index 9c7bb146d9..a59e473725 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index 89bf3ecf31..4b660662a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -27,6 +27,9 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrConnectPrompt +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerConsentPrompt +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.NappletBroker import com.vitorpamplona.amethyst.commons.napplet.NappletConsentPrompt import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityGateway @@ -41,9 +44,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerConsentPrompt -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.NappletConnectCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index b74352c169..29ff080981 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -61,6 +61,10 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -69,10 +73,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger -import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 31e65ccb47..1a56db7ad4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -55,15 +55,15 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger -import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy -import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46ClientInfo -import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46PermissionAuthorizer -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel import com.vitorpamplona.amethyst.model.LocalCache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index 15ef1b7b98..fb63d8c1a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -82,9 +82,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.napplet.signers.Nip46PermissionAuthorizer import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46ClientStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ClientStore.kt similarity index 98% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46ClientStore.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ClientStore.kt index f9d8c603a3..54636e61c0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46ClientStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ClientStore.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.napplet.signers +package com.vitorpamplona.amethyst.commons.connectedApps.nip46 import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt similarity index 95% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt index 4b18746a63..eda4e961f7 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt @@ -18,8 +18,12 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.napplet.signers +package com.vitorpamplona.amethyst.commons.connectedApps.nip46 +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/AppSignerPolicy.kt similarity index 97% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/AppSignerPolicy.kt index f1a506f6a0..a12dbf1bc8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/AppSignerPolicy.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.napplet.signers +package com.vitorpamplona.amethyst.commons.connectedApps.signers /** * The user's top-level trust decision for one app's access to the internal Nostr signer. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrOpDecision.kt similarity index 95% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrOpDecision.kt index a6cbda8f6b..82f2821fbf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrOpDecision.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.napplet.signers +package com.vitorpamplona.amethyst.commons.connectedApps.signers /** * A per-operation standing decision stored in [NostrSignerPermissionStore]. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerConsentPrompt.kt similarity index 98% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerConsentPrompt.kt index ea6f87bd34..e614b4f51b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerConsentPrompt.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.napplet.signers +package com.vitorpamplona.amethyst.commons.connectedApps.signers import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerOp.kt similarity index 79% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerOp.kt index bdb31e981f..5987dc46ef 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerOp.kt @@ -18,9 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.napplet.signers - -import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +package com.vitorpamplona.amethyst.commons.connectedApps.signers /** * A Nostr-specific cryptographic operation that requires the internal signer. @@ -57,15 +55,3 @@ sealed interface NostrSignerOp { } } } - -/** - * Maps a [NappletRequest] to the [NostrSignerOp] it represents, or `null` if the request - * does not involve signing or encryption. - */ -fun NappletRequest.toSignerOp(): NostrSignerOp? = - when (this) { - is NappletRequest.Publish -> NostrSignerOp.SignKind(kind) - is NappletRequest.SignEvent -> NostrSignerOp.SignKind(kind) - is NappletRequest.PublishEncrypted -> NostrSignerOp.Encrypt - else -> null - } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt similarity index 99% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt index 621aaf3149..5acb4bf0c3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.napplet.signers +package com.vitorpamplona.amethyst.commons.connectedApps.signers import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionStore.kt similarity index 99% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionStore.kt index cc0e2bca8a..ef2e0b2afd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionStore.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.napplet.signers +package com.vitorpamplona.amethyst.commons.connectedApps.signers import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 81216f0d78..5c2501011b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -20,20 +20,20 @@ */ package com.vitorpamplona.amethyst.commons.napplet +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrConnectPrompt +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerConsentPrompt +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse -import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult -import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerConsentPrompt -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger -import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant -import com.vitorpamplona.amethyst.commons.napplet.signers.toSignerOp +import com.vitorpamplona.amethyst.commons.napplet.protocol.toSignerOp import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt new file mode 100644 index 0000000000..905f137517 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.napplet.protocol + +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp + +/** + * Maps a [NappletRequest] to the [NostrSignerOp] it represents, or `null` if the request + * does not involve signing or encryption. + * + * This lives on the napplet side (not in the generic signer-permission layer) because it + * is the napplet protocol's own translation into the shared [NostrSignerOp] vocabulary. + */ +fun NappletRequest.toSignerOp(): NostrSignerOp? = + when (this) { + is NappletRequest.Publish -> NostrSignerOp.SignKind(kind) + is NappletRequest.SignEvent -> NostrSignerOp.SignKind(kind) + is NappletRequest.PublishEncrypted -> NostrSignerOp.Encrypt + else -> null + } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt similarity index 92% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizerTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt index ff4d2f02f8..89196d453f 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/Nip46PermissionAuthorizerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt @@ -18,8 +18,13 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.napplet.signers +package com.vitorpamplona.amethyst.commons.connectedApps.nip46 +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedgerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedgerTest.kt similarity index 98% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedgerTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedgerTest.kt index e872b25ddb..f07eb5e833 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedgerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedgerTest.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.napplet.signers +package com.vitorpamplona.amethyst.commons.connectedApps.signers import kotlinx.coroutines.test.runTest import kotlin.test.Test diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt index 6e43798da5..6cc8c16213 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt @@ -20,17 +20,17 @@ */ package com.vitorpamplona.amethyst.commons.napplet +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrConnectPrompt +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState import com.vitorpamplona.amethyst.commons.napplet.permissions.InMemoryNappletPermissionStore import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse -import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult -import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy -import com.vitorpamplona.amethyst.commons.napplet.signers.InMemoryNostrSignerPermissionStore -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray From cd3e1353e78641e8add66e5363f69f7111275bfa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 23:54:21 +0000 Subject: [PATCH 13/52] docs(nip46): correct stale coordinate format in KDoc Two doc comments still described the pre-namespacing coordinate `nip46:`; the actual key is `nip46::`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../amethyst/model/nip46Signer/Nip46SignerState.kt | 2 +- .../connectedApps/nip46/Nip46PermissionAuthorizer.kt | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index 3bd46428d7..bad7429191 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -59,7 +59,7 @@ import kotlinx.coroutines.launch * * Every request is gated by [Nip46PermissionAuthorizer], i.e. the same * "Connected Apps" trust ledger that governs napplets and web origins: a remote - * client is a connected app under the coordinate `nip46:`. + * client is a connected app under the coordinate `nip46::`. * * The listener restarts whenever the enabled flag or the relay set changes * ([collectLatest] cancels the previous run), so editing inbox relays or toggling diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt index eda4e961f7..2a9076d99c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt @@ -46,8 +46,10 @@ import com.vitorpamplona.quartz.utils.TimeUtils * [NostrOpDecision]) and surfaced on the same management screen. * * A NIP-46 client is identified by its transport pubkey, mapped to the ledger - * coordinate `nip46:` (see [coordinateFor]) so it lives in its - * own namespace next to `browser:` and napplet `:` keys. + * coordinate `nip46::` (see [coordinateFor]) so it + * lives in its own namespace next to `browser:` and napplet + * `:` keys, and so the same client paired with two accounts on one + * device gets independent grants. * * Authorization mirrors the napplet path: * - each signing/encryption/decryption request maps to a [NostrSignerOp] From 922a5841d02e8aa5d209b00f0c8a8e0fdfd0e7ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 00:17:14 +0000 Subject: [PATCH 14/52] fix(nip46): make forgetting a client complete and immediate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clearing a connected client on logout had two gaps: - The user-facing "Forget this app" button only revoked the permission ledger; it never cleared the NIP-46 client store, so a forgotten app's metadata and relays lingered and were re-recovered on the next restart. Route NIP-46 coordinates through the host's new forgetClient() so the store is cleared too. - Neither logout path stopped the RUNNING session from listening on the app's relays — only the next restart picked up the change. extraRelays is now a live projection of the client store (recomputed on connect, on start, and on disconnect via a new onDisconnected hook), so a forgotten app's relays are dropped immediately. onLogout and the UI Forget now share one authorizer.forget() path (revoke grant + clear store + clear throttle entry + signal the host), so client-initiated and user-initiated disconnects behave identically. Adds tests for both. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../model/nip46Signer/Nip46SignerState.kt | 42 ++++++++++++------- .../napplets/ConnectedAppDetailScreen.kt | 10 ++++- .../nip46/Nip46PermissionAuthorizer.kt | 20 +++++++-- .../nip46/Nip46PermissionAuthorizerTest.kt | 39 +++++++++++++++++ 4 files changed, 93 insertions(+), 18 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index bad7429191..83041b61f0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -105,22 +106,16 @@ class Nip46SignerState( } }, clientStore = clientStore, + // A forgotten client's relays are gone from the store now; recompute the listen set so we + // stop listening on them this session instead of waiting for a restart. + onDisconnected = { refreshExtraRelaysFromStore() }, ) init { - // Recover the relays of `nostrconnect://`-paired apps so they stay reachable across restarts - // (bunker-flow apps use the inbox relays, which are already in the listen set). - scope.launch(Dispatchers.IO) { - val recovered = - clientStore - .all() - .filterKeys { Nip46PermissionAuthorizer.belongsTo(it, signer.pubKey) } - .values - .flatMap { it.relays } - .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } - .toSet() - if (recovered.isNotEmpty()) extraRelays.value = extraRelays.value + recovered - } + // extraRelays is a live projection of the persisted client store (the nostrconnect apps' own + // relays). Load it on start so paired apps stay reachable across restarts; it is refreshed + // whenever a client connects or is forgotten (bunker-flow apps use the inbox relays instead). + scope.launch(Dispatchers.IO) { refreshExtraRelaysFromStore() } scope.launch(Dispatchers.IO) { combine(settings.nip46SignerEnabled, listeningRelays) { enabled, relays -> enabled to relays } @@ -172,6 +167,25 @@ class Nip46SignerState( return fresh } + /** Recomputes [extraRelays] from the persisted client store — the source of truth for nostrconnect relays. */ + private suspend fun refreshExtraRelaysFromStore() { + extraRelays.value = + clientStore + .all() + .filterKeys { Nip46PermissionAuthorizer.belongsTo(it, signer.pubKey) } + .values + .flatMap { it.relays } + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + } + + /** + * Forgets a connected client (the user's "Forget" action): revokes its grant, drops its stored + * metadata/relays, and stops listening on relays that only it used. Same path as a client-sent + * `logout`, so both are consistent. + */ + suspend fun forgetClient(clientPubKey: HexKey) = authorizer.forget(clientPubKey) + /** Returns the current pairing secret, generating and persisting one the first time. */ private fun ensureSecret(): String { val current = settings.nip46BunkerSecret.value @@ -209,7 +223,7 @@ class Nip46SignerState( coordinate, Nip46ClientInfo(name = offer.name, url = offer.url, image = offer.image, relays = offer.relays.map { it.url }.toSet()), ) - extraRelays.value = extraRelays.value + offer.relays + refreshExtraRelaysFromStore() setEnabled(true) ConnectResult.Connected(offer.clientPubKey, offer.name) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index 29ff080981..6747651bd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -61,6 +61,7 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp @@ -221,7 +222,14 @@ fun ConnectedAppDetailScreen( Button( onClick = { mutate { - signerLedger.revokeAll(coordinate) + val nip46Client = Nip46PermissionAuthorizer.clientPubKeyOf(coordinate) + if (nip46Client != null) { + // Route NIP-46 clients through the host so the client store + the running + // listen set are cleared too, not just the permission ledger. + accountViewModel.account.nip46Signer.forgetClient(nip46Client) + } else { + signerLedger.revokeAll(coordinate) + } capabilityLedger.revokeAll(identity) } nav.popBack() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt index 2a9076d99c..9d24d758dd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt @@ -77,6 +77,12 @@ class Nip46PermissionAuthorizer( val onConnected: (suspend (clientPubKey: HexKey, request: BunkerRequestConnect) -> Unit)? = null, /** Persisted client metadata/relays; cleared on logout so a disconnected app leaves nothing behind. */ val clientStore: Nip46ClientStore? = null, + /** + * Invoked after a client is fully forgotten ([onLogout]/[forget]) so the host can react in the + * running session — e.g. stop listening on relays that only that client used, instead of waiting + * for the next restart. + */ + val onDisconnected: (suspend (clientPubKey: HexKey) -> Unit)? = null, ) : Nip46RequestAuthorizer { // A high-throughput client can authorize many signs per second; last-used is display-only, // so coalesce the DataStore write to at most one per client per LAST_USED_THROTTLE_SECS @@ -136,12 +142,20 @@ class Nip46PermissionAuthorizer( return allowed } - override suspend fun onLogout(clientPubKey: HexKey) { - // The client asked to disconnect — drop its standing grant (so it must pair again) and its - // persisted metadata/relays (so we stop listening on its relays after the next restart). + override suspend fun onLogout(clientPubKey: HexKey) = forget(clientPubKey) + + /** + * Fully disconnects [clientPubKey], from either the client's `logout` request or the user's + * "Forget" action: drops its standing grant (so it must pair again), its persisted metadata/relays, + * and its in-memory throttle entry, then signals [onDisconnected] so the running session can stop + * listening on relays that only this client used. + */ + suspend fun forget(clientPubKey: HexKey) { val coordinate = coordinateFor(clientPubKey) ledger.revokeAll(coordinate) clientStore?.remove(coordinate) + throttleLock.withLock { lastUsedThrottle.remove(coordinate) } + onDisconnected?.invoke(clientPubKey) } companion object { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt index 89196d453f..34de64bde5 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt @@ -135,4 +135,43 @@ class Nip46PermissionAuthorizerTest { assertEquals(null, ledger.store.loadPolicy(coordinate)) } + + @Test + fun forgetClearsGrantAndStoreAndSignalsDisconnect() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.FULL_TRUST) + val store = InMemoryNip46ClientStore() + store.store(coordinate, Nip46ClientInfo(name = "X", relays = setOf("wss://relay.example.com"))) + var disconnected: String? = null + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + clientStore = store, + onDisconnected = { disconnected = it }, + ) + + authorizer.forget(client) + + assertEquals(null, ledger.store.loadPolicy(coordinate), "grant cleared") + assertEquals(null, store.load(coordinate), "stored metadata + relays cleared") + assertEquals(client, disconnected, "host notified so it can drop the relays this session") + } + + @Test + fun logoutIsEquivalentToForget() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) + val store = InMemoryNip46ClientStore() + store.store(coordinate, Nip46ClientInfo(name = "X")) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }, clientStore = store) + + authorizer.onLogout(client) + + assertEquals(null, ledger.store.loadPolicy(coordinate)) + assertEquals(null, store.load(coordinate)) + } } From 2332384897fbbc9c5d81177180abedc4b0987619 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 00:47:50 +0000 Subject: [PATCH 15/52] feat(nip46): use a dedicated transport key so the bunker doesn't reveal the user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-46 lets the remote-signer (transport) key differ from the user's identity key. Previously the bunker advertised and wrapped everything with the identity key, so anyone watching the inbox relays could see kind-24133 traffic addressed to the user's real npub and infer "this npub runs a bunker". Now each account gets a dedicated, persisted transport keypair: - NostrConnectSignerService wraps/unwraps the kind-24133 envelope with a `transportSigner`; BunkerRequestProcessor keeps the identity signer for the actual sign/encrypt/decrypt and answers get_public_key with the real npub (disclosed only to a connected client, over the encrypted channel). - The host mints + persists the transport key lazily (accounts that never enable the signer mint nothing), advertises it in bunker:// and the nostrconnect ack, and listens p-tagged to it. - AccountSettings/LocalPreferences persist nip46TransportKey so the advertised address stays stable across restarts. Bonus: because the envelope is now wrapped with a LOCAL key, external NIP-55 (Amber) accounts no longer round-trip the external app for envelope crypto — only the genuine signing request does. A new test asserts get_public_key returns the identity, never the transport key. Unreleased feature, so no migration needed. The CLI bunker keeps using the operator's own key (dev tool). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../amethyst/LocalPreferences.kt | 4 ++ .../amethyst/model/AccountSettings.kt | 16 +++++++ .../model/nip46Signer/Nip46SignerState.kt | 46 +++++++++++++++---- .../amethyst/cli/commands/BunkerCommand.kt | 4 +- .../server/NostrConnectSignerService.kt | 35 ++++++++------ .../server/NostrConnectSignerServiceTest.kt | 24 ++++++++++ 6 files changed, 106 insertions(+), 23 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index fa6bfd9bc0..9de8c49293 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -111,6 +111,7 @@ private object PrefKeys { const val HIDE_COMMUNITY_RULES_VIOLATIONS = "hideCommunityRulesViolations" const val NIP46_SIGNER_ENABLED = "nip46SignerEnabled" const val NIP46_BUNKER_SECRET = "nip46BunkerSecret" + const val NIP46_TRANSPORT_KEY = "nip46TransportKey" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" @@ -469,6 +470,7 @@ object LocalPreferences { putBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, settings.hideCommunityRulesViolations.value) putBoolean(PrefKeys.NIP46_SIGNER_ENABLED, settings.nip46SignerEnabled.value) putString(PrefKeys.NIP46_BUNKER_SECRET, settings.nip46BunkerSecret.value) + putString(PrefKeys.NIP46_TRANSPORT_KEY, settings.nip46TransportKey.value) putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value)) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value)) @@ -681,6 +683,7 @@ object LocalPreferences { val hideCommunityRulesViolations = getBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, false) val nip46SignerEnabled = getBoolean(PrefKeys.NIP46_SIGNER_ENABLED, false) val nip46BunkerSecret = getString(PrefKeys.NIP46_BUNKER_SECRET, "") ?: "" + val nip46TransportKey = getString(PrefKeys.NIP46_TRANSPORT_KEY, "") ?: "" val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) @@ -862,6 +865,7 @@ object LocalPreferences { hideCommunityRulesViolations = MutableStateFlow(hideCommunityRulesViolations), nip46SignerEnabled = MutableStateFlow(nip46SignerEnabled), nip46BunkerSecret = MutableStateFlow(nip46BunkerSecret), + nip46TransportKey = MutableStateFlow(nip46TransportKey), defaultHomeFollowList = MutableStateFlow(followListPrefs.home), defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories), defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 38eca3aa6f..a8ea44e447 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -198,6 +198,15 @@ class AccountSettings( * apps to pair with an old string. */ val nip46BunkerSecret: MutableStateFlow = MutableStateFlow(""), + /** + * A dedicated per-account transport keypair (hex private key) for the NIP-46 + * bunker. The kind-24133 envelope is wrapped with THIS key, not the account's + * identity key, so the bunker address and on-relay traffic don't reveal which + * user the bunker belongs to (the identity is disclosed only to a connected + * app via `get_public_key`). Generated once and kept stable so the advertised + * `bunker://` address doesn't change. + */ + val nip46TransportKey: MutableStateFlow = MutableStateFlow(""), /** * NIP-9B opt-in: when true, community feeds drop events whose latest cached * `kind:34551` rules document fails [com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator]. @@ -609,6 +618,13 @@ class AccountSettings( } } + fun changeNip46TransportKey(hexPrivKey: String) { + if (nip46TransportKey.value != hexPrivKey) { + nip46TransportKey.tryEmit(hexPrivKey) + saveAccountSettings() + } + } + fun changeLocalBlossomCacheProfilePicturesOnly(enabled: Boolean) { if (localBlossomCacheProfilePicturesOnly.value != enabled) { localBlossomCacheProfilePicturesOnly.tryEmit(enabled) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index 83041b61f0..3641e3d349 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -26,11 +26,15 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAut import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.quartz.nip01Core.core.HexKey +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.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI @@ -54,9 +58,13 @@ import kotlinx.coroutines.launch * Runs Amethyst as a NIP-46 remote signer ("bunker") for the account, so other * apps can sign through it. While [AccountSettings.nip46SignerEnabled] is on, a * [NostrConnectSignerService] listens on the user's inbox relays (plus any relays - * pulled in by a pasted `nostrconnect://` offer) for kind:24133 requests, decrypts - * them with the account's own [signer] — a local key or a NIP-55 external app, - * whichever the user logged in with — and answers each one. + * pulled in by a pasted `nostrconnect://` offer) for kind:24133 requests. + * + * Two keys are kept apart: a dedicated local [transportSigner] wraps/unwraps the + * kind-24133 envelope (so the bunker address never reveals the user, and an + * external NIP-55 account pays no IPC cost for envelope crypto), while the actual + * sign/encrypt/decrypt and `get_public_key` use the account's identity [signer] — + * a local key or a NIP-55 external app, whichever the user logged in with. * * Every request is gated by [Nip46PermissionAuthorizer], i.e. the same * "Connected Apps" trust ledger that governs napplets and web origins: a remote @@ -78,6 +86,14 @@ class Nip46SignerState( /** Relays contributed by pasted `nostrconnect://` offers this session, unioned with the inbox set. */ private val extraRelays = MutableStateFlow>(emptySet()) + /** + * The dedicated per-account transport signer that wraps the kind-24133 envelope — a local key + * unrelated to the account identity, so the bunker address/traffic doesn't reveal who it is for, + * and (unlike the identity signer) an external NIP-55 account pays no IPC cost for envelope crypto. + * Generated + persisted lazily on first use so accounts that never enable the signer mint nothing. + */ + private val transportSigner: NostrSignerInternal by lazy { NostrSignerInternal(KeyPair(ensureTransportKeyBytes())) } + /** All relays the signer listens on: the account inbox plus any nostrconnect offer relays. */ val listeningRelays: StateFlow> = combine(inboxRelays, extraRelays) { inbox, extra -> inbox + extra } @@ -130,11 +146,13 @@ class Nip46SignerState( } if (relays.isEmpty()) return@collectLatest + // Envelope wrapped with the local transport key; the actual work (and get_public_key) + // uses the account's identity signer inside the processor. val processor = BunkerRequestProcessor(signer, { listeningRelays.value }, authorizer) val service = NostrConnectSignerService( client = client, - signer = signer, + transportSigner = transportSigner, processor = processor, relays = relays, onServiced = { method, clientPubKey, error -> @@ -154,10 +172,11 @@ class Nip46SignerState( settings.changeNip46SignerEnabled(enabled) } - /** The `bunker://?relay=…&secret=…` string to paste into another app. Generates a secret if needed. */ + /** The `bunker://?relay=…&secret=…` string to paste into another app. Generates keys/secret if needed. */ fun bunkerUri(): String { val secret = ensureSecret() - return NostrConnectURI.buildBunker(signer.pubKey, inboxRelays.value, secret) + // Advertise the transport key, not the identity key, so the address doesn't reveal who we are. + return NostrConnectURI.buildBunker(transportSigner.pubKey, inboxRelays.value, secret) } /** Replaces the pairing secret with a fresh one, revoking the ability of not-yet-connected apps to use the old one. */ @@ -195,6 +214,16 @@ class Nip46SignerState( return fresh } + /** The transport private key bytes, generating and persisting a fresh keypair the first time (or if corrupt). */ + private fun ensureTransportKeyBytes(): ByteArray { + val stored = settings.nip46TransportKey.value + val existing = stored.takeIf { it.length == 64 }?.let { runCatching { it.hexToByteArray() }.getOrNull() } + if (existing != null) return existing + val fresh = KeyPair() + settings.changeNip46TransportKey(fresh.privKey!!.toHexKey()) + return fresh.privKey!! + } + /** * The client-initiated (`nostrconnect://`) pairing flow: parse a client's * offer, send the connect ack that echoes its secret (so the client learns @@ -207,9 +236,10 @@ class Nip46SignerState( if (!signer.isWriteable()) return ConnectResult.NotWriteable return try { - // Echo the offer secret back to the client on its relays. + // Echo the offer secret back to the client, authored by the transport key so the client + // learns THAT as our remote-signer pubkey (not our identity). val ack = BunkerResponse(newSubId(), offer.secret, null) - val reply = NostrConnectEvent.create(ack, offer.clientPubKey, signer) + val reply = NostrConnectEvent.create(ack, offer.clientPubKey, transportSigner) client.publish(reply, offer.relays) // Register the app (the paste is the user's consent) and listen on its relays. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt index e8a682c6dd..e276320377 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -198,7 +198,9 @@ object BunkerCommand { val service = NostrConnectSignerService( client = ctx.client, - signer = ctx.signer, + // The CLI bunker deliberately advertises the operator's own key as the transport key + // (a simple dev/interop tool); the app uses a separate transport key for privacy. + transportSigner = ctx.signer, processor = processor, relays = relays, onServiced = { method, client, error -> diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index 4a34f72ab6..7a7c8d3c2e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -38,13 +38,20 @@ import kotlinx.coroutines.channels.Channel /** * Runs a NIP-46 remote signer ("bunker") for one account: subscribes to the - * given [relays] for kind-24133 requests addressed to the user, decrypts each - * one with the user's [signer], hands it to [processor], and publishes the - * encrypted reply back to the requesting client. + * given [relays] for kind-24133 requests addressed to the bunker, decrypts each + * envelope, hands the request to [processor], and publishes the encrypted reply. * - * The signer is whatever the account logged in with — a local keypair or a - * NIP-55 external app — so the same [NostrConnectEvent.create] transport - * encryption and the same [BunkerRequestProcessor] dispatch serve both. + * Two keys are deliberately kept apart: + * - [transportSigner] wraps and unwraps the kind-24133 envelope (subscription + * p-tag, [NostrConnectEvent.decryptMessage] / [NostrConnectEvent.create]). It + * is a dedicated per-account key that has nothing to do with the user's + * identity, so the bunker address and the on-relay traffic don't reveal WHO + * the bunker is for, and — since it is a local key — the envelope crypto never + * round-trips an external NIP-55 signer. + * - The [processor]'s signer is the user's IDENTITY signer (a local key or a + * NIP-55 app); it performs the actual `sign_event`/`nip04|44_*` work and + * answers `get_public_key` with the real npub — revealed only to a connected + * client, over the already-encrypted channel. * * [run] is a long-running suspend loop: it services requests until the calling * coroutine is cancelled, then tears the subscription down. Callers who need to @@ -53,7 +60,7 @@ import kotlinx.coroutines.channels.Channel */ class NostrConnectSignerService( val client: INostrClient, - val signer: NostrSigner, + val transportSigner: NostrSigner, val processor: BunkerRequestProcessor, val relays: Set, /** Optional hook, invoked with each serviced request's method + client, for logging/metrics. */ @@ -67,9 +74,9 @@ class NostrConnectSignerService( /** * Bound on events buffered between the relay threads and the single consumer. * Under a flood (a looping client, or a hostile peer p-tagging us) the newest - * events past this many are dropped instead of growing memory without limit — - * the transport [signer] can be an external NIP-55 app where each decrypt is an - * IPC round-trip, so the queue must not run away. + * events past this many are dropped instead of growing memory without limit. + * Envelope decryption is local, but each serviced request can drive an external + * NIP-55 op on the identity signer, so the queue must not run away. */ val maxQueue: Int = 256, /** Max requests decrypted per author within [rateWindowSeconds] before further ones are dropped. */ @@ -126,7 +133,7 @@ class NostrConnectSignerService( return } - val self = signer.pubKey + val self = transportSigner.pubKey // Bounded + DROP_LATEST so a flood bounds memory instead of growing an unlimited queue. val events = Channel(capacity = maxQueue, onBufferOverflow = BufferOverflow.DROP_LATEST) val rateLimiter = RateLimiter(maxRequestsPerWindow, rateWindowSeconds, maxTrackedAuthors) @@ -178,10 +185,10 @@ class NostrConnectSignerService( } private suspend fun handle(event: NostrConnectEvent) { - val client = event.talkingWith(signer.pubKey) + val client = event.talkingWith(transportSigner.pubKey) val request = try { - event.decryptMessage(signer) as? BunkerRequest ?: return + event.decryptMessage(transportSigner) as? BunkerRequest ?: return } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -194,7 +201,7 @@ class NostrConnectSignerService( onServiced?.invoke(request.method, client, error) try { - val reply = NostrConnectEvent.create(response, client, signer) + val reply = NostrConnectEvent.create(response, client, transportSigner) this.client.publish(reply, relays) } catch (e: CancellationException) { throw e diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt index 67f89ecc14..11c0fd9f5e 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt @@ -32,9 +32,11 @@ import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -58,6 +60,7 @@ import kotlin.test.assertTrue class NostrConnectSignerServiceTest { private val serverKey = "a".repeat(64) private val clientKey = "b".repeat(64) + private val identityKey = "d".repeat(64) private val relay: NormalizedRelayUrl = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! /** Passthrough crypto + real event construction, so NostrConnectEvent.create/decryptMessage round-trip. */ @@ -183,6 +186,27 @@ class NostrConnectSignerServiceTest { assertEquals("s3cr3t", reply.result) } + @Test + fun getPublicKeyReturnsIdentityNotTransportKey() = + runTest { + val client = LoopbackClient() + // The client connects to the transport key (serverKey); the actual work signer is a + // separate identity key. get_public_key must reveal the identity, not the transport key. + val transport = serverSigner() + val identity = PassthroughSigner(identityKey) + val processor = BunkerRequestProcessor(identity, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, transport, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + client.deliver(request(BunkerRequestGetPublicKey("reqpk"))) + + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) + assertTrue(reply is BunkerResponsePublicKey) + assertEquals(identityKey, reply.pubkey) + assertTrue(reply.pubkey != serverKey, "must not leak the transport key") + } + @Test fun signRequestGetsSignedEventReply() = runTest { From cceac4478a7e2dfc3ce3931b89ab43272b7dd17a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:15:44 +0000 Subject: [PATCH 16/52] feat(nip46): rotate the bunker transport key as the anti-spam remedy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the "regenerate" action mint a brand-new transport keypair (plus a fresh pairing secret) instead of only rotating the secret, so a spammed user can burn down the old bunker:// address. Anyone holding the old address — a spammer included — can no longer reach the signer, and every app talking to the old transport pubkey is dropped. Legit apps re-pair by re-scanning; their trust survives because the Connected-Apps coordinate keys off the stable identity pubkey, not the transport key. For rotation to actually take effect, the cached `by lazy` transportSigner is replaced with a per-call rebuild from the persisted key, and the service-restart trigger now includes AccountSettings.nip46TransportKey so the running NostrConnectSignerService re-subscribes under the new key. setEnabled() settles the secret and transport key before flipping the flag to avoid a throwaway double-start on first enable. The UI gates rotation behind a confirmation dialog (it disconnects every connected app) and relabels the action "New address". Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../model/nip46Signer/Nip46SignerState.kt | 45 +++++++++++++++---- .../settings/nip46/Nip46SignerScreen.kt | 41 +++++++++++++++-- amethyst/src/main/res/values/strings.xml | 8 +++- 3 files changed, 79 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index 3641e3d349..7a1c458b1c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -91,8 +91,12 @@ class Nip46SignerState( * unrelated to the account identity, so the bunker address/traffic doesn't reveal who it is for, * and (unlike the identity signer) an external NIP-55 account pays no IPC cost for envelope crypto. * Generated + persisted lazily on first use so accounts that never enable the signer mint nothing. + * + * Rebuilt from the persisted key on every call rather than cached, so [rotateAddress] takes effect: + * the service-restart trigger includes [AccountSettings.nip46TransportKey], and this reads the + * current value — deriving a keypair from stored bytes is cheap enough for the per-restart cost. */ - private val transportSigner: NostrSignerInternal by lazy { NostrSignerInternal(KeyPair(ensureTransportKeyBytes())) } + private fun transportSigner(): NostrSignerInternal = NostrSignerInternal(KeyPair(ensureTransportKeyBytes())) /** All relays the signer listens on: the account inbox plus any nostrconnect offer relays. */ val listeningRelays: StateFlow> = @@ -134,11 +138,14 @@ class Nip46SignerState( scope.launch(Dispatchers.IO) { refreshExtraRelaysFromStore() } scope.launch(Dispatchers.IO) { - combine(settings.nip46SignerEnabled, listeningRelays) { enabled, relays -> enabled to relays } - // Inbox/relay StateFlows can re-emit an identical set; without this every duplicate - // would tear the subscription down and re-open it on every relay for no reason. + combine(settings.nip46SignerEnabled, listeningRelays, settings.nip46TransportKey) { enabled, relays, transportKey -> + Triple(enabled, relays, transportKey) + } + // Inbox/relay/key StateFlows can re-emit an identical value; without this every duplicate + // would tear the subscription down and re-open it on every relay for no reason. Including + // the transport key here makes rotateAddress() re-subscribe under the fresh key. .distinctUntilChanged() - .collectLatest { (enabled, relays) -> + .collectLatest { (enabled, relays, _) -> if (!enabled) return@collectLatest if (!signer.isWriteable()) { Log.w("NIP46Signer") { "signer not writeable; cannot host a bunker" } @@ -152,7 +159,7 @@ class Nip46SignerState( val service = NostrConnectSignerService( client = client, - transportSigner = transportSigner, + transportSigner = transportSigner(), processor = processor, relays = relays, onServiced = { method, clientPubKey, error -> @@ -168,7 +175,12 @@ class Nip46SignerState( val enabled: StateFlow get() = settings.nip46SignerEnabled fun setEnabled(enabled: Boolean) { - if (enabled) ensureSecret() + if (enabled) { + // Settle the secret and transport key BEFORE flipping the flag, so the service-restart + // trigger sees the final transport key on its first emission (no throwaway double-start). + ensureSecret() + ensureTransportKeyBytes() + } settings.changeNip46SignerEnabled(enabled) } @@ -176,7 +188,7 @@ class Nip46SignerState( fun bunkerUri(): String { val secret = ensureSecret() // Advertise the transport key, not the identity key, so the address doesn't reveal who we are. - return NostrConnectURI.buildBunker(transportSigner.pubKey, inboxRelays.value, secret) + return NostrConnectURI.buildBunker(transportSigner().pubKey, inboxRelays.value, secret) } /** Replaces the pairing secret with a fresh one, revoking the ability of not-yet-connected apps to use the old one. */ @@ -186,6 +198,21 @@ class Nip46SignerState( return fresh } + /** + * The anti-spam "burn it down" action: mints a brand-new transport key (and pairing secret), so + * the old `bunker://` address goes dark — anyone who had it (a spammer included) can no longer + * reach us, and every app talking to the old transport pubkey is dropped. The running service + * re-subscribes under the new key because [AccountSettings.nip46TransportKey] feeds the restart + * trigger. Legit apps re-pair by re-scanning the new address; their trust survives because the + * Connected-Apps coordinate keys off the stable identity pubkey, not the transport key. + */ + fun rotateAddress(): String { + val fresh = KeyPair() + settings.changeNip46TransportKey(fresh.privKey!!.toHexKey()) + regenerateSecret() + return NostrConnectURI.buildBunker(fresh.pubKey.toHexKey(), inboxRelays.value, settings.nip46BunkerSecret.value) + } + /** Recomputes [extraRelays] from the persisted client store — the source of truth for nostrconnect relays. */ private suspend fun refreshExtraRelaysFromStore() { extraRelays.value = @@ -239,7 +266,7 @@ class Nip46SignerState( // Echo the offer secret back to the client, authored by the transport key so the client // learns THAT as our remote-signer pubkey (not our identity). val ack = BunkerResponse(newSubId(), offer.secret, null) - val reply = NostrConnectEvent.create(ack, offer.clientPubKey, transportSigner) + val reply = NostrConnectEvent.create(ack, offer.clientPubKey, transportSigner()) client.publish(reply, offer.relays) // Register the app (the paste is the user's consent) and listen on its relays. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index fb63d8c1a6..b57613e86c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -115,6 +116,7 @@ fun Nip46SignerScreen( var connectedCount by remember { mutableIntStateOf(0) } var refreshKey by remember { mutableIntStateOf(0) } var scanning by remember { mutableStateOf(false) } + var confirmRotate by remember { mutableStateOf(false) } var bunkerUri by remember { mutableStateOf(null) } LaunchedEffect(enabled, secret, relays) { @@ -182,10 +184,7 @@ fun Nip46SignerScreen( clipboard.setText(AnnotatedString(uri)) Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show() }, - onRegenerate = { - signer.regenerateSecret() - Toast.makeText(context, R.string.nip46_signer_regenerated, Toast.LENGTH_SHORT).show() - }, + onRegenerate = { confirmRotate = true }, ) } @@ -206,6 +205,40 @@ fun Nip46SignerScreen( ) } } + + if (confirmRotate) { + RotateAddressDialog( + onConfirm = { + confirmRotate = false + signer.rotateAddress() + Toast.makeText(context, R.string.nip46_signer_regenerated, Toast.LENGTH_SHORT).show() + }, + onDismiss = { confirmRotate = false }, + ) + } +} + +@Composable +private fun RotateAddressDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(24.dp)) }, + title = { Text(stringResource(R.string.nip46_signer_rotate_confirm_title)) }, + text = { Text(stringResource(R.string.nip46_signer_rotate_confirm_message)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.nip46_signer_rotate_confirm_button)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.nip46_signer_cancel)) + } + }, + ) } // ---------------------------------------------------------------------------- diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 6b81a1e108..4ab827baf0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -913,8 +913,12 @@ Paste this into another app to connect it to your key. Copy Bunker address copied - New secret - Generated a new pairing secret + New address + Generated a new bunker address + Generate a new address? + This mints a fresh bunker address and disconnects every app currently connected. Anyone who has the old address — a spammer included — can no longer reach you. Reconnect your own apps by scanning the new code. + Generate + Cancel Connect an app Paste a nostrconnect:// link Connect From 3ed8779fff0f3e122cb46a84b59629bce169f37c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:44:23 +0000 Subject: [PATCH 17/52] feat(nip46): scan nostrconnect:// from anywhere and un-gate the connect flow Two UX gaps made "scan an app's code to connect it to my signer" hard to reach: - The signer screen's Connect section (Scan a code / paste) lived behind the enabled gate, so a first-time user saw only the "Turn on signer" hero and couldn't scan until they had enabled. Since a successful nostrconnect pairing already calls setEnabled(true), the gate was pointless. The Connect section now shows in both states; the success toast spells out the consequence ("Amethyst now signs for it in the background"). - The app's general QR scanner (uriToRoute) only understood NIP-19 entities, so scanning a nostrconnect:// code did nothing. It now routes a nostrconnect:// offer to the signer screen, which pairs the app on open. Route.Nip46Signer carries an optional connectUri for this. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../vitorpamplona/amethyst/ui/MainActivity.kt | 7 ++ .../amethyst/ui/navigation/AppNavigation.kt | 2 +- .../amethyst/ui/navigation/routes/Routes.kt | 5 +- .../settings/SettingsCatalogBuilder.kt | 2 +- .../settings/nip46/Nip46SignerScreen.kt | 75 +++++++++++-------- amethyst/src/main/res/values/strings.xml | 4 +- .../amethyst/ui/UriToRouteTest.kt | 6 ++ 7 files changed, 64 insertions(+), 37 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 30fa7e80c0..bfb3a0f6d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent @@ -224,6 +225,12 @@ fun uriToRoute( return connectedAppRoute(uri) } + // A scanned/opened `nostrconnect://` offer is an app asking to connect to our signer: open the + // NIP-46 signer screen and let it run the pairing (it enables the signer as part of connecting). + if (uri.startsWith(NostrConnectURI.NOSTRCONNECT_SCHEME)) { + return Route.Nip46Signer(connectUri = uri) + } + relayGroupInviteRoute(uri)?.let { return it } concordInviteRoute(uri)?.let { return it } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 5ab0cc7d03..48e98fb6e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -424,7 +424,7 @@ fun BuildNavigation( composableFromEndArgs(capWidth = false) { NostrAppScreen(it.coordinate, accountViewModel, nav) } composableFromEnd { ConnectedAppsScreen(accountViewModel, nav) } composableFromEndArgs { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) } - composableFromEnd { Nip46SignerScreen(accountViewModel, nav) } + composableFromEndArgs { Nip46SignerScreen(accountViewModel, nav, it.connectUri) } composableFromEnd { RelayAuthSettingsScreen(accountViewModel, nav) } composableFromEndArgs { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { CalendarsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 952668dd60..4b4f2e3347 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -107,7 +107,10 @@ sealed class Route { @Serializable object ConnectedApps : Route() - @Serializable object Nip46Signer : Route() + @Serializable data class Nip46Signer( + /** When set (from a scanned/opened `nostrconnect://` offer), the screen connects that app on open. */ + val connectUri: String? = null, + ) : Route() @Serializable object RelayAuthSettings : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index fcebc84a5d..473bbb9a09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -75,7 +75,7 @@ fun buildSettingsCatalog( symEntry(R.string.profile_badges_title, MaterialSymbols.MilitaryTech, R.string.profile_badges_search_keywords, Route.ProfileBadges), symEntry(R.string.payment_targets, MaterialSymbols.Payment, R.string.payment_targets_search_keywords, Route.EditPaymentTargets), symEntry(R.string.napplet_permissions_title, MaterialSymbols.Apps, R.string.napplet_connected_apps_search_keywords, Route.ConnectedApps), - symEntry(R.string.nip46_signer_title, MaterialSymbols.Key, R.string.nip46_signer_search_keywords, Route.Nip46Signer), + symEntry(R.string.nip46_signer_title, MaterialSymbols.Key, R.string.nip46_signer_search_keywords, Route.Nip46Signer()), symEntry(R.string.relay_auth_settings_title, MaterialSymbols.Lock, R.string.relay_auth_search_keywords, Route.RelayAuthSettings), symEntry(R.string.security_filters, MaterialSymbols.Security, R.string.security_filters_search_keywords, Route.SecurityFilters), symEntry(R.string.call_settings, MaterialSymbols.Phone, R.string.call_settings_search_keywords, Route.CallSettings), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index b57613e86c..293ad1d25e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -101,6 +101,7 @@ private val LiveGreen = Color(0xFF3DDC84) fun Nip46SignerScreen( accountViewModel: AccountViewModel, nav: INav, + connectUri: String? = null, ) { val account = accountViewModel.account val signer = account.nip46Signer @@ -138,6 +139,11 @@ fun Nip46SignerScreen( } } + // Opened from a scanned/shared nostrconnect:// offer — pair that app on open (this also enables the signer). + LaunchedEffect(connectUri) { + if (!connectUri.isNullOrBlank()) onConnect(connectUri) + } + if (scanning) { SimpleQrCodeScanner { contents -> scanning = false @@ -162,47 +168,52 @@ fun Nip46SignerScreen( return@Column } - if (!enabled) { - DisabledHero(onEnable = { signer.setEnabled(true) }) - return@Column - } - - LiveStatusCard( - relayCount = relays.size, - connectedCount = connectedCount, - onToggleOff = { signer.setEnabled(false) }, - ) - - if (relays.isEmpty()) { - WarningCard(stringResource(R.string.nip46_signer_status_no_relays)) - } - - bunkerUri?.let { uri -> - QrHeroCard( - uri = uri, - onCopy = { - clipboard.setText(AnnotatedString(uri)) - Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show() - }, - onRegenerate = { confirmRotate = true }, + if (enabled) { + LiveStatusCard( + relayCount = relays.size, + connectedCount = connectedCount, + onToggleOff = { signer.setEnabled(false) }, ) + + if (relays.isEmpty()) { + WarningCard(stringResource(R.string.nip46_signer_status_no_relays)) + } + + bunkerUri?.let { uri -> + QrHeroCard( + uri = uri, + onCopy = { + clipboard.setText(AnnotatedString(uri)) + Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show() + }, + onRegenerate = { confirmRotate = true }, + ) + } + } else { + DisabledHero(onEnable = { signer.setEnabled(true) }) } + // The Connect section stays reachable even while off: scanning an app's nostrconnect:// + // code pairs it and enables the signer as part of connecting (no separate "enable" step). ConnectSection( onScan = { scanning = true }, onPaste = { onConnect(it) }, ) - ConnectedAppsRow( - count = connectedCount, - onClick = { nav.nav(Route.ConnectedApps) }, - ) + if (enabled || connectedCount > 0) { + ConnectedAppsRow( + count = connectedCount, + onClick = { nav.nav(Route.ConnectedApps) }, + ) + } - Text( - stringResource(R.string.nip46_signer_background_hint), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + if (enabled) { + Text( + stringResource(R.string.nip46_signer_background_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 4ab827baf0..7ef6f6d57b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -923,8 +923,8 @@ Paste a nostrconnect:// link Connect Manage connected apps - App connected - Connected %1$s + App connected. Amethyst now signs for it in the background. + Connected %1$s. Amethyst now signs for it in the background. Not a valid nostrconnect:// link That link carries no relay to connect on Could not connect: %1$s diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt index 798f847409..309e8f338f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt @@ -58,6 +58,12 @@ class UriToRouteTest { assertEquals(Route.Hashtag("foo"), uriToRoute("nostr:hashtag?id=foo", account)) } + @Test + fun nostrConnectOfferRoutesToTheSignerScreenCarryingTheUri() { + val offer = "nostrconnect://" + "b".repeat(64) + "?relay=wss%3A%2F%2Frelay.example.com&secret=abc123" + assertEquals(Route.Nip46Signer(connectUri = offer), uriToRoute(offer, account)) + } + @Test fun fragmentHashtagOrNullExtractsTheTag() { assertEquals("NostrMultiplayerGames", fragmentHashtagOrNull("#NostrMultiplayerGames")) From d8d185b9282ff4c6792ff0bdfa1447df37e9ff54 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:49:12 +0000 Subject: [PATCH 18/52] feat(nip46): register nostrconnect:// deep link to the signer Add a VIEW/BROWSABLE intent filter for the `nostrconnect` scheme on MainActivity so a website's "Connect with Amethyst" link (or any app firing a nostrconnect:// URI) hands off into the same pairing flow as a scanned QR. The incoming intent.data already flows through uriToRoute, which now maps a nostrconnect:// offer to Route.Nip46Signer(connectUri); both the cold-start and onNewIntent paths navigate there and pair on open. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- amethyst/src/main/AndroidManifest.xml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 42e59a2bb1..e3a0429249 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -151,6 +151,14 @@ + + + + + + + + From 4676d176ecd1c424ec24d60f0678c1e0e62e706a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 02:31:25 +0000 Subject: [PATCH 19/52] feat(nip46): live per-request + first-connect consent (Tier 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the NIP-46 remote signer into the same interactive consent surface the napplet/browser signer path uses, so requests that aren't pre-granted prompt instead of silently failing. The ledger already returns ASK for the risky operations (profile 0, contacts 3, deletion 5, decryption, DMs are excluded from REASONABLE_SIGN_KINDS; a PARANOID app asks for everything) — the only reason it didn't work was that authorize() treated ASK as "unauthorized". Now: - authorize(): ALLOW proceeds, DENY refused, ASK consults an in-memory session grant then calls opConsent (the shared per-op dialog). The returned SignerOpGrant is recorded via a new NostrSignerPermissionLedger.record() helper (allow-for-op / until / all / deny-for-op persisted; once/session not), mirroring the broker. No opConsent wired → ASK fails closed (CLI/tests). - onConnect(): first contact asks connectConsent for the trust level (AppConnectResult) instead of silently granting REASONABLE; Blocked/Cancelled reject the connection. Falls back to defaultPolicyOnConnect when no prompt. - forget() also clears the client's in-memory session grants. Nip46ConsentBridge (amethyst) implements the two prompts by reusing the existing NappletConnect/NappletSignerConsent coordinators + dialogs + ledger, building the render info from the bunker request (op label, event JSON preview, client metadata/icon). A 120s timeout fails a stuck per-op prompt closed so it can't wedge the signer's single-consumer loop. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../model/nip46Signer/Nip46ConsentBridge.kt | 107 ++++++++++++++ .../model/nip46Signer/Nip46SignerState.kt | 5 + .../nip46/Nip46PermissionAuthorizer.kt | 92 ++++++++++-- .../signers/NostrSignerPermissionLedger.kt | 20 +++ .../nip46/Nip46PermissionAuthorizerTest.kt | 137 ++++++++++++++++++ 5 files changed, 349 insertions(+), 12 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt new file mode 100644 index 0000000000..8dcb99dc6f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip46Signer + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant +import com.vitorpamplona.amethyst.napplet.NappletConnectCoordinator +import com.vitorpamplona.amethyst.napplet.NappletConnectInfo +import com.vitorpamplona.amethyst.napplet.NappletSignerConsentCoordinator +import com.vitorpamplona.amethyst.napplet.NappletSignerConsentInfo +import com.vitorpamplona.amethyst.napplet.label +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Bridges the (KMP, headless) [com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer] + * to the interactive consent UI. It reuses the SAME dialogs the napplet/browser signer path uses — + * [NappletConnectCoordinator] (first-connect trust picker) and [NappletSignerConsentCoordinator] + * (per-operation allow/deny) — so a NIP-46 remote app prompts through one consistent surface, and + * the user's "remember" choices land in the same [com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger]. + * + * Runs only in the main process (the signer never runs in `:napplet`), so [Amethyst.instance] is set; + * the coordinators launch their Activity from the application context. + */ +object Nip46ConsentBridge { + /** + * Upper bound on how long a per-op prompt may block the signer's single-consumer loop. A user who + * ignores the dialog eventually fails the request closed (deny-once) instead of wedging the signer. + */ + private const val CONSENT_TIMEOUT_MS = 120_000L + + /** First-connect consent: show the app's self-declared identity and let the user pick a trust level. */ + suspend fun requestConnect( + coordinate: String, + clientPubKey: HexKey, + request: BunkerRequestConnect, + ): AppConnectResult { + val context = Amethyst.instance.appContext + val meta = request.clientMetadata + val title = meta?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app) + val domain = meta?.url?.ifBlank { null } ?: (clientPubKey.take(12) + "…") + return NappletConnectCoordinator.requestConnect( + context, + NappletConnectInfo(appletTitle = title, coordinate = coordinate, domain = domain, iconUrl = meta?.image), + ) + } + + /** Per-operation consent: describe the request (op + event preview) and await the user's grant. */ + suspend fun requestOp( + coordinate: String, + clientPubKey: HexKey, + op: NostrSignerOp, + request: BunkerRequest, + ): SignerOpGrant { + val context = Amethyst.instance.appContext + val info = runCatching { Amethyst.instance.nip46ClientStore.load(coordinate) }.getOrNull() + val title = info?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app) + val preview = + if (request is BunkerRequestSign) { + request.event.content + .take(160) + .trim() + } else { + "" + } + val rawData = if (request is BunkerRequestSign) JacksonMapper.toJsonPretty(request.event) else "" + val consentInfo = + NappletSignerConsentInfo( + appletTitle = title, + coordinate = coordinate, + op = op, + operationSummary = op.label(context), + contentPreview = preview, + rawData = rawData, + iconUrl = info?.image, + ) + // Fail closed if the prompt is never answered so a stuck dialog can't hold the signer hostage. + return withTimeoutOrNull(CONSENT_TIMEOUT_MS) { + NappletSignerConsentCoordinator.requestConsent(context, consentInfo) + } ?: SignerOpGrant.DenyOnce + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index 7a1c458b1c..2398990996 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -129,6 +129,11 @@ class Nip46SignerState( // A forgotten client's relays are gone from the store now; recompute the listen set so we // stop listening on them this session instead of waiting for a restart. onDisconnected = { refreshExtraRelaysFromStore() }, + // Interactive consent through the shared signer dialogs: a trust-level picker on first + // connect, and an allow/deny prompt whenever the ledger says ASK (dangerous kinds, + // decryption, DMs, or a PARANOID app). Same surface + ledger as napplet/browser signing. + connectConsent = Nip46ConsentBridge::requestConnect, + opConsent = Nip46ConsentBridge::requestOp, ) init { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt index 9d24d758dd..e2db0fae03 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt @@ -20,10 +20,12 @@ */ package com.vitorpamplona.amethyst.commons.connectedApps.nip46 +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -54,16 +56,16 @@ import com.vitorpamplona.quartz.utils.TimeUtils * Authorization mirrors the napplet path: * - each signing/encryption/decryption request maps to a [NostrSignerOp] * ([sign:kind][NostrSignerOp.SignKind] / [encrypt][NostrSignerOp.Encrypt] / - * [decrypt][NostrSignerOp.Decrypt]) and is allowed only when the ledger's - * standing decision is [NostrOpDecision.ALLOW]. `ASK`/`DENY` are refused — - * a background signer cannot raise an interactive prompt, so the user grants - * access ahead of time by choosing a trust level (or a per-op override) in - * Connected Apps. + * [decrypt][NostrSignerOp.Decrypt]). `ALLOW` proceeds, `DENY` is refused, and + * `ASK` triggers [opConsent] — the interactive prompt through the shared signer + * consent dialog (with in-memory session grants and a remembered per-op result). + * When no [opConsent] is wired (headless CLI, tests) `ASK` fails closed, so the + * signer only ever performs pre-granted operations. * - a `connect` request first validates the pairing secret via - * [validateSecret]; on success the app is registered at - * [defaultPolicyOnConnect] (only if it has no policy yet — never downgrading - * a level the user already chose), then [onConnected] runs so the host can - * record display metadata. + * [validateSecret]; on first contact (no standing policy) it asks [connectConsent] + * for the trust level, falling back to [defaultPolicyOnConnect] when no prompt is + * wired; an existing policy is never downgraded. [onConnected] then runs so the + * host can record display metadata. */ class Nip46PermissionAuthorizer( val ledger: NostrSignerPermissionLedger, @@ -83,7 +85,25 @@ class Nip46PermissionAuthorizer( * for the next restart. */ val onDisconnected: (suspend (clientPubKey: HexKey) -> Unit)? = null, + /** + * Interactive first-connect consent. When a client connects with a valid secret but has no + * standing policy, this is asked (showing the app's metadata) and its [AppConnectResult] decides + * the trust level. `null` (headless CLI, tests) keeps the non-interactive behavior: auto-register + * at [defaultPolicyOnConnect]. + */ + val connectConsent: (suspend (coordinate: String, clientPubKey: HexKey, request: BunkerRequestConnect) -> AppConnectResult)? = null, + /** + * Interactive per-operation consent. Asked when the ledger's standing decision for a request is + * [NostrOpDecision.ASK]; the returned [SignerOpGrant] both decides the in-flight request and is + * recorded (remember/session/deny variants). `null` keeps the non-interactive behavior: ASK is + * treated as deny, so a headless signer only ever performs pre-granted operations. + */ + val opConsent: (suspend (coordinate: String, clientPubKey: HexKey, op: NostrSignerOp, request: BunkerRequest) -> SignerOpGrant)? = null, ) : Nip46RequestAuthorizer { + // Session-only ("allow until the signer restarts") grants: coordinate + op key, held in memory + // and never persisted — mirrors the napplet broker's sessionAllows. Guarded by [throttleLock]. + private val sessionAllows = mutableSetOf() + // A high-throughput client can authorize many signs per second; last-used is display-only, // so coalesce the DataStore write to at most one per client per LAST_USED_THROTTLE_SECS // instead of writing the whole per-client preferences file on every request. @@ -118,7 +138,14 @@ class Nip46PermissionAuthorizer( val coordinate = coordinateFor(clientPubKey) if (!ledger.hasPolicy(coordinate)) { - ledger.setPolicy(coordinate, defaultPolicyOnConnect) + // First contact: ask the user (if a prompt is wired) which trust level to grant; a + // headless signer with no prompt falls back to the non-interactive default. + when (val consent = connectConsent?.invoke(coordinate, clientPubKey, request)) { + null -> ledger.setPolicy(coordinate, defaultPolicyOnConnect) + is AppConnectResult.Connected -> ledger.setPolicy(coordinate, consent.policy) + AppConnectResult.Blocked -> return Nip46ConnectDecision.Reject("blocked by user") + AppConnectResult.Cancelled -> return Nip46ConnectDecision.Reject("connection declined") + } } touchLastUsed(coordinate) onConnected?.invoke(clientPubKey, request) @@ -137,11 +164,49 @@ class Nip46PermissionAuthorizer( // sign/encrypt/decrypt, so this branch is a safety net). val op = request.toSignerOp() ?: return true val coordinate = coordinateFor(clientPubKey) - val allowed = ledger.decide(coordinate, op) == NostrOpDecision.ALLOW + + val allowed = + when (ledger.decide(coordinate, op)) { + NostrOpDecision.ALLOW -> true + NostrOpDecision.DENY -> false + // ASK: honor a live session grant first, otherwise prompt the user (if wired). No + // prompt → deny, so a headless signer only ever performs pre-granted operations. + NostrOpDecision.ASK -> { + if (isSessionAllowed(coordinate, op)) { + true + } else { + askOpConsent(coordinate, clientPubKey, op, request) + } + } + } if (allowed) touchLastUsed(coordinate) return allowed } + private suspend fun askOpConsent( + coordinate: String, + clientPubKey: HexKey, + op: NostrSignerOp, + request: BunkerRequest, + ): Boolean { + val grant = opConsent?.invoke(coordinate, clientPubKey, op, request) ?: return false + ledger.record(coordinate, grant) + if (grant is SignerOpGrant.AllowForSession) { + throttleLock.withLock { sessionAllows.add(sessionKey(coordinate, op)) } + } + return grant.isAllowed + } + + private suspend fun isSessionAllowed( + coordinate: String, + op: NostrSignerOp, + ): Boolean = throttleLock.withLock { sessionAllows.contains(sessionKey(coordinate, op)) } + + private fun sessionKey( + coordinate: String, + op: NostrSignerOp, + ): String = "$coordinate|${op.key}" + override suspend fun onLogout(clientPubKey: HexKey) = forget(clientPubKey) /** @@ -154,7 +219,10 @@ class Nip46PermissionAuthorizer( val coordinate = coordinateFor(clientPubKey) ledger.revokeAll(coordinate) clientStore?.remove(coordinate) - throttleLock.withLock { lastUsedThrottle.remove(coordinate) } + throttleLock.withLock { + lastUsedThrottle.remove(coordinate) + sessionAllows.removeAll { it.startsWith("$coordinate|") } + } onDisconnected?.invoke(clientPubKey) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt index 5acb4bf0c3..ae8d1cd9d4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt @@ -144,6 +144,26 @@ class NostrSignerPermissionLedger( /** Removes all signer permissions for [coordinate] — trust level and all per-op overrides. */ suspend fun revokeAll(coordinate: String) = store.clearAll(coordinate) + /** + * Applies the persisted part of a user's per-operation consent [grant] to [coordinate], so a + * "remember" choice sticks. The transient variants ([SignerOpGrant.AllowOnce], + * [SignerOpGrant.DenyOnce], [SignerOpGrant.AllowForSession]) persist nothing — the caller keeps + * session grants in memory. Mirrors the broker's per-op recording so every consent surface + * (napplet, browser, NIP-46) writes the ledger the same way. + */ + suspend fun record( + coordinate: String, + grant: SignerOpGrant, + ) { + when (grant) { + is SignerOpGrant.AllowForOp -> setOpDecision(coordinate, grant.op, NostrOpDecision.ALLOW) + is SignerOpGrant.AllowUntil -> setTimedOpDecision(coordinate, grant.op, NostrOpDecision.ALLOW, grant.expiresAt) + is SignerOpGrant.AllowAll -> setPolicy(coordinate, AppSignerPolicy.FULL_TRUST) + is SignerOpGrant.DenyForOp -> setOpDecision(coordinate, grant.op, NostrOpDecision.DENY) + SignerOpGrant.AllowOnce, SignerOpGrant.DenyOnce, is SignerOpGrant.AllowForSession -> Unit + } + } + private fun reasonableDecision(op: NostrSignerOp): NostrOpDecision = when (op) { is NostrSignerOp.SignKind -> diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt index 34de64bde5..bd246b5b6e 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.amethyst.commons.connectedApps.nip46 +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent @@ -108,6 +110,141 @@ class Nip46PermissionAuthorizerTest { assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) } + @Test + fun askWithoutAPromptFailsClosed() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) + // No opConsent wired: an ASK op (decrypt under REASONABLE) is denied, never silently allowed. + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) + assertFalse(authorizer.authorize(client, BunkerRequestNip44Decrypt("2", client, "ct"))) + } + + @Test + fun askPromptsAndAllowOnceIsNotPersisted() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + var prompts = 0 + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> + prompts++ + SignerOpGrant.AllowOnce + }, + ) + + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertEquals(2, prompts, "allow-once must prompt every time") + } + + @Test + fun askPromptAllowForOpIsRememberedAndStopsPrompting() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + var prompts = 0 + val op = NostrSignerOp.SignKind(TextNoteEvent.KIND) + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> + prompts++ + SignerOpGrant.AllowForOp(op) + }, + ) + + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertEquals(1, prompts, "allow-for-op persists, so the second request does not prompt") + assertEquals(NostrOpDecision.ALLOW, ledger.store.loadOpDecision(coordinate, op)) + } + + @Test + fun askPromptDenyOnceRefusesTheRequest() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> SignerOpGrant.DenyOnce }, + ) + assertFalse(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + } + + @Test + fun allowForSessionSkipsFuturePromptsUntilForgotten() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + var prompts = 0 + val op = NostrSignerOp.SignKind(TextNoteEvent.KIND) + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> + prompts++ + SignerOpGrant.AllowForSession(op) + }, + ) + + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertEquals(1, prompts, "session grant is remembered in memory, no re-prompt") + assertEquals(null, ledger.store.loadOpDecision(coordinate, op), "session grant is not persisted") + + authorizer.forget(client) + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertEquals(2, prompts, "forgetting clears the session grant, so it prompts again") + } + + @Test + fun firstConnectConsentChoosesTheTrustLevel() = + runTest { + val ledger = ledger() + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + connectConsent = { _, _, _ -> AppConnectResult.Connected(AppSignerPolicy.FULL_TRUST) }, + ) + + val decision = authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "x")) + + assertTrue(decision is Nip46ConnectDecision.Accept) + assertEquals(AppSignerPolicy.FULL_TRUST, ledger.store.loadPolicy(coordinate)) + } + + @Test + fun cancelledConnectConsentRejectsAndStoresNoPolicy() = + runTest { + val ledger = ledger() + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + connectConsent = { _, _, _ -> AppConnectResult.Cancelled }, + ) + + val decision = authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "x")) + + assertTrue(decision is Nip46ConnectDecision.Reject) + assertEquals(null, ledger.store.loadPolicy(coordinate)) + } + @Test fun coordinateRoundTrips() { assertEquals(client, Nip46PermissionAuthorizer.clientPubKeyOf(coordinate)) From f9d9691017acaed4eb3d9432aff898a09c1fe00d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 02:38:15 +0000 Subject: [PATCH 20/52] feat(nip46): activity feed + account clarity on the signer screen (Tier 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the user visibility into what the signer is doing: - Nip46ActivityLog: a bounded, newest-first, in-memory feed of serviced requests (method + kind + client + ok/denied), fed from the service's onServiced hook (enriched to pass the full BunkerRequest so the event kind is available). Survives service restarts; not persisted (it's a live feed). - The signer screen shows a "Recent activity" card (last 8, friendly labels like "Signed an event (kind 1)", green/red status dot, relative time) and a "Signing as npub1…" line so it's clear which account is the bunker. onServiced now hands callers the BunkerRequest instead of just the method string (CLI updated to match). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../model/nip46Signer/Nip46ActivityLog.kt | 59 ++++++++++ .../model/nip46Signer/Nip46SignerState.kt | 18 ++- .../settings/nip46/Nip46SignerScreen.kt | 105 ++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 12 ++ .../amethyst/cli/commands/BunkerCommand.kt | 4 +- .../server/NostrConnectSignerService.kt | 6 +- 6 files changed, 197 insertions(+), 7 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ActivityLog.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ActivityLog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ActivityLog.kt new file mode 100644 index 0000000000..0cbc9b2a14 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ActivityLog.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip46Signer + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +/** One serviced NIP-46 request, for the "recent activity" feed. */ +data class Nip46ActivityEntry( + val atSeconds: Long, + val clientPubKey: HexKey, + /** The NIP-46 method (`sign_event`, `nip44_encrypt`, `get_public_key`, …). */ + val method: String, + /** Event kind for a `sign_event`, else `null`. */ + val kind: Int? = null, + /** `null` when the request succeeded; the error string when it failed or was denied. */ + val error: String? = null, +) { + val ok: Boolean get() = error == null +} + +/** + * A bounded, newest-first, in-memory log of the requests this account's signer has serviced, so the + * user can see what apps are actually doing. Not persisted across app restarts (it is a live feed, + * not an audit trail); it survives service restarts because it lives on the account's signer state. + */ +class Nip46ActivityLog( + private val capacity: Int = 100, +) { + private val _entries = MutableStateFlow>(emptyList()) + val entries: StateFlow> = _entries + + fun record(entry: Nip46ActivityEntry) { + _entries.update { (listOf(entry) + it).take(capacity) } + } + + /** The most recent entries for one client (newest first). */ + fun forClient(clientPubKey: HexKey): List = _entries.value.filter { it.clientPubKey == clientPubKey } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index 2398990996..bc33625974 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI @@ -42,6 +43,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor import com.vitorpamplona.quartz.nip46RemoteSigner.server.NostrConnectSignerService import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -86,6 +88,9 @@ class Nip46SignerState( /** Relays contributed by pasted `nostrconnect://` offers this session, unioned with the inbox set. */ private val extraRelays = MutableStateFlow>(emptySet()) + /** Newest-first, in-memory feed of serviced requests, so the UI can show what apps are doing. */ + val activityLog = Nip46ActivityLog() + /** * The dedicated per-account transport signer that wraps the kind-24133 envelope — a local key * unrelated to the account identity, so the bunker address/traffic doesn't reveal who it is for, @@ -167,8 +172,17 @@ class Nip46SignerState( transportSigner = transportSigner(), processor = processor, relays = relays, - onServiced = { method, clientPubKey, error -> - Log.d("NIP46Signer") { "$method from ${clientPubKey.take(8)}… → ${error ?: "ok"}" } + onServiced = { request, clientPubKey, error -> + Log.d("NIP46Signer") { "${request.method} from ${clientPubKey.take(8)}… → ${error ?: "ok"}" } + activityLog.record( + Nip46ActivityEntry( + atSeconds = TimeUtils.now(), + clientPubKey = clientPubKey, + method = request.method, + kind = (request as? BunkerRequestSign)?.event?.kind, + error = error, + ), + ) }, ) service.run() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index 293ad1d25e..d0a4e70b84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -86,13 +86,16 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.nip46Signer.Nip46ActivityEntry import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import kotlinx.coroutines.launch private val LiveGreen = Color(0xFF3DDC84) @@ -112,7 +115,9 @@ fun Nip46SignerScreen( val enabled by account.settings.nip46SignerEnabled.collectAsStateWithLifecycle() val secret by account.settings.nip46BunkerSecret.collectAsStateWithLifecycle() val relays by signer.listeningRelays.collectAsStateWithLifecycle() + val activity by signer.activityLog.entries.collectAsStateWithLifecycle() val writeable = remember { account.signer.isWriteable() } + val npub = remember { NPub.create(account.signer.pubKey) } var connectedCount by remember { mutableIntStateOf(0) } var refreshKey by remember { mutableIntStateOf(0) } @@ -175,6 +180,8 @@ fun Nip46SignerScreen( onToggleOff = { signer.setEnabled(false) }, ) + SigningAsLine(npub) + if (relays.isEmpty()) { WarningCard(stringResource(R.string.nip46_signer_status_no_relays)) } @@ -207,6 +214,10 @@ fun Nip46SignerScreen( ) } + if (enabled && activity.isNotEmpty()) { + ActivitySection(activity) + } + if (enabled) { Text( stringResource(R.string.nip46_signer_background_hint), @@ -524,6 +535,100 @@ private fun ConnectedAppsRow( } } +@Composable +private fun SigningAsLine(npub: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + MaterialSymbols.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(15.dp), + ) + Text( + stringResource(R.string.nip46_signer_signing_as, npub.take(16) + "…"), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun ActivitySection(entries: List) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.nip46_signer_activity_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(vertical = 4.dp)) { + entries.take(8).forEach { entry -> + ActivityRow(entry) + } + } + } + } +} + +@Composable +private fun ActivityRow(entry: Nip46ActivityEntry) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (entry.ok) LiveGreen else MaterialTheme.colorScheme.error), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + describeNip46Activity(entry), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + entry.clientPubKey.take(12) + "…", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TimeAgo(entry.atSeconds) + } +} + +@Composable +private fun describeNip46Activity(entry: Nip46ActivityEntry): String { + val base = + when (entry.method) { + "sign_event" -> stringResource(R.string.nip46_signer_act_signed_kind, entry.kind ?: 0) + "nip04_encrypt", "nip44_encrypt" -> stringResource(R.string.nip46_signer_act_encrypted) + "nip04_decrypt", "nip44_decrypt" -> stringResource(R.string.nip46_signer_act_decrypted) + "get_public_key" -> stringResource(R.string.nip46_signer_act_shared_pubkey) + "connect" -> stringResource(R.string.nip46_signer_act_connected) + "ping" -> stringResource(R.string.nip46_signer_act_ping) + "get_relays" -> stringResource(R.string.nip46_signer_act_listed_relays) + else -> stringResource(R.string.nip46_signer_act_other, entry.method) + } + return if (entry.ok) base else "$base · ${stringResource(R.string.nip46_signer_activity_denied)}" +} + @Composable private fun WarningCard(message: String) { Card( diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 7ef6f6d57b..e96c045cc4 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -934,6 +934,18 @@ Sign for other apps Turn on signer Live + Signing as %1$s + Recent activity + No requests serviced yet. + denied + Signed an event (kind %1$d) + Encrypted a message + Decrypted a message + Shared your public key + Connected + Ping + Listed relays + %1$s Scan to connect an app to your key Scan a code Paste a link instead diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt index e276320377..d0b19cc38b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -203,9 +203,9 @@ object BunkerCommand { transportSigner = ctx.signer, processor = processor, relays = relays, - onServiced = { method, client, error -> + onServiced = { request, client, error -> val outcome = if (error != null) "error: $error" else "ok" - System.err.println("[bunker] $method from ${client.take(8)}… → $outcome") + System.err.println("[bunker] ${request.method} from ${client.take(8)}… → $outcome") }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index 7a7c8d3c2e..81f394a413 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -63,8 +63,8 @@ class NostrConnectSignerService( val transportSigner: NostrSigner, val processor: BunkerRequestProcessor, val relays: Set, - /** Optional hook, invoked with each serviced request's method + client, for logging/metrics. */ - val onServiced: ((method: String, clientPubKey: String, error: String?) -> Unit)? = null, + /** Optional hook, invoked with each serviced request + client, for logging/metrics/activity feeds. */ + val onServiced: ((request: BunkerRequest, clientPubKey: String, error: String?) -> Unit)? = null, /** * Upper bound on the request-id dedup set. A long-lived signer would otherwise * accumulate every request id it ever saw; past this many, the oldest ids are @@ -198,7 +198,7 @@ class NostrConnectSignerService( val response = processor.process(client, request) val error = (response as? BunkerResponseError)?.error - onServiced?.invoke(request.method, client, error) + onServiced?.invoke(request, client, error) try { val reply = NostrConnectEvent.create(response, client, transportSigner) From 4998d7d460b4eeeff53d673c3de7dd0708a69b1f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 02:42:01 +0000 Subject: [PATCH 21/52] feat(nip46): tailor the connected-app detail screen for remote signers (Tier 2) The shared Connected-App detail screen mis-parsed a nip46:: coordinate and rendered it through the napplet manifest path (showing a truncated coordinate). Add a NIP-46 branch that: - heads the screen with the client's self-declared name + url (from the stored Nip46ClientInfo) and a key badge, and titles the top bar with the app name; - shows that client's recent serviced-request history (reusing the shared Nip46ActivityCard, extracted so the signer screen and this screen share it). The existing trust-level picker, per-op overrides, and NIP-46-aware Forget action render below unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../napplets/ConnectedAppDetailScreen.kt | 95 +++++++++++++- .../settings/nip46/Nip46ActivityUi.kt | 116 ++++++++++++++++++ .../settings/nip46/Nip46SignerScreen.kt | 61 +-------- 3 files changed, 210 insertions(+), 62 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ActivityUi.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index 6747651bd5..dd450b3d1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -31,6 +32,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -54,13 +56,17 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision @@ -83,6 +89,7 @@ import com.vitorpamplona.amethyst.napplet.resolveNappletMeta import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ActivityCard import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -133,8 +140,31 @@ fun ConnectedAppDetailScreen( ) } + // NIP-46 remote clients are tailored: their self-declared metadata (name/url) heads the screen and + // their serviced-request history is shown, instead of the napplet manifest path this coordinate can't + // be resolved through. `null` for napplet/browser coordinates, which keep the generic rendering. + val nip46Client = remember(coordinate) { Nip46PermissionAuthorizer.clientPubKeyOf(coordinate) } + var nip46Info by remember(coordinate) { mutableStateOf(null) } + LaunchedEffect(coordinate) { + if (nip46Client != null) { + nip46Info = withContext(Dispatchers.Default) { Amethyst.instance.nip46ClientStore.load(coordinate) } + } + } + val allActivity by accountViewModel.account.nip46Signer.activityLog.entries + .collectAsStateWithLifecycle() + val nip46Activity = remember(allActivity, nip46Client) { allActivity.filter { nip46Client != null && it.clientPubKey == nip46Client } } + val nip46Title = nip46Info?.name?.ifBlank { null } ?: stringResource(R.string.nip46_signer_remote_app) + Scaffold( - topBar = { TopBarWithBackButton(state?.title ?: coordinate.substringAfter(':', "").ifBlank { coordinate.take(12) + "…" }, nav) }, + topBar = { + val title = + if (nip46Client != null) { + nip46Title + } else { + state?.title ?: coordinate.substringAfter(':', "").ifBlank { coordinate.take(12) + "…" } + } + TopBarWithBackButton(title, nav) + }, ) { padding -> val current = state if (current == null) { @@ -154,7 +184,11 @@ fun ConnectedAppDetailScreen( verticalArrangement = Arrangement.spacedBy(16.dp), ) { // App identity header - AppIdentityHeader(current) + if (nip46Client != null) { + Nip46AppHeader(title = nip46Title, url = nip46Info?.url, clientPubKey = nip46Client) + } else { + AppIdentityHeader(current) + } // Signing trust level section if (current.signerPolicy != null) { @@ -217,6 +251,12 @@ fun ConnectedAppDetailScreen( } } + // Recent activity (NIP-46 clients only) + if (nip46Client != null && nip46Activity.isNotEmpty()) { + SectionHeader(stringResource(R.string.nip46_signer_activity_title)) + Nip46ActivityCard(nip46Activity) + } + // Forget button Spacer(Modifier.size(8.dp)) Button( @@ -245,6 +285,57 @@ fun ConnectedAppDetailScreen( } } +@Composable +private fun Nip46AppHeader( + title: String, + url: String?, + clientPubKey: String, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Box( + modifier = + Modifier + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + MaterialSymbols.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp), + ) + } + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + url?.ifBlank { null } ?: (clientPubKey.take(16) + "…"), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + stringResource(R.string.nip46_signer_remote_app), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable private fun AppIdentityHeader(state: ConnectedAppDetailState) { Surface( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ActivityUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ActivityUi.kt new file mode 100644 index 0000000000..70319cbfa0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ActivityUi.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46 + +import androidx.compose.foundation.background +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip46Signer.Nip46ActivityEntry +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo + +private val LiveGreen = Color(0xFF3DDC84) + +/** A card listing the most recent [entries] a NIP-46 signer serviced (newest first). */ +@Composable +fun Nip46ActivityCard( + entries: List, + max: Int = 8, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(vertical = 4.dp)) { + entries.take(max).forEach { Nip46ActivityRow(it) } + } + } +} + +@Composable +private fun Nip46ActivityRow(entry: Nip46ActivityEntry) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (entry.ok) LiveGreen else MaterialTheme.colorScheme.error), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + describeNip46Activity(entry), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + entry.clientPubKey.take(12) + "…", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TimeAgo(entry.atSeconds) + } +} + +/** A friendly, localized one-liner for a serviced request (e.g. "Signed an event (kind 1)"). */ +@Composable +fun describeNip46Activity(entry: Nip46ActivityEntry): String { + val base = + when (entry.method) { + "sign_event" -> stringResource(R.string.nip46_signer_act_signed_kind, entry.kind ?: 0) + "nip04_encrypt", "nip44_encrypt" -> stringResource(R.string.nip46_signer_act_encrypted) + "nip04_decrypt", "nip44_decrypt" -> stringResource(R.string.nip46_signer_act_decrypted) + "get_public_key" -> stringResource(R.string.nip46_signer_act_shared_pubkey) + "connect" -> stringResource(R.string.nip46_signer_act_connected) + "ping" -> stringResource(R.string.nip46_signer_act_ping) + "get_relays" -> stringResource(R.string.nip46_signer_act_listed_relays) + else -> stringResource(R.string.nip46_signer_act_other, entry.method) + } + return if (entry.ok) base else "$base · ${stringResource(R.string.nip46_signer_activity_denied)}" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index d0a4e70b84..b6b4f4166d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -91,7 +91,6 @@ import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton -import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner @@ -567,68 +566,10 @@ private fun ActivitySection(entries: List) { style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, ) - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - ) { - Column(modifier = Modifier.padding(vertical = 4.dp)) { - entries.take(8).forEach { entry -> - ActivityRow(entry) - } - } - } + Nip46ActivityCard(entries) } } -@Composable -private fun ActivityRow(entry: Nip46ActivityEntry) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Box( - modifier = - Modifier - .size(8.dp) - .clip(CircleShape) - .background(if (entry.ok) LiveGreen else MaterialTheme.colorScheme.error), - ) - Column(modifier = Modifier.weight(1f)) { - Text( - describeNip46Activity(entry), - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - entry.clientPubKey.take(12) + "…", - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - TimeAgo(entry.atSeconds) - } -} - -@Composable -private fun describeNip46Activity(entry: Nip46ActivityEntry): String { - val base = - when (entry.method) { - "sign_event" -> stringResource(R.string.nip46_signer_act_signed_kind, entry.kind ?: 0) - "nip04_encrypt", "nip44_encrypt" -> stringResource(R.string.nip46_signer_act_encrypted) - "nip04_decrypt", "nip44_decrypt" -> stringResource(R.string.nip46_signer_act_decrypted) - "get_public_key" -> stringResource(R.string.nip46_signer_act_shared_pubkey) - "connect" -> stringResource(R.string.nip46_signer_act_connected) - "ping" -> stringResource(R.string.nip46_signer_act_ping) - "get_relays" -> stringResource(R.string.nip46_signer_act_listed_relays) - else -> stringResource(R.string.nip46_signer_act_other, entry.method) - } - return if (entry.ok) base else "$base · ${stringResource(R.string.nip46_signer_activity_denied)}" -} - @Composable private fun WarningCard(message: String) { Card( From 684ce26f632e106fcf22c771d56b75e68c6490ba Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 02:43:32 +0000 Subject: [PATCH 22/52] feat(nip46): surface relay-connection health on the signer screen (Tier 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The live status card now reflects whether the signer's listening relays are actually connected, so silently-missed requests become visible: it reads the client's connectedRelaysFlow(), intersects with the signer's listening set, and shows "Listening on N relays, all connected" or "X of N relays connected" when some are down. Boot-restart needs no change: the existing BootCompletedReceiver already restarts the foreground service whenever isEnabled() is true, and that check honors nip46SignerEnabled — so an enabled signer resurrects after a reboot or app update. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../loggedIn/settings/nip46/Nip46SignerScreen.kt | 12 +++++++++++- amethyst/src/main/res/values/strings.xml | 5 +++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index b6b4f4166d..0db5cdb7bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -114,6 +114,8 @@ fun Nip46SignerScreen( val enabled by account.settings.nip46SignerEnabled.collectAsStateWithLifecycle() val secret by account.settings.nip46BunkerSecret.collectAsStateWithLifecycle() val relays by signer.listeningRelays.collectAsStateWithLifecycle() + val connectedRelays by account.client.connectedRelaysFlow().collectAsStateWithLifecycle() + val liveRelayCount = remember(relays, connectedRelays) { relays.count { it in connectedRelays } } val activity by signer.activityLog.entries.collectAsStateWithLifecycle() val writeable = remember { account.signer.isWriteable() } val npub = remember { NPub.create(account.signer.pubKey) } @@ -175,6 +177,7 @@ fun Nip46SignerScreen( if (enabled) { LiveStatusCard( relayCount = relays.size, + liveRelayCount = liveRelayCount, connectedCount = connectedCount, onToggleOff = { signer.setEnabled(false) }, ) @@ -319,6 +322,7 @@ private fun DisabledHero(onEnable: () -> Unit) { @Composable private fun LiveStatusCard( relayCount: Int, + liveRelayCount: Int, connectedCount: Int, onToggleOff: () -> Unit, ) { @@ -340,11 +344,17 @@ private fun LiveStatusCard( fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onPrimaryContainer, ) + val relayStatus = + if (liveRelayCount < relayCount) { + stringResource(R.string.nip46_signer_relays_some_down, liveRelayCount, relayCount) + } else { + pluralStringResource(R.plurals.nip46_signer_relays_all_live, relayCount, relayCount) + } Text( buildString { append(pluralStringResource(R.plurals.nip46_signer_connected_count, connectedCount, connectedCount)) append(" · ") - append(pluralStringResource(R.plurals.nip46_signer_status_listening, relayCount, relayCount)) + append(relayStatus) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e96c045cc4..975d965dd9 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -909,6 +909,11 @@ Listening on %1$d relays No inbox relays configured. Add inbox relays so apps can reach your signer. + + Listening on %1$d relay + Listening on %1$d relays, all connected + + %1$d of %2$d relays connected Your bunker address Paste this into another app to connect it to your key. Copy From ac6697330e48db76b0e1666e45d53afc2f5f10e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 02:46:30 +0000 Subject: [PATCH 23/52] test(nip46): consent integration test + device verification checklist (Tier 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nip46ConsentIntegrationTest: end-to-end through the real dispatch path (BunkerRequestProcessor → Nip46PermissionAuthorizer → opConsent/connectConsent) with a real NostrSignerInternal — proves an ASK sign prompts and returns a signed event on allow, "unauthorized" on deny, and that a FULL_TRUST app signs even a dangerous kind (0) without prompting. - Device checklist (amethyst/plans/) for the interactive/background/interop behavior JVM tests can't cover: pairing paths, consent variants, rotation, activity feed, relay health, boot restart, and the reference-client matrix. Notification polish was deliberately skipped: the always-on notification is shared with the relay/DM service, and consent uses its own dialog Activity, so neither retitling nor notification actions are warranted. Documented in the checklist. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- ...026-07-16-nip46-signer-device-checklist.md | 72 ++++++++++ .../nip46/Nip46ConsentIntegrationTest.kt | 123 ++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 amethyst/plans/2026-07-16-nip46-signer-device-checklist.md create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ConsentIntegrationTest.kt diff --git a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md new file mode 100644 index 0000000000..eb3d9241b6 --- /dev/null +++ b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md @@ -0,0 +1,72 @@ +# NIP-46 Signer — device verification checklist + +Everything below is behavior that JVM unit tests **cannot** exercise: interactive +consent dialogs, the foreground service, real relay traffic, deep links, and +cross-app interop. The protocol/authorization logic underneath is covered by +`quartz` (`NostrConnectSignerServiceTest`) and `commons` +(`Nip46PermissionAuthorizerTest`, `Nip46ConsentIntegrationTest`) unit tests; this +list is the manual pass that earns "first-class" on a real device. + +Run as the signer on one device/account ("bunker"); use a second app/account as +the client. + +## Pairing +- [ ] **Bunker flow**: Settings → Nostr Signer → turn on → scan/copy the + `bunker://` QR into a client (nsec.app, Coracle, Nostrudel, or a second + Amethyst via `amy login bunker://…`). Client resolves your npub via + `get_public_key`. +- [ ] **NostrConnect flow**: client shows a `nostrconnect://` code → "Scan a + code" on the signer screen pairs it and the signer turns on. +- [ ] **Global scanner**: scan a `nostrconnect://` from the profile/search + camera → lands on the signer screen and pairs. +- [ ] **Deep link**: tap a `nostrconnect://` link (web/other app) → Amethyst + opens the signer screen and pairs (cold start AND already-running). + +## Consent (Tier 1) +- [ ] **First-connect trust picker**: a bunker-flow connect with a valid secret + shows the trust-level dialog (Full trust / Reasonable / Paranoid) BEFORE any + signing; choosing a level records it in Connected Apps. +- [ ] **Cancel/Block**: dismissing the connect dialog rejects the connection (no + silent grant). +- [ ] **Per-op ASK**: with a REASONABLE app, ask the client to sign a + **kind 0 / kind 3 / delete (5)** or **decrypt a DM** → the per-op dialog + appears (these are excluded from the auto-allowed set). +- [ ] **Remember variants**: "allow for this op" stops re-prompting; "session" + stops until the signer restarts; "24h/30d" expire; "deny for op" sticks. +- [ ] **PARANOID app** prompts on every request; **FULL_TRUST** never prompts. +- [ ] **Timeout**: ignore a per-op dialog for 2 minutes → the request fails + closed (deny) and the signer keeps serving later requests (not wedged). + +## Anti-spam rotation (already shipped) +- [ ] "New address" → confirm dialog → old `bunker://` goes dark, connected apps + drop, QR updates; re-pairing a legit app keeps its trust level. + +## Visibility (Tier 2) +- [ ] Signer screen shows "Signing as npub1…", a live "Recent activity" feed + (signed kind N / encrypted / decrypted / shared pubkey, green/red dot, + relative time), and per-app history on the Connected-App detail screen. +- [ ] The Connected-App detail screen for a remote client shows its name/url, + not a raw `nip46:` coordinate. + +## Reliability (Tier 3) +- [ ] **Relay health**: kill connectivity → status shows "X of N relays + connected"; restore → "all connected". +- [ ] **Boot restart**: enable the signer, reboot the device → the foreground + service comes back and the signer answers a request without reopening the + app. (Same for an app update via `MY_PACKAGE_REPLACED`.) +- [ ] **Doze/background**: after ~30 min idle in Doze, a request still gets + serviced (may lag by a relay reconnect). + +## Interop matrix +Pair + sign + nip44 encrypt/decrypt + logout against each: +- [ ] nsec.app +- [ ] Coracle +- [ ] Nostrudel +- [ ] snort / other NIP-46 client + +## Deliberately NOT changed +The always-on foreground **notification** was left as-is: it is shared with the +relay/DM always-on service, so retitling it "Signing for N apps" or deep-linking +it to the signer screen would be wrong when the service is up for another reason. +Interactive consent uses its own dedicated dialog Activity, so it needs no +notification actions. diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ConsentIntegrationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ConsentIntegrationTest.kt new file mode 100644 index 0000000000..ef7c927dc0 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ConsentIntegrationTest.kt @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.connectedApps.nip46 + +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * End-to-end proof that interactive consent flows through the real dispatch path: + * [BunkerRequestProcessor] → [Nip46PermissionAuthorizer] → the `opConsent`/`connectConsent` + * prompts, with a real [NostrSignerInternal] doing the signing. Covers the two outcomes the + * per-op prompt must produce (a signed event vs. an `unauthorized` error) and a dangerous kind + * being allowed once the app is FULL_TRUST. + */ +class Nip46ConsentIntegrationTest { + private val signer = NostrSignerInternal(KeyPair()) + private val client = "c".repeat(64) + private val coordinate get() = Nip46PermissionAuthorizer.coordinateFor(signer.pubKey, client) + + private fun ledger() = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) + + private fun signRequest(kind: Int) = BunkerRequestSign("1", EventTemplate(createdAt = 1L, kind = kind, tags = emptyArray(), content = "hi")) + + @Test + fun askSignPromptedAllowProducesASignedEvent() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer.pubKey, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> SignerOpGrant.AllowOnce }, + ) + val processor = BunkerRequestProcessor(signer, { emptySet() }, authorizer) + + val response = processor.process(client, signRequest(1)) + + assertTrue(response is BunkerResponseEvent, "an allowed sign returns the signed event") + assertEquals(signer.pubKey, response.event.pubKey, "the event is signed by the identity key") + } + + @Test + fun askSignDeniedReturnsUnauthorized() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer.pubKey, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> SignerOpGrant.DenyOnce }, + ) + val processor = BunkerRequestProcessor(signer, { emptySet() }, authorizer) + + val response = processor.process(client, signRequest(1)) + + assertTrue(response is BunkerResponseError) + assertEquals(BunkerRequestProcessor.ERROR_UNAUTHORIZED, response.error) + } + + @Test + fun fullTrustFromConnectConsentSignsEvenDangerousKinds() = + runTest { + val ledger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) + var opPrompts = 0 + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer.pubKey, + validateSecret = { _, _ -> true }, + connectConsent = { _, _, _ -> AppConnectResult.Connected(AppSignerPolicy.FULL_TRUST) }, + opConsent = { _, _, _, _ -> + opPrompts++ + SignerOpGrant.DenyOnce + }, + ) + val processor = BunkerRequestProcessor(signer, { emptySet() }, authorizer) + + authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "x")) + // kind 0 (profile) is a dangerous kind that REASONABLE would ASK for; FULL_TRUST allows it outright. + val response = processor.process(client, signRequest(0)) + + assertTrue(response is BunkerResponseEvent, "FULL_TRUST signs without prompting") + assertEquals(0, opPrompts, "a FULL_TRUST app never reaches the per-op prompt") + } +} From 5c73a7c1f785d58aa70a5324854db95b7a9c7a36 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 03:36:11 +0000 Subject: [PATCH 24/52] refactor(consent): move the shared signer-consent UI out of napplet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-operation consent dialog and the first-connect trust picker are not napplet-specific — napplets, sandboxed browser origins, and now the NIP-46 remote signer all prompt through them. Rename and relocate them out of the napplet package into a neutral home so the shared plumbing reads honestly: napplet/NappletSignerConsent{Info,Coordinator,Activity,Dialog} → connectedApps/consent/SignerConsent{Info,Coordinator,Activity,Dialog} napplet/NappletConnect{Info,Coordinator,Activity,Screen} → connectedApps/consent/SignerConnect{Info,Coordinator,Activity,Screen} The genuinely napplet-specific pieces stay put: the capability-consent flow (NappletConsent*), and the napplet→info builders (buildSignerConsentInfo / buildConnectInfo, which resolve a napplet manifest identity) now return the relocated generic render models. Manifest activity names and all references updated; no behavior change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- amethyst/src/main/AndroidManifest.xml | 4 ++-- .../consent/SignerConnectActivity.kt} | 22 +++++++++---------- .../consent/SignerConnectCoordinator.kt} | 14 ++++++------ .../consent/SignerConsentActivity.kt} | 20 ++++++++--------- .../consent/SignerConsentCoordinator.kt} | 14 ++++++------ .../model/nip46Signer/Nip46ConsentBridge.kt | 18 +++++++-------- .../amethyst/napplet/NostrSignerOpLabels.kt | 14 +++++++----- .../gateways/AccountNappletGateways.kt | 8 +++---- 8 files changed, 58 insertions(+), 56 deletions(-) rename amethyst/src/main/java/com/vitorpamplona/amethyst/{napplet/NappletConnectActivity.kt => connectedApps/consent/SignerConnectActivity.kt} (94%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/{napplet/NappletConnectCoordinator.kt => connectedApps/consent/SignerConnectCoordinator.kt} (89%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/{napplet/NappletSignerConsentActivity.kt => connectedApps/consent/SignerConsentActivity.kt} (95%) rename amethyst/src/main/java/com/vitorpamplona/amethyst/{napplet/NappletSignerConsentCoordinator.kt => connectedApps/consent/SignerConsentCoordinator.kt} (89%) diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index e3a0429249..fa5b7740ef 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -471,14 +471,14 @@ android:theme="@android:style/Theme.Translucent.NoTitleBar" /> decided = true - NappletConnectCoordinator.complete(token, AppConnectResult.Connected(policy)) + SignerConnectCoordinator.complete(token, AppConnectResult.Connected(policy)) finish() }, onBlock = { decided = true - NappletConnectCoordinator.complete(token, AppConnectResult.Blocked) + SignerConnectCoordinator.complete(token, AppConnectResult.Blocked) finish() }, onCancel = { decided = true - NappletConnectCoordinator.complete(token, AppConnectResult.Cancelled) + SignerConnectCoordinator.complete(token, AppConnectResult.Cancelled) finish() }, ) @@ -105,14 +105,14 @@ class NappletConnectActivity : ComponentActivity() { } override fun finish() { - if (!decided) token?.let { NappletConnectCoordinator.cancel(it) } + if (!decided) token?.let { SignerConnectCoordinator.cancel(it) } super.finish() } } @Composable -private fun NappletConnectScreen( - info: NappletConnectInfo, +private fun SignerConnectScreen( + info: SignerConnectInfo, onConnect: (AppSignerPolicy) -> Unit, onBlock: () -> Unit, onCancel: () -> Unit, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt index af56fe83ab..9aee290d8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.napplet +package com.vitorpamplona.amethyst.connectedApps.consent import android.content.Context import android.content.Intent @@ -28,7 +28,7 @@ import java.util.UUID import java.util.concurrent.ConcurrentHashMap /** Everything the "Connect to Nostr" dialog needs to render. */ -data class NappletConnectInfo( +data class SignerConnectInfo( val appletTitle: String, val coordinate: String, val domain: String, @@ -40,9 +40,9 @@ data class NappletConnectInfo( * the Activity resolves the deferred with the user's choice. * A dismissed dialog resolves to [AppConnectResult.Cancelled] — fails closed, no silent grant. */ -object NappletConnectCoordinator { +object SignerConnectCoordinator { private class Pending( - val info: NappletConnectInfo, + val info: SignerConnectInfo, val deferred: CompletableDeferred, ) @@ -50,14 +50,14 @@ object NappletConnectCoordinator { suspend fun requestConnect( context: Context, - info: NappletConnectInfo, + info: SignerConnectInfo, ): AppConnectResult { val token = UUID.randomUUID().toString() val deferred = CompletableDeferred() pending[token] = Pending(info, deferred) context.startActivity( - Intent(context, NappletConnectActivity::class.java) + Intent(context, SignerConnectActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .putExtra(EXTRA_TOKEN, token), ) @@ -69,7 +69,7 @@ object NappletConnectCoordinator { } } - fun infoFor(token: String): NappletConnectInfo? = pending[token]?.info + fun infoFor(token: String): SignerConnectInfo? = pending[token]?.info fun complete( token: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt similarity index 95% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt index 35c4d688e7..57318da202 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.napplet +package com.vitorpamplona.amethyst.connectedApps.consent import android.os.Bundle import androidx.activity.ComponentActivity @@ -69,15 +69,15 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.quartz.utils.TimeUtils -class NappletSignerConsentActivity : ComponentActivity() { +class SignerConsentActivity : ComponentActivity() { private var token: String? = null private var decided = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val token = intent.getStringExtra(NappletSignerConsentCoordinator.EXTRA_TOKEN) + val token = intent.getStringExtra(SignerConsentCoordinator.EXTRA_TOKEN) this.token = token - val info = token?.let { NappletSignerConsentCoordinator.infoFor(it) } + val info = token?.let { SignerConsentCoordinator.infoFor(it) } if (token == null || info == null) { finish() return @@ -85,16 +85,16 @@ class NappletSignerConsentActivity : ComponentActivity() { setContent { AmethystTheme { - NappletSignerConsentDialog( + SignerConsentDialog( info = info, onGrant = { grant -> decided = true - NappletSignerConsentCoordinator.complete(token, grant) + SignerConsentCoordinator.complete(token, grant) finish() }, onDismiss = { decided = true - NappletSignerConsentCoordinator.cancel(token) + SignerConsentCoordinator.cancel(token) finish() }, ) @@ -103,14 +103,14 @@ class NappletSignerConsentActivity : ComponentActivity() { } override fun finish() { - if (!decided) token?.let { NappletSignerConsentCoordinator.cancel(it) } + if (!decided) token?.let { SignerConsentCoordinator.cancel(it) } super.finish() } } @Composable -private fun NappletSignerConsentDialog( - info: NappletSignerConsentInfo, +private fun SignerConsentDialog( + info: SignerConsentInfo, onGrant: (SignerOpGrant) -> Unit, onDismiss: () -> Unit, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt similarity index 89% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt index 8e884cffa5..8f863e9823 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.napplet +package com.vitorpamplona.amethyst.connectedApps.consent import android.content.Context import android.content.Intent @@ -29,7 +29,7 @@ import java.util.UUID import java.util.concurrent.ConcurrentHashMap /** Everything the per-operation consent dialog needs to render. */ -data class NappletSignerConsentInfo( +data class SignerConsentInfo( val appletTitle: String, val coordinate: String, val op: NostrSignerOp, @@ -48,9 +48,9 @@ data class NappletSignerConsentInfo( * Bridges the broker to the per-operation signer consent UI. * A dismissed dialog resolves to [SignerOpGrant.DenyOnce] — fails closed. */ -object NappletSignerConsentCoordinator { +object SignerConsentCoordinator { private class Pending( - val info: NappletSignerConsentInfo, + val info: SignerConsentInfo, val deferred: CompletableDeferred, ) @@ -58,14 +58,14 @@ object NappletSignerConsentCoordinator { suspend fun requestConsent( context: Context, - info: NappletSignerConsentInfo, + info: SignerConsentInfo, ): SignerOpGrant { val token = UUID.randomUUID().toString() val deferred = CompletableDeferred() pending[token] = Pending(info, deferred) context.startActivity( - Intent(context, NappletSignerConsentActivity::class.java) + Intent(context, SignerConsentActivity::class.java) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .putExtra(EXTRA_TOKEN, token), ) @@ -77,7 +77,7 @@ object NappletSignerConsentCoordinator { } } - fun infoFor(token: String): NappletSignerConsentInfo? = pending[token]?.info + fun infoFor(token: String): SignerConsentInfo? = pending[token]?.info fun complete( token: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt index 8dcb99dc6f..19bbce1500 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt @@ -25,10 +25,10 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant -import com.vitorpamplona.amethyst.napplet.NappletConnectCoordinator -import com.vitorpamplona.amethyst.napplet.NappletConnectInfo -import com.vitorpamplona.amethyst.napplet.NappletSignerConsentCoordinator -import com.vitorpamplona.amethyst.napplet.NappletSignerConsentInfo +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo import com.vitorpamplona.amethyst.napplet.label import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper @@ -40,7 +40,7 @@ import kotlinx.coroutines.withTimeoutOrNull /** * Bridges the (KMP, headless) [com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer] * to the interactive consent UI. It reuses the SAME dialogs the napplet/browser signer path uses — - * [NappletConnectCoordinator] (first-connect trust picker) and [NappletSignerConsentCoordinator] + * [SignerConnectCoordinator] (first-connect trust picker) and [SignerConsentCoordinator] * (per-operation allow/deny) — so a NIP-46 remote app prompts through one consistent surface, and * the user's "remember" choices land in the same [com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger]. * @@ -64,9 +64,9 @@ object Nip46ConsentBridge { val meta = request.clientMetadata val title = meta?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app) val domain = meta?.url?.ifBlank { null } ?: (clientPubKey.take(12) + "…") - return NappletConnectCoordinator.requestConnect( + return SignerConnectCoordinator.requestConnect( context, - NappletConnectInfo(appletTitle = title, coordinate = coordinate, domain = domain, iconUrl = meta?.image), + SignerConnectInfo(appletTitle = title, coordinate = coordinate, domain = domain, iconUrl = meta?.image), ) } @@ -90,7 +90,7 @@ object Nip46ConsentBridge { } val rawData = if (request is BunkerRequestSign) JacksonMapper.toJsonPretty(request.event) else "" val consentInfo = - NappletSignerConsentInfo( + SignerConsentInfo( appletTitle = title, coordinate = coordinate, op = op, @@ -101,7 +101,7 @@ object Nip46ConsentBridge { ) // Fail closed if the prompt is never answered so a stuck dialog can't hold the signer hostage. return withTimeoutOrNull(CONSENT_TIMEOUT_MS) { - NappletSignerConsentCoordinator.requestConsent(context, consentInfo) + SignerConsentCoordinator.requestConsent(context, consentInfo) } ?: SignerOpGrant.DenyOnce } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt index a59e473725..fca2cb6f21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper @@ -40,13 +42,13 @@ fun NostrSignerOp.label(context: Context): String = NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt) } -/** Builds the [NappletSignerConsentInfo] needed by the per-op consent dialog. */ +/** Builds the [SignerConsentInfo] needed by the per-op consent dialog. */ fun buildSignerConsentInfo( context: Context, identity: NappletIdentity, op: NostrSignerOp, request: NappletRequest, -): NappletSignerConsentInfo { +): SignerConsentInfo { val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) val (title, iconUrl) = if (identity.authorPubKey == "browser") { @@ -84,7 +86,7 @@ fun buildSignerConsentInfo( } else -> "" } - return NappletSignerConsentInfo( + return SignerConsentInfo( appletTitle = title, coordinate = identity.coordinate, op = op, @@ -95,11 +97,11 @@ fun buildSignerConsentInfo( ) } -/** Creates a [NappletConnectInfo] for the first-connect dialog. */ +/** Creates a [SignerConnectInfo] for the first-connect dialog. */ fun buildConnectInfo( context: Context, identity: NappletIdentity, -): NappletConnectInfo { +): SignerConnectInfo { val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) val (title, iconUrl) = if (identity.authorPubKey == "browser") { @@ -114,5 +116,5 @@ fun buildConnectInfo( } else { identity.identifier.ifBlank { identity.authorPubKey.take(12) + "…" } } - return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl) + return SignerConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index 4b660662a3..e73d3e89a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -44,12 +44,12 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.napplet.NappletConnectCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentSummary import com.vitorpamplona.amethyst.napplet.NappletNotificationStore -import com.vitorpamplona.amethyst.napplet.NappletSignerConsentCoordinator import com.vitorpamplona.amethyst.napplet.buildConnectInfo import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader @@ -139,7 +139,7 @@ class AccountNappletGateways( val connectPrompt = NostrConnectPrompt { identity -> - NappletConnectCoordinator.requestConnect( + SignerConnectCoordinator.requestConnect( context = context, info = buildConnectInfo(context, identity), ) @@ -147,7 +147,7 @@ class AccountNappletGateways( val signerConsent = NostrSignerConsentPrompt { identity, op, request -> - NappletSignerConsentCoordinator.requestConsent( + SignerConsentCoordinator.requestConsent( context = context, info = buildSignerConsentInfo(context, identity, op, request), ) From 31cd6c8bb71b4bb949611d479a9de98b75800596 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 04:11:27 +0000 Subject: [PATCH 25/52] fix(nip46): time-out the first-connect consent prompt; drop dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit fixes: - requestConnect had no timeout while requestOp did. Since authorize()/ onConnect() run inline in the signer service's single-consumer loop, an ignored first-connect dialog blocked every other client's requests forever. Both consent prompts now fail closed on the shared 120s timeout (per-op → deny-once, connect → declined) so a stuck dialog can't hold the loop hostage. - Remove the now-unused Nip46ActivityLog.forClient() (the detail screen filters the collected flow instead). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../model/nip46Signer/Nip46ActivityLog.kt | 3 --- .../model/nip46Signer/Nip46ConsentBridge.kt | 15 +++++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ActivityLog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ActivityLog.kt index 0cbc9b2a14..d047d23314 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ActivityLog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ActivityLog.kt @@ -53,7 +53,4 @@ class Nip46ActivityLog( fun record(entry: Nip46ActivityEntry) { _entries.update { (listOf(entry) + it).take(capacity) } } - - /** The most recent entries for one client (newest first). */ - fun forClient(clientPubKey: HexKey): List = _entries.value.filter { it.clientPubKey == clientPubKey } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt index 19bbce1500..53e78a2e02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt @@ -49,8 +49,9 @@ import kotlinx.coroutines.withTimeoutOrNull */ object Nip46ConsentBridge { /** - * Upper bound on how long a per-op prompt may block the signer's single-consumer loop. A user who - * ignores the dialog eventually fails the request closed (deny-once) instead of wedging the signer. + * Upper bound on how long a consent prompt may block the signer's single-consumer loop. A user who + * ignores the dialog eventually fails the request closed (deny / declined) instead of wedging the + * signer for every other client whose requests queue behind that one blocked prompt. */ private const val CONSENT_TIMEOUT_MS = 120_000L @@ -64,10 +65,12 @@ object Nip46ConsentBridge { val meta = request.clientMetadata val title = meta?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app) val domain = meta?.url?.ifBlank { null } ?: (clientPubKey.take(12) + "…") - return SignerConnectCoordinator.requestConnect( - context, - SignerConnectInfo(appletTitle = title, coordinate = coordinate, domain = domain, iconUrl = meta?.image), - ) + val info = SignerConnectInfo(appletTitle = title, coordinate = coordinate, domain = domain, iconUrl = meta?.image) + // Fail closed (declined) if the prompt is never answered, so a stuck first-connect dialog can't + // hold the single-consumer loop hostage against every other client. + return withTimeoutOrNull(CONSENT_TIMEOUT_MS) { + SignerConnectCoordinator.requestConnect(context, info) + } ?: AppConnectResult.Cancelled } /** Per-operation consent: describe the request (op + event preview) and await the user's grant. */ From 2976afc83b42231c481f58ed6d37d96912976b5e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 04:14:07 +0000 Subject: [PATCH 26/52] docs(nip46): record audit findings + known limitations in the checklist Independent review confirmed the first-connect wedge (fixed) and flagged the inline-consent head-of-line blocking and relay-restart cancellation as architectural limitations to address with a device-tested subscription/ concurrency redesign, plus three low-severity items. Documented so they are not lost. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- ...026-07-16-nip46-signer-device-checklist.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md index eb3d9241b6..e02ca5aeab 100644 --- a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md +++ b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md @@ -64,6 +64,35 @@ Pair + sign + nip44 encrypt/decrypt + logout against each: - [ ] Nostrudel - [ ] snort / other NIP-46 client +## Audit findings — known limitations (2026-07-16) + +An adversarial review of the signer logic surfaced these. The head-of-line +issues below share one root cause: `authorize()`/`onConnect()` run **inline** in +`NostrConnectSignerService`'s single-consumer loop, and relay-set changes restart +that loop via `collectLatest`. + +- **FIXED — unbounded first-connect prompt.** `Nip46ConsentBridge.requestConnect` + now has the same 120s `withTimeoutOrNull` as `requestOp`, so an ignored + first-connect dialog can no longer wedge the loop forever. +- **Consent blocks other clients (bounded).** While one prompt is open, other + clients' requests queue in the 256-deep DROP_LATEST channel and, past that, + drop. Bounded by the 120s timeouts. A proper fix is to dispatch prompt-needing + requests to child jobs (keeping dedup/rate-limit/`decide()` on the loop + thread, serializing only the dialogs) so auto-allowed traffic keeps flowing — + deferred because it risks stacked dialogs + concurrent external-signer ops and + needs on-device validation. +- **Relay-set change cancels in-flight work.** A `logout` (or a new nostrconnect + pairing) mutates the listen set → `collectLatest` restarts the service → + cancels the in-flight `handle()`. Practical impact is low (a logout ACK is lost + but the client is leaving; a pairing-time cancel makes other clients retry). + Proper fix: manage subscriptions incrementally (diff add/remove) instead of a + full restart. Deferred (same reason). +- **Low-severity, left as-is:** activity-log records an O(capacity) list copy per + serviced request (negligible under rate-limiting); the per-author rate limiter + evicts by insertion order rather than LRU (the 3-arg `accessOrder` + `LinkedHashMap` isn't in KMP commonMain); first-time transport-key/secret mint + is unsynchronized (practically serialized on the UI thread). + ## Deliberately NOT changed The always-on foreground **notification** was left as-is: it is shared with the relay/DM always-on service, so retitling it "Signing for N apps" or deep-linking From e69b59a2552100795120787abad5604ce864eba8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 15:15:28 +0000 Subject: [PATCH 27/52] test(nip46): verify a real-world Ditto kind-1 signs through the bunker Runs the exact payload (kind 1 + `client` tag + fixed created_at) through the processor/authorizer with a REASONABLE policy and asserts: it signs with no prompt (kind 1 is auto-allowed), created_at/content/tags are preserved, the event is authored by the identity key (not the transport key), and the signature + id verify. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../nip46/Nip46ConsentIntegrationTest.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ConsentIntegrationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ConsentIntegrationTest.kt index ef7c927dc0..412b069606 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ConsentIntegrationTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ConsentIntegrationTest.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermi import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect @@ -95,6 +96,43 @@ class Nip46ConsentIntegrationTest { assertEquals(BunkerRequestProcessor.ERROR_UNAUTHORIZED, response.error) } + @Test + fun signsARealWorldKind1FromAClientPreservingTheTemplate() = + runTest { + // The exact payload a client like Ditto would hand the bunker: a kind-1 note with a + // `client` tag, a fixed created_at, and content — signed remotely while the key stays on + // the identity signer (here NostrSignerInternal; in production an external Amber account). + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer.pubKey, + validateSecret = { _, _ -> true }, + // No opConsent wired: proves kind 1 is auto-allowed under REASONABLE (no prompt). + ) + val processor = BunkerRequestProcessor(signer, { emptySet() }, authorizer) + + val template = + EventTemplate( + createdAt = 1784214635L, + kind = 1, + tags = arrayOf(arrayOf("client", "Ditto", "31990:781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5:ditto")), + content = "Posting on Ditto through Amethyst's NIP-46 signer, with the keys fully offline on Amber.", + ) + + val response = processor.process(client, BunkerRequestSign("42", template)) + + assertTrue(response is BunkerResponseEvent, "kind 1 signs without a prompt under REASONABLE") + val event = response.event + assertEquals(1, event.kind) + assertEquals(1784214635L, event.createdAt, "the client-supplied created_at is preserved") + assertEquals(template.content, event.content) + assertEquals(listOf("client", "Ditto", "31990:781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5:ditto"), event.tags[0].toList()) + assertEquals(signer.pubKey, event.pubKey, "authored by the identity key, never the transport key") + assertTrue(event.verify(), "the signature and id are valid") + } + @Test fun fullTrustFromConnectConsentSignsEvenDangerousKinds() = runTest { From cdf43db0ebb154aaccc3c2383332423f20306905 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 15:28:59 +0000 Subject: [PATCH 28/52] fix(nip46): ignore stale/replayed sign requests by age MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users on multiple relays were being asked to sign the same request repeatedly, some minutes old. Root cause: kind-24133 is ephemeral, but many relays store and REPLAY it every time the signer re-subscribes (a relay-set change, reconnect, toggle, or rotation), and the in-memory dedup set is scoped to one run() call so it's wiped on restart — the old requests then get signed again. Gate requests by created_at (default 120s window): - a `since` on the subscription filter so compliant relays never replay old stored events, and - a receive-side staleness drop for relays that ignore `since`. The window must exceed realistic client/relay clock skew so a genuinely fresh request is never dropped. Also corrected the seenCap KDoc, which called the event-id dedup set a "request-id" set. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../server/NostrConnectSignerService.kt | 29 ++++++++++++++++--- .../server/NostrConnectSignerServiceTest.kt | 26 +++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index 81f394a413..66b2cea87e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -66,9 +66,12 @@ class NostrConnectSignerService( /** Optional hook, invoked with each serviced request + client, for logging/metrics/activity feeds. */ val onServiced: ((request: BunkerRequest, clientPubKey: String, error: String?) -> Unit)? = null, /** - * Upper bound on the request-id dedup set. A long-lived signer would otherwise - * accumulate every request id it ever saw; past this many, the oldest ids are - * evicted (they are far past any realistic same-request retry window). + * Upper bound on the dedup set of seen kind-24133 **event ids** (the wrapper events, so the same + * request fanned in from multiple relays is handled once). A long-lived signer would otherwise + * accumulate every event id it saw; past this many the oldest are evicted (far past any realistic + * same-event redelivery window). NOTE: this is keyed by the wrapper event id, not the inner NIP-46 + * request id, and it does not survive a service restart — the [maxRequestAgeSeconds] gate is what + * suppresses relay replays of old requests across re-subscriptions. */ val seenCap: Int = 4096, /** @@ -84,6 +87,15 @@ class NostrConnectSignerService( val rateWindowSeconds: Long = 10, /** Cap on distinct authors tracked for rate-limiting (evicts oldest) so key-rotation can't grow it. */ val maxTrackedAuthors: Int = 512, + /** + * Requests older than this (by `created_at`) are ignored. kind-24133 is ephemeral, but many relays + * store and REPLAY it whenever we re-subscribe (a relay-set change, reconnect, toggle, or rotation), + * and the in-memory dedup set does not survive that restart — so without an age gate the same + * minutes-old request gets signed again. Applied both as a `since` on the subscription (compliant + * relays never replay it) and as a receive-side guard (for relays that ignore `since`). The window + * must exceed realistic client/relay clock skew so a genuinely fresh request is never dropped. + */ + val maxRequestAgeSeconds: Long = 120, ) { /** * Fixed-window per-author rate limit. Touched only by the single consumer @@ -158,7 +170,9 @@ class NostrConnectSignerService( // Insertion-ordered dedup, confined to this one consumer coroutine (never the relay threads); // evicts the oldest id past the cap so a long-lived signer can't grow it without bound. val seen = LinkedHashSet() - val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self))) + // Only ask relays for recent requests: kind-24133 is ephemeral, but relays that store it would + // otherwise replay every old request each time we (re)subscribe. See [maxRequestAgeSeconds]. + val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self)), since = TimeUtils.now() - maxRequestAgeSeconds) client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) try { while (true) { @@ -170,6 +184,13 @@ class NostrConnectSignerService( it.remove() } } + // Drop stale requests a relay replayed from storage (the `since` filter covers compliant + // relays; this covers the rest). A live NIP-46 request is seconds old; a minutes-old one + // is a replay we may already have signed in a previous subscription. + if (TimeUtils.now() - event.createdAt > maxRequestAgeSeconds) { + Log.w("NIP46Signer") { "ignoring stale request ${event.id.take(8)}… (${TimeUtils.now() - event.createdAt}s old)" } + continue + } // Rate-limit per author BEFORE decrypting — decryption can be an external-signer // round-trip, so a flooding client must not force one per event. if (!rateLimiter.allow(event.pubKey, TimeUtils.now())) { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt index 11c0fd9f5e..6515e06621 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest @@ -226,6 +227,31 @@ class NostrConnectSignerServiceTest { assertEquals(1, reply.event.kind) } + @Test + fun staleReplayedRequestIsIgnored() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay), maxRequestAgeSeconds = 120) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + // A relay replays a request whose created_at is well past the age window (as if it had been + // stored and re-sent on resubscribe). It must not be serviced — no reply is published. + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "old") + val stale = + NostrConnectEvent.create( + BunkerRequestSign(id = "stale", event = template), + remoteKey = serverKey, + signer = clientSigner(), + createdAt = TimeUtils.now() - 600, + ) + client.deliver(stale) + + assertEquals(0, client.published.size, "a minutes-old replayed request is dropped, not re-signed") + } + @Test fun logoutRequestInvokesAuthorizerAndAcks() = runTest { From 9a0af15f364e91983323ef9263e1f66856fdbba0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 15:40:08 +0000 Subject: [PATCH 29/52] fix(nip46): tighten stale-request window to 30s An app restart re-subscribes with `since = now - window`, so relays replay (and the signer re-signs) anything created within the window. 120s was wide enough that a request made a minute before restart still came back; 30s keeps that replay window small while still tolerating normal NTP clock skew. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../nip46RemoteSigner/server/NostrConnectSignerService.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index 66b2cea87e..247d9d85d3 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -93,9 +93,10 @@ class NostrConnectSignerService( * and the in-memory dedup set does not survive that restart — so without an age gate the same * minutes-old request gets signed again. Applied both as a `since` on the subscription (compliant * relays never replay it) and as a receive-side guard (for relays that ignore `since`). The window - * must exceed realistic client/relay clock skew so a genuinely fresh request is never dropped. + * must exceed realistic client/relay clock skew so a genuinely fresh request is never dropped, but + * kept small so an app restart re-signs as little as possible (relays replay only this far back). */ - val maxRequestAgeSeconds: Long = 120, + val maxRequestAgeSeconds: Long = 30, ) { /** * Fixed-window per-author rate limit. Touched only by the single consumer From 5fa8a0a2aa8fe274c2dd65d382e68cbfd6babcc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 15:57:16 +0000 Subject: [PATCH 30/52] feat(nip46): persist serviced request ids so a restart never re-signs replays The 30s window shrank the restart re-sign problem but couldn't close it: a relay replays stored ephemeral requests on re-subscribe, and the in-memory dedup set is wiped on restart, so anything within the window came back. Persist the recently-serviced kind-24133 event ids (bounded to 128) and seed the service's dedup set from them on start, so a replay after an app restart is dropped by EXACT event id. Chosen over a created_at high-water mark on purpose: a global timestamp floor would wrongly drop a second connected app whose clock lags behind another's, whereas id-matching is immune to client clock skew. The `since` filter still bounds how far back relays replay. - AccountSettings.nip46SeenRequestIds (persisted via putStringSet) + host-side bounded LinkedHashSet, fed to NostrConnectSignerService.initialSeen and advanced through onHandledId. - Tests: a fresh request whose id was serviced last session is not repeated; the serviced id is reported for persistence. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../amethyst/LocalPreferences.kt | 4 +++ .../amethyst/model/AccountSettings.kt | 15 ++++++++ .../model/nip46Signer/Nip46SignerState.kt | 25 +++++++++++++ .../server/NostrConnectSignerService.kt | 23 +++++++++--- .../server/NostrConnectSignerServiceTest.kt | 36 +++++++++++++++++++ 5 files changed, 98 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 9de8c49293..1018dde865 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -112,6 +112,7 @@ private object PrefKeys { const val NIP46_SIGNER_ENABLED = "nip46SignerEnabled" const val NIP46_BUNKER_SECRET = "nip46BunkerSecret" const val NIP46_TRANSPORT_KEY = "nip46TransportKey" + const val NIP46_SEEN_IDS = "nip46SeenRequestIds" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" @@ -471,6 +472,7 @@ object LocalPreferences { putBoolean(PrefKeys.NIP46_SIGNER_ENABLED, settings.nip46SignerEnabled.value) putString(PrefKeys.NIP46_BUNKER_SECRET, settings.nip46BunkerSecret.value) putString(PrefKeys.NIP46_TRANSPORT_KEY, settings.nip46TransportKey.value) + putStringSet(PrefKeys.NIP46_SEEN_IDS, settings.nip46SeenRequestIds.value) putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value)) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value)) @@ -684,6 +686,7 @@ object LocalPreferences { val nip46SignerEnabled = getBoolean(PrefKeys.NIP46_SIGNER_ENABLED, false) val nip46BunkerSecret = getString(PrefKeys.NIP46_BUNKER_SECRET, "") ?: "" val nip46TransportKey = getString(PrefKeys.NIP46_TRANSPORT_KEY, "") ?: "" + val nip46SeenRequestIds = getStringSet(PrefKeys.NIP46_SEEN_IDS, null) ?: setOf() val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) @@ -866,6 +869,7 @@ object LocalPreferences { nip46SignerEnabled = MutableStateFlow(nip46SignerEnabled), nip46BunkerSecret = MutableStateFlow(nip46BunkerSecret), nip46TransportKey = MutableStateFlow(nip46TransportKey), + nip46SeenRequestIds = MutableStateFlow(nip46SeenRequestIds), defaultHomeFollowList = MutableStateFlow(followListPrefs.home), defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories), defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index a8ea44e447..69fc7d61a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -207,6 +207,13 @@ class AccountSettings( * `bunker://` address doesn't change. */ val nip46TransportKey: MutableStateFlow = MutableStateFlow(""), + /** + * The kind-24133 **event ids** this signer recently serviced. Persisted so that a relay replaying + * stored ephemeral requests across an app restart doesn't make it sign the same request twice — + * matched by exact event id, so it is immune to client clock skew (unlike a timestamp watermark, + * a global timestamp would wrongly drop a second app whose clock lags). Bounded to a recent window. + */ + val nip46SeenRequestIds: MutableStateFlow> = MutableStateFlow(emptySet()), /** * NIP-9B opt-in: when true, community feeds drop events whose latest cached * `kind:34551` rules document fails [com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator]. @@ -625,6 +632,14 @@ class AccountSettings( } } + /** Replaces the recent serviced-request id set (already bounded by the caller). */ + fun changeNip46SeenRequestIds(ids: Set) { + if (nip46SeenRequestIds.value != ids) { + nip46SeenRequestIds.tryEmit(ids) + saveAccountSettings() + } + } + fun changeLocalBlossomCacheProfilePicturesOnly(enabled: Boolean) { if (localBlossomCacheProfilePicturesOnly.value != enabled) { localBlossomCacheProfilePicturesOnly.tryEmit(enabled) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index bc33625974..14eb8f3ebd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -56,6 +56,9 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +/** How many recently-serviced request ids to persist for cross-restart replay dedup. */ +private const val MAX_SEEN_IDS = 128 + /** * Runs Amethyst as a NIP-46 remote signer ("bunker") for the account, so other * apps can sign through it. While [AccountSettings.nip46SignerEnabled] is on, a @@ -91,6 +94,24 @@ class Nip46SignerState( /** Newest-first, in-memory feed of serviced requests, so the UI can show what apps are doing. */ val activityLog = Nip46ActivityLog() + /** + * A bounded, recently-serviced set of kind-24133 event ids, persisted so a relay replaying stored + * requests after an app restart is deduped by exact id (see [NostrConnectSignerService.initialSeen]). + * Touched only from the service's single consumer coroutine, so it needs no synchronization. + */ + private val recentHandledIds = LinkedHashSet(settings.nip46SeenRequestIds.value) + + private fun rememberHandledId(eventId: HexKey) { + if (!recentHandledIds.add(eventId)) return + while (recentHandledIds.size > MAX_SEEN_IDS) { + recentHandledIds.iterator().let { + it.next() + it.remove() + } + } + settings.changeNip46SeenRequestIds(recentHandledIds.toSet()) + } + /** * The dedicated per-account transport signer that wraps the kind-24133 envelope — a local key * unrelated to the account identity, so the bunker address/traffic doesn't reveal who it is for, @@ -184,6 +205,10 @@ class Nip46SignerState( ), ) }, + // Seed dedup with the ids we serviced last session so an app restart doesn't + // re-sign a relay's replay of the same stored requests (matched by exact id). + initialSeen = settings.nip46SeenRequestIds.value, + onHandledId = { id -> rememberHandledId(id) }, ) service.run() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index 247d9d85d3..ed7b05825b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -97,6 +97,15 @@ class NostrConnectSignerService( * kept small so an app restart re-signs as little as possible (relays replay only this far back). */ val maxRequestAgeSeconds: Long = 30, + /** + * Event ids serviced in a previous run, used to seed the in-memory dedup set so a relay replaying + * stored requests across an app restart is caught by EXACT event id — immune to client clock skew, + * unlike a timestamp floor (which would wrongly drop a second app whose clock lags). The host + * persists these (bounded) and feeds them back on start; see [onHandledId]. + */ + val initialSeen: Set = emptySet(), + /** Invoked with each serviced request's kind-24133 event id so the host can persist it for [initialSeen]. */ + val onHandledId: (suspend (eventId: String) -> Unit)? = null, ) { /** * Fixed-window per-author rate limit. Touched only by the single consumer @@ -170,7 +179,9 @@ class NostrConnectSignerService( // Insertion-ordered dedup, confined to this one consumer coroutine (never the relay threads); // evicts the oldest id past the cap so a long-lived signer can't grow it without bound. - val seen = LinkedHashSet() + // Seed the dedup set with ids serviced in a prior run so a relay replaying stored requests after + // a restart is caught by exact id (see [initialSeen]). + val seen = LinkedHashSet(initialSeen) // Only ask relays for recent requests: kind-24133 is ephemeral, but relays that store it would // otherwise replay every old request each time we (re)subscribe. See [maxRequestAgeSeconds]. val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self)), since = TimeUtils.now() - maxRequestAgeSeconds) @@ -185,11 +196,11 @@ class NostrConnectSignerService( it.remove() } } - // Drop stale requests a relay replayed from storage (the `since` filter covers compliant - // relays; this covers the rest). A live NIP-46 request is seconds old; a minutes-old one - // is a replay we may already have signed in a previous subscription. + // Drop stale requests a relay replayed from storage past the rolling age window (the + // `since` filter covers compliant relays; this covers the rest; exact-id replays within + // the window are already caught by [seen] above). A live NIP-46 request is seconds old. if (TimeUtils.now() - event.createdAt > maxRequestAgeSeconds) { - Log.w("NIP46Signer") { "ignoring stale request ${event.id.take(8)}… (${TimeUtils.now() - event.createdAt}s old)" } + Log.w("NIP46Signer") { "ignoring stale request ${event.id.take(8)}… (created ${event.createdAt})" } continue } // Rate-limit per author BEFORE decrypting — decryption can be an external-signer @@ -199,6 +210,8 @@ class NostrConnectSignerService( continue } handle(event) + // Remember this id (persisted by the host) so a later restart won't re-service the replay. + onHandledId?.invoke(event.id) } } finally { client.unsubscribe(subId) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt index 6515e06621..98a7dcff66 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt @@ -252,6 +252,42 @@ class NostrConnectSignerServiceTest { assertEquals(0, client.published.size, "a minutes-old replayed request is dropped, not re-signed") } + @Test + fun aFreshRequestWhoseIdWasServicedLastSessionIsNotRepeated() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + + // A still-fresh request whose id the previous run already serviced (seeded via initialSeen, + // as the host would restore from disk). It must be deduped by exact id — even though its + // created_at is within the window — so an app restart doesn't re-sign a relay's replay. + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "again") + val replayed = request(BunkerRequestSign(id = "req", event = template)) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay), initialSeen = setOf(replayed.id)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + client.deliver(replayed) + + assertEquals(0, client.published.size, "an id serviced last session is not signed again after restart") + } + + @Test + fun aHandledIdIsReportedForPersistence() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val handled = mutableListOf() + val service = NostrConnectSignerService(client, signer, processor, setOf(relay), onHandledId = { handled.add(it) }) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + val event = request(BunkerRequestSign(id = "req", event = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "x"))) + client.deliver(event) + + assertEquals(listOf(event.id), handled, "the serviced event id is reported so the host can persist it") + } + @Test fun logoutRequestInvokesAuthorizerAndAcks() = runTest { From 00ec826e8b92995a78a6dac07564a26852db83d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 16:18:52 +0000 Subject: [PATCH 31/52] feat(nip46): refuse requests when the account can no longer sign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject sign/encrypt/decrypt when the identity signer is not writeable — the account was logged out, is read-only, or lost its external (NIP-55) signer — returning an `account unavailable` error instead of prompting the user or hanging on a key that can't be used. Checked before authorization, so no dialog is raised for a key we can't sign with. Public reads (get_public_key, ping, get_relays) stay ungated. Test: a non-writeable signer refuses a sign request without invoking the signer or the authorizer. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../server/BunkerRequestProcessor.kt | 9 ++++++++- .../server/BunkerRequestProcessorTest.kt | 19 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt index f86e3c984c..951a6ecc69 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt @@ -142,7 +142,11 @@ class BunkerRequestProcessor( request: BunkerRequest, block: () -> BunkerResponse, ): BunkerResponse = - if (authorizer.authorize(clientPubKey, request)) { + if (!signer.isWriteable()) { + // The account this bunker signs for is no longer usable — logged out, read-only, or its + // external signer is gone — so refuse rather than prompt or hang on a key we can't use. + BunkerResponseError(request.id, ERROR_ACCOUNT_UNAVAILABLE) + } else if (authorizer.authorize(clientPubKey, request)) { block() } else { BunkerResponseError(request.id, ERROR_UNAUTHORIZED) @@ -155,6 +159,9 @@ class BunkerRequestProcessor( /** Error result returned when [Nip46RequestAuthorizer.authorize] denies a request. */ const val ERROR_UNAUTHORIZED: String = "unauthorized" + /** Error result returned when the account can no longer sign (logged out / read-only / no signer). */ + const val ERROR_ACCOUNT_UNAVAILABLE: String = "account unavailable" + /** NIP-46 `logout` method name — the client asks to be disconnected. */ const val METHOD_LOGOUT: String = "logout" } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt index ceffdd2f65..fcfb8a711b 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt @@ -59,6 +59,7 @@ class BunkerRequestProcessorTest { /** Records what the signer was asked to do and returns fixed values. */ private class FakeSigner( pubKey: HexKey, + val writeable: Boolean = true, ) : NostrSigner(pubKey) { var signCount = 0 var nip44EncryptCount = 0 @@ -75,7 +76,7 @@ class BunkerRequestProcessorTest { sig = "f".repeat(128), ) - override fun isWriteable() = true + override fun isWriteable() = writeable @Suppress("UNCHECKED_CAST") override suspend fun sign( @@ -230,6 +231,22 @@ class BunkerRequestProcessorTest { assertEquals(0, signer.signCount) } + @Test + fun signRefusedWhenAccountNoLongerWriteable() = + runTest { + // The account was logged out / went read-only / lost its external signer: refuse without + // prompting or invoking the signer, even for an otherwise-authorized request. + val signer = FakeSigner(userPubKey, writeable = false) + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = true) + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hi") + val res = processor(signer = signer, authorizer = authorizer).process(clientPubKey, BunkerRequestSign("w", template)) + + assertTrue(res is BunkerResponseError) + assertEquals(BunkerRequestProcessor.ERROR_ACCOUNT_UNAVAILABLE, res.error) + assertEquals(0, signer.signCount, "the unusable signer is never invoked") + assertEquals(0, authorizer.authorizeCalls, "and we don't prompt for a key we can't use") + } + @Test fun nip44EncryptAuthorized() = runTest { From 4fa41f1a67d2dc032976e9f3907782408450474d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 16:19:07 +0000 Subject: [PATCH 32/52] feat(nip46): show which account is acting on the consent dialogs Both signer dialogs now render the account's avatar + display name instead of a raw pubkey / coordinate hex, so it's clear which logged-in identity is approving, signing, encrypting, or decrypting: - Connect dialog: replaces the client transport-pubkey line with the account being connected to (avatar + name). - Per-op dialog: replaces the meaningless coordinate hex with the account that would sign/encrypt/decrypt. The account is resolved from the coordinate's signer pubkey (Nip46PermissionAuthorizer.signerPubKeyOf) via LocalCache, and rendered with a shared ConnectedAccountRow (RobohashFallbackAsyncImage + name, robohash fallback). SignerConnectInfo/SignerConsentInfo carry the account name/picture/pubkey; the napplet/browser paths leave them null and keep their existing domain line. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../consent/ConnectedAccountRow.kt | 68 +++++++++++++++++++ .../consent/SignerConnectActivity.kt | 17 +++-- .../consent/SignerConnectCoordinator.kt | 8 +++ .../consent/SignerConsentActivity.kt | 10 ++- .../consent/SignerConsentCoordinator.kt | 8 +++ .../model/nip46Signer/Nip46ConsentBridge.kt | 32 ++++++++- .../nip46/Nip46PermissionAuthorizer.kt | 8 +++ 7 files changed, 138 insertions(+), 13 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/ConnectedAccountRow.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/ConnectedAccountRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/ConnectedAccountRow.kt new file mode 100644 index 0000000000..c4e20fe1cb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/ConnectedAccountRow.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.connectedApps.consent + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage + +/** + * The account a signer request acts as — avatar + display name — so it's clear WHICH logged-in + * identity is approving/signing/encrypting/decrypting. Shown in both consent dialogs in place of a + * raw pubkey. Falls back to a robohash avatar seeded on [pubKey] when there's no [picture]. + */ +@Composable +fun ConnectedAccountRow( + name: String, + picture: String?, + pubKey: String?, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + RobohashFallbackAsyncImage( + robot = pubKey ?: name, + model = picture, + contentDescription = null, + modifier = Modifier.size(26.dp).clip(CircleShape), + loadProfilePicture = true, + loadRobohash = true, + ) + Text( + name, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt index 528eb5fe11..bd3e035688 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt @@ -168,12 +168,17 @@ private fun SignerConnectScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) - Text( - info.domain, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) + // Show WHICH account is being connected (avatar + name), not a raw pubkey. + if (info.accountName != null) { + ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey) + } else { + Text( + info.domain, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } } Spacer(Modifier.height(16.dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt index 9aee290d8f..0e54a3324f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt @@ -33,6 +33,14 @@ data class SignerConnectInfo( val coordinate: String, val domain: String, val iconUrl: String? = null, + /** + * The account the app is connecting to, shown as an avatar + name instead of a raw pubkey. When + * [accountName] is null (e.g. napplet/browser paths that don't resolve it) the dialog falls back + * to [domain]. [accountPubKey] seeds the robohash avatar fallback when there's no picture. + */ + val accountName: String? = null, + val accountPicture: String? = null, + val accountPubKey: String? = null, ) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt index 57318da202..965a90f1cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt @@ -167,12 +167,10 @@ private fun SignerConsentDialog( color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) - Text( - info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "…" }, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) + // Show WHICH account would sign/encrypt/decrypt (avatar + name), not the coordinate hex. + if (info.accountName != null) { + ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey) + } } val hasContent = info.contentPreview.isNotBlank() || info.rawData.isNotBlank() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt index 8f863e9823..eb567170d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt @@ -42,6 +42,14 @@ data class SignerConsentInfo( */ val rawData: String = "", val iconUrl: String? = null, + /** + * The account that would sign/encrypt/decrypt, shown as an avatar + name so it's clear which + * logged-in identity is acting. Null on paths that don't resolve it; [accountPubKey] seeds the + * robohash avatar fallback when there's no picture. + */ + val accountName: String? = null, + val accountPicture: String? = null, + val accountPubKey: String? = null, ) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt index 53e78a2e02..455bfda6e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.nip46Signer import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant @@ -29,6 +30,7 @@ import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.napplet.label import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper @@ -65,7 +67,18 @@ object Nip46ConsentBridge { val meta = request.clientMetadata val title = meta?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app) val domain = meta?.url?.ifBlank { null } ?: (clientPubKey.take(12) + "…") - val info = SignerConnectInfo(appletTitle = title, coordinate = coordinate, domain = domain, iconUrl = meta?.image) + // The identity being connected to lives in the coordinate; show it as an avatar + name. + val face = accountFace(coordinate) + val info = + SignerConnectInfo( + appletTitle = title, + coordinate = coordinate, + domain = domain, + iconUrl = meta?.image, + accountName = face.name, + accountPicture = face.picture, + accountPubKey = face.pubKey, + ) // Fail closed (declined) if the prompt is never answered, so a stuck first-connect dialog can't // hold the single-consumer loop hostage against every other client. return withTimeoutOrNull(CONSENT_TIMEOUT_MS) { @@ -92,6 +105,7 @@ object Nip46ConsentBridge { "" } val rawData = if (request is BunkerRequestSign) JacksonMapper.toJsonPretty(request.event) else "" + val face = accountFace(coordinate) val consentInfo = SignerConsentInfo( appletTitle = title, @@ -101,10 +115,26 @@ object Nip46ConsentBridge { contentPreview = preview, rawData = rawData, iconUrl = info?.image, + accountName = face.name, + accountPicture = face.picture, + accountPubKey = face.pubKey, ) // Fail closed if the prompt is never answered so a stuck dialog can't hold the signer hostage. return withTimeoutOrNull(CONSENT_TIMEOUT_MS) { SignerConsentCoordinator.requestConsent(context, consentInfo) } ?: SignerOpGrant.DenyOnce } + + /** The account being signed for (avatar + name), resolved from the coordinate's signer pubkey. */ + private fun accountFace(coordinate: String): AccountFace { + val pubKey = Nip46PermissionAuthorizer.signerPubKeyOf(coordinate) + val user = pubKey?.let { LocalCache.getUserIfExists(it) } + return AccountFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey) + } + + private data class AccountFace( + val name: String?, + val picture: String?, + val pubKey: String?, + ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt index e2db0fae03..e91e1e6d03 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt @@ -254,6 +254,14 @@ class Nip46PermissionAuthorizer( /** The client pubkey of a `nip46::` coordinate, or `null` if it is not one. */ fun clientPubKeyOf(coordinate: String): HexKey? = if (coordinate.startsWith("$COORDINATE_PREFIX:")) coordinate.substringAfterLast(':') else null + /** The signer (account identity) pubkey of a `nip46::` coordinate, or `null`. */ + fun signerPubKeyOf(coordinate: String): HexKey? = + if (coordinate.startsWith("$COORDINATE_PREFIX:")) { + coordinate.substringAfter("$COORDINATE_PREFIX:").substringBefore(':').ifBlank { null } + } else { + null + } + /** Maps a signing/encryption/decryption [BunkerRequest] to the [NostrSignerOp] it needs. */ fun BunkerRequest.toSignerOp(): NostrSignerOp? = when (this) { From 99f0c61f72e2d59fb1fb239cd431fad0bef70dce Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 16:41:41 +0000 Subject: [PATCH 33/52] feat(nip46): preview the event as a signed note in the sign dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-op consent dialog now renders a sign_event/publish request as a real NoteCompose preview — what the note will actually look like once signed (text + media + mentions, authored by the signing account) — instead of only a quoted content snippet. The "Show event" JSON toggle stays as a fallback for anyone who wants the raw payload. Reuses the live AccountViewModel via CallSessionBridge (the same handle CallActivity uses to render app UI from a standalone Activity), builds a transient unsigned Note with RumorAssembler.assembleRumor + createTempDraftNote (never persisted/verified — the same path the composer uses to preview an unsent post), and passes EmptyNav so taps don't navigate out. SignerConsentInfo carries the EventTemplate; both the NIP-46 bridge and the napplet buildSignerConsentInfo populate it for Publish/SignEvent. Falls back to the content quote + JSON when the AccountViewModel isn't available (main Activity gone) or the op has no event (encrypt/decrypt). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- ...026-07-16-nip46-signer-device-checklist.md | 13 +++++++ .../consent/SignerConsentActivity.kt | 38 ++++++++++++++++++- .../consent/SignerConsentCoordinator.kt | 8 ++++ .../model/nip46Signer/Nip46ConsentBridge.kt | 1 + .../amethyst/napplet/NostrSignerOpLabels.kt | 8 ++++ 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md index e02ca5aeab..62c14ab439 100644 --- a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md +++ b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md @@ -22,6 +22,19 @@ the client. - [ ] **Deep link**: tap a `nostrconnect://` link (web/other app) → Amethyst opens the signer screen and pairs (cold start AND already-running). +## Note preview in the sign dialog (device-only) +- [ ] A `sign_event`/publish request renders the unsigned event as a **NoteCompose + preview** (text + media + mentions, authored by the signing account), with + the "Show event" JSON toggle still available below it. +- [ ] Works for both a NIP-46 remote app and a napplet Publish/SignEvent. +- [ ] When the main Activity is gone (app fully backgrounded, only the signer + foreground service alive → `CallSessionBridge.accountViewModel` is null), + the dialog falls back to the plain content quote + JSON without crashing. +- [ ] **Risk to watch:** NoteCompose is feed UI rendered inside a standalone + dialog Activity; if it reads a CompositionLocal only provided by the main + scaffold it could crash at runtime (compiles fine). Verify on device; if it + misbehaves, the JSON fallback path is one boolean away. + ## Consent (Tier 1) - [ ] **First-connect trust picker**: a bunker-flow connect with a valid secret shows the trust-level dialog (Full trust / Reasonable / Paranoid) BEFORE any diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt index 965a90f1cd..549bf0ec03 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt @@ -66,7 +66,13 @@ import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.call.CallSessionBridge +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.theme.AmethystTheme +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.utils.TimeUtils class SignerConsentActivity : ComponentActivity() { @@ -119,6 +125,25 @@ private fun SignerConsentDialog( val scrollState = rememberScrollState() val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f + // Reuse the live AccountViewModel (via CallSessionBridge, the same handle CallActivity uses) to + // render the unsigned event as a real note preview — what it will actually look like. Best-effort: + // if the main Activity is gone (only the foreground signer service alive) we fall back to the JSON. + val accountViewModel = remember { CallSessionBridge.accountViewModel } + val previewNav = remember { EmptyNav() } + val previewNote = + remember(info, accountViewModel) { + val template = info.previewTemplate + val author = info.accountPubKey ?: accountViewModel?.account?.signer?.pubKey + if (template != null && author != null && accountViewModel != null) { + runCatching { + val unsigned = RumorAssembler.assembleRumor(author, template) + accountViewModel.createTempDraftNote(unsigned, LocalCache.getOrCreateUser(author)) + }.getOrNull() + } else { + null + } + } + Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false), @@ -173,7 +198,7 @@ private fun SignerConsentDialog( } } - val hasContent = info.contentPreview.isNotBlank() || info.rawData.isNotBlank() + val hasContent = previewNote != null || info.contentPreview.isNotBlank() || info.rawData.isNotBlank() if (hasContent) { Spacer(Modifier.height(12.dp)) Surface( @@ -185,7 +210,16 @@ private fun SignerConsentDialog( shape = MaterialTheme.shapes.medium, ) { Column(modifier = Modifier.padding(12.dp)) { - if (info.contentPreview.isNotBlank()) { + if (previewNote != null && accountViewModel != null) { + // The event rendered as it will look once signed. + NoteCompose( + baseNote = previewNote, + isQuotedNote = true, + quotesLeft = 0, + accountViewModel = accountViewModel, + nav = previewNav, + ) + } else if (info.contentPreview.isNotBlank()) { Text( "“${info.contentPreview}”", style = MaterialTheme.typography.bodySmall, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt index eb567170d8..364754f997 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt @@ -24,6 +24,8 @@ import android.content.Context import android.content.Intent import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import kotlinx.coroutines.CompletableDeferred import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -50,6 +52,12 @@ data class SignerConsentInfo( val accountName: String? = null, val accountPicture: String? = null, val accountPubKey: String? = null, + /** + * The unsigned event a `sign_event`/publish request would sign, so the dialog can render it as a + * note preview (what it will look like) in addition to the raw JSON. Null for encrypt/decrypt and + * non-event ops. + */ + val previewTemplate: EventTemplate? = null, ) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt index 455bfda6e7..cb6344be8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt @@ -118,6 +118,7 @@ object Nip46ConsentBridge { accountName = face.name, accountPicture = face.picture, accountPubKey = face.pubKey, + previewTemplate = (request as? BunkerRequestSign)?.event, ) // Fail closed if the prompt is never answered so a stuck dialog can't hold the signer hostage. return withTimeoutOrNull(CONSENT_TIMEOUT_MS) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt index fca2cb6f21..7d6932b6f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @@ -86,6 +87,12 @@ fun buildSignerConsentInfo( } else -> "" } + val previewTemplate = + when (request) { + is NappletRequest.Publish -> EventTemplate(TimeUtils.now(), request.kind, request.tags, request.content) + is NappletRequest.SignEvent -> EventTemplate(request.createdAt, request.kind, request.tags, request.content) + else -> null + } return SignerConsentInfo( appletTitle = title, coordinate = identity.coordinate, @@ -94,6 +101,7 @@ fun buildSignerConsentInfo( contentPreview = preview, rawData = rawData, iconUrl = iconUrl, + previewTemplate = previewTemplate, ) } From 6151a2df2507a5d5fd0c1440b63b9cea186d9985 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 18:18:11 +0000 Subject: [PATCH 34/52] fix(nip46): move allPolicies disk enumeration off the main thread allPolicies() enumerates the datastore directory and reads each file (blocking disk IO) but is invoked from Compose LaunchedEffects on the main dispatcher, tripping StrictMode's DiskReadViolation. Wrap the File listing + reads in withContext(Dispatchers.IO). Fixes both the NIP-46 signer screen and the Connected Apps screen callers. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../DataStoreNostrSignerPermissionStore.kt | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/DataStoreNostrSignerPermissionStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/DataStoreNostrSignerPermissionStore.kt index a0f232e8bd..ec38b703b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/DataStoreNostrSignerPermissionStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/DataStoreNostrSignerPermissionStore.kt @@ -31,7 +31,9 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore import com.vitorpamplona.quartz.utils.cache.LargeCache +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext import java.io.File import java.security.MessageDigest @@ -102,21 +104,24 @@ class DataStoreNostrSignerPermissionStore( storeFor(coordinate).edit { it.remove(opKey(op)) } } - override suspend fun allPolicies(): Map { - val dir = File(filesDir, "datastore") - if (!dir.exists()) return emptyMap() - val result = mutableMapOf() - for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) { - val ds = - cache.getOrCreate(file.absolutePath) { - PreferenceDataStoreFactory.create(produceFile = { file }) - } - val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue - val policy = loadPolicy(coordinate) ?: continue - result[coordinate] = policy + override suspend fun allPolicies(): Map = + // Enumerates the datastore directory + reads each file — blocking disk IO, so keep it off the + // caller's thread (callers invoke this from Compose LaunchedEffects on the main dispatcher). + withContext(Dispatchers.IO) { + val dir = File(filesDir, "datastore") + if (!dir.exists()) return@withContext emptyMap() + val result = mutableMapOf() + for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) { + val ds = + cache.getOrCreate(file.absolutePath) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue + val policy = loadPolicy(coordinate) ?: continue + result[coordinate] = policy + } + result } - return result - } override suspend fun allOpDecisions(coordinate: String): Map { val prefs = storeFor(coordinate).data.first() From 2a1634bb0703ecca993037e8f65cc3e12a0e5b79 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 18:18:14 +0000 Subject: [PATCH 35/52] =?UTF-8?q?feat(nip46):=20declutter=20the=20signer?= =?UTF-8?q?=20screen=20=E2=80=94=20bigger=20QR,=20drop=20noise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enlarge the bunker QR to fill the card width (responsive, easier to scan) instead of a fixed 232dp. - Drop the raw bunker:// URI text under the QR — the QR + Copy button convey the address; the long hex string was just noise. - Remove the "Signing as npub1…" line — on your own signer settings screen the account is already implied (that clarity belongs on the consent dialogs, where it now lives). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../settings/nip46/Nip46SignerScreen.kt | 44 ++----------------- amethyst/src/main/res/values/strings.xml | 1 - 2 files changed, 4 insertions(+), 41 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index 0db5cdb7bf..9716727705 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -36,6 +36,7 @@ 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.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -76,10 +77,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R @@ -94,7 +93,6 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner -import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import kotlinx.coroutines.launch private val LiveGreen = Color(0xFF3DDC84) @@ -118,7 +116,6 @@ fun Nip46SignerScreen( val liveRelayCount = remember(relays, connectedRelays) { relays.count { it in connectedRelays } } val activity by signer.activityLog.entries.collectAsStateWithLifecycle() val writeable = remember { account.signer.isWriteable() } - val npub = remember { NPub.create(account.signer.pubKey) } var connectedCount by remember { mutableIntStateOf(0) } var refreshKey by remember { mutableIntStateOf(0) } @@ -182,8 +179,6 @@ fun Nip46SignerScreen( onToggleOff = { signer.setEnabled(false) }, ) - SigningAsLine(npub) - if (relays.isEmpty()) { WarningCard(stringResource(R.string.nip46_signer_status_no_relays)) } @@ -397,6 +392,7 @@ private fun QrHeroCard( verticalArrangement = Arrangement.spacedBy(14.dp), ) { Surface( + modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), color = Color.White, ) { @@ -405,7 +401,8 @@ private fun QrHeroCard( modifier = Modifier .padding(16.dp) - .size(232.dp), + .fillMaxWidth() + .aspectRatio(1f), ) } Text( @@ -414,15 +411,6 @@ private fun QrHeroCard( fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, ) - Text( - uri, - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -544,30 +532,6 @@ private fun ConnectedAppsRow( } } -@Composable -private fun SigningAsLine(npub: String) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - MaterialSymbols.Key, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(15.dp), - ) - Text( - stringResource(R.string.nip46_signer_signing_as, npub.take(16) + "…"), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - @Composable private fun ActivitySection(entries: List) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 975d965dd9..b5cf4b115f 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -939,7 +939,6 @@ Sign for other apps Turn on signer Live - Signing as %1$s Recent activity No requests serviced yet. denied From 565342d7bfd90138a4bb93a775c6e49d1927139f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 19:14:51 +0000 Subject: [PATCH 36/52] feat(nip46): drawer entry, dedicated apps screen, foreground surfacing, idle prune MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the Nostr Signer out of Settings into the left drawer's "You" section, directly under Wallet (and available as a bottom-bar favorite). Removed the Settings catalog entry. Give NIP-46 remote-signer clients their own management screen, separate from the napplet/nsite/browser Connected Apps screen — unlike those, each NIP-46 app can carry its own relays that the signer keeps subscribed in the background, so they need distinct visibility (name, npub, relay count, last-used, trust level) and pruning. The shared Connected Apps screen no longer lists NIP-46 apps. Auto-forget apps idle for 7+ days on signer start (Nip46PermissionAuthorizer. pruneIdle), so an app paired once and abandoned stops leaking a background relay subscription forever. last-used is stamped on connect and every serviced op, so an app still in use is never pruned. Surface the consent dialog when Amethyst is backgrounded: a bare startActivity from the app context is silently dropped by Android 12+ background-activity-launch restrictions, so the dialog never appeared and the request timed out. Add a full-screen-intent notification fallback (the same mechanism CallNotifier uses for incoming calls) on a high-importance channel; it no-ops when the app is already in the foreground so there's no redundant heads-up. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- ...026-07-16-nip46-signer-device-checklist.md | 22 ++ .../consent/SignerConnectCoordinator.kt | 26 +- .../consent/SignerConsentCoordinator.kt | 27 +- .../consent/SignerConsentNotifier.kt | 141 ++++++++++ .../model/nip46Signer/Nip46SignerState.kt | 17 +- .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../ui/navigation/bottombars/NavBarItem.kt | 10 + .../amethyst/ui/navigation/routes/Routes.kt | 2 + .../loggedIn/BottomBarFeedPreloaders.kt | 1 + .../loggedIn/napplets/ConnectedAppsScreen.kt | 85 +----- .../settings/SettingsCatalogBuilder.kt | 1 - .../nip46/Nip46ConnectedAppsScreen.kt | 261 ++++++++++++++++++ .../settings/nip46/Nip46SignerScreen.kt | 2 +- amethyst/src/main/res/values/strings.xml | 11 + .../nip46/Nip46PermissionAuthorizer.kt | 23 ++ .../nip46/Nip46PermissionAuthorizerTest.kt | 54 ++++ 16 files changed, 593 insertions(+), 92 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentNotifier.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt diff --git a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md index 62c14ab439..885f69b607 100644 --- a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md +++ b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md @@ -35,6 +35,28 @@ the client. scaffold it could crash at runtime (compiles fine). Verify on device; if it misbehaves, the JSON fallback path is one boolean away. +## Entry point + connected-apps management (2026-07-16) +- [ ] **Drawer entry**: the signer opens from the left drawer's "You" section, directly + under Wallet (moved out of Settings). It's also available as a bottom-bar favorite. +- [ ] **Dedicated apps screen**: "Manage connected apps" on the signer screen opens a + NIP-46-only list (name, npub, relay count, last-used, trust chip), separate from the + napplet/nsite/browser Connected Apps screen. NIP-46 apps no longer appear there. +- [ ] **Idle auto-forget**: an app left unused for 7 days is dropped on the next signer + start (its background relay subscription goes with it); an app still signing is kept. + +## Comes-to-front on a request (2026-07-16) +- [ ] **Backgrounded surfacing**: with Amethyst fully backgrounded (Android 12+), a client + signing/connect request pops the consent dialog — via a full-screen-intent notification + on the high-importance "Signing requests" channel (the `startActivity` fast path is + BAL-blocked when backgrounded). On a locked screen it launches straight to the dialog; + while actively on another app it shows a heads-up prompt to tap. +- [ ] **Foreground**: with Amethyst in the foreground the dialog opens directly (no extra + notification — `SignerConsentNotifier` no-ops when `foregroundTracker.isForeground`). +- [ ] **Android 14+ caveat**: `USE_FULL_SCREEN_INTENT` is restricted for non-calling apps, + so the FSI may degrade to a heads-up rather than auto-launch — verify the prompt still + arrives and is tappable. Requires notification permission (already needed for the + always-on service). + ## Consent (Tier 1) - [ ] **First-connect trust picker**: a bunker-flow connect with a valid secret shows the trust-level dialog (Full trust / Reasonable / Paranoid) BEFORE any diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt index 0e54a3324f..38ea6d4aec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.connectedApps.consent import android.content.Context import android.content.Intent +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult import kotlinx.coroutines.CompletableDeferred import java.util.UUID @@ -64,16 +65,31 @@ object SignerConnectCoordinator { val deferred = CompletableDeferred() pending[token] = Pending(info, deferred) - context.startActivity( - Intent(context, SignerConnectActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .putExtra(EXTRA_TOKEN, token), - ) + // Fast path when Amethyst already owns the foreground; the full-screen-intent notification + // below is what surfaces the dialog when a connect request arrives while backgrounded (see + // SignerConsentNotifier). Wrapped because a BAL-blocked launch can throw on some OEMs. + runCatching { + context.startActivity( + Intent(context, SignerConnectActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_TOKEN, token), + ) + } + + val notificationId = + SignerConsentNotifier.show( + context = context, + activityClass = SignerConnectActivity::class.java, + extraKey = EXTRA_TOKEN, + token = token, + titleRes = R.string.nip46_signer_notif_connect_title, + ) return try { deferred.await() } finally { pending.remove(token) + SignerConsentNotifier.cancel(context, notificationId) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt index 364754f997..71fc5549d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.connectedApps.consent import android.content.Context import android.content.Intent +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant import com.vitorpamplona.quartz.nip01Core.core.Event @@ -80,16 +81,32 @@ object SignerConsentCoordinator { val deferred = CompletableDeferred() pending[token] = Pending(info, deferred) - context.startActivity( - Intent(context, SignerConsentActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .putExtra(EXTRA_TOKEN, token), - ) + // Fast path when Amethyst already owns the foreground: open the dialog directly. When the app + // is backgrounded this is silently dropped by Android 12+ BAL, so the full-screen-intent + // notification below is what actually surfaces the prompt. Wrapped because a blocked launch + // can throw on some OEMs rather than no-op. + runCatching { + context.startActivity( + Intent(context, SignerConsentActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_TOKEN, token), + ) + } + + val notificationId = + SignerConsentNotifier.show( + context = context, + activityClass = SignerConsentActivity::class.java, + extraKey = EXTRA_TOKEN, + token = token, + titleRes = R.string.nip46_signer_notif_sign_title, + ) return try { deferred.await() } finally { pending.remove(token) + SignerConsentNotifier.cancel(context, notificationId) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentNotifier.kt new file mode 100644 index 0000000000..b76ca93b78 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentNotifier.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.connectedApps.consent + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * Surfaces a signer consent/connect [android.app.Activity] from the **background**. + * + * A bare `context.startActivity(...)` from the application context only opens a window while + * Amethyst already owns the foreground. When a signing request arrives over a relay while the app + * is backgrounded, Android 12+ background-activity-launch (BAL) restrictions silently drop that + * `startActivity`, so the dialog would never appear and the request would sit until it times out. + * + * A full-screen-intent notification on an `IMPORTANCE_HIGH` channel (with the + * `USE_FULL_SCREEN_INTENT` permission the manifest declares) is the documented BAL exception — the + * same mechanism [com.vitorpamplona.amethyst.service.call.notification.CallNotifier] uses for + * incoming calls. On a locked/idle screen it launches the Activity immediately; while the user is + * actively on another app it shows as a heads-up banner they tap to review. + * + * Each coordinator posts one notification keyed by the request token's hash so concurrent requests + * don't clobber each other, and cancels it once the deferred resolves (approved, denied, or timed + * out) so no stale prompt lingers. + */ +object SignerConsentNotifier { + private const val CHANNEL_ID = "com.vitorpamplona.amethyst.SIGNER_CONSENT_CHANNEL" + + private fun ensureChannel(context: Context): NotificationChannel { + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.getNotificationChannel(CHANNEL_ID)?.let { return it } + + val channel = + NotificationChannel( + CHANNEL_ID, + stringRes(context, R.string.nip46_signer_notif_channel_name), + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = stringRes(context, R.string.nip46_signer_notif_channel_desc) + } + manager.createNotificationChannel(channel) + return channel + } + + /** + * Posts a full-screen-intent notification whose content/full-screen [PendingIntent] opens + * [activityClass] carrying [token]. Returns the notification id to pass to [cancel] once the + * request resolves. + */ + fun show( + context: Context, + activityClass: Class<*>, + extraKey: String, + token: String, + titleRes: Int, + ): Int { + // When Amethyst already owns the foreground the direct startActivity opens the dialog, so a + // heads-up notification would just be redundant noise on top of it. Only fall back to the + // full-screen intent when we're backgrounded — the case where startActivity is BAL-blocked. + if (appInForeground()) return NO_NOTIFICATION + + val channel = ensureChannel(context) + val notificationId = token.hashCode() + + val intent = + Intent(context, activityClass) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + .putExtra(extraKey, token) + + val pendingIntent = + PendingIntent.getActivity( + context, + notificationId, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val notification = + NotificationCompat + .Builder(context, channel.id) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(stringRes(context, titleRes)) + .setContentText(stringRes(context, R.string.nip46_signer_notif_tap)) + .setContentIntent(pendingIntent) + .setFullScreenIntent(pendingIntent, true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_RECOMMENDATION) + .setAutoCancel(true) + .setOngoing(true) + .setTimeoutAfter(TIMEOUT_MS) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .build() + + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(notificationId, notification) + return notificationId + } + + fun cancel( + context: Context, + notificationId: Int, + ) { + if (notificationId == NO_NOTIFICATION) return + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.cancel(notificationId) + } + + private fun appInForeground(): Boolean = + // Defensive: the signer consent path only runs in the main process (where Amethyst.instance + // is set), but touching it from the keyless :napplet process would throw. Treat any failure + // as "not foreground" so the notification fallback still fires. + runCatching { Amethyst.instance.foregroundTracker.isForeground.value }.getOrDefault(false) + + private const val NO_NOTIFICATION = Int.MIN_VALUE + private const val TIMEOUT_MS = 120_000L +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index 14eb8f3ebd..7bc1e8a2c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -59,6 +59,14 @@ import kotlinx.coroutines.launch /** How many recently-serviced request ids to persist for cross-restart replay dedup. */ private const val MAX_SEEN_IDS = 128 +/** + * Auto-forget a connected app after this long with no activity. Each connected NIP-46 app makes the + * signer hold a background relay subscription indefinitely, so an app paired once and abandoned would + * leak a relay connection forever; pruning idle apps bounds that growth. Last-used is stamped on + * connect and on every serviced request, so an app still in use is never pruned. + */ +private const val IDLE_PRUNE_SECONDS = 7 * 24 * 60 * 60L + /** * Runs Amethyst as a NIP-46 remote signer ("bunker") for the account, so other * apps can sign through it. While [AccountSettings.nip46SignerEnabled] is on, a @@ -166,7 +174,14 @@ class Nip46SignerState( // extraRelays is a live projection of the persisted client store (the nostrconnect apps' own // relays). Load it on start so paired apps stay reachable across restarts; it is refreshed // whenever a client connects or is forgotten (bunker-flow apps use the inbox relays instead). - scope.launch(Dispatchers.IO) { refreshExtraRelaysFromStore() } + // Prune apps idle past IDLE_PRUNE_SECONDS first so we don't re-subscribe to a relay only an + // abandoned app used — forget() already refreshes extraRelays, and we refresh again in case + // nothing was pruned. + scope.launch(Dispatchers.IO) { + runCatching { authorizer.pruneIdle(IDLE_PRUNE_SECONDS) } + .onFailure { Log.w("NIP46Signer") { "idle prune failed: ${it.message}" } } + refreshExtraRelaysFromStore() + } scope.launch(Dispatchers.IO) { combine(settings.nip46SignerEnabled, listeningRelays, settings.nip46TransportKey) { enabled, relays, transportKey -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 48e98fb6e2..c60fbefdca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -258,6 +258,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScree import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ConnectedAppsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46SignerScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppDetailScreen @@ -425,6 +426,7 @@ fun BuildNavigation( composableFromEnd { ConnectedAppsScreen(accountViewModel, nav) } composableFromEndArgs { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) } composableFromEndArgs { Nip46SignerScreen(accountViewModel, nav, it.connectUri) } + composableFromEnd { Nip46ConnectedAppsScreen(accountViewModel, nav) } composableFromEnd { RelayAuthSettingsScreen(accountViewModel, nav) } composableFromEndArgs { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { CalendarsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 78cd5f8f7c..c3a53e7919 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -48,6 +48,7 @@ enum class NavBarItem { INTEREST_SETS, EMOJI_PACKS, WALLET, + NOSTR_SIGNER, COMMUNITIES, ARTICLES, PICTURES, @@ -195,6 +196,13 @@ val NavBarCatalog: Map = icon = MaterialSymbols.AccountBalanceWallet, resolveRoute = { Route.Wallet }, ), + NavBarItem.NOSTR_SIGNER to + NavBarItemDef( + id = NavBarItem.NOSTR_SIGNER, + labelRes = R.string.nip46_signer_title, + icon = MaterialSymbols.Key, + resolveRoute = { Route.Nip46Signer() }, + ), NavBarItem.COMMUNITIES to NavBarItemDef( id = NavBarItem.COMMUNITIES, @@ -443,6 +451,7 @@ val DrawerYouItems: List = NavBarItem.INTEREST_SETS, NavBarItem.EMOJI_PACKS, NavBarItem.WALLET, + NavBarItem.NOSTR_SIGNER, ) /** @@ -495,6 +504,7 @@ val BottomBarCategories: List = NavBarItem.FAVORITE_ALGO_FEEDS, NavBarItem.EMOJI_PACKS, NavBarItem.WALLET, + NavBarItem.NOSTR_SIGNER, ), ), NavBarCategory( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 4b4f2e3347..c2c39eb3ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -112,6 +112,8 @@ sealed class Route { val connectUri: String? = null, ) : Route() + @Serializable object Nip46ConnectedApps : Route() + @Serializable object RelayAuthSettings : Route() @Serializable data class ConnectedAppDetail( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 69c6a2f3eb..41b489db8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -170,6 +170,7 @@ private fun PreloadFor( NavBarItem.INTEREST_SETS, NavBarItem.EMOJI_PACKS, NavBarItem.WALLET, + NavBarItem.NOSTR_SIGNER, NavBarItem.FAVORITE_ALGO_FEEDS, NavBarItem.SETTINGS, -> Unit diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index 1a56db7ad4..2768f983b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -55,7 +55,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger @@ -106,7 +105,7 @@ fun ConnectedAppsScreen( LaunchedEffect(Unit) { val initial = withContext(Dispatchers.Default) { - loadConnectedApps(capabilityLedger, signerLedger, accountViewModel.account.signer.pubKey) + loadConnectedApps(capabilityLedger, signerLedger) } items = initial // Only include real napplet authors in the manifest subscription — skip the "browser" @@ -204,85 +203,15 @@ private fun ConnectedAppCard( onClick: () -> Unit, ) { val author = remember(entry.coordinate) { entry.coordinate.substringBefore(':') } - val nip46Client = remember(entry.coordinate) { Nip46PermissionAuthorizer.clientPubKeyOf(entry.coordinate) } when { author == BROWSER_AUTHOR -> { val url = remember(entry.coordinate) { entry.coordinate.substringAfter(':', "") } BrowserAppCard(url = url, entry = entry, onClick = onClick) } - nip46Client != null -> RemoteSignerAppCard(clientPubKey = nip46Client, entry = entry, onClick = onClick) else -> NappletAppCard(author = author, entry = entry, untitled = untitled, onClick = onClick) } } -/** Card for a NIP-46 remote-signer client — an app that signs through Amethyst over relays. */ -@Composable -private fun RemoteSignerAppCard( - clientPubKey: HexKey, - entry: ConnectedAppEntry, - onClick: () -> Unit, -) { - val npub = remember(clientPubKey) { runCatching { NPub.create(clientPubKey) }.getOrDefault(clientPubKey.take(12) + "…") } - var info by remember(entry.coordinate) { mutableStateOf(null) } - LaunchedEffect(entry.coordinate) { - info = withContext(Dispatchers.Default) { Amethyst.instance.nip46ClientStore.load(entry.coordinate) } - } - val title = info?.name?.ifBlank { null } ?: stringResource(R.string.nip46_signer_remote_app) - Card( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - MaterialSymbols.Key, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(48.dp), - ) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - Text( - title, - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - npub, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontFamily = FontFamily.Monospace, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Column( - horizontalAlignment = Alignment.End, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - if (entry.signerPolicy != null) { - SuggestionChip( - onClick = {}, - label = { Text(entry.signerPolicy.shortLabel(), style = MaterialTheme.typography.labelSmall) }, - ) - } - Icon( - MaterialSymbols.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) - } - } - } -} - /** Card for a web app permission entry — the user visited this origin in the sandboxed browser. */ @Composable private fun BrowserAppCard( @@ -448,18 +377,16 @@ private fun AppSignerPolicy.shortLabel(): String = private suspend fun loadConnectedApps( capabilityLedger: NappletPermissionLedger, signerLedger: NostrSignerPermissionLedger, - signerPubKey: HexKey, ): List { val capGrants = capabilityLedger.allPersistedGrants() val signerPolicies = signerLedger.store.allPolicies() val allCoordinates = (capGrants.keys + signerPolicies.keys).toSet() return allCoordinates - // NIP-46 grants are namespaced by signer, so only surface this account's remote clients; - // napplet/browser grants stay app-global and are shown for every account as before. - .filter { coordinate -> - coordinate.substringBefore(':') != Nip46PermissionAuthorizer.COORDINATE_PREFIX || - Nip46PermissionAuthorizer.belongsTo(coordinate, signerPubKey) - }.map { coordinate -> + // NIP-46 remote-signer clients have their own screen (Nip46ConnectedAppsScreen) because, + // unlike napplets/browser origins, each holds a live background relay subscription that + // needs managing. Exclude them here so this screen stays napplet/nsite/browser only. + .filter { coordinate -> coordinate.substringBefore(':') != Nip46PermissionAuthorizer.COORDINATE_PREFIX } + .map { coordinate -> ConnectedAppEntry( coordinate = coordinate, signerPolicy = signerPolicies[coordinate], diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 473bbb9a09..5e85e88c94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -75,7 +75,6 @@ fun buildSettingsCatalog( symEntry(R.string.profile_badges_title, MaterialSymbols.MilitaryTech, R.string.profile_badges_search_keywords, Route.ProfileBadges), symEntry(R.string.payment_targets, MaterialSymbols.Payment, R.string.payment_targets_search_keywords, Route.EditPaymentTargets), symEntry(R.string.napplet_permissions_title, MaterialSymbols.Apps, R.string.napplet_connected_apps_search_keywords, Route.ConnectedApps), - symEntry(R.string.nip46_signer_title, MaterialSymbols.Key, R.string.nip46_signer_search_keywords, Route.Nip46Signer()), symEntry(R.string.relay_auth_settings_title, MaterialSymbols.Lock, R.string.relay_auth_search_keywords, Route.RelayAuthSettings), symEntry(R.string.security_filters, MaterialSymbols.Security, R.string.security_filters_search_keywords, Route.SecurityFilters), symEntry(R.string.call_settings, MaterialSymbols.Phone, R.string.call_settings_search_keywords, Route.CallSettings), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt new file mode 100644 index 0000000000..4cbfdc638c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46 + +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.util.toTimeAgo +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** One connected NIP-46 remote-signer client, with everything the row needs to render. */ +private data class Nip46AppEntry( + val coordinate: String, + val clientPubKey: HexKey, + val policy: AppSignerPolicy?, + val info: Nip46ClientInfo?, + val lastUsedSeconds: Long?, +) + +/** + * The remote-signer clients connected to this account. Kept separate from the shared Connected Apps + * screen because — unlike napplets/nsites/browser origins — each NIP-46 app can carry its own relays + * (from a `nostrconnect://` offer), which the signer subscribes to in the background for as long as + * the app stays connected. Surfacing them here, with their relay footprint and last-used time, lets + * the user prune the background relay connections they no longer need (idle apps are also auto-forgotten + * after a week; see `Nip46SignerState.IDLE_PRUNE_SECONDS`). + * + * Tapping a row opens the shared [Route.ConnectedAppDetail], which already renders NIP-46 clients + * (history + Forget). + */ +@Composable +fun Nip46ConnectedAppsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + val signerPubKey = remember { account.signer.pubKey } + + var items by remember { mutableStateOf?>(null) } + // Bumped on resume so a Forget performed on the detail screen is reflected when we return. + var refreshKey by remember { mutableIntStateOf(0) } + + LaunchedEffect(refreshKey) { + items = withContext(Dispatchers.Default) { loadNip46Apps(signerPubKey) } + } + + Scaffold( + topBar = { TopBarWithBackButton(stringResource(R.string.nip46_signer_manage_apps), nav) }, + ) { padding -> + val current = items + when { + current == null -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + + current.isEmpty() -> + Box(Modifier.fillMaxSize().padding(padding).padding(32.dp), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + MaterialSymbols.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(56.dp), + ) + Text( + stringResource(R.string.nip46_signer_apps_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + + else -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(current, key = { it.coordinate }) { entry -> + Nip46AppCard( + entry = entry, + onClick = { nav.nav(Route.ConnectedAppDetail(entry.coordinate)) }, + ) + } + } + } + } +} + +@Composable +private fun Nip46AppCard( + entry: Nip46AppEntry, + onClick: () -> Unit, +) { + val npub = remember(entry.clientPubKey) { runCatching { NPub.create(entry.clientPubKey) }.getOrDefault(entry.clientPubKey.take(12) + "…") } + val title = entry.info?.name?.ifBlank { null } ?: stringResource(R.string.nip46_signer_remote_app) + val relayCount = entry.info?.relays?.size ?: 0 + + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + MaterialSymbols.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(48.dp), + ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + npub, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val meta = + buildList { + if (relayCount > 0) add(pluralStringResource(R.plurals.nip46_signer_app_relay_count, relayCount, relayCount)) + entry.lastUsedSeconds?.let { add(stringResource(R.string.nip46_signer_app_last_used, it.toTimeAgo().trim())) } + }.joinToString(" · ") + if (meta.isNotEmpty()) { + Text( + meta, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + entry.policy?.let { policy -> + SuggestionChip( + onClick = {}, + label = { Text(policy.shortLabel(), style = MaterialTheme.typography.labelSmall) }, + ) + } + Icon( + MaterialSymbols.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + } +} + +@Composable +private fun AppSignerPolicy.shortLabel(): String = + when (this) { + AppSignerPolicy.FULL_TRUST -> stringResource(R.string.napplet_policy_full_trust) + AppSignerPolicy.REASONABLE -> stringResource(R.string.napplet_policy_reasonable) + AppSignerPolicy.PARANOID -> stringResource(R.string.napplet_policy_paranoid) + } + +private suspend fun loadNip46Apps(signerPubKey: HexKey): List { + val store = Amethyst.instance.signerPermissionStore + val clientStore = Amethyst.instance.nip46ClientStore + return store + .allPolicies() + .keys + .filter { Nip46PermissionAuthorizer.belongsTo(it, signerPubKey) } + .mapNotNull { coordinate -> + val clientPubKey = Nip46PermissionAuthorizer.clientPubKeyOf(coordinate) ?: return@mapNotNull null + Nip46AppEntry( + coordinate = coordinate, + clientPubKey = clientPubKey, + policy = store.loadPolicy(coordinate), + info = clientStore.load(coordinate), + lastUsedSeconds = store.loadLastUsed(coordinate), + ) + }.sortedByDescending { it.lastUsedSeconds ?: 0L } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index 9716727705..ec33faefa7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -207,7 +207,7 @@ fun Nip46SignerScreen( if (enabled || connectedCount > 0) { ConnectedAppsRow( count = connectedCount, - onClick = { nav.nav(Route.ConnectedApps) }, + onClick = { nav.nav(Route.Nip46ConnectedApps) }, ) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index b5cf4b115f..cfed8928bd 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -928,6 +928,12 @@ Paste a nostrconnect:// link Connect Manage connected apps + No apps are connected to your signer yet.\n\nScan or paste an app\'s code to connect it. Connected apps appear here, and idle ones are removed automatically after a week. + used %1$s + + %1$d relay + %1$d relays + App connected. Amethyst now signs for it in the background. Connected %1$s. Amethyst now signs for it in the background. Not a valid nostrconnect:// link @@ -950,6 +956,11 @@ Ping Listed relays %1$s + Signing requests + Full-screen prompts asking you to approve an app that wants Amethyst to sign, encrypt, or decrypt with your key. + Approve a signing request? + An app wants to connect + Tap to review Scan to connect an app to your key Scan a code Paste a link instead diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt index e91e1e6d03..96a7f765cd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt @@ -226,6 +226,29 @@ class Nip46PermissionAuthorizer( onDisconnected?.invoke(clientPubKey) } + /** + * Forgets every app under this account that hasn't been used in [maxIdleSeconds]. Each connected + * NIP-46 app makes the signer hold a background relay subscription forever, so an app the user + * paired once and abandoned would keep a relay connection alive indefinitely; this bounds that + * growth. `last-used` is stamped on connect and on every serviced operation, so an app that is + * still signing is never pruned. Returns the client pubkeys that were forgotten. + */ + suspend fun pruneIdle( + maxIdleSeconds: Long, + now: Long = TimeUtils.now(), + ): List { + val cutoff = now - maxIdleSeconds + val stale = + ledger.store + .allPolicies() + .keys + .filter { belongsTo(it, signerPubKey) } + .filter { (ledger.lastUsed(it) ?: 0L) < cutoff } + .mapNotNull { clientPubKeyOf(it) } + stale.forEach { forget(it) } + return stale + } + companion object { /** Ledger coordinate namespace for NIP-46 remote-signer clients. */ const val COORDINATE_PREFIX = "nip46" diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt index bd246b5b6e..7c5375f0ed 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt @@ -297,6 +297,60 @@ class Nip46PermissionAuthorizerTest { assertEquals(client, disconnected, "host notified so it can drop the relays this session") } + @Test + fun pruneIdleForgetsAppsPastTheCutoffAndKeepsActiveOnes() = + runTest { + val ledger = ledger() + val store = InMemoryNip46ClientStore() + val idle = "b".repeat(64) + val active = "e".repeat(64) + val idleCoord = Nip46PermissionAuthorizer.coordinateFor(signer, idle) + val activeCoord = Nip46PermissionAuthorizer.coordinateFor(signer, active) + ledger.setPolicy(idleCoord, AppSignerPolicy.REASONABLE) + ledger.setPolicy(activeCoord, AppSignerPolicy.REASONABLE) + store.store(idleCoord, Nip46ClientInfo(name = "Idle", relays = setOf("wss://idle.example.com"))) + store.store(activeCoord, Nip46ClientInfo(name = "Active", relays = setOf("wss://active.example.com"))) + + val now = 1_000_000L + val idleSeconds = 100L + ledger.updateLastUsed(idleCoord, now - idleSeconds - 1) + ledger.updateLastUsed(activeCoord, now - idleSeconds + 1) + + val disconnected = mutableListOf() + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + clientStore = store, + onDisconnected = { disconnected.add(it) }, + ) + + val pruned = authorizer.pruneIdle(idleSeconds, now = now) + + assertEquals(listOf(idle), pruned, "only the idle app is forgotten") + assertEquals(listOf(idle), disconnected, "host notified for the idle app so its relay is dropped") + assertEquals(null, ledger.store.loadPolicy(idleCoord), "idle grant cleared") + assertEquals(AppSignerPolicy.REASONABLE, ledger.store.loadPolicy(activeCoord), "active grant kept") + assertEquals(null, store.load(idleCoord), "idle metadata cleared") + } + + @Test + fun pruneIdleIgnoresOtherAccountsApps() = + runTest { + val ledger = ledger() + val otherSigner = "f".repeat(64) + val otherCoord = Nip46PermissionAuthorizer.coordinateFor(otherSigner, client) + ledger.setPolicy(otherCoord, AppSignerPolicy.REASONABLE) + ledger.updateLastUsed(otherCoord, 0L) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) + + val pruned = authorizer.pruneIdle(100L, now = 1_000_000L) + + assertTrue(pruned.isEmpty(), "an app belonging to another account is never pruned by this signer") + assertEquals(AppSignerPolicy.REASONABLE, ledger.store.loadPolicy(otherCoord)) + } + @Test fun logoutIsEquivalentToForget() = runTest { From 052459567cdfa9aebd71b7c365c47a52a111ea8d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 19:40:59 +0000 Subject: [PATCH 37/52] feat(nip46): rename "Nostr Signer" to "Remote Signer" Renames the user-facing title (drawer entry + screen top bar). Search keywords already include "remote" and "nostr", so discoverability is unchanged. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- amethyst/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index cfed8928bd..e7a359080e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -900,7 +900,7 @@ apps permissions signer napplet nsite webapp trust connected - Nostr Signer + Remote Signer signer bunker nip46 nostr connect remote sign Let other apps sign with your key. Amethyst listens on your inbox relays and checks each app\'s permissions before signing — using the same trust levels as Connected Apps. Act as a remote signer From 0734fdb8de2cb2aebbabd62ef85e2944adb57e03 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 19:45:26 +0000 Subject: [PATCH 38/52] fix(nip46): show the same identity in the connected-app list and detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedicated NIP-46 apps list always rendered the client's npub, while the detail header showed the app's self-declared website (url) when it had one — so an app that advertised a website looked like a bare pubkey in the list but a website once opened. Extract one nip46ClientSubtitle(url, clientPubKey) helper (website host when declared, npub otherwise) and use it in both places so a row and the screen it opens never disagree. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../napplets/ConnectedAppDetailScreen.kt | 3 ++- .../nip46/Nip46ConnectedAppsScreen.kt | 25 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index dd450b3d1f..6220d0d5e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -90,6 +90,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ActivityCard +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46ClientSubtitle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -319,7 +320,7 @@ private fun Nip46AppHeader( Column(modifier = Modifier.weight(1f)) { Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) Text( - url?.ifBlank { null } ?: (clientPubKey.take(16) + "…"), + nip46ClientSubtitle(url, clientPubKey), style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt index 4cbfdc638c..9baf647e02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt @@ -160,7 +160,9 @@ private fun Nip46AppCard( entry: Nip46AppEntry, onClick: () -> Unit, ) { - val npub = remember(entry.clientPubKey) { runCatching { NPub.create(entry.clientPubKey) }.getOrDefault(entry.clientPubKey.take(12) + "…") } + // Same identity line the detail screen (Nip46AppHeader) uses: the app's self-declared website + // when it has one, npub otherwise — so a row and its detail never disagree. + val subtitle = remember(entry.info?.url, entry.clientPubKey) { nip46ClientSubtitle(entry.info?.url, entry.clientPubKey) } val title = entry.info?.name?.ifBlank { null } ?: stringResource(R.string.nip46_signer_remote_app) val relayCount = entry.info?.relays?.size ?: 0 @@ -190,7 +192,7 @@ private fun Nip46AppCard( overflow = TextOverflow.Ellipsis, ) Text( - npub, + subtitle, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, fontFamily = FontFamily.Monospace, @@ -241,6 +243,25 @@ private fun AppSignerPolicy.shortLabel(): String = AppSignerPolicy.PARANOID -> stringResource(R.string.napplet_policy_paranoid) } +/** + * The identity line shown for a NIP-46 client: its self-declared website (host only) when it + * advertised one, otherwise its npub. Shared by the list card and the detail header so a row and + * the screen it opens always agree. + */ +internal fun nip46ClientSubtitle( + url: String?, + clientPubKey: HexKey, +): String { + val host = + url + ?.ifBlank { null } + ?.removePrefix("https://") + ?.removePrefix("http://") + ?.substringBefore('/') + ?.ifBlank { null } + return host ?: runCatching { NPub.create(clientPubKey) }.getOrDefault(clientPubKey.take(12) + "…") +} + private suspend fun loadNip46Apps(signerPubKey: HexKey): List { val store = Amethyst.instance.signerPermissionStore val clientStore = Amethyst.instance.nip46ClientStore From e353467b6f958b1ee04365fd55e3fb4ea67b8635 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 19:53:01 +0000 Subject: [PATCH 39/52] feat(nip46): show the connected app's own icon when it declares one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A NIP-46 client's connect metadata can include an image (its app/site icon). Render it — in a circle, with the Key glyph as the placeholder/error fallback — for both the connected-app list row and the detail header, via a shared Nip46AppIcon. We only draw an icon the app itself advertised; we never fetch a site favicon from the main app, which would bypass Tor and leak the user's IP (the same reason BrowserIconRegistry captures favicons in the sandbox instead). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../napplets/ConnectedAppDetailScreen.kt | 23 ++------ .../nip46/Nip46ConnectedAppsScreen.kt | 52 ++++++++++++++++--- 2 files changed, 50 insertions(+), 25 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index 6220d0d5e2..fd4c256ef2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,7 +31,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -56,7 +54,6 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -90,6 +87,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ActivityCard +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46AppIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46ClientSubtitle import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -186,7 +184,7 @@ fun ConnectedAppDetailScreen( ) { // App identity header if (nip46Client != null) { - Nip46AppHeader(title = nip46Title, url = nip46Info?.url, clientPubKey = nip46Client) + Nip46AppHeader(title = nip46Title, url = nip46Info?.url, image = nip46Info?.image, clientPubKey = nip46Client) } else { AppIdentityHeader(current) } @@ -290,6 +288,7 @@ fun ConnectedAppDetailScreen( private fun Nip46AppHeader( title: String, url: String?, + image: String?, clientPubKey: String, ) { Surface( @@ -302,21 +301,7 @@ private fun Nip46AppHeader( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Box( - modifier = - Modifier - .size(48.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer), - contentAlignment = Alignment.Center, - ) { - Icon( - MaterialSymbols.Key, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(24.dp), - ) - } + Nip46AppIcon(image, Modifier.size(48.dp)) Column(modifier = Modifier.weight(1f)) { Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) Text( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt index 9baf647e02..c11da19088 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46 +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -32,6 +33,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -48,12 +50,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo @@ -61,6 +66,7 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAut import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -175,12 +181,7 @@ private fun Nip46AppCard( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Icon( - MaterialSymbols.Key, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(48.dp), - ) + Nip46AppIcon(entry.info?.image, Modifier.size(48.dp)) Column( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp), @@ -243,6 +244,45 @@ private fun AppSignerPolicy.shortLabel(): String = AppSignerPolicy.PARANOID -> stringResource(R.string.napplet_policy_paranoid) } +/** + * The avatar for a NIP-46 client: its self-declared [image] (the app/site icon it advertised in its + * connect metadata) drawn in a circle, falling back to the Key glyph when it has none or the image + * fails to load. Shared by the list card and the detail header. We only render an icon the app itself + * provided — we never fetch a site favicon from the main app, which would bypass Tor and leak the + * user's IP (see BrowserIconRegistry). + */ +@Composable +internal fun Nip46AppIcon( + image: String?, + modifier: Modifier = Modifier, +) { + val model = image?.takeIf { it.isNotBlank() } + Box( + modifier = modifier.clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + if (model == null) { + Icon( + MaterialSymbols.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp), + ) + } else { + val glyph = rememberMaterialSymbolPainter(MaterialSymbols.Key, MaterialTheme.colorScheme.onPrimaryContainer) + AsyncImage( + model = model, + contentDescription = null, + modifier = Modifier.fillMaxSize().clip(CircleShape), + contentScale = ContentScale.Crop, + placeholder = glyph, + error = glyph, + fallback = glyph, + ) + } + } +} + /** * The identity line shown for a NIP-46 client: its self-declared website (host only) when it * advertised one, otherwise its npub. Shared by the list card and the detail header so a row and From bb56bf6a7357016dc4f1b42a8107ec0fa434ec74 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 19:57:06 +0000 Subject: [PATCH 40/52] feat(nip46): show a connected app's relays on its detail screen The list row's "N relays" count wasn't inspectable, so a user debugging why the signer holds a background relay connection couldn't see which relays an app uses. Add a Relays section to the (already tap-through) detail screen listing each relay URL, with a note that Amethyst keeps a background connection to each while the app stays connected. Apps that brought no relays of their own (the bunker flow) show that they ride the account's inbox relays and add no extra connection. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../napplets/ConnectedAppDetailScreen.kt | 55 +++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 3 + 2 files changed, 58 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index fd4c256ef2..d212c82f69 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -189,6 +189,12 @@ fun ConnectedAppDetailScreen( AppIdentityHeader(current) } + // Relays this remote client is reached on — the whole reason it costs a background + // connection, so surface them for debugging relay footprint. + if (nip46Client != null) { + Nip46RelaysSection(nip46Info?.relays.orEmpty()) + } + // Signing trust level section if (current.signerPolicy != null) { SectionHeader(stringResource(R.string.napplet_connected_app_trust_level)) @@ -284,6 +290,55 @@ fun ConnectedAppDetailScreen( } } +/** + * Lists the relays a NIP-46 remote client is reached on. When the client brought its own relays + * (the `nostrconnect://` flow) each one is a background connection Amethyst keeps open while the app + * stays connected — exactly what a user debugging relay footprint wants to see. An empty set means + * the client talks over the account's inbox relays (the bunker flow), so it adds no extra connection. + */ +@Composable +private fun Nip46RelaysSection(relays: Set) { + SectionHeader(stringResource(R.string.nip46_signer_app_relays_title)) + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (relays.isEmpty()) { + Text( + stringResource(R.string.nip46_signer_app_relays_inbox), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + relays.forEach { relay -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Icon( + MaterialSymbols.Dns, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Text( + relay, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + stringResource(R.string.nip46_signer_app_relays_own_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable private fun Nip46AppHeader( title: String, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e7a359080e..738c801203 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -930,6 +930,9 @@ Manage connected apps No apps are connected to your signer yet.\n\nScan or paste an app\'s code to connect it. Connected apps appear here, and idle ones are removed automatically after a week. used %1$s + Relays + Signs over your inbox relays — this app brought none of its own, so it keeps no extra background connection. + Amethyst holds a background connection to each of these while this app stays connected. Forget the app to drop them. %1$d relay %1$d relays From 98c18a9fcfb9b81f47778415488d0204f5df121a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 20:51:06 +0000 Subject: [PATCH 41/52] feat(nip46): honor nostrconnect perms + per-app live relay status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps found comparing against Primal's NIP-46 signer: Honor the offer's `perms`: we already parsed the `nostrconnect://?perms=` list but ignored it. Now the declared ops are pre-granted at pairing (the deliberate pair is the user's consent for what the app openly asked for), so a client that declares its needs runs without prompting on first use. The two highest-risk classes stay gated even when declared — decryption (private content) and deletion (kind 5) still prompt on first use with full context. Adds Nip46PermissionAuthorizer.parsePerms + tests. Per-app live relay status: the connected-apps list shows a Connected/Offline dot per app (judged on its own nostrconnect relays, or the inbox relays for a bunker-flow app), and the detail Relays section shows a live dot per relay — so "which relays is this costing me and are they up right now" is answerable at a glance. Shared Nip46StatusDot/Nip46LiveStatus/nip46AppOnline helpers. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../model/nip46Signer/Nip46SignerState.kt | 11 ++++ .../napplets/ConnectedAppDetailScreen.kt | 44 ++++++++++----- .../nip46/Nip46ConnectedAppsScreen.kt | 53 +++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 2 + .../nip46/Nip46PermissionAuthorizer.kt | 26 +++++++++ .../nip46/Nip46PermissionAuthorizerTest.kt | 29 ++++++++++ 6 files changed, 152 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index 7bc1e8a2c2..3b3c291f4e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.model.nip46Signer import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -35,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent @@ -333,6 +336,14 @@ class Nip46SignerState( if (!ledger.hasPolicy(coordinate)) { ledger.setPolicy(coordinate, authorizer.defaultPolicyOnConnect) } + // Honor the offer's `perms`: the app declared exactly what it needs and the user chose to + // pair it, so pre-grant those ops instead of prompting on first use. The two highest-risk + // classes stay gated even when declared — decryption (reveals private content) and deletion + // (kind 5) still prompt on first use, where the user sees full context. + Nip46PermissionAuthorizer.parsePerms(offer.perms).forEach { op -> + val gated = op is NostrSignerOp.Decrypt || (op is NostrSignerOp.SignKind && op.kind == DeletionEvent.KIND) + if (!gated) ledger.setOpDecision(coordinate, op, NostrOpDecision.ALLOW) + } ledger.updateLastUsed(coordinate) // Persist the app's label + its relays so it survives a restart, then start listening now. clientStore.store( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index d212c82f69..0da9d8e998 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -88,7 +88,12 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ActivityCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46AppIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46LiveStatus +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46StatusDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46AppOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46ClientSubtitle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -190,9 +195,14 @@ fun ConnectedAppDetailScreen( } // Relays this remote client is reached on — the whole reason it costs a background - // connection, so surface them for debugging relay footprint. + // connection, so surface them (with live status) for debugging relay footprint. if (nip46Client != null) { - Nip46RelaysSection(nip46Info?.relays.orEmpty()) + val connectedRelays by accountViewModel.account.client + .connectedRelaysFlow() + .collectAsStateWithLifecycle() + val inboxRelays by accountViewModel.account.nip46Signer.inboxRelays + .collectAsStateWithLifecycle() + Nip46RelaysSection(nip46Info?.relays.orEmpty(), inboxRelays, connectedRelays) } // Signing trust level section @@ -291,13 +301,18 @@ fun ConnectedAppDetailScreen( } /** - * Lists the relays a NIP-46 remote client is reached on. When the client brought its own relays - * (the `nostrconnect://` flow) each one is a background connection Amethyst keeps open while the app - * stays connected — exactly what a user debugging relay footprint wants to see. An empty set means - * the client talks over the account's inbox relays (the bunker flow), so it adds no extra connection. + * Lists the relays a NIP-46 remote client is reached on, each with a live status dot. When the client + * brought its own relays (the `nostrconnect://` flow) each one is a background connection Amethyst + * keeps open while the app stays connected — exactly what a user debugging relay footprint wants to + * see, including which are actually up right now. An empty set means the client talks over the + * account's inbox relays (the bunker flow), so it adds no extra connection. */ @Composable -private fun Nip46RelaysSection(relays: Set) { +private fun Nip46RelaysSection( + relays: Set, + inboxRelays: Set, + connectedRelays: Set, +) { SectionHeader(stringResource(R.string.nip46_signer_app_relays_title)) Surface( color = MaterialTheme.colorScheme.surfaceVariant, @@ -306,20 +321,23 @@ private fun Nip46RelaysSection(relays: Set) { ) { Column(modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { if (relays.isEmpty()) { + // Bunker-flow app: rides the inbox relays. Show the overall inbox liveness so the row + // still reflects whether the signer can be reached at all. + val online = nip46AppOnline(emptySet(), inboxRelays, connectedRelays) Text( stringResource(R.string.nip46_signer_app_relays_inbox), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + online?.let { Nip46LiveStatus(it) } } else { relays.forEach { relay -> + val relayOnline = + remember(relay, connectedRelays) { + RelayUrlNormalizer.normalizeOrNull(relay)?.let { it in connectedRelays } ?: false + } Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { - Icon( - MaterialSymbols.Dns, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) + Nip46StatusDot(relayOnline) Text( relay, style = MaterialTheme.typography.bodyMedium, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt index c11da19088..cbda6d1f2b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt @@ -51,6 +51,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource @@ -58,6 +59,7 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R @@ -73,6 +75,8 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -105,6 +109,11 @@ fun Nip46ConnectedAppsScreen( val account = accountViewModel.account val signerPubKey = remember { account.signer.pubKey } + // Live relay state so each app shows whether the signer currently reaches it. An app that brought + // its own relays (nostrconnect) is judged on those; a bunker-flow app rides the inbox relays. + val connectedRelays by account.client.connectedRelaysFlow().collectAsStateWithLifecycle() + val inboxRelays by account.nip46Signer.inboxRelays.collectAsStateWithLifecycle() + var items by remember { mutableStateOf?>(null) } // Bumped on resume so a Forget performed on the detail screen is reflected when we return. var refreshKey by remember { mutableIntStateOf(0) } @@ -151,8 +160,13 @@ fun Nip46ConnectedAppsScreen( verticalArrangement = Arrangement.spacedBy(10.dp), ) { items(current, key = { it.coordinate }) { entry -> + val online = + remember(entry.info?.relays, inboxRelays, connectedRelays) { + nip46AppOnline(entry.info?.relays.orEmpty(), inboxRelays, connectedRelays) + } Nip46AppCard( entry = entry, + online = online, onClick = { nav.nav(Route.ConnectedAppDetail(entry.coordinate)) }, ) } @@ -164,6 +178,7 @@ fun Nip46ConnectedAppsScreen( @Composable private fun Nip46AppCard( entry: Nip46AppEntry, + online: Boolean?, onClick: () -> Unit, ) { // Same identity line the detail screen (Nip46AppHeader) uses: the app's self-declared website @@ -214,6 +229,7 @@ private fun Nip46AppCard( overflow = TextOverflow.Ellipsis, ) } + online?.let { Nip46LiveStatus(it) } } Column( horizontalAlignment = Alignment.End, @@ -244,6 +260,43 @@ private fun AppSignerPolicy.shortLabel(): String = AppSignerPolicy.PARANOID -> stringResource(R.string.napplet_policy_paranoid) } +private val LiveGreen = Color(0xFF3DDC84) + +/** A colored dot: green when the signer currently reaches the relay(s), muted otherwise. */ +@Composable +internal fun Nip46StatusDot(online: Boolean) { + Box(Modifier.size(8.dp).clip(CircleShape).background(if (online) LiveGreen else MaterialTheme.colorScheme.outline)) +} + +/** A [Nip46StatusDot] + Connected/Offline label showing whether the signer currently reaches an app's relays. */ +@Composable +internal fun Nip46LiveStatus(online: Boolean) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Nip46StatusDot(online) + Text( + stringResource(if (online) R.string.nip46_signer_app_online else R.string.nip46_signer_app_offline), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Whether the signer currently reaches [appRelays] (an app's own `nostrconnect://` relays). An app + * that brought no relays of its own rides the account [inboxRelays], so it's judged on those. Returns + * `null` when neither resolves to any relay (nothing to show a status for). "Online" means at least + * one of the app's relays is in [connectedRelays]. + */ +internal fun nip46AppOnline( + appRelays: Set, + inboxRelays: Set, + connectedRelays: Set, +): Boolean? { + val effective = appRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet().ifEmpty { inboxRelays } + if (effective.isEmpty()) return null + return effective.any { it in connectedRelays } +} + /** * The avatar for a NIP-46 client: its self-declared [image] (the app/site icon it advertised in its * connect metadata) drawn in a circle, falling back to the Key glyph when it has none or the image diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 738c801203..afa9ba923a 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -930,6 +930,8 @@ Manage connected apps No apps are connected to your signer yet.\n\nScan or paste an app\'s code to connect it. Connected apps appear here, and idle ones are removed automatically after a week. used %1$s + Connected + Offline Relays Signs over your inbox relays — this app brought none of its own, so it keeps no extra background connection. Amethyst holds a background connection to each of these while this app stays connected. Forget the app to drop them. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt index 96a7f765cd..f09df51598 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt @@ -285,6 +285,32 @@ class Nip46PermissionAuthorizer( null } + /** + * Parses a NIP-46 `perms` string (the comma-separated permission list a client advertises in + * its `nostrconnect://…?perms=` offer) into the [NostrSignerOp]s it asks for. Tokens follow the + * de-facto convention: `sign_event:`, `nip04_encrypt`/`nip44_encrypt` (→ [NostrSignerOp.Encrypt]), + * `nip04_decrypt`/`nip44_decrypt` (→ [NostrSignerOp.Decrypt]); `get_public_key`, `connect`, `ping` + * and any unrecognized/blank token are ignored (get_public_key is always allowed and needs no grant). + * A bare `sign_event` with no kind is ignored — Amethyst grants per kind, so a kindless request is + * too broad to honor. + */ + fun parsePerms(perms: String?): List = + perms + ?.split(',') + ?.mapNotNull { token -> + when (val t = token.trim().lowercase()) { + "nip04_encrypt", "nip44_encrypt", "encrypt" -> NostrSignerOp.Encrypt + "nip04_decrypt", "nip44_decrypt", "decrypt" -> NostrSignerOp.Decrypt + else -> + if (t.startsWith("sign_event:")) { + t.removePrefix("sign_event:").toIntOrNull()?.let { NostrSignerOp.SignKind(it) } + } else { + null + } + } + }?.distinct() + .orEmpty() + /** Maps a signing/encryption/decryption [BunkerRequest] to the [NostrSignerOp] it needs. */ fun BunkerRequest.toSignerOp(): NostrSignerOp? = when (this) { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt index 7c5375f0ed..96a51803c7 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt @@ -245,6 +245,35 @@ class Nip46PermissionAuthorizerTest { assertEquals(null, ledger.store.loadPolicy(coordinate)) } + @Test + fun parsePermsMapsTokensToOpsAndIgnoresTheRest() { + val ops = + Nip46PermissionAuthorizer.parsePerms( + "sign_event:1, sign_event:5, nip44_encrypt, nip04_decrypt, get_public_key, connect, sign_event", + ) + assertEquals( + listOf( + NostrSignerOp.SignKind(1), + NostrSignerOp.SignKind(5), + NostrSignerOp.Encrypt, + NostrSignerOp.Decrypt, + ), + ops, + "known tokens map to ops (encrypt/decrypt collapse NIP-04+44); get_public_key/connect/kindless sign_event are dropped", + ) + } + + @Test + fun parsePermsDeduplicatesAndHandlesBlank() { + assertTrue(Nip46PermissionAuthorizer.parsePerms(null).isEmpty()) + assertTrue(Nip46PermissionAuthorizer.parsePerms("").isEmpty()) + assertEquals( + listOf(NostrSignerOp.Encrypt), + Nip46PermissionAuthorizer.parsePerms("nip04_encrypt,nip44_encrypt"), + "NIP-04 and NIP-44 encrypt both map to Encrypt and are de-duplicated", + ) + } + @Test fun coordinateRoundTrips() { assertEquals(client, Nip46PermissionAuthorizer.clientPubKeyOf(coordinate)) From a039f6adbb742ce8c504274117a6aede499802da Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 21:08:04 +0000 Subject: [PATCH 42/52] feat(nip46): security-icon trust picker + reconnect affordance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the three refinements from the Primal comparison: Trust picker as security icons: unify the connect dialog and the app-detail picker on one open→shield→locked glyph set — LockOpen (Full trust, never asks), Shield (Reasonable), Lock (Paranoid) — replacing the connect dialog's emoji and the detail's heart so both surfaces read the same and the icon carries the guard-level at a glance. Reconnect affordance: when an app's relays show Offline (from the live status added last commit), offer a one-tap Reconnect — on the connected-apps row and in the detail's Relays section — that forces the relay pool to re-dial now, ignoring backoff. Shared Nip46ReconnectPill. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../consent/SignerConnectActivity.kt | 16 +++++-- .../napplets/ConnectedAppDetailScreen.kt | 32 +++++++++++-- .../nip46/Nip46ConnectedAppsScreen.kt | 46 ++++++++++++++++--- amethyst/src/main/res/values/strings.xml | 2 + 4 files changed, 82 insertions(+), 14 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt index bd3e035688..c48b742878 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt @@ -63,6 +63,7 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.ui.theme.AmethystTheme @@ -199,21 +200,21 @@ private fun SignerConnectScreen( ) { PolicyOption( selected = selected == AppSignerPolicy.FULL_TRUST, - icon = "❤", + symbol = MaterialSymbols.LockOpen, label = stringResource(R.string.napplet_policy_full_trust), description = stringResource(R.string.napplet_policy_full_trust_desc), onClick = { selected = AppSignerPolicy.FULL_TRUST }, ) PolicyOption( selected = selected == AppSignerPolicy.REASONABLE, - icon = "👍", + symbol = MaterialSymbols.Shield, label = stringResource(R.string.napplet_policy_reasonable), description = stringResource(R.string.napplet_policy_reasonable_desc), onClick = { selected = AppSignerPolicy.REASONABLE }, ) PolicyOption( selected = selected == AppSignerPolicy.PARANOID, - icon = "🕶", + symbol = MaterialSymbols.Lock, label = stringResource(R.string.napplet_policy_paranoid), description = stringResource(R.string.napplet_policy_paranoid_desc), onClick = { selected = AppSignerPolicy.PARANOID }, @@ -254,7 +255,7 @@ private fun SignerConnectScreen( @Composable private fun PolicyOption( selected: Boolean, - icon: String, + symbol: MaterialSymbol, label: String, description: String, onClick: () -> Unit, @@ -276,7 +277,12 @@ private fun PolicyOption( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Text(icon, style = MaterialTheme.typography.headlineSmall) + Icon( + symbol = symbol, + contentDescription = null, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(26.dp), + ) Column(modifier = Modifier.weight(1f)) { Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index 0da9d8e998..920d0dd38a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets +import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -54,6 +55,7 @@ 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.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -89,6 +91,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ActivityCard import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46AppIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46LiveStatus +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ReconnectPill import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46StatusDot import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46AppOnline import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46ClientSubtitle @@ -202,7 +205,12 @@ fun ConnectedAppDetailScreen( .collectAsStateWithLifecycle() val inboxRelays by accountViewModel.account.nip46Signer.inboxRelays .collectAsStateWithLifecycle() - Nip46RelaysSection(nip46Info?.relays.orEmpty(), inboxRelays, connectedRelays) + Nip46RelaysSection( + relays = nip46Info?.relays.orEmpty(), + inboxRelays = inboxRelays, + connectedRelays = connectedRelays, + onReconnect = { accountViewModel.account.client.reconnect(ignoreRetryDelays = true) }, + ) } // Signing trust level section @@ -312,8 +320,26 @@ private fun Nip46RelaysSection( relays: Set, inboxRelays: Set, connectedRelays: Set, + onReconnect: () -> Unit, ) { - SectionHeader(stringResource(R.string.nip46_signer_app_relays_title)) + val context = LocalContext.current + val anyOffline = + remember(relays, inboxRelays, connectedRelays) { + if (relays.isEmpty()) { + nip46AppOnline(emptySet(), inboxRelays, connectedRelays) == false + } else { + relays.any { RelayUrlNormalizer.normalizeOrNull(it)?.let { r -> r !in connectedRelays } ?: true } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f)) { SectionHeader(stringResource(R.string.nip46_signer_app_relays_title)) } + if (anyOffline) { + Nip46ReconnectPill { + onReconnect() + Toast.makeText(context, R.string.nip46_signer_reconnecting, Toast.LENGTH_SHORT).show() + } + } + } Surface( color = MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.medium, @@ -472,7 +498,7 @@ private fun PolicyPicker( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { PolicyCard( selected = selected == AppSignerPolicy.FULL_TRUST, - symbol = MaterialSymbols.Favorite, + symbol = MaterialSymbols.LockOpen, label = stringResource(R.string.napplet_policy_full_trust), description = stringResource(R.string.napplet_policy_full_trust_desc), onClick = { onSelect(AppSignerPolicy.FULL_TRUST) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt index cbda6d1f2b..ad0f634374 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46 +import android.widget.Toast import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -34,6 +36,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -53,6 +56,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily @@ -108,6 +112,7 @@ fun Nip46ConnectedAppsScreen( ) { val account = accountViewModel.account val signerPubKey = remember { account.signer.pubKey } + val context = LocalContext.current // Live relay state so each app shows whether the signer currently reaches it. An app that brought // its own relays (nostrconnect) is judged on those; a bunker-flow app rides the inbox relays. @@ -167,6 +172,10 @@ fun Nip46ConnectedAppsScreen( Nip46AppCard( entry = entry, online = online, + onReconnect = { + account.client.reconnect(ignoreRetryDelays = true) + Toast.makeText(context, R.string.nip46_signer_reconnecting, Toast.LENGTH_SHORT).show() + }, onClick = { nav.nav(Route.ConnectedAppDetail(entry.coordinate)) }, ) } @@ -179,6 +188,7 @@ fun Nip46ConnectedAppsScreen( private fun Nip46AppCard( entry: Nip46AppEntry, online: Boolean?, + onReconnect: () -> Unit, onClick: () -> Unit, ) { // Same identity line the detail screen (Nip46AppHeader) uses: the app's self-declared website @@ -241,12 +251,16 @@ private fun Nip46AppCard( label = { Text(policy.shortLabel(), style = MaterialTheme.typography.labelSmall) }, ) } - Icon( - MaterialSymbols.ChevronRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), - ) + if (online == false) { + Nip46ReconnectPill(onReconnect) + } else { + Icon( + MaterialSymbols.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } } } } @@ -268,6 +282,26 @@ internal fun Nip46StatusDot(online: Boolean) { Box(Modifier.size(8.dp).clip(CircleShape).background(if (online) LiveGreen else MaterialTheme.colorScheme.outline)) } +/** + * A small "Reconnect" pill shown when an app's relays are offline. Forces the whole relay pool to + * re-dial now (ignoring backoff), which re-establishes the offline relays. Consumes its own tap so a + * pill inside a clickable card doesn't also open the card. + */ +@Composable +internal fun Nip46ReconnectPill(onClick: () -> Unit) { + Text( + stringResource(R.string.nip46_signer_app_reconnect), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .clip(RoundedCornerShape(999.dp)) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(999.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 11.dp, vertical = 4.dp), + ) +} + /** A [Nip46StatusDot] + Connected/Offline label showing whether the signer currently reaches an app's relays. */ @Composable internal fun Nip46LiveStatus(online: Boolean) { diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index afa9ba923a..47e9bea0ca 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -932,6 +932,8 @@ used %1$s Connected Offline + Reconnect + Reconnecting to relays… Relays Signs over your inbox relays — this app brought none of its own, so it keeps no extra background connection. Amethyst holds a background connection to each of these while this app stays connected. Forget the app to drop them. From 1d8b7d7c8f8444e06baa5c8461997463c6feee94 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 21:30:43 +0000 Subject: [PATCH 43/52] feat(nip46): batched consent via concurrent request dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third refinement from the Primal comparison — and the one that needed an architecture change, not just UI. Quartz: NostrConnectSignerService now fans each request into a child coroutine under a Semaphore(maxConcurrentHandles=16) instead of handling them inline, so a request awaiting a consent prompt no longer blocks other clients' auto-allowed traffic and several prompts can be pending at once. Intake (dedup, staleness, rate-limit, seen-id persistence) stays on the single consumer. Two guards keep it safe: BunkerRequestProcessor serializes the actual crypto with a Mutex (authorization — the prompt — runs unlocked, only sign/encrypt/decrypt holds the lock) so an external NIP-55 signer never sees concurrent IPC ops; and Nip46PermissionAuthorizer serializes first-connect consent so two connects can't stack dialogs. Covered by BunkerRequestProcessorConcurrencyTest (crypto never overlaps; a blocked prompt doesn't stall another client's signing). Amethyst: SignerConsentCoordinator is now a shared pending StateFlow; one SignerConsentActivity observes it and shows the rich single-request dialog (1 pending) or a batched checkbox list with select-all + a Remember toggle + Allow/Deny selected (>1). Dismissing the sheet denies every still-open request (fail closed). Needs on-device validation (burst batching, no concurrent external-signer IPC, fail-closed on dismiss) — see the device checklist. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- ...026-07-16-nip46-signer-device-checklist.md | 30 ++- .../consent/SignerConsentActivity.kt | 191 +++++++++++++++--- .../consent/SignerConsentCoordinator.kt | 77 ++++--- amethyst/src/main/res/values/strings.xml | 9 + .../nip46/Nip46PermissionAuthorizer.kt | 41 +++- .../server/BunkerRequestProcessor.kt | 15 +- .../server/NostrConnectSignerService.kt | 37 +++- .../BunkerRequestProcessorConcurrencyTest.kt | 172 ++++++++++++++++ 8 files changed, 499 insertions(+), 73 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorConcurrencyTest.kt diff --git a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md index 885f69b607..c8ee2aedf5 100644 --- a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md +++ b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md @@ -109,13 +109,29 @@ that loop via `collectLatest`. - **FIXED — unbounded first-connect prompt.** `Nip46ConsentBridge.requestConnect` now has the same 120s `withTimeoutOrNull` as `requestOp`, so an ignored first-connect dialog can no longer wedge the loop forever. -- **Consent blocks other clients (bounded).** While one prompt is open, other - clients' requests queue in the 256-deep DROP_LATEST channel and, past that, - drop. Bounded by the 120s timeouts. A proper fix is to dispatch prompt-needing - requests to child jobs (keeping dedup/rate-limit/`decide()` on the loop - thread, serializing only the dialogs) so auto-allowed traffic keeps flowing — - deferred because it risks stacked dialogs + concurrent external-signer ops and - needs on-device validation. +- **FIXED — consent no longer blocks other clients (needs on-device + validation).** The service now fans each request out into a child coroutine + under a `Semaphore(maxConcurrentHandles=16)`; dedup/staleness/rate-limit stay on + the single consumer, only `handle()` runs concurrently. So a request awaiting a + prompt no longer stalls auto-allowed traffic, and several prompts can be pending + at once. Two guards keep this safe: (1) the identity signer's crypto is + serialized by `BunkerRequestProcessor.cryptoLock` — authorization (the prompt) + runs UNLOCKED, only the sign/encrypt/decrypt holds the lock — so an external + NIP-55 app never sees concurrent IPC ops; (2) first-connect consent is + serialized by `Nip46PermissionAuthorizer.connectLock` so two connects can't stack + dialogs. Per-op prompts batch: the shared `SignerConsentCoordinator.pending` + flow drives one dialog (1 pending) or a checkbox list (>1). Covered by + `BunkerRequestProcessorConcurrencyTest`, but the on-device paths below still need + a real run: + - [ ] **Burst batching:** a client fires several dangerous-kind requests at once + → one batched sheet with checkboxes + select-all, Allow/Deny selected, + "Remember" toggle. Approving a subset leaves the rest pending. + - [ ] **Auto-allowed keeps flowing:** while a prompt sits open, a REASONABLE + auto-allowed request from another app still gets signed and answered. + - [ ] **No concurrent external-signer ops:** with a NIP-55 external signer, two + approved requests do not drive overlapping IPC (they serialize). + - [ ] **Fail-closed on dismiss:** backing out of the batched sheet denies every + still-open request (not just the selected ones). - **Relay-set change cancels in-flight work.** A `logout` (or a new nostrconnect pairing) mutates the listen set → `collectLatest` restarts the service → cancels the in-flight `handle()`. Practical impact is low (a logout ACK is lost diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt index 549bf0ec03..cfb22ec85c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt @@ -40,13 +40,16 @@ import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface +import androidx.compose.material3.Switch 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 @@ -54,12 +57,14 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp @@ -76,41 +81,40 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler import com.vitorpamplona.quartz.utils.TimeUtils class SignerConsentActivity : ComponentActivity() { - private var token: String? = null - private var decided = false - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val token = intent.getStringExtra(SignerConsentCoordinator.EXTRA_TOKEN) - this.token = token - val info = token?.let { SignerConsentCoordinator.infoFor(it) } - if (token == null || info == null) { - finish() - return - } - setContent { AmethystTheme { - SignerConsentDialog( - info = info, - onGrant = { grant -> - decided = true - SignerConsentCoordinator.complete(token, grant) - finish() - }, - onDismiss = { - decided = true - SignerConsentCoordinator.cancel(token) - finish() - }, - ) + // The signer services requests concurrently, so more than one may await consent. Observe + // the shared queue: one request shows the rich dialog, several show a batched list. When + // the queue empties (all decided), close. + val pending by SignerConsentCoordinator.pending.collectAsStateWithLifecycle() + LaunchedEffect(pending.isEmpty()) { if (pending.isEmpty()) finish() } + when { + pending.isEmpty() -> Unit + pending.size == 1 -> { + val p = pending.first() + SignerConsentDialog( + info = p.info, + onGrant = { SignerConsentCoordinator.complete(p.token, it) }, + onDismiss = { SignerConsentCoordinator.complete(p.token, SignerOpGrant.DenyOnce) }, + ) + } + else -> + BatchedConsentDialog( + pending = pending, + onResolve = { tokens, grant -> SignerConsentCoordinator.completeAll(tokens, grant) }, + onDismiss = { SignerConsentCoordinator.denyAllPending() }, + ) + } } } } - override fun finish() { - if (!decided) token?.let { SignerConsentCoordinator.cancel(it) } - super.finish() + override fun onDestroy() { + super.onDestroy() + // Torn down for good (back / dismiss), not a config change: fail every still-open request closed. + if (isFinishing) SignerConsentCoordinator.denyAllPending() } } @@ -355,3 +359,136 @@ private fun SignerConsentDialog( } } } + +/** + * Shown when more than one request is awaiting consent at once (the signer services requests + * concurrently). Lists each with a checkbox — all selected by default — and resolves the selected + * ones together as Allow or Deny. "Remember" makes an Allow persist per-op ([SignerOpGrant.AllowForOp]); + * off is a one-time [SignerOpGrant.AllowOnce]. Requests left unselected stay pending and re-render + * (as this list, or the single-request dialog once one remains). + */ +@Composable +private fun BatchedConsentDialog( + pending: List, + onResolve: (tokens: List, grant: SignerOpGrant) -> Unit, + onDismiss: () -> Unit, +) { + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f + var selected by remember(pending.size) { mutableStateOf(pending.map { it.token }.toSet()) } + var rememberChoice by remember { mutableStateOf(true) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .heightIn(max = maxHeight), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column(modifier = Modifier.padding(vertical = 20.dp)) { + Text( + pluralStringResource(R.plurals.nip46_signer_batch_title, pending.size, pending.size), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 24.dp), + ) + TextButton( + onClick = { + selected = if (selected.size == pending.size) emptySet() else pending.map { it.token }.toSet() + }, + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 2.dp), + ) { + Text( + stringResource( + if (selected.size == pending.size) R.string.nip46_signer_batch_select_none else R.string.nip46_signer_batch_select_all, + ), + style = MaterialTheme.typography.labelLarge, + ) + } + + Column( + modifier = + Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + ) { + pending.forEach { p -> + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Checkbox( + checked = p.token in selected, + onCheckedChange = { on -> selected = if (on) selected + p.token else selected - p.token }, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + "${p.info.appletTitle} · ${p.info.operationSummary}", + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + ) + if (p.info.contentPreview.isNotBlank()) { + Text( + p.info.contentPreview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Switch(checked = rememberChoice, onCheckedChange = { rememberChoice = it }) + Text( + stringResource(R.string.nip46_signer_batch_remember), + style = MaterialTheme.typography.bodyMedium, + ) + } + + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { + val tokens = pending.filter { it.token in selected } + // Per-op remember uses each request's own op; one-time is a single AllowOnce. + if (rememberChoice) { + tokens.forEach { onResolve(listOf(it.token), SignerOpGrant.AllowForOp(it.info.op)) } + } else { + onResolve(tokens.map { it.token }, SignerOpGrant.AllowOnce) + } + }, + enabled = selected.isNotEmpty(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.nip46_signer_batch_allow, selected.size)) + } + OutlinedButton( + onClick = { onResolve(pending.filter { it.token in selected }.map { it.token }, SignerOpGrant.DenyOnce) }, + enabled = selected.isNotEmpty(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.nip46_signer_batch_deny, selected.size)) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt index 71fc5549d3..ed8e1f9c86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt @@ -28,6 +28,9 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -61,17 +64,29 @@ data class SignerConsentInfo( val previewTemplate: EventTemplate? = null, ) +/** One pending per-operation consent request, as the batched sheet renders it. */ +data class PendingConsent( + val token: String, + val info: SignerConsentInfo, +) + /** - * Bridges the broker to the per-operation signer consent UI. - * A dismissed dialog resolves to [SignerOpGrant.DenyOnce] — fails closed. + * Bridges the broker to the per-operation signer consent UI. The signer services requests + * concurrently (so their prompts can batch), so several requests can await consent at once: they all + * land in [pending], one [SignerConsentActivity] observes that list and shows a single-request dialog + * or a batched list, and each resolved token completes its own deferred. A dismissed/ignored request + * resolves to [SignerOpGrant.DenyOnce] — fails closed. */ object SignerConsentCoordinator { - private class Pending( - val info: SignerConsentInfo, - val deferred: CompletableDeferred, - ) + private val deferreds = ConcurrentHashMap>() + private val _pending = MutableStateFlow>(emptyList()) - private val pending = ConcurrentHashMap() + /** The live set of requests awaiting the user's decision; the Activity renders this. */ + val pending: StateFlow> = _pending + + // A stable notification id (one prompt notification for the whole batch, updated as requests + // arrive) so concurrent requests don't each post their own. + private val batchNotificationId = "nip46-signer-consent".hashCode() suspend fun requestConsent( context: Context, @@ -79,48 +94,54 @@ object SignerConsentCoordinator { ): SignerOpGrant { val token = UUID.randomUUID().toString() val deferred = CompletableDeferred() - pending[token] = Pending(info, deferred) + deferreds[token] = deferred + _pending.update { it + PendingConsent(token, info) } // Fast path when Amethyst already owns the foreground: open the dialog directly. When the app // is backgrounded this is silently dropped by Android 12+ BAL, so the full-screen-intent - // notification below is what actually surfaces the prompt. Wrapped because a blocked launch - // can throw on some OEMs rather than no-op. + // notification is what surfaces the prompt. Both are idempotent — the Activity is singleTop and + // observes [pending], and the notification uses a stable id, so concurrent requests just refresh + // the one prompt. Wrapped because a BAL-blocked launch can throw on some OEMs rather than no-op. runCatching { context.startActivity( Intent(context, SignerConsentActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .putExtra(EXTRA_TOKEN, token), + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP), ) } - - val notificationId = - SignerConsentNotifier.show( - context = context, - activityClass = SignerConsentActivity::class.java, - extraKey = EXTRA_TOKEN, - token = token, - titleRes = R.string.nip46_signer_notif_sign_title, - ) + SignerConsentNotifier.show( + context = context, + activityClass = SignerConsentActivity::class.java, + extraKey = EXTRA_TOKEN, + token = "nip46-signer-consent", + titleRes = R.string.nip46_signer_notif_sign_title, + ) return try { deferred.await() } finally { - pending.remove(token) - SignerConsentNotifier.cancel(context, notificationId) + deferreds.remove(token) + _pending.update { list -> list.filterNot { it.token == token } } + if (_pending.value.isEmpty()) SignerConsentNotifier.cancel(context, batchNotificationId) } } - fun infoFor(token: String): SignerConsentInfo? = pending[token]?.info - fun complete( token: String, grant: SignerOpGrant, ) { - pending[token]?.deferred?.complete(grant) + deferreds[token]?.complete(grant) } - fun cancel(token: String) { - pending[token]?.deferred?.complete(SignerOpGrant.DenyOnce) + fun completeAll( + tokens: Collection, + grant: SignerOpGrant, + ) { + tokens.forEach { complete(it, grant) } + } + + /** Deny every still-open request — used when the user dismisses the whole sheet. Fails closed. */ + fun denyAllPending() { + deferreds.values.forEach { it.complete(SignerOpGrant.DenyOnce) } } const val EXTRA_TOKEN = "napplet_signer_consent_token" diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 47e9bea0ca..83b1dda911 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -952,6 +952,15 @@ Sign for other apps Turn on signer Live + + %1$d signing request + %1$d signing requests + + Select all + Select none + Remember these for each app + Allow %1$d + Deny %1$d Recent activity No requests serviced yet. denied diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt index f09df51598..1eb84daa24 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt @@ -39,6 +39,8 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46ConnectDecision import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46RequestAuthorizer import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * Bridges the NIP-46 signer core to Amethyst's shared "Connected Apps" trust @@ -110,6 +112,12 @@ class Nip46PermissionAuthorizer( private val lastUsedThrottle = mutableMapOf() private val throttleLock = KmpLock() + // Serializes first-connect consent. The service now handles requests concurrently so per-op prompts + // can batch, but the connect prompt is a separate single dialog — this keeps two clients connecting + // at once from stacking two connect dialogs; the second waits for the first to resolve. A coroutine + // Mutex (not KmpLock) because the guarded region awaits a user dialog and must suspend, not block. + private val connectLock = Mutex() + /** The ledger coordinate for [clientPubKey] under this account. */ fun coordinateFor(clientPubKey: HexKey): String = coordinateFor(signerPubKey, clientPubKey) @@ -137,16 +145,31 @@ class Nip46PermissionAuthorizer( } val coordinate = coordinateFor(clientPubKey) - if (!ledger.hasPolicy(coordinate)) { - // First contact: ask the user (if a prompt is wired) which trust level to grant; a - // headless signer with no prompt falls back to the non-interactive default. - when (val consent = connectConsent?.invoke(coordinate, clientPubKey, request)) { - null -> ledger.setPolicy(coordinate, defaultPolicyOnConnect) - is AppConnectResult.Connected -> ledger.setPolicy(coordinate, consent.policy) - AppConnectResult.Blocked -> return Nip46ConnectDecision.Reject("blocked by user") - AppConnectResult.Cancelled -> return Nip46ConnectDecision.Reject("connection declined") + // Serialize first-contact consent so two concurrent connects don't stack dialogs. The + // hasPolicy re-check inside the lock also means a client that connected on another in-flight + // request isn't prompted twice. + val rejection = + connectLock.withLock { + if (ledger.hasPolicy(coordinate)) { + null + } else { + // First contact: ask the user (if a prompt is wired) which trust level to grant; a + // headless signer with no prompt falls back to the non-interactive default. + when (val consent = connectConsent?.invoke(coordinate, clientPubKey, request)) { + null -> { + ledger.setPolicy(coordinate, defaultPolicyOnConnect) + null + } + is AppConnectResult.Connected -> { + ledger.setPolicy(coordinate, consent.policy) + null + } + AppConnectResult.Blocked -> "blocked by user" + AppConnectResult.Cancelled -> "connection declined" + } + } } - } + if (rejection != null) return Nip46ConnectDecision.Reject(rejection) touchLastUsed(coordinate) onConnected?.invoke(clientPubKey, request) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt index 951a6ecc69..973f288b45 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt @@ -45,6 +45,8 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey import com.vitorpamplona.quartz.nip46RemoteSigner.ReadWrite import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * The signer/bunker side of NIP-46: turns a decrypted [BunkerRequest] from a @@ -72,6 +74,15 @@ class BunkerRequestProcessor( val relays: suspend () -> Set, val authorizer: Nip46RequestAuthorizer, ) { + /** + * Serializes the actual crypto ([signer] `sign`/`nip04|44_*`) across concurrent [process] calls. + * The service may run several requests at once so their consent prompts can batch, but the identity + * signer — especially an external NIP-55 app reached over IPC — must not see concurrent operations, + * so only the crypto runs under this lock. Authorization (which may open a user prompt and block for + * a long time) runs OUTSIDE the lock, so a pending prompt never stalls other clients' signing. + */ + private val cryptoLock = Mutex() + /** * Fulfils a single decrypted [request] sent by [clientPubKey], returning the * response to encrypt and send back. Never throws — signer/authorizer errors @@ -147,7 +158,9 @@ class BunkerRequestProcessor( // external signer is gone — so refuse rather than prompt or hang on a key we can't use. BunkerResponseError(request.id, ERROR_ACCOUNT_UNAVAILABLE) } else if (authorizer.authorize(clientPubKey, request)) { - block() + // Authorization ran unlocked (it may have blocked on a user prompt); the crypto itself runs + // under [cryptoLock] so concurrent authorized requests don't hit the signer at the same time. + cryptoLock.withLock { block() } } else { BunkerResponseError(request.id, ERROR_UNAUTHORIZED) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index ed7b05825b..c94e8bd684 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -33,8 +33,12 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Semaphore /** * Runs a NIP-46 remote signer ("bunker") for one account: subscribes to the @@ -97,6 +101,15 @@ class NostrConnectSignerService( * kept small so an app restart re-signs as little as possible (relays replay only this far back). */ val maxRequestAgeSeconds: Long = 30, + /** + * How many requests may be in-flight (past dedup/rate-limit) at once. Each request is handled in + * its own child coroutine so that a request awaiting a user consent prompt does NOT block other + * clients' auto-allowed traffic — and so several prompts can be pending together and be approved in + * one batch. Intake (dedup, staleness, rate-limit) stays on the single consumer; only [handle] fans + * out. The actual crypto is still serialized inside [BunkerRequestProcessor]. This bounds how many + * child coroutines (and pending prompts) can accumulate under a flood. + */ + val maxConcurrentHandles: Int = 16, /** * Event ids serviced in a previous run, used to seed the in-memory dedup set so a relay replaying * stored requests across an app restart is caught by EXACT event id — immune to client clock skew, @@ -155,6 +168,14 @@ class NostrConnectSignerService( return } + // supervisorScope: each request is handled in a child coroutine so a prompt awaiting the user + // doesn't block the loop or other clients; a failing child never tears down the loop or siblings. + supervisorScope { + runLoop() + } + } + + private suspend fun kotlinx.coroutines.CoroutineScope.runLoop() { val self = transportSigner.pubKey // Bounded + DROP_LATEST so a flood bounds memory instead of growing an unlimited queue. val events = Channel(capacity = maxQueue, onBufferOverflow = BufferOverflow.DROP_LATEST) @@ -182,6 +203,8 @@ class NostrConnectSignerService( // Seed the dedup set with ids serviced in a prior run so a relay replaying stored requests after // a restart is caught by exact id (see [initialSeen]). val seen = LinkedHashSet(initialSeen) + // Bounds how many requests can be in-flight (and how many prompts can be pending) at once. + val handleGate = Semaphore(maxConcurrentHandles) // Only ask relays for recent requests: kind-24133 is ephemeral, but relays that store it would // otherwise replay every old request each time we (re)subscribe. See [maxRequestAgeSeconds]. val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self)), since = TimeUtils.now() - maxRequestAgeSeconds) @@ -209,9 +232,21 @@ class NostrConnectSignerService( Log.w("NIP46Signer") { "rate-limited request from ${event.pubKey.take(8)}…" } continue } - handle(event) // Remember this id (persisted by the host) so a later restart won't re-service the replay. + // Done on the single consumer — BEFORE fanning out — because the host's seen-id store is + // not synchronized; a request we decided to service here should not be re-prompted after a + // restart even if it is ultimately denied (the in-memory `seen` already covers this run). onHandledId?.invoke(event.id) + // Acquire BEFORE launching so intake applies backpressure at the cap instead of spawning + // unbounded child coroutines; dedup/rate-limit above already ran on this single consumer. + handleGate.acquire() + launch { + try { + handle(event) + } finally { + handleGate.release() + } + } } } finally { client.unsubscribe(subId) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorConcurrencyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorConcurrencyTest.kt new file mode 100644 index 0000000000..f64a4fc7a6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorConcurrencyTest.kt @@ -0,0 +1,172 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip46RemoteSigner.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +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.BunkerResponseEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Concurrency guarantees the service relies on when it fans requests out into child coroutines so + * their consent prompts can batch: the identity signer's crypto must never run concurrently (an + * external NIP-55 app can't take overlapping IPC ops), while authorization — which may block on a + * user prompt for a long time — must NOT hold that lock, so a pending prompt can't stall other + * clients' signing. + */ +class BunkerRequestProcessorConcurrencyTest { + private val userPubKey = "a".repeat(64) + private val clientPubKey = "c".repeat(64) + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + + private fun signTemplate(id: String) = BunkerRequestSign(id, EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hi")) + + /** A signer whose sign() parks on [signGate] so tests can observe how many run at once. */ + private class GatedSigner( + pubKey: HexKey, + val signGate: CompletableDeferred, + ) : NostrSigner(pubKey) { + var inFlight = 0 + var maxConcurrent = 0 + var signCount = 0 + + val canned = Event(id = "e".repeat(64), pubKey = pubKey, createdAt = 1L, kind = 1, tags = emptyArray(), content = "s", sig = "f".repeat(128)) + + override fun isWriteable() = true + + @Suppress("UNCHECKED_CAST") + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T { + inFlight++ + maxConcurrent = maxOf(maxConcurrent, inFlight) + signGate.await() + inFlight-- + signCount++ + return canned as T + } + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "" + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "" + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "" + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "" + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw NotImplementedError() + + override suspend fun deriveKey(nonce: HexKey): HexKey = throw NotImplementedError() + + override suspend fun signPsbt(psbtHex: String): String = throw NotImplementedError() + + override fun hasForegroundSupport() = false + } + + /** authorize() parks on the deferred returned by [gateFor] (null = allow immediately). */ + private class GatedAuthorizer( + val gateFor: (BunkerRequest) -> CompletableDeferred?, + ) : Nip46RequestAuthorizer { + override suspend fun onConnect( + clientPubKey: HexKey, + request: BunkerRequestConnect, + ) = Nip46ConnectDecision.Accept("ack") + + override suspend fun authorize( + clientPubKey: HexKey, + request: BunkerRequest, + ): Boolean = gateFor(request)?.await() ?: true + } + + @Test + fun cryptoIsSerializedAcrossConcurrentAuthorizedRequests() = + runTest { + val signGate = CompletableDeferred() + val signer = GatedSigner(userPubKey, signGate) + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, GatedAuthorizer { null }) + + launch { processor.process(clientPubKey, signTemplate("1")) } + launch { processor.process(clientPubKey, signTemplate("2")) } + testScheduler.advanceUntilIdle() + + // Both were authorized instantly, but only one may be inside the signer at a time. + assertEquals(1, signer.inFlight, "only one sign holds the crypto lock") + assertEquals(1, signer.maxConcurrent, "crypto never overlapped") + + signGate.complete(Unit) + testScheduler.advanceUntilIdle() + assertEquals(2, signer.signCount, "both eventually signed, one after the other") + assertEquals(1, signer.maxConcurrent, "still never overlapped") + } + + @Test + fun aBlockedPromptDoesNotStallAnotherClientsSigning() = + runTest { + val signGate = CompletableDeferred().apply { complete(Unit) } // signing itself never blocks here + val signer = GatedSigner(userPubKey, signGate) + val prompt = CompletableDeferred() // stands in for a user consent dialog left open + val blocked = signTemplate("blocked") + val processor = + BunkerRequestProcessor(signer, { setOf(relay) }, GatedAuthorizer { if (it === blocked) prompt else null }) + + launch { processor.process(clientPubKey, blocked) } // parks in authorize(), never touching the lock + var fastResult: BunkerResponse? = null + launch { fastResult = processor.process(clientPubKey, signTemplate("fast")) } + testScheduler.advanceUntilIdle() + + // The auto-allowed request signed and returned while the prompt is still open. + assertTrue(fastResult is BunkerResponseEvent, "auto-allowed request completed while a prompt was pending") + assertEquals(1, signer.signCount) + + prompt.complete(true) + testScheduler.advanceUntilIdle() + assertEquals(2, signer.signCount, "the prompted request signs once approved") + } +} From fd5556609f9d5e9ea5959ae7413778927bf85091 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 21:51:37 +0000 Subject: [PATCH 44/52] fix(nip46): audit fixes on the new signer code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of this session's changes surfaced four issues: - Perms re-seeded on every re-pair. connectViaNostrConnect pre-granted the offer's declared ops outside the first-contact guard, so re-pairing an app overwrote per-op decisions the user had since changed (e.g. an op set to DENY came back as ALLOW). Now only on first contact. - Perms could silently grant sensitive ops. The nostrconnect flow shows no dialog (scan = consent), so the declared perms are never surfaced — yet seeding pre-granted everything except decrypt/deletion, which would silently allow config-overwrite (kinds 0/3) and other sensitive kinds. Tightened to only the ops REASONABLE already auto-allows, so pairing never exceeds the default policy; sensitive kinds still prompt on first use. - Batched-consent deny-all race. SignerConsentActivity.onDestroy denied every pending request when finishing; a request arriving as the sheet closed is owned by a freshly-launched instance, so it was wrongly denied. Removed — each dialog's onDismissRequest already fails closed, and the 120s bridge timeout backs it up. - Redundant work: batched-selection state keyed on list size (a new request in a same-size swap was unselectable) → key on the token set; connected-apps loader re-read loadPolicy per app when allPolicies() already carried it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../consent/SignerConsentActivity.kt | 16 ++++++------ .../model/nip46Signer/Nip46SignerState.kt | 25 +++++++++++-------- .../nip46/Nip46ConnectedAppsScreen.kt | 9 ++++--- .../server/NostrConnectSignerService.kt | 2 +- 4 files changed, 30 insertions(+), 22 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt index cfb22ec85c..679b21e994 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt @@ -110,12 +110,11 @@ class SignerConsentActivity : ComponentActivity() { } } } - - override fun onDestroy() { - super.onDestroy() - // Torn down for good (back / dismiss), not a config change: fail every still-open request closed. - if (isFinishing) SignerConsentCoordinator.denyAllPending() - } + // Dismissal is failed-closed at the source: each dialog's onDismissRequest (back / tap-outside) + // denies its own request(s). We deliberately do NOT deny-all in onDestroy — a request arriving as + // this Activity finishes is owned by a freshly-launched instance, and denying it here would race + // that instance and reject a legitimate request. A process kill falls back to the bridge's 120s + // timeout, which also fails closed. } @Composable @@ -374,7 +373,10 @@ private fun BatchedConsentDialog( onDismiss: () -> Unit, ) { val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f - var selected by remember(pending.size) { mutableStateOf(pending.map { it.token }.toSet()) } + val tokens = pending.map { it.token }.toSet() + // Re-seed (all selected) whenever the set of pending tokens actually changes — keying on size alone + // would leave a newly-arrived request unselectable when another resolves in the same frame. + var selected by remember(tokens) { mutableStateOf(tokens) } var rememberChoice by remember { mutableStateOf(true) } Dialog( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index 3b3c291f4e..2e5dffb06b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -37,7 +37,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent @@ -331,18 +330,24 @@ class Nip46SignerState( val reply = NostrConnectEvent.create(ack, offer.clientPubKey, transportSigner()) client.publish(reply, offer.relays) - // Register the app (the paste is the user's consent) and listen on its relays. + // Register the app (the paste is the user's consent) and listen on its relays. Only on + // FIRST contact — a re-pair must not overwrite trust-level or per-op decisions the user + // has since changed (e.g. an op they explicitly set to DENY). val coordinate = authorizer.coordinateFor(offer.clientPubKey) if (!ledger.hasPolicy(coordinate)) { ledger.setPolicy(coordinate, authorizer.defaultPolicyOnConnect) - } - // Honor the offer's `perms`: the app declared exactly what it needs and the user chose to - // pair it, so pre-grant those ops instead of prompting on first use. The two highest-risk - // classes stay gated even when declared — decryption (reveals private content) and deletion - // (kind 5) still prompt on first use, where the user sees full context. - Nip46PermissionAuthorizer.parsePerms(offer.perms).forEach { op -> - val gated = op is NostrSignerOp.Decrypt || (op is NostrSignerOp.SignKind && op.kind == DeletionEvent.KIND) - if (!gated) ledger.setOpDecision(coordinate, op, NostrOpDecision.ALLOW) + // Honor the offer's `perms`, but only the ops the REASONABLE policy already auto-allows. + // The nostrconnect flow has no dialog — the user scans a code and never sees the perms + // spelled out — so pre-granting anything sensitive (config-overwrite kinds 0/3, deletion, + // decryption, DMs, unknown kinds) would be a silent privilege grant. Those still prompt on + // first use, with full context. Seeding the safe set records the app's declared scope as + // explicit, revocable grants without ever exceeding the default policy. + Nip46PermissionAuthorizer.parsePerms(offer.perms).forEach { op -> + val safe = + op is NostrSignerOp.Encrypt || + (op is NostrSignerOp.SignKind && op.kind in NostrSignerPermissionLedger.REASONABLE_SIGN_KINDS) + if (safe) ledger.setOpDecision(coordinate, op, NostrOpDecision.ALLOW) + } } ledger.updateLastUsed(coordinate) // Persist the app's label + its relays so it survives a restart, then start listening now. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt index ad0f634374..fa3cdc90a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt @@ -392,16 +392,17 @@ internal fun nip46ClientSubtitle( private suspend fun loadNip46Apps(signerPubKey: HexKey): List { val store = Amethyst.instance.signerPermissionStore val clientStore = Amethyst.instance.nip46ClientStore + // allPolicies() already carries each app's policy — read it from the map instead of a second + // loadPolicy() call per app. return store .allPolicies() - .keys - .filter { Nip46PermissionAuthorizer.belongsTo(it, signerPubKey) } - .mapNotNull { coordinate -> + .filterKeys { Nip46PermissionAuthorizer.belongsTo(it, signerPubKey) } + .mapNotNull { (coordinate, policy) -> val clientPubKey = Nip46PermissionAuthorizer.clientPubKeyOf(coordinate) ?: return@mapNotNull null Nip46AppEntry( coordinate = coordinate, clientPubKey = clientPubKey, - policy = store.loadPolicy(coordinate), + policy = policy, info = clientStore.load(coordinate), lastUsedSeconds = store.loadLastUsed(coordinate), ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt index c94e8bd684..4be37a0b84 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -175,7 +175,7 @@ class NostrConnectSignerService( } } - private suspend fun kotlinx.coroutines.CoroutineScope.runLoop() { + private suspend fun CoroutineScope.runLoop() { val self = transportSigner.pubKey // Bounded + DROP_LATEST so a flood bounds memory instead of growing an unlimited queue. val events = Channel(capacity = maxQueue, onBufferOverflow = BufferOverflow.DROP_LATEST) From 62c82aef68e58764aea8bf6fddaf847772221144 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 21:54:20 +0000 Subject: [PATCH 45/52] fix(nip46): two batched-consent races found in review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An independent review of the concurrent-servicing changes found two real bugs (the quartz/authorizer concurrency core reviewed clean): - Notification TOCTOU. SignerConsentCoordinator did a non-atomic "if pending empty → cancel notification" in the resolving request's finally. A request arriving concurrently could post the shared full-screen-intent notification between another request's empty-check and its cancel, wiping the new request's only surface while backgrounded — it then sat unseen until the 120s timeout denied it. Add/show and remove/empty-check/cancel now run under one surfaceLock, so a live request's notification can't be cancelled out. - Batched selection re-seeded to all-selected on any pending-set change (fail-open). Because requests are serviced concurrently, the pending set changes under an open sheet; re-seeding silently re-checked deselected items and auto-checked newly-arrived requests, so "Allow selected" could grant ops the user deselected or never saw. Now seed once and reconcile incrementally (selected ∩ tokens): deselections survive and a new request is never auto-selected. Also default the batch "Remember" toggle off. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../consent/SignerConsentActivity.kt | 12 +++-- .../consent/SignerConsentCoordinator.kt | 53 ++++++++++++------- 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt index 679b21e994..b89c9de9a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt @@ -374,10 +374,14 @@ private fun BatchedConsentDialog( ) { val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f val tokens = pending.map { it.token }.toSet() - // Re-seed (all selected) whenever the set of pending tokens actually changes — keying on size alone - // would leave a newly-arrived request unselectable when another resolves in the same frame. - var selected by remember(tokens) { mutableStateOf(tokens) } - var rememberChoice by remember { mutableStateOf(true) } + // Seed all-selected ONCE for the initial batch the user opened. The signer services requests + // concurrently, so `tokens` can change under an open sheet; reconcile incrementally instead of + // re-seeding — drop resolved tokens but KEEP the user's deselections, and never auto-select a + // newly-arrived request. Otherwise a request landing (or resolving) mid-decision would silently + // re-check everything, and an "Allow selected" tap would grant ops the user deselected or never saw. + var selected by remember { mutableStateOf(tokens) } + LaunchedEffect(tokens) { selected = selected intersect tokens } + var rememberChoice by remember { mutableStateOf(false) } Dialog( onDismissRequest = onDismiss, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt index ed8e1f9c86..e2f68f31b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt @@ -31,6 +31,9 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -88,6 +91,12 @@ object SignerConsentCoordinator { // arrive) so concurrent requests don't each post their own. private val batchNotificationId = "nip46-signer-consent".hashCode() + // Guards the surface (post/cancel of the one shared notification) against the pending set so a + // concurrent arrival's post can't be clobbered by another request's teardown cancel. Without it, + // request A could read "pending now empty" and then cancel AFTER request B posted a fresh + // notification under the same id, leaving B with no UI while backgrounded (silent deny at timeout). + private val surfaceLock = Mutex() + suspend fun requestConsent( context: Context, info: SignerConsentInfo, @@ -95,33 +104,39 @@ object SignerConsentCoordinator { val token = UUID.randomUUID().toString() val deferred = CompletableDeferred() deferreds[token] = deferred - _pending.update { it + PendingConsent(token, info) } - // Fast path when Amethyst already owns the foreground: open the dialog directly. When the app - // is backgrounded this is silently dropped by Android 12+ BAL, so the full-screen-intent - // notification is what surfaces the prompt. Both are idempotent — the Activity is singleTop and - // observes [pending], and the notification uses a stable id, so concurrent requests just refresh - // the one prompt. Wrapped because a BAL-blocked launch can throw on some OEMs rather than no-op. - runCatching { - context.startActivity( - Intent(context, SignerConsentActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP), + surfaceLock.withLock { + _pending.update { it + PendingConsent(token, info) } + // Fast path when Amethyst already owns the foreground: open the dialog directly. When the app + // is backgrounded this is silently dropped by Android 12+ BAL, so the full-screen-intent + // notification is what surfaces the prompt. Both are idempotent — the Activity is singleTop and + // observes [pending], and the notification uses a stable id, so concurrent requests just + // refresh the one prompt. Wrapped because a BAL-blocked launch can throw rather than no-op. + runCatching { + context.startActivity( + Intent(context, SignerConsentActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP), + ) + } + SignerConsentNotifier.show( + context = context, + activityClass = SignerConsentActivity::class.java, + extraKey = EXTRA_TOKEN, + token = "nip46-signer-consent", + titleRes = R.string.nip46_signer_notif_sign_title, ) } - SignerConsentNotifier.show( - context = context, - activityClass = SignerConsentActivity::class.java, - extraKey = EXTRA_TOKEN, - token = "nip46-signer-consent", - titleRes = R.string.nip46_signer_notif_sign_title, - ) return try { deferred.await() } finally { deferreds.remove(token) - _pending.update { list -> list.filterNot { it.token == token } } - if (_pending.value.isEmpty()) SignerConsentNotifier.cancel(context, batchNotificationId) + surfaceLock.withLock { + // Remove + emptiness check + cancel are one critical section vs. another request's + // add + show, so a fresh notification is never cancelled out from under a live request. + val stillPending = _pending.updateAndGet { list -> list.filterNot { it.token == token } } + if (stillPending.isEmpty()) SignerConsentNotifier.cancel(context, batchNotificationId) + } } } From 5f723bf6071b756c3dbace7b0f3749f4a3a85775 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 21:58:08 +0000 Subject: [PATCH 46/52] feat(nip46): show the signing account on each batched-consent row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consent coordinator is process-wide, so a batched sheet can bundle requests for different logged-in accounts. The rows showed only the app + operation, not which identity would sign — against the "make it clear which account is signing" rule the single-request dialog already follows. Each row now carries the signing account's avatar + name, so a mixed-account batch is unambiguous. (The batch stays a compact triage list — it does not render a full NoteCompose preview per item the way the single-request dialog does.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../consent/SignerConsentActivity.kt | 26 +++++++++++++++++++ amethyst/src/main/res/values/strings.xml | 1 + 2 files changed, 27 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt index b89c9de9a4..e02b697ddd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button @@ -56,11 +57,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -73,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.service.call.CallSessionBridge +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.theme.AmethystTheme @@ -450,6 +454,28 @@ private fun BatchedConsentDialog( maxLines = 1, ) } + // WHICH account signs — the batch can bundle requests for different + // logged-in accounts (the coordinator is process-wide), so each row must + // say who it acts as, not just which app asked. + p.info.accountName?.let { account -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + RobohashFallbackAsyncImage( + robot = p.info.accountPubKey ?: account, + model = p.info.accountPicture, + contentDescription = null, + modifier = Modifier.size(16.dp).clip(CircleShape), + loadProfilePicture = true, + loadRobohash = true, + ) + Text( + stringResource(R.string.nip46_signer_batch_signing_as, account), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 83b1dda911..e66820789e 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -959,6 +959,7 @@ Select all Select none Remember these for each app + as %1$s Allow %1$d Deny %1$d Recent activity From 3a85a4f9821dd2bc4c687fb9663ed7c2c286f953 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 22:09:48 +0000 Subject: [PATCH 47/52] feat(nip46): per-account consent sheets + expandable per-item preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups on the batched-consent sheet: One account per sheet. The consent coordinator is process-wide, so a batch could bundle requests for different logged-in accounts. Instead of mixing them, the Activity now renders only the oldest-pending account's group; when that clears, the next account's requests render as their own sheet. The account moves to a header (avatar + "signing as ") since every row in a sheet now shares it. Dismissing denies only that account's group. Expandable per-item preview. Each batch row is collapsed to app · operation + a one-line excerpt; tapping it expands the full detail so the user can inspect exactly what they're signing/encrypting/decrypting — the unsigned event rendered as a NoteCompose (with the JSON toggle), or the encrypt/decrypt plaintext. Extracted that rich content block into a shared SignerConsentPreview reused by the single-request dialog and each expanded row. Removed the now-unused denyAllPending (dismissal is per-group / per-request). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../consent/SignerConsentActivity.kt | 298 ++++++++++-------- .../consent/SignerConsentCoordinator.kt | 5 - 2 files changed, 167 insertions(+), 136 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt index e02b697ddd..e9528737f2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.connectedApps.consent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -89,15 +90,22 @@ class SignerConsentActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { AmethystTheme { - // The signer services requests concurrently, so more than one may await consent. Observe - // the shared queue: one request shows the rich dialog, several show a batched list. When - // the queue empties (all decided), close. + // The signer services requests concurrently, so more than one may await consent. We never + // mix accounts in one sheet: render only the requests for the OLDEST-pending account as a + // group. When that account's group clears, the next account's requests render (a fresh + // per-account sheet). One request → the rich dialog; several → a batched list. When the + // whole queue empties, close. val pending by SignerConsentCoordinator.pending.collectAsStateWithLifecycle() + val group = + run { + val account = pending.firstOrNull()?.info?.accountPubKey + pending.filter { it.info.accountPubKey == account } + } LaunchedEffect(pending.isEmpty()) { if (pending.isEmpty()) finish() } when { - pending.isEmpty() -> Unit - pending.size == 1 -> { - val p = pending.first() + group.isEmpty() -> Unit + group.size == 1 -> { + val p = group.first() SignerConsentDialog( info = p.info, onGrant = { SignerConsentCoordinator.complete(p.token, it) }, @@ -106,9 +114,11 @@ class SignerConsentActivity : ComponentActivity() { } else -> BatchedConsentDialog( - pending = pending, + pending = group, onResolve = { tokens, grant -> SignerConsentCoordinator.completeAll(tokens, grant) }, - onDismiss = { SignerConsentCoordinator.denyAllPending() }, + // Dismissing denies only THIS account's group; other accounts' requests stay + // pending and render next as their own sheet. + onDismiss = { SignerConsentCoordinator.completeAll(group.map { it.token }, SignerOpGrant.DenyOnce) }, ) } } @@ -127,30 +137,10 @@ private fun SignerConsentDialog( onGrant: (SignerOpGrant) -> Unit, onDismiss: () -> Unit, ) { - var showRawData by remember { mutableStateOf(false) } var showMoreOptions by remember { mutableStateOf(false) } val scrollState = rememberScrollState() val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f - // Reuse the live AccountViewModel (via CallSessionBridge, the same handle CallActivity uses) to - // render the unsigned event as a real note preview — what it will actually look like. Best-effort: - // if the main Activity is gone (only the foreground signer service alive) we fall back to the JSON. - val accountViewModel = remember { CallSessionBridge.accountViewModel } - val previewNav = remember { EmptyNav() } - val previewNote = - remember(info, accountViewModel) { - val template = info.previewTemplate - val author = info.accountPubKey ?: accountViewModel?.account?.signer?.pubKey - if (template != null && author != null && accountViewModel != null) { - runCatching { - val unsigned = RumorAssembler.assembleRumor(author, template) - accountViewModel.createTempDraftNote(unsigned, LocalCache.getOrCreateUser(author)) - }.getOrNull() - } else { - null - } - } - Dialog( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false), @@ -205,66 +195,9 @@ private fun SignerConsentDialog( } } - val hasContent = previewNote != null || info.contentPreview.isNotBlank() || info.rawData.isNotBlank() - if (hasContent) { - Spacer(Modifier.height(12.dp)) - Surface( - modifier = - Modifier - .padding(horizontal = 24.dp) - .fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.medium, - ) { - Column(modifier = Modifier.padding(12.dp)) { - if (previewNote != null && accountViewModel != null) { - // The event rendered as it will look once signed. - NoteCompose( - baseNote = previewNote, - isQuotedNote = true, - quotesLeft = 0, - accountViewModel = accountViewModel, - nav = previewNav, - ) - } else if (info.contentPreview.isNotBlank()) { - Text( - "“${info.contentPreview}”", - style = MaterialTheme.typography.bodySmall, - ) - } - if (info.rawData.isNotBlank()) { - if (showRawData) { - Spacer(Modifier.height(8.dp)) - Box(modifier = Modifier.horizontalScroll(rememberScrollState())) { - SelectionContainer { - Text( - info.rawData, - style = - MaterialTheme.typography.labelSmall.copy( - fontFamily = FontFamily.Monospace, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - softWrap = false, - ) - } - } - } - TextButton( - onClick = { showRawData = !showRawData }, - contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp), - ) { - Text( - if (showRawData) { - stringResource(R.string.napplet_consent_hide_event) - } else { - stringResource(R.string.napplet_consent_show_event) - }, - style = MaterialTheme.typography.labelSmall, - ) - } - } - } - } + Spacer(Modifier.height(12.dp)) + Box(modifier = Modifier.padding(horizontal = 24.dp)) { + SignerConsentPreview(info) } Spacer(Modifier.height(16.dp)) @@ -363,6 +296,83 @@ private fun SignerConsentDialog( } } +/** + * The "what you're acting on" block: the unsigned event rendered as a real NoteCompose (what it will + * look like once signed) with a JSON toggle for sign/publish, or the raw content / decrypted plaintext + * for encrypt/decrypt. Shared by the single-request dialog and each expanded batch row so a user can + * always inspect exactly what they are signing/encrypting/decrypting. Best-effort: if the main Activity + * is gone (only the foreground signer service alive) the NoteCompose is skipped and the JSON stands in. + */ +@Composable +private fun SignerConsentPreview(info: SignerConsentInfo) { + var showRawData by remember(info) { mutableStateOf(false) } + val accountViewModel = remember { CallSessionBridge.accountViewModel } + val previewNav = remember { EmptyNav() } + val previewNote = + remember(info, accountViewModel) { + val template = info.previewTemplate + val author = info.accountPubKey ?: accountViewModel?.account?.signer?.pubKey + if (template != null && author != null && accountViewModel != null) { + runCatching { + val unsigned = RumorAssembler.assembleRumor(author, template) + accountViewModel.createTempDraftNote(unsigned, LocalCache.getOrCreateUser(author)) + }.getOrNull() + } else { + null + } + } + + val hasContent = previewNote != null || info.contentPreview.isNotBlank() || info.rawData.isNotBlank() + if (!hasContent) return + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + ) { + Column(modifier = Modifier.padding(12.dp)) { + if (previewNote != null && accountViewModel != null) { + NoteCompose( + baseNote = previewNote, + isQuotedNote = true, + quotesLeft = 0, + accountViewModel = accountViewModel, + nav = previewNav, + ) + } else if (info.contentPreview.isNotBlank()) { + Text("“${info.contentPreview}”", style = MaterialTheme.typography.bodySmall) + } + if (info.rawData.isNotBlank()) { + if (showRawData) { + Spacer(Modifier.height(8.dp)) + Box(modifier = Modifier.horizontalScroll(rememberScrollState())) { + SelectionContainer { + Text( + info.rawData, + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + softWrap = false, + ) + } + } + } + TextButton( + onClick = { showRawData = !showRawData }, + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp), + ) { + Text( + if (showRawData) { + stringResource(R.string.napplet_consent_hide_event) + } else { + stringResource(R.string.napplet_consent_show_event) + }, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } +} + /** * Shown when more than one request is awaiting consent at once (the signer services requests * concurrently). Lists each with a checkbox — all selected by default — and resolves the selected @@ -386,6 +396,8 @@ private fun BatchedConsentDialog( var selected by remember { mutableStateOf(tokens) } LaunchedEffect(tokens) { selected = selected intersect tokens } var rememberChoice by remember { mutableStateOf(false) } + // Tokens whose full preview (rendered event + JSON, or encrypt/decrypt plaintext) is expanded. + var expanded by remember { mutableStateOf(emptySet()) } Dialog( onDismissRequest = onDismiss, @@ -402,11 +414,40 @@ private fun BatchedConsentDialog( tonalElevation = 6.dp, ) { Column(modifier = Modifier.padding(vertical = 20.dp)) { - Text( - pluralStringResource(R.plurals.nip46_signer_batch_title, pending.size, pending.size), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(horizontal = 24.dp), - ) + // The sheet is single-account (grouped upstream), so the account is a header, not a + // per-row label. It says WHO every request in this sheet would act as. + val account = pending.first().info + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + account.accountName?.let { name -> + RobohashFallbackAsyncImage( + robot = account.accountPubKey ?: name, + model = account.accountPicture, + contentDescription = null, + modifier = Modifier.size(34.dp).clip(CircleShape), + loadProfilePicture = true, + loadRobohash = true, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + pluralStringResource(R.plurals.nip46_signer_batch_title, pending.size, pending.size), + style = MaterialTheme.typography.titleLarge, + ) + account.accountName?.let { name -> + Text( + stringResource(R.string.nip46_signer_batch_signing_as, name), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } TextButton( onClick = { selected = if (selected.size == pending.size) emptySet() else pending.map { it.token }.toSet() @@ -428,54 +469,49 @@ private fun BatchedConsentDialog( .verticalScroll(rememberScrollState()), ) { pending.forEach { p -> - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Checkbox( - checked = p.token in selected, - onCheckedChange = { on -> selected = if (on) selected + p.token else selected - p.token }, - ) - Column(modifier = Modifier.weight(1f)) { - Text( - "${p.info.appletTitle} · ${p.info.operationSummary}", - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, + val isExpanded = p.token in expanded + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + expanded = if (isExpanded) expanded - p.token else expanded + p.token + }.padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Checkbox handles its own tap (select); tapping elsewhere on the row expands. + Checkbox( + checked = p.token in selected, + onCheckedChange = { on -> selected = if (on) selected + p.token else selected - p.token }, ) - if (p.info.contentPreview.isNotBlank()) { + Column(modifier = Modifier.weight(1f)) { Text( - p.info.contentPreview, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + "${p.info.appletTitle} · ${p.info.operationSummary}", + style = MaterialTheme.typography.bodyMedium, maxLines = 1, ) - } - // WHICH account signs — the batch can bundle requests for different - // logged-in accounts (the coordinator is process-wide), so each row must - // say who it acts as, not just which app asked. - p.info.accountName?.let { account -> - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { - RobohashFallbackAsyncImage( - robot = p.info.accountPubKey ?: account, - model = p.info.accountPicture, - contentDescription = null, - modifier = Modifier.size(16.dp).clip(CircleShape), - loadProfilePicture = true, - loadRobohash = true, - ) + if (p.info.contentPreview.isNotBlank()) { Text( - stringResource(R.string.nip46_signer_batch_signing_as, account), - style = MaterialTheme.typography.labelSmall, + p.info.contentPreview, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, - overflow = TextOverflow.Ellipsis, ) } } + Icon( + if (isExpanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + if (isExpanded) { + Box(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) { + SignerConsentPreview(p.info) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt index e2f68f31b1..92730ea5b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt @@ -154,10 +154,5 @@ object SignerConsentCoordinator { tokens.forEach { complete(it, grant) } } - /** Deny every still-open request — used when the user dismisses the whole sheet. Fails closed. */ - fun denyAllPending() { - deferreds.values.forEach { it.complete(SignerOpGrant.DenyOnce) } - } - const val EXTRA_TOKEN = "napplet_signer_consent_token" } From 5d9d75e2c4c939eca42fc4525c1d4c08a476db96 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Thu, 16 Jul 2026 12:18:28 -0400 Subject: [PATCH 48/52] fix(accounts): serialize Account construction so concurrent loaders can't build twins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI login path (AccountSessionManager) and the background preloaders (RegisterAccounts, EventNotificationConsumer, loadAllWritableAccounts) race loadAccount's check-then-create on cold start and after account switches. Both saw an empty cache and both built an Account: the loser was never cancelled, so its Nip46SignerState kept answering bunker requests with a NostrSignerExternal no Activity ever registers an Amber launcher on — every sign failed 'No activity to launch from' while the twin raced error replies to NIP-46 clients and doubled every consent prompt. Reproduced on-device (f8ff11c7 constructed twice 10ms apart); gone after the lock. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../model/accountsCache/AccountCacheState.kt | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 31f0d14194..00b375d9bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -77,6 +77,9 @@ class AccountCacheState( ) { val accounts = MutableStateFlow>(emptyMap()) + /** Guards [loadAccount]'s check-then-create so concurrent callers can't build twin Accounts. */ + private val loadLock = Any() + fun removeAccount(pubkey: HexKey) { accounts.update { existingAccounts -> val oldValue = existingAccounts[pubkey] @@ -187,6 +190,22 @@ class AccountCacheState( val cached = accounts.value[signer.pubKey] if (cached != null) return cached + // Serialize construction: the UI login path and the always-on service's preload race + // to load the same account on cold start. Without the lock both see a null cache and + // both build an Account — the loser is never cancelled, leaving a zombie whose + // Nip46SignerState answers bunker requests with a NostrSignerExternal no Activity + // ever registers a launcher on (every sign fails "No activity to launch from"), + // while duplicating consent prompts and racing error replies to NIP-46 clients. + return synchronized(loadLock) { + accounts.value[signer.pubKey]?.let { return it } + createAccount(signer, accountSettings) + } + } + + private fun createAccount( + signer: NostrSigner, + accountSettings: AccountSettings, + ): Account { val signerWithClientTag = NostrSignerWithClientTag( inner = meterSigner(signer), From 1ebc4f0ada721cdc50af819b3eac972e3088c11c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 22:26:03 +0000 Subject: [PATCH 49/52] feat(nip46): informed consent for nostrconnect perms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the one real gap from the "first-class" review. The nostrconnect flow treated the paste/scan as blanket consent and, having no dialog, had to DROP the app's declared sensitive perms to avoid a silent grant. Now first contact via nostrconnect shows a connect sheet with the app's identity, the account it would act as, the exact permissions it declared (rendered human-readably — "Sign notes (kind 1)", "Encrypt messages", …), and a trust picker — before anything is published or granted. Approving connects and pre-grants exactly those declared ops (including sensitive ones the user just reviewed) unless they pick Paranoid; Cancel/Block declines and nothing is registered. A re-pair of a known app skips the sheet and keeps the user's prior trust/per-op decisions. Adds SignerConnectInfo.requestedPermissions + rendering, a Nip46ConsentBridge.requestNostrConnectConsent path, and a ConnectResult.Declined. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- ...026-07-16-nip46-signer-device-checklist.md | 6 +++ .../consent/SignerConnectActivity.kt | 29 +++++++++++ .../consent/SignerConnectCoordinator.kt | 6 +++ .../model/nip46Signer/Nip46ConsentBridge.kt | 33 ++++++++++++ .../model/nip46Signer/Nip46SignerState.kt | 50 +++++++++++-------- .../settings/nip46/Nip46SignerScreen.kt | 1 + amethyst/src/main/res/values/strings.xml | 2 + 7 files changed, 107 insertions(+), 20 deletions(-) diff --git a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md index c8ee2aedf5..32fb73b9d1 100644 --- a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md +++ b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md @@ -17,6 +17,12 @@ the client. `get_public_key`. - [ ] **NostrConnect flow**: client shows a `nostrconnect://` code → "Scan a code" on the signer screen pairs it and the signer turns on. +- [ ] **NostrConnect informed consent**: pairing a `nostrconnect://` offer that + carries `perms=` shows a connect sheet listing the app's requested + permissions (e.g. "Sign notes (kind 1)", "Decrypt messages") + a trust + picker BEFORE anything is granted. Approving pre-grants exactly those ops + (unless Paranoid); Cancel/Block declines and nothing is registered. A + re-pair of a known app skips the sheet and keeps prior decisions. - [ ] **Global scanner**: scan a `nostrconnect://` from the profile/search camera → lands on the signer screen and pairs. - [ ] **Deep link**: tap a `nostrconnect://` link (web/other app) → Amethyst diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt index c48b742878..950cb81816 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt @@ -182,6 +182,35 @@ private fun SignerConnectScreen( } } + // What the app declared it needs (nostrconnect `perms`) — so consent is informed, + // not a silent grant. Approving connects and pre-grants exactly these. + if (info.requestedPermissions.isNotEmpty()) { + Spacer(Modifier.height(16.dp)) + Surface( + modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + ) { + Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + stringResource(R.string.nip46_connect_requests_title), + style = MaterialTheme.typography.labelLarge, + ) + info.requestedPermissions.forEach { perm -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + MaterialSymbols.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text(perm, style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + Spacer(Modifier.height(16.dp)) HorizontalDivider() Spacer(Modifier.height(12.dp)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt index 38ea6d4aec..43059fa570 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt @@ -42,6 +42,12 @@ data class SignerConnectInfo( val accountName: String? = null, val accountPicture: String? = null, val accountPubKey: String? = null, + /** + * Human-readable permissions the app declared it needs (from a `nostrconnect://…?perms=` offer), + * shown so the user gives INFORMED consent before those ops are pre-granted. Empty for flows that + * carry no declaration (bunker connect), which just show the trust picker. + */ + val requestedPermissions: List = emptyList(), ) /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt index cb6344be8b..caf6de75ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt @@ -86,6 +86,39 @@ object Nip46ConsentBridge { } ?: AppConnectResult.Cancelled } + /** + * First-connect consent for the client-initiated (`nostrconnect://`) flow: like [requestConnect] + * but built from the pasted/scanned offer, and — crucially — it surfaces the app's declared + * [requestedOps] so the user gives informed consent before those ops are pre-granted. Returns the + * user's [AppConnectResult] (or [AppConnectResult.Cancelled] if the prompt is never answered). + */ + suspend fun requestNostrConnectConsent( + coordinate: String, + name: String?, + url: String?, + image: String?, + requestedOps: List, + ): AppConnectResult { + val context = Amethyst.instance.appContext + val title = name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app) + val domain = url?.ifBlank { null } ?: (Nip46PermissionAuthorizer.clientPubKeyOf(coordinate)?.take(12)?.plus("…") ?: "") + val face = accountFace(coordinate) + val info = + SignerConnectInfo( + appletTitle = title, + coordinate = coordinate, + domain = domain, + iconUrl = image, + accountName = face.name, + accountPicture = face.picture, + accountPubKey = face.pubKey, + requestedPermissions = requestedOps.map { it.label(context) }, + ) + return withTimeoutOrNull(CONSENT_TIMEOUT_MS) { + SignerConnectCoordinator.requestConnect(context, info) + } ?: AppConnectResult.Cancelled + } + /** Per-operation consent: describe the request (op + event preview) and await the user's grant. */ suspend fun requestOp( coordinate: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt index 2e5dffb06b..5466080f30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -23,8 +23,9 @@ package com.vitorpamplona.amethyst.model.nip46Signer import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision -import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -323,30 +324,36 @@ class Nip46SignerState( if (offer.relays.isEmpty()) return ConnectResult.NoRelays if (!signer.isWriteable()) return ConnectResult.NotWriteable + val coordinate = authorizer.coordinateFor(offer.clientPubKey) + val requestedOps = Nip46PermissionAuthorizer.parsePerms(offer.perms) + val firstContact = !ledger.hasPolicy(coordinate) + + // First contact: get informed consent — the app's identity, the perms it declared, and a trust + // level — BEFORE we publish the ack or grant anything. A re-pair keeps the existing trust and + // per-op decisions the user may have since changed (e.g. an op set to DENY), so it skips the prompt. + val grantedPolicy: AppSignerPolicy? = + if (firstContact) { + when (val result = Nip46ConsentBridge.requestNostrConnectConsent(coordinate, offer.name, offer.url, offer.image, requestedOps)) { + is AppConnectResult.Connected -> result.policy + AppConnectResult.Blocked, AppConnectResult.Cancelled -> return ConnectResult.Declined + } + } else { + null + } + return try { - // Echo the offer secret back to the client, authored by the transport key so the client - // learns THAT as our remote-signer pubkey (not our identity). + // Echo the offer secret back to the client — authored by the transport key so the client + // learns THAT as our remote-signer pubkey (not our identity). Only after consent. val ack = BunkerResponse(newSubId(), offer.secret, null) val reply = NostrConnectEvent.create(ack, offer.clientPubKey, transportSigner()) client.publish(reply, offer.relays) - // Register the app (the paste is the user's consent) and listen on its relays. Only on - // FIRST contact — a re-pair must not overwrite trust-level or per-op decisions the user - // has since changed (e.g. an op they explicitly set to DENY). - val coordinate = authorizer.coordinateFor(offer.clientPubKey) - if (!ledger.hasPolicy(coordinate)) { - ledger.setPolicy(coordinate, authorizer.defaultPolicyOnConnect) - // Honor the offer's `perms`, but only the ops the REASONABLE policy already auto-allows. - // The nostrconnect flow has no dialog — the user scans a code and never sees the perms - // spelled out — so pre-granting anything sensitive (config-overwrite kinds 0/3, deletion, - // decryption, DMs, unknown kinds) would be a silent privilege grant. Those still prompt on - // first use, with full context. Seeding the safe set records the app's declared scope as - // explicit, revocable grants without ever exceeding the default policy. - Nip46PermissionAuthorizer.parsePerms(offer.perms).forEach { op -> - val safe = - op is NostrSignerOp.Encrypt || - (op is NostrSignerOp.SignKind && op.kind in NostrSignerPermissionLedger.REASONABLE_SIGN_KINDS) - if (safe) ledger.setOpDecision(coordinate, op, NostrOpDecision.ALLOW) + if (grantedPolicy != null) { + ledger.setPolicy(coordinate, grantedPolicy) + // The user just reviewed and approved these declared perms, so honor them — including + // sensitive ones — unless they chose PARANOID (ask every time, pre-grant nothing). + if (grantedPolicy != AppSignerPolicy.PARANOID) { + requestedOps.forEach { ledger.setOpDecision(coordinate, it, NostrOpDecision.ALLOW) } } } ledger.updateLastUsed(coordinate) @@ -379,6 +386,9 @@ class Nip46SignerState( data object NotWriteable : ConnectResult + /** The user reviewed the connect request and declined (cancelled or blocked). */ + data object Declined : ConnectResult + data class Failed( val reason: String, ) : ConnectResult diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt index ec33faefa7..54e211f4db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -593,5 +593,6 @@ private fun describe( Nip46SignerState.ConnectResult.InvalidUri -> context.getString(R.string.nip46_signer_connect_invalid) Nip46SignerState.ConnectResult.NoRelays -> context.getString(R.string.nip46_signer_connect_no_relays) Nip46SignerState.ConnectResult.NotWriteable -> context.getString(R.string.nip46_signer_readonly) + Nip46SignerState.ConnectResult.Declined -> context.getString(R.string.nip46_signer_connect_declined) is Nip46SignerState.ConnectResult.Failed -> context.getString(R.string.nip46_signer_connect_failed, result.reason) } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e66820789e..348b08fecc 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -946,6 +946,8 @@ Not a valid nostrconnect:// link That link carries no relay to connect on Could not connect: %1$s + Connection declined + This app is requesting: This is a read-only account and cannot sign. Remote signer app Amethyst keeps a background connection (shown as an ongoing notification) so it can answer signing requests while closed. From 0f1ced868238e54a7401bb86d7c280398c6badac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 22:41:42 +0000 Subject: [PATCH 50/52] feat(cli): let `amy login --nostrconnect` request perms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Amethyst signer now honors a nostrconnect offer's `perms=` for informed consent, but the CLI client had no way to emit one — so the new consent flow couldn't be driven from `amy`, the project's interop-test harness. Add a `--perms` flag to `amy login --nostrconnect`, threaded into the offer through quartz's NostrConnectURI (which already builds/parses the param). Also keep the parsed perms on NostrConnect.Offer and surface a client's requested perms in `amy bunker connect`, so an operator can see what an app-side signer would be asked to pre-grant (the CLI bunker still auto-approves the operator's own key — perms is not a gate there). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../kotlin/com/vitorpamplona/amethyst/cli/Main.kt | 3 ++- .../amethyst/cli/commands/BunkerCommand.kt | 4 ++++ .../amethyst/cli/commands/NostrConnect.kt | 15 +++++++++++---- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 8ec28dac1c..3691a6e908 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -479,7 +479,8 @@ private fun printUsage() { | login bunker://PUBKEY?relay=…&secret=… sign through a remote bunker (mints a local | transport key; the account acts as PUBKEY) | login --nostrconnect [--relay URL[,URL…]] client-initiated: print a nostrconnect:// offer, - | [--name N] [--timeout SECS] wait for a signer to connect, then persist it + | [--name N] [--perms P] [--timeout SECS] wait for a signer to connect, then persist it + | (--perms sign_event:1,nip44_encrypt,… requests ops) | |Relays: `relay NOUN [add|remove|set|clear|list] …` (bare NOUN lists it) | NOUN = outbox|inbox|nip65 (kind:10002) dm (10050) key-package (10051) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt index d0b19cc38b..f0a04ed107 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -165,9 +165,13 @@ object BunkerCommand { "connected_to" to offer.clientPubkey, "pubkey" to ctx.identity.pubKeyHex, "relays" to offer.relays.map { it.url }, + "requested_perms" to offer.perms, ), ) System.err.println("[bunker] acked nostrconnect from ${offer.clientPubkey.take(8)}…; now servicing requests") + // The CLI bunker auto-approves the operator's own key, so `perms` isn't a gate here; we surface + // it so an interop operator can see what an app-side signer would have been asked to pre-grant. + offer.perms?.let { System.err.println("[bunker] client requested perms: $it") } serve(ctx, offer.relays, offer.secret, timeoutMs) return 0 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt index 6fda83bd5d..8c20a2b2b5 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt @@ -57,12 +57,14 @@ object NostrConnect { val relays: Set, val secret: String, val name: String?, + /** The client's self-declared permission request (`perms=sign_event:1,nip44_encrypt,…`), if any. */ + val perms: String? = null, ) - /** Parse `nostrconnect://?relay=…&secret=…&name=…` (percent-decoded). */ + /** Parse `nostrconnect://?relay=…&secret=…&perms=…&name=…` (percent-decoded). */ fun parseOffer(uri: String): Offer? { val parsed = NostrConnectURI.parseNostrConnect(uri) ?: return null - return Offer(parsed.clientPubKey, parsed.relays, parsed.secret, parsed.name) + return Offer(parsed.clientPubKey, parsed.relays, parsed.secret, parsed.name, parsed.perms) } private fun buildOffer( @@ -70,7 +72,8 @@ object NostrConnect { relays: Set, secret: String, name: String?, - ): String = NostrConnectURI.buildNostrConnect(clientPubkey, relays, secret, name = name) + perms: String?, + ): String = NostrConnectURI.buildNostrConnect(clientPubkey, relays, secret, perms = perms, name = name) /** * `amy login --nostrconnect [--relay URL[,URL…]] [--name N] [--timeout SECS]` @@ -91,12 +94,16 @@ object NostrConnect { if (relays.isEmpty()) return Output.error("bad_args", "no relays; pass --relay URL[,URL…]") val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 120L) * 1000 val name = args.flag("name") + // Optional NIP-46 permission request (`--perms sign_event:1,nip44_encrypt,…`). When present it + // rides along in the offer so a signer that honors `perms` (e.g. Amethyst's informed-consent + // sheet) can pre-grant exactly these ops. Blank is treated as absent. + val perms = args.flag("perms")?.ifBlank { null } val clientKey = KeyPair() val clientSigner = NostrSignerInternal(clientKey) val clientPub = clientKey.pubKey.toHexKey() val secret = KeyPair().privKey!!.toHexKey().take(32) - val offer = buildOffer(clientPub, relays, secret, name) + val offer = buildOffer(clientPub, relays, secret, name, perms) // Surface the offer immediately so the human/harness can paste it. System.err.println("[nostrconnect] paste this into your signer within ${timeoutMs / 1000}s:") From 13bc33ad70bd8682d2df6783c3a59de3db9ce270 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 23:22:07 +0000 Subject: [PATCH 51/52] feat(cli): gate `amy bunker` with --perms and interactive terminal approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the CLI bunker auto-approved every request (it hosts the operator's own key, so the pairing secret was the only gate). That left two NIP-46 signer behaviors the harness couldn't exercise: a signer that *rejects* disallowed ops, and a signer that asks a human live. Add two opt-in gates to both `amy bunker` and `amy bunker connect`: - `--perms sign_event:1,nip44_encrypt,…` restricts the signer to the listed ops; anything else is rejected. Fully scriptable/headless. This is the server-side mirror of the client's `--nostrconnect --perms`, so an interop run can now test a client against a rejecting signer. - `--interactive` keeps the bunker listening and prompts `y/N` on the terminal for any op the policy doesn't already allow, so the operator approves/rejects each request live. TTY-guarded (errors on a piped stdin), default-deny, prompts serialized by a mutex because the service dispatches requests concurrently. Composes with `--perms` (auto-allow the safe ops, prompt for the rest) — mirroring Amethyst's Reasonable policy. Neither flag → unchanged auto-approve behavior. Thin assembly: the perms parsing and request→op mapping are reused from commons (`Nip46PermissionAuthorizer.parsePerms` / `toSignerOp`), both already unit-tested there. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../com/vitorpamplona/amethyst/cli/Main.kt | 8 +- .../amethyst/cli/commands/BunkerCommand.kt | 132 ++++++++++++++++-- 2 files changed, 124 insertions(+), 16 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 3691a6e908..5aaba4ff9f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -473,9 +473,13 @@ private fun printUsage() { | |Remote signing (NIP-46): | bunker [--relay URL[,URL…]] run a remote signer for this (local-key) account; prints a - | [--secret S] [--timeout SECS] bunker:// uri and signs requests until interrupt/timeout + | [--secret S] [--timeout SECS] bunker:// uri and signs requests until interrupt/timeout. + | [--perms P] [--interactive] --perms sign_event:1,nip44_encrypt,… restricts which ops + | are allowed (rest rejected); --interactive prompts y/N on + | the terminal for anything not pre-allowed (needs a TTY). + | Default (neither): approve everything. | bunker connect NOSTRCONNECT-URI act as signer for a client's nostrconnect:// - | [--timeout SECS] offer (acks + services its requests) + | [--perms P] [--interactive] [--timeout SECS] offer (acks + services; same gating flags) | login bunker://PUBKEY?relay=…&secret=… sign through a remote bunker (mints a local | transport key; the account acts as PUBKEY) | login --nostrconnect [--relay URL[,URL…]] client-initiated: print a nostrconnect:// offer, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt index f0a04ed107..21ace10de9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -24,6 +24,9 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -31,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl 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.nip46RemoteSigner.NostrConnectURI @@ -38,10 +42,14 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46ConnectDecision import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46RequestAuthorizer import com.vitorpamplona.quartz.nip46RemoteSigner.server.NostrConnectSignerService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull /** - * `amy bunker [--relay URL[,URL…]] [--secret S] [--timeout SECS]` + * `amy bunker [--relay URL[,URL…]] [--secret S] [--perms P] [--interactive] [--timeout SECS]` * * Run a NIP-46 remote signer (a "bunker") for the active LOCAL account * (nak's `bunker`). Prints a `bunker://…` connection string, then listens on @@ -53,21 +61,39 @@ import kotlinx.coroutines.withTimeoutOrNull * remotely through this bunker. Long-running — stops at `--timeout` SECS or on * interrupt. * + * By default every request is approved (the CLI bunker hosts the operator's own + * key — the pairing secret is the gate). Two opt-in gates narrow that: + * - `--perms sign_event:1,nip44_encrypt,…` restricts the signer to the listed + * ops (anything else is rejected). Fully scriptable/headless. + * - `--interactive` prompts `y/N` on the terminal for any op the policy doesn't + * already allow, so the operator approves/rejects each one live. Requires a + * TTY; composes with `--perms` (perms auto-allow, prompt for the rest). + * * Thin assembly only: the request dispatch, the encrypted wrapper and the * subscribe/serve loop all live in quartz (`BunkerRequestProcessor`, - * `NostrConnectSignerService`, `NostrConnectEvent`); this file just wires the - * CLI account's `ctx.signer` into them and auto-approves every request (a - * headless bunker for the operator's own local key). + * `NostrConnectSignerService`, `NostrConnectEvent`); the permission parsing + + * request→op mapping are reused from commons (`Nip46PermissionAuthorizer`). */ object BunkerCommand { /** - * A headless bunker authorizer: validate the connect [secret] and then - * approve every operation. The CLI bunker hosts the operator's OWN key, so - * there is no separate user to prompt — the pairing secret is the gate. + * A bunker authorizer: validate the connect [secret], then decide each op. + * + * When [gated] is false (no `--perms`, no `--interactive`) every op is + * approved — the headless default for hosting the operator's own key. When + * gated, an op is allowed if [allowAllSignKinds] covers it or it is in + * [allowedOps]; otherwise it is denied, unless [interactive] is set, in which + * case the operator is prompted on the terminal. Prompts are serialized by + * [promptLock] because the service dispatches requests concurrently. */ private class CliAuthorizer( val secret: String, + val allowedOps: List, + val allowAllSignKinds: Boolean, + val interactive: Boolean, + val gated: Boolean, ) : Nip46RequestAuthorizer { + private val promptLock = Mutex() + override suspend fun onConnect( clientPubKey: HexKey, request: BunkerRequestConnect, @@ -81,7 +107,35 @@ object BunkerCommand { override suspend fun authorize( clientPubKey: HexKey, request: BunkerRequest, - ): Boolean = true + ): Boolean { + if (!gated) return true + // Metadata ops (ping / get_public_key / get_relays) map to no op and need no grant. + val op = request.toSignerOp() ?: return true + val statically = (op is NostrSignerOp.SignKind && allowAllSignKinds) || op in allowedOps + if (statically) return true + return if (interactive) prompt(clientPubKey, request) else false + } + + /** Ask the operator on the terminal. Serialized so concurrent requests don't interleave prompts. */ + private suspend fun prompt( + clientPubKey: HexKey, + request: BunkerRequest, + ): Boolean = + promptLock.withLock { + withContext(Dispatchers.IO) { + val kindInfo = (request as? BunkerRequestSign)?.let { " (kind:${it.event.kind})" } ?: "" + System.err.println("[bunker] ${clientPubKey.take(8)}… requests ${request.method}$kindInfo") + (request as? BunkerRequestSign)?.event?.content?.take(160)?.trim()?.let { + if (it.isNotEmpty()) System.err.println(" content: ${it.replace('\n', ' ')}") + } + System.err.print("[bunker] approve? [y/N] ") + System.err.flush() + val answer = readlnOrNull()?.trim()?.lowercase() + val approved = answer == "y" || answer == "yes" + System.err.println(if (approved) "[bunker] → approved" else "[bunker] → denied") + approved + } + } } suspend fun run( @@ -101,6 +155,7 @@ object BunkerCommand { ): Int { val args = Args(rest) val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } + interactiveTtyError(args)?.let { return it } val accountError = checkHostable(dataDir) if (accountError != null) return accountError @@ -127,7 +182,9 @@ object BunkerCommand { ) System.err.println("[bunker] listening as ${self.take(8)}… on ${relays.size} relay(s); paste the bunker:// uri into `amy login`") - serve(ctx, relays, secret, timeoutMs) + val authorizer = buildAuthorizer(args, secret) + logPolicy(args) + serve(ctx, relays, authorizer, timeoutMs) return 0 } } @@ -144,6 +201,7 @@ object BunkerCommand { ): Int { val args = Args(rest) val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } + interactiveTtyError(args)?.let { return it } val uri = args.positional(0, "nostrconnect-uri") val offer = NostrConnect.parseOffer(uri) ?: return Output.error("bad_args", "not a valid nostrconnect:// uri") val accountError = checkHostable(dataDir) @@ -169,11 +227,13 @@ object BunkerCommand { ), ) System.err.println("[bunker] acked nostrconnect from ${offer.clientPubkey.take(8)}…; now servicing requests") - // The CLI bunker auto-approves the operator's own key, so `perms` isn't a gate here; we surface - // it so an interop operator can see what an app-side signer would have been asked to pre-grant. + // Surface what the client asked for; whether it's honored depends on this bunker's own + // --perms/--interactive gate (below), not on the client's self-declared `perms`. offer.perms?.let { System.err.println("[bunker] client requested perms: $it") } - serve(ctx, offer.relays, offer.secret, timeoutMs) + val authorizer = buildAuthorizer(args, offer.secret) + logPolicy(args) + serve(ctx, offer.relays, authorizer, timeoutMs) return 0 } } @@ -186,18 +246,62 @@ object BunkerCommand { null } + /** + * `--interactive` prompts the operator on the terminal, so it needs a real TTY; a piped/headless + * stdin would make [readlnOrNull] return null and silently deny everything. Fail fast instead. + */ + private fun interactiveTtyError(args: Args): Int? = + if (args.bool("interactive") && System.console() == null) { + Output.error("no_tty", "--interactive needs a terminal (stdin/stdout is not a TTY); use --perms for headless gating") + } else { + null + } + + /** Builds the request gate from `--perms` / `--interactive` (both absent → approve everything). */ + private fun buildAuthorizer( + args: Args, + secret: String, + ): CliAuthorizer { + val perms = args.flag("perms")?.ifBlank { null } + val interactive = args.bool("interactive") + // parsePerms drops a bare `sign_event` (Amethyst grants per kind), so detect it here for "any kind". + val allowAllSignKinds = + perms + ?.split(',') + ?.any { it.trim().lowercase() == "sign_event" || it.trim().lowercase() == "sign" } ?: false + return CliAuthorizer( + secret = secret, + allowedOps = Nip46PermissionAuthorizer.parsePerms(perms), + allowAllSignKinds = allowAllSignKinds, + interactive = interactive, + gated = perms != null || interactive, + ) + } + + /** Tell the operator which gate is active, so an unexpectedly-restrictive run is obvious. */ + private fun logPolicy(args: Args) { + val perms = args.flag("perms")?.ifBlank { null } + val interactive = args.bool("interactive") + when { + perms != null && interactive -> System.err.println("[bunker] gate: auto-allow [$perms], prompt for the rest") + perms != null -> System.err.println("[bunker] gate: allow only [$perms], reject the rest") + interactive -> System.err.println("[bunker] gate: prompt on the terminal for every op") + else -> System.err.println("[bunker] gate: auto-approve every request (secret is the only gate)") + } + } + /** Subscribe for kind:24133 requests addressed to us and service them until timeout/interrupt. */ private suspend fun serve( ctx: Context, relays: Set, - secret: String, + authorizer: CliAuthorizer, timeoutMs: Long?, ) { val processor = BunkerRequestProcessor( signer = ctx.signer, relays = { relays }, - authorizer = CliAuthorizer(secret), + authorizer = authorizer, ) val service = NostrConnectSignerService( From 98c53ed9070ae72e396a40ec7a0a27d15185287b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 15:23:42 +0000 Subject: [PATCH 52/52] docs(nip46): record the amy CLI interop gates in the signer checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document this session's CLI additions in the living NIP-46 device checklist (no separate plan doc for two flags — they're covered by `amy --help`): - a "CLI interop driver (amy)" section covering amy on both sides — client (login bunker:// / --nostrconnect [--perms]) and signer (bunker --perms / --interactive) — as the reproducible interop harness. - a repro command on the NostrConnect informed-consent item: `amy login --nostrconnect --perms sign_event:1,nip44_encrypt` emits a perms-carrying offer that drives the app's consent sheet. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- ...026-07-16-nip46-signer-device-checklist.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md index 32fb73b9d1..efbd3507bb 100644 --- a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md +++ b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md @@ -23,6 +23,8 @@ the client. picker BEFORE anything is granted. Approving pre-grants exactly those ops (unless Paranoid); Cancel/Block declines and nothing is registered. A re-pair of a known app skips the sheet and keeps prior decisions. + Repro: `amy login --nostrconnect --perms sign_event:1,nip44_encrypt` + prints an offer that carries exactly those perms (see CLI interop driver). - [ ] **Global scanner**: scan a `nostrconnect://` from the profile/search camera → lands on the signer screen and pairs. - [ ] **Deep link**: tap a `nostrconnect://` link (web/other app) → Amethyst @@ -105,6 +107,30 @@ Pair + sign + nip44 encrypt/decrypt + logout against each: - [ ] Nostrudel - [ ] snort / other NIP-46 client +## CLI interop driver (`amy`) — added 2026-07-17 +The `cli` module (`amy`) drives the same quartz/commons code, so it plays either +side of every NIP-46 flow for reproducible interop tests without a second phone. +All of it reuses `Nip46PermissionAuthorizer.parsePerms` / `toSignerOp` — no +protocol logic in `cli`. See `amy --help` (the `Remote signing (NIP-46)` block). + +Amy as the **client** (Amethyst is the signer): +- `amy login bunker://…` — pair against Amethyst's advertised `bunker://`, then + every `amy` signing verb routes through it. Surfaces `auth_url` challenges to + stderr, so it completes even when Amethyst defers consent. +- `amy login --nostrconnect [--perms sign_event:1,nip44_encrypt,…]` — mint an + offer for Amethyst to scan. `--perms` is what exercises the app's + **informed-consent** sheet (the offer carries the declared ops). + +Amy as the **signer** (Amethyst, or any client, is the client): +- `amy bunker` — headless auto-approve signer for the operator's own key. +- `amy bunker --perms sign_event:1,nip44_encrypt` — restricted signer: allows + only the listed ops, **rejects** the rest. Use to test how the app-as-client + handles a signer that says no. +- `amy bunker --interactive` — keeps listening and prompts `y/N` per request on + the terminal (TTY-only, default-deny, prompts serialized). Composes with + `--perms` (auto-allow the listed ops, prompt for the rest = the "Reasonable" + policy on the CLI). + ## Audit findings — known limitations (2026-07-16) An adversarial review of the signer logic surfaced these. The head-of-line