feat(nip46): rotate the bunker transport key as the anti-spam remedy

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
This commit is contained in:
Claude
2026-07-17 15:18:27 +00:00
parent 2332384897
commit cceac4478a
3 changed files with 79 additions and 15 deletions
@@ -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<Set<NormalizedRelayUrl>> =
@@ -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<Boolean> 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.
@@ -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<String?>(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))
}
},
)
}
// ----------------------------------------------------------------------------
+6 -2
View File
@@ -913,8 +913,12 @@
<string name="nip46_signer_bunker_uri_hint">Paste this into another app to connect it to your key.</string>
<string name="nip46_signer_copy">Copy</string>
<string name="nip46_signer_copied">Bunker address copied</string>
<string name="nip46_signer_regenerate">New secret</string>
<string name="nip46_signer_regenerated">Generated a new pairing secret</string>
<string name="nip46_signer_regenerate">New address</string>
<string name="nip46_signer_regenerated">Generated a new bunker address</string>
<string name="nip46_signer_rotate_confirm_title">Generate a new address?</string>
<string name="nip46_signer_rotate_confirm_message">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.</string>
<string name="nip46_signer_rotate_confirm_button">Generate</string>
<string name="nip46_signer_cancel">Cancel</string>
<string name="nip46_signer_connect_label">Connect an app</string>
<string name="nip46_signer_connect_hint">Paste a nostrconnect:// link</string>
<string name="nip46_signer_connect_button">Connect</string>