fix(nip46): remote-signer pubKey is the user identity, not the transport key

NostrSignerRemote extended NostrSigner(signer.pubKey), where `signer` is the
ephemeral NIP-46 transport keypair — so `pubKey` returned the transport key,
not the user's identity. Every self-encryption / self-authorship site keys off
`signer.pubKey`, so for bunker accounts this silently broke:
  - private NIP-51 lists (private bookmarks / mute / follows / hashtags) and
    NIP-37 drafts — an `if (signer.pubKey != event.pubKey)` guard short-circuits
    (desktop: private bookmarks always empty);
  - NIP-44 self-encrypted data (Concord list, Cashu) sealed to / read against
    the wrong peer key.
Android is unaffected (no bunker path); desktop and CLI were affected.

Make `NostrSigner.pubKey` open and have `NostrSignerRemote` return the
bunker-resolved user key: `getPublicKey()` now caches it, and `bindUserPubkey()`
sets it eagerly for a reloaded account / stored identity. Internal transport
(the response-subscription `p` filter, request addressing) keeps using the
transport keypair explicitly, so it is unchanged. No-op for local/external
signers, where signer.pubKey already equals the account key.

Wired: desktop AccountManager.loadBunkerAccount binds the resolved pubkey; CLI
Context binds identity.pubKeyHex. amy's Concord-list decrypt workaround is
dropped — `newest.decrypt(ctx.signer)` now works for a bunker. Verified live:
`amy concord import` over a bunker account decrypts the kind-13302 list and
recovers Soapbox heldRoots [0,1].

