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 +13 -1
- package/dist/bunker.js +86 -20
- package/dist/cli/commands/bunker.js +23 -0
- package/dist/cli/index.js +1 -1
- package/dist/help.js +1 -1
- package/llms-full.txt +1 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
//
|
|
54
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
216
|
-
: JSON.stringify({ id
|
|
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 ${
|
|
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
|
|
287
|
+
const buildUrl = () => connectSecret === undefined
|
|
288
|
+
? `bunker://${bunkerPk}?${relayParams}`
|
|
289
|
+
: `bunker://${bunkerPk}?${relayParams}&secret=${connectSecret}`;
|
|
230
290
|
log(`Bunker started: ${bunkerNpub}`);
|
|
231
|
-
log(
|
|
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 (
|
|
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:
|
|
301
|
+
log('WARNING: running open (--open): any client that connects is approved');
|
|
238
302
|
}
|
|
239
303
|
return {
|
|
240
|
-
url
|
|
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
|
|
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.
|
|
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",
|