nostr-bray 1.26.0 → 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,43 @@
1
+ /**
2
+ * NIP-17 gift-wrap helpers that work with any SigningContext (local keys OR remote bunker).
3
+ *
4
+ * nostr-tools' wrapEvent/unwrapEvent require raw private key bytes, which are
5
+ * unavailable when signing is delegated to a NIP-46 bunker. These helpers
6
+ * reimplement the NIP-59 gift-wrap flow using the SigningContext interface
7
+ * (nip44Encrypt, nip44Decrypt, getSigningFunction) so that both local-key
8
+ * and bunker modes work identically.
9
+ */
10
+ import type { Event as NostrEvent } from 'nostr-tools';
11
+ import type { SigningContext } from './signing-context.js';
12
+ interface Recipient {
13
+ publicKey: string;
14
+ relayUrl?: string;
15
+ }
16
+ /**
17
+ * Wrap a DM as a NIP-17 gift-wrapped event using the SigningContext.
18
+ *
19
+ * Flow (NIP-59):
20
+ * 1. Build unsigned rumor (kind 14) with sender pubkey + message
21
+ * 2. Create seal (kind 13): NIP-44 encrypt rumor with sender key, sign with sender key
22
+ * 3. Create gift wrap (kind 1059): NIP-44 encrypt seal with random ephemeral key
23
+ *
24
+ * The sender's private key is never accessed directly -- all crypto operations
25
+ * go through ctx.nip44Encrypt() and ctx.getSigningFunction(), which delegate
26
+ * to the bunker in NIP-46 mode.
27
+ */
28
+ export declare function wrapEventAsync(ctx: SigningContext, recipient: Recipient, message: string): Promise<NostrEvent>;
29
+ /**
30
+ * Unwrap a NIP-17 gift-wrapped event using the SigningContext.
31
+ *
32
+ * Flow:
33
+ * 1. Decrypt the gift wrap (kind 1059) content to get the seal
34
+ * 2. Decrypt the seal (kind 13) content to get the rumor
35
+ * 3. Return the rumor (the actual DM content)
36
+ */
37
+ export declare function unwrapEventAsync(ctx: SigningContext, wrap: NostrEvent): Promise<{
38
+ pubkey: string;
39
+ content: string;
40
+ created_at: number;
41
+ tags: string[][];
42
+ }>;
43
+ export {};
@@ -0,0 +1,91 @@
1
+ /**
2
+ * NIP-17 gift-wrap helpers that work with any SigningContext (local keys OR remote bunker).
3
+ *
4
+ * nostr-tools' wrapEvent/unwrapEvent require raw private key bytes, which are
5
+ * unavailable when signing is delegated to a NIP-46 bunker. These helpers
6
+ * reimplement the NIP-59 gift-wrap flow using the SigningContext interface
7
+ * (nip44Encrypt, nip44Decrypt, getSigningFunction) so that both local-key
8
+ * and bunker modes work identically.
9
+ */
10
+ import { generateSecretKey, finalizeEvent, getEventHash } from 'nostr-tools/pure';
11
+ const TWO_DAYS = 2 * 24 * 60 * 60;
12
+ const now = () => Math.round(Date.now() / 1000);
13
+ const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS);
14
+ /** Build the unsigned kind-14 rumor (inner DM event). */
15
+ function createRumor(senderPubkeyHex, recipient, message) {
16
+ const recipients = Array.isArray(recipient) ? recipient : [recipient];
17
+ const tags = [];
18
+ for (const r of recipients) {
19
+ tags.push(r.relayUrl ? ['p', r.publicKey, r.relayUrl] : ['p', r.publicKey]);
20
+ }
21
+ const rumor = {
22
+ created_at: Math.ceil(Date.now() / 1000),
23
+ kind: 14,
24
+ tags,
25
+ content: message,
26
+ pubkey: senderPubkeyHex,
27
+ };
28
+ rumor.id = getEventHash(rumor);
29
+ return rumor;
30
+ }
31
+ /**
32
+ * Wrap a DM as a NIP-17 gift-wrapped event using the SigningContext.
33
+ *
34
+ * Flow (NIP-59):
35
+ * 1. Build unsigned rumor (kind 14) with sender pubkey + message
36
+ * 2. Create seal (kind 13): NIP-44 encrypt rumor with sender key, sign with sender key
37
+ * 3. Create gift wrap (kind 1059): NIP-44 encrypt seal with random ephemeral key
38
+ *
39
+ * The sender's private key is never accessed directly -- all crypto operations
40
+ * go through ctx.nip44Encrypt() and ctx.getSigningFunction(), which delegate
41
+ * to the bunker in NIP-46 mode.
42
+ */
43
+ export async function wrapEventAsync(ctx, recipient, message) {
44
+ const senderPubkeyHex = ctx.activePublicKeyHex;
45
+ // 1. Build the rumor (unsigned kind-14)
46
+ const rumor = createRumor(senderPubkeyHex, recipient, message);
47
+ // 2. Create the seal (kind 13) -- encrypted with sender's key, signed by sender
48
+ const sealContent = await ctx.nip44Encrypt(recipient.publicKey, JSON.stringify(rumor));
49
+ const sign = ctx.getSigningFunction();
50
+ const seal = await sign({
51
+ kind: 13,
52
+ content: sealContent,
53
+ created_at: randomNow(),
54
+ tags: [],
55
+ });
56
+ // 3. Create the gift wrap (kind 1059) -- encrypted with random ephemeral key
57
+ const randomSk = generateSecretKey();
58
+ const { getConversationKey, encrypt } = await import('nostr-tools/nip44');
59
+ const ck = getConversationKey(randomSk, recipient.publicKey);
60
+ const wrapContent = encrypt(JSON.stringify(seal), ck);
61
+ const wrap = finalizeEvent({
62
+ kind: 1059,
63
+ content: wrapContent,
64
+ created_at: randomNow(),
65
+ tags: [['p', recipient.publicKey]],
66
+ }, randomSk);
67
+ // Zeroise the ephemeral key
68
+ randomSk.fill(0);
69
+ return wrap;
70
+ }
71
+ /**
72
+ * Unwrap a NIP-17 gift-wrapped event using the SigningContext.
73
+ *
74
+ * Flow:
75
+ * 1. Decrypt the gift wrap (kind 1059) content to get the seal
76
+ * 2. Decrypt the seal (kind 13) content to get the rumor
77
+ * 3. Return the rumor (the actual DM content)
78
+ */
79
+ export async function unwrapEventAsync(ctx, wrap) {
80
+ // 1. Decrypt outer layer (gift wrap -> seal)
81
+ // The wrap was encrypted by a random ephemeral key; we decrypt using
82
+ // our key + the wrap's pubkey (the ephemeral key's public half).
83
+ const sealJson = await ctx.nip44Decrypt(wrap.pubkey, wrap.content);
84
+ const seal = JSON.parse(sealJson);
85
+ // 2. Decrypt inner layer (seal -> rumor)
86
+ // The seal was encrypted by the sender's key; we decrypt using
87
+ // our key + the seal's pubkey (the sender's public key).
88
+ const rumorJson = await ctx.nip44Decrypt(seal.pubkey, seal.content);
89
+ const rumor = JSON.parse(rumorJson);
90
+ return rumor;
91
+ }
package/dist/social/dm.js CHANGED
@@ -1,15 +1,19 @@
1
- import { wrapEvent } from 'nostr-tools/nip17';
2
1
  import * as nip04 from 'nostr-tools/nip04';
