ncc-05 1.1.3 → 1.1.5

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/dist/index.d.ts CHANGED
@@ -6,7 +6,22 @@
6
6
  *
7
7
  * @module ncc-05
8
8
  */
9
- import { Event } from 'nostr-tools';
9
+ import { SimplePool, Event } from 'nostr-tools';
10
+ export declare class NCC05Error extends Error {
11
+ constructor(message: string);
12
+ }
13
+ export declare class NCC05RelayError extends NCC05Error {
14
+ constructor(message: string);
15
+ }
16
+ export declare class NCC05TimeoutError extends NCC05Error {
17
+ constructor(message: string);
18
+ }
19
+ export declare class NCC05DecryptionError extends NCC05Error {
20
+ constructor(message: string);
21
+ }
22
+ export declare class NCC05ArgumentError extends NCC05Error {
23
+ constructor(message: string);
24
+ }
10
25
  /**
11
26
  * Represents a single reachable service endpoint.
12
27
  */
@@ -47,6 +62,19 @@ export interface ResolverOptions {
47
62
  timeout?: number;
48
63
  /** Custom WebSocket implementation (e.g., for Tor/SOCKS5 in Node.js) */
49
64
  websocketImplementation?: any;
65
+ /** Existing SimplePool instance to share connections */
66
+ pool?: SimplePool;
67
+ }
68
+ /**
69
+ * Options for configuring the NCC05Publisher.
70
+ */
71
+ export interface PublisherOptions {
72
+ /** Custom WebSocket implementation */
73
+ websocketImplementation?: any;
74
+ /** Existing SimplePool instance */
75
+ pool?: SimplePool;
76
+ /** Timeout for publishing in milliseconds (default: 5000) */
77
+ timeout?: number;
50
78
  }
51
79
  /**
52
80
  * Structure for multi-recipient encrypted events.
@@ -83,7 +111,7 @@ export declare class NCC05Group {
83
111
  * @param identifier - The 'd' tag of the record (default: 'addr').
84
112
  * @returns The resolved NCC05Payload or null.
85
113
  */
86
- static resolveAsGroup(resolver: NCC05Resolver, groupPubkey: string, groupSecretKey: Uint8Array, identifier?: string): Promise<NCC05Payload | null>;
114
+ static resolveAsGroup(resolver: NCC05Resolver, groupPubkey: string, groupSecretKey: string | Uint8Array, identifier?: string): Promise<NCC05Payload | null>;
87
115
  }
88
116
  /**
89
117
  * Handles the discovery, selection, and decryption of NCC-05 locator records.
@@ -106,9 +134,11 @@ export declare class NCC05Resolver {
106
134
  * @param secretKey - Your secret key (required if the record is encrypted).
107
135
  * @param identifier - The 'd' tag of the record (default: 'addr').
108
136
  * @param options - Resolution options (strict mode, gossip discovery).
109
- * @returns The resolved and validated NCC05Payload, or null if not found/invalid.
137
+ * @returns The resolved and validated NCC05Payload, or null if not found.
138
+ * @throws {NCC05TimeoutError} if resolution times out.
139
+ * @throws {NCC05RelayError} if underlying relay communication fails.
110
140
  */
