nostr-bray 3.7.1 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bunker.d.ts CHANGED
@@ -19,9 +19,21 @@ export interface BunkerOptions {
19
19
  heartwoodExtensions?: boolean;
20
20
  stateDir?: string;
21
21
  torProxy?: string;
22
+ /**
23
+ * Approve any client that sends `connect`, with no secret: the behaviour
24
+ * before connect secrets. Ignored when `authorizedKeys` is set.
25
+ */
26
+ open?: boolean;
27
+ /** Called with the new link each time a client uses up the connect secret. */
28
+ onUrlChange?: (url: string) => void;
22
29
  }
23
30
  export interface BunkerInstance {
24
- url: string;
31
+ /** bunker:// URI for clients. Carries the current connect secret, so it changes once used. */
32
+ readonly url: string;
33
+ /** Client pubkeys approved in earlier runs (from approved-clients.json), at start. */
34
+ approvedClients: readonly string[];
35
+ /** Whether a new client needs the connect secret (false with authorizedKeys or open). */
36
+ requiresSecret: boolean;
25
37
  pubkey: string;
26
38
  npub: string;
27
39
  close: () => void;
package/dist/bunker.js CHANGED
@@ -9,6 +9,7 @@
9
9
  * - Bunker responds with kind 24133 encrypted response
10
10
  * - All communication NIP-44 encrypted between client and bunker
11
11
  */
12
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
12
13
  import { getConversationKey, encrypt, decrypt } from 'nostr-tools/nip44';
13
14
  import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools/pure';
14
15
  import { npubEncode } from 'nostr-tools/nip19';
@@ -33,6 +34,50 @@ export function startBunker(opts) {
33
34
  ...cliAuthorizedKeys,
34
35
  ...bunkerApprovals,
35
36
  ]);
37
+ // Who may become a client. With --authorized-keys, only those keys (as
38
+ // before). Otherwise a new client needs the connect secret from the link,
39
+ // unless the daemon runs open. Clients approved earlier never need it.
40
+ const allowList = cliAuthorizedKeys.size > 0;
41
+ const requireSecret = !allowList && opts.open !== true;
42
+ // The unused secret is kept per bunker key, so a link handed out before a
43
+ // restart still works. Only for a persistent bunker key: an ephemeral one
44
+ // gets a new link every run anyway.
45
+ const SECRETS_FILE = 'bunker-secrets.json';
46
+ const persistSecret = requireSecret && opts.bunkerKeyHex !== undefined;
47
+ const newSecret = () => {
48
+ const secret = randomBytes(16).toString('hex');
49
+ if (persistSecret) {
50
+ const current = readStateFile(SECRETS_FILE, opts.stateDir);
51
+ current[bunkerPk] = secret;
52
+ writeStateFile(SECRETS_FILE, current, opts.stateDir);
53
+ }
54
+ return secret;
55
+ };
56
+ const savedSecret = persistSecret
57
+ ? readStateFile(SECRETS_FILE, opts.stateDir)[bunkerPk]
58
+ : undefined;
59
+ let connectSecret = requireSecret
60
+ ? (typeof savedSecret === 'string' && /^[0-9a-f]{32}$/.test(savedSecret) ? savedSecret : newSecret())
61
+ : undefined;
62
+ function secretMatches(given) {
63
+ if (connectSecret === undefined || typeof given !== 'string')
64
+ return false;
65
+ const a = Buffer.from(given);
66
+ const b = Buffer.from(connectSecret);
67
+ return a.length === b.length && timingSafeEqual(a, b);
68
+ }
69
+ function approve(clientPk) {
70
+ authorizedKeys.add(clientPk);
71
+ if (bunkerApprovals.includes(clientPk))
72
+ return;
73
+ bunkerApprovals.push(clientPk);
74
+ // Merge with the file, so a key added meanwhile by `bunker authorize` stays.
75
+ const current = readStateFile(APPROVALS_FILE, opts.stateDir);
76
+ const onDisk = Array.isArray(current[bunkerPk]) ? current[bunkerPk] : [];
77
+ current[bunkerPk] = [...new Set([...onDisk, ...bunkerApprovals])];
78
+ writeStateFile(APPROVALS_FILE, current, opts.stateDir);
79
+ log(`Approved and persisted client: ${clientPk.slice(0, 12)}...`);
80
+ }
36
81
  // Sign in (NIP-42) with the bunker key when a relay asks, so a private
37
82
  // relay lets the bunker read requests addressed to it.
38
83
  const pool = new SimplePool({ websocketImplementation: webSocketFor(opts.torProxy) });
@@ -50,8 +95,8 @@ export function startBunker(opts) {
50
95
  });
