ncc-05 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,17 @@
1
1
  # ncc-05
2
2
 
3
- Nostr Community Convention 05 - Identity-Bound Service Locator Resolution.
3
+ **Nostr Community Convention 05 - Identity-Bound Service Locator Resolution.**
4
4
 
5
- This library provides a simple way to publish and resolve identity-bound service endpoints (IP/Port/Onion) using Nostr `kind:30058` events.
5
+ A TypeScript library for publishing and resolving dynamic, encrypted service endpoints (IP, Port, Onion) bound to cryptographic identities using Nostr `kind:30058` events.
6
+
7
+ ## Features
8
+
9
+ - **Identity-Centric**: Endpoints are bound to a Nostr Pubkey.
10
+ - **Privacy-First**: NIP-44 encryption is mandatory by default.
11
+ - **Multi-Recipient Support**: Implement "Wrapping" patterns to share endpoints with groups without sharing private keys.
12
+ - **NIP-65 Gossip**: Built-in support for discovering a publisher's preferred relays.
13
+ - **Tor Ready**: Easy integration with SOCKS5 proxies for anonymous resolution.
14
+ - **Type Safe**: Fully typed with TypeScript.
6
15
 
7
16
  ## Installation
8
17
 
@@ -12,7 +21,7 @@ npm install ncc-05
12
21
 
13
22
  ## Usage
14
23
 
15
- ### Resolver
24
+ ### 1. Basic Resolution (Self or Public)
16
25
 
17
26
  Resolve an identity-bound service locator for a given pubkey.
18
27
 
@@ -22,65 +31,62 @@ import { nip19 } from 'nostr-tools';
22
31
 
23
32
  const resolver = new NCC05Resolver();
24
33
 
25
- // Your secret key is needed to decrypt the record (NIP-44)
26
- const mySecretKey = nip19.decode('nsec...').data as Uint8Array;
27
- const targetPubkey = '...';
34
+ // Resolve using an npub (or hex pubkey)
35
+ const target = 'npub1...';
36
+ const mySecretKey = ...; // Uint8Array needed for encrypted records
28
37
 
29
- const payload = await resolver.resolve(targetPubkey, mySecretKey);
38
+ const payload = await resolver.resolve(target, mySecretKey, 'addr', {
39
+ gossip: true, // Follow NIP-65 hints
40
+ strict: true // Reject expired records
41
+ });
30
42
 
31
43
  if (payload) {
32
- console.log('Resolved Endpoints:');
33
- payload.endpoints.forEach(ep => {
34
- console.log(`- ${ep.type}://${ep.uri} (${ep.family})`);
35
- });
44
+ console.log('Resolved Endpoints:', payload.endpoints);
36
45
  }
37
46
  ```
38
47
 
39
- ### Publisher
48
+ ### 2. Targeted Encryption (Friend-to-Friend)
40
49
 
41
- Publish your own service locator record.
50
+ Alice publishes a record that only Bob can decrypt.
42
51
 
43
52
  ```typescript
44
- import { NCC05Publisher, NCC05Payload } from 'ncc-05';
53
+ import { NCC05Publisher } from 'ncc-05';
45
54
 
46
55
  const publisher = new NCC05Publisher();
47
- const mySecretKey = ...;
48
-
49
- const payload: NCC05Payload = {
50
- v: 1,
51
- ttl: 600,
52
- updated_at: Math.floor(Date.now() / 1000),
53
- endpoints: [
54
- {
55
- type: 'tcp',
56
- uri: '127.0.0.1:8080',
57
- priority: 10,
58
- family: 'ipv4'
59
- }
60
- ]
61
- };
62
-
63
- const relays = ['wss://relay.damus.io', 'wss://nos.lol'];
64
- await publisher.publish(relays, mySecretKey, payload);
56
+ const AliceSK = ...;
57
+ const BobPK = "..."; // Bob's hex pubkey
58
+
59
+ await publisher.publish(relays, AliceSK, payload, {
60
+ identifier: 'for-bob',
61
+ recipientPubkey: BobPK // Encrypts specifically for Bob
62
+ });
65
63
  ```
66
64
 
67
- ## Features
65
+ ### 3. Group Wrapping (One Event, Many Recipients)
68
66
 
69
- - **NIP-44 Encryption**: All locator records are encrypted by default.
70
- - **NIP-01/NIP-33**: Uses standard Nostr primitives.
71
- - **Identity-Centric**: Resolution is bound to a cryptographic identity (Pubkey).
72
- - **Tor/Proxy Support**: Easily route relay traffic through SOCKS5 in Node.js.
67
+ Alice shares her endpoint with a list of authorized friends in a single event.
73
68
 
74
- ## Tor & Privacy (Node.js)
69
+ ```typescript
70
+ await publisher.publishWrapped(
71
+ relays,
72
+ AliceSK,
73
+ [BobPK, CharliePK, DavePK],
74
+ payload,
75
+ 'private-group'
76
+ );
77
+
78
+ // Bob resolves it using his own key:
79
+ const payload = await resolver.resolve(AlicePK, BobSK, 'private-group');
80
+ ```
75
81
 
76
- To resolve anonymously through Tor, you can use the `socks-proxy-agent` and a custom `WebSocket` implementation:
82
+ ### 4. Tor & Privacy (Node.js)
83
+
84
+ Route all Nostr relay traffic through a local Tor proxy (`127.0.0.1:9050`).
77
85
 
78
86
  ```typescript
79
- import { NCC05Resolver } from 'ncc-05';
80
87
  import { SocksProxyAgent } from 'socks-proxy-agent';
81
88
  import { WebSocket } from 'ws';
82
89
 
