ncc-02-js 0.3.1 → 0.5.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 +93 -21
- package/dist/index.cjs +375 -86
- package/dist/index.d.ts +1 -0
- package/dist/index.mjs +369 -86
- package/dist/models.d.ts +33 -7
- package/dist/privacy.d.ts +44 -0
- package/dist/resolver.d.ts +78 -14
- package/package.json +1 -1
- package/src/index.js +1 -0
- package/src/models.js +117 -22
- package/src/privacy.js +217 -0
- package/src/resolver.js +162 -88
package/src/resolver.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { SimplePool, verifyEvent } from 'nostr-tools';
|
|
2
2
|
import { KINDS } from './models.js';
|
|
3
|
+
import { collectPrivateRecipients, parsePrivateFlag } from './privacy.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Custom error class for NCC-02 specific failures.
|
|
@@ -18,15 +19,19 @@ export class NCC02Error extends Error {
|
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
|
-
* @typedef {Object}
|
|
22
|
+
* @typedef {Object} ServiceStatus
|
|
22
23
|
* @property {string|undefined} endpoint
|
|
23
24
|
* @property {string|undefined} fingerprint
|
|
24
25
|
* @property {number} expiry
|
|
25
|
-
* @property {
|
|
26
|
+
* @property {boolean} isRevoked
|
|
27
|
+
* @property {number} attestationCount
|
|
28
|
+
* @property {Array<{eventId:string, level:string, pubkey:string}>} attestations
|
|
26
29
|
* @property {string} eventId
|
|
27
30
|
* @property {string} pubkey
|
|
31
|
+
* @property {any} serviceEvent
|
|
32
|
+
* @property {boolean} isPrivate
|
|
33
|
+
* @property {string[]} privateRecipients
|
|
28
34
|
*/
|
|
29
|
-
|
|
30
35
|
/**
|
|
31
36
|
* Resolver for NCC-02 Service Records.
|
|
32
37
|
* Implements the client-side resolution and trust verification algorithm.
|
|
@@ -78,6 +83,29 @@ export class NCC02Resolver {
|
|
|
78
83
|
});
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Returns the first event sorted by freshness (newest created_at, tie broken by id).
|
|
88
|
+
* @param {import('nostr-tools').Event[]} events
|
|
89
|
+
* @returns {import('nostr-tools').Event|null}
|
|
90
|
+
*/
|
|
91
|
+
_freshestEvent(events) {
|
|
92
|
+
if (!events || !events.length) return null;
|
|
93
|
+
return events.sort((a, b) => {
|
|
94
|
+
if (b.created_at !== a.created_at) return b.created_at - a.created_at;
|
|
95
|
+
return a.id.localeCompare(b.id);
|
|
96
|
+
})[0];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Query helper that returns only the freshest event matching the filter.
|
|
101
|
+
* @param {import('nostr-tools').Filter} filter
|
|
102
|
+
* @returns {Promise<import('nostr-tools').Event | null>}
|
|
103
|
+
*/
|
|
104
|
+
async _queryFreshest(filter) {
|
|
105
|
+
const events = await this._query(filter);
|
|
106
|
+
return this._freshestEvent(events);
|
|
107
|
+
}
|
|
108
|
+
|
|
81
109
|
/**
|
|
82
110
|
* Resolves a service for a given pubkey and service identifier.
|
|
83
111
|
*
|
|
@@ -87,8 +115,8 @@ export class NCC02Resolver {
|
|
|
87
115
|
* @param {boolean} [options.requireAttestation=false] - If true, fails if no trusted attestation is found.
|
|
88
116
|
* @param {string} [options.minLevel=null] - Minimum trust level ('self', 'verified', 'hardened').
|
|
89
117
|
* @param {string} [options.standard='nostr-service-trust-v0.1'] - Expected trust standard.
|
|
90
|
-
|
|
91
|
-
|
|
118
|
+
* @throws {NCC02Error} If verification or policy checks fail.
|
|
119
|
+
* @returns {Promise<ServiceStatus>} The service status including trust metadata.
|
|
92
120
|
*/
|
|
93
121
|
async resolve(pubkey, serviceId, options = {}) {
|
|
94
122
|
const {
|
|
@@ -97,9 +125,9 @@ export class NCC02Resolver {
|
|
|
97
125
|
standard = 'nostr-service-trust-v0.1'
|
|
98
126
|
} = options;
|
|
99
127
|
|
|
100
|
-
let
|
|
128
|
+
let serviceEvent;
|
|
101
129
|
try {
|
|
102
|
-
|
|
130
|
+
serviceEvent = await this._queryFreshest({
|
|
103
131
|
kinds: [KINDS.SERVICE_RECORD],
|
|
104
132
|
authors: [pubkey],
|
|
105
133
|
'#d': [serviceId]
|
|
@@ -108,22 +136,20 @@ export class NCC02Resolver {
|
|
|
108
136
|
throw new NCC02Error('RELAY_ERROR', `Failed to query relay for ${serviceId}`, err);
|
|
109
137
|
}
|
|
110
138
|
|
|
111
|
-
if (!
|
|
139
|
+
if (!serviceEvent) {
|
|
112
140
|
throw new NCC02Error('NOT_FOUND', `No service record found for ${serviceId}`);
|
|
113
141
|
}
|
|
114
142
|
|
|
115
|
-
// Stable tie-breaking: Sort by created_at DESC, then ID ASC
|
|
116
|
-
const serviceEvent = serviceEvents.sort((/** @type {any} */ a, /** @type {any} */ b) => {
|
|
117
|
-
if (b.created_at !== a.created_at) return b.created_at - a.created_at;
|
|
118
|
-
return a.id.localeCompare(b.id);
|
|
119
|
-
})[0];
|
|
120
|
-
|
|
121
143
|
if (!verifyEvent(serviceEvent)) {
|
|
122
144
|
throw new NCC02Error('INVALID_SIGNATURE', 'Service record signature verification failed');
|
|
123
145
|
}
|
|
124
146
|
|
|
125
147
|
const serviceTags = Object.fromEntries(serviceEvent.tags);
|
|
126
148
|
const now = Math.floor(Date.now() / 1000);
|
|
149
|
+
const privateFlag = parsePrivateFlag(serviceEvent.tags);
|
|
150
|
+
if (privateFlag === null) {
|
|
151
|
+
throw new NCC02Error('MALFORMED_RECORD', 'Service record is missing required tag (private)');
|
|
152
|
+
}
|
|
127
153
|
|
|
128
154
|
// Security Fix: exp is REQUIRED by NCC-02 spec
|
|
129
155
|
if (!serviceTags.exp) {
|
|
@@ -143,112 +169,160 @@ export class NCC02Resolver {
|
|
|
143
169
|
throw new NCC02Error('EXPIRED', 'Service record has expired');
|
|
144
170
|
}
|
|
145
171
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
}
|
|
180
|
-
}
|
|
181
|
-
}
|
|
172
|
+
let trustData;
|
|
173
|
+
try {
|
|
174
|
+
trustData = await this._buildTrustData(serviceEvent, { pubkey, serviceId, standard, minLevel });
|
|
175
|
+
} catch (err) {
|
|
176
|
+
throw new NCC02Error('RELAY_ERROR', 'Failed to query relay for attestations/revocations', err);
|
|
177
|
+
}
|
|
182
178
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
179
|
+
if (requireAttestation && trustData.validAttestations.length === 0) {
|
|
180
|
+
throw new NCC02Error('POLICY_FAILURE', `No trusted attestations meet the required policy for ${serviceId}`);
|
|
186
181
|
}
|
|
187
182
|
|
|
188
183
|
return {
|
|
189
184
|
endpoint: serviceTags.u,
|
|
190
185
|
fingerprint: serviceTags.k,
|
|
191
186
|
expiry: exp,
|
|
192
|
-
attestations: validAttestations,
|
|
187
|
+
attestations: trustData.validAttestations,
|
|
188
|
+
attestationCount: trustData.validAttestations.length,
|
|
189
|
+
isRevoked: trustData.isRevoked,
|
|
190
|
+
isPrivate: privateFlag,
|
|
191
|
+
privateRecipients: collectPrivateRecipients(serviceEvent.tags),
|
|
193
192
|
eventId: serviceEvent.id,
|
|
194
|
-
pubkey: serviceEvent.pubkey
|
|
193
|
+
pubkey: serviceEvent.pubkey,
|
|
194
|
+
serviceEvent
|
|
195
195
|
};
|
|
196
196
|
}
|
|
197
197
|
|
|
198
198
|
/**
|
|
199
|
-
* @param {
|
|
200
|
-
* @param {
|
|
199
|
+
* @param {any} serviceEvent
|
|
200
|
+
* @param {Object} options
|
|
201
|
+
* @param {string} options.pubkey
|
|
202
|
+
* @param {string} options.serviceId
|
|
203
|
+
* @param {string|null} options.standard
|
|
204
|
+
* @param {string|null} options.minLevel
|
|
201
205
|
*/
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
206
|
+
async _buildTrustData(serviceEvent, options) {
|
|
207
|
+
const attestations = await this._query({
|
|
208
|
+
kinds: [KINDS.ATTESTATION],
|
|
209
|
+
'#e': [serviceEvent.id]
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const attestationIds = attestations.map(att => att.id);
|
|
213
|
+
/** @type {any[]} */
|
|
214
|
+
let revocations = [];
|
|
215
|
+
if (attestationIds.length) {
|
|
216
|
+
revocations = await this._query({
|
|
217
|
+
kinds: [KINDS.REVOCATION],
|
|
218
|
+
'#e': attestationIds
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @type {Record<string, any[]>} */
|
|
223
|
+
const revocationIndex = this._groupValidRevocations(revocations);
|
|
224
|
+
const validAttestations = [];
|
|
225
|
+
let isRevoked = false;
|
|
226
|
+
|
|
227
|
+
for (const att of attestations) {
|
|
228
|
+
if (!this.trustedCAPubkeys.has(att.pubkey)) continue;
|
|
229
|
+
const attTags = Object.fromEntries(att.tags);
|
|
230
|
+
if (attTags.subj !== options.pubkey) continue;
|
|
231
|
+
if (attTags.srv !== options.serviceId) continue;
|
|
232
|
+
if (options.standard && attTags.std !== options.standard) continue;
|
|
233
|
+
|
|
234
|
+
const { valid, revoked } = this._evaluateAttestation(att, attTags, revocationIndex[att.id]);
|
|
235
|
+
if (revoked) {
|
|
236
|
+
isRevoked = true;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (!valid) continue;
|
|
240
|
+
|
|
241
|
+
if (options.minLevel && !this._isLevelSufficient(attTags.lvl, options.minLevel)) continue;
|
|
242
|
+
|
|
243
|
+
validAttestations.push({
|
|
244
|
+
pubkey: att.pubkey,
|
|
245
|
+
level: attTags.lvl,
|
|
246
|
+
eventId: att.id
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { validAttestations, isRevoked };
|
|
208
251
|
}
|
|
209
252
|
|
|
210
253
|
/**
|
|
211
|
-
* @param {any}
|
|
212
|
-
* @
|
|
213
|
-
|
|
254
|
+
* @param {any[]} revocations
|
|
255
|
+
* @returns {Record<string, any[]>}
|
|
256
|
+
*/
|
|
257
|
+
/**
|
|
258
|
+
* @param {any[]} revocations
|
|
259
|
+
* @returns {Record<string, any[]>}
|
|
214
260
|
*/
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
261
|
+
_groupValidRevocations(revocations) {
|
|
262
|
+
/** @type {Record<string, any[]>} */
|
|
263
|
+
const indexed = {};
|
|
264
|
+
for (const rev of revocations) {
|
|
265
|
+
if (!verifyEvent(rev)) continue;
|
|
266
|
+
const tags = Object.fromEntries(rev.tags);
|
|
267
|
+
const targetId = tags.e;
|
|
268
|
+
if (!targetId) continue;
|
|
269
|
+
if (!indexed[targetId]) indexed[targetId] = [];
|
|
270
|
+
indexed[targetId].push(rev);
|
|
271
|
+
}
|
|
272
|
+
return indexed;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* @param {any} att
|
|
277
|
+
* @param {Record<string, string>} tags
|
|
278
|
+
* @param {any[]} revocations
|
|
279
|
+
*/
|
|
280
|
+
/**
|
|
281
|
+
* @param {any} att
|
|
282
|
+
* @param {Record<string, string>} tags
|
|
283
|
+
* @param {any[]} [revocations]
|
|
284
|
+
*/
|
|
285
|
+
_evaluateAttestation(att, tags, revocations = []) {
|
|
286
|
+
for (const rev of revocations) {
|
|
287
|
+
if (rev.pubkey === att.pubkey) {
|
|
288
|
+
return { valid: false, revoked: true };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (!verifyEvent(att)) return { valid: false, revoked: false };
|
|
218
293
|
|
|
219
294
|
const now = Math.floor(Date.now() / 1000);
|
|
220
|
-
|
|
221
|
-
// 2. NBF validation
|
|
295
|
+
|
|
222
296
|
if (tags.nbf) {
|
|
223
|
-
const nbf = parseInt(tags.nbf);
|
|
224
|
-
if (isNaN(nbf) || nbf > now) return false;
|
|
297
|
+
const nbf = parseInt(tags.nbf, 10);
|
|
298
|
+
if (isNaN(nbf) || nbf > now) return { valid: false, revoked: false };
|
|
225
299
|
}
|
|
226
300
|
|
|
227
|
-
// 3. EXP validation
|
|
228
301
|
if (tags.exp) {
|
|
229
|
-
const exp = parseInt(tags.exp);
|
|
230
|
-
if (isNaN(exp) || exp < now) return false;
|
|
302
|
+
const exp = parseInt(tags.exp, 10);
|
|
303
|
+
if (isNaN(exp) || exp < now) return { valid: false, revoked: false };
|
|
231
304
|
}
|
|
232
305
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
for (const rev of revocations) {
|
|
236
|
-
const revTags = Object.fromEntries(rev.tags);
|
|
237
|
-
if (revTags.e === att.id && rev.pubkey === att.pubkey) {
|
|
238
|
-
if (verifyEvent(rev)) {
|
|
239
|
-
return false; // Valid revocation found
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
306
|
+
return { valid: true, revoked: false };
|
|
307
|
+
}
|
|
243
308
|
|
|
244
|
-
|
|
309
|
+
/**
|
|
310
|
+
* @param {string | undefined} actual
|
|
311
|
+
* @param {string} required
|
|
312
|
+
*/
|
|
313
|
+
_isLevelSufficient(actual, required) {
|
|
314
|
+
/** @type {Record<string, number>} */
|
|
315
|
+
const levels = { 'self': 0, 'verified': 1, 'hardened': 2 };
|
|
316
|
+
const actualVal = actual ? (levels[actual] ?? -1) : -1;
|
|
317
|
+
const requiredVal = levels[required] ?? 0;
|
|
318
|
+
return actualVal >= requiredVal;
|
|
245
319
|
}
|
|
246
320
|
|
|
247
321
|
/**
|
|
248
322
|
* Verifies that the actual fingerprint found during transport-level connection
|
|
249
323
|
* matches the one declared in the signed service record.
|
|
250
324
|
*
|
|
251
|
-
* @param {
|
|
325
|
+
* @param {ServiceStatus} resolved - The object returned by resolve().
|
|
252
326
|
* @param {string} actualFingerprint - The fingerprint obtained from the service.
|
|
253
327
|
* @returns {boolean}
|
|
254
328
|
*/
|