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:
Claude
2026-06-21 19:38:56 +00:00
parent 69d03bacca
commit 54a6b44324
8 changed files with 364 additions and 5 deletions
+21
View File
@@ -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
View File
@@ -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,
)