Plan: quartz/plans/2026-07-17-nip46-remote-signer-self-pubkey.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-07-17 17:09:59 -04:00
co-authored by Claude Opus 4.8
parent a96dd12a49
commit f0c21f3513
6 changed files with 73 additions and 10 deletions
@@ -221,7 +221,11 @@ class Context(
secret = b.connectSecret,
// Bunker requires web authorization: surface the URL; the request keeps waiting.
onAuthUrl = { url -> System.err.println("[nip46] authorize this request in a browser, then it will continue:\n $url") },
)
).also {
// signer.pubKey must be the USER identity, not the ephemeral transport key, so
// self-encryption/decryption (Concord list, private NIP-51 lists) uses the right peer.
it.bindUserPubkey(identity.pubKeyHex)
}
} ?: NostrSignerInternal(identity.keyPair())
/**
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.cli.stores.ConcordStore
import com.vitorpamplona.amethyst.cli.stores.StoredCommunity
import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -136,20 +135,15 @@ object ConcordCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val relays = (ctx.outboxRelays() + ctx.bootstrapRelays())
// Filter by the ACCOUNT identity, not ctx.signer.pubKey — for a bunker the signer's pubKey
// is the ephemeral NIP-46 transport key, not the user's identity.
val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(ctx.identity.pubKeyHex))
val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(ctx.signer.pubKey))
val events = ctx.drain(relays.associateWith { listOf(filter) }).map { it.second }
val newest =
events.filterIsInstance<ConcordCommunityListEvent>().maxByOrNull { it.createdAt }
?: return Output.error("not_found", "no kind-13302 Concord list published by this account").let { 1 }
// Decrypt with the ACCOUNT identity as the NIP-44 self-peer. `newest.decrypt(signer)` uses
// `signer.pubKey`, which for a bunker is the ephemeral transport key, not the identity the
// list is self-encrypted to — so decrypt manually against ctx.identity.pubKeyHex.
val entries =
try {
ConcordCommunityList.decode(ctx.signer.nip44Decrypt(newest.content, ctx.identity.pubKeyHex))
newest.decrypt(ctx.signer)
} catch (e: Exception) {
return Output.error("decrypt_failed", "could not decrypt kind-13302: ${e.message}").let { 1 }
}
@@ -346,6 +346,11 @@ class AccountManager internal constructor(
remoteSigner.getPublicKey()
}
// Bind the identity so signer.pubKey is the USER key, not the ephemeral transport key —
// otherwise self-encryption (private NIP-51 lists, drafts, …) keys off the wrong pubkey.
// Idempotent with the getPublicKey() branch above, which already caches the same value.
remoteSigner.bindUserPubkey(pubKeyHex)
val resolvedNpub = npub ?: pubKeyHex.hexToByteArray().toNpub()
val state =
@@ -27,8 +27,15 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
abstract class NostrSigner(
val pubKey: HexKey,
pubKey: HexKey,
) {
/**
* The account's own identity pubkey. `open` because a NIP-46 remote signer resolves it
* from the bunker (`get_public_key`) rather than from a local key it holds — see
* `NostrSignerRemote`, whose transport keypair is deliberately NOT the user identity.
*/
open val pubKey: HexKey = pubKey
abstract fun isWriteable(): Boolean
suspend fun <T : Event> sign(ev: EventTemplate<T>): T = sign(ev.createdAt, ev.kind, ev.tags, ev.content)
@@ -70,6 +70,31 @@ class NostrSignerRemote(
*/
val onAuthUrl: ((String) -> Unit)? = null,
) : NostrSigner(signer.pubKey) {
// The user's real identity, resolved from the bunker via `get_public_key`. The constructor
// `signer` is the ephemeral NIP-46 TRANSPORT keypair, NOT the user — so until this is bound,
// `pubKey` falls back to the transport key. Every self-encryption / self-authorship site keys
// off `pubKey`, so leaving it as the transport key silently breaks private NIP-51 lists, NIP-37
// drafts, Concord list decryption, etc. for bunker accounts. Bound either eagerly from a saved
// identity ([bindUserPubkey]) or lazily by the first [getPublicKey] call.
private var resolvedUserPubkey: HexKey? = null
/**
* The account identity. Returns the bunker-resolved user key once known, else the transport
* key. Internal NIP-46 transport (the response-subscription `p` filter, request addressing)
* deliberately uses `signer.pubKey`/`remotePubkey` directly and is unaffected by this.
*/
override val pubKey: HexKey
get() = resolvedUserPubkey ?: signer.pubKey
/**
* Bind the user's identity pubkey when it is already known (e.g. a persisted bunker account
* reloaded from disk, or the CLI's stored identity) so `pubKey` is correct without a
* `get_public_key` round-trip.
*/
fun bindUserPubkey(userPubkey: HexKey) {
resolvedUserPubkey = userPubkey
}
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private val manager =
@@ -292,6 +317,8 @@ class NostrSignerRemote(
)
if (result is SignerResult.RequestAddressed.Successful<PublicKeyResult>) {
// Cache it so `pubKey` reflects the real identity from here on (self-encryption etc.).
resolvedUserPubkey = result.result.pubkey
return result.result.pubkey
}
@@ -119,6 +119,32 @@ class NostrSignerRemoteIsolationTest {
private val generalRelay = NormalizedRelayUrl("wss://relay.damus.io/")
private val validHex = "a".repeat(64)
@Test
fun pubKeyFallsBackToTransportKeyUntilBoundThenReturnsUserIdentity() {
val trackingClient = TrackingNostrClient()
val ephemeralSigner = NostrSignerInternal(KeyPair())
val userIdentity = "b".repeat(64)
val remote =
NostrSignerRemote(
signer = ephemeralSigner,
remotePubkey = validHex,
relays = setOf(bunkerRelay),
client = trackingClient,
)
// Before the user identity is known, pubKey is the ephemeral transport key — NOT the
// remotePubkey (which is the bunker's addressing key) and not yet the user's identity.
assertEquals(ephemeralSigner.pubKey, remote.pubKey)
// Once bound (mirrors a reloaded account / a cached get_public_key), pubKey is the user key.
remote.bindUserPubkey(userIdentity)
assertEquals(userIdentity, remote.pubKey)
// The transport keypair used for NIP-46 addressing is untouched by the binding.
assertEquals(ephemeralSigner.pubKey, remote.signer.pubKey)
}
@Test
fun subscriptionFilterTargetsOnlyBunkerRelays() {
val trackingClient = TrackingNostrClient()