mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
+36
-9
@@ -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.
|
||||
|
||||
+37
-4
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user