ncc-05 1.1.4 → 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,19 +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
- // Security: Verify NIP-65 event signature and author
88
- if (relayListEvent && verifyEvent(relayListEvent) && relayListEvent.pubkey === hexPubkey) {
89
- const discoveredRelays = relayListEvent.tags
90
- .filter(t => t[0] === 'r')
91
- .map(t => t[1]);
92
- if (discoveredRelays.length > 0) {
93
- 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
+ }
94
155
  }
95
156
  }
157
+ catch (e) {
158
+ console.warn(`[NCC-05] Gossip discovery failed: ${e.message}`);
159
+ // Proceed with bootstrap relays
160
+ }
96
161
  }
97
162
  const filter = {
98
163
  authors: [hexPubkey],
@@ -100,47 +165,64 @@ export class NCC05Resolver {
100
165
  '#d': [identifier],
101
166
  limit: 10
102
167
  };
103
- const queryPromise = this.pool.querySync(queryRelays, filter);
104
- const timeoutPromise = new Promise((r) => setTimeout(() => r(null), this.timeout));
105
- const result = await Promise.race([queryPromise, timeoutPromise]);
106
- if (!result || (Array.isArray(result) && result.length === 0))
107
- return null;
108
- // 2. Filter for valid signatures, correct author, and sort by created_at
109
- const validEvents = result
110
- .filter(e => e.pubkey === hexPubkey && verifyEvent(e))
111
- .sort((a, b) => b.created_at - a.created_at);
112
- if (validEvents.length === 0)
113
- return null;
114
- const latestEvent = validEvents[0];
168
+ const sk = secretKey ? ensureUint8Array(secretKey) : undefined;
115
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];
116
182
  let content = latestEvent.content;
117
183
  // Security: Robust multi-recipient detection
118
184
  const isWrapped = content.includes('"wraps"') &&
119
185
  content.includes('"ciphertext"') &&
120
186
  content.startsWith('{');
121
- if (isWrapped && secretKey) {
122
- const wrapped = JSON.parse(content);
123
- const myPk = getPublicKey(secretKey);
124
- const myWrap = wrapped.wraps[myPk];
125
- if (myWrap) {
126
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
127
- const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
128
- // Convert hex symmetric key back to Uint8Array for NIP-44 decryption
129
- const symmetricKey = new Uint8Array(symmetricKeyHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
130
- const sessionConversationKey = nip44.getConversationKey(symmetricKey, getPublicKey(symmetricKey));
131
- content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
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
+ }
132
203
  }
133
- else {
134
- return null; // Not intended for us
204
+ catch (e) {
205
+ throw new NCC05DecryptionError("Failed to decrypt wrapped content");
135
206
  }
136
207
  }
137
- else if (secretKey && !content.startsWith('{')) {
208
+ else if (sk && !content.startsWith('{')) {
138
209
  // Standard NIP-44 (likely encrypted if not starting with {)
139
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
140
- content = nip44.decrypt(latestEvent.content, conversationKey);
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");
216
+ }
141
217
  }
142
218
  // Security: Safe JSON parsing
143
- const payload = JSON.parse(content);
219
+ let payload;
220
+ try {
221
+ payload = JSON.parse(content);
222
+ }
223
+ catch (e) {
224
+ return null; // Invalid JSON
225
+ }
144
226
  if (!payload || !payload.endpoints || !Array.isArray(payload.endpoints)) {
145
227
  return null;
146
228
  }
@@ -154,13 +236,19 @@ export class NCC05Resolver {
154
236
  return payload;
155
237
  }
156
238
  catch (e) {
157
- return null; // Decryption or parsing failed
239
+ if (e instanceof NCC05Error)
240
+ throw e;
241
+ throw new NCC05RelayError(`Relay query failed: ${e.message}`);
158
242
  }
159
243
  }
160
244
  /**
161
245
  * Closes connections to all relays in the pool.
162
246
  */
