nostr-bray 3.3.0 → 3.4.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/README.md CHANGED
@@ -215,6 +215,7 @@ Search order: `BRAY_CONFIG` env var > `$XDG_CONFIG_HOME/bray/config.json` > `~/.
215
215
  | `NOSTR_NCRYPTSEC_PASSWORD` | Password for ncryptsec |
216
216
  | `NWC_URI_FILE` | Path to a private `0600` file containing the NWC bearer URI |
217
217
  | `NOSTR_RELAYS` | Comma-separated relay URLs |
218
+ | `NOSTR_FORBID_PUBKEY` | Comma-separated npubs or hex this process must never sign as. See below. |
218
219
  | `TOR_PROXY` | SOCKS5h proxy for Tor |
219
220
  | `NIP04_ENABLED` | Set `1` to enable legacy NIP-04 DMs |
220
221
  | `TRANSPORT` | `stdio` (default) or `http` |
@@ -225,6 +226,32 @@ Raw `NWC_URI` is refused; use `NWC_URI_FILE` or `wallet connect <nwc-file>` so
225
226
  the bearer credential never appears in a process environment, command argument
226
227
  or MCP tool argument.
227
228
 
229
+ ### Keys this process must never be
230
+
231
+ An agent that comes up holding the wrong key is indistinguishable from one
232
+ working correctly. Every signature verifies, every tool succeeds, and the
233
+ only symptom is that `whoami` quietly answers with somebody else's npub —
234
+ which the driving model has no reason to doubt, because it is its own tool
235
+ telling it.
236
+
237
+ The worst version is a **principal's** key: a person's own. An agent signing
238
+ as its principal can attest that it belongs to itself, and approve its own
239
+ requests, because it *is* the principal those checks look for.
240
+
241
+ ```bash
242
+ NOSTR_FORBID_PUBKEY=npub1yourownkey...,npub1anotherhuman...
243
+ ```
244
+
245
+ or `forbidPubkeys` in the config file. Either takes npubs or hex. Activating
246
+ one of those keys throws instead — at startup, on `identity-switch`, and on
247
+ `bunker --persona` alike, because the check sits at the moment an identity
248
+ becomes active rather than at any one entry point. An entry that cannot be
249
+ parsed is an error too: a list nobody can read must not silently permit
250
+ everything.
251
+
252
+ The active npub is printed on stderr on every start, so the answer to "which
253
+ key is this?" does not depend on asking the agent.
254
+
228
255
  ## Handing out a wallet connection
229
256
 
230
257
  `zap-send` spends through the NWC URI you configured. That URI is an
package/dist/cli/index.js CHANGED
@@ -384,7 +384,7 @@ if (config.bunkerUri) {
384
384
  console.error(`Connected to ${hw ? 'Heartwood' : 'bunker'} — signing as ${ctx.activeNpub}`);
385
385
  }
386
386
  else {
387
- ctx = new IdentityContext(config.secretKey, config.secretFormat);
387
+ ctx = new IdentityContext(config.secretKey, config.secretFormat, { forbidPubkeys: config.forbidPubkeys });
388
388
  }
389
389
  // NIP-42: the pool answers AUTH challenges with the active identity's key.
390
390
  pool.setAuthSigner(async (evt) => ctx.getSigningFunction()(evt));
package/dist/config.d.ts CHANGED
@@ -36,6 +36,8 @@ interface ConfigFile {
36
36
  trustCacheTtl?: number;
37
37
  trustCacheMax?: number;
38
38
  dispatchIdentities?: string;
39
+ /** Public keys this process must never sign as. See ContextOptions. */
40
+ forbidPubkeys?: string[];
39
41
  }
40
42
  /**
41
43
  * Load config from a JSON file. Searches in order:
package/dist/config.js CHANGED
@@ -283,6 +283,14 @@ export async function loadConfig() {
283
283
  : 'weekly';
284
284
  // --- Dispatch identities file ---
285
285
  const dispatchIdentities = process.env.DISPATCH_IDENTITIES ?? file.dispatchIdentities ?? undefined;
286
+ // Comma-separated npubs or hex. A list nobody can parse is rejected at
287
+ // startup by resolvePubkeyRef rather than silently permitting everything.
288
+ const forbidPubkeys = [
289
+ ...(process.env.NOSTR_FORBID_PUBKEY ?? '').split(','),
290
+ ...(file.forbidPubkeys ?? []),
291
+ ]
292
+ .map((s) => s.trim())
293
+ .filter(Boolean);
286
294
  // --- Wallets file ---
287
295
  const walletsFile = process.env.BRAY_WALLETS_FILE
288
296
  ?? file.walletsFile
@@ -309,5 +317,6 @@ export async function loadConfig() {
309
317
  port,
310
318
  bindAddress,
311
319
  dispatchIdentities,
320
+ forbidPubkeys,
312
321
  };
313
322
  }
package/dist/context.d.ts CHANGED
@@ -3,14 +3,41 @@ import type { PublicIdentity, SignFn } from './types.js';
3
3
  import type { ExtendedSigningContext } from './signing-context.js';
4
4
  export interface ContextOptions {
5
5
  maxCache?: number;
6
+ /** Public keys this process must never sign as, hex or npub.
7
+ *
8
+ * For PRINCIPALS - a person's own key. An agent that comes up holding one
9
+ * is indistinguishable from one working correctly: every signature
10
+ * verifies, every tool succeeds, and the only symptom is that `whoami`
11
+ * quietly answers with a human's npub. Listing them here turns that into
12
+ * a refusal at the moment of activation, which covers startup, an
13
+ * `identity-switch`, and `bunker --persona` alike. */
14
+ forbidPubkeys?: string[];
6
15
  }
16
+ /** An npub or 64-hex pubkey as lowercase hex. Throws on anything else, so a
17
+ * forbidden entry nobody can parse fails loudly rather than silently
18
+ * permitting everything. */
19
+ export declare function resolvePubkeyRef(ref: string): string;
7
20
  export declare class IdentityContext implements ExtendedSigningContext {
8
21
  private root;
9
22
  private cache;
10
23
  private masterEntry;
11
24
  private activeEntry;
12
25
  private maxCacheSize;
26
+ private forbidden;
13
27
  constructor(secretKey: string, format: 'nsec' | 'hex' | 'mnemonic', opts?: ContextOptions);
28
+ /**
29
+ * Make an identity the active one, refusing a forbidden key.
30
+ *
31
+ * Every assignment to `activeEntry` goes through here - the constructor,
32
+ * `use()`, `switch()` and the persona paths - so there is no route to
33
+ * signing as a forbidden key, and no new route can be added by accident.
34
+ *
35
+ * Throws rather than falling back to the master identity. A process asked
36
+ * to be a key it must not be has been misconfigured, and quietly running
37
+ * as something else is how the misconfiguration survives to do damage.
38
+ * Only the npub is in the message; it is public, and nothing else is.
39
+ */
40
+ private activate;
14
41
  /** Current active identity's npub (bech32) */
15
42
  get activeNpub(): string;
16
43
  /** Derivation path of the current active identity, for reporting to a human */
package/dist/context.js CHANGED
@@ -2,6 +2,7 @@ import { fromNsec, fromMnemonic, derive, zeroise } from 'nsec-tree';
2
2
  import { createBlindProof, createFullProof } from 'nsec-tree/proof';
3
3
  import { finalizeEvent, getPublicKey } from 'nostr-tools/pure';
4
4
  import { decode, npubEncode, nsecEncode } from 'nostr-tools/nip19';
5
+ import { decode as decodeNip19 } from 'nostr-tools/nip19';
5
6
  /** Build an Identity-compatible object from raw key bytes */
6
7
  function rawIdentity(privateKey) {
7
8
  const publicKeyHex = getPublicKey(privateKey);
@@ -15,14 +16,45 @@ function rawIdentity(privateKey) {
15
16
  index: 0,
16
17
  };
17
18
  }
19
+ /** An npub or 64-hex pubkey as lowercase hex. Throws on anything else, so a
20
+ * forbidden entry nobody can parse fails loudly rather than silently
21
+ * permitting everything. */
22
+ export function resolvePubkeyRef(ref) {
23
+ const trimmed = ref.trim();
24
+ if (!trimmed)
25
+ throw new Error('forbidPubkeys entry is empty');
26
+ if (/^[0-9a-fA-F]{64}$/.test(trimmed))
27
+ return trimmed.toLowerCase();
28
+ if (trimmed.toLowerCase().startsWith('nsec1')) {
29
+ throw new Error('forbidPubkeys wants a PUBLIC key; that is an nsec. Treat it as exposed.');
30
+ }
31
+ try {
32
+ const decoded = decodeNip19(trimmed);
33
+ if (decoded.type === 'npub')
34
+ return decoded.data;
35
+ throw new Error(`forbidPubkeys entry "${trimmed}" is a ${decoded.type}, not an npub`);
36
+ }
37
+ catch (err) {
38
+ if (err instanceof Error && err.message.startsWith('forbidPubkeys'))
39
+ throw err;
40
+ throw new Error(`forbidPubkeys entry "${trimmed}" is neither an npub nor 64 hex characters`);
41
+ }
42
+ }
18
43
  export class IdentityContext {
19
44
  root;
20
45
  cache = new Map();
21
46
  masterEntry;
47
+ // Definite-assignment asserted: every branch of the constructor assigns it
48
+ // through activate(), and TypeScript does not track assignments made
49
+ // inside a called method.
22
50
  activeEntry;
23
51
  maxCacheSize;
52
+ forbidden;
24
53
  constructor(secretKey, format, opts) {
25
54
  this.maxCacheSize = opts?.maxCache ?? 5;
55
+ // Built before anything is activated, so the constructor's own
56
+ // activation is checked too.
57
+ this.forbidden = new Set((opts?.forbidPubkeys ?? []).map(resolvePubkeyRef));
26
58
  // Parse the raw secret key bytes — this IS the user's actual Nostr identity
27
59
  let rawKeyBytes;
28
60
  if (format === 'mnemonic') {
@@ -30,7 +62,7 @@ export class IdentityContext {
30
62
  this.root = fromMnemonic(secretKey);
31
63
  const derived = derive(this.root, 'master', 0);
32
64
  this.masterEntry = { identity: derived, purpose: 'master', index: 0, lastUsed: Date.now() };
33
- this.activeEntry = this.masterEntry;
65
+ this.activate(this.masterEntry);
34
66
  return;
35
67
  }
36
68
  else if (format === 'hex') {
@@ -47,10 +79,32 @@ export class IdentityContext {
47
79
  index: 0,
48
80
  lastUsed: Date.now(),
49
81
  };
50
- this.activeEntry = this.masterEntry;
82
+ this.activate(this.masterEntry);
51
83
  // nsec-tree root for child derivation
52
84
  this.root = fromNsec(rawKeyBytes);
53
85
  }
86
+ /**
87
+ * Make an identity the active one, refusing a forbidden key.
88
+ *
89
+ * Every assignment to `activeEntry` goes through here - the constructor,
90
+ * `use()`, `switch()` and the persona paths - so there is no route to
91
+ * signing as a forbidden key, and no new route can be added by accident.
92
+ *
93
+ * Throws rather than falling back to the master identity. A process asked
94
+ * to be a key it must not be has been misconfigured, and quietly running
95
+ * as something else is how the misconfiguration survives to do damage.
96
+ * Only the npub is in the message; it is public, and nothing else is.
97
+ */
98
+ activate(entry) {
99
+ const hex = Buffer.from(entry.identity.publicKey).toString('hex');
100
+ if (this.forbidden.has(hex)) {
101
+ throw new Error(`Refusing to activate ${entry.identity.npub}: it is on this process's forbidden list. ` +
102
+ 'That list is for principals - a person\'s key, never an agent\'s. An agent signing as its ' +
103
+ 'principal can attest that it belongs to itself and approve its own requests, because it is ' +
104
+ 'the principal those checks look for. Point NOSTR_SECRET_KEY at the agent\'s own key.');
105
+ }
106
+ this.activeEntry = entry;
107
+ }
54
108
  /** Current active identity's npub (bech32) */
