mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
co-authored by
Claude Opus 4.8
parent
a96dd12a49
commit
f0c21f3513
@@ -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 }
|
||||
}
|
||||
|
||||
+5
@@ -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 =
|
||||
|
||||
+8
-1
@@ -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)
|
||||
|
||||
+27
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+26
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user