ncc-02-js 0.3.1 → 0.4.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/models.js CHANGED
@@ -1,6 +1,13 @@
1
1
  import { finalizeEvent, verifyEvent, getPublicKey } from 'nostr-tools/pure';
2
2
  import { hexToBytes } from 'nostr-tools/utils';
3
3
 
4
+ /**
5
+ * @typedef {Object} NostrSigner
6
+ * @property {() => Promise<string>} getPublicKey
7
+ * @property {(event: any) => Promise<any>} signEvent
8
+ * @property {(event: any) => Promise<any>} [decryptEvent]
9
+ */
10
+
4
11
  /**
5
12
  * NCC-02 Nostr Event Kinds
6
13
  */
@@ -15,12 +22,78 @@ export const KINDS = {
15
22
  */
16
23
  export class NCC02Builder {
17
24
  /**
18
- * @param {string | Uint8Array} privateKey - The private key to sign events with.
25
+ * @param {string | Uint8Array | NostrSigner} signer - Raw private key or asynchronous signer.
26
+ */
27
+ constructor(signer) {
28
+ if (!signer) throw new Error('Signer or private key is required');
29
+ this.signer = this._normalizeSigner(signer);
30
+ this._pubkeyPromise = this.signer.getPublicKey();
31
+ this._pubkey = undefined;
32
+ }
33
+
34
+ async _getPublicKey() {
35
+ if (!this._pubkey) {
36
+ this._pubkey = await this._pubkeyPromise;
37
+ }
38
+ return this._pubkey;
39
+ }
40
+
41
+ /**
42
+ * @param {any} event
43
+ */
44
+ async _finalizeEvent(event) {
45
+ const pubkey = await this._getPublicKey();
46
+ const eventWithPubkey = { ...event, pubkey };
47
+ const signed = await this.signer.signEvent(eventWithPubkey);
48
+ if (!signed || typeof signed.id !== 'string' || typeof signed.sig !== 'string') {
49
+ throw new Error('Signer must return a signed event with id and sig');
50
+ }
51
+ return signed;
52
+ }
53
+
54
+ /**
55
+ * @param {any} signer
56
+ * @returns {NostrSigner}
19
57
  */
20
- constructor(privateKey) {
21
- if (!privateKey) throw new Error('Private key is required');
22
- this.sk = typeof privateKey === 'string' ? hexToBytes(privateKey) : privateKey;
23
- this.pk = getPublicKey(this.sk);
58
+ _normalizeSigner(signer) {
59
+ if (typeof signer === 'string' || signer instanceof Uint8Array) {
60
+ const privateKey = typeof signer === 'string' ? hexToBytes(signer) : signer;
61
+ const pubkey = getPublicKey(privateKey);
62
+ return {
63
+ getPublicKey: async () => pubkey,
64
+ /** @param {any} event */
65
+ signEvent: async (event) => {
66
+ const clonedEvent = {
67
+ ...event,
68
+ tags: Array.isArray(event.tags) ? event.tags.map((/** @type {any[]} */ tag) => [...tag]) : []
69
+ };
70
+ return finalizeEvent(clonedEvent, privateKey);
71
+ }
72
+ };
73
+ }
74
+
75
+ if (typeof signer === 'object' && signer !== null) {
76
+ if (typeof signer.getPublicKey === 'function' && typeof signer.signEvent === 'function') {
77
+ return {
78
+ getPublicKey: async () => {
79
+ const pubkey = await signer.getPublicKey();
80
+ if (typeof pubkey !== 'string') throw new Error('Signer.getPublicKey must return a hex string');
81
+ return pubkey;
82
+ },
83
+ /** @param {any} event */
84
+ signEvent: async (event) => {
85
+ const signed = await signer.signEvent(event);
86
+ if (!signed || typeof signed.id !== 'string' || typeof signed.sig !== 'string') {
87
+ throw new Error('Signer.signEvent must return a signed event');
88
+ }
89
+ return signed;
90
+ },
91
+ decryptEvent: typeof signer.decryptEvent === 'function' ? signer.decryptEvent.bind(signer) : undefined
92
+ };
93
+ }
94
+ }
95
+
96
+ throw new Error('Unsupported signer provided to NCC02Builder');
24
97
  }
25
98
 
26
99
  /**
@@ -31,7 +104,7 @@ export class NCC02Builder {
31
104
  * @param {string} [options.fingerprint] - The 'k' tag fingerprint.
32
105
  * @param {number} [options.expiryDays=14] - Expiry in days.
33
106
  */
34
- createServiceRecord(options) {
107
+ async createServiceRecord(options) {
35
108
  const { serviceId, endpoint, fingerprint, expiryDays = 14 } = options;
36
109
  if (!serviceId) throw new Error('serviceId (d tag) is required');
37
110
 
@@ -40,17 +113,16 @@ export class NCC02Builder {
40
113
  ['d', serviceId],
41
114
  ['exp', expiry.toString()]
42
115
  ];
43
- if(endpoint) tags.push(['u', endpoint]);
44
- if(fingerprint) tags.push(['k', fingerprint]);
116
+ if (endpoint) tags.push(['u', endpoint]);
117
+ if (fingerprint) tags.push(['k', fingerprint]);
45
118
 
46
119
  const event = {
47
120
  kind: KINDS.SERVICE_RECORD,
48
121
  created_at: Math.floor(Date.now() / 1000),
49
- tags: tags,
50
- content: `NCC-02 Service Record for ${serviceId}`,
51
- pubkey: this.pk
122
+ tags,
123
+ content: `NCC-02 Service Record for ${serviceId}`
52
124
  };
53
- return finalizeEvent(event, this.sk);
125
+ return this._finalizeEvent(event);
54
126
  }
55
127
 
56
128
  /**
@@ -62,7 +134,7 @@ export class NCC02Builder {
62
134
  * @param {string} [options.level='verified'] - The 'lvl' tag level.
63
135
  * @param {number} [options.validDays=30] - Validity in days.
64
136
  */
65
- createAttestation(options) {
137
+ async createAttestation(options) {
66
138
  const { subjectPubkey, serviceId, serviceEventId, level = 'verified', validDays = 30 } = options;
67
139
  if (!subjectPubkey) throw new Error('subjectPubkey is required');
68
140
  if (!serviceId) throw new Error('serviceId is required');
@@ -82,10 +154,9 @@ export class NCC02Builder {
82
154
  ['nbf', now.toString()],
83
155
  ['exp', expiry.toString()]
84
156
  ],
85
- content: 'NCC-02 Attestation',
86
- pubkey: this.pk
157
+ content: 'NCC-02 Attestation'
87
158
  };
88
- return finalizeEvent(event, this.sk);
159
+ return this._finalizeEvent(event);
89
160
  }
90
161
 
91
162
  /**
@@ -94,7 +165,7 @@ export class NCC02Builder {
94
165
  * @param {string} options.attestationId - The 'e' tag referencing the attestation.
95
166
  * @param {string} [options.reason=''] - Optional reason.
96
167
  */
97
- createRevocation(options) {
168
+ async createRevocation(options) {
98
169
  const { attestationId, reason = '' } = options;
99
170
  if (!attestationId) throw new Error('attestationId (e tag) is required');
100
171
 
@@ -104,11 +175,10 @@ export class NCC02Builder {
104
175
  const event = {
105
176
  kind: KINDS.REVOCATION,
106
177
  created_at: Math.floor(Date.now() / 1000),
107
- tags: tags,
108
- content: 'NCC-02 Revocation',
109
- pubkey: this.pk
178
+ tags,
179
+ content: 'NCC-02 Revocation'
110
180
  };
111
- return finalizeEvent(event, this.sk);
181
+ return this._finalizeEvent(event);
112
182
  }
113
183
  }
114
184
 
@@ -119,3 +189,17 @@ export class NCC02Builder {
119
189
  export function verifyNCC02Event(event) {
120
190
  return verifyEvent(event);
121
191
  }
192
+
193
+ /**
194
+ * Checks whether an NCC event has expired based on its 'exp' tag.
195
+ * @param {any} event
196
+ * @returns {boolean}
197
+ */
198
+ export function isExpired(event) {
199
+ if (!event || !Array.isArray(event.tags)) return false;
200
+ const expTag = event.tags.find((/** @type {any[]} */ tag) => tag[0] === 'exp');
201
+ if (!expTag) return false;
202
+ const expiry = parseInt(expTag[1], 10);
203
+ if (Number.isNaN(expiry)) return false;
204
+ return expiry <= Math.floor(Date.now() / 1000);
205
+ }
package/src/resolver.js CHANGED
@@ -18,15 +18,17 @@ export class NCC02Error extends Error {
18
18
  }
19
19
 
20
20
  /**
21
- * @typedef {Object} ResolvedService
21
+ * @typedef {Object} ServiceStatus
22
22
  * @property {string|undefined} endpoint
23
23
  * @property {string|undefined} fingerprint
24
24
  * @property {number} expiry
25
- * @property {any[]} attestations
25
+ * @property {boolean} isRevoked
26
+ * @property {number} attestationCount
27
+ * @property {Array<{eventId:string, level:string, pubkey:string}>} attestations
26
28
  * @property {string} eventId
27
29
  * @property {string} pubkey
30
+ * @property {any} serviceEvent
28
31
  */
29
-
30
32
  /**
31
33
  * Resolver for NCC-02 Service Records.
32
34
  * Implements the client-side resolution and trust verification algorithm.
@@ -78,6 +80,29 @@ export class NCC02Resolver {
78
80
  });
79
81
  }
80
82
 
83
+ /**
84
+ * Returns the first event sorted by freshness (newest created_at, tie broken by id).
85
+ * @param {import('nostr-tools').Event[]} events
86
+ * @returns {import('nostr-tools').Event|null}
87
+ */
88
+ _freshestEvent(events) {
89
+ if (!events || !events.length) return null;
90
+ return events.sort((a, b) => {
91
+ if (b.created_at !== a.created_at) return b.created_at - a.created_at;
92
+ return a.id.localeCompare(b.id);
93
+ })[0];
94
+ }
95
+
96
+ /**
97
+ * Query helper that returns only the freshest event matching the filter.
98
+ * @param {import('nostr-tools').Filter} filter
99
+ * @returns {Promise<import('nostr-tools').Event | null>}
100
+ */
101
+ async _queryFreshest(filter) {
102
+ const events = await this._query(filter);
103
+ return this._freshestEvent(events);
104
+ }
105
+
81
106
  /**
82
107
  * Resolves a service for a given pubkey and service identifier.
83
108
  *
@@ -87,8 +112,8 @@ export class NCC02Resolver {
87
112
  * @param {boolean} [options.requireAttestation=false] - If true, fails if no trusted attestation is found.
88
113
  * @param {string} [options.minLevel=null] - Minimum trust level ('self', 'verified', 'hardened').
89
114
  * @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.
115
+ * @throws {NCC02Error} If verification or policy checks fail.
116
+ * @returns {Promise<ServiceStatus>} The service status including trust metadata.
92
117
  */
93
118
  async resolve(pubkey, serviceId, options = {}) {
94
119
  const {
@@ -97,9 +122,9 @@ export class NCC02Resolver {
97
122
  standard = 'nostr-service-trust-v0.1'
98
123
  } = options;
99
124
 
100
- let serviceEvents;
125
+ let serviceEvent;
101
126
  try {
102
- serviceEvents = await this._query({
127
+ serviceEvent = await this._queryFreshest({
103
128
  kinds: [KINDS.SERVICE_RECORD],
104
129
  authors: [pubkey],
105
130
  '#d': [serviceId]
@@ -108,16 +133,10 @@ export class NCC02Resolver {
108
133
  throw new NCC02Error('RELAY_ERROR', `Failed to query relay for ${serviceId}`, err);
109
134
  }
110
135
 
111
- if (!serviceEvents || !serviceEvents.length) {
136
+ if (!serviceEvent) {
112
137
  throw new NCC02Error('NOT_FOUND', `No service record found for ${serviceId}`);
113
138
  }
114
139
 
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
140
  if (!verifyEvent(serviceEvent)) {
122
141
  throw new NCC02Error('INVALID_SIGNATURE', 'Service record signature verification failed');
123
142
  }
@@ -143,112 +162,158 @@ export class NCC02Resolver {
143
162
  throw new NCC02Error('EXPIRED', 'Service record has expired');
144
163
  }
145
164
 
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
- }
165
+ let trustData;
166
+ try {
167
+ trustData = await this._buildTrustData(serviceEvent, { pubkey, serviceId, standard, minLevel });
168
+ } catch (err) {
169
+ throw new NCC02Error('RELAY_ERROR', 'Failed to query relay for attestations/revocations', err);
170
+ }
182
171
 
183
- if (requireAttestation && validAttestations.length === 0) {
184
- throw new NCC02Error('POLICY_FAILURE', `No trusted attestations meet the required policy for ${serviceId}`);
185
- }
172
+ if (requireAttestation && trustData.validAttestations.length === 0) {
173
+ throw new NCC02Error('POLICY_FAILURE', `No trusted attestations meet the required policy for ${serviceId}`);
186
174
  }
187
175
 
188
176
  return {
189
177
  endpoint: serviceTags.u,
190
178
  fingerprint: serviceTags.k,
191
179
  expiry: exp,
192
- attestations: validAttestations,
180
+ attestations: trustData.validAttestations,
181
+ attestationCount: trustData.validAttestations.length,
182
+ isRevoked: trustData.isRevoked,
193
183
  eventId: serviceEvent.id,
194
- pubkey: serviceEvent.pubkey
184
+ pubkey: serviceEvent.pubkey,
185
+ serviceEvent
195
186
  };
196
187
  }
197
188
 
198
189
  /**
199
- * @param {string | undefined} actual
200
- * @param {string} required
190
+ * @param {any} serviceEvent
191
+ * @param {Object} options
192
+ * @param {string} options.pubkey
193
+ * @param {string} options.serviceId
194
+ * @param {string|null} options.standard
195
+ * @param {string|null} options.minLevel
201
196
  */
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;
197
+ async _buildTrustData(serviceEvent, options) {
198
+ const attestations = await this._query({
199
+ kinds: [KINDS.ATTESTATION],
200
+ '#e': [serviceEvent.id]
201
+ });
202
+
203
+ const attestationIds = attestations.map(att => att.id);
204
+ /** @type {any[]} */
205
+ let revocations = [];
206
+ if (attestationIds.length) {
207
+ revocations = await this._query({
208
+ kinds: [KINDS.REVOCATION],
209
+ '#e': attestationIds
210
+ });
211
+ }
212
+
213
+ /** @type {Record<string, any[]>} */
214
+ const revocationIndex = this._groupValidRevocations(revocations);
215
+ const validAttestations = [];
216
+ let isRevoked = false;
217
+
218
+ for (const att of attestations) {
219
+ if (!this.trustedCAPubkeys.has(att.pubkey)) continue;
220
+ const attTags = Object.fromEntries(att.tags);
221
+ if (attTags.subj !== options.pubkey) continue;
222
+ if (attTags.srv !== options.serviceId) continue;
223
+ if (options.standard && attTags.std !== options.standard) continue;
224
+
225
+ const { valid, revoked } = this._evaluateAttestation(att, attTags, revocationIndex[att.id]);
226
+ if (revoked) {
227
+ isRevoked = true;
228
+ continue;
229
+ }
230
+ if (!valid) continue;
231
+
232
+ if (options.minLevel && !this._isLevelSufficient(attTags.lvl, options.minLevel)) continue;
233
+
234
+ validAttestations.push({
235
+ pubkey: att.pubkey,
236
+ level: attTags.lvl,
237
+ eventId: att.id
238
+ });
239
+ }
240
+
241
+ return { validAttestations, isRevoked };
208
242
  }
209
243
 
210
244
  /**
211
- * @param {any} att
212
- * @param {any} tags
213
- * @param {any[]} revocations
245
+ * @param {any[]} revocations
246
+ * @returns {Record<string, any[]>}
247
+ */
248
+ /**
249
+ * @param {any[]} revocations
250
+ * @returns {Record<string, any[]>}
214
251
  */
215
- _isAttestationValid(att, tags, revocations) {
216
- // 1. Verify Attestation signature
217
- if (!verifyEvent(att)) return false;
252
+ _groupValidRevocations(revocations) {
253
+ /** @type {Record<string, any[]>} */
254
+ const indexed = {};
255
+ for (const rev of revocations) {
256
+ if (!verifyEvent(rev)) continue;
257
+ const tags = Object.fromEntries(rev.tags);
258
+ const targetId = tags.e;
259
+ if (!targetId) continue;
260
+ if (!indexed[targetId]) indexed[targetId] = [];
261
+ indexed[targetId].push(rev);
262
+ }
263
+ return indexed;
264
+ }
265
+
266
+ /**
267
+ * @param {any} att
268
+ * @param {Record<string, string>} tags
269
+ * @param {any[]} revocations
270
+ */
271
+ /**
272
+ * @param {any} att
273
+ * @param {Record<string, string>} tags
274
+ * @param {any[]} [revocations]
275
+ */
276
+ _evaluateAttestation(att, tags, revocations = []) {
277
+ for (const rev of revocations) {
278
+ if (rev.pubkey === att.pubkey) {
279
+ return { valid: false, revoked: true };
280
+ }
281
+ }
282
+
283
+ if (!verifyEvent(att)) return { valid: false, revoked: false };
218
284
 
219
285
  const now = Math.floor(Date.now() / 1000);
220
-
221
- // 2. NBF validation
286
+
222
287
  if (tags.nbf) {
223
- const nbf = parseInt(tags.nbf);
224
- if (isNaN(nbf) || nbf > now) return false;
288
+ const nbf = parseInt(tags.nbf, 10);
289
+ if (isNaN(nbf) || nbf > now) return { valid: false, revoked: false };
225
290
  }
226
291
 
227
- // 3. EXP validation
228
292
  if (tags.exp) {
229
- const exp = parseInt(tags.exp);
230
- if (isNaN(exp) || exp < now) return false;
293
+ const exp = parseInt(tags.exp, 10);
294
+ if (isNaN(exp) || exp < now) return { valid: false, revoked: false };
231
295
  }
232
296
 
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
- }
297
+ return { valid: true, revoked: false };
298
+ }
243
299
 
244
- return true; // No valid revocation found
300
+ /**
301
+ * @param {string | undefined} actual
302
+ * @param {string} required
303
+ */
304
+ _isLevelSufficient(actual, required) {
305
+ /** @type {Record<string, number>} */
306
+ const levels = { 'self': 0, 'verified': 1, 'hardened': 2 };
307
+ const actualVal = actual ? (levels[actual] ?? -1) : -1;
308
+ const requiredVal = levels[required] ?? 0;
309
+ return actualVal >= requiredVal;
245
310
  }
246
311
 
247
312
  /**
248
313
  * Verifies that the actual fingerprint found during transport-level connection
249
314
  * matches the one declared in the signed service record.
250
315
  *
251
- * @param {ResolvedService} resolved - The object returned by resolve().
316
+ * @param {ServiceStatus} resolved - The object returned by resolve().
252
317
  * @param {string} actualFingerprint - The fingerprint obtained from the service.
253
318
  * @returns {boolean}
254
319
  */