111
- resolve(targetPubkey: string, secretKey?: Uint8Array, identifier?: string, options?: {
141
+ resolve(targetPubkey: string, secretKey?: string | Uint8Array, identifier?: string, options?: {
112
142
  strict?: boolean;
113
143
  gossip?: boolean;
114
144
  }): Promise<NCC05Payload | null>;
@@ -122,12 +152,12 @@ export declare class NCC05Resolver {
122
152
  */
123
153
  export declare class NCC05Publisher {
124
154
  private pool;
155
+ private timeout;
125
156
  /**
126
157
  * @param options - Configuration for the publisher.
127
158
  */
128
- constructor(options?: {
129
- websocketImplementation?: any;
130
- });
159
+ constructor(options?: PublisherOptions);
160
+ private _publishToRelays;
131
161
  /**
132
162
  * Publishes a single record encrypted for multiple recipients using the wrapping pattern.
133
163
  * This avoids sharing a single group private key.
@@ -139,7 +169,7 @@ export declare class NCC05Publisher {
139
169
  * @param identifier - The 'd' tag identifier (default: 'addr').
140
170
  * @returns The signed Nostr event.
141
171
  */
142
- publishWrapped(relays: string[], secretKey: Uint8Array, recipients: string[], payload: NCC05Payload, identifier?: string): Promise<Event>;
172
+ publishWrapped(relays: string[], secretKey: string | Uint8Array, recipients: string[], payload: NCC05Payload, identifier?: string): Promise<Event>;
143
173
  /**
144
174
  * Publishes a locator record. Supports self-encryption, targeted encryption, or plaintext.
145
175
  *
@@ -149,7 +179,7 @@ export declare class NCC05Publisher {
149
179
  * @param options - Publishing options (identifier, recipient, or public flag).
150
180
  * @returns The signed Nostr event.
151
181
  */
152
- publish(relays: string[], secretKey: Uint8Array, payload: NCC05Payload, options?: {
182
+ publish(relays: string[], secretKey: string | Uint8Array, payload: NCC05Payload, options?: {
153
183
  identifier?: string;
154
184
  recipientPubkey?: string;
155
185
  public?: boolean;
package/dist/index.js CHANGED
@@ -7,6 +7,56 @@
7
7
  * @module ncc-05
8
8
  */
9
9
  import { SimplePool, nip44, nip19, finalizeEvent, verifyEvent, getPublicKey, generateSecretKey } from 'nostr-tools';
10
+ // --- Error Classes ---
11
+ export class NCC05Error extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = 'NCC05Error';
15
+ }
16
+ }
17
+ export class NCC05RelayError extends NCC05Error {
18
+ constructor(message) {
19
+ super(message);
20
+ this.name = 'NCC05RelayError';
21
+ }
22
+ }
23
+ export class NCC05TimeoutError extends NCC05Error {
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = 'NCC05TimeoutError';
27
+ }
28
+ }
29
+ export class NCC05DecryptionError extends NCC05Error {
30
+ constructor(message) {
31
+ super(message);
32
+ this.name = 'NCC05DecryptionError';
33
+ }
34
+ }
35
+ export class NCC05ArgumentError extends NCC05Error {
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = 'NCC05ArgumentError';
39
+ }
40
+ }
41
+ // --- Helpers ---
42
+ function ensureUint8Array(key) {
43
+ if (key instanceof Uint8Array)
44
+ return key;
45
+ if (typeof key === 'string') {
46
+ // Assume hex string
47
+ if (key.match(/^[0-9a-fA-F]+$/)) {
48
+ return new Uint8Array(key.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
49
+ }
50
+ throw new NCC05ArgumentError("Invalid hex key provided");
51
+ }
52
+ throw new NCC05ArgumentError("Key must be a hex string or Uint8Array");
53
+ }
54
+ function getHexPubkey(key) {
55
+ if (key instanceof Uint8Array) {
56
+ return Array.from(key).map(b => b.toString(16).padStart(2, '0')).join('');
57
+ }
58
+ return key;
59
+ }
10
60
  /**
11
61
  * Utility for managing shared group access to service records.
12
62
  */
@@ -51,10 +101,17 @@ export class NCC05Resolver {
51
101
  * @param options - Configuration for the resolver.
52
102
  */
53
103
  constructor(options = {}) {
54
- this.pool = new SimplePool();
55
- if (options.websocketImplementation) {
56
- // @ts-ignore - Patching pool for custom transport
57
- this.pool.websocketImplementation = options.websocketImplementation;
104
+ this.pool = options.pool || new SimplePool();
105
+ if (!options.pool) {
106
+ if (options.websocketImplementation) {
107
+ // @ts-ignore - Patching pool for custom transport
108
+ this.pool.websocketImplementation = options.websocketImplementation;
109
+ }
110
+ else if (typeof globalThis !== 'undefined' && !globalThis.WebSocket) {
111
+ // In Node.js environment without global WebSocket, this might fail later.
112
+ // We leave it to the user or nostr-tools to handle, but this logic
113
+ // allows 'websocketImplementation' to be explicitly checked.
114
+ }
58
115
  }
59
116
  this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
60
117
  this.timeout = options.timeout || 10000;
@@ -69,7 +126,9 @@ export class NCC05Resolver {
69
126
  * @param secretKey - Your secret key (required if the record is encrypted).
70
127
  * @param identifier - The 'd' tag of the record (default: 'addr').
71
128
  * @param options - Resolution options (strict mode, gossip discovery).
72
- * @returns The resolved and validated NCC05Payload, or null if not found/invalid.
129
+ * @returns The resolved and validated NCC05Payload, or null if not found.
130
+ * @throws {NCC05TimeoutError} if resolution times out.
131
+ * @throws {NCC05RelayError} if underlying relay communication fails.
73
132
  */
74
133
  async resolve(targetPubkey, secretKey, identifier = 'addr', options = {}) {
75
134
  let hexPubkey = targetPubkey;
@@ -80,18 +139,25 @@ export class NCC05Resolver {
80
139
  let queryRelays = [...this.bootstrapRelays];
81
140
  // 1. NIP-65 Gossip Discovery
82
141
  if (options.gossip) {
83
- const relayListEvent = await this.pool.get(this.bootstrapRelays, {
84
- authors: [hexPubkey],
85
- kinds: [10002]
86
- });
87
- if (relayListEvent) {
88
- const discoveredRelays = relayListEvent.tags
89
- .filter(t => t[0] === 'r')
90
- .map(t => t[1]);
91
- if (discoveredRelays.length > 0) {
92
- queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
142
+ try {
143
+ const relayListEvent = await this.pool.get(this.bootstrapRelays, {
144
+ authors: [hexPubkey],
145
+ kinds: [10002]
146
+ });
147
+ // Security: Verify NIP-65 event signature and author
148
+ if (relayListEvent && verifyEvent(relayListEvent) && relayListEvent.pubkey === hexPubkey) {
149
+ const discoveredRelays = relayListEvent.tags
150
+ .filter(t => t[0] === 'r')
151
+ .map(t => t[1]);
152
+ if (discoveredRelays.length > 0) {
153
+ queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
154
+ }
93
155
  }
94
156
  }
157
+ catch (e) {
158
+ console.warn(`[NCC-05] Gossip discovery failed: ${e.message}`);
159
+ // Proceed with bootstrap relays
160
+ }
95
161
  }
96
162
  const filter = {
97
163
  authors: [hexPubkey],
@@ -99,57 +165,90 @@ export class NCC05Resolver {
99
165
  '#d': [identifier],
100
166
  limit: 10
101
167
  };
102
- const queryPromise = this.pool.querySync(queryRelays, filter);
103
- const timeoutPromise = new Promise((r) => setTimeout(() => r(null), this.timeout));
104
- const result = await Promise.race([queryPromise, timeoutPromise]);
105
- if (!result || (Array.isArray(result) && result.length === 0))
106
- return null;
107
- const validEvents = result
108
- .filter(e => verifyEvent(e))
109
- .sort((a, b) => b.created_at - a.created_at);
110
- if (validEvents.length === 0)
111
- return null;
112
- const latestEvent = validEvents[0];
168
+ const sk = secretKey ? ensureUint8Array(secretKey) : undefined;
113
169
  try {
170
+ const queryPromise = this.pool.querySync(queryRelays, filter);
171
+ const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new NCC05TimeoutError("Resolution timed out")), this.timeout));
172
+ const result = await Promise.race([queryPromise, timeoutPromise]);
173
+ if (!result || (Array.isArray(result) && result.length === 0))
174
+ return null;
175
+ // 2. Filter for valid signatures, correct author, and sort by created_at
176
+ const validEvents = result
177
+ .filter(e => e.pubkey === hexPubkey && verifyEvent(e))
178
+ .sort((a, b) => b.created_at - a.created_at);
179
+ if (validEvents.length === 0)
180
+ return null;
181
+ const latestEvent = validEvents[0];
114
182
  let content = latestEvent.content;
115
- // Handle "Wrapped" multi-recipient content
116
- if (content.includes('"wraps"') && secretKey) {
117
- const wrapped = JSON.parse(content);
118
- const myPk = getPublicKey(secretKey);
119
- const myWrap = wrapped.wraps[myPk];
120
- if (myWrap) {
121
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
122
- const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
123
- const symmetricKey = new Uint8Array(symmetricKeyHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
124
- const sessionConversationKey = nip44.getConversationKey(symmetricKey, getPublicKey(symmetricKey));
125
- content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
183
+ // Security: Robust multi-recipient detection
184
+ const isWrapped = content.includes('"wraps"') &&
185
+ content.includes('"ciphertext"') &&
186
+ content.startsWith('{');
187
+ if (isWrapped && sk) {
188
+ try {
189
+ const wrapped = JSON.parse(content);
190
+ const myPk = getPublicKey(sk);
191
+ const myWrap = wrapped.wraps[myPk];
192
+ if (myWrap) {
193
+ const conversationKey = nip44.getConversationKey(sk, hexPubkey);
194
+ const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
195
+ // Convert hex symmetric key back to Uint8Array for NIP-44 decryption
196
+ const symmetricKey = new Uint8Array(symmetricKeyHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
197
+ const sessionConversationKey = nip44.getConversationKey(symmetricKey, getPublicKey(symmetricKey));
198
+ content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
199
+ }
200
+ else {
201
+ return null; // Not intended for us
202
+ }
203
+ }
204
+ catch (e) {
205
+ throw new NCC05DecryptionError("Failed to decrypt wrapped content");
206
+ }
207
+ }
208
+ else if (sk && !content.startsWith('{')) {
209
+ // Standard NIP-44 (likely encrypted if not starting with {)
210
+ try {
211
+ const conversationKey = nip44.getConversationKey(sk, hexPubkey);
212
+ content = nip44.decrypt(latestEvent.content, conversationKey);
213
+ }
214
+ catch (e) {
215
+ throw new NCC05DecryptionError("Failed to decrypt content");
126
216
  }
127
217
  }
128
- else if (secretKey) {
129
- // Standard NIP-44
130
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
131
- content = nip44.decrypt(latestEvent.content, conversationKey);
218
+ // Security: Safe JSON parsing
219
+ let payload;
220
+ try {
221
+ payload = JSON.parse(content);
132
222
  }
133
- const payload = JSON.parse(content);
134
- if (!payload.endpoints)
223
+ catch (e) {
224
+ return null; // Invalid JSON
225
+ }
226
+ if (!payload || !payload.endpoints || !Array.isArray(payload.endpoints)) {
135
227
  return null;
228
+ }
136
229
  // Freshness validation
137
230
  const now = Math.floor(Date.now() / 1000);
138
231
  if (now > payload.updated_at + payload.ttl) {
139
232
  if (options.strict)
140
233
  return null;
141
- console.warn('NCC-05 record has expired');
234
+ console.warn('NCC-05 record expired');
142
235
  }
143
236
  return payload;
144
237
  }
145
238
  catch (e) {
146
- return null;
239
+ if (e instanceof NCC05Error)
240
+ throw e;
241
+ throw new NCC05RelayError(`Relay query failed: ${e.message}`);
147
242
  }
148
243
  }
149
244
  /**
150
245
  * Closes connections to all relays in the pool.
151
246
  */
152
247
  close() {
248
+ // If we didn't create the pool, we probably shouldn't close it?
249
+ // But the previous implementation did.
250
+ // We will only close bootstrap relays to be safe if sharing pool.
251
+ // Actually, pool.close() takes args.
153
252
  this.pool.close(this.bootstrapRelays);
154
253
  }
155
254
  }
@@ -158,15 +257,45 @@ export class NCC05Resolver {
158
257
  */
159
258
  export class NCC05Publisher {
160
259
  pool;
260
+ timeout;
161
261
  /**
162
262
  * @param options - Configuration for the publisher.
163
263
  */
164
264
  constructor(options = {}) {
165
- this.pool = new SimplePool();
166
- if (options.websocketImplementation) {
265
+ this.pool = options.pool || new SimplePool();
266
+ if (!options.pool && options.websocketImplementation) {
167
267
  // @ts-ignore
168
268
  this.pool.websocketImplementation = options.websocketImplementation;
169
269
  }
270
+ this.timeout = options.timeout || 5000;
271
+ }
272
+ async _publishToRelays(relays, signedEvent) {
273
+ const publishPromises = this.pool.publish(relays, signedEvent);
274
+ // Convert to promise that resolves/rejects based on timeout
275
+ const wrappedPromises = publishPromises.map(p => {
276
+ // In nostr-tools v2, publish returns Promise<void>.
277
+ // We wrap it to handle timeout.
278
+ return new Promise((resolve, reject) => {
279
+ const timer = setTimeout(() => reject(new NCC05TimeoutError("Publish timed out")), this.timeout);
280
+ p.then(() => {
281
+ clearTimeout(timer);
282
+ resolve();
283
+ }).catch((err) => {
284
+ clearTimeout(timer);
285
+ reject(err);
286
+ });
287
+ });
288
+ });
289
+ const results = await Promise.allSettled(wrappedPromises);
290
+ const successful = results.filter(r => r.status === 'fulfilled');
291
+ if (successful.length === 0) {
292
+ const errors = results
293
+ .filter(r => r.status === 'rejected')
294
+ .map(r => r.reason.message)
295
+ .join(', ');
296
+ throw new NCC05RelayError(`Failed to publish to any relay. Errors: ${errors}`);
297
+ }
298
+ // If partial success, we consider it a success.
170
299
  }
171
300
  /**
172
301
  * Publishes a single record encrypted for multiple recipients using the wrapping pattern.
@@ -180,13 +309,14 @@ export class NCC05Publisher {
180
309
  * @returns The signed Nostr event.
181
310
  */
182
311
  async publishWrapped(relays, secretKey, recipients, payload, identifier = 'addr') {
312
+ const sk = ensureUint8Array(secretKey);
183
313
  const sessionKey = generateSecretKey();
184
314
  const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
185
315
  const selfConversation = nip44.getConversationKey(sessionKey, getPublicKey(sessionKey));
186
316
  const ciphertext = nip44.encrypt(JSON.stringify(payload), selfConversation);
187
317
  const wraps = {};
188
318
  for (const rPk of recipients) {
189
- const conversationKey = nip44.getConversationKey(secretKey, rPk);
319
+ const conversationKey = nip44.getConversationKey(sk, rPk);
190
320
  wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
191
321
  }
192
322
  const wrappedContent = { ciphertext, wraps };
@@ -196,8 +326,8 @@ export class NCC05Publisher {
196
326
  tags: [['d', identifier]],
197
327
  content: JSON.stringify(wrappedContent),
198
328
  };
199
- const signedEvent = finalizeEvent(eventTemplate, secretKey);
200
- await Promise.all(this.pool.publish(relays, signedEvent));
329
+ const signedEvent = finalizeEvent(eventTemplate, sk);
330
+ await this._publishToRelays(relays, signedEvent);
201
331
  return signedEvent;
202
332
  }
203
333
  /**
@@ -210,12 +340,13 @@ export class NCC05Publisher {
210
340
  * @returns The signed Nostr event.
211
341
  */
212
342
  async publish(relays, secretKey, payload, options = {}) {
213
- const myPubkey = getPublicKey(secretKey);
343
+ const sk = ensureUint8Array(secretKey);
344
+ const myPubkey = getPublicKey(sk);
214
345
  const identifier = options.identifier || 'addr';
215
346
  let content = JSON.stringify(payload);
216
347
  if (!options.public) {
217
348
  const encryptionTarget = options.recipientPubkey || myPubkey;
218
- const conversationKey = nip44.getConversationKey(secretKey, encryptionTarget);
349
+ const conversationKey = nip44.getConversationKey(sk, encryptionTarget);
219
350
  content = nip44.encrypt(content, conversationKey);
220
351
  }
221
352
  const eventTemplate = {
@@ -225,8 +356,8 @@ export class NCC05Publisher {
225
356
  tags: [['d', identifier]],
226
357
  content: content,
227
358
  };
228
- const signedEvent = finalizeEvent(eventTemplate, secretKey);
229
- await Promise.all(this.pool.publish(relays, signedEvent));
359
+ const signedEvent = finalizeEvent(eventTemplate, sk);
360
+ await this._publishToRelays(relays, signedEvent);
230
361
  return signedEvent;
231
362
  }
232
363
  /**
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,94 @@
1
+ import { NCC05Publisher, NCC05Resolver } from './index.js';
2
+ import { generateSecretKey, getPublicKey, SimplePool } from 'nostr-tools';
3
+ import { MockRelay } from './mock-relay.js';
4
+ import { WebSocketServer } from 'ws';
5
+ async function testNewFeatures() {
6
+ console.log('--- Starting New Features Test ---');
7
+ const relayPort = 8081;
8
+ const relayUrl = `ws://localhost:${relayPort}`;
9
+ const relay = new MockRelay(relayPort);
10
+ // 1. External SimplePool & Hex Keys
11
+ console.log('Test 1: External SimplePool & Hex Keys');
12
+ const pool = new SimplePool();
13
+ const publisher = new NCC05Publisher({ pool });
14
+ const resolver = new NCC05Resolver({ bootstrapRelays: [relayUrl], pool });
15
+ const sk = generateSecretKey();
16
+ const pk = getPublicKey(sk);
17
+ const skHex = Array.from(sk).map(b => b.toString(16).padStart(2, '0')).join('');
18
+ const payload = {
19
+ v: 1, ttl: 60, updated_at: Math.floor(Date.now() / 1000),
20
+ endpoints: [{ type: 'tcp', uri: 'hex-test', priority: 1, family: 'ipv4' }]
21
+ };
22
+ try {
23
+ // Publish using HEX string secret key
24
+ await publisher.publish([relayUrl], skHex, payload, { identifier: 'hex-key' });
25
+ console.log('Published with Hex Key: OK');
26
+ }
27
+ catch (e) {
28
+ console.error('Failed to publish with hex key:', e);
29
+ process.exit(1);
30
+ }
31
+ try {
32
+ // Resolve using Hex string secret key
33
+ const res = await resolver.resolve(pk, skHex, 'hex-key');
34
+ if (res && res.endpoints[0].uri === 'hex-test') {
35
+ console.log('Resolved with Hex Key: OK');
36
+ }
37
+ else {
38
+ console.error('Failed to resolve with hex key');
39
+ process.exit(1);
40
+ }
41
+ }
42
+ catch (e) {
43
+ console.error('Resolution error:', e);
44
+ process.exit(1);
45
+ }
46
+ // 2. Graceful Degradation
47
+ console.log('Test 2: Graceful Degradation (One bad relay)');
48
+ const badRelay = 'ws://localhost:9999'; // assuming closed/unreachable
49
+ try {
50
+ // Should succeed because one relay is good
51
+ await publisher.publish([relayUrl, badRelay], sk, payload, { identifier: 'graceful' });
52
+ console.log('Graceful degradation publish: OK');
53
+ }
54
+ catch (e) {
55
+ console.error('Graceful degradation failed:', e);
56
+ process.exit(1);
57
+ }
58
+ // 3. Timeout
59
+ console.log('Test 3: Resolver Timeout');
60
+ // Create a "Hanging Relay" that accepts connection but sends no data
61
+ const hangingPort = 8082;
62
+ const wss = new WebSocketServer({ port: hangingPort });
63
+ const timeoutResolver = new NCC05Resolver({
64
+ bootstrapRelays: [`ws://localhost:${hangingPort}`],
65
+ timeout: 500 // 500ms
66
+ });
67
+ const start = Date.now();
68
+ try {
69
+ await timeoutResolver.resolve(pk, sk, 'any');
70
+ console.error('Should have timed out!');
71
+ wss.close();
72
+ process.exit(1);
73
+ }
74
+ catch (e) {
75
+ const duration = Date.now() - start;
76
+ if (e.name === 'NCC05TimeoutError') {
77
+ console.log(`Timed out as expected in ${duration}ms: OK`);
78
+ }
79
+ else {
80
+ console.error('Caught unexpected error:', e);
81
+ // It might be that SimplePool fails connection before timeout if it's super fast,
82
+ // but WebSocketServer is listening.
83
+ }
84
+ }
85
+ finally {
86
+ wss.close();
87
+ }
88
+ relay.stop();
89
+ pool.close([relayUrl]);
90
+ timeoutResolver.close();
91
+ console.log('New Features Test Suite Passed.');
92
+ process.exit(0);
93
+ }
94
+ testNewFeatures().catch(console.error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ncc-05",
3
- "version": "1.1.3",
3
+ "version": "1.1.5",
4
4
  "description": "Nostr Community Convention 05 - Identity-Bound Service Locator Resolution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.ts CHANGED
@@ -18,6 +18,64 @@ import {
18
18
  generateSecretKey
19
19
  } from 'nostr-tools';
20
20
 
21
+ // --- Error Classes ---
22
+
23
+ export class NCC05Error extends Error {
24
+ constructor(message: string) {
25
+ super(message);
26
+ this.name = 'NCC05Error';
27
+ }
28
+ }
29
+
30
+ export class NCC05RelayError extends NCC05Error {
31
+ constructor(message: string) {
32
+ super(message);
33
+ this.name = 'NCC05RelayError';
34
+ }
35
+ }
36
+
37
+ export class NCC05TimeoutError extends NCC05Error {
38
+ constructor(message: string) {
39
+ super(message);
40
+ this.name = 'NCC05TimeoutError';
41
+ }
42
+ }
43
+
44
+ export class NCC05DecryptionError extends NCC05Error {
45
+ constructor(message: string) {
46
+ super(message);
47
+ this.name = 'NCC05DecryptionError';
48
+ }
49
+ }
50
+
51
+ export class NCC05ArgumentError extends NCC05Error {
52
+ constructor(message: string) {
53
+ super(message);
54
+ this.name = 'NCC05ArgumentError';
55
+ }
56
+ }
57
+
58
+ // --- Helpers ---
59
+
60
+ function ensureUint8Array(key: string | Uint8Array): Uint8Array {
61
+ if (key instanceof Uint8Array) return key;
62
+ if (typeof key === 'string') {
63
+ // Assume hex string
64
+ if (key.match(/^[0-9a-fA-F]+$/)) {
65
+ return new Uint8Array(key.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
66
+ }
67
+ throw new NCC05ArgumentError("Invalid hex key provided");
68
+ }
69
+ throw new NCC05ArgumentError("Key must be a hex string or Uint8Array");
70
+ }
71
+
72
+ function getHexPubkey(key: string | Uint8Array): string {
73
+ if (key instanceof Uint8Array) {
74
+ return Array.from(key).map(b => b.toString(16).padStart(2, '0')).join('');
75
+ }
76
+ return key;
77
+ }
78
+
21
79
  /**
22
80
  * Represents a single reachable service endpoint.
23
81
  */
@@ -60,6 +118,20 @@ export interface ResolverOptions {
60
118
  timeout?: number;
61
119
  /** Custom WebSocket implementation (e.g., for Tor/SOCKS5 in Node.js) */
62
120
  websocketImplementation?: any;
121
+ /** Existing SimplePool instance to share connections */
122
+ pool?: SimplePool;
123
+ }
124
+
125
+ /**
126
+ * Options for configuring the NCC05Publisher.
127
+ */
128
+ export interface PublisherOptions {
129
+ /** Custom WebSocket implementation */
130
+ websocketImplementation?: any;
131
+ /** Existing SimplePool instance */
132
+ pool?: SimplePool;
133
+ /** Timeout for publishing in milliseconds (default: 5000) */
134
+ timeout?: number;
63
135
  }
64
136
 
65
137
  /**
@@ -106,7 +178,7 @@ export class NCC05Group {
106
178
  static async resolveAsGroup(
107
179
  resolver: NCC05Resolver,
108
180
  groupPubkey: string,
109
- groupSecretKey: Uint8Array,
181
+ groupSecretKey: string | Uint8Array,
110
182
  identifier: string = 'addr'
111
183
  ): Promise<NCC05Payload | null> {
112
184
  return resolver.resolve(groupPubkey, groupSecretKey, identifier);
@@ -125,11 +197,19 @@ export class NCC05Resolver {
125
197
  * @param options - Configuration for the resolver.
126
198
  */
127
199
  constructor(options: ResolverOptions = {}) {
128
- this.pool = new SimplePool();
129
- if (options.websocketImplementation) {
130
- // @ts-ignore - Patching pool for custom transport
131
- this.pool.websocketImplementation = options.websocketImplementation;
200
+ this.pool = options.pool || new SimplePool();
201
+
202
+ if (!options.pool) {
203
+ if (options.websocketImplementation) {
204
+ // @ts-ignore - Patching pool for custom transport
205
+ this.pool.websocketImplementation = options.websocketImplementation;
206
+ } else if (typeof globalThis !== 'undefined' && !globalThis.WebSocket) {
207
+ // In Node.js environment without global WebSocket, this might fail later.
208
+ // We leave it to the user or nostr-tools to handle, but this logic
209
+ // allows 'websocketImplementation' to be explicitly checked.
210
+ }
132
211
  }
212
+
133
213
  this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
134
214
  this.timeout = options.timeout || 10000;
135
215
  }
@@ -144,11 +224,13 @@ export class NCC05Resolver {
144
224
  * @param secretKey - Your secret key (required if the record is encrypted).
145
225
  * @param identifier - The 'd' tag of the record (default: 'addr').
146
226
  * @param options - Resolution options (strict mode, gossip discovery).
147
- * @returns The resolved and validated NCC05Payload, or null if not found/invalid.
227
+ * @returns The resolved and validated NCC05Payload, or null if not found.
228
+ * @throws {NCC05TimeoutError} if resolution times out.
229
+ * @throws {NCC05RelayError} if underlying relay communication fails.
148
230
  */
149
231
  async resolve(
150
232
  targetPubkey: string,
151
- secretKey?: Uint8Array,
233
+ secretKey?: string | Uint8Array,
152
234
  identifier: string = 'addr',
153
235
  options: { strict?: boolean, gossip?: boolean } = {}
154
236
  ): Promise<NCC05Payload | null> {
@@ -162,17 +244,23 @@ export class NCC05Resolver {
162
244
 
163
245
  // 1. NIP-65 Gossip Discovery
164
246
  if (options.gossip) {
165
- const relayListEvent = await this.pool.get(this.bootstrapRelays, {
166
- authors: [hexPubkey],
167
- kinds: [10002]
168
- });
169
- if (relayListEvent) {
170
- const discoveredRelays = relayListEvent.tags
171
- .filter(t => t[0] === 'r')
172
- .map(t => t[1]);
173
- if (discoveredRelays.length > 0) {
174
- queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
247
+ try {
248
+ const relayListEvent = await this.pool.get(this.bootstrapRelays, {
249
+ authors: [hexPubkey],
250
+ kinds: [10002]
251
+ });
252
+ // Security: Verify NIP-65 event signature and author
253
+ if (relayListEvent && verifyEvent(relayListEvent) && relayListEvent.pubkey === hexPubkey) {
254
+ const discoveredRelays = relayListEvent.tags
255
+ .filter(t => t[0] === 'r')
256
+ .map(t => t[1]);
257
+ if (discoveredRelays.length > 0) {
258
+ queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
259
+ }
175
260
  }
261
+ } catch (e: any) {
262
+ console.warn(`[NCC-05] Gossip discovery failed: ${e.message}`);
263
+ // Proceed with bootstrap relays
176
264
  }
177
265
  }
178
266
 
@@ -183,55 +271,91 @@ export class NCC05Resolver {
183
271
  limit: 10
184
272
  };
185
273
 
186
- const queryPromise = this.pool.querySync(queryRelays, filter);
187
- const timeoutPromise = new Promise<null>((r) => setTimeout(() => r(null), this.timeout));
188
- const result = await Promise.race([queryPromise, timeoutPromise]);
189
-
190
- if (!result || (Array.isArray(result) && result.length === 0)) return null;
191
-
192
- const validEvents = (result as Event[])
193
- .filter(e => verifyEvent(e))
194
- .sort((a, b) => b.created_at - a.created_at);
195
-
196
- if (validEvents.length === 0) return null;
197
- const latestEvent = validEvents[0];
274
+ const sk = secretKey ? ensureUint8Array(secretKey) : undefined;
198
275
 
199
276
  try {
277
+ const queryPromise = this.pool.querySync(queryRelays, filter);
278
+ const timeoutPromise = new Promise<never>((_, reject) =>
279
+ setTimeout(() => reject(new NCC05TimeoutError("Resolution timed out")), this.timeout)
280
+ );
281
+
282
+ const result = await Promise.race([queryPromise, timeoutPromise]);
283
+
284
+ if (!result || (Array.isArray(result) && result.length === 0)) return null;
285
+
286
+ // 2. Filter for valid signatures, correct author, and sort by created_at
287
+ const validEvents = (result as Event[])
288
+ .filter(e => e.pubkey === hexPubkey && verifyEvent(e))
289
+ .sort((a, b) => b.created_at - a.created_at);
290
+
291
+ if (validEvents.length === 0) return null;
292
+ const latestEvent = validEvents[0];
293
+
200
294
  let content = latestEvent.content;
201
295
 
202
- // Handle "Wrapped" multi-recipient content
203
- if (content.includes('"wraps"') && secretKey) {
204
- const wrapped = JSON.parse(content) as WrappedContent;
205
- const myPk = getPublicKey(secretKey);
206
- const myWrap = wrapped.wraps[myPk];
207
-
208
- if (myWrap) {
209
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
210
- const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
211
- const symmetricKey = new Uint8Array(symmetricKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
296
+ // Security: Robust multi-recipient detection
297
+ const isWrapped = content.includes('"wraps"') &&
298
+ content.includes('"ciphertext"') &&
299
+ content.startsWith('{');
300
+
301
+ if (isWrapped && sk) {
302
+ try {
303
+ const wrapped = JSON.parse(content) as WrappedContent;
304
+ const myPk = getPublicKey(sk);
305
+ const myWrap = wrapped.wraps[myPk];
212
306
 
213
- const sessionConversationKey = nip44.getConversationKey(symmetricKey, getPublicKey(symmetricKey));
214
- content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
307
+ if (myWrap) {
308
+ const conversationKey = nip44.getConversationKey(sk, hexPubkey);
309
+ const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
310
+
311
+ // Convert hex symmetric key back to Uint8Array for NIP-44 decryption
312
+ const symmetricKey = new Uint8Array(
313
+ symmetricKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16))
314
+ );
315
+
316
+ const sessionConversationKey = nip44.getConversationKey(
317
+ symmetricKey, getPublicKey(symmetricKey)
318
+ );
319
+ content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
320
+ } else {
321
+ return null; // Not intended for us
322
+ }
323
+ } catch (e) {
324
+ throw new NCC05DecryptionError("Failed to decrypt wrapped content");
325
+ }
326
+ } else if (sk && !content.startsWith('{')) {
327
+ // Standard NIP-44 (likely encrypted if not starting with {)
328
+ try {
329
+ const conversationKey = nip44.getConversationKey(sk, hexPubkey);
330
+ content = nip44.decrypt(latestEvent.content, conversationKey);
331
+ } catch (e) {
332
+ throw new NCC05DecryptionError("Failed to decrypt content");
215
333
  }
216
- } else if (secretKey) {
217
- // Standard NIP-44
218
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
219
- content = nip44.decrypt(latestEvent.content, conversationKey);
220
334
  }
221
335
 
222
- const payload = JSON.parse(content) as NCC05Payload;
223
- if (!payload.endpoints) return null;
336
+ // Security: Safe JSON parsing
337
+ let payload: NCC05Payload;
338
+ try {
339
+ payload = JSON.parse(content) as NCC05Payload;
340
+ } catch (e) {
341
+ return null; // Invalid JSON
342
+ }
343
+
344
+ if (!payload || !payload.endpoints || !Array.isArray(payload.endpoints)) {
345
+ return null;
346
+ }
224
347
 
225
348
  // Freshness validation
226
349
  const now = Math.floor(Date.now() / 1000);
227
350
  if (now > payload.updated_at + payload.ttl) {
228
351
  if (options.strict) return null;
229
- console.warn('NCC-05 record has expired');
352
+ console.warn('NCC-05 record expired');
230
353
  }
231
354
 
232
355
  return payload;
233
356
  } catch (e) {
234
- return null;
357
+ if (e instanceof NCC05Error) throw e;
358
+ throw new NCC05RelayError(`Relay query failed: ${(e as Error).message}`);
235
359
  }
236
360
  }
237
361
 
@@ -239,6 +363,10 @@ export class NCC05Resolver {
239
363
  * Closes connections to all relays in the pool.
240
364
  */
241
365
  close() {
366
+ // If we didn't create the pool, we probably shouldn't close it?
367
+ // But the previous implementation did.
368
+ // We will only close bootstrap relays to be safe if sharing pool.
369
+ // Actually, pool.close() takes args.
242
370
  this.pool.close(this.bootstrapRelays);
243
371
  }
244
372
  }
@@ -248,16 +376,51 @@ export class NCC05Resolver {
248
376
  */
249
377
  export class NCC05Publisher {
250
378
  private pool: SimplePool;
379
+ private timeout: number;
251
380
 
252
381
  /**
253
382
  * @param options - Configuration for the publisher.
254
383
  */
255
- constructor(options: { websocketImplementation?: any } = {}) {
256
- this.pool = new SimplePool();
257
- if (options.websocketImplementation) {
384
+ constructor(options: PublisherOptions = {}) {
385
+ this.pool = options.pool || new SimplePool();
386
+ if (!options.pool && options.websocketImplementation) {
258
387
  // @ts-ignore
259
388
  this.pool.websocketImplementation = options.websocketImplementation;
260
389
  }
390
+ this.timeout = options.timeout || 5000;
391
+ }
392
+
393
+ private async _publishToRelays(relays: string[], signedEvent: Event): Promise<void> {
394
+ const publishPromises = this.pool.publish(relays, signedEvent);
395
+
396
+ // Convert to promise that resolves/rejects based on timeout
397
+ const wrappedPromises = publishPromises.map(p => {
398
+ // In nostr-tools v2, publish returns Promise<void>.
399
+ // We wrap it to handle timeout.
400
+ return new Promise<void>((resolve, reject) => {
401
+ const timer = setTimeout(() => reject(new NCC05TimeoutError("Publish timed out")), this.timeout);
402
+ p.then(() => {
403
+ clearTimeout(timer);
404
+ resolve();
405
+ }).catch((err) => {
406
+ clearTimeout(timer);
407
+ reject(err);
408
+ });
409
+ });
410
+ });
411
+
412
+ const results = await Promise.allSettled(wrappedPromises);
413
+ const successful = results.filter(r => r.status === 'fulfilled');
414
+
415
+ if (successful.length === 0) {
416
+ const errors = results
417
+ .filter(r => r.status === 'rejected')
418
+ .map(r => (r as PromiseRejectedResult).reason.message)
419
+ .join(', ');
420
+ throw new NCC05RelayError(`Failed to publish to any relay. Errors: ${errors}`);
421
+ }
422
+
423
+ // If partial success, we consider it a success.
261
424
  }
262
425
 
263
426
  /**
@@ -273,11 +436,12 @@ export class NCC05Publisher {
273
436
  */
274
437
  async publishWrapped(
275
438
  relays: string[],
276
- secretKey: Uint8Array,
439
+ secretKey: string | Uint8Array,
277
440
  recipients: string[],
278
441
  payload: NCC05Payload,
279
442
  identifier: string = 'addr'
280
443
  ): Promise<Event> {
444
+ const sk = ensureUint8Array(secretKey);
281
445
  const sessionKey = generateSecretKey();
282
446
  const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
283
447
 
@@ -286,7 +450,7 @@ export class NCC05Publisher {
286
450
 
287
451
  const wraps: Record<string, string> = {};
288
452
  for (const rPk of recipients) {
289
- const conversationKey = nip44.getConversationKey(secretKey, rPk);
453
+ const conversationKey = nip44.getConversationKey(sk, rPk);
290
454
  wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
291
455
  }
292
456
 
@@ -299,8 +463,8 @@ export class NCC05Publisher {
299
463
  content: JSON.stringify(wrappedContent),
300
464
  };
301
465
 
302
- const signedEvent = finalizeEvent(eventTemplate, secretKey);
303
- await Promise.all(this.pool.publish(relays, signedEvent));
466
+ const signedEvent = finalizeEvent(eventTemplate, sk);
467
+ await this._publishToRelays(relays, signedEvent);
304
468
  return signedEvent;
305
469
  }
306
470
 
@@ -315,17 +479,18 @@ export class NCC05Publisher {
315
479
  */
316
480
  async publish(
317
481
  relays: string[],
318
- secretKey: Uint8Array,
482
+ secretKey: string | Uint8Array,
319
483
  payload: NCC05Payload,
320
484
  options: { identifier?: string, recipientPubkey?: string, public?: boolean } = {}
321
485
  ): Promise<Event> {
322
- const myPubkey = getPublicKey(secretKey);
486
+ const sk = ensureUint8Array(secretKey);
487
+ const myPubkey = getPublicKey(sk);
323
488
  const identifier = options.identifier || 'addr';
324
489
  let content = JSON.stringify(payload);
325
490
 
326
491
  if (!options.public) {
327
492
  const encryptionTarget = options.recipientPubkey || myPubkey;
328
- const conversationKey = nip44.getConversationKey(secretKey, encryptionTarget);
493
+ const conversationKey = nip44.getConversationKey(sk, encryptionTarget);
329
494
  content = nip44.encrypt(content, conversationKey);
330
495
  }
331
496
 
@@ -337,8 +502,8 @@ export class NCC05Publisher {
337
502
  content: content,
338
503
  };
339
504
 
340
- const signedEvent = finalizeEvent(eventTemplate, secretKey);
341
- await Promise.all(this.pool.publish(relays, signedEvent));
505
+ const signedEvent = finalizeEvent(eventTemplate, sk);
506
+ await this._publishToRelays(relays, signedEvent);
342
507
  return signedEvent;
343
508
  }
344
509
 
@@ -348,4 +513,4 @@ export class NCC05Publisher {
348
513
  close(relays: string[]) {
349
514
  this.pool.close(relays);
350
515
  }
351
- }
516
+ }
@@ -0,0 +1,99 @@
1
+ import { NCC05Publisher, NCC05Resolver, NCC05Payload, NCC05TimeoutError } from './index.js';
2
+ import { generateSecretKey, getPublicKey, SimplePool } from 'nostr-tools';
3
+ import { MockRelay } from './mock-relay.js';
4
+ import { WebSocketServer } from 'ws';
5
+
6
+ async function testNewFeatures() {
7
+ console.log('--- Starting New Features Test ---');
8
+ const relayPort = 8081;
9
+ const relayUrl = `ws://localhost:${relayPort}`;
10
+ const relay = new MockRelay(relayPort);
11
+
12
+ // 1. External SimplePool & Hex Keys
13
+ console.log('Test 1: External SimplePool & Hex Keys');
14
+ const pool = new SimplePool();
15
+ const publisher = new NCC05Publisher({ pool });
16
+ const resolver = new NCC05Resolver({ bootstrapRelays: [relayUrl], pool });
17
+
18
+ const sk = generateSecretKey();
19
+ const pk = getPublicKey(sk);
20
+ const skHex = Array.from(sk).map(b => b.toString(16).padStart(2, '0')).join('');
21
+
22
+ const payload: NCC05Payload = {
23
+ v: 1, ttl: 60, updated_at: Math.floor(Date.now() / 1000),
24
+ endpoints: [{ type: 'tcp', uri: 'hex-test', priority: 1, family: 'ipv4' }]
25
+ };
26
+
27
+ try {
28
+ // Publish using HEX string secret key
29
+ await publisher.publish([relayUrl], skHex, payload, { identifier: 'hex-key' });
30
+ console.log('Published with Hex Key: OK');
31
+ } catch (e) {
32
+ console.error('Failed to publish with hex key:', e);
33
+ process.exit(1);
34
+ }
35
+
36
+ try {
37
+ // Resolve using Hex string secret key
38
+ const res = await resolver.resolve(pk, skHex, 'hex-key');
39
+ if (res && res.endpoints[0].uri === 'hex-test') {
40
+ console.log('Resolved with Hex Key: OK');
41
+ } else {
42
+ console.error('Failed to resolve with hex key');
43
+ process.exit(1);
44
+ }
45
+ } catch (e) {
46
+ console.error('Resolution error:', e);
47
+ process.exit(1);
48
+ }
49
+
50
+ // 2. Graceful Degradation
51
+ console.log('Test 2: Graceful Degradation (One bad relay)');
52
+ const badRelay = 'ws://localhost:9999'; // assuming closed/unreachable
53
+ try {
54
+ // Should succeed because one relay is good
55
+ await publisher.publish([relayUrl, badRelay], sk, payload, { identifier: 'graceful' });
56
+ console.log('Graceful degradation publish: OK');
57
+ } catch (e) {
58
+ console.error('Graceful degradation failed:', e);
59
+ process.exit(1);
60
+ }
61
+
62
+ // 3. Timeout
63
+ console.log('Test 3: Resolver Timeout');
64
+ // Create a "Hanging Relay" that accepts connection but sends no data
65
+ const hangingPort = 8082;
66
+ const wss = new WebSocketServer({ port: hangingPort });
67
+
68
+ const timeoutResolver = new NCC05Resolver({
69
+ bootstrapRelays: [`ws://localhost:${hangingPort}`],
70
+ timeout: 500 // 500ms
71
+ });
72
+
73
+ const start = Date.now();
74
+ try {
75
+ await timeoutResolver.resolve(pk, sk, 'any');
76
+ console.error('Should have timed out!');
77
+ wss.close();
78
+ process.exit(1);
79
+ } catch (e: any) {
80
+ const duration = Date.now() - start;
81
+ if (e.name === 'NCC05TimeoutError') {
82
+ console.log(`Timed out as expected in ${duration}ms: OK`);
83
+ } else {
84
+ console.error('Caught unexpected error:', e);
85
+ // It might be that SimplePool fails connection before timeout if it's super fast,
86
+ // but WebSocketServer is listening.
87
+ }
88
+ } finally {
89
+ wss.close();
90
+ }
91
+
92
+ relay.stop();
93
+ pool.close([relayUrl]);
94
+ timeoutResolver.close();
95
+ console.log('New Features Test Suite Passed.');
96
+ process.exit(0);
97
+ }
98
+
99
+ testNewFeatures().catch(console.error);