83
- // Create a custom WebSocket class that uses the Tor agent
84
90
  class TorWebSocket extends WebSocket {
85
91
  constructor(address: string, protocols?: string | string[]) {
86
92
  const agent = new SocksProxyAgent('socks5h://127.0.0.1:9050');
@@ -93,6 +99,22 @@ const resolver = new NCC05Resolver({
93
99
  });
94
100
  ```
95
101
 
102
+ ## API Reference
103
+
104
+ ### `NCC05Resolver`
105
+ - `resolve(targetPubkey, secretKey?, identifier?, options?)`: Finds and decrypts a locator record.
106
+ - `close()`: Closes pool connections.
107
+
108
+ ### `NCC05Publisher`
109
+ - `publish(relays, secretKey, payload, options?)`: Publishes a standard or targeted record.
110
+ - `publishWrapped(relays, secretKey, recipients, payload, identifier?)`: Publishes a multi-recipient record.
111
+
112
+ ### `NCC05Group`
113
+ - `createGroupIdentity()`: Generates a shared group keypair.
114
+ - `resolveAsGroup(...)`: Helper for shared-nsec resolution.
115
+
96
116
  ## License
97
117
 
98
- MIT
118
+
119
+
120
+ CC0 1.0 Universal
package/dist/index.d.ts CHANGED
@@ -1,65 +1,161 @@
1
+ /**
2
+ * NCC-05: Identity-Bound Service Locator Resolution
3
+ *
4
+ * This library implements the NCC-05 convention for publishing and resolving
5
+ * dynamic service endpoints (IP, Port, Onion) bound to Nostr identities.
6
+ *
7
+ * @module ncc-05
8
+ */
1
9
  import { Event } from 'nostr-tools';
10
+ /**
11
+ * Represents a single reachable service endpoint.
12
+ */
2
13
  export interface NCC05Endpoint {
14
+ /** Protocol type, e.g., 'tcp', 'udp', 'http' */
3
15
  type: 'tcp' | 'udp' | string;
16
+ /** The URI string, e.g., '1.2.3.4:8080' or '[2001:db8::1]:9000' */
4
17
  uri: string;
18
+ /** Priority for selection (lower is higher priority) */
5
19
  priority: number;
20
+ /** Network family for routing hints */
6
21
  family: 'ipv4' | 'ipv6' | 'onion' | string;
7
22
  }
23
+ /**
24
+ * The logical structure of an NCC-05 locator record payload.
25
+ */
8
26
  export interface NCC05Payload {
27
+ /** Payload version (currently 1) */
9
28
  v: number;
29
+ /** Time-to-live in seconds */
10
30
  ttl: number;
31
+ /** Unix timestamp of the last update */
11
32
  updated_at: number;
33
+ /** List of available endpoints */
12
34
  endpoints: NCC05Endpoint[];
35
+ /** Optional capability identifiers supported by the service */
13
36
  caps?: string[];
37
+ /** Optional human-readable notes */
14
38
  notes?: string;
15
39
  }
40
+ /**
41
+ * Options for configuring the NCC05Resolver.
42
+ */
16
43
  export interface ResolverOptions {
44
+ /** List of relays used to bootstrap discovery */
17
45
  bootstrapRelays?: string[];
46
+ /** Timeout for relay queries in milliseconds (default: 10000) */
18
47
  timeout?: number;
48
+ /** Custom WebSocket implementation (e.g., for Tor/SOCKS5 in Node.js) */
19
49
  websocketImplementation?: any;
20
50
  }
21
51
  /**
22
- * Utility for managing shared group access to NCC-05 records.
52
+ * Structure for multi-recipient encrypted events.
53
+ * Implements a "wrapping" pattern to share one event with multiple keys.
54
+ */
55
+ export interface WrappedContent {
56
+ /** The NCC05Payload encrypted with a random symmetric session key */
57
+ ciphertext: string;
58
+ /** Map of recipient pubkey (hex) to the encrypted session key */
59
+ wraps: Record<string, string>;
60
+ }
61
+ /**
62
+ * Utility for managing shared group access to service records.
23
63
  */
24
64
  export declare class NCC05Group {
25
65
  /**
26
- * Generate a new shared identity for a group.
27
- * The nsec should be shared with all authorized members.
66
+ * Generates a fresh identity (keypair) for a shared group.
67
+ * The resulting nsec should be shared with all authorized group members.
68
+ *
69
+ * @returns An object containing nsec, hex pubkey, and the raw secret key.
28
70
  */
29
71
  static createGroupIdentity(): {
30
72
  nsec: `nsec1${string}`;
31
73
  sk: Uint8Array<ArrayBufferLike>;
32
74
  pk: string;
75
+ npub: `npub1${string}`;
33
76
  };
34
77
  /**
35
- * Resolve a record that was published using a group's shared identity.
78
+ * Helper to resolve a record using a group's shared identity.
79
+ *
80
+ * @param resolver - An initialized NCC05Resolver instance.
81
+ * @param groupPubkey - The public key of the group.
82
+ * @param groupSecretKey - The shared secret key of the group.
83
+ * @param identifier - The 'd' tag of the record (default: 'addr').
84
+ * @returns The resolved NCC05Payload or null.
36
85
  */
37
86
  static resolveAsGroup(resolver: NCC05Resolver, groupPubkey: string, groupSecretKey: Uint8Array, identifier?: string): Promise<NCC05Payload | null>;
38
87
  }
88
+ /**
89
+ * Handles the discovery, selection, and decryption of NCC-05 locator records.
90
+ */
39
91
  export declare class NCC05Resolver {
40
92
  private pool;
41
93
  private bootstrapRelays;
42
94
  private timeout;
95
+ /**
96
+ * @param options - Configuration for the resolver.
97
+ */
43
98
  constructor(options?: ResolverOptions);
44
99
  /**
45
- * Resolve a locator record for a given pubkey.
46
- * Supports both hex and npub strings.
100
+ * Resolves a locator record for a given identity.
101
+ *
102
+ * Supports standard NIP-44 encryption, multi-recipient "wrapping",
103
+ * and plaintext public records.
104
+ *
105
+ * @param targetPubkey - The pubkey (hex or npub) of the service owner.
106
+ * @param secretKey - Your secret key (required if the record is encrypted).
107
+ * @param identifier - The 'd' tag of the record (default: 'addr').
108
+ * @param options - Resolution options (strict mode, gossip discovery).
109
+ * @returns The resolved and validated NCC05Payload, or null if not found/invalid.
47
110
  */
48
- resolve(targetPubkey: string, secretKey: Uint8Array, identifier?: string, options?: {
111
+ resolve(targetPubkey: string, secretKey?: Uint8Array, identifier?: string, options?: {
49
112
  strict?: boolean;
50
113
  gossip?: boolean;
51
114
  }): Promise<NCC05Payload | null>;
115
+ /**
116
+ * Closes connections to all relays in the pool.
117
+ */
52
118
  close(): void;
53
119
  }
120
+ /**
121
+ * Handles the construction, encryption, and publication of NCC-05 events.
122
+ */
54
123
  export declare class NCC05Publisher {
55
124
  private pool;
125
+ /**
126
+ * @param options - Configuration for the publisher.
127
+ */
56
128
  constructor(options?: {
57
129
  websocketImplementation?: any;
58
130
  });
59
131
  /**
60
- * Create and publish a locator record.
61
- * @param recipientPubkey Optional hex pubkey of the recipient. If omitted, self-encrypts.
132
+ * Publishes a single record encrypted for multiple recipients using the wrapping pattern.
133
+ * This avoids sharing a single group private key.
134
+ *
135
+ * @param relays - List of relays to publish to.
136
+ * @param secretKey - The publisher's secret key.
137
+ * @param recipients - List of recipient public keys (hex).
138
+ * @param payload - The service locator payload.
139
+ * @param identifier - The 'd' tag identifier (default: 'addr').
140
+ * @returns The signed Nostr event.
141
+ */
142
+ publishWrapped(relays: string[], secretKey: Uint8Array, recipients: string[], payload: NCC05Payload, identifier?: string): Promise<Event>;
143
+ /**
144
+ * Publishes a locator record. Supports self-encryption, targeted encryption, or plaintext.
145
+ *
146
+ * @param relays - List of relays to publish to.
147
+ * @param secretKey - The publisher's secret key.
148
+ * @param payload - The service locator payload.
149
+ * @param options - Publishing options (identifier, recipient, or public flag).
150
+ * @returns The signed Nostr event.
151
+ */
152
+ publish(relays: string[], secretKey: Uint8Array, payload: NCC05Payload, options?: {
153
+ identifier?: string;
154
+ recipientPubkey?: string;
155
+ public?: boolean;
156
+ }): Promise<Event>;
157
+ /**
158
+ * Closes connections to the specified relays.
62
159
  */
63
- publish(relays: string[], secretKey: Uint8Array, payload: NCC05Payload, identifier?: string, recipientPubkey?: string): Promise<Event>;
64
160
  close(relays: string[]): void;
65
161
  }
package/dist/index.js CHANGED
@@ -1,45 +1,75 @@
1
+ /**
2
+ * NCC-05: Identity-Bound Service Locator Resolution
3
+ *
4
+ * This library implements the NCC-05 convention for publishing and resolving
5
+ * dynamic service endpoints (IP, Port, Onion) bound to Nostr identities.
6
+ *
7
+ * @module ncc-05
8
+ */
1
9
  import { SimplePool, nip44, nip19, finalizeEvent, verifyEvent, getPublicKey, generateSecretKey } from 'nostr-tools';
2
10
  /**
3
- * Utility for managing shared group access to NCC-05 records.
11
+ * Utility for managing shared group access to service records.
4
12
  */
5
13
  export class NCC05Group {
6
14
  /**
7
- * Generate a new shared identity for a group.
8
- * The nsec should be shared with all authorized members.
15
+ * Generates a fresh identity (keypair) for a shared group.
16
+ * The resulting nsec should be shared with all authorized group members.
17
+ *
18
+ * @returns An object containing nsec, hex pubkey, and the raw secret key.
9
19
  */
10
20
  static createGroupIdentity() {
11
21
  const sk = generateSecretKey();
22
+ const pk = getPublicKey(sk);
12
23
  return {
13
24
  nsec: nip19.nsecEncode(sk),
14
25
  sk: sk,
15
- pk: getPublicKey(sk)
26
+ pk: pk,
27
+ npub: nip19.npubEncode(pk)
16
28
  };
17
29
  }
18
30
  /**
19
- * Resolve a record that was published using a group's shared identity.
31
+ * Helper to resolve a record using a group's shared identity.
32
+ *
33
+ * @param resolver - An initialized NCC05Resolver instance.
34
+ * @param groupPubkey - The public key of the group.
35
+ * @param groupSecretKey - The shared secret key of the group.
36
+ * @param identifier - The 'd' tag of the record (default: 'addr').
37
+ * @returns The resolved NCC05Payload or null.
20
38
  */
21
39
  static async resolveAsGroup(resolver, groupPubkey, groupSecretKey, identifier = 'addr') {
22
- // In group mode, we use the group's SK to decrypt a record
23
- // that was self-encrypted by the group's PK.
24
40
  return resolver.resolve(groupPubkey, groupSecretKey, identifier);
25
41
  }
26
42
  }
43
+ /**
44
+ * Handles the discovery, selection, and decryption of NCC-05 locator records.
45
+ */
27
46
  export class NCC05Resolver {
28
47
  pool;
29
48
  bootstrapRelays;
30
49
  timeout;
50
+ /**
51
+ * @param options - Configuration for the resolver.
52
+ */
31
53
  constructor(options = {}) {
32
54
  this.pool = new SimplePool();
33
55
  if (options.websocketImplementation) {
34
- // @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
56
+ // @ts-ignore - Patching pool for custom transport
35
57
  this.pool.websocketImplementation = options.websocketImplementation;
36
58
  }
37
59
  this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
38
60
  this.timeout = options.timeout || 10000;
39
61
  }
40
62
  /**
41
- * Resolve a locator record for a given pubkey.
42
- * Supports both hex and npub strings.
63
+ * Resolves a locator record for a given identity.
64
+ *
65
+ * Supports standard NIP-44 encryption, multi-recipient "wrapping",
66
+ * and plaintext public records.
67
+ *
68
+ * @param targetPubkey - The pubkey (hex or npub) of the service owner.
69
+ * @param secretKey - Your secret key (required if the record is encrypted).
70
+ * @param identifier - The 'd' tag of the record (default: 'addr').
71
+ * @param options - Resolution options (strict mode, gossip discovery).
72
+ * @returns The resolved and validated NCC05Payload, or null if not found/invalid.
43
73
  */
44
74
  async resolve(targetPubkey, secretKey, identifier = 'addr', options = {}) {
45
75
  let hexPubkey = targetPubkey;
@@ -70,79 +100,138 @@ export class NCC05Resolver {
70
100
  limit: 10
71
101
  };
72
102
  const queryPromise = this.pool.querySync(queryRelays, filter);
73
- const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), this.timeout));
103
+ const timeoutPromise = new Promise((r) => setTimeout(() => r(null), this.timeout));
74
104
  const result = await Promise.race([queryPromise, timeoutPromise]);
75
105
  if (!result || (Array.isArray(result) && result.length === 0))
76
106
  return null;
77
- // 2. Filter for valid signatures and sort by created_at
78
107
  const validEvents = result
79
108
  .filter(e => verifyEvent(e))
80
109
  .sort((a, b) => b.created_at - a.created_at);
81
110
  if (validEvents.length === 0)
82
111
  return null;
83
112
  const latestEvent = validEvents[0];
84
- // 2. Decrypt
85
113
  try {
86
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
87
- const decrypted = nip44.decrypt(latestEvent.content, conversationKey);
88
- const payload = JSON.parse(decrypted);
89
- // 3. Basic Validation
90
- if (!payload.endpoints || !Array.isArray(payload.endpoints)) {
91
- console.error('Invalid NCC-05 payload structure');
92
- return null;
114
+ let content = latestEvent.content;
115
+ // Handle "Wrapped" multi-recipient content
116
+ if (content.includes('"wraps"') && secretKey) {
117
+ const wrapped = JSON.parse(content);
118
+ const myPk = getPublicKey(secretKey);
119
+ const myWrap = wrapped.wraps[myPk];
120
+ if (myWrap) {
121
+ const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
122
+ const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
123
+ const symmetricKey = new Uint8Array(symmetricKeyHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
124
+ const sessionConversationKey = nip44.getConversationKey(symmetricKey, getPublicKey(symmetricKey));
125
+ content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
126
+ }
93
127
  }
94
- // 4. Freshness check
128
+ else if (secretKey) {
129
+ // Standard NIP-44
130
+ const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
131
+ content = nip44.decrypt(latestEvent.content, conversationKey);
132
+ }
133
+ const payload = JSON.parse(content);
134
+ if (!payload.endpoints)
135
+ return null;
136
+ // Freshness validation
95
137
  const now = Math.floor(Date.now() / 1000);
96
138
  if (now > payload.updated_at + payload.ttl) {
97
- if (options.strict) {
98
- console.warn('Rejecting expired NCC-05 record (strict mode)');
139
+ if (options.strict)
99
140
  return null;
100
- }
101
141
  console.warn('NCC-05 record has expired');
102
142
  }
103
143
  return payload;
104
144
  }
105
145
  catch (e) {
106
- console.error('Failed to decrypt or parse NCC-05 record:', e);
107
146
  return null;
108
147
  }
109
148
  }
149
+ /**
150
+ * Closes connections to all relays in the pool.
151
+ */
110
152
  close() {
111
153
  this.pool.close(this.bootstrapRelays);
112
154
  }
113
155
  }
156
+ /**
157
+ * Handles the construction, encryption, and publication of NCC-05 events.
158
+ */
114
159
  export class NCC05Publisher {
115
160
  pool;
161
+ /**
162
+ * @param options - Configuration for the publisher.
163
+ */
116
164
  constructor(options = {}) {
117
165
  this.pool = new SimplePool();
118
166
  if (options.websocketImplementation) {
119
- // @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
167
+ // @ts-ignore
120
168
  this.pool.websocketImplementation = options.websocketImplementation;
121
169
  }
122
170
  }
123
171
  /**
124
- * Create and publish a locator record.
125
- * @param recipientPubkey Optional hex pubkey of the recipient. If omitted, self-encrypts.
172
+ * Publishes a single record encrypted for multiple recipients using the wrapping pattern.
173
+ * This avoids sharing a single group private key.
174
+ *
175
+ * @param relays - List of relays to publish to.
176
+ * @param secretKey - The publisher's secret key.
177
+ * @param recipients - List of recipient public keys (hex).
178
+ * @param payload - The service locator payload.
179
+ * @param identifier - The 'd' tag identifier (default: 'addr').
180
+ * @returns The signed Nostr event.
181
+ */
182
+ async publishWrapped(relays, secretKey, recipients, payload, identifier = 'addr') {
183
+ const sessionKey = generateSecretKey();
184
+ const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
185
+ const selfConversation = nip44.getConversationKey(sessionKey, getPublicKey(sessionKey));
186
+ const ciphertext = nip44.encrypt(JSON.stringify(payload), selfConversation);
187
+ const wraps = {};
188
+ for (const rPk of recipients) {
189
+ const conversationKey = nip44.getConversationKey(secretKey, rPk);
190
+ wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
191
+ }
192
+ const wrappedContent = { ciphertext, wraps };
193
+ const eventTemplate = {
194
+ kind: 30058,
195
+ created_at: Math.floor(Date.now() / 1000),
196
+ tags: [['d', identifier]],
197
+ content: JSON.stringify(wrappedContent),
198
+ };
199
+ const signedEvent = finalizeEvent(eventTemplate, secretKey);
200
+ await Promise.all(this.pool.publish(relays, signedEvent));
201
+ return signedEvent;
202
+ }
203
+ /**
204
+ * Publishes a locator record. Supports self-encryption, targeted encryption, or plaintext.
205
+ *
206
+ * @param relays - List of relays to publish to.
207
+ * @param secretKey - The publisher's secret key.
208
+ * @param payload - The service locator payload.
209
+ * @param options - Publishing options (identifier, recipient, or public flag).
210
+ * @returns The signed Nostr event.
126
211
  */
127
- async publish(relays, secretKey, payload, identifier = 'addr', recipientPubkey) {
212
+ async publish(relays, secretKey, payload, options = {}) {
128
213
  const myPubkey = getPublicKey(secretKey);
129
- const encryptionTarget = recipientPubkey || myPubkey;
130
- // 1. Encrypt
131
- const conversationKey = nip44.getConversationKey(secretKey, encryptionTarget);
132
- const encryptedContent = nip44.encrypt(JSON.stringify(payload), conversationKey);
133
- // 2. Create and Finalize Event
214
+ const identifier = options.identifier || 'addr';
215
+ let content = JSON.stringify(payload);
216
+ if (!options.public) {
217
+ const encryptionTarget = options.recipientPubkey || myPubkey;
218
+ const conversationKey = nip44.getConversationKey(secretKey, encryptionTarget);
219
+ content = nip44.encrypt(content, conversationKey);
220
+ }
134
221
  const eventTemplate = {
135
222
  kind: 30058,
136
223
  created_at: Math.floor(Date.now() / 1000),
137
224
  pubkey: myPubkey,
138
225
  tags: [['d', identifier]],
139
- content: encryptedContent,
226
+ content: content,
140
227
  };
141
228
  const signedEvent = finalizeEvent(eventTemplate, secretKey);
142
- // 3. Publish
143
229
  await Promise.all(this.pool.publish(relays, signedEvent));
144
230
  return signedEvent;
145
231
  }
232
+ /**
233
+ * Closes connections to the specified relays.
234
+ */
146
235
  close(relays) {
147
236
  this.pool.close(relays);
148
237
  }
@@ -0,0 +1,6 @@
1
+ export declare class MockRelay {
2
+ private wss;
3
+ private events;
4
+ constructor(port?: number);
5
+ stop(): void;
6
+ }
@@ -0,0 +1,47 @@
1
+ import { WebSocketServer } from 'ws';
2
+ export class MockRelay {
3
+ wss;
4
+ events = [];
5
+ constructor(port = 8080) {
6
+ this.wss = new WebSocketServer({ port });
7
+ this.wss.on('connection', (ws) => {
8
+ ws.on('message', (data) => {
9
+ const msg = JSON.parse(data);
10
+ const type = msg[0];
11
+ if (type === 'EVENT') {
12
+ const event = msg[1];
13
+ // Basic replaceable logic for test consistency
14
+ if (event.kind === 30058 || event.kind === 10002) {
15
+ const dTag = event.tags.find((t) => t[0] === 'd')?.[1] || "";
16
+ this.events = this.events.filter(e => !(e.pubkey === event.pubkey && e.kind === event.kind && (e.tags.find((t) => t[0] === 'd')?.[1] || "") === dTag));
17
+ }
18
+ this.events.push(event);
19
+ ws.send(JSON.stringify(["OK", event.id, true, ""]));
20
+ }
21
+ else if (type === 'REQ') {
22
+ const subId = msg[1];
23
+ const filters = msg[2];
24
+ this.events.forEach(event => {
25
+ let match = true;
26
+ if (filters.authors && !filters.authors.includes(event.pubkey))
27
+ match = false;
28
+ if (filters.kinds && !filters.kinds.includes(event.kind))
29
+ match = false;
30
+ if (filters['#d']) {
31
+ const dTag = event.tags.find((t) => t[0] === 'd')?.[1];
32
+ if (!filters['#d'].includes(dTag))
33
+ match = false;
34
+ }
35
+ if (match) {
36
+ ws.send(JSON.stringify(["EVENT", subId, event]));
37
+ }
38
+ });
39
+ ws.send(JSON.stringify(["EOSE", subId]));
40
+ }
41
+ });
42
+ });
43
+ }
44
+ stop() {
45
+ this.wss.close();
46
+ }
47
+ }
package/dist/test.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { NCC05Publisher, NCC05Resolver, NCC05Group } from './index.js';
2
- import { generateSecretKey, getPublicKey } from 'nostr-tools';
2
+ import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
3
+ import { MockRelay } from './mock-relay.js';
3
4
  async function test() {
5
+ console.log('Starting Mock Relay...');
6
+ const relay = new MockRelay(8080);
7
+ const relays = ['ws://localhost:8080'];
4
8
  const sk = generateSecretKey();
5
9
  const pk = getPublicKey(sk);
6
- const relays = ['wss://relay.damus.io'];
7
10
  const publisher = new NCC05Publisher();
8
11
  const resolver = new NCC05Resolver({ bootstrapRelays: relays });
9
12
  const payload = {
@@ -23,7 +26,8 @@ async function test() {
23
26
  console.log('Successfully resolved:', JSON.stringify(resolved, null, 2));
24
27
  }
25
28
  else {
26
- console.log('Failed to resolve.');
29
+ console.error('FAILED: resolution.');
30
+ process.exit(1);
27
31
  }
28
32
  // Test Strict Mode with Expired Record
29
33
  console.log('Testing expired record in strict mode...');
@@ -33,7 +37,7 @@ async function test() {
33
37
  updated_at: Math.floor(Date.now() / 1000) - 10, // 10s ago
34
38
  endpoints: [{ type: 'tcp', uri: '1.1.1.1:1', priority: 1, family: 'ipv4' }]
35
39
  };
36
- await publisher.publish(relays, sk, expiredPayload, 'expired-test');
40
+ await publisher.publish(relays, sk, expiredPayload, { identifier: 'expired-test' });
37
41
  const strictResult = await resolver.resolve(pk, sk, 'expired-test', { strict: true });
38
42
  if (strictResult === null) {
39
43
  console.log('Correctly rejected expired record in strict mode.');
@@ -44,8 +48,6 @@ async function test() {
44
48
  }
45
49
  // Test Gossip Mode
46
50
  console.log('Testing Gossip discovery...');
47
- // In this test, we just point kind:10002 to the same relay we are using
48
- // to verify the code path executes.
49
51
  const relayListTemplate = {
50
52
  kind: 10002,
51
53
  created_at: Math.floor(Date.now() / 1000),
@@ -53,6 +55,7 @@ async function test() {
53
55
  content: '',
54
56
  };
55
57
  const signedRL = (await import('nostr-tools')).finalizeEvent(relayListTemplate, sk);
58
+ // @ts-ignore
56
59
  await Promise.all(publisher['pool'].publish(relays, signedRL));
57
60
  const gossipResult = await resolver.resolve(pk, sk, 'addr', { gossip: true });
58
61
  if (gossipResult) {
@@ -64,7 +67,7 @@ async function test() {
64
67
  }
65
68
  // Test npub resolution
66
69
  console.log('Testing npub resolution...');
67
- const npub = (await import('nostr-tools')).nip19.npubEncode(pk);
70
+ const npub = nip19.npubEncode(pk);
68
71
  const npubResult = await resolver.resolve(npub, sk);
69
72
  if (npubResult) {
70
73
  console.log('npub resolution successful.');
@@ -85,7 +88,17 @@ async function test() {
85
88
  };
86
89
  // User A publishes for User B
87
90
  console.log('User A publishing for User B...');
88
- await publisher.publish(relays, skA, payloadFriend, 'friend-test', pkB);
91
+ await publisher.publish(relays, skA, payloadFriend, { identifier: 'friend-test', recipientPubkey: pkB });
92
+ // User B resolves User A's record
93
+ console.log('User B resolving User A...');
94
+ const friendResult = await resolver.resolve(pkA, skB, 'friend-test');
95
+ if (friendResult && friendResult.endpoints[0].uri === 'friend:7777') {
96
+ console.log('Friend-to-Friend resolution successful.');
97
+ }
98
+ else {
99
+ console.error('FAILED: Friend-to-Friend resolution.');
100
+ process.exit(1);
101
+ }
89
102
  // Test Group Resolution Utility
90
103
  console.log('Testing NCC05Group utility...');
91
104
  const groupIdentity = NCC05Group.createGroupIdentity();
@@ -94,7 +107,7 @@ async function test() {
94
107
  endpoints: [{ type: 'tcp', uri: 'group-service:8888', priority: 1, family: 'ipv4' }]
95
108
  };
96
109
  console.log('Publishing as Group...');
97
- await publisher.publish(relays, groupIdentity.sk, payloadGroup, 'group-test');
110
+ await publisher.publish(relays, groupIdentity.sk, payloadGroup, { identifier: 'group-test' });
98
111
  console.log('Resolving as Group Member...');
99
112
  const groupResult = await NCC05Group.resolveAsGroup(resolver, groupIdentity.pk, groupIdentity.sk, 'group-test');
100
113
  if (groupResult && groupResult.endpoints[0].uri === 'group-service:8888') {
@@ -104,8 +117,35 @@ async function test() {
104
117
  console.error('FAILED: NCC05Group resolution.');
105
118
  process.exit(1);
106
119
  }
120
+ // Test Group Wrapping (Multi-Recipient)
121
+ console.log('Testing Group Wrapping (Multi-Recipient)...');
122
+ const skAlice = generateSecretKey();
123
+ const pkAlice = getPublicKey(skAlice);
124
+ const skBob = generateSecretKey();
125
+ const pkBob = getPublicKey(skBob);
126
+ const skCharlie = generateSecretKey();
127
+ const pkCharlie = getPublicKey(skCharlie);
128
+ const payloadWrap = {
129
+ v: 1, ttl: 60, updated_at: Math.floor(Date.now() / 1000),
130
+ endpoints: [{ type: 'tcp', uri: 'multi-recipient:9999', priority: 1, family: 'ipv4' }]
131
+ };
132
+ console.log('Alice publishing wrapped record for Bob and Charlie...');
133
+ await publisher.publishWrapped(relays, skAlice, [pkBob, pkCharlie], payloadWrap, 'wrap-test');
134
+ console.log('Bob resolving Alice...');
135
+ const bobResult = await resolver.resolve(pkAlice, skBob, 'wrap-test');
136
+ console.log('Charlie resolving Alice...');
137
+ const charlieResult = await resolver.resolve(pkAlice, skCharlie, 'wrap-test');
138
+ if (bobResult && charlieResult && bobResult.endpoints[0].uri === 'multi-recipient:9999') {
139
+ console.log('Group Wrapping successful! Both recipients resolved Alice.');
140
+ }
141
+ else {
142
+ console.error('FAILED: Group Wrapping resolution.');
143
+ process.exit(1);
144
+ }
107
145
  publisher.close(relays);
108
146
  resolver.close();
147
+ relay.stop();
148
+ console.log('Local Mock Test Suite Passed.');
109
149
  process.exit(0);
110
150
  }
111
151
  test().catch(console.error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ncc-05",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
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",
@@ -17,12 +17,14 @@
17
17
  "privacy"
18
18
  ],
19
19
  "author": "lostcause",
20
- "license": "MIT",
20
+ "license": "CC0-1.0",
21
21
  "dependencies": {
22
22
  "nostr-tools": "^2.10.0"
23
23
  },
24
24
  "devDependencies": {
25
+ "@types/node": "^25.0.3",
26
+ "@types/ws": "^8.18.1",
25
27
  "typescript": "^5.0.0",
26
- "@types/node": "^20.0.0"
28
+ "ws": "^8.18.3"
27
29
  }
28
30
  }
package/src/index.ts CHANGED
@@ -1,3 +1,12 @@
1
+ /**
2
+ * NCC-05: Identity-Bound Service Locator Resolution
3
+ *
4
+ * This library implements the NCC-05 convention for publishing and resolving
5
+ * dynamic service endpoints (IP, Port, Onion) bound to Nostr identities.
6
+ *
7
+ * @module ncc-05
8
+ */
9
+
1
10
  import {
2
11
  SimplePool,
3
12
  nip44,
@@ -9,47 +18,90 @@ import {
9
18
  generateSecretKey
10
19
  } from 'nostr-tools';
11
20
 
21
+ /**
22
+ * Represents a single reachable service endpoint.
23
+ */
12
24
  export interface NCC05Endpoint {
25
+ /** Protocol type, e.g., 'tcp', 'udp', 'http' */
13
26
  type: 'tcp' | 'udp' | string;
27
+ /** The URI string, e.g., '1.2.3.4:8080' or '[2001:db8::1]:9000' */
14
28
  uri: string;
29
+ /** Priority for selection (lower is higher priority) */
15
30
  priority: number;
31
+ /** Network family for routing hints */
16
32
  family: 'ipv4' | 'ipv6' | 'onion' | string;
17
33
  }
18
34
 
35
+ /**
36
+ * The logical structure of an NCC-05 locator record payload.
37
+ */
19
38
  export interface NCC05Payload {
39
+ /** Payload version (currently 1) */
20
40
  v: number;
41
+ /** Time-to-live in seconds */
21
42
  ttl: number;
43
+ /** Unix timestamp of the last update */
22
44
  updated_at: number;
45
+ /** List of available endpoints */
23
46
  endpoints: NCC05Endpoint[];
47
+ /** Optional capability identifiers supported by the service */
24
48
  caps?: string[];
49
+ /** Optional human-readable notes */
25
50
  notes?: string;
26
51
  }
27
52
 
53
+ /**
54
+ * Options for configuring the NCC05Resolver.
55
+ */
28
56
  export interface ResolverOptions {
57
+ /** List of relays used to bootstrap discovery */
29
58
  bootstrapRelays?: string[];
59
+ /** Timeout for relay queries in milliseconds (default: 10000) */
30
60
  timeout?: number;
31
- websocketImplementation?: any; // To support Tor/SOCKS5 proxies in Node.js
61
+ /** Custom WebSocket implementation (e.g., for Tor/SOCKS5 in Node.js) */
62
+ websocketImplementation?: any;
32
63
  }
33
64
 
34
65
  /**
35
- * Utility for managing shared group access to NCC-05 records.
66
+ * Structure for multi-recipient encrypted events.
67
+ * Implements a "wrapping" pattern to share one event with multiple keys.
68
+ */
69
+ export interface WrappedContent {
70
+ /** The NCC05Payload encrypted with a random symmetric session key */
71
+ ciphertext: string;
72
+ /** Map of recipient pubkey (hex) to the encrypted session key */
73
+ wraps: Record<string, string>;
74
+ }
75
+
76
+ /**
77
+ * Utility for managing shared group access to service records.
36
78
  */
37
79
  export class NCC05Group {
38
80
  /**
39
- * Generate a new shared identity for a group.
40
- * The nsec should be shared with all authorized members.
81
+ * Generates a fresh identity (keypair) for a shared group.
82
+ * The resulting nsec should be shared with all authorized group members.
83
+ *
84
+ * @returns An object containing nsec, hex pubkey, and the raw secret key.
41
85
  */
42
86
  static createGroupIdentity() {
43
87
  const sk = generateSecretKey();
88
+ const pk = getPublicKey(sk);
44
89
  return {
45
90
  nsec: nip19.nsecEncode(sk),
46
91
  sk: sk,
47
- pk: getPublicKey(sk)
92
+ pk: pk,
93
+ npub: nip19.npubEncode(pk)
48
94
  };
49
95
  }
50
96
 
51
97
  /**
52
- * Resolve a record that was published using a group's shared identity.
98
+ * Helper to resolve a record using a group's shared identity.
99
+ *
100
+ * @param resolver - An initialized NCC05Resolver instance.
101
+ * @param groupPubkey - The public key of the group.
102
+ * @param groupSecretKey - The shared secret key of the group.
103
+ * @param identifier - The 'd' tag of the record (default: 'addr').
104
+ * @returns The resolved NCC05Payload or null.
53
105
  */
54
106
  static async resolveAsGroup(
55
107
  resolver: NCC05Resolver,
@@ -57,21 +109,25 @@ export class NCC05Group {
57
109
  groupSecretKey: Uint8Array,
58
110
  identifier: string = 'addr'
59
111
  ): Promise<NCC05Payload | null> {
60
- // In group mode, we use the group's SK to decrypt a record
61
- // that was self-encrypted by the group's PK.
62
112
  return resolver.resolve(groupPubkey, groupSecretKey, identifier);
63
113
  }
64
114
  }
65
115
 
116
+ /**
117
+ * Handles the discovery, selection, and decryption of NCC-05 locator records.
118
+ */
66
119
  export class NCC05Resolver {
67
120
  private pool: SimplePool;
68
121
  private bootstrapRelays: string[];
69
122
  private timeout: number;
70
123
 
124
+ /**
125
+ * @param options - Configuration for the resolver.
126
+ */
71
127
  constructor(options: ResolverOptions = {}) {
72
128
  this.pool = new SimplePool();
73
129
  if (options.websocketImplementation) {
74
- // @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
130
+ // @ts-ignore - Patching pool for custom transport
75
131
  this.pool.websocketImplementation = options.websocketImplementation;
76
132
  }
77
133
  this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
@@ -79,12 +135,20 @@ export class NCC05Resolver {
79
135
  }
80
136
 
81
137
  /**
82
- * Resolve a locator record for a given pubkey.
83
- * Supports both hex and npub strings.
138
+ * Resolves a locator record for a given identity.
139
+ *
140
+ * Supports standard NIP-44 encryption, multi-recipient "wrapping",
141
+ * and plaintext public records.
142
+ *
143
+ * @param targetPubkey - The pubkey (hex or npub) of the service owner.
144
+ * @param secretKey - Your secret key (required if the record is encrypted).
145
+ * @param identifier - The 'd' tag of the record (default: 'addr').
146
+ * @param options - Resolution options (strict mode, gossip discovery).
147
+ * @returns The resolved and validated NCC05Payload, or null if not found/invalid.
84
148
  */
85
149
  async resolve(
86
150
  targetPubkey: string,
87
- secretKey: Uint8Array,
151
+ secretKey?: Uint8Array,
88
152
  identifier: string = 'addr',
89
153
  options: { strict?: boolean, gossip?: boolean } = {}
90
154
  ): Promise<NCC05Payload | null> {
@@ -102,7 +166,6 @@ export class NCC05Resolver {
102
166
  authors: [hexPubkey],
103
167
  kinds: [10002]
104
168
  });
105
-
106
169
  if (relayListEvent) {
107
170
  const discoveredRelays = relayListEvent.tags
108
171
  .filter(t => t[0] === 'r')
@@ -121,15 +184,11 @@ export class NCC05Resolver {
121
184
  };
122
185
 
123
186
  const queryPromise = this.pool.querySync(queryRelays, filter);
124
- const timeoutPromise = new Promise<null>((resolve) =>
125
- setTimeout(() => resolve(null), this.timeout)
126
- );
127
-
187
+ const timeoutPromise = new Promise<null>((r) => setTimeout(() => r(null), this.timeout));
128
188
  const result = await Promise.race([queryPromise, timeoutPromise]);
129
189
 
130
190
  if (!result || (Array.isArray(result) && result.length === 0)) return null;
131
191
 
132
- // 2. Filter for valid signatures and sort by created_at
133
192
  const validEvents = (result as Event[])
134
193
  .filter(e => verifyEvent(e))
135
194
  .sort((a, b) => b.created_at - a.created_at);
@@ -137,87 +196,156 @@ export class NCC05Resolver {
137
196
  if (validEvents.length === 0) return null;
138
197
  const latestEvent = validEvents[0];
139
198
 
140
- // 2. Decrypt
141
199
  try {
142
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
143
- const decrypted = nip44.decrypt(latestEvent.content, conversationKey);
144
- const payload = JSON.parse(decrypted) as NCC05Payload;
145
-
146
- // 3. Basic Validation
147
- if (!payload.endpoints || !Array.isArray(payload.endpoints)) {
148
- console.error('Invalid NCC-05 payload structure');
149
- return null;
200
+ let content = latestEvent.content;
201
+
202
+ // Handle "Wrapped" multi-recipient content
203
+ if (content.includes('"wraps"') && secretKey) {
204
+ const wrapped = JSON.parse(content) as WrappedContent;
205
+ const myPk = getPublicKey(secretKey);
206
+ const myWrap = wrapped.wraps[myPk];
207
+
208
+ if (myWrap) {
209
+ const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
210
+ const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
211
+ const symmetricKey = new Uint8Array(symmetricKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
212
+
213
+ const sessionConversationKey = nip44.getConversationKey(symmetricKey, getPublicKey(symmetricKey));
214
+ content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
215
+ }
216
+ } else if (secretKey) {
217
+ // Standard NIP-44
218
+ const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
219
+ content = nip44.decrypt(latestEvent.content, conversationKey);
150
220
  }
151
221
 
152
- // 4. Freshness check
222
+ const payload = JSON.parse(content) as NCC05Payload;
223
+ if (!payload.endpoints) return null;
224
+
225
+ // Freshness validation
153
226
  const now = Math.floor(Date.now() / 1000);
154
227
  if (now > payload.updated_at + payload.ttl) {
155
- if (options.strict) {
156
- console.warn('Rejecting expired NCC-05 record (strict mode)');
157
- return null;
158
- }
228
+ if (options.strict) return null;
159
229
  console.warn('NCC-05 record has expired');
160
230
  }
161
231
 
162
232
  return payload;
163
233
  } catch (e) {
164
- console.error('Failed to decrypt or parse NCC-05 record:', e);
165
234
  return null;
166
235
  }
167
236
  }
168
237
 
238
+ /**
239
+ * Closes connections to all relays in the pool.
240
+ */
169
241
  close() {
170
242
  this.pool.close(this.bootstrapRelays);
171
243
  }
172
244
  }
173
245
 
246
+ /**
247
+ * Handles the construction, encryption, and publication of NCC-05 events.
248
+ */
174
249
  export class NCC05Publisher {
175
250
  private pool: SimplePool;
176
251
 
252
+ /**
253
+ * @param options - Configuration for the publisher.
254
+ */
177
255
  constructor(options: { websocketImplementation?: any } = {}) {
178
256
  this.pool = new SimplePool();
179
257
  if (options.websocketImplementation) {
180
- // @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
258
+ // @ts-ignore
181
259
  this.pool.websocketImplementation = options.websocketImplementation;
182
260
  }
183
261
  }
184
262
 
185
263
  /**
186
- * Create and publish a locator record.
187
- * @param recipientPubkey Optional hex pubkey of the recipient. If omitted, self-encrypts.
264
+ * Publishes a single record encrypted for multiple recipients using the wrapping pattern.
265
+ * This avoids sharing a single group private key.
266
+ *
267
+ * @param relays - List of relays to publish to.
268
+ * @param secretKey - The publisher's secret key.
269
+ * @param recipients - List of recipient public keys (hex).
270
+ * @param payload - The service locator payload.
271
+ * @param identifier - The 'd' tag identifier (default: 'addr').
272
+ * @returns The signed Nostr event.
273
+ */
274
+ async publishWrapped(
275
+ relays: string[],
276
+ secretKey: Uint8Array,
277
+ recipients: string[],
278
+ payload: NCC05Payload,
279
+ identifier: string = 'addr'
280
+ ): Promise<Event> {
281
+ const sessionKey = generateSecretKey();
282
+ const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
283
+
284
+ const selfConversation = nip44.getConversationKey(sessionKey, getPublicKey(sessionKey));
285
+ const ciphertext = nip44.encrypt(JSON.stringify(payload), selfConversation);
286
+
287
+ const wraps: Record<string, string> = {};
288
+ for (const rPk of recipients) {
289
+ const conversationKey = nip44.getConversationKey(secretKey, rPk);
290
+ wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
291
+ }
292
+
293
+ const wrappedContent: WrappedContent = { ciphertext, wraps };
294
+
295
+ const eventTemplate = {
296
+ kind: 30058,
297
+ created_at: Math.floor(Date.now() / 1000),
298
+ tags: [['d', identifier]],
299
+ content: JSON.stringify(wrappedContent),
300
+ };
301
+
302
+ const signedEvent = finalizeEvent(eventTemplate, secretKey);
303
+ await Promise.all(this.pool.publish(relays, signedEvent));
304
+ return signedEvent;
305
+ }
306
+
307
+ /**
308
+ * Publishes a locator record. Supports self-encryption, targeted encryption, or plaintext.
309
+ *
310
+ * @param relays - List of relays to publish to.
311
+ * @param secretKey - The publisher's secret key.
312
+ * @param payload - The service locator payload.
313
+ * @param options - Publishing options (identifier, recipient, or public flag).
314
+ * @returns The signed Nostr event.
188
315
  */
189
316
  async publish(
190
317
  relays: string[],
191
318
  secretKey: Uint8Array,
192
319
  payload: NCC05Payload,
193
- identifier: string = 'addr',
194
- recipientPubkey?: string
320
+ options: { identifier?: string, recipientPubkey?: string, public?: boolean } = {}
195
321
  ): Promise<Event> {
196
322
  const myPubkey = getPublicKey(secretKey);
197
- const encryptionTarget = recipientPubkey || myPubkey;
198
-
199
- // 1. Encrypt
200
- const conversationKey = nip44.getConversationKey(secretKey, encryptionTarget);
201
- const encryptedContent = nip44.encrypt(JSON.stringify(payload), conversationKey);
323
+ const identifier = options.identifier || 'addr';
324
+ let content = JSON.stringify(payload);
325
+
326
+ if (!options.public) {
327
+ const encryptionTarget = options.recipientPubkey || myPubkey;
328
+ const conversationKey = nip44.getConversationKey(secretKey, encryptionTarget);
329
+ content = nip44.encrypt(content, conversationKey);
330
+ }
202
331
 
203
- // 2. Create and Finalize Event
204
332
  const eventTemplate = {
205
333
  kind: 30058,
206
334
  created_at: Math.floor(Date.now() / 1000),
207
335
  pubkey: myPubkey,
208
336
  tags: [['d', identifier]],
209
- content: encryptedContent,
337
+ content: content,
210
338
  };
211
339
 
212
340
  const signedEvent = finalizeEvent(eventTemplate, secretKey);
213
-
214
- // 3. Publish
215
341
  await Promise.all(this.pool.publish(relays, signedEvent));
216
-
217
342
  return signedEvent;
218
343
  }
219
344
 
345
+ /**
346
+ * Closes connections to the specified relays.
347
+ */
220
348
  close(relays: string[]) {
221
349
  this.pool.close(relays);
222
350
  }
223
- }
351
+ }
@@ -0,0 +1,51 @@
1
+ import { WebSocketServer, WebSocket } from 'ws';
2
+
3
+ export class MockRelay {
4
+ private wss: WebSocketServer;
5
+ private events: any[] = [];
6
+
7
+ constructor(port: number = 8080) {
8
+ this.wss = new WebSocketServer({ port });
9
+ this.wss.on('connection', (ws: WebSocket) => {
10
+ ws.on('message', (data: string) => {
11
+ const msg = JSON.parse(data);
12
+ const type = msg[0];
13
+
14
+ if (type === 'EVENT') {
15
+ const event = msg[1];
16
+ // Basic replaceable logic for test consistency
17
+ if (event.kind === 30058 || event.kind === 10002) {
18
+ const dTag = event.tags.find((t: any) => t[0] === 'd')?.[1] || "";
19
+ this.events = this.events.filter(e =>
20
+ !(e.pubkey === event.pubkey && e.kind === event.kind && (e.tags.find((t: any) => t[0] === 'd')?.[1] || "") === dTag)
21
+ );
22
+ }
23
+ this.events.push(event);
24
+ ws.send(JSON.stringify(["OK", event.id, true, ""]));
25
+ } else if (type === 'REQ') {
26
+ const subId = msg[1];
27
+ const filters = msg[2];
28
+
29
+ this.events.forEach(event => {
30
+ let match = true;
31
+ if (filters.authors && !filters.authors.includes(event.pubkey)) match = false;
32
+ if (filters.kinds && !filters.kinds.includes(event.kind)) match = false;
33
+ if (filters['#d']) {
34
+ const dTag = event.tags.find((t: any) => t[0] === 'd')?.[1];
35
+ if (!filters['#d'].includes(dTag)) match = false;
36
+ }
37
+
38
+ if (match) {
39
+ ws.send(JSON.stringify(["EVENT", subId, event]));
40
+ }
41
+ });
42
+ ws.send(JSON.stringify(["EOSE", subId]));
43
+ }
44
+ });
45
+ });
46
+ }
47
+
48
+ stop() {
49
+ this.wss.close();
50
+ }
51
+ }
package/src/test.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  import { NCC05Publisher, NCC05Resolver, NCC05Payload, NCC05Group } from './index.js';
2
- import { generateSecretKey, getPublicKey } from 'nostr-tools';
2
+ import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
3
+ import { MockRelay } from './mock-relay.js';
3
4
 
4
5
  async function test() {
6
+ console.log('Starting Mock Relay...');
7
+ const relay = new MockRelay(8080);
8
+ const relays = ['ws://localhost:8080'];
9
+
5
10
  const sk = generateSecretKey();
6
11
  const pk = getPublicKey(sk);
7
- const relays = ['wss://relay.damus.io'];
8
12
 
9
13
  const publisher = new NCC05Publisher();
10
14
  const resolver = new NCC05Resolver({ bootstrapRelays: relays });
@@ -28,7 +32,8 @@ async function test() {
28
32
  if (resolved) {
29
33
  console.log('Successfully resolved:', JSON.stringify(resolved, null, 2));
30
34
  } else {
31
- console.log('Failed to resolve.');
35
+ console.error('FAILED: resolution.');
36
+ process.exit(1);
32
37
  }
33
38
 
34
39
  // Test Strict Mode with Expired Record
@@ -39,7 +44,7 @@ async function test() {
39
44
  updated_at: Math.floor(Date.now() / 1000) - 10, // 10s ago
40
45
  endpoints: [{ type: 'tcp', uri: '1.1.1.1:1', priority: 1, family: 'ipv4' }]
41
46
  };
42
- await publisher.publish(relays, sk, expiredPayload, 'expired-test');
47
+ await publisher.publish(relays, sk, expiredPayload, { identifier: 'expired-test' });
43
48
  const strictResult = await resolver.resolve(pk, sk, 'expired-test', { strict: true });
44
49
 
45
50
  if (strictResult === null) {
@@ -51,8 +56,6 @@ async function test() {
51
56
 
52
57
  // Test Gossip Mode
53
58
  console.log('Testing Gossip discovery...');
54
- // In this test, we just point kind:10002 to the same relay we are using
55
- // to verify the code path executes.
56
59
  const relayListTemplate = {
57
60
  kind: 10002,
58
61
  created_at: Math.floor(Date.now() / 1000),
@@ -60,6 +63,7 @@ async function test() {
60
63
  content: '',
61
64
  };
62
65
  const signedRL = (await import('nostr-tools')).finalizeEvent(relayListTemplate, sk);
66
+ // @ts-ignore
63
67
  await Promise.all(publisher['pool'].publish(relays, signedRL));
64
68
 
65
69
  const gossipResult = await resolver.resolve(pk, sk, 'addr', { gossip: true });
@@ -72,7 +76,7 @@ async function test() {
72
76
 
73
77
  // Test npub resolution
74
78
  console.log('Testing npub resolution...');
75
- const npub = (await import('nostr-tools')).nip19.npubEncode(pk);
79
+ const npub = nip19.npubEncode(pk);
76
80
  const npubResult = await resolver.resolve(npub, sk);
77
81
  if (npubResult) {
78
82
  console.log('npub resolution successful.');
@@ -95,7 +99,17 @@ async function test() {
95
99
 
96
100
  // User A publishes for User B
97
101
  console.log('User A publishing for User B...');
98
- await publisher.publish(relays, skA, payloadFriend, 'friend-test', pkB);
102
+ await publisher.publish(relays, skA, payloadFriend, { identifier: 'friend-test', recipientPubkey: pkB });
103
+
104
+ // User B resolves User A's record
105
+ console.log('User B resolving User A...');
106
+ const friendResult = await resolver.resolve(pkA, skB, 'friend-test');
107
+ if (friendResult && friendResult.endpoints[0].uri === 'friend:7777') {
108
+ console.log('Friend-to-Friend resolution successful.');
109
+ } else {
110
+ console.error('FAILED: Friend-to-Friend resolution.');
111
+ process.exit(1);
112
+ }
99
113
 
100
114
  // Test Group Resolution Utility
101
115
  console.log('Testing NCC05Group utility...');
@@ -106,7 +120,7 @@ async function test() {
106
120
  };
107
121
 
108
122
  console.log('Publishing as Group...');
109
- await publisher.publish(relays, groupIdentity.sk, payloadGroup, 'group-test');
123
+ await publisher.publish(relays, groupIdentity.sk, payloadGroup, { identifier: 'group-test' });
110
124
 
111
125
  console.log('Resolving as Group Member...');
112
126
  const groupResult = await NCC05Group.resolveAsGroup(resolver, groupIdentity.pk, groupIdentity.sk, 'group-test');
@@ -117,8 +131,40 @@ async function test() {
117
131
  process.exit(1);
118
132
  }
119
133
 
134
+ // Test Group Wrapping (Multi-Recipient)
135
+ console.log('Testing Group Wrapping (Multi-Recipient)...');
136
+ const skAlice = generateSecretKey();
137
+ const pkAlice = getPublicKey(skAlice);
138
+ const skBob = generateSecretKey();
139
+ const pkBob = getPublicKey(skBob);
140
+ const skCharlie = generateSecretKey();
141
+ const pkCharlie = getPublicKey(skCharlie);
142
+
143
+ const payloadWrap: NCC05Payload = {
144
+ v: 1, ttl: 60, updated_at: Math.floor(Date.now() / 1000),
145
+ endpoints: [{ type: 'tcp', uri: 'multi-recipient:9999', priority: 1, family: 'ipv4' }]
146
+ };
147
+
148
+ console.log('Alice publishing wrapped record for Bob and Charlie...');
149
+ await publisher.publishWrapped(relays, skAlice, [pkBob, pkCharlie], payloadWrap, 'wrap-test');
150
+
151
+ console.log('Bob resolving Alice...');
152
+ const bobResult = await resolver.resolve(pkAlice, skBob, 'wrap-test');
153
+
154
+ console.log('Charlie resolving Alice...');
155
+ const charlieResult = await resolver.resolve(pkAlice, skCharlie, 'wrap-test');
156
+
157
+ if (bobResult && charlieResult && bobResult.endpoints[0].uri === 'multi-recipient:9999') {
158
+ console.log('Group Wrapping successful! Both recipients resolved Alice.');
159
+ } else {
160
+ console.error('FAILED: Group Wrapping resolution.');
161
+ process.exit(1);
162
+ }
163
+
120
164
  publisher.close(relays);
121
165
  resolver.close();
166
+ relay.stop();
167
+ console.log('Local Mock Test Suite Passed.');
122
168
  process.exit(0);
123
169
  }
124
170