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/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} ResolvedService
22
+ * @typedef {Object} ServiceStatus
22
23
  * @property {string|undefined} endpoint
23
24
  * @property {string|undefined} fingerprint
24
25
  * @property {number} expiry
25
- * @property {any[]} attestations
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
- * @throws {NCC02Error} If verification or policy checks fail.
91
- * @returns {Promise<ResolvedService>} The verified service details.
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 serviceEvents;
128
+ let serviceEvent;
101
129
  try {
102
- serviceEvents = await this._query({
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 (!serviceEvents || !serviceEvents.length) {
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
- const validAttestations = [];
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
- }
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
- if (requireAttestation && validAttestations.length === 0) {
184
- throw new NCC02Error('POLICY_FAILURE', `No trusted attestations meet the required policy for ${serviceId}`);
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 {string | undefined} actual
200
- * @param {string} required
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
- _isLevelSufficient(actual, required) {
203
- /** @type {Record<string, number>} */
204
- const levels = { 'self': 0, 'verified': 1, 'hardened': 2 };
205
- const actualVal = actual ? (levels[actual] ?? -1) : -1;
206
- const requiredVal = levels[required] ?? 0;
207
- return actualVal >= requiredVal;
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} att
212
- * @param {any} tags
213
- * @param {any[]} revocations
254
+ * @param {any[]} revocations
255
+ * @returns {Record<string, any[]>}
256
+ */
257
+ /**
258
+ * @param {any[]} revocations
259
+ * @returns {Record<string, any[]>}
214
260
  */
215
- _isAttestationValid(att, tags, revocations) {
216
- // 1. Verify Attestation signature
217
- if (!verifyEvent(att)) return false;
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
- // 4. Revocation validation
234
- // A revocation is valid only if it matches the attestation ID, is from the same CA, AND has a valid signature.
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
- return true; // No valid revocation found
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 {ResolvedService} resolved - The object returned by resolve().
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
  */