mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
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.
|
||||
Reference in New Issue
Block a user