ncc-05 1.0.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
@@ -18,16 +18,33 @@ export interface ResolverOptions {
18
18
  timeout?: number;
19
19
  websocketImplementation?: any;
20
20
  }
21
+ /**
22
+ * Advanced multi-recipient "wrapping" structure.
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
+ }
30
+ export declare class NCC05Group {
31
+ static createGroupIdentity(): {
32
+ nsec: `nsec1${string}`;
33
+ sk: Uint8Array<ArrayBufferLike>;
34
+ pk: string;
35
+ npub: `npub1${string}`;
36
+ };
37
+ /**
38
+ * Resolve a record that was published using a group's shared identity.
39
+ */
40
+ static resolveAsGroup(resolver: NCC05Resolver, groupPubkey: string, groupSecretKey: Uint8Array, identifier?: string): Promise<NCC05Payload | null>;
41
+ }
21
42
  export declare class NCC05Resolver {
22
43
  private pool;
23
44
  private bootstrapRelays;
24
45
  private timeout;
25
46
  constructor(options?: ResolverOptions);
26
- /**
27
- * Resolve a locator record for a given pubkey.
28
- * Supports both hex and npub strings.
29
- */
30
- resolve(targetPubkey: string, secretKey: Uint8Array, identifier?: string, options?: {
47
+ resolve(targetPubkey: string, secretKey?: Uint8Array, identifier?: string, options?: {
31
48
  strict?: boolean;
32
49
  gossip?: boolean;
33
50
  }): Promise<NCC05Payload | null>;
@@ -38,9 +55,11 @@ export declare class NCC05Publisher {
38
55
  constructor(options?: {
39
56
  websocketImplementation?: any;
40
57
  });
41
- /**
42
- * Create and publish a locator record.
43
- */
44
- publish(relays: string[], secretKey: Uint8Array, payload: NCC05Payload, identifier?: 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>;
45
64
  close(relays: string[]): void;
46
65
  }
package/dist/index.js CHANGED
@@ -1,4 +1,22 @@
1
- import { SimplePool, nip44, nip19, finalizeEvent, verifyEvent, getPublicKey } from 'nostr-tools';
1
+ import { SimplePool, nip44, nip19, finalizeEvent, verifyEvent, getPublicKey, generateSecretKey } from 'nostr-tools';
2
+ export class NCC05Group {
3
+ static createGroupIdentity() {
4
+ const sk = generateSecretKey();
5
+ const pk = getPublicKey(sk);
6
+ return {
7
+ nsec: nip19.nsecEncode(sk),
8
+ sk: sk,
9
+ pk: pk,
10
+ npub: nip19.npubEncode(pk)
11
+ };
12
+ }
13
+ /**
14
+ * Resolve a record that was published using a group's shared identity.
15
+ */
16
+ static async resolveAsGroup(resolver, groupPubkey, groupSecretKey, identifier = 'addr') {
17
+ return resolver.resolve(groupPubkey, groupSecretKey, identifier);
18
+ }
19
+ }
2
20
  export class NCC05Resolver {
3
21
  pool;
4
22
  bootstrapRelays;
@@ -6,16 +24,12 @@ export class NCC05Resolver {
6
24
  constructor(options = {}) {
7
25
  this.pool = new SimplePool();
8
26
  if (options.websocketImplementation) {
9
- // @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
27
+ // @ts-ignore
10
28
  this.pool.websocketImplementation = options.websocketImplementation;
11
29
  }
12
30
  this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
13
31
  this.timeout = options.timeout || 10000;
14
32
  }
15
- /**
16
- * Resolve a locator record for a given pubkey.
17
- * Supports both hex and npub strings.
18
- */
19
33
  async resolve(targetPubkey, secretKey, identifier = 'addr', options = {}) {
20
34
  let hexPubkey = targetPubkey;
21
35
  if (targetPubkey.startsWith('npub1')) {
@@ -23,7 +37,6 @@ export class NCC05Resolver {
23
37
  hexPubkey = decoded.data;
24
38
  }
25
39
  let queryRelays = [...this.bootstrapRelays];
26
- // 1. NIP-65 Gossip Discovery
27
40
  if (options.gossip) {
28
41
  const relayListEvent = await this.pool.get(this.bootstrapRelays, {
29
42
  authors: [hexPubkey],
@@ -33,9 +46,7 @@ export class NCC05Resolver {
33
46
  const discoveredRelays = relayListEvent.tags
34
47
  .filter(t => t[0] === 'r')
35
48
  .map(t => t[1]);
36
- if (discoveredRelays.length > 0) {
37
- queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
38
- }
49
+ queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
39
50
  }
40
51
  }
41
52
  const filter = {
@@ -45,77 +56,103 @@ export class NCC05Resolver {
45
56
  limit: 10
46
57
  };
47
58
  const queryPromise = this.pool.querySync(queryRelays, filter);
48
- const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), this.timeout));
59
+ const timeoutPromise = new Promise((r) => setTimeout(() => r(null), this.timeout));
49
60
  const result = await Promise.race([queryPromise, timeoutPromise]);
50
61
  if (!result || (Array.isArray(result) && result.length === 0))
51
62
  return null;
52
- // 2. Filter for valid signatures and sort by created_at
53
63
  const validEvents = result
54
64
  .filter(e => verifyEvent(e))
55
65
  .sort((a, b) => b.created_at - a.created_at);
56
66
  if (validEvents.length === 0)
57
67
  return null;
58
68
  const latestEvent = validEvents[0];
59
- // 2. Decrypt
60
69
  try {
61
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
62
- const decrypted = nip44.decrypt(latestEvent.content, conversationKey);
63
- const payload = JSON.parse(decrypted);
64
- // 3. Basic Validation
65
- if (!payload.endpoints || !Array.isArray(payload.endpoints)) {
66
- console.error('Invalid NCC-05 payload structure');
67
- 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
+ }
68
85
  }
69
- // 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;
70
93
  const now = Math.floor(Date.now() / 1000);
71
94
  if (now > payload.updated_at + payload.ttl) {
72
- if (options.strict) {
73
- console.warn('Rejecting expired NCC-05 record (strict mode)');
95
+ if (options.strict)
74
96
  return null;
75
- }
76
- console.warn('NCC-05 record has expired');
97
+ console.warn('NCC-05 record expired');
77
98
  }
78
99
  return payload;
79
100
  }
80
101
  catch (e) {
81
- console.error('Failed to decrypt or parse NCC-05 record:', e);
82
102
  return null;
83
103
  }
84
104
  }
85
- close() {
86
- this.pool.close(this.bootstrapRelays);
87
- }
105
+ close() { this.pool.close(this.bootstrapRelays); }
88
106
  }
89
107
  export class NCC05Publisher {
90
108
  pool;
91
109
  constructor(options = {}) {
92
110
  this.pool = new SimplePool();
93
111
  if (options.websocketImplementation) {
94
- // @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
112
+ // @ts-ignore
95
113
  this.pool.websocketImplementation = options.websocketImplementation;
96
114
  }
97
115
  }
98
- /**
99
- * Create and publish a locator record.
100
- */
101
- async publish(relays, secretKey, payload, identifier = 'addr') {
102
- const pubkey = getPublicKey(secretKey);
103
- // 1. Encrypt
104
- const conversationKey = nip44.getConversationKey(secretKey, pubkey);
105
- const encryptedContent = nip44.encrypt(JSON.stringify(payload), conversationKey);
106
- // 2. Create and Finalize Event
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 };
107
127
  const eventTemplate = {
108
128
  kind: 30058,
109
129
  created_at: Math.floor(Date.now() / 1000),
110
130
  tags: [['d', identifier]],
111
- content: encryptedContent,
131
+ content: JSON.stringify(wrappedContent),
112
132
  };
113
133
  const signedEvent = finalizeEvent(eventTemplate, secretKey);
114
- // 3. Publish
115
134
  await Promise.all(this.pool.publish(relays, signedEvent));
116
135
  return signedEvent;
117
136
  }
118
- close(relays) {
119
- this.pool.close(relays);
137
+ async publish(relays, secretKey, payload, options = {}) {
138
+ const myPubkey = getPublicKey(secretKey);
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
+ }
146
+ const eventTemplate = {
147
+ kind: 30058,
148
+ created_at: Math.floor(Date.now() / 1000),
149
+ pubkey: myPubkey,
150
+ tags: [['d', identifier]],
151
+ content: content,
152
+ };
153
+ const signedEvent = finalizeEvent(eventTemplate, secretKey);
154
+ await Promise.all(this.pool.publish(relays, signedEvent));
155
+ return signedEvent;
120
156
  }
157
+ close(relays) { this.pool.close(relays); }
121
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
- import { NCC05Publisher, NCC05Resolver } from './index.js';
2
- import { generateSecretKey, getPublicKey } from 'nostr-tools';
1
+ import { NCC05Publisher, NCC05Resolver, NCC05Group } from './index.js';
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.');
@@ -73,8 +76,76 @@ async function test() {
73
76
  console.error('FAILED: npub resolution did not find record.');
74
77
  process.exit(1);
75
78
  }
79
+ // Test Friend-to-Friend resolution
80
+ console.log('Testing Friend-to-Friend resolution...');
81
+ const skA = generateSecretKey();
82
+ const pkA = getPublicKey(skA);
83
+ const skB = generateSecretKey();
84
+ const pkB = getPublicKey(skB);
85
+ const payloadFriend = {
86
+ v: 1, ttl: 60, updated_at: Math.floor(Date.now() / 1000),
87
+ endpoints: [{ type: 'tcp', uri: 'friend:7777', priority: 1, family: 'ipv4' }]
88
+ };
89
+ // User A publishes for User B
90
+ console.log('User A publishing for User B...');
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
+ }
102
+ // Test Group Resolution Utility
103
+ console.log('Testing NCC05Group utility...');
104
+ const groupIdentity = NCC05Group.createGroupIdentity();
105
+ const payloadGroup = {
106
+ v: 1, ttl: 60, updated_at: Math.floor(Date.now() / 1000),
107
+ endpoints: [{ type: 'tcp', uri: 'group-service:8888', priority: 1, family: 'ipv4' }]
108
+ };
109
+ console.log('Publishing as Group...');
110
+ await publisher.publish(relays, groupIdentity.sk, payloadGroup, { identifier: 'group-test' });
111
+ console.log('Resolving as Group Member...');
112
+ const groupResult = await NCC05Group.resolveAsGroup(resolver, groupIdentity.pk, groupIdentity.sk, 'group-test');
113
+ if (groupResult && groupResult.endpoints[0].uri === 'group-service:8888') {
114
+ console.log('NCC05Group resolution successful.');
115
+ }
116
+ else {
117
+ console.error('FAILED: NCC05Group resolution.');
118
+ process.exit(1);
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
+ }
76
145
  publisher.close(relays);
77
146
  resolver.close();
147
+ relay.stop();
148
+ console.log('Local Mock Test Suite Passed.');
78
149
  process.exit(0);
79
150
  }
80
151
  test().catch(console.error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ncc-05",
3
- "version": "1.0.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",
@@ -9,14 +9,22 @@
9
9
  "build": "tsc",
10
10
  "prepublishOnly": "npm run build"
11
11
  },
12
- "keywords": ["nostr", "dns", "identity", "resolution", "privacy"],
12
+ "keywords": [
13
+ "nostr",
14
+ "dns",
15
+ "identity",
16
+ "resolution",
17
+ "privacy"
18
+ ],
13
19
  "author": "lostcause",
14
20
  "license": "MIT",
15
21
  "dependencies": {
16
22
  "nostr-tools": "^2.10.0"
17
23
  },
18
24
  "devDependencies": {
25
+ "@types/node": "^20.0.0",
26
+ "@types/ws": "^8.18.1",
19
27
  "typescript": "^5.0.0",
20
- "@types/node": "^20.0.0"
28
+ "ws": "^8.18.3"
21
29
  }
22
- }
30
+ }
package/src/index.ts CHANGED
@@ -5,7 +5,8 @@ import {
5
5
  finalizeEvent,
6
6
  verifyEvent,
7
7
  Event,
8
- getPublicKey
8
+ getPublicKey,
9
+ generateSecretKey
9
10
  } from 'nostr-tools';
10
11
 
11
12
  export interface NCC05Endpoint {
@@ -27,7 +28,42 @@ export interface NCC05Payload {
27
28
  export interface ResolverOptions {
28
29
  bootstrapRelays?: string[];
29
30
  timeout?: number;
30
- websocketImplementation?: any; // To support Tor/SOCKS5 proxies in Node.js
31
+ websocketImplementation?: any;
32
+ }
33
+
34
+ /**
35
+ * Advanced multi-recipient "wrapping" structure.
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
+
44
+ export class NCC05Group {
45
+ static createGroupIdentity() {
46
+ const sk = generateSecretKey();
47
+ const pk = getPublicKey(sk);
48
+ return {
49
+ nsec: nip19.nsecEncode(sk),
50
+ sk: sk,
51
+ pk: pk,
52
+ npub: nip19.npubEncode(pk)
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Resolve a record that was published using a group's shared identity.
58
+ */
59
+ static async resolveAsGroup(
60
+ resolver: NCC05Resolver,
61
+ groupPubkey: string,
62
+ groupSecretKey: Uint8Array,
63
+ identifier: string = 'addr'
64
+ ): Promise<NCC05Payload | null> {
65
+ return resolver.resolve(groupPubkey, groupSecretKey, identifier);
66
+ }
31
67
  }
32
68
 
33
69
  export class NCC05Resolver {
@@ -38,20 +74,16 @@ export class NCC05Resolver {
38
74
  constructor(options: ResolverOptions = {}) {
39
75
  this.pool = new SimplePool();
40
76
  if (options.websocketImplementation) {
41
- // @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
77
+ // @ts-ignore
42
78
  this.pool.websocketImplementation = options.websocketImplementation;
43
79
  }
44
80
  this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
45
81
  this.timeout = options.timeout || 10000;
46
82
  }
47
83
 
48
- /**
49
- * Resolve a locator record for a given pubkey.
50
- * Supports both hex and npub strings.
51
- */
52
84
  async resolve(
53
85
  targetPubkey: string,
54
- secretKey: Uint8Array,
86
+ secretKey?: Uint8Array,
55
87
  identifier: string = 'addr',
56
88
  options: { strict?: boolean, gossip?: boolean } = {}
57
89
  ): Promise<NCC05Payload | null> {
@@ -63,20 +95,16 @@ export class NCC05Resolver {
63
95
 
64
96
  let queryRelays = [...this.bootstrapRelays];
65
97
 
66
- // 1. NIP-65 Gossip Discovery
67
98
  if (options.gossip) {
68
99
  const relayListEvent = await this.pool.get(this.bootstrapRelays, {
69
100
  authors: [hexPubkey],
70
101
  kinds: [10002]
71
102
  });
72
-
73
103
  if (relayListEvent) {
74
104
  const discoveredRelays = relayListEvent.tags
75
105
  .filter(t => t[0] === 'r')
76
106
  .map(t => t[1]);
77
- if (discoveredRelays.length > 0) {
78
- queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
79
- }
107
+ queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
80
108
  }
81
109
  }
82
110
 
@@ -88,15 +116,11 @@ export class NCC05Resolver {
88
116
  };
89
117
 
90
118
  const queryPromise = this.pool.querySync(queryRelays, filter);
91
- const timeoutPromise = new Promise<null>((resolve) =>
92
- setTimeout(() => resolve(null), this.timeout)
93
- );
94
-
119
+ const timeoutPromise = new Promise<null>((r) => setTimeout(() => r(null), this.timeout));
95
120
  const result = await Promise.race([queryPromise, timeoutPromise]);
96
121
 
97
122
  if (!result || (Array.isArray(result) && result.length === 0)) return null;
98
123
 
99
- // 2. Filter for valid signatures and sort by created_at
100
124
  const validEvents = (result as Event[])
101
125
  .filter(e => verifyEvent(e))
102
126
  .sort((a, b) => b.created_at - a.created_at);
@@ -104,38 +128,47 @@ export class NCC05Resolver {
104
128
  if (validEvents.length === 0) return null;
105
129
  const latestEvent = validEvents[0];
106
130
 
107
- // 2. Decrypt
108
131
  try {
109
- const conversationKey = nip44.getConversationKey(secretKey, hexPubkey);
110
- const decrypted = nip44.decrypt(latestEvent.content, conversationKey);
111
- const payload = JSON.parse(decrypted) as NCC05Payload;
112
-
113
- // 3. Basic Validation
114
- if (!payload.endpoints || !Array.isArray(payload.endpoints)) {
115
- console.error('Invalid NCC-05 payload structure');
116
- 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);
117
154
  }
118
155
 
119
- // 4. Freshness check
156
+ const payload = JSON.parse(content) as NCC05Payload;
157
+ if (!payload.endpoints) return null;
158
+
120
159
  const now = Math.floor(Date.now() / 1000);
121
160
  if (now > payload.updated_at + payload.ttl) {
122
- if (options.strict) {
123
- console.warn('Rejecting expired NCC-05 record (strict mode)');
124
- return null;
125
- }
126
- console.warn('NCC-05 record has expired');
161
+ if (options.strict) return null;
162
+ console.warn('NCC-05 record expired');
127
163
  }
128
164
 
129
165
  return payload;
130
166
  } catch (e) {
131
- console.error('Failed to decrypt or parse NCC-05 record:', e);
132
167
  return null;
133
168
  }
134
169
  }
135
170
 
136
- close() {
137
- this.pool.close(this.bootstrapRelays);
138
- }
171
+ close() { this.pool.close(this.bootstrapRelays); }
139
172
  }
140
173
 
141
174
  export class NCC05Publisher {
@@ -144,43 +177,72 @@ export class NCC05Publisher {
144
177
  constructor(options: { websocketImplementation?: any } = {}) {
145
178
  this.pool = new SimplePool();
146
179
  if (options.websocketImplementation) {
147
- // @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
180
+ // @ts-ignore
148
181
  this.pool.websocketImplementation = options.websocketImplementation;
149
182
  }
150
183
  }
151
184
 
152
- /**
153
- * Create and publish a locator record.
154
- */
155
- async publish(
185
+ async publishWrapped(
156
186
  relays: string[],
157
187
  secretKey: Uint8Array,
188
+ recipients: string[],
158
189
  payload: NCC05Payload,
159
190
  identifier: string = 'addr'
160
191
  ): Promise<Event> {
161
- const pubkey = getPublicKey(secretKey);
192
+ const sessionKey = generateSecretKey();
193
+ const sessionKeyHex = Array.from(sessionKey).map(b => b.toString(16).padStart(2, '0')).join('');
162
194
 
163
- // 1. Encrypt
164
- const conversationKey = nip44.getConversationKey(secretKey, pubkey);
165
- const encryptedContent = nip44.encrypt(JSON.stringify(payload), conversationKey);
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 };
166
205
 
167
- // 2. Create and Finalize Event
168
206
  const eventTemplate = {
169
207
  kind: 30058,
170
208
  created_at: Math.floor(Date.now() / 1000),
171
209
  tags: [['d', identifier]],
172
- content: encryptedContent,
210
+ content: JSON.stringify(wrappedContent),
173
211
  };
174
212
 
175
213
  const signedEvent = finalizeEvent(eventTemplate, secretKey);
176
-
177
- // 3. Publish
178
214
  await Promise.all(this.pool.publish(relays, signedEvent));
179
-
180
215
  return signedEvent;
181
216
  }
182
217
 
183
- close(relays: string[]) {
184
- this.pool.close(relays);
218
+ async publish(
219
+ relays: string[],
220
+ secretKey: Uint8Array,
221
+ payload: NCC05Payload,
222
+ options: { identifier?: string, recipientPubkey?: string, public?: boolean } = {}
223
+ ): Promise<Event> {
224
+ const myPubkey = getPublicKey(secretKey);
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
+ }
233
+
234
+ const eventTemplate = {
235
+ kind: 30058,
236
+ created_at: Math.floor(Date.now() / 1000),
237
+ pubkey: myPubkey,
238
+ tags: [['d', identifier]],
239
+ content: content,
240
+ };
241
+
242
+ const signedEvent = finalizeEvent(eventTemplate, secretKey);
243
+ await Promise.all(this.pool.publish(relays, signedEvent));
244
+ return signedEvent;
185
245
  }
186
- }
246
+
247
+ close(relays: string[]) { this.pool.close(relays); }
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
- import { NCC05Publisher, NCC05Resolver, NCC05Payload } from './index.js';
2
- import { generateSecretKey, getPublicKey } from 'nostr-tools';
1
+ import { NCC05Publisher, NCC05Resolver, NCC05Payload, NCC05Group } from './index.js';
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.');
@@ -81,8 +85,86 @@ async function test() {
81
85
  process.exit(1);
82
86
  }
83
87
 
88
+ // Test Friend-to-Friend resolution
89
+ console.log('Testing Friend-to-Friend resolution...');
90
+ const skA = generateSecretKey();
91
+ const pkA = getPublicKey(skA);
92
+ const skB = generateSecretKey();
93
+ const pkB = getPublicKey(skB);
94
+
95
+ const payloadFriend: NCC05Payload = {
96
+ v: 1, ttl: 60, updated_at: Math.floor(Date.now() / 1000),
97
+ endpoints: [{ type: 'tcp', uri: 'friend:7777', priority: 1, family: 'ipv4' }]
98
+ };
99
+
100
+ // User A publishes for User B
101
+ console.log('User A publishing for User B...');
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
+ }
113
+
114
+ // Test Group Resolution Utility
115
+ console.log('Testing NCC05Group utility...');
116
+ const groupIdentity = NCC05Group.createGroupIdentity();
117
+ const payloadGroup: NCC05Payload = {
118
+ v: 1, ttl: 60, updated_at: Math.floor(Date.now() / 1000),
119
+ endpoints: [{ type: 'tcp', uri: 'group-service:8888', priority: 1, family: 'ipv4' }]
120
+ };
121
+
122
+ console.log('Publishing as Group...');
123
+ await publisher.publish(relays, groupIdentity.sk, payloadGroup, { identifier: 'group-test' });
124
+
125
+ console.log('Resolving as Group Member...');
126
+ const groupResult = await NCC05Group.resolveAsGroup(resolver, groupIdentity.pk, groupIdentity.sk, 'group-test');
127
+ if (groupResult && groupResult.endpoints[0].uri === 'group-service:8888') {
128
+ console.log('NCC05Group resolution successful.');
129
+ } else {
130
+ console.error('FAILED: NCC05Group resolution.');
131
+ process.exit(1);
132
+ }
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
+
84
164
  publisher.close(relays);
85
165
  resolver.close();
166
+ relay.stop();
167
+ console.log('Local Mock Test Suite Passed.');
86
168
  process.exit(0);
87
169
  }
88
170