NostrSignerRemote.pubKey returns the ephemeral NIP-46 transport key, not the verified user identity, so every self-encryption / self-authorship site that uses signer.pubKey as "myself" breaks for bunker accounts. Verified impact: Android unaffected (no bunker path); desktop private NIP-51 lists (private bookmarks/mute/follows) and NIP-37 drafts silently empty, Cashu self-encryption sealed to the wrong peer; CLI the same incl. `concord list`. Records the two failure modes, the affected call sites, and the fix direction (resolve pubKey to the user key via get_public_key while pinning transport uses to the transport keypair). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8.1 KiB
NIP-46 remote signer: pubKey must be the user identity, not the transport key
Date: 2026-07-17
Module: quartz (core fix) + desktopApp / cli (affected front ends)
Status: design / not started
Severity: correctness — private data invisible/corrupted for bunker (NIP-46) accounts on desktop + CLI. Android unaffected.
The bug
NostrSignerRemote extends NostrSigner(signer.pubKey)
(quartz/.../nip46RemoteSigner/signer/NostrSignerRemote.kt:54-72), where the
constructor arg signer: NostrSignerInternal is the ephemeral NIP-46
transport keypair. NostrSigner.pubKey is a plain val never reassigned. So
for a bunker account:
NostrSignerRemote.pubKey == transport key T (ephemeral, per-connection)
the user's real identity == user key U (only via get_public_key RPC)
events the bunker signs have pubKey == U
Every site that treats signer.pubKey as "myself" therefore uses T instead of
U. Two failure modes:
Mode A — authorship guard short-circuits (unconditional failure)
Sites that gate on signer.pubKey == event.pubKey. Event was signed by the
bunker (pubKey == U), signer.pubKey == T, so the guard blocks decryption
before any key math:
nip51Lists/PrivateTagArrayEvent.kt:44-66—decrypt()throwsUnauthorizedDecryptionException;privateTags()returnsnull.nip51Lists/PrivateReplaceableTagArrayEvent.kt:44-53— same guard.nip37Drafts/DraftWrapEvent.kt:52—canDecrypt = signer.pubKey == pubKey→ always false.
Covers all private NIP-51 lists (private bookmarks, the private half of the mute list, private follows/people lists, private hashtag/geohash lists…) and NIP-37 drafts. Fails even for data Amethyst itself wrote through the bunker (written as U, read back with T). Independent of key-stability nuances.
Mode B — wrong self-peer / wrong author filter (no guard)
Sites that self-encrypt directly to signer.pubKey:
nip51Lists/encryption/PrivateTagsInContent.kt:41,57,64,74— the crypto under the guarded lists (nip44Encrypt(…, signer.pubKey)/decrypt(…, signer.pubKey)).concord/cord02Community/ConcordCommunityListEvent.kt:56,74+ConcordCommunityList.kt:170,222— Concord list self-encryption.nip60Cashu/token/CashuTokenEvent.kt:74,quote/CashuMintQuoteEvent.kt:136.amethyst/.../nip78AppSpecific/AppSpecificState.kt:60(Android-only file → moot).
The bunker derives ECDH(u, T) instead of ECDH(u, U): standard-client content
(peer U) fails MAC; content Amethyst writes is sealed to peer T, unreadable by
every other client and by the same user's local-key login. Author filters like
authors = listOf(signer.pubKey) also query T, so the user's own U-authored
events are never fetched (e.g. Account.importConcordCommunities filter, the
CLI concord list).
Impact by front end (verified)
- Android
amethyst/: UNAFFECTED. NoNostrSignerRemoteis ever constructed (AccountCacheState.loadAccountbuilds onlyNostrSignerInternal/NostrSignerExternal; the onlyNostrSignerRemotereference is a type-check inMeteringNostrSigner.kt:122). Android has no bunker login, sosigner.pubKey == account pubkeyalways. - Desktop
desktopApp/: AFFECTED. Bunker login is a first-class path (AccountManager.loginWithBunker/loginWithNostrConnect). Confirmed live:BookmarksScreen.kt:189,355→list.privateBookmarks(account.signer)→ guard returns null → private bookmarks always empty for bunker accounts. Same guard breaks the private mute section, private follow lists, and drafts. Cashu self-encryption sealed to the wrong peer. (No Concord feature on desktop.) - CLI
cli/: AFFECTED. Same failures;concord listbreaks on both the author filter and the decrypt. Theamy concord import/readdiagnostics (2026-07-17) already work around it by usingctx.identity.pubKeyHex— but every other self-encryption site in the CLI is still wrong.
Transport key is persisted + stable across restarts on both desktop and CLI,
so Mode-B data round-trips within one install (looks fine locally) but is
non-portable and non-standard; a fresh nostrconnect:// session regenerating
the key makes it permanently unrecoverable. Mode-A fails regardless.
Fix
Core (quartz): make NostrSignerRemote.pubKey return the verified user
key U, not the transport key T. Constraints:
- The user key is known only after the
get_public_keyRPC, so it can't be set from the rawbunker://parts at construction. Resolve it during the login/connect handshake (desktopNostrConnectLoginUseCaseand CLI login already callgetPublicKey()), and construct/finalize the signer with U as its identity. - Keep the transport key for NIP-46 transport. Internal sites that legitimately
need T — the response subscription filter
p: signer.pubKey(NostrSignerRemote.kt:91), request addressing/encryption to the bunker (RemoteSignerManager,remoteKey = remotePubkey) — must reference the transport keypair explicitly (this.signer.pubKey), not the base-classpubKey. Audit everysigner.pubKey/pubKeyuse insideNostrSignerRemoteandRemoteSignerManagerand pin transport uses to the transport keypair before flipping the basepubKey.
Two implementation options:
- Explicit identity param — add
userPubkey: HexKeytoNostrSignerRemote(or a factory that RPCsget_public_key, then builds the signer with U asNostrSigner(userPubkey)). Cleanest; makes the contract explicit. Requires touching every construction site (desktopAccountManager, CLIContext,NostrConnectLoginUseCase). - Late-resolved pubKey — allow the base identity to be set once after the
connect handshake. Smaller call-site churn, but
NostrSigner.pubKeybecoming non-valripples widely; less desirable.
Prefer (1).
For local (NostrSignerInternal) and external NIP-55 (NostrSignerExternal)
signers, signer.pubKey already equals the account key, so the change is a
no-op for them — only bunker accounts change behavior. That keeps the blast
radius to exactly the broken case.
Once NostrSignerRemote.pubKey == U, all Mode-A guards pass and all Mode-B
self-encryption uses the right peer; amy can drop its manual-decrypt
workaround and the Account.importConcordCommunities filter/decrypt become
correct for any future bunker use.
Migration / data caveat
Any Mode-B data a bunker user already wrote was sealed to peer T. After the fix (peer U) it becomes unreadable — but it was already unreadable everywhere except that one install, so the fix trades a hidden-corruption state for a correct one. Private NIP-51 lists / drafts (Mode-A) were never successfully written wrong (the guard blocked the write path's read-modify-write too), so there's nothing to migrate there — they simply start working. Call this out in the PR; no migration code needed, but a note for affected desktop users is kind.
Testing
- quartz unit: construct a
NostrSignerRemotewhose transport key ≠ user key; assertpubKey == userKey; assertPrivateTagArrayEvent.decrypt/DraftWrapEvent.canDecryptsucceed against a U-authored event; assert the NIP-46 response subscription still filters on the transport key. - round-trip: self-encrypt a private list with the remote signer, decrypt
with a local
NostrSignerInternalfor U → must match (proves portability). - desktop: bunker login → private bookmarks / private mute / drafts render.
- CLI:
amy concord list(not justimport) loads communities for a bunker account; drop theimportworkaround and confirmnewest.decrypt(signer)works.
Suggested sequence
- quartz: audit transport-vs-identity
pubKeyuses insideNostrSignerRemote/RemoteSignerManager; pin transport uses to the transport keypair. - quartz: add the explicit-identity construction (option 1) + unit tests.
- desktop
AccountManager+NostrConnectLoginUseCase: pass the verified U into the signer; verify private lists on-device. - CLI
Context: passidentity.pubKeyHexas the signer identity; drop the amy Concord decrypt workaround.