ncc-05 1.0.0
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 +98 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +121 -0
- package/dist/test.d.ts +1 -0
- package/dist/test.js +80 -0
- package/package.json +22 -0
- package/src/index.ts +186 -0
- package/src/test.ts +89 -0
- package/tsconfig.json +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# ncc-05
|
|
2
|
+
|
|
3
|
+
Nostr Community Convention 05 - Identity-Bound Service Locator Resolution.
|
|
4
|
+
|
|
5
|
+
This library provides a simple way to publish and resolve identity-bound service endpoints (IP/Port/Onion) using Nostr `kind:30058` events.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install ncc-05
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Resolver
|
|
16
|
+
|
|
17
|
+
Resolve an identity-bound service locator for a given pubkey.
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import { NCC05Resolver } from 'ncc-05';
|
|
21
|
+
import { nip19 } from 'nostr-tools';
|
|
22
|
+
|
|
23
|
+
const resolver = new NCC05Resolver();
|
|
24
|
+
|
|
25
|
+
// Your secret key is needed to decrypt the record (NIP-44)
|
|
26
|
+
const mySecretKey = nip19.decode('nsec...').data as Uint8Array;
|
|
27
|
+
const targetPubkey = '...';
|
|
28
|
+
|
|
29
|
+
const payload = await resolver.resolve(targetPubkey, mySecretKey);
|
|
30
|
+
|
|
31
|
+
if (payload) {
|
|
32
|
+
console.log('Resolved Endpoints:');
|
|
33
|
+
payload.endpoints.forEach(ep => {
|
|
34
|
+
console.log(`- ${ep.type}://${ep.uri} (${ep.family})`);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Publisher
|
|
40
|
+
|
|
41
|
+
Publish your own service locator record.
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { NCC05Publisher, NCC05Payload } from 'ncc-05';
|
|
45
|
+
|
|
46
|
+
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);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Features
|
|
68
|
+
|
|
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.
|
|
73
|
+
|
|
74
|
+
## Tor & Privacy (Node.js)
|
|
75
|
+
|
|
76
|
+
To resolve anonymously through Tor, you can use the `socks-proxy-agent` and a custom `WebSocket` implementation:
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
import { NCC05Resolver } from 'ncc-05';
|
|
80
|
+
import { SocksProxyAgent } from 'socks-proxy-agent';
|
|
81
|
+
import { WebSocket } from 'ws';
|
|
82
|
+
|
|
83
|
+
// Create a custom WebSocket class that uses the Tor agent
|
|
84
|
+
class TorWebSocket extends WebSocket {
|
|
85
|
+
constructor(address: string, protocols?: string | string[]) {
|
|
86
|
+
const agent = new SocksProxyAgent('socks5h://127.0.0.1:9050');
|
|
87
|
+
super(address, protocols, { agent });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const resolver = new NCC05Resolver({
|
|
92
|
+
websocketImplementation: TorWebSocket
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Event } from 'nostr-tools';
|
|
2
|
+
export interface NCC05Endpoint {
|
|
3
|
+
type: 'tcp' | 'udp' | string;
|
|
4
|
+
uri: string;
|
|
5
|
+
priority: number;
|
|
6
|
+
family: 'ipv4' | 'ipv6' | 'onion' | string;
|
|
7
|
+
}
|
|
8
|
+
export interface NCC05Payload {
|
|
9
|
+
v: number;
|
|
10
|
+
ttl: number;
|
|
11
|
+
updated_at: number;
|
|
12
|
+
endpoints: NCC05Endpoint[];
|
|
13
|
+
caps?: string[];
|
|
14
|
+
notes?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface ResolverOptions {
|
|
17
|
+
bootstrapRelays?: string[];
|
|
18
|
+
timeout?: number;
|
|
19
|
+
websocketImplementation?: any;
|
|
20
|
+
}
|
|
21
|
+
export declare class NCC05Resolver {
|
|
22
|
+
private pool;
|
|
23
|
+
private bootstrapRelays;
|
|
24
|
+
private timeout;
|
|
25
|
+
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?: {
|
|
31
|
+
strict?: boolean;
|
|
32
|
+
gossip?: boolean;
|
|
33
|
+
}): Promise<NCC05Payload | null>;
|
|
34
|
+
close(): void;
|
|
35
|
+
}
|
|
36
|
+
export declare class NCC05Publisher {
|
|
37
|
+
private pool;
|
|
38
|
+
constructor(options?: {
|
|
39
|
+
websocketImplementation?: any;
|
|
40
|
+
});
|
|
41
|
+
/**
|
|
42
|
+
* Create and publish a locator record.
|
|
43
|
+
*/
|
|
44
|
+
publish(relays: string[], secretKey: Uint8Array, payload: NCC05Payload, identifier?: string): Promise<Event>;
|
|
45
|
+
close(relays: string[]): void;
|
|
46
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { SimplePool, nip44, nip19, finalizeEvent, verifyEvent, getPublicKey } from 'nostr-tools';
|
|
2
|
+
export class NCC05Resolver {
|
|
3
|
+
pool;
|
|
4
|
+
bootstrapRelays;
|
|
5
|
+
timeout;
|
|
6
|
+
constructor(options = {}) {
|
|
7
|
+
this.pool = new SimplePool();
|
|
8
|
+
if (options.websocketImplementation) {
|
|
9
|
+
// @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
|
|
10
|
+
this.pool.websocketImplementation = options.websocketImplementation;
|
|
11
|
+
}
|
|
12
|
+
this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
|
|
13
|
+
this.timeout = options.timeout || 10000;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a locator record for a given pubkey.
|
|
17
|
+
* Supports both hex and npub strings.
|
|
18
|
+
*/
|
|
19
|
+
async resolve(targetPubkey, secretKey, identifier = 'addr', options = {}) {
|
|
20
|
+
let hexPubkey = targetPubkey;
|
|
21
|
+
if (targetPubkey.startsWith('npub1')) {
|
|
22
|
+
const decoded = nip19.decode(targetPubkey);
|
|
23
|
+
hexPubkey = decoded.data;
|
|
24
|
+
}
|
|
25
|
+
let queryRelays = [...this.bootstrapRelays];
|
|
26
|
+
// 1. NIP-65 Gossip Discovery
|
|
27
|
+
if (options.gossip) {
|
|
28
|
+
const relayListEvent = await this.pool.get(this.bootstrapRelays, {
|
|
29
|
+
authors: [hexPubkey],
|
|
30
|
+
kinds: [10002]
|
|
31
|
+
});
|
|
32
|
+
if (relayListEvent) {
|
|
33
|
+
const discoveredRelays = relayListEvent.tags
|
|
34
|
+
.filter(t => t[0] === 'r')
|
|
35
|
+
.map(t => t[1]);
|
|
36
|
+
if (discoveredRelays.length > 0) {
|
|
37
|
+
queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const filter = {
|
|
42
|
+
authors: [hexPubkey],
|
|
43
|
+
kinds: [30058],
|
|
44
|
+
'#d': [identifier],
|
|
45
|
+
limit: 10
|
|
46
|
+
};
|
|
47
|
+
const queryPromise = this.pool.querySync(queryRelays, filter);
|
|
48
|
+
const timeoutPromise = new Promise((resolve) => setTimeout(() => resolve(null), this.timeout));
|
|
49
|
+
const result = await Promise.race([queryPromise, timeoutPromise]);
|
|
50
|
+
if (!result || (Array.isArray(result) && result.length === 0))
|
|
51
|
+
return null;
|
|
52
|
+
// 2. Filter for valid signatures and sort by created_at
|
|
53
|
+
const validEvents = result
|
|
54
|
+
.filter(e => verifyEvent(e))
|
|
55
|
+
.sort((a, b) => b.created_at - a.created_at);
|
|
56
|
+
if (validEvents.length === 0)
|
|
57
|
+
return null;
|
|
58
|
+
const latestEvent = validEvents[0];
|
|
59
|
+
// 2. Decrypt
|
|
60
|
+
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;
|
|
68
|
+
}
|
|
69
|
+
// 4. Freshness check
|
|
70
|
+
const now = Math.floor(Date.now() / 1000);
|
|
71
|
+
if (now > payload.updated_at + payload.ttl) {
|
|
72
|
+
if (options.strict) {
|
|
73
|
+
console.warn('Rejecting expired NCC-05 record (strict mode)');
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
console.warn('NCC-05 record has expired');
|
|
77
|
+
}
|
|
78
|
+
return payload;
|
|
79
|
+
}
|
|
80
|
+
catch (e) {
|
|
81
|
+
console.error('Failed to decrypt or parse NCC-05 record:', e);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
close() {
|
|
86
|
+
this.pool.close(this.bootstrapRelays);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export class NCC05Publisher {
|
|
90
|
+
pool;
|
|
91
|
+
constructor(options = {}) {
|
|
92
|
+
this.pool = new SimplePool();
|
|
93
|
+
if (options.websocketImplementation) {
|
|
94
|
+
// @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
|
|
95
|
+
this.pool.websocketImplementation = options.websocketImplementation;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
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
|
|
107
|
+
const eventTemplate = {
|
|
108
|
+
kind: 30058,
|
|
109
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
110
|
+
tags: [['d', identifier]],
|
|
111
|
+
content: encryptedContent,
|
|
112
|
+
};
|
|
113
|
+
const signedEvent = finalizeEvent(eventTemplate, secretKey);
|
|
114
|
+
// 3. Publish
|
|
115
|
+
await Promise.all(this.pool.publish(relays, signedEvent));
|
|
116
|
+
return signedEvent;
|
|
117
|
+
}
|
|
118
|
+
close(relays) {
|
|
119
|
+
this.pool.close(relays);
|
|
120
|
+
}
|
|
121
|
+
}
|
package/dist/test.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/test.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { NCC05Publisher, NCC05Resolver } from './index.js';
|
|
2
|
+
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
|
3
|
+
async function test() {
|
|
4
|
+
const sk = generateSecretKey();
|
|
5
|
+
const pk = getPublicKey(sk);
|
|
6
|
+
const relays = ['wss://relay.damus.io'];
|
|
7
|
+
const publisher = new NCC05Publisher();
|
|
8
|
+
const resolver = new NCC05Resolver({ bootstrapRelays: relays });
|
|
9
|
+
const payload = {
|
|
10
|
+
v: 1,
|
|
11
|
+
ttl: 60,
|
|
12
|
+
updated_at: Math.floor(Date.now() / 1000),
|
|
13
|
+
endpoints: [
|
|
14
|
+
{ type: 'tcp', uri: '127.0.0.1:9000', priority: 1, family: 'ipv4' }
|
|
15
|
+
]
|
|
16
|
+
};
|
|
17
|
+
console.log('Publishing...');
|
|
18
|
+
await publisher.publish(relays, sk, payload);
|
|
19
|
+
console.log('Published.');
|
|
20
|
+
console.log('Resolving...');
|
|
21
|
+
const resolved = await resolver.resolve(pk, sk);
|
|
22
|
+
if (resolved) {
|
|
23
|
+
console.log('Successfully resolved:', JSON.stringify(resolved, null, 2));
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
console.log('Failed to resolve.');
|
|
27
|
+
}
|
|
28
|
+
// Test Strict Mode with Expired Record
|
|
29
|
+
console.log('Testing expired record in strict mode...');
|
|
30
|
+
const expiredPayload = {
|
|
31
|
+
v: 1,
|
|
32
|
+
ttl: 1,
|
|
33
|
+
updated_at: Math.floor(Date.now() / 1000) - 10, // 10s ago
|
|
34
|
+
endpoints: [{ type: 'tcp', uri: '1.1.1.1:1', priority: 1, family: 'ipv4' }]
|
|
35
|
+
};
|
|
36
|
+
await publisher.publish(relays, sk, expiredPayload, 'expired-test');
|
|
37
|
+
const strictResult = await resolver.resolve(pk, sk, 'expired-test', { strict: true });
|
|
38
|
+
if (strictResult === null) {
|
|
39
|
+
console.log('Correctly rejected expired record in strict mode.');
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.error('FAILED: Strict mode allowed an expired record.');
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
// Test Gossip Mode
|
|
46
|
+
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
|
+
const relayListTemplate = {
|
|
50
|
+
kind: 10002,
|
|
51
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
52
|
+
tags: [['r', relays[0]]],
|
|
53
|
+
content: '',
|
|
54
|
+
};
|
|
55
|
+
const signedRL = (await import('nostr-tools')).finalizeEvent(relayListTemplate, sk);
|
|
56
|
+
await Promise.all(publisher['pool'].publish(relays, signedRL));
|
|
57
|
+
const gossipResult = await resolver.resolve(pk, sk, 'addr', { gossip: true });
|
|
58
|
+
if (gossipResult) {
|
|
59
|
+
console.log('Gossip discovery successful.');
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
console.error('FAILED: Gossip discovery did not find record.');
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
// Test npub resolution
|
|
66
|
+
console.log('Testing npub resolution...');
|
|
67
|
+
const npub = (await import('nostr-tools')).nip19.npubEncode(pk);
|
|
68
|
+
const npubResult = await resolver.resolve(npub, sk);
|
|
69
|
+
if (npubResult) {
|
|
70
|
+
console.log('npub resolution successful.');
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
console.error('FAILED: npub resolution did not find record.');
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
publisher.close(relays);
|
|
77
|
+
resolver.close();
|
|
78
|
+
process.exit(0);
|
|
79
|
+
}
|
|
80
|
+
test().catch(console.error);
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ncc-05",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Nostr Community Convention 05 - Identity-Bound Service Locator Resolution",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"prepublishOnly": "npm run build"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["nostr", "dns", "identity", "resolution", "privacy"],
|
|
13
|
+
"author": "lostcause",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"nostr-tools": "^2.10.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"typescript": "^5.0.0",
|
|
20
|
+
"@types/node": "^20.0.0"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SimplePool,
|
|
3
|
+
nip44,
|
|
4
|
+
nip19,
|
|
5
|
+
finalizeEvent,
|
|
6
|
+
verifyEvent,
|
|
7
|
+
Event,
|
|
8
|
+
getPublicKey
|
|
9
|
+
} from 'nostr-tools';
|
|
10
|
+
|
|
11
|
+
export interface NCC05Endpoint {
|
|
12
|
+
type: 'tcp' | 'udp' | string;
|
|
13
|
+
uri: string;
|
|
14
|
+
priority: number;
|
|
15
|
+
family: 'ipv4' | 'ipv6' | 'onion' | string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface NCC05Payload {
|
|
19
|
+
v: number;
|
|
20
|
+
ttl: number;
|
|
21
|
+
updated_at: number;
|
|
22
|
+
endpoints: NCC05Endpoint[];
|
|
23
|
+
caps?: string[];
|
|
24
|
+
notes?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ResolverOptions {
|
|
28
|
+
bootstrapRelays?: string[];
|
|
29
|
+
timeout?: number;
|
|
30
|
+
websocketImplementation?: any; // To support Tor/SOCKS5 proxies in Node.js
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class NCC05Resolver {
|
|
34
|
+
private pool: SimplePool;
|
|
35
|
+
private bootstrapRelays: string[];
|
|
36
|
+
private timeout: number;
|
|
37
|
+
|
|
38
|
+
constructor(options: ResolverOptions = {}) {
|
|
39
|
+
this.pool = new SimplePool();
|
|
40
|
+
if (options.websocketImplementation) {
|
|
41
|
+
// @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
|
|
42
|
+
this.pool.websocketImplementation = options.websocketImplementation;
|
|
43
|
+
}
|
|
44
|
+
this.bootstrapRelays = options.bootstrapRelays || ['wss://relay.damus.io', 'wss://nos.lol'];
|
|
45
|
+
this.timeout = options.timeout || 10000;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve a locator record for a given pubkey.
|
|
50
|
+
* Supports both hex and npub strings.
|
|
51
|
+
*/
|
|
52
|
+
async resolve(
|
|
53
|
+
targetPubkey: string,
|
|
54
|
+
secretKey: Uint8Array,
|
|
55
|
+
identifier: string = 'addr',
|
|
56
|
+
options: { strict?: boolean, gossip?: boolean } = {}
|
|
57
|
+
): Promise<NCC05Payload | null> {
|
|
58
|
+
let hexPubkey = targetPubkey;
|
|
59
|
+
if (targetPubkey.startsWith('npub1')) {
|
|
60
|
+
const decoded = nip19.decode(targetPubkey);
|
|
61
|
+
hexPubkey = decoded.data as string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let queryRelays = [...this.bootstrapRelays];
|
|
65
|
+
|
|
66
|
+
// 1. NIP-65 Gossip Discovery
|
|
67
|
+
if (options.gossip) {
|
|
68
|
+
const relayListEvent = await this.pool.get(this.bootstrapRelays, {
|
|
69
|
+
authors: [hexPubkey],
|
|
70
|
+
kinds: [10002]
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (relayListEvent) {
|
|
74
|
+
const discoveredRelays = relayListEvent.tags
|
|
75
|
+
.filter(t => t[0] === 'r')
|
|
76
|
+
.map(t => t[1]);
|
|
77
|
+
if (discoveredRelays.length > 0) {
|
|
78
|
+
queryRelays = [...new Set([...queryRelays, ...discoveredRelays])];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const filter = {
|
|
84
|
+
authors: [hexPubkey],
|
|
85
|
+
kinds: [30058],
|
|
86
|
+
'#d': [identifier],
|
|
87
|
+
limit: 10
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const queryPromise = this.pool.querySync(queryRelays, filter);
|
|
91
|
+
const timeoutPromise = new Promise<null>((resolve) =>
|
|
92
|
+
setTimeout(() => resolve(null), this.timeout)
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const result = await Promise.race([queryPromise, timeoutPromise]);
|
|
96
|
+
|
|
97
|
+
if (!result || (Array.isArray(result) && result.length === 0)) return null;
|
|
98
|
+
|
|
99
|
+
// 2. Filter for valid signatures and sort by created_at
|
|
100
|
+
const validEvents = (result as Event[])
|
|
101
|
+
.filter(e => verifyEvent(e))
|
|
102
|
+
.sort((a, b) => b.created_at - a.created_at);
|
|
103
|
+
|
|
104
|
+
if (validEvents.length === 0) return null;
|
|
105
|
+
const latestEvent = validEvents[0];
|
|
106
|
+
|
|
107
|
+
// 2. Decrypt
|
|
108
|
+
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;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 4. Freshness check
|
|
120
|
+
const now = Math.floor(Date.now() / 1000);
|
|
121
|
+
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');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return payload;
|
|
130
|
+
} catch (e) {
|
|
131
|
+
console.error('Failed to decrypt or parse NCC-05 record:', e);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
close() {
|
|
137
|
+
this.pool.close(this.bootstrapRelays);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export class NCC05Publisher {
|
|
142
|
+
private pool: SimplePool;
|
|
143
|
+
|
|
144
|
+
constructor(options: { websocketImplementation?: any } = {}) {
|
|
145
|
+
this.pool = new SimplePool();
|
|
146
|
+
if (options.websocketImplementation) {
|
|
147
|
+
// @ts-ignore - Patching pool for custom WebSocket (Tor/Proxy)
|
|
148
|
+
this.pool.websocketImplementation = options.websocketImplementation;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Create and publish a locator record.
|
|
154
|
+
*/
|
|
155
|
+
async publish(
|
|
156
|
+
relays: string[],
|
|
157
|
+
secretKey: Uint8Array,
|
|
158
|
+
payload: NCC05Payload,
|
|
159
|
+
identifier: string = 'addr'
|
|
160
|
+
): Promise<Event> {
|
|
161
|
+
const pubkey = getPublicKey(secretKey);
|
|
162
|
+
|
|
163
|
+
// 1. Encrypt
|
|
164
|
+
const conversationKey = nip44.getConversationKey(secretKey, pubkey);
|
|
165
|
+
const encryptedContent = nip44.encrypt(JSON.stringify(payload), conversationKey);
|
|
166
|
+
|
|
167
|
+
// 2. Create and Finalize Event
|
|
168
|
+
const eventTemplate = {
|
|
169
|
+
kind: 30058,
|
|
170
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
171
|
+
tags: [['d', identifier]],
|
|
172
|
+
content: encryptedContent,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const signedEvent = finalizeEvent(eventTemplate, secretKey);
|
|
176
|
+
|
|
177
|
+
// 3. Publish
|
|
178
|
+
await Promise.all(this.pool.publish(relays, signedEvent));
|
|
179
|
+
|
|
180
|
+
return signedEvent;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
close(relays: string[]) {
|
|
184
|
+
this.pool.close(relays);
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/test.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { NCC05Publisher, NCC05Resolver, NCC05Payload } from './index.js';
|
|
2
|
+
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
|
3
|
+
|
|
4
|
+
async function test() {
|
|
5
|
+
const sk = generateSecretKey();
|
|
6
|
+
const pk = getPublicKey(sk);
|
|
7
|
+
const relays = ['wss://relay.damus.io'];
|
|
8
|
+
|
|
9
|
+
const publisher = new NCC05Publisher();
|
|
10
|
+
const resolver = new NCC05Resolver({ bootstrapRelays: relays });
|
|
11
|
+
|
|
12
|
+
const payload: NCC05Payload = {
|
|
13
|
+
v: 1,
|
|
14
|
+
ttl: 60,
|
|
15
|
+
updated_at: Math.floor(Date.now() / 1000),
|
|
16
|
+
endpoints: [
|
|
17
|
+
{ type: 'tcp', uri: '127.0.0.1:9000', priority: 1, family: 'ipv4' }
|
|
18
|
+
]
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
console.log('Publishing...');
|
|
22
|
+
await publisher.publish(relays, sk, payload);
|
|
23
|
+
console.log('Published.');
|
|
24
|
+
|
|
25
|
+
console.log('Resolving...');
|
|
26
|
+
const resolved = await resolver.resolve(pk, sk);
|
|
27
|
+
|
|
28
|
+
if (resolved) {
|
|
29
|
+
console.log('Successfully resolved:', JSON.stringify(resolved, null, 2));
|
|
30
|
+
} else {
|
|
31
|
+
console.log('Failed to resolve.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Test Strict Mode with Expired Record
|
|
35
|
+
console.log('Testing expired record in strict mode...');
|
|
36
|
+
const expiredPayload: NCC05Payload = {
|
|
37
|
+
v: 1,
|
|
38
|
+
ttl: 1,
|
|
39
|
+
updated_at: Math.floor(Date.now() / 1000) - 10, // 10s ago
|
|
40
|
+
endpoints: [{ type: 'tcp', uri: '1.1.1.1:1', priority: 1, family: 'ipv4' }]
|
|
41
|
+
};
|
|
42
|
+
await publisher.publish(relays, sk, expiredPayload, 'expired-test');
|
|
43
|
+
const strictResult = await resolver.resolve(pk, sk, 'expired-test', { strict: true });
|
|
44
|
+
|
|
45
|
+
if (strictResult === null) {
|
|
46
|
+
console.log('Correctly rejected expired record in strict mode.');
|
|
47
|
+
} else {
|
|
48
|
+
console.error('FAILED: Strict mode allowed an expired record.');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Test Gossip Mode
|
|
53
|
+
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
|
+
const relayListTemplate = {
|
|
57
|
+
kind: 10002,
|
|
58
|
+
created_at: Math.floor(Date.now() / 1000),
|
|
59
|
+
tags: [['r', relays[0]]],
|
|
60
|
+
content: '',
|
|
61
|
+
};
|
|
62
|
+
const signedRL = (await import('nostr-tools')).finalizeEvent(relayListTemplate, sk);
|
|
63
|
+
await Promise.all(publisher['pool'].publish(relays, signedRL));
|
|
64
|
+
|
|
65
|
+
const gossipResult = await resolver.resolve(pk, sk, 'addr', { gossip: true });
|
|
66
|
+
if (gossipResult) {
|
|
67
|
+
console.log('Gossip discovery successful.');
|
|
68
|
+
} else {
|
|
69
|
+
console.error('FAILED: Gossip discovery did not find record.');
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Test npub resolution
|
|
74
|
+
console.log('Testing npub resolution...');
|
|
75
|
+
const npub = (await import('nostr-tools')).nip19.npubEncode(pk);
|
|
76
|
+
const npubResult = await resolver.resolve(npub, sk);
|
|
77
|
+
if (npubResult) {
|
|
78
|
+
console.log('npub resolution successful.');
|
|
79
|
+
} else {
|
|
80
|
+
console.error('FAILED: npub resolution did not find record.');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
publisher.close(relays);
|
|
85
|
+
resolver.close();
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test().catch(console.error);
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"outDir": "./dist",
|
|
8
|
+
"rootDir": "./src",
|
|
9
|
+
"strict": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true
|
|
13
|
+
},
|
|
14
|
+
"include": ["src/**/*"],
|
|
15
|
+
"exclude": ["node_modules", "dist"]
|
|
16
|
+
}
|