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/LICENSE +121 -0
- package/README.md +64 -0
- package/dist/index.js +2796 -0
- package/dist/index.mjs +2760 -0
- package/package.json +44 -0
- package/src/index.js +3 -0
- package/src/mockRelay.js +54 -0
- package/src/models.js +115 -0
- package/src/resolver.js +212 -0
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
package/src/mockRelay.js
ADDED
|
@@ -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
|
+
}
|
package/src/resolver.js
ADDED
|
@@ -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
|
+
}
|