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
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(theNostrSigner),RemoteSignerManager(request/response correlation), per-method*Responseparsers,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.
signPsbtis not implemented for remote bunkers (UnsupportedMethodException).