ncc-02-js 0.2.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/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "ncc-02-js",
3
+ "version": "0.2.0",
4
+ "description": "Nostr-native service discovery and trust implementation (NCC-02)",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "type": "module",
8
+ "scripts": {
9
+ "test": "node tests/test.js",
10
+ "build": "esbuild src/index.js --bundle --platform=node --format=cjs --outfile=dist/index.js && esbuild src/index.js --bundle --platform=node --format=esm --outfile=dist/index.mjs",
11
+ "lint": "eslint src/**/*.js",
12
+ "type-check": "tsc -p jsconfig.json"
13
+ },
14
+ "keywords": [
15
+ "nostr",
16
+ "ncc-02",
17
+ "service-discovery",
18
+ "trust",
19
+ "verification"
20
+ ],
21
+ "author": "lostcause",
22
+ "license": "CC0-1.0",
23
+ "dependencies": {
24
+ "nostr-tools": "^2.10.4"
25
+ },
26
+ "devDependencies": {
27
+ "@typescript-eslint/eslint-plugin": "^8.25.0",
28
+ "@typescript-eslint/parser": "^8.25.0",
29
+ "esbuild": "^0.25.0",
30
+ "eslint": "^9.21.0",
31
+ "globals": "^16.0.0",
32
+ "typescript": "^5.8.2"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "src",
37
+ "README.md",
38
+ "LICENSE"
39
+ ],
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/imattau/ncc-02.git"
43
+ }
44
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from './models.js';
2
+ export * from './resolver.js';
3
+ export * from './mockRelay.js';
@@ -0,0 +1,54 @@
1
+ import { verifyEvent } from 'nostr-tools/pure';
2
+
3
+ /**
4
+ * Simple in-memory mock relay for testing NCC-02 resolution.
5
+ * Adheres to standard Nostr relay filtering rules.
6
+ */
7
+ export class MockRelay {
8
+ constructor() {
9
+ /** @type {any[]} */
10
+ this.events = [];
11
+ }
12
+
13
+ /**
14
+ * Publishes an event to the relay.
15
+ * Mimics a standard relay by verifying signatures before acceptance.
16
+ * @param {any} event
17
+ */
18
+ async publish(event) {
19
+ if (!verifyEvent(event)) {
20
+ return false;
21
+ }
22
+ this.events.push(event);
23
+ return true;
24
+ }
25
+
26
+ /**
27
+ * Queries events using standard Nostr filters.
28
+ * Supports kinds, authors, ids, and tag filters (e.g. #d, #e).
29
+ * @param {any} filter
30
+ * @returns {Promise<any[]>}
31
+ */
32
+ async query(filter) {
33
+ return this.events.filter(event => {
34
+ if (filter.kinds && !filter.kinds.includes(event.kind)) return false;
35
+ if (filter.authors && !filter.authors.includes(event.pubkey)) return false;
36
+ if (filter.ids && !filter.ids.includes(event.id)) return false;
37
+
38
+ for (const key in filter) {
39
+ if (key.startsWith('#')) {
40
+ const tagName = key.slice(1);
41
+ const filterValues = filter[key];
42
+ const eventTagValues = event.tags
43
+ .filter((/** @type {any[]} */ t) => t[0] === tagName)
44
+ .map((/** @type {any[]} */ t) => t[1]);
45
+
46
+ if (!filterValues.some((/** @type {string} */ fv) => eventTagValues.includes(fv))) {
47
+ return false;
48
+ }
49
+ }
50
+ }
51
+ return true;
52
+ });
53
+ }
54
+ }
package/src/models.js ADDED
@@ -0,0 +1,115 @@
1
+ import { finalizeEvent, verifyEvent, getPublicKey } from 'nostr-tools/pure';
2
+ import { hexToBytes } from 'nostr-tools/utils';
3
+
4
+ /**
5
+ * NCC-02 Nostr Event Kinds
6
+ */
7
+ export const KINDS = {
8
+ SERVICE_RECORD: 30059,
9
+ ATTESTATION: 30060,
10
+ REVOCATION: 30061
11
+ };
12
+
13
+ /**
14
+ * Utility for building and signing NCC-02 events.
15
+ */
16
+ export class NCC02Builder {
17
+ /**
18
+ * @param {string | Uint8Array} privateKey - The private key to sign events with.
19
+ */
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);
24
+ }
25
+
26
+ /**
27
+ * Creates a signed Service Record (Kind 30059).
28
+ * @param {string} serviceId
29
+ * @param {string} endpoint
30
+ * @param {string} fingerprint
31
+ * @param {number} [expiryDays=14]
32
+ */
33
+ createServiceRecord(serviceId, endpoint, fingerprint, expiryDays = 14) {
34
+ if (!serviceId) throw new Error('serviceId (d tag) is required');
35
+ if (!endpoint) throw new Error('endpoint (u tag) is required');
36
+ if (!fingerprint) throw new Error('fingerprint (k tag) is required');
37
+
38
+ const expiry = Math.floor(Date.now() / 1000) + (expiryDays * 24 * 60 * 60);
39
+ const event = {
40
+ kind: KINDS.SERVICE_RECORD,
41
+ created_at: Math.floor(Date.now() / 1000),
42
+ tags: [
43
+ ['d', serviceId],
44
+ ['u', endpoint],
45
+ ['k', fingerprint],
46
+ ['exp', expiry.toString()]
47
+ ],
48
+ content: `NCC-02 Service Record for ${serviceId}`,
49
+ pubkey: this.pk
50
+ };
51
+ return finalizeEvent(event, this.sk);
52
+ }
53
+
54
+ /**
55
+ * Creates a signed Certificate Attestation (Kind 30060).
56
+ * @param {string} subjectPubkey
57
+ * @param {string} serviceId
58
+ * @param {string} serviceEventId
59
+ * @param {string} [level='verified']
60
+ * @param {number} [validDays=30]
61
+ */
62
+ createAttestation(subjectPubkey, serviceId, serviceEventId, level = 'verified', validDays = 30) {
63
+ if (!subjectPubkey) throw new Error('subjectPubkey is required');
64
+ if (!serviceId) throw new Error('serviceId is required');
65
+ if (!serviceEventId) throw new Error('serviceEventId (e tag) is required');
66
+
67
+ const now = Math.floor(Date.now() / 1000);
68
+ const expiry = now + (validDays * 24 * 60 * 60);
69
+ const event = {
70
+ kind: KINDS.ATTESTATION,
71
+ created_at: now,
72
+ tags: [
73
+ ['subj', subjectPubkey],
74
+ ['srv', serviceId],
75
+ ['e', serviceEventId],
76
+ ['std', 'nostr-service-trust-v0.1'],
77
+ ['lvl', level],
78
+ ['nbf', now.toString()],
79
+ ['exp', expiry.toString()]
80
+ ],
81
+ content: 'NCC-02 Attestation',
82
+ pubkey: this.pk
83
+ };
84
+ return finalizeEvent(event, this.sk);
85
+ }
86
+
87
+ /**
88
+ * Creates a signed Revocation (Kind 30061).
89
+ * @param {string} attestationId
90
+ * @param {string} [reason='']
91
+ */
92
+ createRevocation(attestationId, reason = '') {
93
+ if (!attestationId) throw new Error('attestationId (e tag) is required');
94
+
95
+ const tags = [['e', attestationId]];
96
+ if (reason) tags.push(['reason', reason]);
97
+
98
+ const event = {
99
+ kind: KINDS.REVOCATION,
100
+ created_at: Math.floor(Date.now() / 1000),
101
+ tags: tags,
102
+ content: 'NCC-02 Revocation',
103
+ pubkey: this.pk
104
+ };
105
+ return finalizeEvent(event, this.sk);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Verifies a Nostr event signature.
111
+ * @param {any} event
112
+ */
113
+ export function verifyNCC02Event(event) {
114
+ return verifyEvent(event);
115
+ }
@@ -0,0 +1,212 @@
1
+ import { verifyEvent } from 'nostr-tools/pure';
2
+ import { KINDS } from './models.js';
3
+
4
+ /**
5
+ * Custom error class for NCC-02 specific failures.
6
+ */
7
+ export class NCC02Error extends Error {
8
+ /**
9
+ * @param {string} code
10
+ * @param {string} message
11
+ * @param {any} [cause]
12
+ */
13
+ constructor(code, message, cause) {
14
+ super(message);
15
+ this.code = code;
16
+ if (cause) this.cause = cause;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * @typedef {Object} ResolvedService
22
+ * @property {string} endpoint
23
+ * @property {string} fingerprint
24
+ * @property {number} expiry
25
+ * @property {any[]} attestations
26
+ * @property {string} eventId
27
+ * @property {string} pubkey
28
+ */
29
+
30
+ /**
31
+ * Resolver for NCC-02 Service Records.
32
+ * Implements the client-side resolution and trust verification algorithm.
33
+ */
34
+ export class NCC02Resolver {
35
+ /**
36
+ * @param {any} relay - A relay client providing a `query` method.
37
+ * @param {string[]} [trustedCAPubkeys=[]] - List of CA pubkeys trusted by this client.
38
+ */
39
+ constructor(relay, trustedCAPubkeys = []) {
40
+ this.relay = relay;
41
+ this.trustedCAPubkeys = new Set(trustedCAPubkeys);
42
+ }
43
+
44
+ /**
45
+ * Resolves a service for a given pubkey and service identifier.
46
+ *
47
+ * @param {string} pubkey - The pubkey of the service owner.
48
+ * @param {string} serviceId - The 'd' tag identifier of the service (e.g., 'api').
49
+ * @param {Object} [options={}] - Policy options.
50
+ * @param {boolean} [options.requireAttestation=false] - If true, fails if no trusted attestation is found.
51
+ * @param {string} [options.minLevel=null] - Minimum trust level ('self', 'verified', 'hardened').
52
+ * @param {string} [options.standard='nostr-service-trust-v0.1'] - Expected trust standard.
53
+ * @throws {NCC02Error} If verification or policy checks fail.
54
+ * @returns {Promise<ResolvedService>} The verified service details.
55
+ */
56
+ async resolve(pubkey, serviceId, options = {}) {
57
+ const {
58
+ requireAttestation = false,
59
+ minLevel = null,
60
+ standard = 'nostr-service-trust-v0.1'
61
+ } = options;
62
+
63
+ let serviceEvents;
64
+ try {
65
+ serviceEvents = await this.relay.query({
66
+ kinds: [KINDS.SERVICE_RECORD],
67
+ authors: [pubkey],
68
+ '#d': [serviceId]
69
+ });
70
+ } catch (err) {
71
+ throw new NCC02Error('RELAY_ERROR', `Failed to query relay for ${serviceId}`, err);
72
+ }
73
+
74
+ if (!serviceEvents || !serviceEvents.length) {
75
+ throw new NCC02Error('NOT_FOUND', `No service record found for ${serviceId}`);
76
+ }
77
+
78
+ // Stable tie-breaking: Sort by created_at DESC, then ID ASC
79
+ const serviceEvent = serviceEvents.sort((/** @type {any} */ a, /** @type {any} */ b) => {
80
+ if (b.created_at !== a.created_at) return b.created_at - a.created_at;
81
+ return a.id.localeCompare(b.id);
82
+ })[0];
83
+
84
+ if (!verifyEvent(serviceEvent)) {
85
+ throw new NCC02Error('INVALID_SIGNATURE', 'Service record signature verification failed');
86
+ }
87
+
88
+ const serviceTags = Object.fromEntries(serviceEvent.tags);
89
+ const now = Math.floor(Date.now() / 1000);
90
+
91
+ // Security Fix: exp is REQUIRED by NCC-02 spec
92
+ if (!serviceTags.u || !serviceTags.k || !serviceTags.exp) {
93
+ throw new NCC02Error('MALFORMED_RECORD', 'Service record is missing required tags (u, k, or exp)');
94
+ }
95
+
96
+ const exp = parseInt(serviceTags.exp);
97
+ if (isNaN(exp)) {
98
+ throw new NCC02Error('MALFORMED_RECORD', 'Service record expiry tag is not a valid number');
99
+ }
100
+ if (exp < now) {
101
+ throw new NCC02Error('EXPIRED', 'Service record has expired');
102
+ }
103
+
104
+ let attestations;
105
+ let revocations;
106
+ try {
107
+ [attestations, revocations] = await Promise.all([
108
+ this.relay.query({ kinds: [KINDS.ATTESTATION], '#e': [serviceEvent.id] }),
109
+ this.relay.query({ kinds: [KINDS.REVOCATION] })
110
+ ]);
111
+ } catch (err) {
112
+ throw new NCC02Error('RELAY_ERROR', 'Failed to query relay for attestations/revocations', err);
113
+ }
114
+
115
+ const validAttestations = [];
116
+ for (const att of attestations) {
117
+ if (this.trustedCAPubkeys.has(att.pubkey)) {
118
+ const attTags = Object.fromEntries(att.tags);
119
+
120
+ // Cross-validate subject, service ID, and standard
121
+ if (attTags.subj !== pubkey) continue;
122
+ if (attTags.srv !== serviceId) continue;
123
+ if (standard && attTags.std !== standard) continue;
124
+
125
+ // Trust Level Filtering
126
+ if (minLevel && !this._isLevelSufficient(attTags.lvl, minLevel)) continue;
127
+
128
+ if (this._isAttestationValid(att, attTags, revocations)) {
129
+ validAttestations.push({
130
+ pubkey: att.pubkey,
131
+ level: attTags.lvl,
132
+ eventId: att.id
133
+ });
134
+ }
135
+ }
136
+ }
137
+
138
+ if (requireAttestation && validAttestations.length === 0) {
139
+ throw new NCC02Error('POLICY_FAILURE', `No trusted attestations meet the required policy for ${serviceId}`);
140
+ }
141
+
142
+ return {
143
+ endpoint: serviceTags.u,
144
+ fingerprint: serviceTags.k,
145
+ expiry: exp,
146
+ attestations: validAttestations,
147
+ eventId: serviceEvent.id,
148
+ pubkey: serviceEvent.pubkey
149
+ };
150
+ }
151
+
152
+ /**
153
+ * @param {string | undefined} actual
154
+ * @param {string} required
155
+ */
156
+ _isLevelSufficient(actual, required) {
157
+ /** @type {Record<string, number>} */
158
+ const levels = { 'self': 0, 'verified': 1, 'hardened': 2 };
159
+ const actualVal = actual ? (levels[actual] ?? -1) : -1;
160
+ const requiredVal = levels[required] ?? 0;
161
+ return actualVal >= requiredVal;
162
+ }
163
+
164
+ /**
165
+ * @param {any} att
166
+ * @param {any} tags
167
+ * @param {any[]} revocations
168
+ */
169
+ _isAttestationValid(att, tags, revocations) {
170
+ // 1. Verify Attestation signature
171
+ if (!verifyEvent(att)) return false;
172
+
173
+ const now = Math.floor(Date.now() / 1000);
174
+
175
+ // 2. NBF validation
176
+ if (tags.nbf) {
177
+ const nbf = parseInt(tags.nbf);
178
+ if (isNaN(nbf) || nbf > now) return false;
179
+ }
180
+
181
+ // 3. EXP validation
182
+ if (tags.exp) {
183
+ const exp = parseInt(tags.exp);
184
+ if (isNaN(exp) || exp < now) return false;
185
+ }
186
+
187
+ // 4. Revocation validation
188
+ // A revocation is valid only if it matches the attestation ID, is from the same CA, AND has a valid signature.
189
+ for (const rev of revocations) {
190
+ const revTags = Object.fromEntries(rev.tags);
191
+ if (revTags.e === att.id && rev.pubkey === att.pubkey) {
192
+ if (verifyEvent(rev)) {
193
+ return false; // Valid revocation found
194
+ }
195
+ }
196
+ }
197
+
198
+ return true; // No valid revocation found
199
+ }
200
+
201
+ /**
202
+ * Verifies that the actual fingerprint found during transport-level connection
203
+ * matches the one declared in the signed service record.
204
+ *
205
+ * @param {ResolvedService} resolved - The object returned by resolve().
206
+ * @param {string} actualFingerprint - The fingerprint obtained from the service.
207
+ * @returns {boolean}
208
+ */
209
+ verifyEndpoint(resolved, actualFingerprint) {
210
+ return resolved.fingerprint === actualFingerprint;
211
+ }
212
+ }