diff --git a/cli/README.md b/cli/README.md index 45ad3aa204..9e629be4f9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -223,9 +223,15 @@ 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. Percent-encoded relay params are decoded. | +| `amy login bunker://PUBKEY?relay=…&secret=…` | Log in through a bunker (signer advertises). 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. | +| `amy bunker connect nostrconnect://…` | Client-initiated (NostrConnect) flow, signer side: ack a client's offer (echo its secret) and service its requests. | +| `amy login --nostrconnect [--relay URL[,URL…]] [--name N] [--timeout SECS]` | Client-initiated flow, client side: print a `nostrconnect://` offer, wait for a signer to connect, then persist a bunker account that acts as the signer's key. | -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. +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. Example (two terminals, shared `$HOME`): diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index f88ec2de7e..19250eb1fe 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). Interop-verified vs real `nak` both directions; connect/get_public_key/get_relays/sign/nip04/nip44/ping. `nostrconnect://` + `auth_url` still pending. | +| `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. | | `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 7f3cbc1d52..c3bc468c76 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -101,16 +101,29 @@ data class Identity( } } require(relays.isNotEmpty()) { "bunker uri must carry at least one relay" } - val clientPriv = KeyPair().privKey!!.toHexKey() - return Identity( - privKeyHex = null, - pubKeyHex = remotePubkey, - nsec = null, - npub = remotePubkey.hexToByteArray().toNpub(), - bunker = Bunker(remotePubkey, relays, secret, clientPriv), - ) + return bunkerIdentity(remotePubkey, relays, secret, KeyPair().privKey!!.toHexKey()) } + /** + * Build a remote-signer identity from already-resolved parts (used by + * the NostrConnect flow once the signer pubkey is discovered). The + * account acts as [signerPubkey]; [clientPrivKeyHex] is the local + * transport key. + */ + fun bunkerIdentity( + signerPubkey: String, + relays: List, + connectSecret: String?, + clientPrivKeyHex: String, + ): Identity = + Identity( + privKeyHex = null, + pubKeyHex = signerPubkey, + nsec = null, + npub = signerPubkey.hexToByteArray().toNpub(), + bunker = Bunker(signerPubkey, relays, connectSecret, clientPrivKeyHex), + ) + fun fromPrivateKey(priv: ByteArray): Identity { val pub = KeyPair(privKey = priv).pubKey return Identity( diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 2df33fbbb9..244aadaac5 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -354,8 +354,12 @@ private fun printUsage() { |Remote signing (NIP-46): | bunker [--relay URL[,URL…]] run a remote signer for this (local-key) account; prints a | [--secret S] [--timeout SECS] bunker:// uri and signs requests until interrupt/timeout + | bunker connect NOSTRCONNECT-URI act as signer for a client's nostrconnect:// + | [--timeout SECS] offer (acks + services its requests) | login bunker://PUBKEY?relay=…&secret=… sign through a remote bunker (mints a local | transport key; the account acts as PUBKEY) + | login --nostrconnect [--relay URL[,URL…]] client-initiated: print a nostrconnect:// offer, + | [--name N] [--timeout SECS] wait for a signer to connect, then persist it | |Relays: | relay add URL [--type T] T=nip65|inbox|key_package|all (default all) 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 10fe304ed5..12f68685d7 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 @@ -77,13 +77,22 @@ object BunkerCommand { suspend fun run( dataDir: DataDir, rest: Array, + ): Int = + if (rest.firstOrNull() == "connect") { + connect(dataDir, rest.drop(1).toTypedArray()) + } else { + advertise(dataDir, rest) + } + + /** `amy bunker …` — advertise a bunker:// uri and service requests. */ + private suspend fun advertise( + dataDir: DataDir, + rest: Array, ): Int { val args = Args(rest) val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } - // A self-bunker needs the real key to sign; a remote (bunker) account can't host one. - if (dataDir.loadIdentityFileOrNull()?.bunker != null) { - return Output.error("bad_account", "this account signs through a remote bunker — host a bunker from a local-key account") - } + val accountError = checkHostable(dataDir) + if (accountError != null) return accountError Context.open(dataDir).use { ctx -> if (!ctx.identity.hasPrivateKey) { @@ -114,36 +123,97 @@ object BunkerCommand { ) System.err.println("[bunker] listening as ${self.take(8)}… on ${relays.size} relay(s); paste the bunker:// uri into `amy login`") - val events = Channel(UNLIMITED) - val seen = mutableSetOf() - val subId = newSubId() - val listener = - object : SubscriptionListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - if (event is NostrConnectEvent && seen.add(event.id)) events.trySend(event) - } - } - - val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self))) - ctx.client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) - try { - val loop: suspend () -> Unit = { - while (true) handle(ctx, events.receive(), secret, relays) - } - if (timeoutMs != null) withTimeoutOrNull(timeoutMs) { loop() } else loop() - } finally { - ctx.client.unsubscribe(subId) - events.close() - } + serve(ctx, relays, secret, timeoutMs) return 0 } } + /** + * `amy bunker connect ` — the client-initiated + * (NostrConnect) flow: parse a client's offer, send the connect ACK that + * echoes the offer's secret back (so the client learns our signer pubkey), + * then service that client's requests on the offer's relays. + */ + private suspend fun connect( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } + val uri = args.positional(0, "nostrconnect-uri") + val offer = NostrConnect.parseOffer(uri) ?: return Output.error("bad_args", "not a valid nostrconnect:// uri") + val accountError = checkHostable(dataDir) + if (accountError != null) return accountError + + Context.open(dataDir).use { ctx -> + if (!ctx.identity.hasPrivateKey) { + return Output.error("read_only", "bunker host needs a local private key (this account is read-only)") + } + ctx.prepare() + if (offer.relays.isEmpty()) return Output.error("bad_args", "nostrconnect uri carries no relay") + + // Send the connect ACK (result == secret) to the client. + val ack = BunkerResponse(newSubId(), offer.secret, null) + val reply = NostrConnectEvent.create(ack, offer.clientPubkey, ctx.signer) + ctx.client.publish(reply, offer.relays) + Output.emit( + mapOf( + "connected_to" to offer.clientPubkey, + "pubkey" to ctx.identity.pubKeyHex, + "relays" to offer.relays.map { it.url }, + ), + ) + System.err.println("[bunker] acked nostrconnect from ${offer.clientPubkey.take(8)}…; now servicing requests") + + serve(ctx, offer.relays, offer.secret, timeoutMs) + return 0 + } + } + + /** A remote-signer (bunker) account cannot itself host a bunker. */ + private fun checkHostable(dataDir: DataDir): Int? = + if (dataDir.loadIdentityFileOrNull()?.bunker != null) { + Output.error("bad_account", "this account signs through a remote bunker — host a bunker from a local-key account") + } else { + null + } + + /** Subscribe for kind:24133 requests addressed to us and service them until timeout/interrupt. */ + private suspend fun serve( + ctx: Context, + relays: Set, + secret: String, + timeoutMs: Long?, + ) { + val self = ctx.identity.pubKeyHex + val events = Channel(UNLIMITED) + val seen = mutableSetOf() + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is NostrConnectEvent && seen.add(event.id)) events.trySend(event) + } + } + + val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self))) + ctx.client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) + try { + val loop: suspend () -> Unit = { + while (true) handle(ctx, events.receive(), secret, relays) + } + if (timeoutMs != null) withTimeoutOrNull(timeoutMs) { loop() } else loop() + } finally { + ctx.client.unsubscribe(subId) + events.close() + } + } + private suspend fun handle( ctx: Context, event: NostrConnectEvent, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt index 232fe25286..7e2f3bce1b 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/LoginCommand.kt @@ -49,8 +49,14 @@ object LoginCommand { dataDir: DataDir, rest: Array, ): Int { + // NIP-46 NostrConnect (client-initiated) login: no key positional — + // amy mints a transport key, prints an offer, and waits for a signer. + val preArgs = Args(rest) + if (preArgs.bool("nostrconnect")) { + return NostrConnect.login(dataDir, preArgs) + } if (rest.isEmpty()) { - return Output.error("bad_args", "login [--password X]") + return Output.error("bad_args", "login [--password X]") } if (dataDir.identityExists()) { return Output.error("exists", "identity already exists at ${dataDir.identityFile}; use a fresh --data-dir or delete it first") diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt new file mode 100644 index 0000000000..29810dffaa --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Identity +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.defaults.DefaultNIP65RelaySet +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.OkHttpClient + +/** + * NIP-46 NostrConnect (client-initiated) flow helpers. + * + * Parsing/encoding the `nostrconnect://` offer is shared between the client + * (`amy login --nostrconnect`, [login]) and the signer (`amy bunker connect`, + * which only parses). The signer side lives in [BunkerCommand]. + */ +object NostrConnect { + data class Offer( + val clientPubkey: String, + val relays: Set, + val secret: String, + val name: String?, + ) + + /** Parse `nostrconnect://?relay=…&secret=…&name=…` (percent-decoded). */ + fun parseOffer(uri: String): Offer? { + if (!uri.startsWith("nostrconnect://")) return null + val parts = uri.removePrefix("nostrconnect://").split("?", limit = 2) + val clientPubkey = parts[0].lowercase() + if (clientPubkey.length != 64 || clientPubkey.any { it !in "0123456789abcdef" }) return null + val relays = mutableSetOf() + var secret: String? = null + var name: String? = null + parts.getOrNull(1)?.split("&")?.forEach { param -> + val kv = param.split("=", limit = 2) + if (kv.size < 2) return@forEach + val value = java.net.URLDecoder.decode(kv[1], "UTF-8") + when (kv[0]) { + "relay" -> RelayUrlNormalizer.normalizeOrNull(value)?.let { relays.add(it) } + "secret" -> secret = value + "name" -> name = value + } + } + if (secret == null) return null + return Offer(clientPubkey, relays, secret!!, name) + } + + private fun buildOffer( + clientPubkey: String, + relays: Set, + secret: String, + name: String?, + ): String { + val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") } + return buildString { + append("nostrconnect://").append(clientPubkey) + append("?").append(relays.joinToString("&") { "relay=${enc(it.url)}" }) + append("&secret=").append(enc(secret)) + if (name != null) append("&name=").append(enc(name)) + } + } + + /** + * `amy login --nostrconnect [--relay URL[,URL…]] [--name N] [--timeout SECS]` + * + * Mint a local transport keypair, print a `nostrconnect://` offer for the + * user to paste into a signer, then wait for the signer's connect ACK + * (a kind:24133 whose decrypted result echoes our secret). The ACK's author + * is the remote signer; persist a bunker account that acts as that key. + */ + suspend fun login( + dataDir: DataDir, + args: Args, + ): Int { + if (dataDir.identityExists()) { + return Output.error("exists", "identity already exists at ${dataDir.identityFile}; use a fresh --data-dir or delete it first") + } + val relays = RawEventSupport.relayFlag(args).ifEmpty { DefaultNIP65RelaySet } + if (relays.isEmpty()) return Output.error("bad_args", "no relays; pass --relay URL[,URL…]") + val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 120L) * 1000 + val name = args.flag("name") + + val clientKey = KeyPair() + val clientSigner = NostrSignerInternal(clientKey) + val clientPub = clientKey.pubKey.toHexKey() + val secret = KeyPair().privKey!!.toHexKey().take(32) + val offer = buildOffer(clientPub, relays, secret, name) + + // Surface the offer immediately so the human/harness can paste it. + System.err.println("[nostrconnect] paste this into your signer within ${timeoutMs / 1000}s:") + System.err.println(offer) + + val okhttp = OkHttpClient.Builder().build() + val client = NostrClient(websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp }) + val incoming = Channel(UNLIMITED) + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is NostrConnectEvent) incoming.trySend(event) + } + } + + try { + client.connect() + val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(clientPub))) + client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) + + val signerPub = + withTimeoutOrNull(timeoutMs) { + // Drain kind:24133 until one decrypts to a BunkerResponse echoing our secret. + var found: String? = null + while (found == null) { + found = verifyAck(incoming.receive(), clientSigner, secret) + } + found + } + + if (signerPub == null) { + return Output.error("timeout", "no signer connected within ${timeoutMs / 1000}s") + } + + val identity = Identity.bunkerIdentity(signerPub, relays.map { it.url }, secret, clientKey.privKey!!.toHexKey()) + dataDir.saveIdentity(identity) + Output.emit( + mapOf( + "npub" to identity.npub, + "hex" to identity.pubKeyHex, + "read_only" to false, + "signer" to "bunker", + "bunker_relays" to relays.map { it.url }, + "data_dir" to dataDir.root.absolutePath, + ), + ) + return 0 + } finally { + client.unsubscribe(subId) + incoming.close() + client.close() + } + } + + /** Returns the signer pubkey if [event] is the connect ACK echoing [secret], else null. */ + private suspend fun verifyAck( + event: NostrConnectEvent, + clientSigner: NostrSignerInternal, + secret: String, + ): String? = + try { + val msg = event.decryptMessage(clientSigner) + if (msg is BunkerResponse && msg.result == secret) event.pubKey else null + } catch (e: Exception) { + null + } +}