From f0c21f35135235b0e7bad44b242a29eaf7ead05a Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 17 Jul 2026 17:09:59 -0400 Subject: [PATCH] fix(nip46): remote-signer pubKey is the user identity, not the transport key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../com/vitorpamplona/amethyst/cli/Context.kt | 6 ++++- .../amethyst/cli/commands/ConcordCommands.kt | 10 ++----- .../desktop/account/AccountManager.kt | 5 ++++ .../quartz/nip01Core/signers/NostrSigner.kt | 9 ++++++- .../signer/NostrSignerRemote.kt | 27 +++++++++++++++++++ .../signer/NostrSignerRemoteIsolationTest.kt | 26 ++++++++++++++++++ 6 files changed, 73 insertions(+), 10 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 00aa30c9ad..e43419f20d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -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()) /** diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 1f7ed423a5..e0a145c1bf 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -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().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 } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt index 068b5d1770..549847e8b0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -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 = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt index fd5fd2e0d6..70836a44b9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt @@ -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 sign(ev: EventTemplate): T = sign(ev.createdAt, ev.kind, ev.tags, ev.content) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index 38ca469718..5ca77a0410 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -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) { + // Cache it so `pubKey` reflects the real identity from here on (self-encryption etc.). + resolvedUserPubkey = result.result.pubkey return result.result.pubkey } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt index f86af44b03..5ea4738247 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemoteIsolationTest.kt @@ -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()