From 54a6b4432487102e1cc33dd558d808aed223b0ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 19:38:56 +0000 Subject: [PATCH] =?UTF-8?q?feat(cli):=20NIP-46=20bunker=20=E2=80=94=20remo?= =?UTF-8?q?te=20signer=20server=20+=20bunker=20login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add both halves of NIP-46 remote signing so two amy processes interop: - `amy bunker [--relay …] [--secret S] [--timeout SECS]` runs a remote signer for the active local-key account: prints a bunker:// URI, then subscribes to kind:24133, decrypts each BunkerRequest, dispatches to ctx.signer (connect/get_public_key/sign_event/nip04/nip44/ping), and publishes the encrypted BunkerResponse. Long-running like `subscribe`. - `amy login bunker://PUBKEY?relay=…&secret=…` creates a remote-signer account: mints a local transport keypair, records the connection, and acts as PUBKEY. Context builds a NostrSignerRemote (vs the local NostrSignerInternal) and runs openSubscription()+connect() in prepare(); every signing/encryption call is delegated to the bunker. Storage: IdentityFile gains a `bunker` block; the transport key is kept in the existing SecretStore `secret` field. Identity/DataDir load+save handle the remote-signer account type; `canSign` reflects "writeable via bunker". Thin assembly over quartz nip46RemoteSigner (NostrSignerRemote, BunkerRequest*/BunkerResponse*, NostrConnectEvent). Verified end-to-end over relay.damus.io: alice hosts a bunker, bob logs in and `amy event` signs remotely — the note is authored by alice's key and verifies (id_ok + signature_ok); bunker logs connect→ok, sign_event→ok. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY --- cli/README.md | 21 ++ cli/ROADMAP.md | 3 +- .../com/vitorpamplona/amethyst/cli/Config.kt | 86 ++++++++ .../com/vitorpamplona/amethyst/cli/Context.kt | 35 +++- .../com/vitorpamplona/amethyst/cli/Main.kt | 10 +- .../amethyst/cli/commands/BunkerCommand.kt | 192 ++++++++++++++++++ .../amethyst/cli/commands/LoginCommand.kt | 6 +- .../amethyst/cli/secrets/IdentitySecret.kt | 16 ++ 8 files changed, 364 insertions(+), 5 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt diff --git a/cli/README.md b/cli/README.md index 416f23dbd9..268306027b 100644 --- a/cli/README.md +++ b/cli/README.md @@ -216,6 +216,27 @@ Army-knife verbs that operate purely on their arguments. They never touch | `amy filter [filter flags]` | Assemble and print a NIP-01 filter JSON from the same flags `fetch`/`subscribe` use — no query is sent. | | `amy relay info URL` | Fetch and print a relay's NIP-11 information document. | +### Remote signing (NIP-46 bunker) + +Two amy processes can talk: one **hosts** a bunker with its local key; the other **logs in** through it and signs remotely (events come out authored by the host's key). + +| 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. | + +Example (two terminals, shared `$HOME`): + +```bash +# terminal 1 — host alice's key as a bunker +amy --account alice bunker --relay wss://relay.example --secret s3cret +# → bunker://?relay=wss://relay.example/&secret=s3cret + +# terminal 2 — bob signs through it +amy --account bob login 'bunker://?relay=wss://relay.example/&secret=s3cret' +amy --account bob event --kind 1 --content "signed remotely" # authored by alice +``` + ### Raw events | Command | What it does | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index aafe3e17a3..2206a6beec 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -97,7 +97,8 @@ 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` / `serve` / `admin` / `wallet` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | +| `bunker` | `amy bunker` + `amy login bunker://` | ✅ | NIP-46 remote signer (server) + bunker login (client). Two amy processes interop. | +| `serve` / `admin` / `wallet` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | **Tier 1 status:** shipped — `decode`, `encode`, `verify`, `key`, `event`, `publish`, `fetch`, `subscribe`, `count`, `encrypt`, `decrypt`, `gift`, 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 f8b10c6c22..80dcef8ef2 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Config.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.cli import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.cli.secrets.BunkerFile import com.vitorpamplona.amethyst.cli.secrets.IdentityFile import com.vitorpamplona.amethyst.cli.secrets.IdentitySecret import com.vitorpamplona.amethyst.cli.secrets.SecretStore @@ -48,10 +49,15 @@ data class Identity( val pubKeyHex: String, val nsec: String?, val npub: String, + val bunker: Bunker? = null, ) { @get:com.fasterxml.jackson.annotation.JsonIgnore val hasPrivateKey: Boolean get() = privKeyHex != null + /** Whether this identity can sign — directly (local key) or via a bunker. */ + @get:com.fasterxml.jackson.annotation.JsonIgnore + val canSign: Boolean get() = privKeyHex != null || bunker != null + fun keyPair(): KeyPair = if (privKeyHex != null) { KeyPair(privKey = privKeyHex.hexToByteArray(), pubKey = pubKeyHex.hexToByteArray()) @@ -59,11 +65,49 @@ data class Identity( KeyPair(pubKey = pubKeyHex.hexToByteArray()) } + /** The local transport keypair for a NIP-46 bunker account. */ + fun clientKeyPair(): KeyPair = KeyPair(privKey = bunker!!.clientPrivKeyHex.hexToByteArray()) + companion object { fun create(): Identity = fromPrivateKey(KeyPair().privKey!!) fun fromNsec(nsec: String): Identity = fromPrivateKey(nsec.bechToBytes()) + /** + * Parse a `bunker://?relay=…&secret=…` URI into a + * remote-signer identity. The account acts as `remote-pubkey` (the + * key the bunker signs with); a fresh local keypair is minted for the + * NIP-46 transport. Mirrors the parsing in Quartz's + * `NostrSignerRemote.fromBunkerUri`. + */ + fun fromBunkerUri(uri: String): Identity { + require(uri.startsWith("bunker://")) { "not a bunker:// uri" } + val parts = uri.removePrefix("bunker://").split("?", limit = 2) + val remotePubkey = parts[0].lowercase() + require(remotePubkey.length == 64 && remotePubkey.all { it in "0123456789abcdef" }) { + "bunker uri must carry a 64-hex remote pubkey" + } + val relays = mutableListOf() + var secret: String? = null + parts.getOrNull(1)?.split("&")?.forEach { param -> + val kv = param.split("=", limit = 2) + if (kv.size < 2) return@forEach + when (kv[0]) { + "relay" -> relays.add(kv[1]) + "secret" -> secret = kv[1] + } + } + 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), + ) + } + fun fromPrivateKey(priv: ByteArray): Identity { val pub = KeyPair(privKey = priv).pubKey return Identity( @@ -93,16 +137,31 @@ data class Identity( pubKeyHex: String, npub: String, privKeyHex: String?, + bunker: Bunker? = null, ): Identity = Identity( privKeyHex = privKeyHex, pubKeyHex = pubKeyHex, nsec = privKeyHex?.hexToByteArray()?.toNsec(), npub = npub, + bunker = bunker, ) } } +/** + * In-memory NIP-46 bunker connection. [clientPrivKeyHex] is the local + * transport key (persisted via the [SecretStore], not in the clear); + * [remotePubkey] is the user key the bunker signs as. + */ +data class Bunker( + val remotePubkey: String, + val relays: List, + val connectSecret: String?, + @get:com.fasterxml.jackson.annotation.JsonIgnore + val clientPrivKeyHex: String, +) + /** Opaque per-run state (subscription cursors, etc). Stored alongside identity. */ data class RunState( var giftWrapSince: Long? = null, @@ -165,6 +224,18 @@ class DataDir( */ fun loadIdentityOrNull(): Identity? { val file = loadIdentityFileOrNull() ?: return null + // Bunker (NIP-46) account: `secret` holds the local transport key, and + // `bunker` records the remote signer. The account has no user privkey. + file.bunker?.let { b -> + val clientPriv = file.secret?.let { secrets.resolve(it) } ?: file.privKeyHex + requireNotNull(clientPriv) { "bunker identity is missing its transport key" } + return Identity.fromDisk( + pubKeyHex = file.pubKeyHex, + npub = file.npub, + privKeyHex = null, + bunker = Bunker(b.remotePubkey, b.relays, b.connectSecret, clientPriv), + ) + } val privHex: String? = when { file.secret != null -> secrets.resolve(file.secret) @@ -182,6 +253,21 @@ class DataDir( * to disk. Read-only identities persist `secret: null`. */ fun saveIdentity(id: Identity) { + if (id.bunker != null) { + // Persist the local transport key under its own pubkey; record the + // remote signer connection alongside it. + val clientPub = id.clientKeyPair().pubKey.toHexKey() + val secret = secrets.store(clientPub, id.bunker.clientPrivKeyHex) + val file = + IdentityFile( + pubKeyHex = id.pubKeyHex, + npub = id.npub, + secret = secret, + bunker = BunkerFile(id.bunker.remotePubkey, id.bunker.relays, id.bunker.connectSecret), + ) + SecureFileIO.writeTextAtomic(identityFile, Output.mapper.writeValueAsString(file)) + return + } val secret: IdentitySecret? = id.privKeyHex?.let { secrets.store(id.pubKeyHex, it) } val file = IdentityFile(pubKeyHex = id.pubKeyHex, npub = id.npub, secret = secret) SecureFileIO.writeTextAtomic(identityFile, Output.mapper.writeValueAsString(file)) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 6ab7d37abb..77edbb6699 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -42,12 +42,15 @@ 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.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.fs.FsEventStore import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import kotlinx.coroutines.CompletableDeferred @@ -93,8 +96,6 @@ class Context( val identity: Identity, val state: RunState, ) : AutoCloseable { - val signer = NostrSignerInternal(identity.keyPair()) - private val okhttp = OkHttpClient.Builder().build() val client: NostrClient = @@ -102,6 +103,24 @@ class Context( websocketBuilder = BasicOkHttpWebSocket.Builder { okhttp }, ) + /** + * The account's signer. For a local account this is a [NostrSignerInternal] + * over the stored key; for a NIP-46 bunker account it is a + * [NostrSignerRemote] that delegates signing/encryption to the remote + * signer over [client]. The remote signer's subscription + connect + * handshake are driven from [prepare]. + */ + val signer: NostrSigner = + identity.bunker?.let { b -> + NostrSignerRemote( + signer = NostrSignerInternal(identity.clientKeyPair()), + remotePubkey = b.remotePubkey, + relays = b.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet(), + client = client, + secret = b.connectSecret, + ) + } ?: NostrSignerInternal(identity.keyPair()) + /** * NIP-05 resolver for turning `alice@damus.io`-style identifiers into pubkeys. * Uses the same OkHttp instance as the WebSocket client so we share connection @@ -152,6 +171,12 @@ class Context( if (prepared) return marmot.restoreAll() client.connect() + // A bunker account must open its NIP-46 response subscription and run + // the connect handshake before any signing/encryption call. + (signer as? NostrSignerRemote)?.let { + it.openSubscription() + it.connect() + } prepared = true } @@ -608,6 +633,12 @@ class Context( override fun close() { dataDir.saveRunState(state) + (signer as? NostrSignerRemote)?.let { + try { + it.closeSubscription() + } catch (_: Exception) { + } + } try { client.close() } catch (_: Exception) { 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 22ffa2f68a..2df33fbbb9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.cli import com.vitorpamplona.amethyst.cli.commands.AwaitCommands import com.vitorpamplona.amethyst.cli.commands.BlossomCommands +import com.vitorpamplona.amethyst.cli.commands.BunkerCommand import com.vitorpamplona.amethyst.cli.commands.CountCommand import com.vitorpamplona.amethyst.cli.commands.CreateCommand import com.vitorpamplona.amethyst.cli.commands.DebitCommands @@ -213,6 +214,7 @@ private suspend fun dispatch(argv: Array): Int { "sync" -> SyncCommand.run(dataDir, tail) "git" -> GitCommands.dispatch(dataDir, tail) "podcast" -> PodcastCommands.dispatch(dataDir, tail) + "bunker" -> BunkerCommand.run(dataDir, tail) else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -346,9 +348,15 @@ private fun printUsage() { |Identity: | init [--nsec NSEC] create or import a bare identity (no defaults published) | create [--name NAME] provision a full Amethyst-style account + publish bootstrap events - | login KEY [--password X] import (nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05) + | login KEY [--password X] import (nsec|ncryptsec|mnemonic|npub|nprofile|hex|nip05|bunker://) | whoami print current identity | + |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 + | login bunker://PUBKEY?relay=…&secret=… sign through a remote bunker (mints a local + | transport key; the account acts as PUBKEY) + | |Relays: | relay add URL [--type T] T=nip65|inbox|key_package|all (default all) | relay list print configured relays 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 new file mode 100644 index 0000000000..ad26b24d60 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -0,0 +1,192 @@ +/* + * 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.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +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.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.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck +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.BunkerResponsePong +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import kotlinx.coroutines.withTimeoutOrNull + +/** + * `amy bunker [--relay URL[,URL…]] [--secret S] [--timeout SECS]` + * + * Run a NIP-46 remote signer (a "bunker") for the active LOCAL account + * (nak's `bunker`). Prints a `bunker://…` connection string, then listens on + * the relays for kind:24133 requests, decrypts each one, performs it with the + * account's key (sign / nip04 / nip44 / get_public_key / ping), and publishes + * the encrypted reply. + * + * Pair it with `amy login bunker://…` in another amy: that account then signs + * remotely through this bunker. Long-running — stops at `--timeout` SECS or on + * interrupt. + * + * Thin assembly only: every request/response type + the encrypted wrapper + * live in quartz (`BunkerRequest*`, `BunkerResponse*`, `NostrConnectEvent`); + * this file dispatches to `ctx.signer`. + */ +object BunkerCommand { + suspend fun run( + 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") + } + + 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() + + val relays = RawEventSupport.relayFlag(args).ifEmpty { ctx.outboxRelays() } + if (relays.isEmpty()) return Output.error("no_relays", "no relays available; pass --relay or run `amy relay add`") + val secret = args.flag("secret") ?: KeyPair().privKey!!.toHexKey().take(32) + val self = ctx.identity.pubKeyHex + + val uri = + buildString { + append("bunker://").append(self) + append("?").append(relays.joinToString("&") { "relay=${it.url}" }) + append("&secret=").append(secret) + } + Output.emit( + mapOf( + "bunker_uri" to uri, + "pubkey" to self, + "relays" to relays.map { it.url }, + "secret" to secret, + ), + ) + 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() + } + return 0 + } + } + + private suspend fun handle( + ctx: Context, + event: NostrConnectEvent, + secret: String, + relays: Set, + ) { + val signer = ctx.signer + val client = event.talkingWith(signer.pubKey) + val request = + try { + event.decryptMessage(signer) as? BunkerRequest ?: return + } catch (e: Exception) { + System.err.println("[bunker] could not decrypt request ${event.id.take(8)}: ${e.message}") + return + } + + val response: BunkerResponse = + try { + when (request) { + is BunkerRequestConnect -> + if (request.secret == secret) { + BunkerResponseAck(request.id) + } else { + BunkerResponseError(request.id, "invalid secret") + } + is BunkerRequestGetPublicKey -> BunkerResponsePublicKey(request.id, signer.pubKey) + is BunkerRequestPing -> BunkerResponsePong(request.id) + is BunkerRequestSign -> { + val signed = signer.sign(request.event.createdAt, request.event.kind, request.event.tags, request.event.content) + BunkerResponseEvent(request.id, signed) + } + is BunkerRequestNip04Encrypt -> BunkerResponseEncrypt(request.id, signer.nip04Encrypt(request.message, request.pubKey)) + is BunkerRequestNip04Decrypt -> BunkerResponseDecrypt(request.id, signer.nip04Decrypt(request.ciphertext, request.pubKey)) + is BunkerRequestNip44Encrypt -> BunkerResponseEncrypt(request.id, signer.nip44Encrypt(request.message, request.pubKey)) + is BunkerRequestNip44Decrypt -> BunkerResponseDecrypt(request.id, signer.nip44Decrypt(request.ciphertext, request.pubKey)) + else -> BunkerResponseError(request.id, "unsupported method: ${request.method}") + } + } catch (e: Exception) { + BunkerResponseError(request.id, "${e::class.simpleName}: ${e.message}") + } + + System.err.println("[bunker] ${request.method} from ${client.take(8)}… → ${if (response is BunkerResponseError) "error: ${response.error}" else "ok"}") + + try { + val reply = NostrConnectEvent.create(response, client, signer) + ctx.client.publish(reply, relays) + } catch (e: Exception) { + System.err.println("[bunker] failed to send reply for ${request.method}: ${e.message}") + } + } +} 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 c7aa12e330..232fe25286 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 @@ -71,7 +71,9 @@ object LoginCommand { mapOf( "npub" to identity.npub, "hex" to identity.pubKeyHex, - "read_only" to !identity.hasPrivateKey, + "read_only" to !identity.canSign, + "signer" to if (identity.bunker != null) "bunker" else "local", + "bunker_relays" to identity.bunker?.relays, "data_dir" to dataDir.root.absolutePath, ), ) @@ -82,6 +84,8 @@ object LoginCommand { key: String, args: Args, ): Identity? { + // 0. NIP-46 bunker connection string. + if (key.startsWith("bunker://")) return Identity.fromBunkerUri(key) // 1. ncryptsec — password mandatory. if (key.startsWith("ncryptsec")) { val pw = diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt index f91000fd04..ea1f670120 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/secrets/IdentitySecret.kt @@ -80,4 +80,20 @@ data class IdentityFile( // never written by current code. val privKeyHex: String? = null, val nsec: String? = null, + // Present for NIP-46 remote-signer (bunker) accounts. [pubKeyHex] is the + // user pubkey the bunker signs as; [secret] holds the LOCAL transport + // keypair used to encrypt NIP-46 traffic (not the user's key). + val bunker: BunkerFile? = null, +) + +/** + * NIP-46 bunker connection persisted in `identity.json`. The transport + * (client) private key lives in [IdentityFile.secret]; this records where + * to reach the remote signer and the optional connect secret. + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +data class BunkerFile( + val remotePubkey: String, + val relays: List, + val connectSecret: String? = null, )