163
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.
164
252
  this.pool.close(this.bootstrapRelays);
165
253
  }
166
254
  }
@@ -169,15 +257,45 @@ export class NCC05Resolver {
169
257
  */
170
258
  export class NCC05Publisher {
171
259
  pool;
260
+ timeout;
172
261
  /**
173
262
  * @param options - Configuration for the publisher.
174
263
  */
175
264
  constructor(options = {}) {
176
- this.pool = new SimplePool();
177
- if (options.websocketImplementation) {
265
+ this.pool = options.pool || new SimplePool();
266
+ if (!options.pool && options.websocketImplementation) {
178
267
  // @ts-ignore
179
268
  this.pool.websocketImplementation = options.websocketImplementation;
180
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.
181
299
  }
182
300
  /**
183
301
  * Publishes a single record encrypted for multiple recipients using the wrapping pattern.
@@ -191,13 +309,14 @@ export class NCC05Publisher {
191
309
  * @returns The signed Nostr event.
192
310
  */
193
311
  async publishWrapped(relays, secretKey, recipients, payload, identifier = 'addr') {
312
+ const sk = ensureUint8Array(secretKey);
194
313
  const sessionKey = generateSecretKey();
195
314
  const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
196
315
  const selfConversation = nip44.getConversationKey(sessionKey, getPublicKey(sessionKey));
197
316
  const ciphertext = nip44.encrypt(JSON.stringify(payload), selfConversation);
198
317
  const wraps = {};
199
318
  for (const rPk of recipients) {
200
- const conversationKey = nip44.getConversationKey(secretKey, rPk);
319
+ const conversationKey = nip44.getConversationKey(sk, rPk);
201
320
  wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
202
321
  }
203
322
  const wrappedContent = { ciphertext, wraps };
@@ -207,8 +326,8 @@ export class NCC05Publisher {
207
326
  tags: [['d', identifier]],
208
327
  content: JSON.stringify(wrappedContent),
209
328
  };
210
- const signedEvent = finalizeEvent(eventTemplate, secretKey);
211
- await Promise.all(this.pool.publish(relays, signedEvent));
329
+ const signedEvent = finalizeEvent(eventTemplate, sk);
330
+ await this._publishToRelays(relays, signedEvent);
212
331
  return signedEvent;
213
332
  }
214
333
  /**
@@ -221,12 +340,13 @@ export class NCC05Publisher {
221
340
  * @returns The signed Nostr event.
222
341
  */
223
342
  async publish(relays, secretKey, payload, options = {}) {
224
- const myPubkey = getPublicKey(secretKey);
343
+ const sk = ensureUint8Array(secretKey);
344
+ const myPubkey = getPublicKey(sk);
225
345
  const identifier = options.identifier || 'addr';
226
346
  let content = JSON.stringify(payload);
227
347
  if (!options.public) {
228
348
  const encryptionTarget = options.recipientPubkey || myPubkey;
229
- const conversationKey = nip44.getConversationKey(secretKey, encryptionTarget);
349
+ const conversationKey = nip44.getConversationKey(sk, encryptionTarget);
230
350
  content = nip44.encrypt(content, conversationKey);
231
351
  }
232
352
  const eventTemplate = {
@@ -236,8 +356,8 @@ export class NCC05Publisher {
236
356
  tags: [['d', identifier]],
237
357
  content: content,
238
358
  };
239
- const signedEvent = finalizeEvent(eventTemplate, secretKey);
240
- await Promise.all(this.pool.publish(relays, signedEvent));
359
+ const signedEvent = finalizeEvent(eventTemplate, sk);
360
+ await this._publishToRelays(relays, signedEvent);
241
361
  return signedEvent;
242
362
  }
243
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.4",
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,18 +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
- // Security: Verify NIP-65 event signature and author
170
- if (relayListEvent && verifyEvent(relayListEvent) && relayListEvent.pubkey === hexPubkey) {
171
- const discoveredRelays = relayListEvent.tags
172
- .filter(t => t[0] === 'r')
173
- .map(t => t[1]);
174
- if (discoveredRelays.length > 0) {
175
- 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
+ }
176
260
  }
261
+ } catch (e: any) {
262
+ console.warn(`[NCC-05] Gossip discovery failed: ${e.message}`);
263
+ // Proceed with bootstrap relays
177
264
  }
178
265
  }
179
266
 
@@ -184,21 +271,26 @@ export class NCC05Resolver {
184
271
  limit: 10
185
272
  };
186
273
 
187
- const queryPromise = this.pool.querySync(queryRelays, filter);
188
- const timeoutPromise = new Promise<null>((r) => setTimeout(() => r(null), this.timeout));
189
- const result = await Promise.race([queryPromise, timeoutPromise]);
190
-
191
- if (!result || (Array.isArray(result) && result.length === 0)) return null;
192
-
193
- // 2. Filter for valid signatures, correct author, and sort by created_at
194
- const validEvents = (result as Event[])
195
- .filter(e => e.pubkey === hexPubkey && verifyEvent(e))
196
- .sort((a, b) => b.created_at - a.created_at);
197
-
198
- if (validEvents.length === 0) return null;
199
- const latestEvent = validEvents[0];
274
+ const sk = secretKey ? ensureUint8Array(secretKey) : undefined;
200
275
 
201
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
+
202
294
  let content = latestEvent.content;
203
295
 
204
296
  // Security: Robust multi-recipient detection
@@ -206,35 +298,49 @@ export class NCC05Resolver {
206
298
  content.includes('"ciphertext"') &&
207
299
  content.startsWith('{');
208
300
 
209
- if (isWrapped && secretKey) {
210
- const wrapped = JSON.parse(content) as WrappedContent;
211
- const myPk = getPublicKey(secretKey);
212
- const myWrap = wrapped.wraps[myPk];
213
-
214
- if (myWrap) {
215
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
216
- const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
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];
217
306
 
218
- // Convert hex symmetric key back to Uint8Array for NIP-44 decryption
219
- const symmetricKey = new Uint8Array(
220
- symmetricKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16))
221
- );
222
-
223
- const sessionConversationKey = nip44.getConversationKey(
224
- symmetricKey, getPublicKey(symmetricKey)
225
- );
226
- content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
227
- } else {
228
- return null; // Not intended for us
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");
229
325
  }
230
- } else if (secretKey && !content.startsWith('{')) {
326
+ } else if (sk && !content.startsWith('{')) {
231
327
  // Standard NIP-44 (likely encrypted if not starting with {)
232
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
233
- content = nip44.decrypt(latestEvent.content, conversationKey);
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");
333
+ }
234
334
  }
