ncc-02-js 0.3.0 → 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.
@@ -51,6 +53,15 @@ export class NCC02Resolver {
51
53
  this.trustedCAPubkeys = new Set(options.trustedCAPubkeys || []);
52
54
  }
53
55
 
56
+ /**
57
+ * Closes the connection to the relays if the pool is owned by this resolver.
58
+ */
59
+ close() {
60
+ if (this.ownsPool && this.pool) {
61
+ this.pool.close(this.relays);
62
+ }
63
+ }
64
+
54
65
  /**
55
66
  * Internal query helper using SimplePool.subscribeMany (since list() is deprecated).
56
67
  * @param {import('nostr-tools').Filter} filter
@@ -69,6 +80,29 @@ export class NCC02Resolver {
69
80
  });
70
81
  }
71
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
+
72
106
  /**
73
107
  * Resolves a service for a given pubkey and service identifier.
74
108
  *
@@ -78,8 +112,8 @@ export class NCC02Resolver {
78
112
  * @param {boolean} [options.requireAttestation=false] - If true, fails if no trusted attestation is found.
79
113
  * @param {string} [options.minLevel=null] - Minimum trust level ('self', 'verified', 'hardened').
80
114
  * @param {string} [options.standard='nostr-service-trust-v0.1'] - Expected trust standard.
81
- * @throws {NCC02Error} If verification or policy checks fail.
82
- * @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.
83
117
  */
