feat(cli): NIP-46 nostrconnect:// reverse flow (both sides)

Add the client-initiated NostrConnect flow to complete NIP-46 parity:

- `amy login --nostrconnect [--relay …] [--name N]` (client) mints a
  transport keypair, prints a nostrconnect:// offer, subscribes, and
  waits for the signer's connect ACK (a kind:24133 whose decrypted
  result echoes our secret). The ACK's author is the signer; it persists
  a bunker account acting as that key.
- `amy bunker connect nostrconnect://…` (signer) parses a client offer,
  sends the secret-echo ACK, then services that client's requests on the
  offer's relays. Shares the serve loop with `amy bunker`.

NostrConnect.kt holds the offer parse/build (+percent-decode) and the
client handshake. BunkerCommand grew a `connect` sub-mode and factored
the request loop into serve().

Verified end-to-end:
- amy client ⇄ real `nak bunker connect`: amy learns nak's key and signs;
  event authored by nak, signature valid.
- amy client ⇄ amy `bunker connect`: same, authored by the host key.

Only `auth_url` challenges remain unimplemented in the NIP-46 surface.

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 20:04:03 +00:00
parent 695a5b4b25
commit 8b7caa5b01
7 changed files with 337 additions and 42 deletions
+8 -2
View File
@@ -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`):
+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). 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`,
@@ -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<String>,
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(
@@ -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)
@@ -77,13 +77,22 @@ object BunkerCommand {
suspend fun run(
dataDir: DataDir,
rest: Array<String>,
): 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<String>,
): 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<NostrConnectEvent>(UNLIMITED)
val seen = mutableSetOf<String>()
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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 <nostrconnect://…>` — 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<String>,
): 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<NormalizedRelayUrl>,
secret: String,
timeoutMs: Long?,
) {
val self = ctx.identity.pubKeyHex
val events = Channel<NostrConnectEvent>(UNLIMITED)
val seen = mutableSetOf<String>()
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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,
@@ -49,8 +49,14 @@ object LoginCommand {
dataDir: DataDir,
rest: Array<String>,
): 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 <nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05> [--password X]")
return Output.error("bad_args", "login <nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05|bunker://|--nostrconnect> [--password X]")
}
if (dataDir.identityExists()) {
return Output.error("exists", "identity already exists at ${dataDir.identityFile}; use a fresh --data-dir or delete it first")
@@ -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<NormalizedRelayUrl>,
val secret: String,
val name: String?,
)
/** Parse `nostrconnect://<client-pubkey>?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<NormalizedRelayUrl>()
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<NormalizedRelayUrl>,
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<NostrConnectEvent>(UNLIMITED)
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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
}
}