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>
This commit is contained in:
Vitor Pamplona
2026-07-17 17:00:41 -04:00
co-authored by Claude Opus 4.8
parent ac1ca888c7
commit a96dd12a49
@@ -0,0 +1,151 @@
# 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.