235
335
 
236
336
  // Security: Safe JSON parsing
237
- const payload = JSON.parse(content) as NCC05Payload;
337
+ let payload: NCC05Payload;
338
+ try {
339
+ payload = JSON.parse(content) as NCC05Payload;
340
+ } catch (e) {
341
+ return null; // Invalid JSON
342
+ }
343
+
238
344
  if (!payload || !payload.endpoints || !Array.isArray(payload.endpoints)) {
239
345
  return null;
240
346
  }
@@ -248,7 +354,8 @@ export class NCC05Resolver {
248
354
 
249
355
  return payload;
250
356
  } catch (e) {
251
- return null; // Decryption or parsing failed
357
+ if (e instanceof NCC05Error) throw e;
358
+ throw new NCC05RelayError(`Relay query failed: ${(e as Error).message}`);
252
359
  }
253
360
  }
254
361
 
@@ -256,6 +363,10 @@ export class NCC05Resolver {
256
363
  * Closes connections to all relays in the pool.
257
364
  */
258
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.
259
370
  this.pool.close(this.bootstrapRelays);
260
371
  }
261
372
  }
@@ -265,16 +376,51 @@ export class NCC05Resolver {
265
376
  */
266
377
  export class NCC05Publisher {
267
378
  private pool: SimplePool;
379
+ private timeout: number;
268
380
 
269
381
  /**
270
382
  * @param options - Configuration for the publisher.
271
383
  */
272
- constructor(options: { websocketImplementation?: any } = {}) {
273
- this.pool = new SimplePool();
274
- if (options.websocketImplementation) {
384
+ constructor(options: PublisherOptions = {}) {
385
+ this.pool = options.pool || new SimplePool();
386
+ if (!options.pool && options.websocketImplementation) {
275
387
  // @ts-ignore
276
388
  this.pool.websocketImplementation = options.websocketImplementation;
277
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.
278
424
  }
279
425
 
280
426
  /**
@@ -290,11 +436,12 @@ export class NCC05Publisher {
290
436
  */
291
437
  async publishWrapped(
292
438
  relays: string[],
293
- secretKey: Uint8Array,
439
+ secretKey: string | Uint8Array,
294
440
  recipients: string[],
295
441
  payload: NCC05Payload,
296
442
  identifier: string = 'addr'
297
443
  ): Promise<Event> {
444
+ const sk = ensureUint8Array(secretKey);
298
445
  const sessionKey = generateSecretKey();
299
446
  const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
300
447
 
@@ -303,7 +450,7 @@ export class NCC05Publisher {
303
450
 
304
451
  const wraps: Record<string, string> = {};
305
452
  for (const rPk of recipients) {
306
- const conversationKey = nip44.getConversationKey(secretKey, rPk);
453
+ const conversationKey = nip44.getConversationKey(sk, rPk);
307
454
  wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
308
455
  }
309
456
 
@@ -316,8 +463,8 @@ export class NCC05Publisher {
316
463
  content: JSON.stringify(wrappedContent),
317
464
  };
318
465
 
319
- const signedEvent = finalizeEvent(eventTemplate, secretKey);
320
- await Promise.all(this.pool.publish(relays, signedEvent));
466
+ const signedEvent = finalizeEvent(eventTemplate, sk);
467
+ await this._publishToRelays(relays, signedEvent);
321
468
  return signedEvent;
322
469
  }
323
470
 
@@ -332,17 +479,18 @@ export class NCC05Publisher {
332
479
  */
333
480
  async publish(
334
481
  relays: string[],
335
- secretKey: Uint8Array,
482
+ secretKey: string | Uint8Array,
336
483
  payload: NCC05Payload,
337
484
  options: { identifier?: string, recipientPubkey?: string, public?: boolean } = {}
338
485
  ): Promise<Event> {
339
- const myPubkey = getPublicKey(secretKey);
486
+ const sk = ensureUint8Array(secretKey);
487
+ const myPubkey = getPublicKey(sk);
340
488
  const identifier = options.identifier || 'addr';
341
489
  let content = JSON.stringify(payload);
342
490
 
343
491
  if (!options.public) {
344
492
  const encryptionTarget = options.recipientPubkey || myPubkey;
345
- const conversationKey = nip44.getConversationKey(secretKey, encryptionTarget);
493
+ const conversationKey = nip44.getConversationKey(sk, encryptionTarget);
346
494
  content = nip44.encrypt(content, conversationKey);
347
495
  }
348
496
 
@@ -354,8 +502,8 @@ export class NCC05Publisher {
354
502
  content: content,
355
503
  };
356
504
 
357
- const signedEvent = finalizeEvent(eventTemplate, secretKey);
358
- await Promise.all(this.pool.publish(relays, signedEvent));
505
+ const signedEvent = finalizeEvent(eventTemplate, sk);
506
+ await this._publishToRelays(relays, signedEvent);
359
507
  return signedEvent;
360
508
  }
361
509
 
@@ -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);