nostr-bray 1.26.1 → 1.27.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.
@@ -20,6 +20,8 @@ export interface BunkerConfig {
20
20
  }
21
21
  /** Parse a bunker:// URI */
22
22
  export declare function parseBunkerUri(uri: string): BunkerConfig;
23
+ /** Resolve the client secret key: URI secret > cached > generate & persist. */
24
+ export declare function resolveClientKey(config: BunkerConfig, stateDir?: string): Uint8Array;
23
25
  export declare class BunkerContext implements SigningContext {
24
26
  protected signer: BunkerSigner;
25
27
  protected pool: SimplePool;
@@ -14,6 +14,7 @@ import { useWebSocketImplementation, SimplePool } from 'nostr-tools/pool';
14
14
  import { generateSecretKey } from 'nostr-tools/pure';
15
15
  import { npubEncode } from 'nostr-tools/nip19';
16
16
  import WebSocket from 'ws';
17
+ import { readStateFile, writeStateFile } from './state.js';
17
18
  useWebSocketImplementation(WebSocket);
18
19
  /** Parse a bunker:// URI */
19
20
  export function parseBunkerUri(uri) {
@@ -27,6 +28,21 @@ export function parseBunkerUri(uri) {
27
28
  }
28
29
  return { pubkey, relays, secret };
29
30
  }
31
+ const CLIENT_KEYS_FILE = 'client-keys.json';
32
+ /** Resolve the client secret key: URI secret > cached > generate & persist. */
33
+ export function resolveClientKey(config, stateDir) {
34
+ if (config.secret) {
35
+ return Buffer.from(config.secret, 'hex');
36
+ }
37
+ const cache = readStateFile(CLIENT_KEYS_FILE, stateDir);
38
+ if (cache[config.pubkey]) {
39
+ return Buffer.from(cache[config.pubkey], 'hex');
40
+ }
41
+ const sk = generateSecretKey();
42
+ cache[config.pubkey] = Buffer.from(sk).toString('hex');
43
+ writeStateFile(CLIENT_KEYS_FILE, cache, stateDir);
44
+ return sk;
45
+ }
30
46
  export class BunkerContext {
31
47
  signer;
32
48
  pool;
@@ -40,9 +56,7 @@ export class BunkerContext {
40
56
  /** Connect to a remote bunker. Blocks until the connection is established. */
41
57
  static async connect(uri, timeoutMs = 15_000) {
42
58
  const config = parseBunkerUri(uri);
43
- const clientSk = config.secret
44
- ? Buffer.from(config.secret, 'hex')
45
- : generateSecretKey();
59
+ const clientSk = resolveClientKey(config);
46
60
  const pool = new SimplePool();
47
61
  const signer = BunkerSigner.fromBunker(clientSk, { pubkey: config.pubkey, relays: config.relays, secret: null }, { pool });
48
62
  // Connect and verify
package/dist/bunker.d.ts CHANGED
@@ -17,6 +17,7 @@ export interface BunkerOptions {
17
17
  bunkerKeyHex?: string;
18
18
  quiet?: boolean;
19
19
  heartwoodExtensions?: boolean;
20
+ stateDir?: string;
20
21
  }
21
22
  export interface BunkerInstance {
22
23
  url: string;
package/dist/bunker.js CHANGED
@@ -14,17 +14,26 @@ import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools/pure
14
14
  import { npubEncode } from 'nostr-tools/nip19';
15
15
  import { useWebSocketImplementation, SimplePool } from 'nostr-tools/pool';
16
16
  import WebSocket from 'ws';
17
+ import { readStateFile, writeStateFile } from './state.js';
17
18
  useWebSocketImplementation(WebSocket);
18
19
  export function startBunker(opts) {
19
20
  const { ctx, relays, quiet } = opts;
20
- const authorizedKeys = new Set(opts.authorizedKeys ?? []);
21
21
  const log = quiet ? () => { } : (...args) => console.error('[bunker]', ...args);
22
- // Use persistent key if provided, otherwise generate ephemeral
22
+ // Bunker keypair — must be computed first so we can load persisted approvals
23
23
  const bunkerSk = opts.bunkerKeyHex
24
24
  ? Buffer.from(opts.bunkerKeyHex, 'hex')
25
25
  : generateSecretKey();
26
26
  const bunkerPk = getPublicKey(bunkerSk);
27
27
  const bunkerNpub = npubEncode(bunkerPk);
28
+ // Authorised clients: CLI flag controls the gate, persisted approvals supplement
29
+ const APPROVALS_FILE = 'approved-clients.json';
30
+ const persisted = readStateFile(APPROVALS_FILE, opts.stateDir);
31
+ const bunkerApprovals = persisted[bunkerPk] ?? [];
32
+ const cliAuthorizedKeys = new Set(opts.authorizedKeys ?? []);
33
+ const authorizedKeys = new Set([
34
+ ...cliAuthorizedKeys,
35
+ ...bunkerApprovals,
36
+ ]);
28
37
  const pool = new SimplePool();
29
38
  // Subscribe to kind 24133 events addressed to us
30
39
  const sub = pool.subscribeMany(relays, { kinds: [24133], '#p': [bunkerPk] }, {
@@ -39,8 +48,8 @@ export function startBunker(opts) {
39
48
  });
40
49
  async function handleRequest(event) {
41
50
  const clientPk = event.pubkey;
42
- // Check authorization
43
- if (authorizedKeys.size > 0 && !authorizedKeys.has(clientPk)) {
51
+ // Check authorization — gate only activates when CLI --authorized-keys were set
52
+ if (cliAuthorizedKeys.size > 0 && !authorizedKeys.has(clientPk)) {
44
53
  log(`Rejected request from unauthorized key: ${clientPk.slice(0, 12)}...`);
45
54
  return;
46
55
  }
@@ -60,6 +69,15 @@ export function startBunker(opts) {
60
69
  let error;
61
70
  switch (request.method) {
62
71
  case 'connect':
72
+ // Persist newly-approved client
73
+ if (!bunkerApprovals.includes(clientPk)) {
74
+ bunkerApprovals.push(clientPk);
75
+ authorizedKeys.add(clientPk);
76
+ const current = readStateFile(APPROVALS_FILE, opts.stateDir);
77
+ current[bunkerPk] = bunkerApprovals;
78
+ writeStateFile(APPROVALS_FILE, current, opts.stateDir);
79
+ log(`Approved and persisted client: ${clientPk.slice(0, 12)}...`);
80
+ }
63
81
  result = 'ack';
64
82
  break;
65
83
  case 'ping':
@@ -188,8 +206,8 @@ export function startBunker(opts) {
188
206
  log(`Bunker started: ${bunkerNpub}`);
189
207
  log(`URI: ${bunkerUrl}`);
190
208
  log(`Signing as: ${ctx.activeNpub}`);
191
- if (authorizedKeys.size > 0) {
192
- log(`Authorized keys: ${authorizedKeys.size}`);
209
+ if (cliAuthorizedKeys.size > 0) {
210
+ log(`Authorized keys: ${authorizedKeys.size} (${cliAuthorizedKeys.size} CLI + ${bunkerApprovals.length} persisted)`);
193
211
  }
194
212
  else {
195
213
  log('WARNING: No authorized keys set — accepting requests from anyone');
@@ -0,0 +1,4 @@
1
+ /** Read and parse a JSON state file. Returns `{}` if missing or corrupt. */
2
+ export declare function readStateFile<T = Record<string, unknown>>(name: string, stateDir?: string): T;
3
+ /** Write a JSON state file with 0600 permissions. Creates directory if needed. */
4
+ export declare function writeStateFile(name: string, data: unknown, stateDir?: string): void;
package/dist/state.js ADDED
@@ -0,0 +1,23 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ const DEFAULT_STATE_DIR = join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'bray');
5
+ /** Read and parse a JSON state file. Returns `{}` if missing or corrupt. */
6
+ export function readStateFile(name, stateDir = DEFAULT_STATE_DIR) {
7
+ const path = join(stateDir, name);
8
+ try {
9
+ return JSON.parse(readFileSync(path, 'utf-8'));
10
+ }
11
+ catch {
12
+ return {};
13
+ }
14
+ }
15
+ /** Write a JSON state file with 0600 permissions. Creates directory if needed. */
16
+ export function writeStateFile(name, data, stateDir = DEFAULT_STATE_DIR) {
17
+ if (!existsSync(stateDir)) {
18
+ mkdirSync(stateDir, { recursive: true });
19
+ }
20
+ const path = join(stateDir, name);
21
+ writeFileSync(path, JSON.stringify(data, null, 2), { mode: 0o600 });
22
+ chmodSync(path, 0o600);
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "1.26.1",
3
+ "version": "1.27.0",
4
4
  "description": "Trust-aware Nostr for AI and humans. Three dimensions of trust -- verification, proximity, and access -- woven into every interaction.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",