Files
amethyst/.claude/skills/auth-signers/references/nip46-remote-signer.md
T
Claude 60edd473c7 Refreshes .claude/ skill library: fixes stale refs, adds 4 new skills
- Updates CLAUDE.md tech stack to current versions (Compose 1.10.3, Kotlin 2.3.20).
- Reframes kotlin-multiplatform iOS as mature; adds secp256k1-kmp 0.23.0 references.
- Updates desktop-expert Main.kt references (code grew from ~270 to 1341 lines and
  NavigationRail moved to ui/deck/SinglePaneLayout.kt); replaces obsolete
  "hardcoded ctrl = true" anti-pattern note with accurate isMacOS branching.
- Removes compose-desktop.md (superseded by desktop-expert/).
- Adds nostr-expert references: nip19-bech32, event-factory, crypto-and-encryption,
  large-cache. Adds kotlin-expert/common-utilities, compose-expert/rich-text-parsing,
  android-expert/image-loading.
- New skills: account-state (Account + LocalCache), relay-client (subscriptions,
  filter assemblers, preloaders), feed-patterns (FeedFilter + FeedViewModel family),
  auth-signers (NostrSigner across internal / NIP-46 / NIP-55).
2026-04-21 21:00:45 +00:00

4.9 KiB

NIP-46 Remote Signer (Bunker)

Remote signers are a different Nostr client (the "bunker") that holds the private key and signs on request over Nostr DMs. Amethyst connects to a bunker via a bunker:// URL and proxies every signing / encryption operation as a request/response over kind 24133 events.

Layout

quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/:

nip46RemoteSigner/
├── BunkerMessage.kt            ── common wrapper
├── BunkerRequest.kt            ── sealed base for requests
├── BunkerRequestConnect.kt
├── BunkerRequestGetPublicKey.kt
├── BunkerRequestGetRelays.kt
├── BunkerRequestNip04Decrypt.kt
├── BunkerRequestNip04Encrypt.kt
├── BunkerRequestNip44Decrypt.kt
├── BunkerRequestNip44Encrypt.kt
├── BunkerRequestPing.kt
├── BunkerRequestSign.kt
├── BunkerResponse.kt           ── sealed base for responses
├── BunkerResponseAck.kt
├── BunkerResponseDecrypt.kt
├── BunkerResponseEncrypt.kt
├── BunkerResponseError.kt
├── BunkerResponseEvent.kt
├── BunkerResponseGetRelays.kt
├── BunkerResponsePong.kt
├── BunkerResponsePublicKey.kt
├── NostrConnectEvent.kt        ── kind 24133 payload
├── kotlinSerialization/        ── JSON codecs for each request/response
└── signer/
    ├── NostrSignerRemote.kt       ── the NostrSigner impl
    ├── RemoteSignerManager.kt     ── connection lifecycle
    ├── ConnectResponse.kt
    ├── Nip04DecryptResponse.kt
    ├── Nip04EncryptResponse.kt
    ├── Nip44DecryptResponse.kt
    ├── Nip44EncryptResponse.kt
    ├── PingResponse.kt
    ├── PubKeyResponse.kt
    ├── SignerResult.kt
    └── SignResponse.kt

Connection Flow

User pastes bunker://<bunker-pubkey>?relay=wss://…&secret=…
              │
              ▼
RemoteSignerManager.connect(url) generates a client keypair
              │
              ▼
Publish BunkerRequestConnect (kind 24133, encrypted) to relay
              │
              ▼
Wait for BunkerResponseAck or BunkerResponseError
              │
              ▼ on ack
Return a NostrSignerRemote(clientKeys, bunkerPubKey, relays)

The client keypair is not the user's Nostr identity — it's a session key used to correspond with the bunker. The bunker controls the real signing key. RemoteSignerManager persists session state so reconnecting skips the handshake.

Request / Response Pattern

Every signing or encryption call is an async round-trip:

Amethyst                              Bunker
   │                                   │
   │── BunkerRequestSign(template) ──►│
   │                                   │  user approves (sometimes)
   │◄── BunkerResponseEvent(signed) ──│
   │                                   │

NostrSignerRemote.sign(template, onReady) serializes the BunkerRequestSign, encrypts it to the bunker's pubkey, publishes it, and waits (with a timeout) for a BunkerResponseEvent carrying the signed event. The response goes through a request-id correlation map so concurrent signs don't interleave.

Supported Requests

  • BunkerRequestConnect / BunkerRequestPing — lifecycle.
  • BunkerRequestGetPublicKey — verify what pubkey this session controls.
  • BunkerRequestGetRelays — discover the bunker's preferred inbox relays.
  • BunkerRequestSign — the main path.
  • BunkerRequestNip04Encrypt/Decrypt — legacy DMs.
  • BunkerRequestNip44Encrypt/Decrypt — NIP-44 payloads (gift-wrap, modern DMs).

Each has a matching response with the same correlation id.

Timeouts & Disconnects

  • Timeout: default in NostrSignerRemote. Expired requests surface as SignerExceptions.TimedOut (or equivalent). Treat as "maybe the user will approve later but UI has given up" — don't auto-retry.
  • Relay disconnect: RemoteSignerManager reconnects transparently when the bunker's relay reappears; in-flight requests may still time out.
  • Bunker revoke: if the bunker closes the session, next sign attempt returns BunkerResponseError; prompt the user to reconnect.

Gotchas

  • The session key is not the user key. Logs and UI should never surface the session pubkey as "your pubkey".
  • Don't assume a sign is fast — UX should show a spinner and allow cancellation for 10+ seconds.
  • Relay hints from BunkerRequestGetRelays can change the relay list mid-session; the session manager handles it, but re-reads of relays in feature code may be stale.
  • NIP-46 over Nostr is the only transport here — there's no HTTP/WS shortcut. Any feature gating on "can this sign" must check relay reachability.
  • nip55-android-signer.md — the other remote-ish signer (but local to the device).
  • nostr-expert/references/crypto-and-encryption.md — NIP-44 details (used by request/response encryption).