nostr-bray 1.12.0 → 1.14.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
@@ -4,6 +4,7 @@
4
4
 
5
5
  [![CI](https://github.com/forgesworn/bray/actions/workflows/ci.yml/badge.svg)](https://github.com/forgesworn/bray/actions/workflows/ci.yml)
6
6
  [![npm](https://img.shields.io/npm/v/nostr-bray)](https://www.npmjs.com/package/nostr-bray)
7
+ [![coverage](https://img.shields.io/badge/coverage-96%25-brightgreen)](./package.json)
7
8
  [![licence](https://img.shields.io/npm/l/nostr-bray)](./LICENSE)
8
9
  [![TypeScript](https://img.shields.io/badge/TypeScript-ESM-blue)](./tsconfig.json)
9
10
 
@@ -0,0 +1,25 @@
1
+ /**
2
+ * NIP-46 Bunker — remote signing daemon.
3
+ *
4
+ * Holds the user's secret key and responds to signing requests from
5
+ * authorised clients over Nostr relays. Clients never see the nsec.
6
+ *
7
+ * Protocol: NIP-46 (Nostr Connect)
8
+ * - Client sends kind 24133 encrypted request
9
+ * - Bunker responds with kind 24133 encrypted response
10
+ * - All communication NIP-44 encrypted between client and bunker
11
+ */
12
+ import type { IdentityContext } from './context.js';
13
+ export interface BunkerOptions {
14
+ ctx: IdentityContext;
15
+ relays: string[];
16
+ authorizedKeys?: string[];
17
+ quiet?: boolean;
18
+ }
19
+ export interface BunkerInstance {
20
+ url: string;
21
+ pubkey: string;
22
+ npub: string;
23
+ close: () => void;
24
+ }
25
+ export declare function startBunker(opts: BunkerOptions): BunkerInstance;
package/dist/bunker.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * NIP-46 Bunker — remote signing daemon.
3
+ *
4
+ * Holds the user's secret key and responds to signing requests from
5
+ * authorised clients over Nostr relays. Clients never see the nsec.
6
+ *
7
+ * Protocol: NIP-46 (Nostr Connect)
8
+ * - Client sends kind 24133 encrypted request
9
+ * - Bunker responds with kind 24133 encrypted response
10
+ * - All communication NIP-44 encrypted between client and bunker
11
+ */
12
+ import { getConversationKey, encrypt, decrypt } from 'nostr-tools/nip44';
13
+ import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools/pure';
14
+ import { npubEncode } from 'nostr-tools/nip19';
15
+ import { useWebSocketImplementation, SimplePool } from 'nostr-tools/pool';
16
+ import WebSocket from 'ws';
17
+ useWebSocketImplementation(WebSocket);
18
+ export function startBunker(opts) {
19
+ const { ctx, relays, quiet } = opts;
20
+ const authorizedKeys = new Set(opts.authorizedKeys ?? []);
21
+ const log = quiet ? () => { } : (...args) => console.error('[bunker]', ...args);
22
+ // Generate a bunker-specific keypair for NIP-46 communication
23
+ const bunkerSk = generateSecretKey();
24
+ const bunkerPk = getPublicKey(bunkerSk);
25
+ const bunkerNpub = npubEncode(bunkerPk);
26
+ const pool = new SimplePool();
27
+ // Subscribe to kind 24133 events addressed to us
28
+ const sub = pool.subscribeMany(relays, { kinds: [24133], '#p': [bunkerPk] }, {
29
+ onevent: async (event) => {
30
+ try {
31
+ await handleRequest(event);
32
+ }
33
+ catch (e) {
34
+ log(`Error handling request: ${e.message}`);
35
+ }
36
+ },
37
+ });
38
+ async function handleRequest(event) {
39
+ const clientPk = event.pubkey;
40
+ // Check authorization
41
+ if (authorizedKeys.size > 0 && !authorizedKeys.has(clientPk)) {
42
+ log(`Rejected request from unauthorized key: ${clientPk.slice(0, 12)}...`);
43
+ return;
44
+ }
45
+ // Decrypt the request
46
+ const conversationKey = getConversationKey(bunkerSk, clientPk);
47
+ let request;
48
+ try {
49
+ const plaintext = decrypt(event.content, conversationKey);
50
+ request = JSON.parse(plaintext);
51
+ }
52
+ catch {
53
+ log('Failed to decrypt request');
54
+ return;
55
+ }
56
+ log(`Request ${request.id}: ${request.method}`);
57
+ let result;
58
+ let error;
59
+ switch (request.method) {
60
+ case 'connect':
61
+ result = 'ack';
62
+ break;
63
+ case 'ping':
64
+ result = 'pong';
65
+ break;
66
+ case 'get_public_key':
67
+ result = ctx.activePublicKeyHex;
68
+ break;
69
+ case 'sign_event': {
70
+ const template = JSON.parse(request.params[0]);
71
+ const sign = ctx.getSigningFunction();
72
+ const signed = await sign(template);
73
+ result = JSON.stringify(signed);
74
+ break;
75
+ }
76
+ case 'nip04_encrypt': {
77
+ const [thirdPartyPk, plaintext] = request.params;
78
+ const { encrypt: nip04encrypt } = await import('nostr-tools/nip04');
79
+ result = nip04encrypt(ctx.activePrivateKey, thirdPartyPk, plaintext);
80
+ break;
81
+ }
82
+ case 'nip04_decrypt': {
83
+ const [thirdPartyPk, ciphertext] = request.params;
84
+ const { decrypt: nip04decrypt } = await import('nostr-tools/nip04');
85
+ result = nip04decrypt(ctx.activePrivateKey, thirdPartyPk, ciphertext);
86
+ break;
87
+ }
88
+ case 'nip44_encrypt': {
89
+ const [thirdPartyPk, plaintext] = request.params;
90
+ const ck = getConversationKey(ctx.activePrivateKey, thirdPartyPk);
91
+ result = encrypt(plaintext, ck);
92
+ break;
93
+ }
94
+ case 'nip44_decrypt': {
95
+ const [thirdPartyPk, ciphertext] = request.params;
96
+ const ck = getConversationKey(ctx.activePrivateKey, thirdPartyPk);
97
+ result = decrypt(ciphertext, ck);
98
+ break;
99
+ }
100
+ default:
101
+ result = '';
102
+ error = `unsupported method: ${request.method}`;
103
+ }
104
+ // Encrypt and send response
105
+ const response = error
106
+ ? JSON.stringify({ id: request.id, result: '', error })
107
+ : JSON.stringify({ id: request.id, result });
108
+ const encrypted = encrypt(response, conversationKey);
109
+ const responseEvent = finalizeEvent({
110
+ kind: 24133,
111
+ created_at: Math.floor(Date.now() / 1000),
112
+ tags: [['p', clientPk]],
113
+ content: encrypted,
114
+ }, bunkerSk);
115
+ await Promise.any(pool.publish(relays, responseEvent));
116
+ log(`Response ${request.id}: ${error ?? 'ok'}`);
117
+ }
118
+ // Build bunker:// URI
119
+ const relayParams = relays.map(r => `relay=${encodeURIComponent(r)}`).join('&');
120
+ const bunkerUrl = `bunker://${bunkerPk}?${relayParams}`;
121
+ log(`Bunker started: ${bunkerNpub}`);
122
+ log(`URI: ${bunkerUrl}`);
123
+ log(`Signing as: ${ctx.activeNpub}`);
124
+ if (authorizedKeys.size > 0) {
125
+ log(`Authorized keys: ${authorizedKeys.size}`);
126
+ }
127
+ else {
128
+ log('WARNING: No authorized keys set — accepting requests from anyone');
129
+ }
130
+ return {
131
+ url: bunkerUrl,
132
+ pubkey: bunkerPk,
133
+ npub: bunkerNpub,
134
+ close: () => {
135
+ sub.close();
136
+ pool.destroy();
137
+ bunkerSk.fill(0);
138
+ },
139
+ };
140
+ }
package/dist/cli.js CHANGED
@@ -19,15 +19,54 @@ import { handleTrustSpokenChallenge, handleTrustSpokenVerify } from './trust/spo
19
19
  import { handleDuressConfigure, handleDuressActivate } from './safety/handlers.js';
20
20
  import { handleZapSend, handleZapBalance, handleZapMakeInvoice, handleZapLookupInvoice, handleZapListTransactions, handleZapReceipts, handleZapDecode } from './zap/handlers.js';
21
21
  import { handleDecode, handleEncodeNpub, handleEncodeNote, handleEncodeNprofile, handleEncodeNevent, handleVerify, handleEncrypt, handleDecrypt, handleCount, handleFetch, handleKeyPublic, handleEncodeNsec, handleFilter, handleNipList, handleNipShow } from './util/handlers.js';
22
+ import { handleKeyEncrypt, handleKeyDecrypt } from './util/ncryptsec.js';
22
23
  import { getCommandHelp } from './help.js';
23
24
  import * as fmt from './format.js';
24
25
  const args = process.argv.slice(2);
25
26
  const command = args[0];
26
27
  // No command = start MCP server
27
- if (!command || command === 'serve') {
28
+ if (!command) {
28
29
  await import('./index.js');
29
30
  process.exit(0);
30
31
  }
32
+ // Bunker = NIP-46 remote signer
33
+ if (command === 'bunker' && !args.includes('--help')) {
34
+ const { startBunker } = await import('./bunker.js');
35
+ const config = (await import('./config.js')).loadConfig();
36
+ const { IdentityContext: IC } = await import('./context.js');
37
+ const bCtx = new IC(config.secretKey, config.secretFormat);
38
+ config.secretKey = '';
39
+ const authorizedKeys = args.includes('--authorized-keys')
40
+ ? args[args.indexOf('--authorized-keys') + 1].split(',')
41
+ : undefined;
42
+ const bunker = startBunker({
43
+ ctx: bCtx,
44
+ relays: config.relays,
45
+ authorizedKeys,
46
+ quiet: args.includes('--quiet'),
47
+ });
48
+ console.error(`nostr-bray bunker running`);
49
+ console.error(`URI: ${bunker.url}`);
50
+ console.error(`Signing as: ${bCtx.activeNpub}`);
51
+ console.error('Press Ctrl+C to stop');
52
+ process.on('SIGINT', () => { bunker.close(); bCtx.destroy(); process.exit(0); });
53
+ process.on('SIGTERM', () => { bunker.close(); bCtx.destroy(); process.exit(0); });
54
+ await new Promise(() => { });
55
+ }
56
+ // Serve = in-memory test relay
57
+ if (command === 'serve' && !args.includes('--help')) {
58
+ const { startRelay } = await import('./serve.js');
59
+ const hostname = args.includes('--hostname') ? args[args.indexOf('--hostname') + 1] : 'localhost';
60
+ const port = args.includes('--port') ? parseInt(args[args.indexOf('--port') + 1], 10) : 10547;
61
+ const eventsFile = args.includes('--events') ? args[args.indexOf('--events') + 1] : undefined;
62
+ const relay = startRelay({ hostname, port, eventsFile, quiet: args.includes('--quiet') });
63
+ console.error(`nostr-bray test relay running at ${relay.url}`);
64
+ console.error('Press Ctrl+C to stop');
65
+ process.on('SIGINT', () => { relay.close(); process.exit(0); });
66
+ process.on('SIGTERM', () => { relay.close(); process.exit(0); });
67
+ // Keep process alive
68
+ await new Promise(() => { });
69
+ }
31
70
  // Per-command help: `nostr-bray post --help`
32
71
  if (args.includes('--help') && command && command !== 'help' && command !== '--help' && command !== '-h') {
33
72
  const help = getCommandHelp(command);
@@ -119,6 +158,8 @@ Utility:
119
158
  encode-nevent <hex> [relay,...] Encode event ID + relays as nevent
120
159
  encode-nsec <hex> Encode hex private key as nsec
121
160
  key-public <nsec-or-hex> Derive pubkey from secret key
161
+ key-encrypt <nsec-or-hex> <pass> Encrypt key as ncryptsec (NIP-49)
162
+ key-decrypt <ncryptsec> <pass> Decrypt ncryptsec to verify pubkey
122
163
  filter <event-json> <filter-json> Test if event matches filter
123
164
  nips List all official NIPs
124
165
  nip <number> Show a specific NIP
@@ -130,6 +171,8 @@ Utility:
130
171
 
131
172
  Modes:
132
173
  (no command) Start MCP server (stdio)
174
+ serve [--port N] [--events file] Start in-memory test relay
175
+ bunker [--authorized-keys pk,pk] Start NIP-46 remote signer daemon
133
176
  shell Interactive REPL (persistent relay connection)
134
177
 
135
178
  Environment:
@@ -586,6 +629,12 @@ async function run(cmdArgs) {
586
629
  case 'key-public':
587
630
  out(handleKeyPublic(req(1, 'key-public <nsec-or-hex>')));
588
631
  break;
632
+ case 'key-encrypt':
633
+ out(handleKeyEncrypt(req(1, 'key-encrypt <nsec-or-hex> <password>'), req(2, 'key-encrypt <nsec-or-hex> <password>')));
634
+ break;
635
+ case 'key-decrypt':
636
+ out(handleKeyDecrypt(req(1, 'key-decrypt <ncryptsec> <password>'), req(2, 'key-decrypt <ncryptsec> <password>')));
637
+ break;
589
638
  case 'filter':
590
639
  out(handleFilter(JSON.parse(req(1, 'filter <event-json> <filter-json>')), JSON.parse(req(2, 'filter <event-json> <filter-json>'))));
591
640
  break;
@@ -645,7 +694,8 @@ const ALL_COMMANDS = [
645
694
  'blossom-upload', 'blossom-list', 'blossom-delete',
646
695
  'group-info', 'group-chat', 'group-send', 'group-members',
647
696
  'decode', 'encode-npub', 'encode-note', 'encode-nprofile', 'encode-nevent', 'encode-nsec',
648
- 'key-public', 'filter', 'nips', 'nip', 'verify', 'encrypt', 'decrypt', 'count', 'fetch',
697
+ 'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'nips', 'nip', 'verify', 'encrypt', 'decrypt', 'count', 'fetch',
698
+ 'bunker',
649
699
  'help', 'exit',
650
700
  ];
651
701
  async function shell() {
package/dist/help.js CHANGED
@@ -84,6 +84,10 @@ export const COMMAND_HELP = {
84
84
  count: { usage: 'count [--kinds N,N] [--authors X,X] [--since N]', description: 'Count events matching a filter on relays.', examples: ['nostr-bray count --kinds 1', 'nostr-bray count --kinds 1,7 --authors abc123...'] },
85
85
  fetch: { usage: 'fetch <nip19>', description: 'Fetch events by nip19 code (note, nevent, nprofile, npub, naddr). Resolves the entity and queries relays.', examples: ['nostr-bray fetch note1...', 'nostr-bray fetch nevent1...', 'nostr-bray fetch npub1...'] },
86
86
  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'] },
87
+ '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.' },
88
+ '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'] },
89
+ bunker: { usage: 'bunker [--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.', examples: ['nostr-bray bunker', 'nostr-bray bunker --authorized-keys abc123...,def456...', 'nostr-bray bunker --quiet'], notes: 'Without --authorized-keys, accepts requests from anyone. Set authorized keys for production use.' },
90
+ 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'] },
87
91
  };
88
92
  /** Get formatted help for a single command */
89
93
  export function getCommandHelp(cmd) {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * In-memory Nostr relay for testing purposes.
3
+ *
4
+ * Implements NIP-01 (events, subscriptions, EOSE) and NIP-11 (relay info).
5
+ * No persistence — events live in memory until the process exits.
6
+ */
7
+ export interface ServeOptions {
8
+ hostname?: string;
9
+ port?: number;
10
+ eventsFile?: string;
11
+ quiet?: boolean;
12
+ }
13
+ export declare function startRelay(opts?: ServeOptions): {
14
+ url: string;
15
+ close: () => void;
16
+ };
package/dist/serve.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * In-memory Nostr relay for testing purposes.
3
+ *
4
+ * Implements NIP-01 (events, subscriptions, EOSE) and NIP-11 (relay info).
5
+ * No persistence — events live in memory until the process exits.
6
+ */
7
+ import { WebSocketServer, WebSocket } from 'ws';
8
+ import { createServer } from 'node:http';
9
+ import { readFileSync } from 'node:fs';
10
+ import { verifyEvent } from 'nostr-tools/pure';
11
+ /** Check if an event matches a single filter */
12
+ function matchFilter(filter, event) {
13
+ if (filter.ids && !filter.ids.includes(event.id))
14
+ return false;
15
+ if (filter.kinds && !filter.kinds.includes(event.kind))
16
+ return false;
17
+ if (filter.authors && !filter.authors.includes(event.pubkey))
18
+ return false;
19
+ if (filter.since && event.created_at < filter.since)
20
+ return false;
21
+ if (filter.until && event.created_at > filter.until)
22
+ return false;
23
+ // Tag filters (#e, #p, #t, #d, etc.)
24
+ for (const key of Object.keys(filter)) {
25
+ if (key.startsWith('#')) {
26
+ const vals = filter[key];
27
+ if (!vals || !Array.isArray(vals))
28
+ continue;
29
+ const tagName = key.slice(1);
30
+ const eventTagValues = event.tags.filter(t => t[0] === tagName).map(t => t[1]);
31
+ if (!vals.some(v => eventTagValues.includes(v)))
32
+ return false;
33
+ }
34
+ }
35
+ return true;
36
+ }
37
+ /** Check if an event matches any filter in a list */
38
+ function matchFilters(filters, event) {
39
+ return filters.some(f => matchFilter(f, event));
40
+ }
41
+ export function startRelay(opts = {}) {
42
+ const hostname = opts.hostname ?? 'localhost';
43
+ const port = opts.port ?? 10547;
44
+ const quiet = opts.quiet ?? false;
45
+ const log = quiet ? () => { } : (...args) => console.error('[relay]', ...args);
46
+ const events = new Map();
47
+ const subscriptions = new Map();
48
+ // Pre-load events from JSONL file
49
+ if (opts.eventsFile) {
50
+ const lines = readFileSync(opts.eventsFile, 'utf-8').split('\n').filter(Boolean);
51
+ for (const line of lines) {
52
+ try {
53
+ const event = JSON.parse(line);
54
+ events.set(event.id, event);
55
+ }
56
+ catch { /* skip malformed lines */ }
57
+ }
58
+ log(`Loaded ${events.size} events from ${opts.eventsFile}`);
59
+ }
60
+ const httpServer = createServer((req, res) => {
61
+ // NIP-11 relay info document
62
+ if (req.headers.accept?.includes('application/nostr+json')) {
63
+ res.writeHead(200, { 'Content-Type': 'application/nostr+json' });
64
+ res.end(JSON.stringify({
65
+ name: 'nostr-bray test relay',
66
+ description: 'In-memory relay for testing',
67
+ supported_nips: [1, 11],
68
+ software: 'nostr-bray',
69
+ version: '0.1.0',
70
+ }));
71
+ return;
72
+ }
73
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
74
+ res.end('nostr-bray test relay — connect via WebSocket');
75
+ });
76
+ const wss = new WebSocketServer({ server: httpServer });
77
+ wss.on('connection', (ws) => {
78
+ const clientSubs = new Set();
79
+ log('Client connected');
80
+ ws.on('message', (raw) => {
81
+ let msg;
82
+ try {
83
+ msg = JSON.parse(raw.toString());
84
+ }
85
+ catch {
86
+ ws.send(JSON.stringify(['NOTICE', 'Invalid JSON']));
87
+ return;
88
+ }
89
+ if (!Array.isArray(msg) || msg.length < 2) {
90
+ ws.send(JSON.stringify(['NOTICE', 'Invalid message format']));
91
+ return;
92
+ }
93
+ const type = msg[0];
94
+ if (type === 'EVENT') {
95
+ const event = msg[1];
96
+ if (!event?.id || !event?.sig || !event?.pubkey) {
97
+ ws.send(JSON.stringify(['OK', event?.id ?? '', false, 'invalid: missing fields']));
98
+ return;
99
+ }
100
+ if (!verifyEvent(event)) {
101
+ ws.send(JSON.stringify(['OK', event.id, false, 'invalid: signature verification failed']));
102
+ return;
103
+ }
104
+ // Store event
105
+ events.set(event.id, event);
106
+ ws.send(JSON.stringify(['OK', event.id, true, '']));
107
+ log(`Stored event ${event.id.slice(0, 8)}... kind:${event.kind}`);
108
+ // Notify matching subscriptions
109
+ for (const sub of subscriptions.values()) {
110
+ if (matchFilters(sub.filters, event) && sub.ws.readyState === WebSocket.OPEN) {
111
+ sub.ws.send(JSON.stringify(['EVENT', sub.id, event]));
112
+ }
113
+ }
114
+ }
115
+ else if (type === 'REQ') {
116
+ const subId = msg[1];
117
+ const filters = msg.slice(2);
118
+ // Register subscription
119
+ const sub = { id: subId, filters, ws };
120
+ const key = `${subId}-${Date.now()}`;
121
+ subscriptions.set(key, sub);
122
+ clientSubs.add(key);
123
+ // Send matching stored events
124
+ let count = 0;
125
+ const limit = filters.reduce((min, f) => Math.min(min, f.limit ?? Infinity), Infinity);
126
+ const matching = [...events.values()]
127
+ .filter(e => matchFilters(filters, e))
128
+ .sort((a, b) => b.created_at - a.created_at)
129
+ .slice(0, limit === Infinity ? undefined : limit);
130
+ for (const event of matching) {
131
+ ws.send(JSON.stringify(['EVENT', subId, event]));
132
+ count++;
133
+ }
134
+ // EOSE
135
+ ws.send(JSON.stringify(['EOSE', subId]));
136
+ log(`REQ ${subId}: ${count} events, ${filters.length} filter(s)`);
137
+ }
138
+ else if (type === 'CLOSE') {
139
+ const subId = msg[1];
140
+ for (const key of clientSubs) {
141
+ if (subscriptions.get(key)?.id === subId) {
142
+ subscriptions.delete(key);
143
+ clientSubs.delete(key);
144
+ }
145
+ }
146
+ ws.send(JSON.stringify(['CLOSED', subId, '']));
147
+ }
148
+ else if (type === 'COUNT') {
149
+ const subId = msg[1];
150
+ const filters = msg.slice(2);
151
+ const count = [...events.values()].filter(e => matchFilters(filters, e)).length;
152
+ ws.send(JSON.stringify(['COUNT', subId, { count }]));
153
+ }
154
+ else {
155
+ ws.send(JSON.stringify(['NOTICE', `Unknown message type: ${type}`]));
156
+ }
157
+ });
158
+ ws.on('close', () => {
159
+ for (const key of clientSubs) {
160
+ subscriptions.delete(key);
161
+ }
162
+ log('Client disconnected');
163
+ });
164
+ });
165
+ httpServer.listen(port, hostname, () => {
166
+ log(`Listening on ws://${hostname}:${port}`);
167
+ });
168
+ return {
169
+ url: `ws://${hostname}:${port}`,
170
+ close: () => {
171
+ wss.close();
172
+ httpServer.close();
173
+ },
174
+ };
175
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * NIP-49 ncryptsec — password-encrypted secret keys.
3
+ *
4
+ * Encrypt an nsec with a passphrase for safe storage.
5
+ * Decrypt an ncryptsec back to the raw key.
6
+ */
7
+ /** Encrypt a secret key (nsec or hex) with a password. Returns ncryptsec string. */
8
+ export declare function handleKeyEncrypt(secret: string, password: string): {
9
+ ncryptsec: string;
10
+ pubkeyHex: string;
11
+ npub: string;
12
+ };
13
+ /** Decrypt an ncryptsec with a password. Returns the derived pubkey (never the raw key). */
14
+ export declare function handleKeyDecrypt(ncryptsec: string, password: string): {
15
+ pubkeyHex: string;
16
+ npub: string;
17
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * NIP-49 ncryptsec — password-encrypted secret keys.
3
+ *
4
+ * Encrypt an nsec with a passphrase for safe storage.
5
+ * Decrypt an ncryptsec back to the raw key.
6
+ */
7
+ import { encrypt, decrypt } from 'nostr-tools/nip49';
8
+ import { decode } from 'nostr-tools/nip19';
9
+ import { getPublicKey } from 'nostr-tools/pure';
10
+ import { npubEncode } from 'nostr-tools/nip19';
11
+ /** Encrypt a secret key (nsec or hex) with a password. Returns ncryptsec string. */
12
+ export function handleKeyEncrypt(secret, password) {
13
+ let bytes;
14
+ if (secret.startsWith('nsec1')) {
15
+ bytes = decode(secret).data;
16
+ }
17
+ else {
18
+ bytes = Buffer.from(secret, 'hex');
19
+ }
20
+ try {
21
+ const ncryptsec = encrypt(bytes, password);
22
+ const pubkeyHex = getPublicKey(bytes);
23
+ return { ncryptsec, pubkeyHex, npub: npubEncode(pubkeyHex) };
24
+ }
25
+ finally {
26
+ bytes.fill(0);
27
+ }
28
+ }
29
+ /** Decrypt an ncryptsec with a password. Returns the derived pubkey (never the raw key). */
30
+ export function handleKeyDecrypt(ncryptsec, password) {
31
+ const bytes = decrypt(ncryptsec, password);
32
+ try {
33
+ const pubkeyHex = getPublicKey(bytes);
34
+ return { pubkeyHex, npub: npubEncode(pubkeyHex) };
35
+ }
36
+ finally {
37
+ bytes.fill(0);
38
+ }
39
+ }
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { hexId } from '../validation.js';
3
+ import { handleKeyEncrypt, handleKeyDecrypt } from './ncryptsec.js';
3
4
  import { handleDecode, handleEncodeNpub, handleEncodeNote, handleEncodeNprofile, handleEncodeNevent, handleEncodeNaddr, handleVerify, handleEncrypt, handleDecrypt, handleCount, handleFetch, handleKeyPublic, handleEncodeNsec, handleFilter, handleNipList, handleNipShow, } from './handlers.js';
4
5
  export function registerUtilTools(server, deps) {
5
6
  server.registerTool('decode', {
@@ -167,4 +168,26 @@ export function registerUtilTools(server, deps) {
167
168
  const nip = await handleNipShow(number);
168
169
  return { content: [{ type: 'text', text: nip.content }] };
169
170
  });
171
+ server.registerTool('key_encrypt', {
172
+ description: 'Encrypt a secret key with a password (NIP-49 ncryptsec). Returns the ncryptsec string and the derived pubkey. WARNING: secret key is transmitted through the MCP transport.',
173
+ inputSchema: {
174
+ secret: z.string().describe('Secret key (nsec or hex)'),
175
+ password: z.string().describe('Password to encrypt with'),
176
+ },
177
+ annotations: { readOnlyHint: true },
178
+ }, async ({ secret, password }) => {
179
+ const result = handleKeyEncrypt(secret, password);
180
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
181
+ });
182
+ server.registerTool('key_decrypt', {
183
+ description: 'Decrypt an ncryptsec (NIP-49) with a password. Returns the derived pubkey for verification — never the raw key.',
184
+ inputSchema: {
185
+ ncryptsec: z.string().describe('ncryptsec string to decrypt'),
186
+ password: z.string().describe('Password used during encryption'),
187
+ },
188
+ annotations: { readOnlyHint: true },
189
+ }, async ({ ncryptsec, password }) => {
190
+ const result = handleKeyDecrypt(ncryptsec, password);
191
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
192
+ });
170
193
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "MCP server giving AI agents sovereign Nostr identities — key derivation, personas, attestations, duress detection, zaps",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,6 +17,7 @@
17
17
  "scripts": {
18
18
  "build": "tsc",
19
19
  "test": "vitest run",
20
+ "test:coverage": "vitest run --coverage",
20
21
  "test:watch": "vitest",
21
22
  "lint": "tsc --noEmit",
22
23
  "start": "node dist/index.js"
@@ -89,6 +90,7 @@
89
90
  "@semantic-release/git": "^10.0.1",
90
91
  "@types/node": "^25.5.0",
91
92
  "@types/ws": "^8.18.1",
93
+ "@vitest/coverage-v8": "^4.1.1",
92
94
  "semantic-release": "^25.0.3",
93
95
  "typescript": "^6.0.2",
94
96
  "vitest": "^4.1.1"