mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
feat(cli): NIP-46 bunker — remote signer server + bunker login
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
This commit is contained in:
@@ -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://<alice-pubkey>?relay=wss://relay.example/&secret=s3cret
|
||||
|
||||
# terminal 2 — bob signs through it
|
||||
amy --account bob login 'bunker://<alice-pubkey>?relay=wss://relay.example/&secret=s3cret'
|
||||
amy --account bob event --kind 1 --content "signed remotely" # authored by alice
|
||||
```
|
||||
|
||||
### Raw events
|
||||
|
||||
| Command | What it does |
|
||||
|
||||
+2
-1
@@ -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`,
|
||||
|
||||
@@ -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://<remote-pubkey>?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<String>()
|
||||
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<String>,
|
||||
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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<String>): 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
|
||||
|
||||
@@ -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<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")
|
||||
}
|
||||
|
||||
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<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()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handle(
|
||||
ctx: Context,
|
||||
event: NostrConnectEvent,
|
||||
secret: String,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
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<Event>(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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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<String>,
|
||||
val connectSecret: String? = null,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user