ncc-05 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -19,17 +19,20 @@ export interface ResolverOptions {
19
19
  websocketImplementation?: any;
20
20
  }
21
21
  /**
22
- * Utility for managing shared group access to NCC-05 records.
22
+ * Advanced multi-recipient "wrapping" structure.
23
23
  */
24
+ export interface WrappedContent {
25
+ /** The actual payload encrypted with a random symmetric key */
26
+ ciphertext: string;
27
+ /** Map of recipient pubkey -> wrapped symmetric key */
28
+ wraps: Record<string, string>;
29
+ }
24
30
  export declare class NCC05Group {
25
- /**
26
- * Generate a new shared identity for a group.
27
- * The nsec should be shared with all authorized members.
28
- */
29
31
  static createGroupIdentity(): {
30
32
  nsec: `nsec1${string}`;
31
33
  sk: Uint8Array<ArrayBufferLike>;
32
34
  pk: string;
35
+ npub: `npub1${string}`;
33
36
  };
34
37
  /**
35
38
  * Resolve a record that was published using a group's shared identity.
@@ -41,11 +44,7 @@ export declare class NCC05Resolver {
41
44
  private bootstrapRelays;
42
45
  private timeout;
43
46
  constructor(options?: ResolverOptions);
44
- /**
45
- * Resolve a locator record for a given pubkey.
46
- * Supports both hex and npub strings.
47
- */
48
- resolve(targetPubkey: string, secretKey: Uint8Array, identifier?: string, options?: {
47
+ resolve(targetPubkey: string, secretKey?: Uint8Array, identifier?: string, options?: {
49
48
  strict?: boolean;
50
49
  gossip?: boolean;
51
50
  }): Promise<NCC05Payload | null>;
@@ -56,10 +55,11 @@ export declare class NCC05Publisher {
56
55
  constructor(options?: {
57
56
  websocketImplementation?: any;
58
57
  });
59
- /**
60
- * Create and publish a locator record.
61
- * @param recipientPubkey Optional hex pubkey of the recipient. If omitted, self-encrypts.
62
- */
63
- publish(relays: string[], secretKey: Uint8Array, payload: NCC05Payload, identifier?: string, recipientPubkey?: string): Promise<Event>;
58
+ publishWrapped(relays: string[], secretKey: Uint8Array, recipients: string[], payload: NCC05Payload, identifier?: string): Promise<Event>;
59
+ publish(relays: string[], secretKey: Uint8Array, payload: NCC05Payload, options?: {
60
+ identifier?: string;
61
+ recipientPubkey?: string;
62
+ public?: boolean;
63
+ }): Promise<Event>;
64
64
  close(relays: string[]): void;
65
65
  }
package/dist/index.js CHANGED
@@ -1,26 +1,19 @@
1
1
  import { SimplePool, nip44, nip19, finalizeEvent, verifyEvent, getPublicKey, generateSecretKey } from 'nostr-tools';
2
- /**
3
- * Utility for managing shared group access to NCC-05 records.
4
- */
5
2
  export class NCC05Group {
6
- /**
7
- * Generate a new shared identity for a group.
8
- * The nsec should be shared with all authorized members.
9
- */
10
3
  static createGroupIdentity() {
11
4
  const sk = generateSecretKey();
5
+ const pk = getPublicKey(sk);
12
6
  return {
13
7
  nsec: nip19.nsecEncode(sk),
14
8
  sk: sk,
15
- pk: getPublicKey(sk)
9
+ pk: pk,
10
+ npub: nip19.npubEncode(pk)
16
11
  };
17
12
  }
18
13
  /**
19
14
  * Resolve a record that was published using a group's shared identity.
20
15
  */
21
16
  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
17
  return resolver.resolve(groupPubkey, groupSecretKey, identifier);
25
18
  }
26
19
  }
@@ -31,16 +24,12 @@ export class NCC05Resolver {
31
24
  constructor(options = {}) {
32
25
  this.pool = new SimplePool();
33
26
  if (options.websocketImplementation) {
34
- // @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
27
+ // @ts-ignore
35
28
  this.pool.websocketImplementation = options.websocketImplementation;
36
29
  }
37
30
  this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
38
31
  this.timeout = options.timeout || 10000;
39
32
  }
40
- /**
41
- * Resolve a locator record for a given pubkey.
42
- * Supports both hex and npub strings.
43
- */
44
33
  async resolve(targetPubkey, secretKey, identifier = 'addr', options = {}) {
45
34
  let hexPubkey = targetPubkey;
46
35
  if (targetPubkey.startsWith('npub1')) {
@@ -48,7 +37,6 @@ export class NCC05Resolver {
48
37
  hexPubkey = decoded.data;
49
38
  }
50
39
  let queryRelays = [...this.bootstrapRelays];
51
- // 1. NIP-65 Gossip Discovery
52
40
  if (options.gossip) {
53
41
  const relayListEvent = await this.pool.get(this.bootstrapRelays, {
54
42
  authors: [hexPubkey],
@@ -58,9 +46,7 @@ export class NCC05Resolver {
58
46
  const discoveredRelays = relayListEvent.tags
59
47
  .filter(t => t[0] === 'r')
60
48
  .map(t => t[1]);
61
- if (discoveredRelays.length > 0) {
62
- queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
63
- }
49
+ queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
64
50
  }
65
51
  }
66
52
  const filter = {
@@ -70,80 +56,103 @@ export class NCC05Resolver {
70
56
  limit: 10
71
57
  };
72
58
  const queryPromise = this.pool.querySync(queryRelays, filter);
73
- const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), this.timeout));
59
+ const timeoutPromise = new Promise((r) => setTimeout(() => r(null), this.timeout));
74
60
  const result = await Promise.race([queryPromise, timeoutPromise]);
75
61
  if (!result || (Array.isArray(result) && result.length === 0))
76
62
  return null;
77
- // 2. Filter for valid signatures and sort by created_at
78
63
  const validEvents = result
79
64
  .filter(e => verifyEvent(e))
80
65
  .sort((a, b) => b.created_at - a.created_at);
81
66
  if (validEvents.length === 0)
82
67
  return null;
83
68
  const latestEvent = validEvents[0];
84
- // 2. Decrypt
85
69
  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;
70
+ let content = latestEvent.content;
71
+ // 1. Try to detect if it's a "Wrapped" multi-recipient event
72
+ if (content.includes('"wraps"') && secretKey) {
73
+ const wrapped = JSON.parse(content);
74
+ const myPk = getPublicKey(secretKey);
75
+ const myWrap = wrapped.wraps[myPk];
76
+ if (myWrap) {
77
+ const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
78
+ const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
79
+ // Convert hex symmetric key back to Uint8Array for NIP-44 decryption
80
+ const symmetricKey = new Uint8Array(symmetricKeyHex.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
81
+ // The payload was self-encrypted by the publisher with the session key
82
+ const sessionConversationKey = nip44.getConversationKey(symmetricKey, getPublicKey(symmetricKey));
83
+ content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
84
+ }
93
85
  }
94
- // 4. Freshness check
86
+ else if (secretKey) {
87
+ const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
88
+ content = nip44.decrypt(latestEvent.content, conversationKey);
89
+ }
90
+ const payload = JSON.parse(content);
91
+ if (!payload.endpoints)
92
+ return null;
95
93
  const now = Math.floor(Date.now() / 1000);
96
94
  if (now > payload.updated_at + payload.ttl) {
97
- if (options.strict) {
98
- console.warn('Rejecting expired NCC-05 record (strict mode)');
95
+ if (options.strict)
99
96
  return null;
100
- }
101
- console.warn('NCC-05 record has expired');
97
+ console.warn('NCC-05 record expired');
102
98
  }
103
99
  return payload;
104
100
  }
105
101
  catch (e) {
106
- console.error('Failed to decrypt or parse NCC-05 record:', e);
107
102
  return null;
108
103
  }
109
104
  }
110
- close() {
111
- this.pool.close(this.bootstrapRelays);
112
- }
105
+ close() { this.pool.close(this.bootstrapRelays); }
113
106
  }
114
107
  export class NCC05Publisher {
115
108
  pool;
116
109
  constructor(options = {}) {
117
110
  this.pool = new SimplePool();
118
111
  if (options.websocketImplementation) {
119
- // @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
112
+ // @ts-ignore
120
113
  this.pool.websocketImplementation = options.websocketImplementation;
121
114
  }
122
115
  }
123
- /**
124
- * Create and publish a locator record.
125
- * @param recipientPubkey Optional hex pubkey of the recipient. If omitted, self-encrypts.
126
- */
127
- async publish(relays, secretKey, payload, identifier = 'addr', recipientPubkey) {
116
+ async publishWrapped(relays, secretKey, recipients, payload, identifier = 'addr') {
117
+ const sessionKey = generateSecretKey();
118
+ const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
119
+ const selfConversation = nip44.getConversationKey(sessionKey, getPublicKey(sessionKey));
120
+ const ciphertext = nip44.encrypt(JSON.stringify(payload), selfConversation);
121
+ const wraps = {};
122
+ for (const rPk of recipients) {
123
+ const conversationKey = nip44.getConversationKey(secretKey, rPk);
124
+ wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
125
+ }
126
+ const wrappedContent = { ciphertext, wraps };
127
+ const eventTemplate = {
128
+ kind: 30058,
129
+ created_at: Math.floor(Date.now() / 1000),
130
+ tags: [['d', identifier]],
131
+ content: JSON.stringify(wrappedContent),
132
+ };
133
+ const signedEvent = finalizeEvent(eventTemplate, secretKey);
134
+ await Promise.all(this.pool.publish(relays, signedEvent));
135
+ return signedEvent;
136
+ }
137
+ async publish(relays, secretKey, payload, options = {}) {
128
138
  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
139
+ const identifier = options.identifier || 'addr';
140
+ let content = JSON.stringify(payload);
141
+ if (!options.public) {
142
+ const encryptionTarget = options.recipientPubkey || myPubkey;
143
+ const conversationKey = nip44.getConversationKey(secretKey, encryptionTarget);
144
+ content = nip44.encrypt(content, conversationKey);
145
+ }
134
146
  const eventTemplate = {
135
147
  kind: 30058,
136
148
  created_at: Math.floor(Date.now() / 1000),
137
149
  pubkey: myPubkey,
138
150
  tags: [['d', identifier]],
139
- content: encryptedContent,
151
+ content: content,
140
152
  };
141
153
  const signedEvent = finalizeEvent(eventTemplate, secretKey);
142
- // 3. Publish
143
154
  await Promise.all(this.pool.publish(relays, signedEvent));
144
155
  return signedEvent;
145
156
  }
146
- close(relays) {
147
- this.pool.close(relays);
148
- }
157
+ close(relays) { this.pool.close(relays); }
149
158
  }
@@ -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.1",
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",
@@ -22,7 +22,9 @@
22
22
  "nostr-tools": "^2.10.0"
23
23
  },
24
24
  "devDependencies": {
25
+ "@types/node": "^20.0.0",
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
@@ -28,23 +28,28 @@ export interface NCC05Payload {
28
28
  export interface ResolverOptions {
29
29
  bootstrapRelays?: string[];
30
30
  timeout?: number;
31
- websocketImplementation?: any; // To support Tor/SOCKS5 proxies in Node.js
31
+ websocketImplementation?: any;
32
32
  }
33
33
 
34
34
  /**
35
- * Utility for managing shared group access to NCC-05 records.
35
+ * Advanced multi-recipient "wrapping" structure.
36
36
  */
37
+ export interface WrappedContent {
38
+ /** The actual payload encrypted with a random symmetric key */
39
+ ciphertext: string;
40
+ /** Map of recipient pubkey -> wrapped symmetric key */
41
+ wraps: Record<string, string>;
42
+ }
43
+
37
44
  export class NCC05Group {
38
- /**
39
- * Generate a new shared identity for a group.
40
- * The nsec should be shared with all authorized members.
41
- */
42
45
  static createGroupIdentity() {
43
46
  const sk = generateSecretKey();
47
+ const pk = getPublicKey(sk);
44
48
  return {
45
49
  nsec: nip19.nsecEncode(sk),
46
50
  sk: sk,
47
- pk: getPublicKey(sk)
51
+ pk: pk,
52
+ npub: nip19.npubEncode(pk)
48
53
  };
49
54
  }
50
55
 
@@ -57,8 +62,6 @@ export class NCC05Group {
57
62
  groupSecretKey: Uint8Array,
58
63
  identifier: string = 'addr'
59
64
  ): 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
65
  return resolver.resolve(groupPubkey, groupSecretKey, identifier);
63
66
  }
64
67
  }
@@ -71,20 +74,16 @@ export class NCC05Resolver {
71
74
  constructor(options: ResolverOptions = {}) {
72
75
  this.pool = new SimplePool();
73
76
  if (options.websocketImplementation) {
74
- // @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
77
+ // @ts-ignore
75
78
  this.pool.websocketImplementation = options.websocketImplementation;
76
79
  }
77
80
  this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
78
81
  this.timeout = options.timeout || 10000;
79
82
  }
80
83
 
81
- /**
82
- * Resolve a locator record for a given pubkey.
83
- * Supports both hex and npub strings.
84
- */
85
84
  async resolve(
86
85
  targetPubkey: string,
87
- secretKey: Uint8Array,
86
+ secretKey?: Uint8Array,
88
87
  identifier: string = 'addr',
89
88
  options: { strict?: boolean, gossip?: boolean } = {}
90
89
  ): Promise<NCC05Payload | null> {
@@ -96,20 +95,16 @@ export class NCC05Resolver {
96
95
 
97
96
  let queryRelays = [...this.bootstrapRelays];
98
97
 
99
- // 1. NIP-65 Gossip Discovery
100
98
  if (options.gossip) {
101
99
  const relayListEvent = await this.pool.get(this.bootstrapRelays, {
102
100
  authors: [hexPubkey],
103
101
  kinds: [10002]
104
102
  });
105
-
106
103
  if (relayListEvent) {
107
104
  const discoveredRelays = relayListEvent.tags
108
105
  .filter(t => t[0] === 'r')
109
106
  .map(t => t[1]);
110
- if (discoveredRelays.length > 0) {
111
- queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
112
- }
107
+ queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
113
108
  }
114
109
  }
115
110
 
@@ -121,15 +116,11 @@ export class NCC05Resolver {
121
116
  };
122
117
 
123
118
  const queryPromise = this.pool.querySync(queryRelays, filter);
124
- const timeoutPromise = new Promise<null>((resolve) =>
125
- setTimeout(() => resolve(null), this.timeout)
126
- );
127
-
119
+ const timeoutPromise = new Promise<null>((r) => setTimeout(() => r(null), this.timeout));
128
120
  const result = await Promise.race([queryPromise, timeoutPromise]);
129
121
 
130
122
  if (!result || (Array.isArray(result) && result.length === 0)) return null;
131
123
 
132
- // 2. Filter for valid signatures and sort by created_at
133
124
  const validEvents = (result as Event[])
134
125
  .filter(e => verifyEvent(e))
135
126
  .sort((a, b) => b.created_at - a.created_at);
@@ -137,38 +128,47 @@ export class NCC05Resolver {
137
128
  if (validEvents.length === 0) return null;
138
129
  const latestEvent = validEvents[0];
139
130
 
140
- // 2. Decrypt
141
131
  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;
132
+ let content = latestEvent.content;
133
+
134
+ // 1. Try to detect if it's a "Wrapped" multi-recipient event
135
+ if (content.includes('"wraps"') && secretKey) {
136
+ const wrapped = JSON.parse(content) as WrappedContent;
137
+ const myPk = getPublicKey(secretKey);
138
+ const myWrap = wrapped.wraps[myPk];
139
+
140
+ if (myWrap) {
141
+ const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
142
+ const symmetricKeyHex = nip44.decrypt(myWrap, conversationKey);
143
+
144
+ // Convert hex symmetric key back to Uint8Array for NIP-44 decryption
145
+ const symmetricKey = new Uint8Array(symmetricKeyHex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
146
+
147
+ // The payload was self-encrypted by the publisher with the session key
148
+ const sessionConversationKey = nip44.getConversationKey(symmetricKey, getPublicKey(symmetricKey));
149
+ content = nip44.decrypt(wrapped.ciphertext, sessionConversationKey);
150
+ }
151
+ } else if (secretKey) {
152
+ const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
153
+ content = nip44.decrypt(latestEvent.content, conversationKey);
150
154
  }
151
155
 
152
- // 4. Freshness check
156
+ const payload = JSON.parse(content) as NCC05Payload;
157
+ if (!payload.endpoints) return null;
158
+
153
159
  const now = Math.floor(Date.now() / 1000);
154
160
  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
- }
159
- console.warn('NCC-05 record has expired');
161
+ if (options.strict) return null;
162
+ console.warn('NCC-05 record expired');
160
163
  }
161
164
 
162
165
  return payload;
163
166
  } catch (e) {
164
- console.error('Failed to decrypt or parse NCC-05 record:', e);
165
167
  return null;
166
168
  }
167
169
  }
168
170
 
169
- close() {
170
- this.pool.close(this.bootstrapRelays);
171
- }
171
+ close() { this.pool.close(this.bootstrapRelays); }
172
172
  }
173
173
 
174
174
  export class NCC05Publisher {
@@ -177,47 +177,72 @@ export class NCC05Publisher {
177
177
  constructor(options: { websocketImplementation?: any } = {}) {
178
178
  this.pool = new SimplePool();
179
179
  if (options.websocketImplementation) {
180
- // @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
180
+ // @ts-ignore
181
181
  this.pool.websocketImplementation = options.websocketImplementation;
182
182
  }
183
183
  }
184
184
 
185
- /**
186
- * Create and publish a locator record.
187
- * @param recipientPubkey Optional hex pubkey of the recipient. If omitted, self-encrypts.
188
- */
185
+ async publishWrapped(
186
+ relays: string[],
187
+ secretKey: Uint8Array,
188
+ recipients: string[],
189
+ payload: NCC05Payload,
190
+ identifier: string = 'addr'
191
+ ): Promise<Event> {
192
+ const sessionKey = generateSecretKey();
193
+ const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
194
+
195
+ const selfConversation = nip44.getConversationKey(sessionKey, getPublicKey(sessionKey));
196
+ const ciphertext = nip44.encrypt(JSON.stringify(payload), selfConversation);
197
+
198
+ const wraps: Record<string, string> = {};
199
+ for (const rPk of recipients) {
200
+ const conversationKey = nip44.getConversationKey(secretKey, rPk);
201
+ wraps[rPk] = nip44.encrypt(sessionKeyHex, conversationKey);
202
+ }
203
+
204
+ const wrappedContent: WrappedContent = { ciphertext, wraps };
205
+
206
+ const eventTemplate = {
207
+ kind: 30058,
208
+ created_at: Math.floor(Date.now() / 1000),
209
+ tags: [['d', identifier]],
210
+ content: JSON.stringify(wrappedContent),
211
+ };
212
+
213
+ const signedEvent = finalizeEvent(eventTemplate, secretKey);
214
+ await Promise.all(this.pool.publish(relays, signedEvent));
215
+ return signedEvent;
216
+ }
217
+
189
218
  async publish(
190
219
  relays: string[],
191
220
  secretKey: Uint8Array,
192
221
  payload: NCC05Payload,
193
- identifier: string = 'addr',
194
- recipientPubkey?: string
222
+ options: { identifier?: string, recipientPubkey?: string, public?: boolean } = {}
195
223
  ): Promise<Event> {
196
224
  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);
225
+ const identifier = options.identifier || 'addr';
226
+ let content = JSON.stringify(payload);
227
+
228
+ if (!options.public) {
229
+ const encryptionTarget = options.recipientPubkey || myPubkey;
230
+ const conversationKey = nip44.getConversationKey(secretKey, encryptionTarget);
231
+ content = nip44.encrypt(content, conversationKey);
232
+ }
202
233
 
203
- // 2. Create and Finalize Event
204
234
  const eventTemplate = {
205
235
  kind: 30058,
206
236
  created_at: Math.floor(Date.now() / 1000),
207
237
  pubkey: myPubkey,
208
238
  tags: [['d', identifier]],
209
- content: encryptedContent,
239
+ content: content,
210
240
  };
211
241
 
212
242
  const signedEvent = finalizeEvent(eventTemplate, secretKey);
213
-
214
- // 3. Publish
215
243
  await Promise.all(this.pool.publish(relays, signedEvent));
216
-
217
244
  return signedEvent;
218
245
  }
219
246
 
220
- close(relays: string[]) {
221
- this.pool.close(relays);
222
- }
247
+ close(relays: string[]) { this.pool.close(relays); }
223
248
  }
@@ -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