diff --git a/cli/README.md b/cli/README.md index 268306027b..45ad3aa204 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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`): diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index 2206a6beec..f88ec2de7e 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` + `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`, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt index 80dcef8ef2..7f3cbc1d52 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -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" } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt index ad26b24d60..10fe304ed5 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -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(request.event.createdAt, request.event.kind, request.event.tags, request.event.content)