From 2c37ae642cf2c64d66b00f8d6ca92c8415167f58 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 20:19:01 +0000 Subject: [PATCH] feat(nip46): handle auth_url challenges in the remote-signer client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds NIP-46 `auth_url` (web-authorization) support to quartz's remote signer, so amy (and Amethyst) can use auth-requiring bunkers like nsec.app / nsecbunker: - Both BunkerResponse deserializers (kotlinx + the JVM/Android Jackson one actually used by OptimizedJsonMapper) now special-case `{result:"auth_url", error:}` BEFORE the generic error branch, which previously flattened it to a plain error and dropped the marker. - RemoteSignerManager gained an `onAuthUrl` callback and a wait loop: on an auth_url response it surfaces the URL once and keeps waiting for the real response under the same request id (UNLIMITED channel so both are buffered). newResponse peeks the pending entry (get, not remove) so the follow-up isn't dropped; the waiter still removes it in finally. - NostrSignerRemote forwards `onAuthUrl`; amy's Context prints the URL to stderr and the pending command keeps waiting. Tests: a deserializer test (auth_url keeps result+error) and a manager test (auth_url surfaced via callback, then the real response resumes the request) — both green; existing duplicate/late-response manager tests still pass after the get-vs-remove change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 2 +- cli/ROADMAP.md | 2 +- .../com/vitorpamplona/amethyst/cli/Context.kt | 2 + .../nip46RemoteSigner/BunkerResponse.kt | 7 ++- .../BunkerResponseKSerializer.kt | 8 +++ .../signer/NostrSignerRemote.kt | 7 +++ .../signer/RemoteSignerManager.kt | 35 +++++++++++- .../signer/RemoteSignerManagerRetryTest.kt | 57 +++++++++++++++++++ .../jackson/BunkerResponseDeserializer.kt | 8 +++ 9 files changed, 122 insertions(+), 6 deletions(-) diff --git a/cli/README.md b/cli/README.md index 9e629be4f9..01b706e749 100644 --- a/cli/README.md +++ b/cli/README.md @@ -231,7 +231,7 @@ Interop-tested against the real [`nak`](https://github.com/fiatjaf/nak) binary: - **bunker:// both directions** — `amy login bunker://` ⇄ `nak bunker`, and `nak event --sec bunker://` ⇄ `amy bunker`. - **nostrconnect:// client** — `amy login --nostrconnect` ⇄ `nak bunker connect` (amy signs, event authored by nak's key). -Supports `connect` (secret-checked), `get_public_key`, `get_relays`, `sign_event`, `nip04_encrypt/decrypt`, `nip44_encrypt/decrypt`, `ping`. `auth_url` challenges are not implemented. +Supports `connect` (secret-checked), `get_public_key`, `get_relays`, `sign_event`, `nip04_encrypt/decrypt`, `nip44_encrypt/decrypt`, `ping`. When a bunker answers with an `auth_url` challenge, amy prints the authorization URL to stderr and keeps waiting for the real response (open the URL in a browser to authorize). Example (two terminals, shared `$HOME`): diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 19250eb1fe..6dc6b0772a 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -97,7 +97,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `sync` | `amy sync` | ✅ | NIP-77 Negentropy reconcile with the local store (down/up/both). | | `git` | `amy git` | ✅ in part | NIP-34 repo announce/list/show/issue. clone/push (packfile transport) out of scope. | | `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. | -| `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction. Interop-verified vs real `nak`. `auth_url` challenge still pending. | +| `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. | | `serve` / `admin` / `wallet` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | **Tier 1 status:** shipped — `decode`, `encode`, `verify`, `key`, `event`, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 77edbb6699..e734f17af0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -118,6 +118,8 @@ class Context( relays = b.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet(), client = client, secret = b.connectSecret, + // Bunker requires web authorization: surface the URL; the request keeps waiting. + onAuthUrl = { url -> System.err.println("[nip46] authorize this request in a browser, then it will continue:\n $url") }, ) } ?: NostrSignerInternal(identity.keyPair()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt index 4829578777..a2efda7826 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerResponse.kt @@ -24,4 +24,9 @@ open class BunkerResponse( val id: String, val result: String?, val error: String?, -) : BunkerMessage() +) : BunkerMessage() { + companion object { + /** NIP-46 auth-challenge marker: `result == "auth_url"`, with the URL carried in `error`. */ + const val RESULT_AUTH_URL = "auth_url" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt index 9787b10eb3..8d6c80ab05 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/kotlinSerialization/BunkerResponseKSerializer.kt @@ -75,6 +75,14 @@ object BunkerResponseKSerializer : KSerializer { val result = jsonObject["result"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } val error = jsonObject["error"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + // NIP-46 auth challenge: `{"result":"auth_url","error":""}`. It carries + // an `error` (the URL) but is NOT a failure — keep both fields so the client + // can surface the URL and keep waiting for the real response. Must precede the + // generic error branch below, which would otherwise drop the `auth_url` marker. + if (result == BunkerResponse.RESULT_AUTH_URL) { + return BunkerResponse(id, result, error) + } + if (error != null) { return BunkerResponseError.parse(id, result, error) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index ccc750e5a3..a1f419ca2d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -57,6 +57,12 @@ class NostrSignerRemote( val client: INostrClient, val permissions: String? = null, val secret: String? = null, + /** + * Invoked with the authorization URL when the bunker answers with a NIP-46 + * `auth_url` challenge. Surface it (open a browser / print it); the pending + * request keeps waiting for the real response. + */ + val onAuthUrl: ((String) -> Unit)? = null, ) : NostrSigner(signer.pubKey) { private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) @@ -66,6 +72,7 @@ class NostrSignerRemote( remoteKey = remotePubkey, relayList = relays, client = client, + onAuthUrl = onAuthUrl, ) val subscription = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt index 432c715e41..44fbc2ca90 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt @@ -41,6 +41,13 @@ class RemoteSignerManager( val remoteKey: String, val relayList: Set, val maxRetries: Int = 1, + /** + * Invoked with the authorization URL when the bunker answers a request with + * a NIP-46 `auth_url` challenge. The caller should surface it (open a + * browser / print it) so the user can authorize; the manager keeps waiting + * for the real response on the same request id. + */ + val onAuthUrl: ((String) -> Unit)? = null, ) { private val pending = LargeCache>() @@ -48,7 +55,10 @@ class RemoteSignerManager( val decryptedJson = signer.decrypt(responseEvent.content, remoteKey) val bunkerResponse = OptimizedJsonMapper.fromJsonTo(decryptedJson) - val channel = pending.remove(bunkerResponse.id) + // Peek (don't remove): a request may receive an `auth_url` challenge + // followed by the real response under the same id. The waiting + // continuation removes the entry in its `finally`. + val channel = pending.get(bunkerResponse.id) if (channel == null) { Log.d("NIP46") { "no channel for bunker response id=${bunkerResponse.id} (duplicate, unknown, or late)" } return @@ -89,13 +99,32 @@ class RemoteSignerManager( signer = signer, ) - val channel = Channel(capacity = 1) + // UNLIMITED so an `auth_url` challenge and the follow-up real + // response (same id) can both be buffered without dropping either. + val channel = Channel(capacity = Channel.UNLIMITED) pending.put(attemptRequest.id, channel) + var announcedAuthUrl: String? = null val response = try { client.publish(event, relayList = relayList) - withTimeoutOrNull(timeout) { channel.receive() } + withTimeoutOrNull(timeout) { + var real: BunkerResponse? = null + while (real == null) { + val r = channel.receive() + if (r.result == BunkerResponse.RESULT_AUTH_URL) { + // Surface the auth URL once and keep waiting for the real response. + val url = r.error + if (url != null && url != announcedAuthUrl) { + announcedAuthUrl = url + onAuthUrl?.invoke(url) + } + } else { + real = r + } + } + real + } } finally { pending.remove(attemptRequest.id) channel.close() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt index 99d18cb353..3f346531b8 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManagerRetryTest.kt @@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.utils.Hex @@ -72,6 +73,62 @@ class RemoteSignerManagerRetryTest { signer = bunkerSigner, ) + private suspend fun bunkerAuthUrlFor( + requestId: String, + url: String, + ): NostrConnectEvent = + NostrConnectEvent.create( + message = BunkerResponse(requestId, BunkerResponse.RESULT_AUTH_URL, url), + remoteKey = signer.pubKey, + signer = bunkerSigner, + ) + + @Test + fun authUrlResponseKeepsBothResultAndError() { + val json = """{"id":"abc","result":"auth_url","error":"https://signer.example/auth?x=1"}""" + val resp = OptimizedJsonMapper.fromJsonTo(json) + // Must NOT be flattened into a plain error — the auth_url marker has to survive. + assertEquals(BunkerResponse.RESULT_AUTH_URL, resp.result) + assertEquals("https://signer.example/auth?x=1", resp.error) + } + + @Test + fun authUrlChallengeIsSurfacedThenRealResponseResumes() = + runTest { + val capturing = CapturingNostrClient() + val seenUrls = mutableListOf() + val manager = + RemoteSignerManager( + timeout = 5_000, + client = capturing, + signer = signer, + remoteKey = remoteKey, + relayList = setOf(relay), + maxRetries = 0, + onAuthUrl = { seenUrls.add(it) }, + ) + + val deferred = + async { + manager.launchWaitAndParse( + bunkerRequestBuilder = { BunkerRequestPing() }, + parser = PingResponse::parse, + ) + } + runCurrent() + + val requestId = decodeRequestId(capturing.publishedEvents.single()) + // The bunker first asks for web authorization, then (after the user + // authorizes) sends the real response under the same id. + manager.newResponse(bunkerAuthUrlFor(requestId, "https://signer.example/auth")) + manager.newResponse(bunkerPongFor(requestId)) + advanceUntilIdle() + + val result = deferred.await() + assertIs>(result) + assertEquals(listOf("https://signer.example/auth"), seenUrls) + } + @Test fun timeoutReturnsTimedOutAfterMaxRetries() = runTest { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt index ccaf834c4b..40f61798fb 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/jackson/BunkerResponseDeserializer.kt @@ -44,6 +44,14 @@ class BunkerResponseDeserializer : StdDeserializer(BunkerRespons val result = jsonObject.get("result")?.asText() val error = jsonObject.get("error")?.asText() + // NIP-46 auth challenge: `{"result":"auth_url","error":""}`. It carries + // an `error` (the URL) but is NOT a failure — keep both fields so the client + // can surface the URL and keep waiting for the real response. Must precede the + // generic error branch below, which would otherwise drop the `auth_url` marker. + if (result == BunkerResponse.RESULT_AUTH_URL) { + return BunkerResponse(id, result, error) + } + if (error != null) { return BunkerResponseError.parse(id, result, error) }