nostr-bray 1.42.3 → 2.1.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.
@@ -1,4 +1,4 @@
1
- import { handleSocialPost, handleSocialReply, handleSocialReact, handleSocialDelete, handleSocialRepost, handleSocialProfileGet, handleSocialProfileSet, handleContactsGet, handleContactsFollow, handleContactsUnfollow, handleDmSend, handleDmRead, handleNotifications, handleFeed, handleNipPublish, handleNipRead, handleBlossomUpload, handleBlossomList, handleBlossomDelete, handleGroupInfo, handleGroupChat, handleGroupSend, handleGroupMembers, handleGroupCreate, handleGroupUpdate, handleGroupAddUser, handleGroupRemoveUser, handleGroupAdmins, handleGroupRoles, handleGroupInspect, handleGroupCreateInvite, handleGroupJoin, handleGroupLeave, handleGroupDeleteEvent, handleGroupDelete, handleGroupForumTopics, handleGroupForumTopicCreate, handleGroupForumComments, handleGroupForumComment, } from '../../exports.js';
1
+ import { handleSocialPost, handleSocialReply, handleSocialReact, handleSocialDelete, handleSocialRepost, handleSocialProfileGet, handleSocialProfileSet, handleContactsGet, handleContactsFollow, handleContactsUnfollow, handleDmSend, handleDmRead, handleNotifications, handleFeed, handleNipPublish, handleNipRead, handleBlossomUpload, handleBlossomList, handleBlossomDelete, handleBlossomDownload, handleBlossomCheck, handleBlossomDiscover, handleBlossomVerify, handleBlossomRepair, handleBlossomUsage, handleBlossomMirror, handleBlossomServersGet, handleBlossomServersSet, handleGroupInfo, handleGroupChat, handleGroupSend, handleGroupMembers, handleGroupCreate, handleGroupUpdate, handleGroupAddUser, handleGroupRemoveUser, handleGroupAdmins, handleGroupRoles, handleGroupInspect, handleGroupCreateInvite, handleGroupJoin, handleGroupLeave, handleGroupDeleteEvent, handleGroupDelete, handleGroupForumTopics, handleGroupForumTopicCreate, handleGroupForumComments, handleGroupForumComment, } from '../../exports.js';
2
2
  import * as fmt from '../../format.js';
3
3
  export async function dispatch(cmd, cmdArgs, h, ctx, pool, config) {
4
4
  const { req, flag, flags, hasFlag, out } = h;
@@ -121,6 +121,67 @@ export async function dispatch(cmd, cmdArgs, h, ctx, pool, config) {
121
121
  sha256: req(2, 'blossom-delete <server> <sha256>'),
122
122
  }));
123
123
  break;
124
+ case 'blossom-download': {
125
+ const { writeFileSync } = await import('node:fs');
126
+ const result = await handleBlossomDownload({
127
+ server: req(1, 'blossom-download <server> <sha256> [out-file]'),
128
+ sha256: req(2, 'blossom-download <server> <sha256> [out-file]'),
129
+ });
130
+ const dest = cmdArgs[3];
131
+ if (dest) {
132
+ writeFileSync(dest, result.data);
133
+ out({ sha256: cmdArgs[2], bytes: result.data.length, contentType: result.contentType, path: dest });
134
+ }
135
+ else {
136
+ // No destination: stream the blob to stdout so it can be piped
137
+ process.stdout.write(result.data);
138
+ }
139
+ break;
140
+ }
141
+ case 'blossom-check':
142
+ out(await handleBlossomCheck({
143
+ server: req(1, 'blossom-check <server> <sha256> [--verify]'),
144
+ sha256: req(2, 'blossom-check <server> <sha256> [--verify]'),
145
+ verify: hasFlag('verify'),
146
+ }));
147
+ break;
148
+ case 'blossom-discover':
149
+ out(await handleBlossomDiscover(pool, ctx.activeNpub, {
150
+ pubkeys: flags('pubkey').length ? flags('pubkey') : undefined,
151
+ }));
152
+ break;
153
+ case 'blossom-verify':
154
+ out(await handleBlossomVerify({
155
+ content: req(1, 'blossom-verify <content> [--verify-hash]'),
156
+ verifyHash: hasFlag('verify-hash'),
157
+ }));
158
+ break;
159
+ case 'blossom-repair':
160
+ out(await handleBlossomRepair(ctx, {
161
+ sha256: req(1, 'blossom-repair <sha256> --search <url> [--search <url>...] [--target <url>]'),
162
+ searchServers: flags('search'),
163
+ targetServer: flag('target'),
164
+ }));
165
+ break;
166
+ case 'blossom-usage':
167
+ out(await handleBlossomUsage({
168
+ servers: flags('server'),
169
+ pubkeyHex: req(1, 'blossom-usage <pubkey-hex> --server <url> [--server <url>...]'),
170
+ }));
171
+ break;
172
+ case 'blossom-mirror':
173
+ out(await handleBlossomMirror(ctx, {
174
+ servers: flags('server'),
175
+ sourceUrl: flag('source'),
176
+ filePath: flag('file'),
177
+ }));
178
+ break;
179
+ case 'blossom-servers':
180
+ out(await handleBlossomServersGet(pool, ctx.activeNpub, req(1, 'blossom-servers <pubkey-hex>')));
181
+ break;
182
+ case 'blossom-servers-set':
183
+ out(await handleBlossomServersSet(ctx, pool, { servers: flags('server') }));
184
+ break;
124
185
  case 'group-info':
