ncc-05 1.1.4 → 1.1.7
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 +43 -11
- package/dist/index.js +184 -61
- package/dist/test-lifecycle.d.ts +1 -0
- package/dist/test-lifecycle.js +33 -0
- package/dist/test-new.d.ts +1 -0
- package/dist/test-new.js +94 -0
- package/eslint.config.mjs +25 -0
- package/package.json +8 -1
- package/src/index.ts +224 -73
- package/src/test-lifecycle.ts +40 -0
- package/src/test-new.ts +99 -0
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,13 +111,14 @@ 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.
|
|
90
118
|
*/
|
|
91
119
|
export declare class NCC05Resolver {
|
|
92
120
|
private pool;
|
|
121
|
+
private _ownPool;
|
|
93
122
|
private bootstrapRelays;
|
|
94
123
|
private timeout;
|
|
95
124
|
/**
|
|
@@ -106,14 +135,16 @@ export declare class NCC05Resolver {
|
|
|
106
135
|
* @param secretKey - Your secret key (required if the record is encrypted).
|
|
107
136
|
* @param identifier - The 'd' tag of the record (default: 'addr').
|
|
108
137
|
* @param options - Resolution options (strict mode, gossip discovery).
|
|
109
|
-
* @returns The resolved and validated NCC05Payload, or null if not found
|
|
138
|
+
* @returns The resolved and validated NCC05Payload, or null if not found.
|
|
139
|
+
* @throws {NCC05TimeoutError} if resolution times out.
|
|
140
|
+
* @throws {NCC05RelayError} if underlying relay communication fails.
|
|
110
141
|
*/
|
|
111
|
-
resolve(targetPubkey: string, secretKey?: Uint8Array, identifier?: string, options?: {
|
|
142
|
+
resolve(targetPubkey: string, secretKey?: string | Uint8Array, identifier?: string, options?: {
|
|
112
143
|
strict?: boolean;
|
|
113
144
|
gossip?: boolean;
|
|
114
145
|
}): Promise<NCC05Payload | null>;
|
|
115
146
|
/**
|
|
116
|
-
* Closes connections to all relays in the pool.
|
|
147
|
+
* Closes connections to all relays in the pool if managed internally.
|
|
117
148
|
*/
|
|
118
149
|
close(): void;
|
|
119
150
|
}
|
|
@@ -122,12 +153,13 @@ export declare class NCC05Resolver {
|
|
|
122
153
|
*/
|
|
123
154
|
export declare class NCC05Publisher {
|
|
124
155
|
private pool;
|
|
156
|
+
private _ownPool;
|
|
157
|
+
private timeout;
|
|
125
158
|
/**
|
|
126
159
|
* @param options - Configuration for the publisher.
|
|
127
160
|
*/
|
|
128
|
-
constructor(options?:
|
|
129
|
-
|
|
130
|
-
});
|
|
161
|
+
constructor(options?: PublisherOptions);
|
|
162
|
+
private _publishToRelays;
|
|
131
163
|
/**
|
|
132
164
|
* Publishes a single record encrypted for multiple recipients using the wrapping pattern.
|
|
133
165
|
* This avoids sharing a single group private key.
|
|
@@ -139,7 +171,7 @@ export declare class NCC05Publisher {
|
|
|
139
171
|
* @param identifier - The 'd' tag identifier (default: 'addr').
|
|
140
172
|
* @returns The signed Nostr event.
|
|
141
173
|
*/
|
|
142
|
-
publishWrapped(relays: string[], secretKey: Uint8Array, recipients: string[], payload: NCC05Payload, identifier?: string): Promise<Event>;
|
|
174
|
+
publishWrapped(relays: string[], secretKey: string | Uint8Array, recipients: string[], payload: NCC05Payload, identifier?: string): Promise<Event>;
|
|
143
175
|
/**
|
|
144
176
|
* Publishes a locator record. Supports self-encryption, targeted encryption, or plaintext.
|
|
145
177
|
*
|
|
@@ -149,13 +181,13 @@ export declare class NCC05Publisher {
|
|
|
149
181
|
* @param options - Publishing options (identifier, recipient, or public flag).
|
|
150
182
|
* @returns The signed Nostr event.
|
|
151
183
|
*/
|
|
152
|
-
publish(relays: string[], secretKey: Uint8Array, payload: NCC05Payload, options?: {
|
|
184
|
+
publish(relays: string[], secretKey: string | Uint8Array, payload: NCC05Payload, options?: {
|
|
153
185
|
identifier?: string;
|
|
154
186
|
recipientPubkey?: string;
|
|
155
187
|
public?: boolean;
|
|
156
188
|
}): Promise<Event>;
|
|
157
189
|
/**
|
|
158
|
-
* Closes connections to the specified relays.
|
|
190
|
+
* Closes connections to the specified relays if managed internally.
|
|
159
191
|
*/
|
|
160
192
|
close(relays: string[]): void;
|
|
161
193
|
}
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,50 @@
|
|
|
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
|
+
}
|
|
10
54
|
/**
|
|
11
55
|
* Utility for managing shared group access to service records.
|
|
12
56
|
*/
|
|
@@ -45,16 +89,24 @@ export class NCC05Group {
|
|
|
45
89
|
*/
|
|
46
90
|
export class NCC05Resolver {
|
|
47
91
|
pool;
|
|
92
|
+
_ownPool;
|
|
48
93
|
bootstrapRelays;
|
|
49
94
|
timeout;
|
|
50
95
|
/**
|
|
51
96
|
* @param options - Configuration for the resolver.
|
|
52
97
|
*/
|
|
53
98
|
constructor(options = {}) {
|
|
54
|
-
this.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
99
|
+
this._ownPool = !options.pool;
|
|
100
|
+
this.pool = options.pool || new SimplePool();
|
|
101
|
+
if (this._ownPool) {
|
|
102
|
+
if (options.websocketImplementation) {
|
|
103
|
+
// @ts-ignore - Patching pool for custom transport
|
|
104
|
+
this.pool.websocketImplementation = options.websocketImplementation;
|
|
105
|
+
}
|
|
106
|
+
else if (typeof WebSocket === 'undefined' && typeof globalThis !== 'undefined' && globalThis.WebSocket) {
|
|
107
|
+
// @ts-ignore
|
|
108
|
+
this.pool.websocketImplementation = globalThis.WebSocket;
|
|
109
|
+
}
|
|
58
110
|
}
|
|
59
111
|
this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
|
|
60
112
|
this.timeout = options.timeout || 10000;
|
|
@@ -69,7 +121,9 @@ export class NCC05Resolver {
|
|
|
69
121
|
* @param secretKey - Your secret key (required if the record is encrypted).
|
|
70
122
|
* @param identifier - The 'd' tag of the record (default: 'addr').
|
|
71
123
|
* @param options - Resolution options (strict mode, gossip discovery).
|
|
72
|
-
* @returns The resolved and validated NCC05Payload, or null if not found
|
|
124
|
+
* @returns The resolved and validated NCC05Payload, or null if not found.
|
|
125
|
+
* @throws {NCC05TimeoutError} if resolution times out.
|
|
126
|
+
* @throws {NCC05RelayError} if underlying relay communication fails.
|
|
73
127
|
*/
|
|
74
128
|
async resolve(targetPubkey, secretKey, identifier = 'addr', options = {}) {
|
|
75
129
|
let hexPubkey = targetPubkey;
|
|
@@ -80,19 +134,25 @@ export class NCC05Resolver {
|
|
|
80
134
|
let queryRelays = [...this.bootstrapRelays];
|
|
81
135
|
// 1. NIP-65 Gossip Discovery
|
|
82
136
|
if (options.gossip) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
137
|
+
try {
|
|
138
|
+
const relayListEvent = await this.pool.get(this.bootstrapRelays, {
|
|
139
|
+
authors: [hexPubkey],
|
|
140
|
+
kinds: [10002]
|
|
141
|
+
});
|
|
142
|
+
// Security: Verify NIP-65 event signature and author
|
|
143
|
+
if (relayListEvent && verifyEvent(relayListEvent) && relayListEvent.pubkey === hexPubkey) {
|
|
144
|
+
const discoveredRelays = relayListEvent.tags
|
|
145
|
+
.filter(t => t[0] === 'r')
|
|
146
|
+
.map(t => t[1]);
|
|
147
|
+
if (discoveredRelays.length > 0) {
|
|
148
|
+
queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
|
|
149
|
+
}
|
|
94
150
|
}
|
|
95
151
|
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
console.warn(`[NCC-05] Gossip discovery failed: ${e.message}`);
|
|
154
|
+
// Proceed with bootstrap relays
|
|
155
|
+
}
|
|
96
156
|
}
|
|
97
157
|
const filter = {
|
|
98
158
|
authors: [hexPubkey],
|
|
@@ -100,47 +160,64 @@ export class NCC05Resolver {
|
|
|
100
160
|
'#d': [identifier],
|
|
101
161
|
limit: 10
|
|
102
162
|
};
|
|
103
|
-
const
|
|
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];
|
|
163
|
+
const sk = secretKey ? ensureUint8Array(secretKey) : undefined;
|
|
115
164
|
try {
|
|
165
|
+
const queryPromise = this.pool.querySync(queryRelays, filter);
|
|
166
|
+
const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new NCC05TimeoutError("Resolution timed out")), this.timeout));
|
|
167
|
+
const result = await Promise.race([queryPromise, timeoutPromise]);
|
|
168
|
+
if (!result || (Array.isArray(result) && result.length === 0))
|
|
169
|
+
return null;
|
|
170
|
+
// 2. Filter for valid signatures, correct author, and sort by created_at
|
|
171
|
+
const validEvents = result
|
|
172
|
+
.filter(e => e.pubkey === hexPubkey && verifyEvent(e))
|
|
173
|
+
.sort((a, b) => b.created_at - a.created_at);
|
|
174
|
+
if (validEvents.length === 0)
|
|
175
|
+
return null;
|
|
176
|
+
const latestEvent = validEvents[0];
|
|
116
177
|
let content = latestEvent.content;
|
|
117
178
|
// Security: Robust multi-recipient detection
|
|
118
179
|
const isWrapped = content.includes('"wraps"') &&
|
|
119
180
|
content.includes('"ciphertext"') &&
|
|
120
181
|
content.startsWith('{');
|
|
121
|
-
if (isWrapped &&
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
182
|
+
if (isWrapped && sk) {
|
|
183
|
+
try {
|
|
184
|
+
const wrapped = JSON.parse(content);
|
|
185
|
+
const myPk = getPublicKey(sk);
|
|
186
|
+
const myWrap = wrapped.wraps[myPk];
|
|
187
|
+
if (myWrap) {
|
|
188
|
+
const conversationKey = nip44.getConversationKey(sk, hexPubkey);
|
|
189
|
+
const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
|
|
190
|
+
// Convert hex symmetric key back to Uint8Array for NIP-44 decryption
|
|
191
|
+
const symmetricKey = new Uint8Array(symmetricKeyHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
|
|
192
|
+
const sessionConversationKey = nip44.getConversationKey(symmetricKey, getPublicKey(symmetricKey));
|
|
193
|
+
content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
return null; // Not intended for us
|
|
197
|
+
}
|
|
132
198
|
}
|
|
133
|
-
|
|
134
|
-
|
|
199
|
+
catch (_e) {
|
|
200
|
+
throw new NCC05DecryptionError("Failed to decrypt wrapped content");
|
|
135
201
|
}
|
|
136
202
|
}
|
|
137
|
-
else if (
|
|
203
|
+
else if (sk && !content.startsWith('{')) {
|
|
138
204
|
// Standard NIP-44 (likely encrypted if not starting with {)
|
|
139
|
-
|
|
140
|
-
|
|
205
|
+
try {
|
|
206
|
+
const conversationKey = nip44.getConversationKey(sk, hexPubkey);
|
|
207
|
+
content = nip44.decrypt(latestEvent.content, conversationKey);
|
|
208
|
+
}
|
|
209
|
+
catch (_e) {
|
|
210
|
+
throw new NCC05DecryptionError("Failed to decrypt content");
|
|
211
|
+
}
|
|
141
212
|
}
|
|
142
213
|
// Security: Safe JSON parsing
|
|
143
|
-
|
|
214
|
+
let payload;
|
|
215
|
+
try {
|
|
216
|
+
payload = JSON.parse(content);
|
|
217
|
+
}
|
|
218
|
+
catch (_e) {
|
|
219
|
+
return null; // Invalid JSON
|
|
220
|
+
}
|
|
144
221
|
if (!payload || !payload.endpoints || !Array.isArray(payload.endpoints)) {
|
|
145
222
|
return null;
|
|
146
223
|
}
|
|
@@ -154,14 +231,18 @@ export class NCC05Resolver {
|
|
|
154
231
|
return payload;
|
|
155
232
|
}
|
|
156
233
|
catch (e) {
|
|
157
|
-
|
|
234
|
+
if (e instanceof NCC05Error)
|
|
235
|
+
throw e;
|
|
236
|
+
throw new NCC05RelayError(`Relay query failed: ${e.message}`);
|
|
158
237
|
}
|
|
159
238
|
}
|
|
160
239
|
/**
|
|
161
|
-
* Closes connections to all relays in the pool.
|
|
240
|
+
* Closes connections to all relays in the pool if managed internally.
|
|
162
241
|
*/
|
|
163
242
|
close() {
|
|
164
|
-
|
|
243
|
+
if (this._ownPool) {
|
|
244
|
+
this.pool.close(this.bootstrapRelays);
|
|
245
|
+
}
|
|
165
246
|
}
|
|
166
247
|
}
|
|
167
248
|
/**
|
|
@@ -169,15 +250,53 @@ export class NCC05Resolver {
|
|
|
169
250
|
*/
|
|
170
251
|
export class NCC05Publisher {
|
|
171
252
|
pool;
|
|
253
|
+
_ownPool;
|
|
254
|
+
timeout;
|
|
172
255
|
/**
|
|
173
256
|
* @param options - Configuration for the publisher.
|
|
174
257
|
*/
|
|
175
258
|
constructor(options = {}) {
|
|
176
|
-
this.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
259
|
+
this._ownPool = !options.pool;
|
|
260
|
+
this.pool = options.pool || new SimplePool();
|
|
261
|
+
if (this._ownPool) {
|
|
262
|
+
if (options.websocketImplementation) {
|
|
263
|
+
// @ts-ignore
|
|
264
|
+
this.pool.websocketImplementation = options.websocketImplementation;
|
|
265
|
+
}
|
|
266
|
+
else if (typeof WebSocket === 'undefined' && typeof globalThis !== 'undefined' && globalThis.WebSocket) {
|
|
267
|
+
// @ts-ignore
|
|
268
|
+
this.pool.websocketImplementation = globalThis.WebSocket;
|
|
269
|
+
}
|
|
180
270
|
}
|
|
271
|
+
this.timeout = options.timeout || 5000;
|
|
272
|
+
}
|
|
273
|
+
async _publishToRelays(relays, signedEvent) {
|
|
274
|
+
const publishPromises = this.pool.publish(relays, signedEvent);
|
|
275
|
+
// Convert to promise that resolves/rejects based on timeout
|
|
276
|
+
const wrappedPromises = publishPromises.map(p => {
|
|
277
|
+
// In nostr-tools v2, publish returns Promise<void>.
|
|
278
|
+
// We wrap it to handle timeout.
|
|
279
|
+
return new Promise((resolve, reject) => {
|
|
280
|
+
const timer = setTimeout(() => reject(new NCC05TimeoutError("Publish timed out")), this.timeout);
|
|
281
|
+
p.then(() => {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
resolve();
|
|
284
|
+
}).catch((err) => {
|
|
285
|
+
clearTimeout(timer);
|
|
286
|
+
reject(err);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
const results = await Promise.allSettled(wrappedPromises);
|
|
291
|
+
const successful = results.filter(r => r.status === 'fulfilled');
|
|
292
|
+
if (successful.length === 0) {
|
|
293
|
+
const errors = results
|
|
294
|
+
.filter(r => r.status === 'rejected')
|
|
295
|
+
.map(r => r.reason.message)
|
|
296
|
+
.join(', ');
|
|
297
|
+
throw new NCC05RelayError(`Failed to publish to any relay. Errors: ${errors}`);
|
|
298
|
+
}
|
|
299
|
+
// If partial success, we consider it a success.
|
|
181
300
|
}
|
|
182
301
|
/**
|
|
183
302
|
* Publishes a single record encrypted for multiple recipients using the wrapping pattern.
|
|
@@ -191,13 +310,14 @@ export class NCC05Publisher {
|
|
|
191
310
|
* @returns The signed Nostr event.
|
|
192
311
|
*/
|
|
193
312
|
async publishWrapped(relays, secretKey, recipients, payload, identifier = 'addr') {
|
|
313
|
+
const sk = ensureUint8Array(secretKey);
|
|
194
314
|
const sessionKey = generateSecretKey();
|
|
195
315
|
const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
196
316
|
const selfConversation = nip44.getConversationKey(sessionKey, getPublicKey(sessionKey));
|
|
197
317
|
const ciphertext = nip44.encrypt(JSON.stringify(payload), selfConversation);
|
|
198
318
|
const wraps = {};
|
|
199
319
|
for (const rPk of recipients) {
|
|
200
|
-
const conversationKey = nip44.getConversationKey(
|
|
320
|
+
const conversationKey = nip44.getConversationKey(sk, rPk);
|
|
201
321
|
wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
|
|
202
322
|
}
|
|
203
323
|
const wrappedContent = { ciphertext, wraps };
|
|
@@ -207,8 +327,8 @@ export class NCC05Publisher {
|
|
|
207
327
|
tags: [['d', identifier]],
|
|
208
328
|
content: JSON.stringify(wrappedContent),
|
|
209
329
|
};
|
|
210
|
-
const signedEvent = finalizeEvent(eventTemplate,
|
|
211
|
-
await
|
|
330
|
+
const signedEvent = finalizeEvent(eventTemplate, sk);
|
|
331
|
+
await this._publishToRelays(relays, signedEvent);
|
|
212
332
|
return signedEvent;
|
|
213
333
|
}
|
|
214
334
|
/**
|
|
@@ -221,12 +341,13 @@ export class NCC05Publisher {
|
|
|
221
341
|
* @returns The signed Nostr event.
|
|
222
342
|
*/
|
|
223
343
|
async publish(relays, secretKey, payload, options = {}) {
|
|
224
|
-
const
|
|
344
|
+
const sk = ensureUint8Array(secretKey);
|
|
345
|
+
const myPubkey = getPublicKey(sk);
|
|
225
346
|
const identifier = options.identifier || 'addr';
|
|
226
347
|
let content = JSON.stringify(payload);
|
|
227
348
|
if (!options.public) {
|
|
228
349
|
const encryptionTarget = options.recipientPubkey || myPubkey;
|
|
229
|
-
const conversationKey = nip44.getConversationKey(
|
|
350
|
+
const conversationKey = nip44.getConversationKey(sk, encryptionTarget);
|
|
230
351
|
content = nip44.encrypt(content, conversationKey);
|
|
231
352
|
}
|
|
232
353
|
const eventTemplate = {
|
|
@@ -236,14 +357,16 @@ export class NCC05Publisher {
|
|
|
236
357
|
tags: [['d', identifier]],
|
|
237
358
|
content: content,
|
|
238
359
|
};
|
|
239
|
-
const signedEvent = finalizeEvent(eventTemplate,
|
|
240
|
-
await
|
|
360
|
+
const signedEvent = finalizeEvent(eventTemplate, sk);
|
|
361
|
+
await this._publishToRelays(relays, signedEvent);
|
|
241
362
|
return signedEvent;
|
|
242
363
|
}
|
|
243
364
|
/**
|
|
244
|
-
* Closes connections to the specified relays.
|
|
365
|
+
* Closes connections to the specified relays if managed internally.
|
|
245
366
|
*/
|
|
246
367
|
close(relays) {
|
|
247
|
-
this.
|
|
368
|
+
if (this._ownPool) {
|
|
369
|
+
this.pool.close(relays);
|
|
370
|
+
}
|
|
248
371
|
}
|
|
249
372
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { NCC05Resolver } from './index.js';
|
|
2
|
+
import { SimplePool } from 'nostr-tools';
|
|
3
|
+
async function testLifecycle() {
|
|
4
|
+
console.log('--- Starting Lifecycle Test ---');
|
|
5
|
+
// 1. Internal Pool Management
|
|
6
|
+
console.log('Test 1: Internal Pool (should close)');
|
|
7
|
+
const resolverInternal = new NCC05Resolver();
|
|
8
|
+
// @ts-ignore - Access private property for testing or infer from behavior
|
|
9
|
+
const internalPool = resolverInternal['pool'];
|
|
10
|
+
internalPool.close = (_relays) => {
|
|
11
|
+
console.log('Internal pool close called.');
|
|
12
|
+
};
|
|
13
|
+
resolverInternal.close(); // Should log
|
|
14
|
+
// 2. Shared Pool Management
|
|
15
|
+
console.log('Test 2: Shared Pool (should NOT close)');
|
|
16
|
+
const sharedPool = new SimplePool();
|
|
17
|
+
let sharedClosed = false;
|
|
18
|
+
sharedPool.close = (_relays) => {
|
|
19
|
+
sharedClosed = true;
|
|
20
|
+
console.error('ERROR: Shared pool was closed!');
|
|
21
|
+
};
|
|
22
|
+
const resolverShared = new NCC05Resolver({ pool: sharedPool });
|
|
23
|
+
resolverShared.close(); // Should NOT close sharedPool
|
|
24
|
+
if (!sharedClosed) {
|
|
25
|
+
console.log('Shared pool correctly remained open.');
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
console.log('Lifecycle Test Suite Passed.');
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
testLifecycle().catch(console.error);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/test-new.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { NCC05Publisher, NCC05Resolver, 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
|
+
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 instanceof 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);
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import globals from "globals";
|
|
2
|
+
import pluginJs from "@eslint/js";
|
|
3
|
+
import tseslint from "typescript-eslint";
|
|
4
|
+
|
|
5
|
+
export default [
|
|
6
|
+
{files: ["**/*.{js,mjs,cjs,ts}"]},
|
|
7
|
+
{languageOptions: { globals: globals.node }},
|
|
8
|
+
pluginJs.configs.recommended,
|
|
9
|
+
...tseslint.configs.recommended,
|
|
10
|
+
{
|
|
11
|
+
rules: {
|
|
12
|
+
"@typescript-eslint/no-explicit-any": "off",
|
|
13
|
+
"@typescript-eslint/ban-ts-comment": "off",
|
|
14
|
+
"@typescript-eslint/no-unused-vars": ["warn", {
|
|
15
|
+
"argsIgnorePattern": "^_",
|
|
16
|
+
"varsIgnorePattern": "^_",
|
|
17
|
+
"caughtErrorsIgnorePattern": "^_"
|
|
18
|
+
}],
|
|
19
|
+
"no-console": "off"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
ignores: ["dist/", "node_modules/"]
|
|
24
|
+
}
|
|
25
|
+
];
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ncc-05",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.7",
|
|
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",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"build": "tsc",
|
|
10
|
+
"lint": "eslint src/**/*.ts",
|
|
10
11
|
"prepublishOnly": "npm run build"
|
|
11
12
|
},
|
|
12
13
|
"keywords": [
|
|
@@ -22,9 +23,15 @@
|
|
|
22
23
|
"nostr-tools": "^2.10.0"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
26
|
+
"@eslint/js": "^9.39.2",
|
|
25
27
|
"@types/node": "^25.0.3",
|
|
26
28
|
"@types/ws": "^8.18.1",
|
|
29
|
+
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
|
30
|
+
"@typescript-eslint/parser": "^8.50.1",
|
|
31
|
+
"eslint": "^9.39.2",
|
|
32
|
+
"globals": "^16.5.0",
|
|
27
33
|
"typescript": "^5.0.0",
|
|
34
|
+
"typescript-eslint": "^8.50.1",
|
|
28
35
|
"ws": "^8.18.3"
|
|
29
36
|
}
|
|
30
37
|
}
|
package/src/index.ts
CHANGED
|
@@ -18,6 +18,57 @@ 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
|
+
|
|
21
72
|
/**
|
|
22
73
|
* Represents a single reachable service endpoint.
|
|
23
74
|
*/
|
|
@@ -60,6 +111,20 @@ export interface ResolverOptions {
|
|
|
60
111
|
timeout?: number;
|
|
61
112
|
/** Custom WebSocket implementation (e.g., for Tor/SOCKS5 in Node.js) */
|
|
62
113
|
websocketImplementation?: any;
|
|
114
|
+
/** Existing SimplePool instance to share connections */
|
|
115
|
+
pool?: SimplePool;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Options for configuring the NCC05Publisher.
|
|
120
|
+
*/
|
|
121
|
+
export interface PublisherOptions {
|
|
122
|
+
/** Custom WebSocket implementation */
|
|
123
|
+
websocketImplementation?: any;
|
|
124
|
+
/** Existing SimplePool instance */
|
|
125
|
+
pool?: SimplePool;
|
|
126
|
+
/** Timeout for publishing in milliseconds (default: 5000) */
|
|
127
|
+
timeout?: number;
|
|
63
128
|
}
|
|
64
129
|
|
|
65
130
|
/**
|
|
@@ -106,7 +171,7 @@ export class NCC05Group {
|
|
|
106
171
|
static async resolveAsGroup(
|
|
107
172
|
resolver: NCC05Resolver,
|
|
108
173
|
groupPubkey: string,
|
|
109
|
-
groupSecretKey: Uint8Array,
|
|
174
|
+
groupSecretKey: string | Uint8Array,
|
|
110
175
|
identifier: string = 'addr'
|
|
111
176
|
): Promise<NCC05Payload | null> {
|
|
112
177
|
return resolver.resolve(groupPubkey, groupSecretKey, identifier);
|
|
@@ -118,6 +183,7 @@ export class NCC05Group {
|
|
|
118
183
|
*/
|
|
119
184
|
export class NCC05Resolver {
|
|
120
185
|
private pool: SimplePool;
|
|
186
|
+
private _ownPool: boolean;
|
|
121
187
|
private bootstrapRelays: string[];
|
|
122
188
|
private timeout: number;
|
|
123
189
|
|
|
@@ -125,11 +191,19 @@ export class NCC05Resolver {
|
|
|
125
191
|
* @param options - Configuration for the resolver.
|
|
126
192
|
*/
|
|
127
193
|
constructor(options: ResolverOptions = {}) {
|
|
128
|
-
this.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
194
|
+
this._ownPool = !options.pool;
|
|
195
|
+
this.pool = options.pool || new SimplePool();
|
|
196
|
+
|
|
197
|
+
if (this._ownPool) {
|
|
198
|
+
if (options.websocketImplementation) {
|
|
199
|
+
// @ts-ignore - Patching pool for custom transport
|
|
200
|
+
this.pool.websocketImplementation = options.websocketImplementation;
|
|
201
|
+
} else if (typeof WebSocket === 'undefined' && typeof globalThis !== 'undefined' && globalThis.WebSocket) {
|
|
202
|
+
// @ts-ignore
|
|
203
|
+
this.pool.websocketImplementation = globalThis.WebSocket;
|
|
204
|
+
}
|
|
132
205
|
}
|
|
206
|
+
|
|
133
207
|
this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
|
|
134
208
|
this.timeout = options.timeout || 10000;
|
|
135
209
|
}
|
|
@@ -144,11 +218,13 @@ export class NCC05Resolver {
|
|
|
144
218
|
* @param secretKey - Your secret key (required if the record is encrypted).
|
|
145
219
|
* @param identifier - The 'd' tag of the record (default: 'addr').
|
|
146
220
|
* @param options - Resolution options (strict mode, gossip discovery).
|
|
147
|
-
* @returns The resolved and validated NCC05Payload, or null if not found
|
|
221
|
+
* @returns The resolved and validated NCC05Payload, or null if not found.
|
|
222
|
+
* @throws {NCC05TimeoutError} if resolution times out.
|
|
223
|
+
* @throws {NCC05RelayError} if underlying relay communication fails.
|
|
148
224
|
*/
|
|
149
225
|
async resolve(
|
|
150
226
|
targetPubkey: string,
|
|
151
|
-
secretKey?: Uint8Array,
|
|
227
|
+
secretKey?: string | Uint8Array,
|
|
152
228
|
identifier: string = 'addr',
|
|
153
229
|
options: { strict?: boolean, gossip?: boolean } = {}
|
|
154
230
|
): Promise<NCC05Payload | null> {
|
|
@@ -162,18 +238,23 @@ export class NCC05Resolver {
|
|
|
162
238
|
|
|
163
239
|
// 1. NIP-65 Gossip Discovery
|
|
164
240
|
if (options.gossip) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
241
|
+
try {
|
|
242
|
+
const relayListEvent = await this.pool.get(this.bootstrapRelays, {
|
|
243
|
+
authors: [hexPubkey],
|
|
244
|
+
kinds: [10002]
|
|
245
|
+
});
|
|
246
|
+
// Security: Verify NIP-65 event signature and author
|
|
247
|
+
if (relayListEvent && verifyEvent(relayListEvent) && relayListEvent.pubkey === hexPubkey) {
|
|
248
|
+
const discoveredRelays = relayListEvent.tags
|
|
249
|
+
.filter(t => t[0] === 'r')
|
|
250
|
+
.map(t => t[1]);
|
|
251
|
+
if (discoveredRelays.length > 0) {
|
|
252
|
+
queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
|
|
253
|
+
}
|
|
176
254
|
}
|
|
255
|
+
} catch (e: any) {
|
|
256
|
+
console.warn(`[NCC-05] Gossip discovery failed: ${e.message}`);
|
|
257
|
+
// Proceed with bootstrap relays
|
|
177
258
|
}
|
|
178
259
|
}
|
|
179
260
|
|
|
@@ -184,21 +265,26 @@ export class NCC05Resolver {
|
|
|
184
265
|
limit: 10
|
|
185
266
|
};
|
|
186
267
|
|
|
187
|
-
const
|
|
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];
|
|
268
|
+
const sk = secretKey ? ensureUint8Array(secretKey) : undefined;
|
|
200
269
|
|
|
201
270
|
try {
|
|
271
|
+
const queryPromise = this.pool.querySync(queryRelays, filter);
|
|
272
|
+
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
273
|
+
setTimeout(() => reject(new NCC05TimeoutError("Resolution timed out")), this.timeout)
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const result = await Promise.race([queryPromise, timeoutPromise]);
|
|
277
|
+
|
|
278
|
+
if (!result || (Array.isArray(result) && result.length === 0)) return null;
|
|
279
|
+
|
|
280
|
+
// 2. Filter for valid signatures, correct author, and sort by created_at
|
|
281
|
+
const validEvents = (result as Event[])
|
|
282
|
+
.filter(e => e.pubkey === hexPubkey && verifyEvent(e))
|
|
283
|
+
.sort((a, b) => b.created_at - a.created_at);
|
|
284
|
+
|
|
285
|
+
if (validEvents.length === 0) return null;
|
|
286
|
+
const latestEvent = validEvents[0];
|
|
287
|
+
|
|
202
288
|
let content = latestEvent.content;
|
|
203
289
|
|
|
204
290
|
// Security: Robust multi-recipient detection
|
|
@@ -206,35 +292,49 @@ export class NCC05Resolver {
|
|
|
206
292
|
content.includes('"ciphertext"') &&
|
|
207
293
|
content.startsWith('{');
|
|
208
294
|
|
|
209
|
-
if (isWrapped &&
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (myWrap) {
|
|
215
|
-
const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
|
|
216
|
-
const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
|
|
295
|
+
if (isWrapped && sk) {
|
|
296
|
+
try {
|
|
297
|
+
const wrapped = JSON.parse(content) as WrappedContent;
|
|
298
|
+
const myPk = getPublicKey(sk);
|
|
299
|
+
const myWrap = wrapped.wraps[myPk];
|
|
217
300
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
symmetricKeyHex
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
301
|
+
if (myWrap) {
|
|
302
|
+
const conversationKey = nip44.getConversationKey(sk, hexPubkey);
|
|
303
|
+
const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
|
|
304
|
+
|
|
305
|
+
// Convert hex symmetric key back to Uint8Array for NIP-44 decryption
|
|
306
|
+
const symmetricKey = new Uint8Array(
|
|
307
|
+
symmetricKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16))
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const sessionConversationKey = nip44.getConversationKey(
|
|
311
|
+
symmetricKey, getPublicKey(symmetricKey)
|
|
312
|
+
);
|
|
313
|
+
content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
|
|
314
|
+
} else {
|
|
315
|
+
return null; // Not intended for us
|
|
316
|
+
}
|
|
317
|
+
} catch (_e) {
|
|
318
|
+
throw new NCC05DecryptionError("Failed to decrypt wrapped content");
|
|
229
319
|
}
|
|
230
|
-
} else if (
|
|
320
|
+
} else if (sk && !content.startsWith('{')) {
|
|
231
321
|
// Standard NIP-44 (likely encrypted if not starting with {)
|
|
232
|
-
|
|
233
|
-
|
|
322
|
+
try {
|
|
323
|
+
const conversationKey = nip44.getConversationKey(sk, hexPubkey);
|
|
324
|
+
content = nip44.decrypt(latestEvent.content, conversationKey);
|
|
325
|
+
} catch (_e) {
|
|
326
|
+
throw new NCC05DecryptionError("Failed to decrypt content");
|
|
327
|
+
}
|
|
234
328
|
}
|
|
235
329
|
|
|
236
330
|
// Security: Safe JSON parsing
|
|
237
|
-
|
|
331
|
+
let payload: NCC05Payload;
|
|
332
|
+
try {
|
|
333
|
+
payload = JSON.parse(content) as NCC05Payload;
|
|
334
|
+
} catch (_e) {
|
|
335
|
+
return null; // Invalid JSON
|
|
336
|
+
}
|
|
337
|
+
|
|
238
338
|
if (!payload || !payload.endpoints || !Array.isArray(payload.endpoints)) {
|
|
239
339
|
return null;
|
|
240
340
|
}
|
|
@@ -248,15 +348,18 @@ export class NCC05Resolver {
|
|
|
248
348
|
|
|
249
349
|
return payload;
|
|
250
350
|
} catch (e) {
|
|
251
|
-
|
|
351
|
+
if (e instanceof NCC05Error) throw e;
|
|
352
|
+
throw new NCC05RelayError(`Relay query failed: ${(e as Error).message}`);
|
|
252
353
|
}
|
|
253
354
|
}
|
|
254
355
|
|
|
255
356
|
/**
|
|
256
|
-
* Closes connections to all relays in the pool.
|
|
357
|
+
* Closes connections to all relays in the pool if managed internally.
|
|
257
358
|
*/
|
|
258
359
|
close() {
|
|
259
|
-
|
|
360
|
+
if (this._ownPool) {
|
|
361
|
+
this.pool.close(this.bootstrapRelays);
|
|
362
|
+
}
|
|
260
363
|
}
|
|
261
364
|
}
|
|
262
365
|
|
|
@@ -265,16 +368,60 @@ export class NCC05Resolver {
|
|
|
265
368
|
*/
|
|
266
369
|
export class NCC05Publisher {
|
|
267
370
|
private pool: SimplePool;
|
|
371
|
+
private _ownPool: boolean;
|
|
372
|
+
private timeout: number;
|
|
268
373
|
|
|
269
374
|
/**
|
|
270
375
|
* @param options - Configuration for the publisher.
|
|
271
376
|
*/
|
|
272
|
-
constructor(options:
|
|
273
|
-
this.
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
377
|
+
constructor(options: PublisherOptions = {}) {
|
|
378
|
+
this._ownPool = !options.pool;
|
|
379
|
+
this.pool = options.pool || new SimplePool();
|
|
380
|
+
|
|
381
|
+
if (this._ownPool) {
|
|
382
|
+
if (options.websocketImplementation) {
|
|
383
|
+
// @ts-ignore
|
|
384
|
+
this.pool.websocketImplementation = options.websocketImplementation;
|
|
385
|
+
} else if (typeof WebSocket === 'undefined' && typeof globalThis !== 'undefined' && globalThis.WebSocket) {
|
|
386
|
+
// @ts-ignore
|
|
387
|
+
this.pool.websocketImplementation = globalThis.WebSocket;
|
|
388
|
+
}
|
|
277
389
|
}
|
|
390
|
+
|
|
391
|
+
this.timeout = options.timeout || 5000;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private async _publishToRelays(relays: string[], signedEvent: Event): Promise<void> {
|
|
395
|
+
const publishPromises = this.pool.publish(relays, signedEvent);
|
|
396
|
+
|
|
397
|
+
// Convert to promise that resolves/rejects based on timeout
|
|
398
|
+
const wrappedPromises = publishPromises.map(p => {
|
|
399
|
+
// In nostr-tools v2, publish returns Promise<void>.
|
|
400
|
+
// We wrap it to handle timeout.
|
|
401
|
+
return new Promise<void>((resolve, reject) => {
|
|
402
|
+
const timer = setTimeout(() => reject(new NCC05TimeoutError("Publish timed out")), this.timeout);
|
|
403
|
+
p.then(() => {
|
|
404
|
+
clearTimeout(timer);
|
|
405
|
+
resolve();
|
|
406
|
+
}).catch((err) => {
|
|
407
|
+
clearTimeout(timer);
|
|
408
|
+
reject(err);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
const results = await Promise.allSettled(wrappedPromises);
|
|
414
|
+
const successful = results.filter(r => r.status === 'fulfilled');
|
|
415
|
+
|
|
416
|
+
if (successful.length === 0) {
|
|
417
|
+
const errors = results
|
|
418
|
+
.filter(r => r.status === 'rejected')
|
|
419
|
+
.map(r => (r as PromiseRejectedResult).reason.message)
|
|
420
|
+
.join(', ');
|
|
421
|
+
throw new NCC05RelayError(`Failed to publish to any relay. Errors: ${errors}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// If partial success, we consider it a success.
|
|
278
425
|
}
|
|
279
426
|
|
|
280
427
|
/**
|
|
@@ -290,11 +437,12 @@ export class NCC05Publisher {
|
|
|
290
437
|
*/
|
|
291
438
|
async publishWrapped(
|
|
292
439
|
relays: string[],
|
|
293
|
-
secretKey: Uint8Array,
|
|
440
|
+
secretKey: string | Uint8Array,
|
|
294
441
|
recipients: string[],
|
|
295
442
|
payload: NCC05Payload,
|
|
296
443
|
identifier: string = 'addr'
|
|
297
444
|
): Promise<Event> {
|
|
445
|
+
const sk = ensureUint8Array(secretKey);
|
|
298
446
|
const sessionKey = generateSecretKey();
|
|
299
447
|
const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
300
448
|
|
|
@@ -303,7 +451,7 @@ export class NCC05Publisher {
|
|
|
303
451
|
|
|
304
452
|
const wraps: Record<string, string> = {};
|
|
305
453
|
for (const rPk of recipients) {
|
|
306
|
-
const conversationKey = nip44.getConversationKey(
|
|
454
|
+
const conversationKey = nip44.getConversationKey(sk, rPk);
|
|
307
455
|
wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
|
|
308
456
|
}
|
|
309
457
|
|
|
@@ -316,8 +464,8 @@ export class NCC05Publisher {
|
|
|
316
464
|
content: JSON.stringify(wrappedContent),
|
|
317
465
|
};
|
|
318
466
|
|
|
319
|
-
const signedEvent = finalizeEvent(eventTemplate,
|
|
320
|
-
await
|
|
467
|
+
const signedEvent = finalizeEvent(eventTemplate, sk);
|
|
468
|
+
await this._publishToRelays(relays, signedEvent);
|
|
321
469
|
return signedEvent;
|
|
322
470
|
}
|
|
323
471
|
|
|
@@ -332,17 +480,18 @@ export class NCC05Publisher {
|
|
|
332
480
|
*/
|
|
333
481
|
async publish(
|
|
334
482
|
relays: string[],
|
|
335
|
-
secretKey: Uint8Array,
|
|
483
|
+
secretKey: string | Uint8Array,
|
|
336
484
|
payload: NCC05Payload,
|
|
337
485
|
options: { identifier?: string, recipientPubkey?: string, public?: boolean } = {}
|
|
338
486
|
): Promise<Event> {
|
|
339
|
-
const
|
|
487
|
+
const sk = ensureUint8Array(secretKey);
|
|
488
|
+
const myPubkey = getPublicKey(sk);
|
|
340
489
|
const identifier = options.identifier || 'addr';
|
|
341
490
|
let content = JSON.stringify(payload);
|
|
342
491
|
|
|
343
492
|
if (!options.public) {
|
|
344
493
|
const encryptionTarget = options.recipientPubkey || myPubkey;
|
|
345
|
-
const conversationKey = nip44.getConversationKey(
|
|
494
|
+
const conversationKey = nip44.getConversationKey(sk, encryptionTarget);
|
|
346
495
|
content = nip44.encrypt(content, conversationKey);
|
|
347
496
|
}
|
|
348
497
|
|
|
@@ -354,15 +503,17 @@ export class NCC05Publisher {
|
|
|
354
503
|
content: content,
|
|
355
504
|
};
|
|
356
505
|
|
|
357
|
-
const signedEvent = finalizeEvent(eventTemplate,
|
|
358
|
-
await
|
|
506
|
+
const signedEvent = finalizeEvent(eventTemplate, sk);
|
|
507
|
+
await this._publishToRelays(relays, signedEvent);
|
|
359
508
|
return signedEvent;
|
|
360
509
|
}
|
|
361
510
|
|
|
362
511
|
/**
|
|
363
|
-
* Closes connections to the specified relays.
|
|
512
|
+
* Closes connections to the specified relays if managed internally.
|
|
364
513
|
*/
|
|
365
514
|
close(relays: string[]) {
|
|
366
|
-
this.
|
|
515
|
+
if (this._ownPool) {
|
|
516
|
+
this.pool.close(relays);
|
|
517
|
+
}
|
|
367
518
|
}
|
|
368
519
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { NCC05Resolver } from './index.js';
|
|
2
|
+
import { SimplePool } from 'nostr-tools';
|
|
3
|
+
|
|
4
|
+
async function testLifecycle() {
|
|
5
|
+
console.log('--- Starting Lifecycle Test ---');
|
|
6
|
+
|
|
7
|
+
// 1. Internal Pool Management
|
|
8
|
+
console.log('Test 1: Internal Pool (should close)');
|
|
9
|
+
const resolverInternal = new NCC05Resolver();
|
|
10
|
+
// @ts-ignore - Access private property for testing or infer from behavior
|
|
11
|
+
const internalPool = resolverInternal['pool'];
|
|
12
|
+
internalPool.close = (_relays?: string[]) => {
|
|
13
|
+
console.log('Internal pool close called.');
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
resolverInternal.close(); // Should log
|
|
17
|
+
|
|
18
|
+
// 2. Shared Pool Management
|
|
19
|
+
console.log('Test 2: Shared Pool (should NOT close)');
|
|
20
|
+
const sharedPool = new SimplePool();
|
|
21
|
+
let sharedClosed = false;
|
|
22
|
+
sharedPool.close = (_relays?: string[]) => {
|
|
23
|
+
sharedClosed = true;
|
|
24
|
+
console.error('ERROR: Shared pool was closed!');
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const resolverShared = new NCC05Resolver({ pool: sharedPool });
|
|
28
|
+
resolverShared.close(); // Should NOT close sharedPool
|
|
29
|
+
|
|
30
|
+
if (!sharedClosed) {
|
|
31
|
+
console.log('Shared pool correctly remained open.');
|
|
32
|
+
} else {
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.log('Lifecycle Test Suite Passed.');
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
testLifecycle().catch(console.error);
|
package/src/test-new.ts
ADDED
|
@@ -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) {
|
|
80
|
+
const duration = Date.now() - start;
|
|
81
|
+
if (e instanceof 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);
|