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
+3 -1
View File
@@ -223,7 +223,9 @@ Two amy processes can talk: one **hosts** a bunker with its local key; the other
| Command | What it does |
|---|---|
| `amy bunker [--relay URL[,URL…]] [--secret S] [--timeout SECS]` | Run a NIP-46 remote signer for the active local-key account. Prints a `bunker://…` URI, then services sign / nip04 / nip44 / get_public_key / ping requests until interrupted (or `--timeout`). |
| `amy login bunker://PUBKEY?relay=…&secret=…` | Log in through a bunker. Mints a local transport keypair; the account then acts as PUBKEY and every signing/encryption call is delegated to the remote signer. |
| `amy login bunker://PUBKEY?relay=…&secret=…` | Log in through a bunker. Mints a local transport keypair; the account then acts as PUBKEY and every signing/encryption call is delegated to the remote signer. Percent-encoded relay params are decoded. |
Interop-tested against the real [`nak`](https://github.com/fiatjaf/nak) binary, both directions: `amy login bunker://``nak bunker`, and `nak event --sec bunker://``amy bunker`. Supports `connect` (secret-checked), `get_public_key`, `get_relays`, `sign_event`, `nip04_encrypt/decrypt`, `nip44_encrypt/decrypt`, `ping`. The `nostrconnect://` reverse flow and `auth_url` challenges are not implemented.
Example (two terminals, shared `$HOME`):
+1 -1
View File
@@ -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` + `amy login bunker://` | ✅ | NIP-46 remote signer (server) + bunker login (client). Two amy processes interop. |
| `bunker` | `amy bunker` + `amy login bunker://` | ✅ | NIP-46 remote signer (server) + bunker login (client). Interop-verified vs real `nak` both directions; connect/get_public_key/get_relays/sign/nip04/nip44/ping. `nostrconnect://` + `auth_url` still pending. |
| `serve` / `admin` / `wallet` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. |
**Tier 1 status:** shipped — `decode`, `encode`, `verify`, `key`, `event`,
@@ -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)