ncc-02-js 0.2.5 → 0.3.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/README.md +20 -51
- package/dist/index.cjs +50 -38
- package/dist/index.mjs +50 -38
- package/dist/models.d.ts +4 -4
- package/dist/resolver.d.ts +8 -4
- package/package.json +1 -1
- package/src/models.js +10 -10
- package/src/resolver.js +64 -46
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ This library provides tools for service owners to publish records and for client
|
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
-
- **Service Discovery**: Resolve Kind 30059 service records.
|
|
9
|
+
- **Service Discovery**: Resolve Kind 30059 service records for both public and private services.
|
|
10
10
|
- **Verification**: Built-in signature and expiry validation.
|
|
11
11
|
- **Trust Policy**: Support for third-party attestations (Kind 30060) and revocations (Kind 30061).
|
|
12
12
|
- **Security**: Cross-validation of subject and service identifiers to prevent impersonation.
|
|
@@ -36,68 +36,33 @@ try {
|
|
|
36
36
|
requireAttestation: true,
|
|
37
37
|
minLevel: 'verified' // 'self', 'verified', 'hardened'
|
|
38
38
|
});
|
|
39
|
-
|
|
39
|
+
if(service.endpoint) {
|
|
40
|
+
console.log('Resolved endpoint:', service.endpoint);
|
|
41
|
+
} else {
|
|
42
|
+
console.log('Resolved private service, use NCC-05 for endpoint discovery.');
|
|
43
|
+
}
|
|
40
44
|
} catch (err) {
|
|
41
45
|
console.error('Resolution failed:', err.code, err.message);
|
|
46
|
+
} finally {
|
|
47
|
+
resolver.close(); // Clean up WebSocket connections
|
|
42
48
|
}
|
|
43
49
|
```
|
|
44
50
|
|
|
45
51
|
### 2. Publish a Service Record
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
import { NCC02Builder } from 'ncc-02-js';
|
|
49
|
-
|
|
50
|
-
// Initialize with private key (hex)
|
|
51
|
-
const builder = new NCC02Builder(privateKey);
|
|
52
|
-
|
|
53
|
-
// Example 1: IP-based Service
|
|
54
|
-
const event = builder.createServiceRecord({
|
|
55
|
-
serviceId: 'media',
|
|
56
|
-
endpoint: 'https://203.0.113.45:8443',
|
|
57
|
-
fingerprint: 'sha256:fingerprint',
|
|
58
|
-
expiryDays: 14
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
// Example 2: Tor Onion Service
|
|
62
|
-
const onionEvent = builder.createServiceRecord({
|
|
63
|
-
serviceId: 'wallet',
|
|
64
|
-
endpoint: 'tcp://vww6ybal4bd7szmgncyruucpgfkqahzddi37ktceo3ah7ngmcopnpyyd.onion:80',
|
|
65
|
-
fingerprint: 'sha256:fingerprint',
|
|
66
|
-
expiryDays: 7
|
|
67
|
-
});
|
|
68
|
-
// publish events to relays...
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
### 3. Issue an Attestation (CA)
|
|
72
|
-
|
|
73
|
-
```javascript
|
|
74
|
-
const caBuilder = new NCC02Builder(caPrivateKey);
|
|
75
|
-
const attestation = caBuilder.createAttestation({
|
|
76
|
-
subjectPubkey: 'npub1...', // The service owner being certified
|
|
77
|
-
serviceId: 'media',
|
|
78
|
-
serviceEventId: serviceRecordEventId,
|
|
79
|
-
level: 'verified',
|
|
80
|
-
validDays: 30
|
|
81
|
-
});
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
## Trust Model & Security
|
|
52
|
+
...
|
|
53
|
+
### Trust Model & Security
|
|
85
54
|
|
|
86
55
|
### Trust Levels
|
|
87
56
|
- `self`: Asserted by the service owner (default if no attestation).
|
|
88
57
|
- `verified`: Attested by a trusted third party.
|
|
89
58
|
- `hardened`: Attested by a third party with stricter verification (e.g., physical proof or long-term history).
|
|
90
59
|
|
|
91
|
-
###
|
|
92
|
-
-
|
|
93
|
-
- **Man-in-the-Middle (MITM)**: Mitigated via cryptographic pinning of transport-level keys.
|
|
94
|
-
- **Stale Records**: Limited by required expiry (`exp`) and support for revocations.
|
|
95
|
-
- **Relay Censorship**: Mitigated by querying multiple relays (implemented via `SimplePool`).
|
|
60
|
+
### Resolution Optimization
|
|
61
|
+
The resolver is designed to be network-efficient. It will only query for attestations (Kind 30060) and revocations (Kind 30061) if the provided policy requires them (e.g., when `requireAttestation` is set to `true` or a `minLevel` higher than `self` is requested).
|
|
96
62
|
|
|
97
|
-
###
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
## API Reference
|
|
63
|
+
### Threat Model
|
|
64
|
+
...
|
|
65
|
+
### API Reference
|
|
101
66
|
|
|
102
67
|
### `NCC02Resolver(relays, options)`
|
|
103
68
|
- `relays`: Array of relay URLs.
|
|
@@ -108,8 +73,12 @@ The library follows a fail-closed principle. If a policy requirement is not met
|
|
|
108
73
|
- `options.requireAttestation`: Fails if no trusted attestation is found.
|
|
109
74
|
- `options.minLevel`: Minimum trust level required.
|
|
110
75
|
|
|
76
|
+
#### `close()`
|
|
77
|
+
Closes WebSocket connections to all relays. If the resolver was initialized with an external `pool`, this will *not* close the pool; it only untracks the relays for this instance. If the resolver created its own internal pool, it will close it entirely.
|
|
78
|
+
|
|
79
|
+
|
|
111
80
|
### `NCC02Builder(privateKey)`
|
|
112
|
-
- `createServiceRecord({ serviceId, endpoint
|
|
81
|
+
- `createServiceRecord({ serviceId, endpoint?, fingerprint?, expiryDays? })`
|
|
113
82
|
- `createAttestation({ subjectPubkey, serviceId, serviceEventId, level, validDays })`
|
|
114
83
|
- `createRevocation({ attestationId, reason })`
|
|
115
84
|
|
package/dist/index.cjs
CHANGED
|
@@ -2509,25 +2509,24 @@ var NCC02Builder = class {
|
|
|
2509
2509
|
* Creates a signed Service Record (Kind 30059).
|
|
2510
2510
|
* @param {Object} options
|
|
2511
2511
|
* @param {string} options.serviceId - The 'd' tag identifier.
|
|
2512
|
-
* @param {string} options.endpoint - The 'u' tag URI.
|
|
2513
|
-
* @param {string} options.fingerprint - The 'k' tag fingerprint.
|
|
2512
|
+
* @param {string} [options.endpoint] - The 'u' tag URI.
|
|
2513
|
+
* @param {string} [options.fingerprint] - The 'k' tag fingerprint.
|
|
2514
2514
|
* @param {number} [options.expiryDays=14] - Expiry in days.
|
|
2515
2515
|
*/
|
|
2516
2516
|
createServiceRecord(options) {
|
|
2517
2517
|
const { serviceId, endpoint, fingerprint, expiryDays = 14 } = options;
|
|
2518
2518
|
if (!serviceId) throw new Error("serviceId (d tag) is required");
|
|
2519
|
-
if (!endpoint) throw new Error("endpoint (u tag) is required");
|
|
2520
|
-
if (!fingerprint) throw new Error("fingerprint (k tag) is required");
|
|
2521
2519
|
const expiry = Math.floor(Date.now() / 1e3) + expiryDays * 24 * 60 * 60;
|
|
2520
|
+
const tags = [
|
|
2521
|
+
["d", serviceId],
|
|
2522
|
+
["exp", expiry.toString()]
|
|
2523
|
+
];
|
|
2524
|
+
if (endpoint) tags.push(["u", endpoint]);
|
|
2525
|
+
if (fingerprint) tags.push(["k", fingerprint]);
|
|
2522
2526
|
const event = {
|
|
2523
2527
|
kind: KINDS.SERVICE_RECORD,
|
|
2524
2528
|
created_at: Math.floor(Date.now() / 1e3),
|
|
2525
|
-
tags
|
|
2526
|
-
["d", serviceId],
|
|
2527
|
-
["u", endpoint],
|
|
2528
|
-
["k", fingerprint],
|
|
2529
|
-
["exp", expiry.toString()]
|
|
2530
|
-
],
|
|
2529
|
+
tags,
|
|
2531
2530
|
content: `NCC-02 Service Record for ${serviceId}`,
|
|
2532
2531
|
pubkey: this.pk
|
|
2533
2532
|
};
|
|
@@ -7693,6 +7692,14 @@ var NCC02Resolver = class {
|
|
|
7693
7692
|
this.ownsPool = !options.pool;
|
|
7694
7693
|
this.trustedCAPubkeys = new Set(options.trustedCAPubkeys || []);
|
|
7695
7694
|
}
|
|
7695
|
+
/**
|
|
7696
|
+
* Closes the connection to the relays if the pool is owned by this resolver.
|
|
7697
|
+
*/
|
|
7698
|
+
close() {
|
|
7699
|
+
if (this.ownsPool && this.pool) {
|
|
7700
|
+
this.pool.close(this.relays);
|
|
7701
|
+
}
|
|
7702
|
+
}
|
|
7696
7703
|
/**
|
|
7697
7704
|
* Internal query helper using SimplePool.subscribeMany (since list() is deprecated).
|
|
7698
7705
|
* @param {import('nostr-tools').Filter} filter
|
|
@@ -7752,8 +7759,11 @@ var NCC02Resolver = class {
|
|
|
7752
7759
|
}
|
|
7753
7760
|
const serviceTags = Object.fromEntries(serviceEvent.tags);
|
|
7754
7761
|
const now2 = Math.floor(Date.now() / 1e3);
|
|
7755
|
-
if (!serviceTags.
|
|
7756
|
-
throw new NCC02Error("MALFORMED_RECORD", "Service record is missing required
|
|
7762
|
+
if (!serviceTags.exp) {
|
|
7763
|
+
throw new NCC02Error("MALFORMED_RECORD", "Service record is missing required tag (exp)");
|
|
7764
|
+
}
|
|
7765
|
+
if (serviceTags.u && (serviceTags.u.startsWith("wss://") || serviceTags.u.startsWith("https://")) && !serviceTags.k) {
|
|
7766
|
+
throw new NCC02Error("MALFORMED_RECORD", "Service record with 'https' or 'wss' endpoint must have a 'k' tag");
|
|
7757
7767
|
}
|
|
7758
7768
|
const exp = parseInt(serviceTags.exp);
|
|
7759
7769
|
if (isNaN(exp)) {
|
|
@@ -7762,35 +7772,37 @@ var NCC02Resolver = class {
|
|
|
7762
7772
|
if (exp < now2) {
|
|
7763
7773
|
throw new NCC02Error("EXPIRED", "Service record has expired");
|
|
7764
7774
|
}
|
|
7765
|
-
let attestations;
|
|
7766
|
-
let revocations;
|
|
7767
|
-
try {
|
|
7768
|
-
[attestations, revocations] = await Promise.all([
|
|
7769
|
-
this._query({ kinds: [KINDS.ATTESTATION], "#e": [serviceEvent.id] }),
|
|
7770
|
-
this._query({ kinds: [KINDS.REVOCATION] })
|
|
7771
|
-
]);
|
|
7772
|
-
} catch (err) {
|
|
7773
|
-
throw new NCC02Error("RELAY_ERROR", "Failed to query relay for attestations/revocations", err);
|
|
7774
|
-
}
|
|
7775
7775
|
const validAttestations = [];
|
|
7776
|
-
|
|
7777
|
-
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
7782
|
-
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
|
|
7786
|
-
|
|
7787
|
-
|
|
7788
|
-
|
|
7776
|
+
if (requireAttestation || minLevel === "verified" || minLevel === "hardened") {
|
|
7777
|
+
let attestations;
|
|
7778
|
+
let revocations;
|
|
7779
|
+
try {
|
|
7780
|
+
[attestations, revocations] = await Promise.all([
|
|
7781
|
+
this._query({ kinds: [KINDS.ATTESTATION], "#e": [serviceEvent.id] }),
|
|
7782
|
+
this._query({ kinds: [KINDS.REVOCATION] })
|
|
7783
|
+
]);
|
|
7784
|
+
} catch (err) {
|
|
7785
|
+
throw new NCC02Error("RELAY_ERROR", "Failed to query relay for attestations/revocations", err);
|
|
7786
|
+
}
|
|
7787
|
+
for (const att of attestations) {
|
|
7788
|
+
if (this.trustedCAPubkeys.has(att.pubkey)) {
|
|
7789
|
+
const attTags = Object.fromEntries(att.tags);
|
|
7790
|
+
if (attTags.subj !== pubkey) continue;
|
|
7791
|
+
if (attTags.srv !== serviceId) continue;
|
|
7792
|
+
if (standard && attTags.std !== standard) continue;
|
|
7793
|
+
if (minLevel && !this._isLevelSufficient(attTags.lvl, minLevel)) continue;
|
|
7794
|
+
if (this._isAttestationValid(att, attTags, revocations)) {
|
|
7795
|
+
validAttestations.push({
|
|
7796
|
+
pubkey: att.pubkey,
|
|
7797
|
+
level: attTags.lvl,
|
|
7798
|
+
eventId: att.id
|
|
7799
|
+
});
|
|
7800
|
+
}
|
|
7789
7801
|
}
|
|
7790
7802
|
}
|
|
7791
|
-
|
|
7792
|
-
|
|
7793
|
-
|
|
7803
|
+
if (requireAttestation && validAttestations.length === 0) {
|
|
7804
|
+
throw new NCC02Error("POLICY_FAILURE", `No trusted attestations meet the required policy for ${serviceId}`);
|
|
7805
|
+
}
|
|
7794
7806
|
}
|
|
7795
7807
|
return {
|
|
7796
7808
|
endpoint: serviceTags.u,
|
package/dist/index.mjs
CHANGED
|
@@ -2475,25 +2475,24 @@ var NCC02Builder = class {
|
|
|
2475
2475
|
* Creates a signed Service Record (Kind 30059).
|
|
2476
2476
|
* @param {Object} options
|
|
2477
2477
|
* @param {string} options.serviceId - The 'd' tag identifier.
|
|
2478
|
-
* @param {string} options.endpoint - The 'u' tag URI.
|
|
2479
|
-
* @param {string} options.fingerprint - The 'k' tag fingerprint.
|
|
2478
|
+
* @param {string} [options.endpoint] - The 'u' tag URI.
|
|
2479
|
+
* @param {string} [options.fingerprint] - The 'k' tag fingerprint.
|
|
2480
2480
|
* @param {number} [options.expiryDays=14] - Expiry in days.
|
|
2481
2481
|
*/
|
|
2482
2482
|
createServiceRecord(options) {
|
|
2483
2483
|
const { serviceId, endpoint, fingerprint, expiryDays = 14 } = options;
|
|
2484
2484
|
if (!serviceId) throw new Error("serviceId (d tag) is required");
|
|
2485
|
-
if (!endpoint) throw new Error("endpoint (u tag) is required");
|
|
2486
|
-
if (!fingerprint) throw new Error("fingerprint (k tag) is required");
|
|
2487
2485
|
const expiry = Math.floor(Date.now() / 1e3) + expiryDays * 24 * 60 * 60;
|
|
2486
|
+
const tags = [
|
|
2487
|
+
["d", serviceId],
|
|
2488
|
+
["exp", expiry.toString()]
|
|
2489
|
+
];
|
|
2490
|
+
if (endpoint) tags.push(["u", endpoint]);
|
|
2491
|
+
if (fingerprint) tags.push(["k", fingerprint]);
|
|
2488
2492
|
const event = {
|
|
2489
2493
|
kind: KINDS.SERVICE_RECORD,
|
|
2490
2494
|
created_at: Math.floor(Date.now() / 1e3),
|
|
2491
|
-
tags
|
|
2492
|
-
["d", serviceId],
|
|
2493
|
-
["u", endpoint],
|
|
2494
|
-
["k", fingerprint],
|
|
2495
|
-
["exp", expiry.toString()]
|
|
2496
|
-
],
|
|
2495
|
+
tags,
|
|
2497
2496
|
content: `NCC-02 Service Record for ${serviceId}`,
|
|
2498
2497
|
pubkey: this.pk
|
|
2499
2498
|
};
|
|
@@ -7659,6 +7658,14 @@ var NCC02Resolver = class {
|
|
|
7659
7658
|
this.ownsPool = !options.pool;
|
|
7660
7659
|
this.trustedCAPubkeys = new Set(options.trustedCAPubkeys || []);
|
|
7661
7660
|
}
|
|
7661
|
+
/**
|
|
7662
|
+
* Closes the connection to the relays if the pool is owned by this resolver.
|
|
7663
|
+
*/
|
|
7664
|
+
close() {
|
|
7665
|
+
if (this.ownsPool && this.pool) {
|
|
7666
|
+
this.pool.close(this.relays);
|
|
7667
|
+
}
|
|
7668
|
+
}
|
|
7662
7669
|
/**
|
|
7663
7670
|
* Internal query helper using SimplePool.subscribeMany (since list() is deprecated).
|
|
7664
7671
|
* @param {import('nostr-tools').Filter} filter
|
|
@@ -7718,8 +7725,11 @@ var NCC02Resolver = class {
|
|
|
7718
7725
|
}
|
|
7719
7726
|
const serviceTags = Object.fromEntries(serviceEvent.tags);
|
|
7720
7727
|
const now2 = Math.floor(Date.now() / 1e3);
|
|
7721
|
-
if (!serviceTags.
|
|
7722
|
-
throw new NCC02Error("MALFORMED_RECORD", "Service record is missing required
|
|
7728
|
+
if (!serviceTags.exp) {
|
|
7729
|
+
throw new NCC02Error("MALFORMED_RECORD", "Service record is missing required tag (exp)");
|
|
7730
|
+
}
|
|
7731
|
+
if (serviceTags.u && (serviceTags.u.startsWith("wss://") || serviceTags.u.startsWith("https://")) && !serviceTags.k) {
|
|
7732
|
+
throw new NCC02Error("MALFORMED_RECORD", "Service record with 'https' or 'wss' endpoint must have a 'k' tag");
|
|
7723
7733
|
}
|
|
7724
7734
|
const exp = parseInt(serviceTags.exp);
|
|
7725
7735
|
if (isNaN(exp)) {
|
|
@@ -7728,35 +7738,37 @@ var NCC02Resolver = class {
|
|
|
7728
7738
|
if (exp < now2) {
|
|
7729
7739
|
throw new NCC02Error("EXPIRED", "Service record has expired");
|
|
7730
7740
|
}
|
|
7731
|
-
let attestations;
|
|
7732
|
-
let revocations;
|
|
7733
|
-
try {
|
|
7734
|
-
[attestations, revocations] = await Promise.all([
|
|
7735
|
-
this._query({ kinds: [KINDS.ATTESTATION], "#e": [serviceEvent.id] }),
|
|
7736
|
-
this._query({ kinds: [KINDS.REVOCATION] })
|
|
7737
|
-
]);
|
|
7738
|
-
} catch (err) {
|
|
7739
|
-
throw new NCC02Error("RELAY_ERROR", "Failed to query relay for attestations/revocations", err);
|
|
7740
|
-
}
|
|
7741
7741
|
const validAttestations = [];
|
|
7742
|
-
|
|
7743
|
-
|
|
7744
|
-
|
|
7745
|
-
|
|
7746
|
-
|
|
7747
|
-
|
|
7748
|
-
|
|
7749
|
-
|
|
7750
|
-
|
|
7751
|
-
|
|
7752
|
-
|
|
7753
|
-
|
|
7754
|
-
|
|
7742
|
+
if (requireAttestation || minLevel === "verified" || minLevel === "hardened") {
|
|
7743
|
+
let attestations;
|
|
7744
|
+
let revocations;
|
|
7745
|
+
try {
|
|
7746
|
+
[attestations, revocations] = await Promise.all([
|
|
7747
|
+
this._query({ kinds: [KINDS.ATTESTATION], "#e": [serviceEvent.id] }),
|
|
7748
|
+
this._query({ kinds: [KINDS.REVOCATION] })
|
|
7749
|
+
]);
|
|
7750
|
+
} catch (err) {
|
|
7751
|
+
throw new NCC02Error("RELAY_ERROR", "Failed to query relay for attestations/revocations", err);
|
|
7752
|
+
}
|
|
7753
|
+
for (const att of attestations) {
|
|
7754
|
+
if (this.trustedCAPubkeys.has(att.pubkey)) {
|
|
7755
|
+
const attTags = Object.fromEntries(att.tags);
|
|
7756
|
+
if (attTags.subj !== pubkey) continue;
|
|
7757
|
+
if (attTags.srv !== serviceId) continue;
|
|
7758
|
+
if (standard && attTags.std !== standard) continue;
|
|
7759
|
+
if (minLevel && !this._isLevelSufficient(attTags.lvl, minLevel)) continue;
|
|
7760
|
+
if (this._isAttestationValid(att, attTags, revocations)) {
|
|
7761
|
+
validAttestations.push({
|
|
7762
|
+
pubkey: att.pubkey,
|
|
7763
|
+
level: attTags.lvl,
|
|
7764
|
+
eventId: att.id
|
|
7765
|
+
});
|
|
7766
|
+
}
|
|
7755
7767
|
}
|
|
7756
7768
|
}
|
|
7757
|
-
|
|
7758
|
-
|
|
7759
|
-
|
|
7769
|
+
if (requireAttestation && validAttestations.length === 0) {
|
|
7770
|
+
throw new NCC02Error("POLICY_FAILURE", `No trusted attestations meet the required policy for ${serviceId}`);
|
|
7771
|
+
}
|
|
7760
7772
|
}
|
|
7761
7773
|
return {
|
|
7762
7774
|
endpoint: serviceTags.u,
|
package/dist/models.d.ts
CHANGED
|
@@ -22,14 +22,14 @@ export class NCC02Builder {
|
|
|
22
22
|
* Creates a signed Service Record (Kind 30059).
|
|
23
23
|
* @param {Object} options
|
|
24
24
|
* @param {string} options.serviceId - The 'd' tag identifier.
|
|
25
|
-
* @param {string} options.endpoint - The 'u' tag URI.
|
|
26
|
-
* @param {string} options.fingerprint - The 'k' tag fingerprint.
|
|
25
|
+
* @param {string} [options.endpoint] - The 'u' tag URI.
|
|
26
|
+
* @param {string} [options.fingerprint] - The 'k' tag fingerprint.
|
|
27
27
|
* @param {number} [options.expiryDays=14] - Expiry in days.
|
|
28
28
|
*/
|
|
29
29
|
createServiceRecord(options: {
|
|
30
30
|
serviceId: string;
|
|
31
|
-
endpoint
|
|
32
|
-
fingerprint
|
|
31
|
+
endpoint?: string;
|
|
32
|
+
fingerprint?: string;
|
|
33
33
|
expiryDays?: number;
|
|
34
34
|
}): import("nostr-tools/core").VerifiedEvent;
|
|
35
35
|
/**
|
package/dist/resolver.d.ts
CHANGED
|
@@ -13,8 +13,8 @@ export class NCC02Error extends Error {
|
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
15
|
* @typedef {Object} ResolvedService
|
|
16
|
-
* @property {string} endpoint
|
|
17
|
-
* @property {string} fingerprint
|
|
16
|
+
* @property {string|undefined} endpoint
|
|
17
|
+
* @property {string|undefined} fingerprint
|
|
18
18
|
* @property {number} expiry
|
|
19
19
|
* @property {any[]} attestations
|
|
20
20
|
* @property {string} eventId
|
|
@@ -42,6 +42,10 @@ export class NCC02Resolver {
|
|
|
42
42
|
pool: SimplePool;
|
|
43
43
|
ownsPool: boolean;
|
|
44
44
|
trustedCAPubkeys: Set<string>;
|
|
45
|
+
/**
|
|
46
|
+
* Closes the connection to the relays if the pool is owned by this resolver.
|
|
47
|
+
*/
|
|
48
|
+
close(): void;
|
|
45
49
|
/**
|
|
46
50
|
* Internal query helper using SimplePool.subscribeMany (since list() is deprecated).
|
|
47
51
|
* @param {import('nostr-tools').Filter} filter
|
|
@@ -87,8 +91,8 @@ export class NCC02Resolver {
|
|
|
87
91
|
verifyEndpoint(resolved: ResolvedService, actualFingerprint: string): boolean;
|
|
88
92
|
}
|
|
89
93
|
export type ResolvedService = {
|
|
90
|
-
endpoint: string;
|
|
91
|
-
fingerprint: string;
|
|
94
|
+
endpoint: string | undefined;
|
|
95
|
+
fingerprint: string | undefined;
|
|
92
96
|
expiry: number;
|
|
93
97
|
attestations: any[];
|
|
94
98
|
eventId: string;
|
package/package.json
CHANGED
package/src/models.js
CHANGED
|
@@ -27,26 +27,26 @@ export class NCC02Builder {
|
|
|
27
27
|
* Creates a signed Service Record (Kind 30059).
|
|
28
28
|
* @param {Object} options
|
|
29
29
|
* @param {string} options.serviceId - The 'd' tag identifier.
|
|
30
|
-
* @param {string} options.endpoint - The 'u' tag URI.
|
|
31
|
-
* @param {string} options.fingerprint - The 'k' tag fingerprint.
|
|
30
|
+
* @param {string} [options.endpoint] - The 'u' tag URI.
|
|
31
|
+
* @param {string} [options.fingerprint] - The 'k' tag fingerprint.
|
|
32
32
|
* @param {number} [options.expiryDays=14] - Expiry in days.
|
|
33
33
|
*/
|
|
34
34
|
createServiceRecord(options) {
|
|
35
35
|
const { serviceId, endpoint, fingerprint, expiryDays = 14 } = options;
|
|
36
36
|
if (!serviceId) throw new Error('serviceId (d tag) is required');
|
|
37
|
-
if (!endpoint) throw new Error('endpoint (u tag) is required');
|
|
38
|
-
if (!fingerprint) throw new Error('fingerprint (k tag) is required');
|
|
39
37
|
|
|
40
38
|
const expiry = Math.floor(Date.now() / 1000) + (expiryDays * 24 * 60 * 60);
|
|
39
|
+
const tags = [
|
|
40
|
+
['d', serviceId],
|
|
41
|
+
['exp', expiry.toString()]
|
|
42
|
+
];
|
|
43
|
+
if(endpoint) tags.push(['u', endpoint]);
|
|
44
|
+
if(fingerprint) tags.push(['k', fingerprint]);
|
|
45
|
+
|
|
41
46
|
const event = {
|
|
42
47
|
kind: KINDS.SERVICE_RECORD,
|
|
43
48
|
created_at: Math.floor(Date.now() / 1000),
|
|
44
|
-
tags:
|
|
45
|
-
['d', serviceId],
|
|
46
|
-
['u', endpoint],
|
|
47
|
-
['k', fingerprint],
|
|
48
|
-
['exp', expiry.toString()]
|
|
49
|
-
],
|
|
49
|
+
tags: tags,
|
|
50
50
|
content: `NCC-02 Service Record for ${serviceId}`,
|
|
51
51
|
pubkey: this.pk
|
|
52
52
|
};
|
package/src/resolver.js
CHANGED
|
@@ -19,8 +19,8 @@ export class NCC02Error extends Error {
|
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* @typedef {Object} ResolvedService
|
|
22
|
-
* @property {string} endpoint
|
|
23
|
-
* @property {string} fingerprint
|
|
22
|
+
* @property {string|undefined} endpoint
|
|
23
|
+
* @property {string|undefined} fingerprint
|
|
24
24
|
* @property {number} expiry
|
|
25
25
|
* @property {any[]} attestations
|
|
26
26
|
* @property {string} eventId
|
|
@@ -43,7 +43,7 @@ export class NCC02Resolver {
|
|
|
43
43
|
*/
|
|
44
44
|
constructor(relays, options = {}) {
|
|
45
45
|
if (!Array.isArray(relays)) {
|
|
46
|
-
|
|
46
|
+
throw new Error('NCC02Resolver expects an array of relay URLs.');
|
|
47
47
|
}
|
|
48
48
|
this.relays = relays;
|
|
49
49
|
this.pool = options.pool || new SimplePool();
|
|
@@ -51,22 +51,31 @@ export class NCC02Resolver {
|
|
|
51
51
|
this.trustedCAPubkeys = new Set(options.trustedCAPubkeys || []);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Closes the connection to the relays if the pool is owned by this resolver.
|
|
56
|
+
*/
|
|
57
|
+
close() {
|
|
58
|
+
if (this.ownsPool && this.pool) {
|
|
59
|
+
this.pool.close(this.relays);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
54
63
|
/**
|
|
55
64
|
* Internal query helper using SimplePool.subscribeMany (since list() is deprecated).
|
|
56
65
|
* @param {import('nostr-tools').Filter} filter
|
|
57
66
|
* @returns {Promise<import('nostr-tools').Event[]>}
|
|
58
67
|
*/
|
|
59
68
|
async _query(filter) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
});
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
/** @type {import('nostr-tools').Event[]} */
|
|
71
|
+
const events = [];
|
|
72
|
+
// subscribeMany(relays, filters, callbacks)
|
|
73
|
+
// @ts-ignore - subscribeMany filters parameter type mismatch with simple Object
|
|
74
|
+
const sub = this.pool.subscribeMany(this.relays, [filter], {
|
|
75
|
+
onevent(e) { events.push(e); },
|
|
76
|
+
oneose() { sub.close(); resolve(events); }
|
|
69
77
|
});
|
|
78
|
+
});
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
/**
|
|
@@ -117,8 +126,13 @@ export class NCC02Resolver {
|
|
|
117
126
|
const now = Math.floor(Date.now() / 1000);
|
|
118
127
|
|
|
119
128
|
// Security Fix: exp is REQUIRED by NCC-02 spec
|
|
120
|
-
if (!serviceTags.
|
|
121
|
-
throw new NCC02Error('MALFORMED_RECORD', 'Service record is missing required
|
|
129
|
+
if (!serviceTags.exp) {
|
|
130
|
+
throw new NCC02Error('MALFORMED_RECORD', 'Service record is missing required tag (exp)');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 'k' is required for TLS-based endpoints
|
|
134
|
+
if (serviceTags.u && (serviceTags.u.startsWith('wss://') || serviceTags.u.startsWith('https://')) && !serviceTags.k) {
|
|
135
|
+
throw new NCC02Error('MALFORMED_RECORD', 'Service record with \'https\' or \'wss\' endpoint must have a \'k\' tag');
|
|
122
136
|
}
|
|
123
137
|
|
|
124
138
|
const exp = parseInt(serviceTags.exp);
|
|
@@ -129,42 +143,46 @@ export class NCC02Resolver {
|
|
|
129
143
|
throw new NCC02Error('EXPIRED', 'Service record has expired');
|
|
130
144
|
}
|
|
131
145
|
|
|
132
|
-
let attestations;
|
|
133
|
-
let revocations;
|
|
134
|
-
try {
|
|
135
|
-
[attestations, revocations] = await Promise.all([
|
|
136
|
-
this._query({ kinds: [KINDS.ATTESTATION], '#e': [serviceEvent.id] }),
|
|
137
|
-
this._query({ kinds: [KINDS.REVOCATION] })
|
|
138
|
-
]);
|
|
139
|
-
} catch (err) {
|
|
140
|
-
throw new NCC02Error('RELAY_ERROR', 'Failed to query relay for attestations/revocations', err);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
146
|
const validAttestations = [];
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
147
|
+
|
|
148
|
+
// Optimization: Only fetch attestations if policy requires it
|
|
149
|
+
if (requireAttestation || minLevel === 'verified' || minLevel === 'hardened') {
|
|
150
|
+
let attestations;
|
|
151
|
+
let revocations;
|
|
152
|
+
try {
|
|
153
|
+
[attestations, revocations] = await Promise.all([
|
|
154
|
+
this._query({ kinds: [KINDS.ATTESTATION], '#e': [serviceEvent.id] }),
|
|
155
|
+
this._query({ kinds: [KINDS.REVOCATION] })
|
|
156
|
+
]);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
throw new NCC02Error('RELAY_ERROR', 'Failed to query relay for attestations/revocations', err);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (const att of attestations) {
|
|
162
|
+
if (this.trustedCAPubkeys.has(att.pubkey)) {
|
|
163
|
+
const attTags = Object.fromEntries(att.tags);
|
|
164
|
+
|
|
165
|
+
// Cross-validate subject, service ID, and standard
|
|
166
|
+
if (attTags.subj !== pubkey) continue;
|
|
167
|
+
if (attTags.srv !== serviceId) continue;
|
|
168
|
+
if (standard && attTags.std !== standard) continue;
|
|
169
|
+
|
|
170
|
+
// Trust Level Filtering
|
|
171
|
+
if (minLevel && !this._isLevelSufficient(attTags.lvl, minLevel)) continue;
|
|
172
|
+
|
|
173
|
+
if (this._isAttestationValid(att, attTags, revocations)) {
|
|
174
|
+
validAttestations.push({
|
|
175
|
+
pubkey: att.pubkey,
|
|
176
|
+
level: attTags.lvl,
|
|
177
|
+
eventId: att.id
|
|
178
|
+
});
|
|
179
|
+
}
|
|
162
180
|
}
|
|
163
181
|
}
|
|
164
|
-
}
|
|
165
182
|
|
|
166
|
-
|
|
167
|
-
|
|
183
|
+
if (requireAttestation && validAttestations.length === 0) {
|
|
184
|
+
throw new NCC02Error('POLICY_FAILURE', `No trusted attestations meet the required policy for ${serviceId}`);
|
|
185
|
+
}
|
|
168
186
|
}
|
|
169
187
|
|
|
170
188
|
return {
|