fix(cli): nak-compatible bunker — percent-encoded URIs + get_relays

Close the NIP-46 interop gap against the real nak binary:

- `Identity.fromBunkerUri` now URL-decodes the relay/secret params. nak
  emits `bunker://<pk>?relay=wss%3A%2F%2F…&secret=…`; without decoding,
  `amy login` of a nak bunker URI produced a broken relay and never
  connected. (This was the one real break — found by testing vs nak.)
- `amy bunker` now percent-encodes the relay/secret in the URI it prints,
  matching nak's output format.
- `amy bunker` implements `get_relays` (returns its relay set), so nak
  clients that probe it get a proper reply instead of an error.

Verified end-to-end with `go install`-built nak over relay.damus.io,
both directions:
- `amy login bunker://` ⇄ `nak bunker`: amy signs, event authored by
  nak's key, signature valid.
- `nak event --sec bunker://<amy>` ⇄ `amy bunker`: nak signs through
  amy, event authored by amy's key; amy logs connect→ok, sign_event→ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
This commit is contained in:
Claude
2026-06-21 19:52:37 +00:00
parent 54a6b44324
commit 695a5b4b25
4 changed files with 17 additions and 6 deletions
@@ -92,9 +92,12 @@ data class Identity(
parts.getOrNull(1)?.split("&")?.forEach { param ->
val kv = param.split("=", limit = 2)
if (kv.size < 2) return@forEach
// Relay/secret params are percent-encoded by spec-compliant
// emitters (nak does: `relay=wss%3A%2F%2F…`), so decode them.
val value = java.net.URLDecoder.decode(kv[1], "UTF-8")
when (kv[0]) {
"relay" -> relays.add(kv[1])
"secret" -> secret = kv[1]
"relay" -> relays.add(value)
"secret" -> secret = value
}
}
require(relays.isNotEmpty()) { "bunker uri must carry at least one relay" }
@@ -34,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt
@@ -46,9 +47,11 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseGetRelays
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.ReadWrite
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED
import kotlinx.coroutines.withTimeoutOrNull
@@ -93,11 +96,13 @@ object BunkerCommand {
val secret = args.flag("secret") ?: KeyPair().privKey!!.toHexKey().take(32)
val self = ctx.identity.pubKeyHex
// Percent-encode params (spec/nak convention: relay=wss%3A%2F%2F…).
val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") }
val uri =
buildString {
append("bunker://").append(self)
append("?").append(relays.joinToString("&") { "relay=${it.url}" })
append("&secret=").append(secret)
append("?").append(relays.joinToString("&") { "relay=${enc(it.url)}" })
append("&secret=").append(enc(secret))
}
Output.emit(
mapOf(
@@ -165,6 +170,7 @@ object BunkerCommand {
BunkerResponseError(request.id, "invalid secret")
}
is BunkerRequestGetPublicKey -> BunkerResponsePublicKey(request.id, signer.pubKey)
is BunkerRequestGetRelays -> BunkerResponseGetRelays(request.id, relays.associate { it.url to ReadWrite(read = true, write = true) })
is BunkerRequestPing -> BunkerResponsePong(request.id)
is BunkerRequestSign -> {
val signed = signer.sign<Event>(request.event.createdAt, request.event.kind, request.event.tags, request.event.content)