mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 09:13:23 +00:00
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:
+8
-2
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user