51
96
  async function handleRequest(event) {
52
97
  const clientPk = event.pubkey;
53
- // Check authorization — gate only activates when CLI --authorized-keys were set
54
- if (cliAuthorizedKeys.size > 0 && !authorizedKeys.has(clientPk)) {
98
+ // With --authorized-keys, drop anyone else unanswered, as before.
99
+ if (allowList && !authorizedKeys.has(clientPk)) {
55
100
  log(`Rejected request from unauthorized key: ${clientPk.slice(0, 12)}...`);
56
101
  return;
57
102
  }
@@ -67,19 +112,30 @@ export function startBunker(opts) {
67
112
  return;
68
113
  }
69
114
  log(`Request ${request.id}: ${request.method}`);
115
+ // A client not yet approved gets in only with the connect secret, which
116
+ // then works once: the daemon moves to a new one and reports the new link.
117
+ // Check, rotate and approve must stay free of `await`: two clients racing
118
+ // with the same secret then cannot both get in.
119
+ if (requireSecret && !authorizedKeys.has(clientPk)) {
120
+ if (request.method !== 'connect') {
121
+ log(`Ignored ${request.method} from unapproved client: ${clientPk.slice(0, 12)}...`);
122
+ return;
123
+ }
124
+ if (!secretMatches(request.params?.[1])) {
125
+ log(`Refused connect without the current secret: ${clientPk.slice(0, 12)}...`);
126
+ await respond(clientPk, conversationKey, request.id, '', 'connect refused: use the current bunker:// link from the daemon, which carries a one-time secret');
127
+ return;
128
+ }
129
+ connectSecret = newSecret();
130
+ approve(clientPk);
131
+ log('Connect secret used; moved to a new one');
132
+ opts.onUrlChange?.(buildUrl());
133
+ }
70
134
  let result;
71
135
  let error;
72
136
  switch (request.method) {
73
137
  case 'connect':
74
- // Persist newly-approved client
75
- if (!bunkerApprovals.includes(clientPk)) {
76
- bunkerApprovals.push(clientPk);
77
- authorizedKeys.add(clientPk);
78
- const current = readStateFile(APPROVALS_FILE, opts.stateDir);
79
- current[bunkerPk] = bunkerApprovals;
80
- writeStateFile(APPROVALS_FILE, current, opts.stateDir);
81
- log(`Approved and persisted client: ${clientPk.slice(0, 12)}...`);
82
- }
138
+ approve(clientPk);
83
139
  result = 'ack';
84
140
  break;
85
141
  case 'ping':
@@ -210,10 +266,12 @@ export function startBunker(opts) {
210
266
  result = '';
211
267
  error = `unsupported method: ${request.method}`;
212
268
  }
213
- // Encrypt and send response
269
+ await respond(clientPk, conversationKey, request.id, result, error);
270
+ }
271
+ async function respond(clientPk, conversationKey, id, result, error) {
214
272
  const response = error
215
- ? JSON.stringify({ id: request.id, result: '', error })
216
- : JSON.stringify({ id: request.id, result });
273
+ ? JSON.stringify({ id, result: '', error })
274
+ : JSON.stringify({ id, result });
217
275
  const encrypted = encrypt(response, conversationKey);
218
276
  const responseEvent = finalizeEvent({
219
277
  kind: 24133,
@@ -222,22 +280,30 @@ export function startBunker(opts) {
222
280
  content: encrypted,
223
281
  }, bunkerSk);
224
282
  await Promise.any(pool.publish(relays, responseEvent));
225
- log(`Response ${request.id}: ${error ?? 'ok'}`);
283
+ log(`Response ${id}: ${error ?? 'ok'}`);
226
284
  }
227
285
  // Build bunker:// URI
228
286
  const relayParams = relays.map(r => `relay=${encodeURIComponent(r)}`).join('&');
229
- const bunkerUrl = `bunker://${bunkerPk}?${relayParams}`;
287
+ const buildUrl = () => connectSecret === undefined
288
+ ? `bunker://${bunkerPk}?${relayParams}`
289
+ : `bunker://${bunkerPk}?${relayParams}&secret=${connectSecret}`;
230
290
  log(`Bunker started: ${bunkerNpub}`);
231
- log(`URI: ${bunkerUrl}`);
291
+ // The secret stays out of the log; the caller shows the link (bunker.url).
292
+ log(`URI: bunker://${bunkerPk}?${relayParams}${connectSecret === undefined ? '' : ' (plus a connect secret)'}`);
232
293
  log(`Signing as: ${ctx.activeNpub}`);
233
- if (cliAuthorizedKeys.size > 0) {
294
+ if (allowList) {
234
295
  log(`Authorized keys: ${authorizedKeys.size} (${cliAuthorizedKeys.size} CLI + ${bunkerApprovals.length} persisted)`);
235
296
  }
297
+ else if (requireSecret) {
298
+ log(`New clients need the secret in this link; it works once. ${bunkerApprovals.length} client(s) approved earlier.`);
299
+ }
236
300
  else {
237
- log('WARNING: No authorized keys set — accepting requests from anyone');
301
+ log('WARNING: running open (--open): any client that connects is approved');
238
302
  }
239
303
  return {
240
- url: bunkerUrl,
304
+ get url() { return buildUrl(); },
305
+ approvedClients: [...bunkerApprovals],
306
+ requiresSecret: requireSecret,
241
307
  pubkey: bunkerPk,
242
308
  npub: bunkerNpub,
243
309
  close: () => {
@@ -339,9 +339,32 @@ export async function dispatch(args) {
339
339
  bunkerKeyHex,
340
340
  quiet: args.includes('--quiet'),
341
341
  torProxy: config.torProxy,
342
+ open: args.includes('--open'),
343
+ onUrlChange: url => {
344
+ console.error('A new client connected, so that secret is used up. Give the next new client this link:');
345
+ console.error(url);
346
+ },
342
347
  });
348
+ if (authorizedKeys && args.includes('--open')) {
349
+ console.error('--open is ignored with --authorized-keys: only the listed clients are accepted.');
350
+ }
343
351
  console.error(`nostr-bray bunker running`);
344
352
  console.error(`URI: ${bunker.url}`);
353
+ if (authorizedKeys) {
354
+ console.error('Only the --authorized-keys clients (and clients approved earlier) are accepted.');
355
+ }
356
+ else if (!bunker.requiresSecret) {
357
+ console.error('Running open: any client that connects is approved. Anyone with the link can sign as you.');
358
+ }
359
+ else {
360
+ console.error('The link carries a one-time secret for one new client. Treat it like a password.');
361
+ console.error('It survives restarts until used; each new client needs the next link the daemon prints.');
362
+ if (bunker.approvedClients.length > 0) {
363
+ console.error(`Clients approved earlier (they need no secret; remove any you do not recognise from approved-clients.json in bray's state directory):`);
364
+ for (const pk of bunker.approvedClients)
365
+ console.error(` ${pk}`);
366
+ }
367
+ }
345
368
  console.error(`Signing as: ${bCtx.activeNpub}`);
346
369
  console.error('Press Ctrl+C to stop');
347
370
  process.on('SIGINT', () => { bunker.close(); bCtx.destroy(); process.exit(0); });
package/dist/cli/index.js CHANGED
@@ -253,7 +253,7 @@ Modes:
253
253
  bunker authorize <hex-pubkey> Pre-authorise an app pubkey on the local bunker
254
254
  bunker status Show saved bunker connection state
255
255
  bunker sign <file|-> One-shot: sign an event template via the bunker (or the saved signer)
256
- bunker daemon [--profile N] [--persona N] [--authorized-keys pk,pk] Start NIP-46 remote signer daemon
256
+ bunker daemon [--profile N] [--persona N] [--authorized-keys pk,pk] [--open] Start NIP-46 remote signer daemon
257
257
  shell Interactive REPL (persistent relay connection)
258
258
 
259
259
  Environment:
package/dist/help.js CHANGED
@@ -93,7 +93,7 @@ export const COMMAND_HELP = {
93
93
  shell: { usage: 'shell', description: 'Start an interactive REPL with a persistent relay connection. Supports tab autocomplete. Type "help" for commands, "exit" to quit.', examples: ['nostr-bray shell'] },
94
94
  'key-encrypt': { usage: 'key-encrypt <nsec-or-hex> <password>', description: 'Encrypt a secret key with a password using NIP-49. Returns an ncryptsec string safe for storage. Also returns the derived pubkey for verification.', examples: ['nostr-bray key-encrypt nsec1... mypassword'], notes: 'WARNING: secret key is passed as a CLI argument. Use only on trusted systems.' },
95
95
  'key-decrypt': { usage: 'key-decrypt <ncryptsec> <password>', description: 'Decrypt an ncryptsec (NIP-49) with a password. Returns the pubkey for verification — never the raw secret key.', examples: ['nostr-bray key-decrypt ncryptsec1... mypassword'] },
96
- bunker: { usage: 'bunker [--profile <name>] [--persona <name>] [--bunker-key-file <f>] [--authorized-keys pk1,pk2] [--quiet]', description: 'Start a NIP-46 remote signer daemon. Holds your secret key and responds to signing requests from authorised clients over Nostr relays. Clients never see your nsec. --profile <name> loads the master from an nsec-tree-cli profile (~/.nsec-tree/profiles/<name>.json); --persona <name> signs as a derived sub-identity (the same key as nsec-tree-cli "export nsec <name>"). The bunker:// stays stable across restarts — a per-identity connection key is persisted automatically.', examples: ['nostr-bray bunker', 'nostr-bray bunker --profile master --persona magazine', 'nostr-bray bunker --authorized-keys abc123...,def456...', 'nostr-bray bunker --quiet'], notes: 'Without --authorized-keys, accepts requests from anyone. Set authorized keys for production use.' },
96
+ bunker: { usage: 'bunker [--profile <name>] [--persona <name>] [--bunker-key-file <f>] [--authorized-keys pk1,pk2] [--open] [--quiet]', description: 'Start a NIP-46 remote signer daemon. Holds your secret key and responds to signing requests from authorised clients over Nostr relays. Clients never see your nsec. --profile <name> loads the master from an nsec-tree-cli profile (~/.nsec-tree/profiles/<name>.json); --persona <name> signs as a derived sub-identity (the same key as nsec-tree-cli "export nsec <name>"). The bunker:// key stays stable across restarts, since a per-identity connection key is persisted automatically. The printed link carries a one-time connect secret: a new client must connect with it, after which the daemon prints the next link, so one link cannot be shared between two apps. An unused secret survives restarts. Clients approved earlier reconnect without it and are listed at start.', examples: ['nostr-bray bunker', 'nostr-bray bunker --profile master --persona magazine', 'nostr-bray bunker --authorized-keys abc123...,def456...', 'nostr-bray bunker --quiet'], notes: 'With --authorized-keys, only those clients are accepted and no secret is used. --open approves any client that connects, with no secret, as earlier releases did; anyone who learns the link can then sign as you.' },
97
97
  serve: { usage: 'serve [--port N] [--hostname H] [--events file.jsonl] [--quiet]', description: 'Start an in-memory Nostr relay for testing. Implements NIP-01 (events, subscriptions, EOSE) and NIP-11 (relay info). Events live in memory only — no persistence. Optionally pre-load events from a JSONL file.', examples: ['nostr-bray serve', 'nostr-bray serve --port 7777', 'nostr-bray serve --events test-data.jsonl', 'nostr-bray serve --port 7777 --quiet'] },
98
98
  };
99
99
  /** Get formatted help for a single command */
package/llms-full.txt CHANGED
@@ -39,6 +39,7 @@ Single-process MCP server (stdio or HTTP transport) with 261 tools in 29 groups.
39
39
  *Authentication tiers (use exactly one):
40
40
  1. **Bunker** (NIP-46) -- most secure, keys never leave the signer
41
41
  For a signer app (Amber, nsec.app), `npx nostr-bray bunker pair --relay <wss://…>` prints a nostrconnect:// URI to paste or scan into the app. Once the app answers, bray saves the signer (a secret-free bunker:// URI marked paired, plus the client key the app approved) in its state directory, and the MCP server and CLI sign through it with no BUNKER_URI. Options: `--relay` (repeatable; defaults to NOSTR_RELAYS, then the config file relays), `--timeout <seconds>` (default 300), `--expect <npub>` (refuse any other account), `--yes` (skip the y/N prompt). In a terminal bray shows the account the signer offers and asks y/N before saving; outside one, `--expect` or `--yes` is required. The URI is a one-time credential: whoever answers it first becomes the signer. With TOR_PROXY set, pairing goes through the proxy and only .onion relays are accepted unless ALLOW_CLEARNET_WITH_TOR=1. `bunker status` reports `paired`; if the app forgets bray, start-up fails with a hint to run `bunker pair` again. An explicit BUNKER_URI or key takes precedence; `bunker connect` replaces the saved signer. A paired signer is checked with get_public_key on start, not sent connect again.
42
+ bray can also be the signer: `npx nostr-bray bunker` runs a NIP-46 daemon holding your key and prints a bunker:// link with a one-time connect secret. A new client must connect with that link; the daemon then prints the next link, so one link serves one app. An unused secret survives restarts. Clients approved earlier reconnect without the secret and are listed at start. `--authorized-keys pk1,pk2` accepts only those clients; `--open` approves any client that connects, as earlier releases did, so anyone who learns the link can sign as you.
42
43
  2. **ncryptsec** (NIP-49) -- password-encrypted key, decrypted at startup
43
44
  3. **File** -- secret read from disk, deleted from process.env after parsing
44
45
  4. **Env var** -- quickest start, lowest security
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "3.7.1",
3
+ "version": "3.8.0",
4
4
  "mcpName": "dev.forgesworn/bray",
5
5
  "description": "Trust-aware Nostr MCP server, CLI and TypeScript SDK for AI agents and humans. Three dimensions of trust -- verification, proximity, and access -- woven into every interaction.",
6
6
  "type": "module",