84
118
  async resolve(pubkey, serviceId, options = {}) {
85
119
  const {
@@ -88,9 +122,9 @@ export class NCC02Resolver {
88
122
  standard = 'nostr-service-trust-v0.1'
89
123
  } = options;
90
124
 
91
- let serviceEvents;
125
+ let serviceEvent;
92
126
  try {
93
- serviceEvents = await this._query({
127
+ serviceEvent = await this._queryFreshest({
94
128
  kinds: [KINDS.SERVICE_RECORD],
95
129
  authors: [pubkey],
96
130
  '#d': [serviceId]
@@ -99,16 +133,10 @@ export class NCC02Resolver {
99
133
  throw new NCC02Error('RELAY_ERROR', `Failed to query relay for ${serviceId}`, err);
100
134
  }
101
135
 
102
- if (!serviceEvents || !serviceEvents.length) {
136
+ if (!serviceEvent) {
103
137
  throw new NCC02Error('NOT_FOUND', `No service record found for ${serviceId}`);
104
138
  }
105
139
 
106
- // Stable tie-breaking: Sort by created_at DESC, then ID ASC
107
- const serviceEvent = serviceEvents.sort((/** @type {any} */ a, /** @type {any} */ b) => {
108
- if (b.created_at !== a.created_at) return b.created_at - a.created_at;
109
- return a.id.localeCompare(b.id);
110
- })[0];
111
-
112
140
  if (!verifyEvent(serviceEvent)) {
113
141
  throw new NCC02Error('INVALID_SIGNATURE', 'Service record signature verification failed');
114
142
  }
@@ -134,41 +162,14 @@ export class NCC02Resolver {
134
162
  throw new NCC02Error('EXPIRED', 'Service record has expired');
135
163
  }
136
164
 
137
- let attestations;
138
- let revocations;
165
+ let trustData;
139
166
  try {
140
- [attestations, revocations] = await Promise.all([
141
- this._query({ kinds: [KINDS.ATTESTATION], '#e': [serviceEvent.id] }),
142
- this._query({ kinds: [KINDS.REVOCATION] })
143
- ]);
167
+ trustData = await this._buildTrustData(serviceEvent, { pubkey, serviceId, standard, minLevel });
144
168
  } catch (err) {
145
169
  throw new NCC02Error('RELAY_ERROR', 'Failed to query relay for attestations/revocations', err);
146
170
  }
147
171
 
148
- const validAttestations = [];
149
- for (const att of attestations) {
150
- if (this.trustedCAPubkeys.has(att.pubkey)) {
151
- const attTags = Object.fromEntries(att.tags);
152
-
153
- // Cross-validate subject, service ID, and standard
154
- if (attTags.subj !== pubkey) continue;
155
- if (attTags.srv !== serviceId) continue;
156
- if (standard && attTags.std !== standard) continue;
157
-
158
- // Trust Level Filtering
159
- if (minLevel && !this._isLevelSufficient(attTags.lvl, minLevel)) continue;
160
-
161
- if (this._isAttestationValid(att, attTags, revocations)) {
162
- validAttestations.push({
163
- pubkey: att.pubkey,
164
- level: attTags.lvl,
165
- eventId: att.id
166
- });
167
- }
168
- }
169
- }
170
-
171
- if (requireAttestation && validAttestations.length === 0) {
172
+ if (requireAttestation && trustData.validAttestations.length === 0) {
172
173
  throw new NCC02Error('POLICY_FAILURE', `No trusted attestations meet the required policy for ${serviceId}`);
173
174
  }
174
175
 
@@ -176,66 +177,143 @@ export class NCC02Resolver {
176
177
  endpoint: serviceTags.u,
177
178
  fingerprint: serviceTags.k,
178
179
  expiry: exp,
179
- attestations: validAttestations,
180
+ attestations: trustData.validAttestations,
181
+ attestationCount: trustData.validAttestations.length,
182
+ isRevoked: trustData.isRevoked,
180
183
  eventId: serviceEvent.id,
181
- pubkey: serviceEvent.pubkey
184
+ pubkey: serviceEvent.pubkey,
185
+ serviceEvent
182
186
  };
183
187
  }
184
188
 
185
189
  /**
186
- * @param {string | undefined} actual
187
- * @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
188
196
  */
189
- _isLevelSufficient(actual, required) {
190
- /** @type {Record<string, number>} */
191
- const levels = { 'self': 0, 'verified': 1, 'hardened': 2 };
192
- const actualVal = actual ? (levels[actual] ?? -1) : -1;
193
- const requiredVal = levels[required] ?? 0;
194
- 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 };
195
242
  }
196
243
 
197
244
  /**
198
- * @param {any} att
199
- * @param {any} tags
200
- * @param {any[]} revocations
245
+ * @param {any[]} revocations
246
+ * @returns {Record<string, any[]>}
247
+ */
248
+ /**
249
+ * @param {any[]} revocations
250
+ * @returns {Record<string, any[]>}
201
251
  */
202
- _isAttestationValid(att, tags, revocations) {
203
- // 1. Verify Attestation signature
204
- 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 };
205
284
 
206
285
  const now = Math.floor(Date.now() / 1000);
207
-
208
- // 2. NBF validation
286
+
209
287
  if (tags.nbf) {
210
- const nbf = parseInt(tags.nbf);
211
- 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 };
212
290
  }
213
291
 
214
- // 3. EXP validation
215
292
  if (tags.exp) {
216
- const exp = parseInt(tags.exp);
217
- 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 };
218
295
  }
219
296
 
220
- // 4. Revocation validation
221
- // A revocation is valid only if it matches the attestation ID, is from the same CA, AND has a valid signature.
222
- for (const rev of revocations) {
223
- const revTags = Object.fromEntries(rev.tags);
224
- if (revTags.e === att.id && rev.pubkey === att.pubkey) {
225
- if (verifyEvent(rev)) {
226
- return false; // Valid revocation found
227
- }
228
- }
229
- }
297
+ return { valid: true, revoked: false };
298
+ }
230
299
 
231
- 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;
232
310
  }
233
311
 
234
312
  /**
235
313
  * Verifies that the actual fingerprint found during transport-level connection
236
314
  * matches the one declared in the signed service record.
237
315
  *
238
- * @param {ResolvedService} resolved - The object returned by resolve().
316
+ * @param {ServiceStatus} resolved - The object returned by resolve().
239
317
  * @param {string} actualFingerprint - The fingerprint obtained from the service.
240
318
  * @returns {boolean}
241
319
  */