From 13bc33ad70bd8682d2df6783c3a59de3db9ce270 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 23:22:07 +0000 Subject: [PATCH] feat(cli): gate `amy bunker` with --perms and interactive terminal approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the CLI bunker auto-approved every request (it hosts the operator's own key, so the pairing secret was the only gate). That left two NIP-46 signer behaviors the harness couldn't exercise: a signer that *rejects* disallowed ops, and a signer that asks a human live. Add two opt-in gates to both `amy bunker` and `amy bunker connect`: - `--perms sign_event:1,nip44_encrypt,…` restricts the signer to the listed ops; anything else is rejected. Fully scriptable/headless. This is the server-side mirror of the client's `--nostrconnect --perms`, so an interop run can now test a client against a rejecting signer. - `--interactive` keeps the bunker listening and prompts `y/N` on the terminal for any op the policy doesn't already allow, so the operator approves/rejects each request live. TTY-guarded (errors on a piped stdin), default-deny, prompts serialized by a mutex because the service dispatches requests concurrently. Composes with `--perms` (auto-allow the safe ops, prompt for the rest) — mirroring Amethyst's Reasonable policy. Neither flag → unchanged auto-approve behavior. Thin assembly: the perms parsing and request→op mapping are reused from commons (`Nip46PermissionAuthorizer.parsePerms` / `toSignerOp`), both already unit-tested there. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF --- .../com/vitorpamplona/amethyst/cli/Main.kt | 8 +- .../amethyst/cli/commands/BunkerCommand.kt | 132 ++++++++++++++++-- 2 files changed, 124 insertions(+), 16 deletions(-) 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 3691a6e908..5aaba4ff9f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -473,9 +473,13 @@ 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 + | [--secret S] [--timeout SECS] bunker:// uri and signs requests until interrupt/timeout. + | [--perms P] [--interactive] --perms sign_event:1,nip44_encrypt,… restricts which ops + | are allowed (rest rejected); --interactive prompts y/N on + | the terminal for anything not pre-allowed (needs a TTY). + | Default (neither): approve everything. | bunker connect NOSTRCONNECT-URI act as signer for a client's nostrconnect:// - | [--timeout SECS] offer (acks + services its requests) + | [--perms P] [--interactive] [--timeout SECS] offer (acks + services; same gating flags) | 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, 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 index f0a04ed107..21ace10de9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -24,6 +24,9 @@ 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.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -31,6 +34,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId 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.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI @@ -38,10 +42,14 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46ConnectDecision import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46RequestAuthorizer import com.vitorpamplona.quartz.nip46RemoteSigner.server.NostrConnectSignerService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull /** - * `amy bunker [--relay URL[,URL…]] [--secret S] [--timeout SECS]` + * `amy bunker [--relay URL[,URL…]] [--secret S] [--perms P] [--interactive] [--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 @@ -53,21 +61,39 @@ import kotlinx.coroutines.withTimeoutOrNull * remotely through this bunker. Long-running — stops at `--timeout` SECS or on * interrupt. * + * By default every request is approved (the CLI bunker hosts the operator's own + * key — the pairing secret is the gate). Two opt-in gates narrow that: + * - `--perms sign_event:1,nip44_encrypt,…` restricts the signer to the listed + * ops (anything else is rejected). Fully scriptable/headless. + * - `--interactive` prompts `y/N` on the terminal for any op the policy doesn't + * already allow, so the operator approves/rejects each one live. Requires a + * TTY; composes with `--perms` (perms auto-allow, prompt for the rest). + * * Thin assembly only: the request dispatch, the encrypted wrapper and the * subscribe/serve loop all live in quartz (`BunkerRequestProcessor`, - * `NostrConnectSignerService`, `NostrConnectEvent`); this file just wires the - * CLI account's `ctx.signer` into them and auto-approves every request (a - * headless bunker for the operator's own local key). + * `NostrConnectSignerService`, `NostrConnectEvent`); the permission parsing + + * request→op mapping are reused from commons (`Nip46PermissionAuthorizer`). */ object BunkerCommand { /** - * A headless bunker authorizer: validate the connect [secret] and then - * approve every operation. The CLI bunker hosts the operator's OWN key, so - * there is no separate user to prompt — the pairing secret is the gate. + * A bunker authorizer: validate the connect [secret], then decide each op. + * + * When [gated] is false (no `--perms`, no `--interactive`) every op is + * approved — the headless default for hosting the operator's own key. When + * gated, an op is allowed if [allowAllSignKinds] covers it or it is in + * [allowedOps]; otherwise it is denied, unless [interactive] is set, in which + * case the operator is prompted on the terminal. Prompts are serialized by + * [promptLock] because the service dispatches requests concurrently. */ private class CliAuthorizer( val secret: String, + val allowedOps: List, + val allowAllSignKinds: Boolean, + val interactive: Boolean, + val gated: Boolean, ) : Nip46RequestAuthorizer { + private val promptLock = Mutex() + override suspend fun onConnect( clientPubKey: HexKey, request: BunkerRequestConnect, @@ -81,7 +107,35 @@ object BunkerCommand { override suspend fun authorize( clientPubKey: HexKey, request: BunkerRequest, - ): Boolean = true + ): Boolean { + if (!gated) return true + // Metadata ops (ping / get_public_key / get_relays) map to no op and need no grant. + val op = request.toSignerOp() ?: return true + val statically = (op is NostrSignerOp.SignKind && allowAllSignKinds) || op in allowedOps + if (statically) return true + return if (interactive) prompt(clientPubKey, request) else false + } + + /** Ask the operator on the terminal. Serialized so concurrent requests don't interleave prompts. */ + private suspend fun prompt( + clientPubKey: HexKey, + request: BunkerRequest, + ): Boolean = + promptLock.withLock { + withContext(Dispatchers.IO) { + val kindInfo = (request as? BunkerRequestSign)?.let { " (kind:${it.event.kind})" } ?: "" + System.err.println("[bunker] ${clientPubKey.take(8)}… requests ${request.method}$kindInfo") + (request as? BunkerRequestSign)?.event?.content?.take(160)?.trim()?.let { + if (it.isNotEmpty()) System.err.println(" content: ${it.replace('\n', ' ')}") + } + System.err.print("[bunker] approve? [y/N] ") + System.err.flush() + val answer = readlnOrNull()?.trim()?.lowercase() + val approved = answer == "y" || answer == "yes" + System.err.println(if (approved) "[bunker] → approved" else "[bunker] → denied") + approved + } + } } suspend fun run( @@ -101,6 +155,7 @@ object BunkerCommand { ): Int { val args = Args(rest) val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } + interactiveTtyError(args)?.let { return it } val accountError = checkHostable(dataDir) if (accountError != null) return accountError @@ -127,7 +182,9 @@ object BunkerCommand { ) System.err.println("[bunker] listening as ${self.take(8)}… on ${relays.size} relay(s); paste the bunker:// uri into `amy login`") - serve(ctx, relays, secret, timeoutMs) + val authorizer = buildAuthorizer(args, secret) + logPolicy(args) + serve(ctx, relays, authorizer, timeoutMs) return 0 } } @@ -144,6 +201,7 @@ object BunkerCommand { ): Int { val args = Args(rest) val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } + interactiveTtyError(args)?.let { return it } 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) @@ -169,11 +227,13 @@ object BunkerCommand { ), ) System.err.println("[bunker] acked nostrconnect from ${offer.clientPubkey.take(8)}…; now servicing requests") - // The CLI bunker auto-approves the operator's own key, so `perms` isn't a gate here; we surface - // it so an interop operator can see what an app-side signer would have been asked to pre-grant. + // Surface what the client asked for; whether it's honored depends on this bunker's own + // --perms/--interactive gate (below), not on the client's self-declared `perms`. offer.perms?.let { System.err.println("[bunker] client requested perms: $it") } - serve(ctx, offer.relays, offer.secret, timeoutMs) + val authorizer = buildAuthorizer(args, offer.secret) + logPolicy(args) + serve(ctx, offer.relays, authorizer, timeoutMs) return 0 } } @@ -186,18 +246,62 @@ object BunkerCommand { null } + /** + * `--interactive` prompts the operator on the terminal, so it needs a real TTY; a piped/headless + * stdin would make [readlnOrNull] return null and silently deny everything. Fail fast instead. + */ + private fun interactiveTtyError(args: Args): Int? = + if (args.bool("interactive") && System.console() == null) { + Output.error("no_tty", "--interactive needs a terminal (stdin/stdout is not a TTY); use --perms for headless gating") + } else { + null + } + + /** Builds the request gate from `--perms` / `--interactive` (both absent → approve everything). */ + private fun buildAuthorizer( + args: Args, + secret: String, + ): CliAuthorizer { + val perms = args.flag("perms")?.ifBlank { null } + val interactive = args.bool("interactive") + // parsePerms drops a bare `sign_event` (Amethyst grants per kind), so detect it here for "any kind". + val allowAllSignKinds = + perms + ?.split(',') + ?.any { it.trim().lowercase() == "sign_event" || it.trim().lowercase() == "sign" } ?: false + return CliAuthorizer( + secret = secret, + allowedOps = Nip46PermissionAuthorizer.parsePerms(perms), + allowAllSignKinds = allowAllSignKinds, + interactive = interactive, + gated = perms != null || interactive, + ) + } + + /** Tell the operator which gate is active, so an unexpectedly-restrictive run is obvious. */ + private fun logPolicy(args: Args) { + val perms = args.flag("perms")?.ifBlank { null } + val interactive = args.bool("interactive") + when { + perms != null && interactive -> System.err.println("[bunker] gate: auto-allow [$perms], prompt for the rest") + perms != null -> System.err.println("[bunker] gate: allow only [$perms], reject the rest") + interactive -> System.err.println("[bunker] gate: prompt on the terminal for every op") + else -> System.err.println("[bunker] gate: auto-approve every request (secret is the only gate)") + } + } + /** Subscribe for kind:24133 requests addressed to us and service them until timeout/interrupt. */ private suspend fun serve( ctx: Context, relays: Set, - secret: String, + authorizer: CliAuthorizer, timeoutMs: Long?, ) { val processor = BunkerRequestProcessor( signer = ctx.signer, relays = { relays }, - authorizer = CliAuthorizer(secret), + authorizer = authorizer, ) val service = NostrConnectSignerService(