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 +27 -0
- package/dist/cli/index.js +1 -1
- package/dist/config.d.ts +2 -0
- package/dist/config.js +9 -0
- package/dist/context.d.ts +27 -0
- package/dist/context.js +61 -7
- package/dist/index.js +6 -1
- package/dist/types.d.ts +5 -0
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
"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",
|