3
2
  import { decode, npubEncode } from 'nostr-tools/nip19';
4
3
  import { toAnnotation } from '../trust-context.js';
4
+ import { wrapEventAsync, unwrapEventAsync } from '../nip17-wrap.js';
5
5
  /** Send a DM. Uses NIP-17 gift wrap by default. NIP-04 only if explicitly requested and enabled. */
6
6
  export async function handleDmSend(ctx, pool, args) {
7
7
  if (args.nip04) {
8
8
  if (!args.nip04Enabled) {
9
9
  throw new Error('NIP-04 is not enabled. Set NIP04_ENABLED=1 to use legacy DMs.');
10
10
  }
11
- // NIP-04 legacy DM (kind 4)
12
- const encrypted = await nip04.encrypt(ctx.activePrivateKey, args.recipientPubkeyHex, args.message);
11
+ // NIP-04 legacy DM (kind 4) — requires local key (not available in bunker mode)
12
+ const localCtx = ctx;
13
+ if (!localCtx.activePrivateKey) {
14
+ throw new Error('NIP-04 is not supported in bunker mode. Use NIP-17 (the default) instead.');
15
+ }
16
+ const encrypted = await nip04.encrypt(localCtx.activePrivateKey, args.recipientPubkeyHex, args.message);
13
17
  const sign = ctx.getSigningFunction();
14
18
  const event = await sign({
15
19
  kind: 4,
@@ -34,7 +38,7 @@ export async function handleDmSend(ctx, pool, args) {
34
38
  }
35
39
  const recipientRelayHint = args.recipientRelay ?? recipientRelays[0];
36
40
  // Wrap for recipient
37
- const event = wrapEvent(ctx.activePrivateKey, { publicKey: args.recipientPubkeyHex, relayUrl: recipientRelayHint }, args.message);
41
+ const event = await wrapEventAsync(ctx, { publicKey: args.recipientPubkeyHex, relayUrl: recipientRelayHint }, args.message);
38
42
  // Publish to recipient's relays (primary) and sender's relays (fallback)
39
43
  let publish;
40
44
  if (recipientRelays.length > 0) {
@@ -47,10 +51,10 @@ export async function handleDmSend(ctx, pool, args) {
47
51
  publish = await pool.publish(ctx.activeNpub, event);
48
52
  }
49
53
  // Sender copy — wrap the same message addressed to ourselves so our client shows sent DMs.
50
- // The inner rumor's p-tag (set by wrapEvent) points to us, but clients use the
54
+ // The inner rumor's p-tag (set by wrapEventAsync) points to us, but clients use the
51
55
  // outer gift-wrap's p-tag to route, and the inner content to display.
52
56
  const senderHex = decode(ctx.activeNpub).data;
53
- const senderCopy = wrapEvent(ctx.activePrivateKey, { publicKey: senderHex, relayUrl: recipientRelayHint }, args.message);
57
+ const senderCopy = await wrapEventAsync(ctx, { publicKey: senderHex, relayUrl: recipientRelayHint }, args.message);
54
58
  const senderCopyPublish = await pool.publish(ctx.activeNpub, senderCopy);
55
59
  const result = { event, senderCopy, protocol: 'nip17', publish, senderCopyPublish };
56
60
  if (recipientRelays.length > 0 && publish.accepted.length === 0) {
@@ -97,8 +101,7 @@ async function decryptDmEvents(ctx, events) {
97
101
  if (event.kind === 1059) {
98
102
  // NIP-17 gift wrap — try to unwrap
99
103
  try {
100
- const { unwrapEvent } = await import('nostr-tools/nip17');
101
- const unwrapped = unwrapEvent(event, ctx.activePrivateKey);
104
+ const unwrapped = await unwrapEventAsync(ctx, event);
102
105
  results.push({
103
106
  id: event.id,
104
107
  from: unwrapped.pubkey,
@@ -120,9 +123,21 @@ async function decryptDmEvents(ctx, events) {
120
123
  }
121
124
  }
122
125
  else if (event.kind === 4) {
123
- // NIP-04 legacy DM
126
+ // NIP-04 legacy DM — needs local key; silently skip in bunker mode
127
+ const localCtx = ctx;
128
+ if (!localCtx.activePrivateKey) {
129
+ results.push({
130
+ id: event.id,
131
+ from: event.pubkey,
132
+ createdAt: event.created_at,
133
+ protocol: 'nip04-deprecated',
134
+ decrypted: false,
135
+ error: 'NIP-04 decryption not available in bunker mode',
136
+ });
137
+ continue;
138
+ }
124
139
  try {
125
- const decrypted = await nip04.decrypt(ctx.activePrivateKey, event.pubkey, event.content);
140
+ const decrypted = await nip04.decrypt(localCtx.activePrivateKey, event.pubkey, event.content);
126
141
  results.push({
127
142
  id: event.id,
128
143
  from: event.pubkey,
@@ -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
+ }
@@ -1,10 +1,10 @@
1
1
  import { createAttestation, createRevocation } from 'nostr-attestations';
2
2
  import { validateAttestation } from 'nostr-attestations';
3
3
  import { attestationFilter } from 'nostr-attestations';
4
- import { wrapEvent } from 'nostr-tools/nip17';
5
4
  import { toUnsignedEvent } from 'nsec-tree/event';
6
5
  import { decode } from 'nostr-tools/nip19';
7
6
  import { hasExtendedIdentity } from '../signing-context.js';
7
+ import { wrapEventAsync } from '../nip17-wrap.js';
8
8
  /** Create and publish a kind 31000 attestation */
9
9
  export async function handleTrustAttest(ctx, pool, args) {
10
10
  if (!args.type && !args.assertionId) {
@@ -88,7 +88,7 @@ export async function handleTrustRequest(ctx, pool, args) {
88
88
  attestation_type: args.attestationType,
89
89
  message: args.message,
90
90
  });
91
- const event = wrapEvent(ctx.activePrivateKey, { publicKey: args.recipientPubkeyHex }, payload);
91
+ const event = await wrapEventAsync(ctx, { publicKey: args.recipientPubkeyHex }, payload);
92
92
  const publish = await pool.publish(ctx.activeNpub, event);
93
93
  return { event, publish };
94
94
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "1.26.0",
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",