55
109
  get activeNpub() {
56
110
  return this.activeEntry.identity.npub;
@@ -90,14 +144,14 @@ export class IdentityContext {
90
144
  for (const [, entry] of this.cache) {
91
145
  if (entry.purpose === purpose && entry.index === index && !entry.personaName) {
92
146
  entry.lastUsed = Date.now();
93
- this.activeEntry = entry;
147
+ this.activate(entry);
94
148
  return;
95
149
  }
96
150
  }
97
151
  const identity = derive(this.root, purpose, index);
98
152
  const entry = { identity, purpose, index, lastUsed: Date.now() };
99
153
  this.putCache(identity.npub, entry);
100
- this.activeEntry = entry;
154
+ this.activate(entry);
101
155
  }
102
156
  /** Derive a named persona */
103
157
  async derivePersona(name, index) {
@@ -123,7 +177,7 @@ export class IdentityContext {
123
177
  /** Switch active identity by purpose+index, persona name, or "master" */
124
178
  async switch(purposeOrName, index) {
125
179
  if (purposeOrName === 'master') {
126
- this.activeEntry = this.masterEntry;
180
+ this.activate(this.masterEntry);
127
181
  this.masterEntry.lastUsed = Date.now();
128
182
  return;
129
183
  }
@@ -133,7 +187,7 @@ export class IdentityContext {
133
187
  const matchesPersona = entry.personaName === purposeOrName && (index === undefined || entry.index === index);
134
188
  if (matchesPurpose || matchesPersona) {
135
189
  entry.lastUsed = Date.now();
136
- this.activeEntry = entry;
190
+ this.activate(entry);
137
191
  return;
138
192
  }
139
193
  }
@@ -160,7 +214,7 @@ export class IdentityContext {
160
214
  lastUsed: Date.now(),
161
215
  };
162
216
  this.putCache(identity.npub, entry);
163
- this.activeEntry = entry;
217
+ this.activate(entry);
164
218
  }
165
219
  /** Get a signing function bound to the current active identity */
166
220
  getSigningFunction() {
package/dist/index.js CHANGED
@@ -104,7 +104,12 @@ if (config.bunkerUri) {
104
104
  void establishBunkerInBackground(base);
105
105
  }
106
106
  else {
107
- ctx = new IdentityContext(config.secretKey, config.secretFormat);
107
+ ctx = new IdentityContext(config.secretKey, config.secretFormat, { forbidPubkeys: config.forbidPubkeys });
108
+ // Said out loud, on stderr, every start. The alternative is asking the
109
+ // agent which identity it holds, and an agent whose key is wrong will
110
+ // answer that confidently and wrongly - it has no reason to doubt its
111
+ // own tools. stdout is reserved for JSON-RPC.
112
+ console.error(`Signing as ${ctx.activeNpub}`);
108
113
  // Local key is ready immediately -- load its relay list now.
109
114
  loadIdentityRelays(ctx.activeNpub);
110
115
  }
package/dist/types.d.ts CHANGED
@@ -61,6 +61,11 @@ export interface BrayConfig {
61
61
  readonly port: number;
62
62
  readonly bindAddress: string;
63
63
  readonly dispatchIdentities?: string;
64
+ /** Public keys this process must never sign as, hex or npub. For
65
+ * principals: an agent holding one can attest that it belongs to itself
66
+ * and approve its own requests. Enforced in IdentityContext, at the
67
+ * moment an identity is activated. */
68
+ readonly forbidPubkeys?: string[];
64
69
  }
65
70
  export type { SigningContext, ExtendedSigningContext } from './signing-context.js';
66
71
  export { hasExtendedIdentity } from './signing-context.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "mcpName": "dev.forgesworn/bray",
5
5
  "description": "Trust-aware Nostr for AI and humans. Three dimensions of trust -- verification, proximity, and access -- woven into every interaction.",
6
6
  "type": "module",