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()