125
186
  out(await handleGroupInfo(pool, ctx.activeNpub, {
126
187
  relay: req(1, 'group-info <relay-url> <group-id>'),
@@ -1,4 +1,4 @@
1
- import { handleDecode, handleEncodeNpub, handleEncodeNote, handleEncodeNprofile, handleEncodeNevent, handleVerify, handleEncrypt, handleDecrypt, handleCount, handleFetch, handleKeyPublic, handleEncodeNsec, handleFilter, handleNipList, handleNipShow, handleKeyEncrypt, handleKeyDecrypt, handleValidateEvent, lookupKind, searchKinds, } from '../../exports.js';
1
+ import { handleDecode, handleEncodeNpub, handleEncodeNote, handleEncodeNprofile, handleEncodeNevent, handleVerify, handleEncrypt, handleDecrypt, handleCount, handleFetch, handleKeyPublic, handleEncodeNsec, handleFilter, handleNipList, handleNipShow, handleKeyEncrypt, handleKeyDecrypt, handleValidateEvent, lookupKind, searchKinds, handleGiftWrap, handleGiftUnwrap, } from '../../exports.js';
2
2
  import { validateInputPath } from '../../validation.js';
3
3
  import * as fmt from '../../format.js';
4
4
  export async function dispatch(cmd, cmdArgs, h, ctx, pool) {
@@ -63,6 +63,19 @@ export async function dispatch(cmd, cmdArgs, h, ctx, pool) {
63
63
  out(handleValidateEvent(JSON.parse(raw), mode));
64
64
  break;
65
65
  }
66
+ case 'gift-wrap': {
67
+ const recipient = req(1, 'gift-wrap <recipient-pubkey-hex> <event-json|-> [--file path]');
68
+ out(await handleGiftWrap(ctx, {
69
+ event: JSON.parse(await readJsonArg(cmdArgs[2], flag('file'))),
70
+ recipientPubkey: recipient,
71
+ }));
72
+ break;
73
+ }
74
+ case 'gift-unwrap':
75
+ out(await handleGiftUnwrap(ctx, {
76
+ event: JSON.parse(await readJsonArg(cmdArgs[1], flag('file'))),
77
+ }));
78
+ break;
66
79
  case 'kind': {
67
80
  const arg = req(1, 'kind <number|search text>');
68
81
  if (/^\d+$/.test(arg)) {
@@ -107,3 +120,15 @@ export async function dispatch(cmd, cmdArgs, h, ctx, pool) {
107
120
  throw new Error(`Unknown command: ${cmd}. Run --help for usage.`);
108
121
  }
109
122
  }
123
+ /**
124
+ * Read a JSON argument from, in order: an explicit --file, a positional
125
+ * argument, or stdin. `-` selects stdin explicitly, matching common CLI usage.
126
+ */
127
+ async function readJsonArg(positional, filePath) {
128
+ const { readFileSync } = await import('node:fs');
129
+ if (filePath)
130
+ return readFileSync(validateInputPath(filePath), 'utf8');
131
+ if (positional && positional !== '-' && !positional.startsWith('--'))
132
+ return positional;
133
+ return readFileSync(0, 'utf8');
134
+ }
@@ -27,6 +27,8 @@ export const COMPOUND_COMMANDS = new Set([
27
27
  'trust-read', 'trust-verify', 'trust-revoke', 'trust-request', 'trust-rank',
28
28
  'nip-publish', 'nip-read',
29
29
  'relay-set', 'relay-add', 'relay-curl',
30
+ // blossom two-word forms
31
+ 'blossom-servers-set',
30
32
  'outbox-relays', 'outbox-publish',
31
33
  'ring-prove', 'ring-verify',
32
34
  'musig2-key', 'musig2-nonce', 'musig2-partial-sign', 'musig2-aggregate',
@@ -44,7 +46,7 @@ export const OFFLINE_COMMANDS = new Set([
44
46
  'ring-verify', 'zap-decode', 'safety-configure', 'safety-activate',
45
47
  'decode', 'encode-npub', 'encode-note', 'encode-nprofile', 'encode-nevent', 'encode-nsec',
46
48
  'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'verify', 'encrypt', 'decrypt',
47
- 'validate-event', 'kind',
49
+ 'validate-event', 'kind', 'gift-wrap', 'gift-unwrap',
48
50
  'musig2-key', 'musig2-nonce', 'musig2-partial-sign', 'musig2-aggregate',
49
51
  // wallet config subcommands don't touch the relay
50
52
  'wallet-connect', 'wallet-disconnect', 'wallet-status',
package/dist/cli/index.js CHANGED
@@ -56,7 +56,12 @@ if (command === 'serve' && !args.includes('--help')) {
56
56
  const hostname = args.includes('--hostname') ? args[args.indexOf('--hostname') + 1] : 'localhost';
57
57
  const port = args.includes('--port') ? parseInt(args[args.indexOf('--port') + 1], 10) : 10547;
58
58
  const eventsFile = args.includes('--events') ? args[args.indexOf('--events') + 1] : undefined;
59
- const relayServer = startRelay({ hostname, port, eventsFile, quiet: args.includes('--quiet') });
59
+ const relayServer = startRelay({
60
+ hostname, port, eventsFile,
61
+ quiet: args.includes('--quiet'),
62
+ auth: args.includes('--auth') || args.includes('--eager-auth'),
63
+ eagerAuth: args.includes('--eager-auth'),
64
+ });
60
65
  console.error(`nostr-bray test relay running at ${relayServer.url}`);
61
66
  console.error('Press Ctrl+C to stop');
62
67
  process.on('SIGINT', () => { relayServer.close(); process.exit(0); });
@@ -102,6 +107,15 @@ Social:
102
107
  blossom-upload <server> <file> Upload file to blossom media server
103
108
  blossom-list <server> <pubkey> List blobs on blossom server
104
109
  blossom-delete <server> <sha256> Delete blob from blossom server
110
+ blossom-download <server> <sha256> [file] Download a blob (stdout if no file)
111
+ blossom-check <server> <sha256> [--verify] Check a blob exists
112
+ blossom-discover [--pubkey hex ...] Discover servers from kind 10063 lists
113
+ blossom-verify <content> [--verify-hash] Check media URLs in content resolve
114
+ blossom-repair <sha256> --search <url>... [--target <url>] Re-upload a lost blob
115
+ blossom-usage <pubkey-hex> --server <url>... Storage used per server
116
+ blossom-mirror --server <url>... [--source <url>|--file <path>] Copy a blob to servers
117
+ blossom-servers <pubkey-hex> Read a kind 10063 server list
118
+ blossom-servers-set --server <url>... Publish your kind 10063 server list
105
119
  group-info <relay> <group-id> Fetch verified NIP-29 group metadata
106
120
  group-chat <relay> <group-id> Fetch relay-scoped group chat
107
121
  group-send <relay> <group-id> "message" Send a group message
@@ -206,6 +220,8 @@ Utility:
206
220
  nips List all official NIPs
207
221
  nip <number> Show a specific NIP
208
222
  kind <number|text> Look up an event kind: required tags, value shapes
223
+ gift-wrap <pubkey-hex> <event|-> [--file f] Seal + wrap an event for a recipient (NIP-59)
224
+ gift-unwrap <event-json|-> [--file f] Open a kind 1059 gift wrap addressed to you
209
225
  verify <event-json> Verify event hash and signature
210
226
  encrypt <pubkey-hex> "plaintext" NIP-44 encrypt for a recipient
211
227
  decrypt <pubkey-hex> <ciphertext> NIP-44 decrypt from a sender
@@ -220,7 +236,8 @@ MuSig2 (BIP-327 multi-signature):
220
236
 
221
237
  Modes:
222
238
  (no command) Start MCP server (stdio)
223
- serve [--port N] [--events file] Start in-memory test relay
239
+ serve [--port N] [--events file] [--auth] [--eager-auth] Start in-memory test relay
240
+ (--auth requires NIP-42; --eager-auth also challenges on connect)
224
241
  bunker connect <bunker://…> Save remote bunker URI for future commands
225
242
  bunker authorize <hex-pubkey> Pre-authorise an app pubkey on the local bunker
226
243
  bunker status Show saved bunker connection state
@@ -382,6 +399,7 @@ const SOCIAL_CMDS = new Set([
382
399
  'contacts', 'follow', 'unfollow', 'dm', 'dm-read', 'feed', 'notifications',
383
400
  'nip-publish', 'nip-read',
384
401
  'blossom-upload', 'blossom-list', 'blossom-delete',
402
+ 'blossom-download', 'blossom-check', 'blossom-discover', 'blossom-verify', 'blossom-repair', 'blossom-usage', 'blossom-mirror', 'blossom-servers', 'blossom-servers-set',
385
403
  'group-info', 'group-chat', 'group-send', 'group-members',
386
404
  'group-admins', 'group-roles', 'group-inspect',
387
405
  'group-create', 'group-update', 'group-add-user', 'group-remove-user',
@@ -398,7 +416,7 @@ const SAFETY_CMDS = new Set(['safety-configure', 'safety-activate']);
398
416
  const EVENT_CMDS = new Set(['event', 'publish-raw']);
399
417
  const UTIL_CMDS = new Set([
400
418
  'decode', 'encode-npub', 'encode-note', 'encode-nprofile', 'encode-nevent', 'encode-nsec',
401
- 'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'nips', 'nip', 'verify', 'validate-event', 'kind', 'encrypt', 'decrypt', 'count', 'fetch',
419
+ 'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'nips', 'nip', 'verify', 'validate-event', 'kind', 'gift-wrap', 'gift-unwrap', 'encrypt', 'decrypt', 'count', 'fetch',
402
420
  ]);
403
421
  const MUSIG2_CMDS = new Set(['musig2-key', 'musig2-nonce', 'musig2-partial-sign', 'musig2-aggregate']);
404
422
  const SYNC_CMDS = new Set(['sync-plan', 'sync-pull', 'sync-push']);
@@ -455,6 +473,7 @@ const ALL_COMMANDS = [
455
473
  'zap-send', 'zap-balance', 'zap-invoice', 'zap-lookup', 'zap-transactions', 'zap-receipts', 'zap-decode',
456
474
  'safety-configure', 'safety-activate',
457
475
  'blossom-upload', 'blossom-list', 'blossom-delete',
476
+ 'blossom-download', 'blossom-check', 'blossom-discover', 'blossom-verify', 'blossom-repair', 'blossom-usage', 'blossom-mirror', 'blossom-servers', 'blossom-servers-set',
458
477
  'group-info', 'group-chat', 'group-send', 'group-members',
459
478
  'group-admins', 'group-roles', 'group-inspect',
460
479
  'group-create', 'group-update', 'group-add-user', 'group-remove-user',
@@ -462,7 +481,7 @@ const ALL_COMMANDS = [
462
481
  'group-forum-topics', 'group-forum-topic-create', 'group-forum-comments', 'group-forum-comment',
463
482
  'event', 'publish-raw',
464
483
  'decode', 'encode-npub', 'encode-note', 'encode-nprofile', 'encode-nevent', 'encode-nsec',
465
- 'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'nips', 'nip', 'verify', 'validate-event', 'kind', 'encrypt', 'decrypt', 'count', 'fetch',
484
+ 'key-public', 'key-encrypt', 'key-decrypt', 'filter', 'nips', 'nip', 'verify', 'validate-event', 'kind', 'gift-wrap', 'gift-unwrap', 'encrypt', 'decrypt', 'count', 'fetch',
466
485
  'musig2-key', 'musig2-nonce', 'musig2-partial-sign', 'musig2-aggregate',
467
486
  'sync-plan', 'sync-pull', 'sync-push',
468
487
  ...ADMIN_COMMANDS,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * NIP-59 gift wrap for arbitrary events.
3
+ *
4
+ * `src/nip17-wrap.ts` wraps DMs specifically: it builds a kind 14 rumor from a
5
+ * message string. This is the general form — seal and wrap any event you like,
6
+ * which is what `nak gift wrap` does and what NIP-59 actually describes.
7
+ *
8
+ * Three layers:
9
+ * rumor — an unsigned event, signed by nobody, identified only by its hash
10
+ * seal — kind 13, the rumor NIP-44 encrypted to the recipient, signed by you
11
+ * wrap — kind 1059, the seal NIP-44 encrypted under a throwaway key
12
+ *
13
+ * Only the wrap is public, and it is signed by an ephemeral key, so nothing
14
+ * on the wire links back to the sender.
15
+ *
16
+ * All crypto goes through the SigningContext rather than a raw private key, so
17
+ * this works unchanged under NIP-46 where the key lives in a remote bunker.
18
+ */
19
+ import type { Event as NostrEvent } from 'nostr-tools';
20
+ import type { SigningContext } from '../signing-context.js';
21
+ /** An event to be wrapped. Anything already signed has its signature dropped: a rumor is unsigned by definition. */
22
+ export interface RumorInput {
23
+ kind: number;
24
+ content?: string;
25
+ tags?: string[][];
26
+ created_at?: number;
27
+ }
28
+ export interface GiftWrapResult {
29
+ /** The kind 1059 wrap, ready to publish. */
30
+ wrap: NostrEvent;
31
+ /** The rumor's id, which the recipient will see after unwrapping. */
32
+ rumorId: string;
33
+ recipient: string;
34
+ }
35
+ /**
36
+ * Seal and wrap an event for one recipient.
37
+ *
38
+ * @throws if the recipient pubkey is not 64-char hex.
39
+ */
40
+ export declare function handleGiftWrap(ctx: SigningContext, args: {
41
+ event: RumorInput;
42
+ recipientPubkey: string;
43
+ }): Promise<GiftWrapResult>;
44
+ export interface GiftUnwrapResult {
45
+ /** The rumor that was hidden inside. */
46
+ rumor: {
47
+ id?: string;
48
+ pubkey: string;
49
+ kind: number;
50
+ content: string;
51
+ tags: string[][];
52
+ created_at: number;
53
+ };
54
+ /** Pubkey that signed the seal — the real sender. */
55
+ sender: string;
56
+ /**
57
+ * Whether the seal's signer matches the rumor's claimed author.
58
+ *
59
+ * A mismatch means someone sealed an event attributed to a third party.
60
+ * NIP-59 requires the recipient to check this; a rumor is unsigned, so its
61
+ * `pubkey` field is a claim, not proof.
62
+ */
63
+ senderMatchesRumor: boolean;
64
+ }
65
+ /**
66
+ * Unwrap a kind 1059 gift wrap back to its rumor.
67
+ *
68
+ * @throws if the event is not a gift wrap, or either layer fails to decrypt.
69
+ */
70
+ export declare function handleGiftUnwrap(ctx: SigningContext, args: {
71
+ event: NostrEvent;
72
+ }): Promise<GiftUnwrapResult>;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * NIP-59 gift wrap for arbitrary events.
3
+ *
4
+ * `src/nip17-wrap.ts` wraps DMs specifically: it builds a kind 14 rumor from a
5
+ * message string. This is the general form — seal and wrap any event you like,
6
+ * which is what `nak gift wrap` does and what NIP-59 actually describes.
7
+ *
8
+ * Three layers:
9
+ * rumor — an unsigned event, signed by nobody, identified only by its hash
10
+ * seal — kind 13, the rumor NIP-44 encrypted to the recipient, signed by you
11
+ * wrap — kind 1059, the seal NIP-44 encrypted under a throwaway key
12
+ *
13
+ * Only the wrap is public, and it is signed by an ephemeral key, so nothing
14
+ * on the wire links back to the sender.
15
+ *
16
+ * All crypto goes through the SigningContext rather than a raw private key, so
17
+ * this works unchanged under NIP-46 where the key lives in a remote bunker.
18
+ */
19
+ import { finalizeEvent, generateSecretKey, getEventHash } from 'nostr-tools/pure';
20
+ /** Timestamps are randomised up to two days back, per NIP-59, to frustrate correlation. */
21
+ const TWO_DAYS = 2 * 24 * 60 * 60;
22
+ const randomNow = () => Math.round(Date.now() / 1000 - Math.random() * TWO_DAYS);
23
+ const HEX64 = /^[0-9a-f]{64}$/i;
24
+ /**
25
+ * Seal and wrap an event for one recipient.
26
+ *
27
+ * @throws if the recipient pubkey is not 64-char hex.
28
+ */
29
+ export async function handleGiftWrap(ctx, args) {
30
+ const recipient = args.recipientPubkey.toLowerCase();
31
+ if (!HEX64.test(recipient)) {
32
+ throw new Error('recipientPubkey must be a 64-character hex public key');
33
+ }
34
+ // A rumor carries an id but no signature — that is what makes it deniable.
35
+ const rumor = {
36
+ pubkey: ctx.activePublicKeyHex,
37
+ created_at: args.event.created_at ?? Math.floor(Date.now() / 1000),
38
+ kind: args.event.kind,
39
+ tags: args.event.tags ?? [],
40
+ content: args.event.content ?? '',
41
+ };
42
+ const rumorId = getEventHash(rumor);
43
+ const rumorWithId = { ...rumor, id: rumorId };
44
+ // Seal: rumor encrypted to the recipient, signed by us
45
+ const sealContent = await ctx.nip44Encrypt(recipient, JSON.stringify(rumorWithId));
46
+ const sign = ctx.getSigningFunction();
47
+ const seal = await sign({
48
+ kind: 13,
49
+ content: sealContent,
50
+ created_at: randomNow(),
51
+ tags: [],
52
+ });
53
+ // Wrap: seal encrypted under a throwaway key, so the wrap is unattributable
54
+ const ephemeralSk = generateSecretKey();
55
+ try {
56
+ const { getConversationKey, encrypt } = await import('nostr-tools/nip44');
57
+ const ck = getConversationKey(ephemeralSk, recipient);
58
+ const wrap = finalizeEvent({
59
+ kind: 1059,
60
+ content: encrypt(JSON.stringify(seal), ck),
61
+ created_at: randomNow(),
62
+ tags: [['p', recipient]],
63
+ }, ephemeralSk);
64
+ return { wrap, rumorId, recipient };
65
+ }
66
+ finally {
67
+ ephemeralSk.fill(0);
68
+ }
69
+ }
70
+ /**
71
+ * Unwrap a kind 1059 gift wrap back to its rumor.
72
+ *
73
+ * @throws if the event is not a gift wrap, or either layer fails to decrypt.
74
+ */
75
+ export async function handleGiftUnwrap(ctx, args) {
76
+ const wrap = args.event;
77
+ if (wrap?.kind !== 1059) {
78
+ throw new Error(`Expected a kind 1059 gift wrap, got kind ${wrap?.kind}`);
79
+ }
80
+ // Outer layer: decrypt against the ephemeral pubkey that signed the wrap
81
+ let seal;
82
+ try {
83
+ seal = JSON.parse(await ctx.nip44Decrypt(wrap.pubkey, wrap.content));
84
+ }
85
+ catch (e) {
86
+ throw new Error(`Could not decrypt the gift wrap — it may not be addressed to this identity: ${e.message}`);
87
+ }
88
+ if (seal?.kind !== 13) {
89
+ throw new Error(`Expected a kind 13 seal inside the wrap, got kind ${seal?.kind}`);
90
+ }
91
+ // Inner layer: decrypt against the seal's signer, which is the real sender
92
+ let rumor;
93
+ try {
94
+ rumor = JSON.parse(await ctx.nip44Decrypt(seal.pubkey, seal.content));
95
+ }
96
+ catch (e) {
97
+ throw new Error(`Could not decrypt the seal: ${e.message}`);
98
+ }
99
+ return {
100
+ rumor,
101
+ sender: seal.pubkey,
102
+ senderMatchesRumor: rumor.pubkey === seal.pubkey,
103
+ };
104
+ }
@@ -1,3 +1,4 @@
1
1
  export * from './handlers.js';
2
2
  export * from '../event-validation/index.js';
3
3
  export * from './pow.js';
4
+ export * from './gift-wrap.js';
@@ -1,3 +1,4 @@
1
1
  export * from './handlers.js';
2
2
  export * from '../event-validation/index.js';
3
3
  export * from './pow.js';
4
+ export * from './gift-wrap.js';
package/dist/serve.d.ts CHANGED
@@ -9,6 +9,16 @@ export interface ServeOptions {
9
9
  port?: number;
10
10
  eventsFile?: string;
11
11
  quiet?: boolean;
12
+ /**
13
+ * Require NIP-42 AUTH before serving EVENT or REQ.
14
+ *
15
+ * Exists so clients can be tested against an auth-gated relay without
16
+ * needing a real one — bray's own NIP-42 support is otherwise only
17
+ * exercisable against third-party infrastructure.
18
+ */
19
+ auth?: boolean;
20
+ /** Send the AUTH challenge on connect rather than waiting for a rejection. */
21
+ eagerAuth?: boolean;
12
22
  }
13
23
  export declare function startRelay(opts?: ServeOptions): {
14
24
  url: string;
package/dist/serve.js CHANGED
@@ -7,6 +7,7 @@
7
7
  import { WebSocketServer, WebSocket } from 'ws';
8
8
  import { createServer } from 'node:http';
9
9
  import { readFileSync } from 'node:fs';
10
+ import { randomBytes } from 'node:crypto';
10
11
  import { verifyEvent } from 'nostr-tools/pure';
11
12
  let subCounter = 0;
12
13
  /** Check if an event matches a single filter */
@@ -39,10 +40,36 @@ function matchFilter(filter, event) {
39
40
  function matchFilters(filters, event) {
40
41
  return filters.some(f => matchFilter(f, event));
41
42
  }
43
+ /**
44
+ * Check a NIP-42 auth event against the challenge this connection issued.
45
+ *
46
+ * Returns a reason string when it should be rejected, or undefined when it is
47
+ * good. Beyond the signature this checks kind, freshness and that the
48
+ * `challenge` tag matches — without the last one an auth event captured from
49
+ * another connection would be replayable here.
50
+ */
51
+ function checkAuthEvent(event, challenge) {
52
+ if (!event?.id || !event?.sig || !event?.pubkey)
53
+ return 'malformed auth event';
54
+ if (event.kind !== 22242)
55
+ return `expected kind 22242, got ${event.kind}`;
56
+ if (!verifyEvent(event))
57
+ return 'invalid signature';
58
+ const sent = event.tags?.find(t => t[0] === 'challenge')?.[1];
59
+ if (sent !== challenge)
60
+ return 'challenge mismatch';
61
+ // NIP-42 suggests rejecting anything far from the present
62
+ const age = Math.abs(Math.floor(Date.now() / 1000) - event.created_at);
63
+ if (age > 600)
64
+ return 'auth event timestamp too far from now';
65
+ return undefined;
66
+ }
42
67
  export function startRelay(opts = {}) {
43
68
  const hostname = opts.hostname ?? 'localhost';
44
69
  const port = opts.port ?? 10547;
45
70
  const quiet = opts.quiet ?? false;
71
+ const requireAuth = opts.auth ?? false;
72
+ const eagerAuth = opts.eagerAuth ?? false;
46
73
  const log = quiet ? () => { } : (...args) => console.error('[relay]', ...args);
47
74
  // The bundled test relay has no auth and accepts arbitrary signed events.
48
75
  // Binding it to anything other than loopback exposes a writable Nostr endpoint
@@ -86,7 +113,14 @@ export function startRelay(opts = {}) {
86
113
  const wss = new WebSocketServer({ server: httpServer });
87
114
  wss.on('connection', (ws) => {
88
115
  const clientSubs = new Set();
116
+ // NIP-42: one challenge per connection; authedPubkey is set once the
117
+ // client returns a valid kind 22242 naming this relay and that challenge.
118
+ const challenge = randomBytes(16).toString('hex');
119
+ let authedPubkey;
89
120
  log('Client connected');
121
+ if (requireAuth && eagerAuth) {
122
+ ws.send(JSON.stringify(['AUTH', challenge]));
123
+ }
90
124
  ws.on('message', (raw) => {
91
125
  let msg;
92
126
  try {
@@ -101,6 +135,31 @@ export function startRelay(opts = {}) {
101
135
  return;
102
136
  }
103
137
  const type = msg[0];
138
+ if (type === 'AUTH') {
139
+ const authEvent = msg[1];
140
+ const problem = checkAuthEvent(authEvent, challenge);
141
+ if (problem) {
142
+ ws.send(JSON.stringify(['OK', authEvent?.id ?? '', false, `auth-required: ${problem}`]));
143
+ return;
144
+ }
145
+ authedPubkey = authEvent.pubkey;
146
+ log('Client authenticated as', authedPubkey);
147
+ ws.send(JSON.stringify(['OK', authEvent.id, true, '']));
148
+ return;
149
+ }
150
+ if (requireAuth && !authedPubkey && (type === 'EVENT' || type === 'REQ')) {
151
+ // Send the challenge alongside the rejection so a client that did not
152
+ // ask for one up front can still recover without reconnecting.
153
+ ws.send(JSON.stringify(['AUTH', challenge]));
154
+ if (type === 'EVENT') {
155
+ const e = msg[1];
156
+ ws.send(JSON.stringify(['OK', e?.id ?? '', false, 'auth-required: authentication required']));
157
+ }
158
+ else {
159
+ ws.send(JSON.stringify(['CLOSED', String(msg[1]), 'auth-required: authentication required']));
160
+ }
161
+ return;
162
+ }
104
163
  if (type === 'EVENT') {
105
164
  const event = msg[1];
106
165
  if (!event?.id || !event?.sig || !event?.pubkey) {
@@ -7,6 +7,7 @@ import { handleDecode, handleEncodeNpub, handleEncodeNote, handleEncodeNprofile,
7
7
  import { handleValidateEvent } from '../event-validation/validator.js';
8
8
  import { eventValidationModeSchema, semanticEventInputSchema } from '../event-validation/tools-schema.js';
9
9
  import { lookupKind, searchKinds } from '../event-validation/kind-lookup.js';
10
+ import { handleGiftWrap, handleGiftUnwrap } from '../event/gift-wrap.js';
10
11
  export function registerUtilTools(server, deps) {
11
12
  server.registerTool('decode', {
12
13
  description: 'Decode a nip19 entity (npub, nsec, note, nevent, nprofile, naddr) or nostr: URI to its components.',
@@ -87,6 +88,35 @@ export function registerUtilTools(server, deps) {
87
88
  const result = handleValidateEvent(event, mode);
88
89
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
89
90
  });
91
+ server.registerTool('gift-wrap', {
92
+ description: 'Seal and gift-wrap any event for one recipient (NIP-59). Returns a kind 1059 wrap signed by a throwaway key, ' +
93
+ 'so nothing public links it to you. Use for metadata-private delivery of arbitrary events; DMs already wrap themselves via dm-send.',
94
+ inputSchema: {
95
+ event: z.object({
96
+ kind: z.number().int().min(0).describe('Kind of the event to wrap'),
97
+ content: z.string().optional().describe('Event content'),
98
+ tags: z.array(z.array(z.string())).optional().describe('Event tags'),
99
+ created_at: z.number().int().optional().describe('Unix timestamp (defaults to now)'),
100
+ }).describe('The event to wrap. It is sealed unsigned, as NIP-59 requires.'),
101
+ recipient_pubkey: hexId.describe('Recipient hex public key'),
102
+ },
103
+ annotations: { readOnlyHint: true, openWorldHint: false },
104
+ }, async ({ event, recipient_pubkey }) => {
105
+ const result = await handleGiftWrap(deps.ctx, { event, recipientPubkey: recipient_pubkey });
106
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
107
+ });
108
+ server.registerTool('gift-unwrap', {
109
+ description: 'Unwrap a NIP-59 kind 1059 gift wrap addressed to the active identity, returning the hidden rumor and the ' +
110
+ 'pubkey that sealed it. Also reports whether the sealer matches the rumor\'s claimed author — a rumor is ' +
111
+ 'unsigned, so its pubkey is a claim rather than proof, and a mismatch means someone sealed an event attributed to a third party.',
112
+ inputSchema: {
113
+ event: nostrEventSchema.describe('The kind 1059 gift wrap to open'),
114
+ },
115
+ annotations: { readOnlyHint: true, openWorldHint: false },
116
+ }, async ({ event }) => {
117
+ const result = await handleGiftUnwrap(deps.ctx, { event: event });
118
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
119
+ });
90
120
  server.registerTool('kind-info', {
91
121
  description: 'Look up what a Nostr event kind is for and what shape it takes: description, storage class ' +
92
122
  '(regular/replaceable/ephemeral/addressable), required tags, repeatable tags, and the expected value type ' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nostr-bray",
3
- "version": "1.42.3",
3
+ "version": "2.1.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",
@@ -149,7 +149,7 @@
149
149
  "homepage": "https://github.com/forgesworn/bray#readme",
150
150
  "license": "MIT",
151
151
  "engines": {
152
- "node": ">=20"
152
+ "node": ">=22.19.0"
153
153
  },
154
154
  "dependencies": {
155
155
  "@forgesworn/range-proof": "^2.0.1",
@@ -166,7 +166,7 @@
166
166
  "signet-protocol": "^1.1.0",
167
167
  "socks-proxy-agent": "^10.0.0",
168
168
  "spoken-token": "^2.0.3",
169
- "undici": "^7.25.0",
169
+ "undici": "^8.9.0",
170
170
  "ws": "^8.20.0",
171
171
  "zod": "^4.3.6"
172
172
  },