From cceac4478a7e2dfc3ce3931b89ab43272b7dd17a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:15:44 +0000 Subject: [PATCH] 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