Files
amethyst/quartz/plans/2026-07-17-nip46-remote-signer-self-pubkey.md
Vitor PamplonaandClaude Opus 4.8 a96dd12a49 docs(nip46): plan to fix remote-signer self-pubkey bug
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>
2026-07-17 17:00:41 -04:00

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() throws UnauthorizedDecryptionException; privateTags() returns null.
  • 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. No NostrSignerRemote is ever constructed (AccountCacheState.loadAccount builds only NostrSignerInternal / NostrSignerExternal; the only NostrSignerRemote reference is a type-check in MeteringNostrSigner.kt:122). Android has no bunker login, so signer.pubKey == account pubkey always.
  • 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 list breaks on both the author filter and the decrypt. The amy concord import/read diagnostics (2026-07-17) already work around it by using ctx.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_key RPC, so it can't be set from the raw bunker:// parts at construction. Resolve it during the login/connect handshake (desktop NostrConnectLoginUseCase and CLI login already call getPublicKey()), 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-class pubKey. Audit every signer.pubKey/pubKey use inside NostrSignerRemote and RemoteSignerManager and pin transport uses to the transport keypair before flipping the base pubKey.

Two implementation options:

  1. Explicit identity param — add userPubkey: HexKey to NostrSignerRemote (or a factory that RPCs get_public_key, then builds the signer with U as NostrSigner(userPubkey)). Cleanest; makes the contract explicit. Requires touching every construction site (desktop AccountManager, CLI Context, NostrConnectLoginUseCase).
  2. Late-resolved pubKey — allow the base identity to be set once after the connect handshake. Smaller call-site churn, but NostrSigner.pubKey becoming non-val ripples 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 NostrSignerRemote whose transport key ≠ user key; assert pubKey == userKey; assert PrivateTagArrayEvent.decrypt / DraftWrapEvent.canDecrypt succeed 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 NostrSignerInternal for U → must match (proves portability).
  • desktop: bunker login → private bookmarks / private mute / drafts render.
  • CLI: amy concord list (not just import) loads communities for a bunker account; drop the import workaround and confirm newest.decrypt(signer) works.

Suggested sequence

  1. quartz: audit transport-vs-identity pubKey uses inside NostrSignerRemote/RemoteSignerManager; pin transport uses to the transport keypair.
  2. quartz: add the explicit-identity construction (option 1) + unit tests.
  3. desktop AccountManager + NostrConnectLoginUseCase: pass the verified U into the signer; verify private lists on-device.
  4. CLI Context: pass identity.pubKeyHex as the signer identity; drop the amy Concord decrypt workaround.