Files
Claude 4f04a7b0e9 feat: sign in with a NIP-05 address through the signer its domain advertises
Typing `name@domain` used to give a read-only account. A domain can
advertise a NIP-46 remote signer for the address in its nostr.json
(`nip46`), and the login now uses it, in Amethyst (Android and desktop,
through the shared login) and in amy.

- Quartz: Nip05Client.loadRemoteSigner reads both shapes into a
  Nip05RemoteSigner. Legacy first (nip46[pubkey] -> relays, the address's
  own key signs; what nostr-tools and nsec.app read), then the NIP-46
  appendix's `name=_` announcement (signer key, relays,
  nostrconnect_url). Relays are normalized and capped; a
  nostrconnect_url must be a web page with a `<nostrconnect>` slot.
  NostrConnectURI.fillNostrConnectUrl fills it.
- Commons: Nip05SignerLoginUseCase connects with no secret, or opens the
  signer's page with a nostrconnect:// code through
  RemoteSignerAuthPrompts (the same dialog as auth_url), then requires
  the signer's account to be the address's key (SignerMismatchException).
  AccountSessionManager tries it for `name@domain` before the read-only
  login; LoginViewModel only allows it where remote-signer login is
  supported.
- amy: `amy login name@domain` logs in through the signer and adds
  `nip05` to its output; `--read-only` keeps the old login; pages to
  approve go to stderr. LoginCommand.nip05ClientBuilder is a test seam.

Tests: parser shapes and precedence (quartz), the use case against an
in-process signer (legacy, mismatch refused, the signer's page carrying
the code), AccountSessionManager saving a signing account or the
read-only one, and `amy login bob@signer.example` end to end against a
geode relay with the domain document served from memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DgJn7fqMFnv58kvLC1soen
2026-10-20 16:01:44 +00:00

4.7 KiB

NIP-46 remote signer (bunker)

A bunker holds the user's key and signs on request. Amethyst talks to it with kind-24133 NostrConnectEvents, NIP-44-encrypted between a local transport keypair and the bunker's pubkey.

Layout (quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/)

  • BunkerRequest*.kt — Connect, GetPublicKey, GetRelays, Ping, Sign, Nip04Encrypt/Decrypt, Nip44Encrypt/Decrypt, Invalid; BunkerRequestParser.kt.
  • BunkerResponse*.kt — Ack, Event, Encrypt, Decrypt, PublicKey, GetRelays, Pong, Error.
  • NostrConnectEvent.kt (kind 24133), NostrConnectURI.kt, BunkerMessage.kt, kotlinSerialization/.
  • signer/ — the client side: NostrSignerRemote (the NostrSigner), RemoteSignerManager (request/response correlation), per-method *Response parsers, SignerResult.
  • server/ — the bunker side Quartz can also run: NostrConnectSignerService, BunkerRequestProcessor, Nip46RequestAuthorizer.

Connection

bunker://<bunker-pubkey>?relay=wss://…&secret=…
  -> NostrSignerRemote.fromBunkerUri(uri, transportSigner: NostrSignerInternal, client)
  -> remote.connect()            // BunkerRequestConnect(remoteKey, permissions, secret, clientMetadata)
  -> remote.getPublicKey()       // binds the user identity

fromBunkerUri takes optional permissions, clientMetadata and onAuthUrl, the callback for NIP-46 auth_url challenges (surface the URL; the request keeps waiting on the same id). The apps pass RemoteSignerAuthPrompts::report (commons/.../domain/nip46/), from the login use cases and AccountCacheState alike; RemoteSignerAuthPrompt in AmethystApp shows each page with an Open button, and only http(s) pages get through. The CLI prints the URL instead.

switchRelays() sends NIP-46 switch_relays; a new list moves the requests and the response subscription (relays is mutable, the subscription a DynamicSubscription) and fires onRelaysSwitched. AccountCacheState asks it whenever it loads a remote-signer account and saves the new relays into remoteSignerBunkerUri; AccountsTorStateConnector re-reads that on every settings save. logout() sends NIP-46 logout: AccountSessionManager.logOff runs RemoteSignerLogoutUseCase (its own short-lived signer on the saved transport key, bounded, never throws) before the account's settings are deleted. The CLI sends neither.

On the signer side (server/), BunkerRequestProcessor's switchRelays hook answers switch_relays (paired clients only; strangers get null). Nip46SignerState uses it to move a nostrconnect:// app, whose session sits on relays the app picked, onto the account's NIP-65 inbox relays, keeping only those whose NIP-11 doesn't require auth, payment or restricted writes (acceptsSignerTraffic). The app's own relays leave the listen set only once one of its requests arrives on an inbox relay (NostrConnectSignerService.onRequestSeenOn), so an app that never got the answer isn't stranded.

name@domain logins: Nip05Client.loadRemoteSigner reads the domain's nip46 (the legacy nip46[pubkey] relays first, then the spec's name=_ announcement with nostrconnect_url), and Nip05SignerLoginUseCase logs in through it — no-secret connect, or the signer's page carrying a nostrconnect:// code, shown through RemoteSignerAuthPrompts — and requires the account to be the address's key. AccountSessionManager and amy login fall back to read-only without a signer.

Identity

The constructor's signer is the transport key, not the user. NostrSignerRemote.pubKey returns the identity resolved by getPublicKey() or set with bindUserPubkey(...) (persisted accounts, the CLI's stored identity), and falls back to the transport key until then. Anything that encrypts to self or checks authorship must use pubKey after binding. Transport plumbing (response p filter, request addressing) uses signer.pubKey/remotePubkey on purpose.

Request lifecycle (RemoteSignerManager.launchWaitAndParse)

Each call creates a request with a random id, publishes it to the bunker relays (client.publish(event, relayList)), parks a channel in a LargeCache keyed by id, and waits up to timeout (65 s). No answer → after 2 s, one retry that resends the same event under the same id → then SignerResult.RequestAddressed.TimedOut, which NostrSignerRemote throws as SignerExceptions.TimedOutException. Concurrent requests never interleave because responses route by id (newResponse).

Gotchas

  • Show progress for long waits; approval can take tens of seconds.
  • Only Nostr relays carry the traffic — a feature that can sign offline cannot with a bunker.
  • signPsbt is not implemented for remote bunkers (UnsupportedMethodException).