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/README.md +78 -44
- package/dist/index.cjs +220 -78
- package/dist/index.mjs +219 -78
- package/dist/models.d.ts +29 -7
- package/dist/resolver.d.ts +78 -14
- package/package.json +1 -1
- package/src/models.js +105 -21
- package/src/resolver.js +157 -79
package/README.md
CHANGED
|
@@ -43,78 +43,112 @@ try {
|
|
|
43
43
|
}
|
|
44
44
|
} catch (err) {
|
|
45
45
|
console.error('Resolution failed:', err.code, err.message);
|
|
46
|
+
} finally {
|
|
47
|
+
resolver.close(); // Clean up WebSocket connections
|
|
46
48
|
}
|
|
47
49
|
```
|
|
48
50
|
|
|
49
|
-
### 2. Publish
|
|
51
|
+
### 2. Publish Service Records and Attestations
|
|
50
52
|
|
|
51
53
|
```javascript
|
|
52
54
|
import { NCC02Builder } from 'ncc-02-js';
|
|
53
55
|
|
|
54
|
-
// Initialize with private key (hex)
|
|
55
56
|
const builder = new NCC02Builder(privateKey);
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
fingerprint: 'sha256:fingerprint',
|
|
62
|
-
expiryDays: 14
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
// Example 2: Private / Invite-Only Service
|
|
66
|
-
const privateEvent = builder.createServiceRecord({
|
|
67
|
-
serviceId: 'wallet',
|
|
68
|
-
fingerprint: 'sha256:fingerprint',
|
|
58
|
+
const serviceRecord = await builder.createServiceRecord({
|
|
59
|
+
serviceId: 'api',
|
|
60
|
+
endpoint: 'https://service.example.com',
|
|
61
|
+
fingerprint: '<spki fingerprint>',
|
|
69
62
|
expiryDays: 7
|
|
70
63
|
});
|
|
71
|
-
// publish events to relays...
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
### 3. Issue an Attestation (CA)
|
|
75
64
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
serviceId: 'media',
|
|
81
|
-
serviceEventId: serviceRecordEventId,
|
|
65
|
+
const attestation = await builder.createAttestation({
|
|
66
|
+
subjectPubkey: ownerPubkey,
|
|
67
|
+
serviceId: 'api',
|
|
68
|
+
serviceEventId: serviceRecord.id,
|
|
82
69
|
level: 'verified',
|
|
83
70
|
validDays: 30
|
|
84
71
|
});
|
|
72
|
+
|
|
73
|
+
await builder.createRevocation({
|
|
74
|
+
attestationId: attestation.id,
|
|
75
|
+
reason: 'Key rotation'
|
|
76
|
+
});
|
|
85
77
|
```
|
|
86
78
|
|
|
87
|
-
|
|
79
|
+
The builder helpers emit the expected NCC-02 event kinds: Kind 30059 for service records, 30060 for attestations, and 30061 for revocations. `createServiceRecord` always includes the `d` and `exp` tags while optionally populating `u` and `k`. Attestations include `subj`, `srv`, `e`, `std`, `lvl`, `nbf`, and `exp`. Revocations only need the `e` tag and an optional reason. You can supply either a raw private key (hex string or `Uint8Array`) or a signer implementing `getPublicKey`/`signEvent`.
|
|
88
80
|
|
|
89
|
-
### Trust
|
|
90
|
-
- `self`: Asserted by the service owner (default if no attestation).
|
|
91
|
-
- `verified`: Attested by a trusted third party.
|
|
92
|
-
- `hardened`: Attested by a third party with stricter verification (e.g., physical proof or long-term history).
|
|
81
|
+
### 3. Trust Model & Security
|
|
93
82
|
|
|
94
|
-
|
|
95
|
-
- **Endpoint Impersonation**: Prevented by binding the endpoint URI to a public key fingerprint (`k` tag).
|
|
96
|
-
- **Man-in-the-Middle (MITM)**: Mitigated via cryptographic pinning of transport-level keys.
|
|
97
|
-
- **Stale Records**: Limited by required expiry (`exp`) and support for revocations.
|
|
98
|
-
- **Relay Censorship**: Mitigated by querying multiple relays (implemented via `SimplePool`).
|
|
83
|
+
The resolver treats the service owner’s pubkey as the root authority. Certificates and revocations are additive layers that you opt into via `requireAttestation` or `minLevel`. Validation failures surface as `NCC02Error` instances with codes such as `INVALID_SIGNATURE`, `EXPIRED`, or `POLICY_FAILURE`, helping clients react instead of defaulting to insecure fallbacks.
|
|
99
84
|
|
|
100
|
-
###
|
|
101
|
-
|
|
85
|
+
### 4. Resolution Optimization
|
|
86
|
+
|
|
87
|
+
To avoid unnecessary relay traffic, attestation and revocation feeds are only fetched when the resolver’s policy requests them. Keep expiries short and rotate fingerprints with the `k` tag to reduce the blast radius of compromised keys.
|
|
88
|
+
|
|
89
|
+
### 5. Threat Model
|
|
90
|
+
|
|
91
|
+
The library defends against endpoint impersonation, MITM, stale records, and relay censorship by enforcing signed records, short expiries, revocation checks, and optional third-party attestations. It deliberately keeps scope focused on application-layer trust and does not replace TLS or browser PKI.
|
|
92
|
+
|
|
93
|
+
### 6. Testing with MockRelay
|
|
94
|
+
|
|
95
|
+
```javascript
|
|
96
|
+
import { MockRelay, NCC02Builder, NCC02Resolver } from 'ncc-02-js';
|
|
97
|
+
|
|
98
|
+
const mock = new MockRelay();
|
|
99
|
+
const builder = new NCC02Builder(privateKey);
|
|
100
|
+
const serviceRecord = await builder.createServiceRecord({ serviceId: 'api', endpoint: 'https://example', fingerprint: '<fp>' });
|
|
101
|
+
await mock.publish(serviceRecord);
|
|
102
|
+
|
|
103
|
+
const resolver = new NCC02Resolver(['mock://local'], { pool: mock });
|
|
104
|
+
await resolver.resolve(ownerPubkey, 'api');
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`MockRelay` mimics a Nostr relay by verifying signatures and honoring `kinds`, `authors`, `ids`, and `#tag` filters. It makes writing unit tests and integration suites deterministic without external dependencies.
|
|
108
|
+
|
|
109
|
+
### 7. Endpoint Verification Helpers
|
|
110
|
+
|
|
111
|
+
After resolving a service, call `resolver.verifyEndpoint(serviceStatus, actualFingerprint)` to ensure the runtime transport fingerprint matches the declared `k` tag. Use `verifyNCC02Event` when you ingest raw events and `isExpired` to quickly skip expired records before attempting network validation.
|
|
102
112
|
|
|
103
113
|
## API Reference
|
|
104
114
|
|
|
105
115
|
### `NCC02Resolver(relays, options)`
|
|
106
116
|
- `relays`: Array of relay URLs.
|
|
107
|
-
- `options.pool`: (Optional)
|
|
108
|
-
- `options.trustedCAPubkeys`:
|
|
117
|
+
- `options.pool`: (Optional) Shared `nostr-tools` `SimplePool`.
|
|
118
|
+
- `options.trustedCAPubkeys`: Optional array of certifier pubkeys that may issue trusted attestations.
|
|
109
119
|
|
|
110
120
|
#### `resolve(pubkey, serviceId, options)`
|
|
111
|
-
- `options.requireAttestation`:
|
|
112
|
-
- `options.minLevel`: Minimum
|
|
121
|
+
- `options.requireAttestation`: Reject if no trusted attestation is available.
|
|
122
|
+
- `options.minLevel`: Minimum `lvl` tag level (`self`, `verified`, `hardened`).
|
|
123
|
+
- `options.standard`: Expected `std` tag value (defaults to `nostr-service-trust-v0.1`).
|
|
124
|
+
- Returns `ServiceStatus` including `endpoint`, `fingerprint`, `expiry`, `attestations`, `attestationCount`, `isRevoked`, `eventId`, `pubkey`, and the raw `serviceEvent`.
|
|
125
|
+
|
|
126
|
+
#### `verifyEndpoint(resolved, actualFingerprint)`
|
|
127
|
+
- Compares the resolved fingerprint with the observed transport fingerprint for an additional rejection guard.
|
|
128
|
+
|
|
129
|
+
#### `close()`
|
|
130
|
+
- Closes relay subscriptions when the resolver owns the `SimplePool`.
|
|
131
|
+
|
|
132
|
+
### `NCC02Builder(signer)`
|
|
133
|
+
- `signer`: Hex private key, `Uint8Array`, or custom signer with `getPublicKey`/`signEvent`.
|
|
134
|
+
|
|
135
|
+
#### `createServiceRecord({ serviceId, endpoint?, fingerprint?, expiryDays? })`
|
|
136
|
+
- Returns a signed Kind 30059 event. `endpoint` maps to `u`, `fingerprint` to `k`, and `expiryDays` controls `exp`.
|
|
137
|
+
|
|
138
|
+
#### `createAttestation({ subjectPubkey, serviceId, serviceEventId, level?, validDays? })`
|
|
139
|
+
- Emits Kind 30060 with `subj`, `srv`, `e`, `std`, `lvl`, `nbf`, and `exp`.
|
|
140
|
+
|
|
141
|
+
#### `createRevocation({ attestationId, reason? })`
|
|
142
|
+
- Emits Kind 30061 pointing to the revoked attestation.
|
|
143
|
+
|
|
144
|
+
### `MockRelay`
|
|
145
|
+
- `publish(event)`: Verifies and stores the event (returns `true` if accepted).
|
|
146
|
+
- `query(filter)`: Filters stored events by `kinds`, `authors`, `ids`, and `#tag` criteria.
|
|
113
147
|
|
|
114
|
-
###
|
|
115
|
-
- `
|
|
116
|
-
- `
|
|
117
|
-
- `
|
|
148
|
+
### Helpers
|
|
149
|
+
- `verifyNCC02Event(event)`: Signature verification wrapper.
|
|
150
|
+
- `isExpired(event)`: Returns `true` when the `exp` tag is in the past.
|
|
151
|
+
- `KINDS`: Enum with `SERVICE_RECORD`, `ATTESTATION`, and `REVOCATION`.
|
|
118
152
|
|
|
119
153
|
## License
|
|
120
154
|
|
package/dist/index.cjs
CHANGED
|
@@ -34,6 +34,7 @@ __export(index_exports, {
|
|
|
34
34
|
NCC02Builder: () => NCC02Builder,
|
|
35
35
|
NCC02Error: () => NCC02Error,
|
|
36
36
|
NCC02Resolver: () => NCC02Resolver,
|
|
37
|
+
isExpired: () => isExpired,
|
|
37
38
|
verifyNCC02Event: () => verifyNCC02Event
|
|
38
39
|
});
|
|
39
40
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -2498,12 +2499,73 @@ var KINDS = {
|
|
|
2498
2499
|
};
|
|
2499
2500
|
var NCC02Builder = class {
|
|
2500
2501
|
/**
|
|
2501
|
-
* @param {string | Uint8Array}
|
|
2502
|
+
* @param {string | Uint8Array | NostrSigner} signer - Raw private key or asynchronous signer.
|
|
2502
2503
|
*/
|
|
2503
|
-
constructor(
|
|
2504
|
-
if (!
|
|
2505
|
-
this.
|
|
2506
|
-
this.
|
|
2504
|
+
constructor(signer) {
|
|
2505
|
+
if (!signer) throw new Error("Signer or private key is required");
|
|
2506
|
+
this.signer = this._normalizeSigner(signer);
|
|
2507
|
+
this._pubkeyPromise = this.signer.getPublicKey();
|
|
2508
|
+
this._pubkey = void 0;
|
|
2509
|
+
}
|
|
2510
|
+
async _getPublicKey() {
|
|
2511
|
+
if (!this._pubkey) {
|
|
2512
|
+
this._pubkey = await this._pubkeyPromise;
|
|
2513
|
+
}
|
|
2514
|
+
return this._pubkey;
|
|
2515
|
+
}
|
|
2516
|
+
/**
|
|
2517
|
+
* @param {any} event
|
|
2518
|
+
*/
|
|
2519
|
+
async _finalizeEvent(event) {
|
|
2520
|
+
const pubkey = await this._getPublicKey();
|
|
2521
|
+
const eventWithPubkey = { ...event, pubkey };
|
|
2522
|
+
const signed = await this.signer.signEvent(eventWithPubkey);
|
|
2523
|
+
if (!signed || typeof signed.id !== "string" || typeof signed.sig !== "string") {
|
|
2524
|
+
throw new Error("Signer must return a signed event with id and sig");
|
|
2525
|
+
}
|
|
2526
|
+
return signed;
|
|
2527
|
+
}
|
|
2528
|
+
/**
|
|
2529
|
+
* @param {any} signer
|
|
2530
|
+
* @returns {NostrSigner}
|
|
2531
|
+
*/
|
|
2532
|
+
_normalizeSigner(signer) {
|
|
2533
|
+
if (typeof signer === "string" || signer instanceof Uint8Array) {
|
|
2534
|
+
const privateKey = typeof signer === "string" ? hexToBytes2(signer) : signer;
|
|
2535
|
+
const pubkey = getPublicKey(privateKey);
|
|
2536
|
+
return {
|
|
2537
|
+
getPublicKey: async () => pubkey,
|
|
2538
|
+
/** @param {any} event */
|
|
2539
|
+
signEvent: async (event) => {
|
|
2540
|
+
const clonedEvent = {
|
|
2541
|
+
...event,
|
|
2542
|
+
tags: Array.isArray(event.tags) ? event.tags.map((tag) => [...tag]) : []
|
|
2543
|
+
};
|
|
2544
|
+
return finalizeEvent(clonedEvent, privateKey);
|
|
2545
|
+
}
|
|
2546
|
+
};
|
|
2547
|
+
}
|
|
2548
|
+
if (typeof signer === "object" && signer !== null) {
|
|
2549
|
+
if (typeof signer.getPublicKey === "function" && typeof signer.signEvent === "function") {
|
|
2550
|
+
return {
|
|
2551
|
+
getPublicKey: async () => {
|
|
2552
|
+
const pubkey = await signer.getPublicKey();
|
|
2553
|
+
if (typeof pubkey !== "string") throw new Error("Signer.getPublicKey must return a hex string");
|
|
2554
|
+
return pubkey;
|
|
2555
|
+
},
|
|
2556
|
+
/** @param {any} event */
|
|
2557
|
+
signEvent: async (event) => {
|
|
2558
|
+
const signed = await signer.signEvent(event);
|
|
2559
|
+
if (!signed || typeof signed.id !== "string" || typeof signed.sig !== "string") {
|
|
2560
|
+
throw new Error("Signer.signEvent must return a signed event");
|
|
2561
|
+
}
|
|
2562
|
+
return signed;
|
|
2563
|
+
},
|
|
2564
|
+
decryptEvent: typeof signer.decryptEvent === "function" ? signer.decryptEvent.bind(signer) : void 0
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
throw new Error("Unsupported signer provided to NCC02Builder");
|
|
2507
2569
|
}
|
|
2508
2570
|
/**
|
|
2509
2571
|
* Creates a signed Service Record (Kind 30059).
|
|
@@ -2513,7 +2575,7 @@ var NCC02Builder = class {
|
|
|
2513
2575
|
* @param {string} [options.fingerprint] - The 'k' tag fingerprint.
|
|
2514
2576
|
* @param {number} [options.expiryDays=14] - Expiry in days.
|
|
2515
2577
|
*/
|
|
2516
|
-
createServiceRecord(options) {
|
|
2578
|
+
async createServiceRecord(options) {
|
|
2517
2579
|
const { serviceId, endpoint, fingerprint, expiryDays = 14 } = options;
|
|
2518
2580
|
if (!serviceId) throw new Error("serviceId (d tag) is required");
|
|
2519
2581
|
const expiry = Math.floor(Date.now() / 1e3) + expiryDays * 24 * 60 * 60;
|
|
@@ -2527,10 +2589,9 @@ var NCC02Builder = class {
|
|
|
2527
2589
|
kind: KINDS.SERVICE_RECORD,
|
|
2528
2590
|
created_at: Math.floor(Date.now() / 1e3),
|
|
2529
2591
|
tags,
|
|
2530
|
-
content: `NCC-02 Service Record for ${serviceId}
|
|
2531
|
-
pubkey: this.pk
|
|
2592
|
+
content: `NCC-02 Service Record for ${serviceId}`
|
|
2532
2593
|
};
|
|
2533
|
-
return
|
|
2594
|
+
return this._finalizeEvent(event);
|
|
2534
2595
|
}
|
|
2535
2596
|
/**
|
|
2536
2597
|
* Creates a signed Certificate Attestation (Kind 30060).
|
|
@@ -2541,7 +2602,7 @@ var NCC02Builder = class {
|
|
|
2541
2602
|
* @param {string} [options.level='verified'] - The 'lvl' tag level.
|
|
2542
2603
|
* @param {number} [options.validDays=30] - Validity in days.
|
|
2543
2604
|
*/
|
|
2544
|
-
createAttestation(options) {
|
|
2605
|
+
async createAttestation(options) {
|
|
2545
2606
|
const { subjectPubkey, serviceId, serviceEventId, level = "verified", validDays = 30 } = options;
|
|
2546
2607
|
if (!subjectPubkey) throw new Error("subjectPubkey is required");
|
|
2547
2608
|
if (!serviceId) throw new Error("serviceId is required");
|
|
@@ -2560,10 +2621,9 @@ var NCC02Builder = class {
|
|
|
2560
2621
|
["nbf", now2.toString()],
|
|
2561
2622
|
["exp", expiry.toString()]
|
|
2562
2623
|
],
|
|
2563
|
-
content: "NCC-02 Attestation"
|
|
2564
|
-
pubkey: this.pk
|
|
2624
|
+
content: "NCC-02 Attestation"
|
|
2565
2625
|
};
|
|
2566
|
-
return
|
|
2626
|
+
return this._finalizeEvent(event);
|
|
2567
2627
|
}
|
|
2568
2628
|
/**
|
|
2569
2629
|
* Creates a signed Revocation (Kind 30061).
|
|
@@ -2571,7 +2631,7 @@ var NCC02Builder = class {
|
|
|
2571
2631
|
* @param {string} options.attestationId - The 'e' tag referencing the attestation.
|
|
2572
2632
|
* @param {string} [options.reason=''] - Optional reason.
|
|
2573
2633
|
*/
|
|
2574
|
-
createRevocation(options) {
|
|
2634
|
+
async createRevocation(options) {
|
|
2575
2635
|
const { attestationId, reason = "" } = options;
|
|
2576
2636
|
if (!attestationId) throw new Error("attestationId (e tag) is required");
|
|
2577
2637
|
const tags = [["e", attestationId]];
|
|
@@ -2580,15 +2640,22 @@ var NCC02Builder = class {
|
|
|
2580
2640
|
kind: KINDS.REVOCATION,
|
|
2581
2641
|
created_at: Math.floor(Date.now() / 1e3),
|
|
2582
2642
|
tags,
|
|
2583
|
-
content: "NCC-02 Revocation"
|
|
2584
|
-
pubkey: this.pk
|
|
2643
|
+
content: "NCC-02 Revocation"
|
|
2585
2644
|
};
|
|
2586
|
-
return
|
|
2645
|
+
return this._finalizeEvent(event);
|
|
2587
2646
|
}
|
|
2588
2647
|
};
|
|
2589
2648
|
function verifyNCC02Event(event) {
|
|
2590
2649
|
return verifyEvent(event);
|
|
2591
2650
|
}
|
|
2651
|
+
function isExpired(event) {
|
|
2652
|
+
if (!event || !Array.isArray(event.tags)) return false;
|
|
2653
|
+
const expTag = event.tags.find((tag) => tag[0] === "exp");
|
|
2654
|
+
if (!expTag) return false;
|
|
2655
|
+
const expiry = parseInt(expTag[1], 10);
|
|
2656
|
+
if (Number.isNaN(expiry)) return false;
|
|
2657
|
+
return expiry <= Math.floor(Date.now() / 1e3);
|
|
2658
|
+
}
|
|
2592
2659
|
|
|
2593
2660
|
// node_modules/@scure/base/lib/esm/index.js
|
|
2594
2661
|
function assertNumber(n) {
|
|
@@ -7692,6 +7759,14 @@ var NCC02Resolver = class {
|
|
|
7692
7759
|
this.ownsPool = !options.pool;
|
|
7693
7760
|
this.trustedCAPubkeys = new Set(options.trustedCAPubkeys || []);
|
|
7694
7761
|
}
|
|
7762
|
+
/**
|
|
7763
|
+
* Closes the connection to the relays if the pool is owned by this resolver.
|
|
7764
|
+
*/
|
|
7765
|
+
close() {
|
|
7766
|
+
if (this.ownsPool && this.pool) {
|
|
7767
|
+
this.pool.close(this.relays);
|
|
7768
|
+
}
|
|
7769
|
+
}
|
|
7695
7770
|
/**
|
|
7696
7771
|
* Internal query helper using SimplePool.subscribeMany (since list() is deprecated).
|
|
7697
7772
|
* @param {import('nostr-tools').Filter} filter
|
|
@@ -7711,6 +7786,27 @@ var NCC02Resolver = class {
|
|
|
7711
7786
|
});
|
|
7712
7787
|
});
|
|
7713
7788
|
}
|
|
7789
|
+
/**
|
|
7790
|
+
* Returns the first event sorted by freshness (newest created_at, tie broken by id).
|
|
7791
|
+
* @param {import('nostr-tools').Event[]} events
|
|
7792
|
+
* @returns {import('nostr-tools').Event|null}
|
|
7793
|
+
*/
|
|
7794
|
+
_freshestEvent(events) {
|
|
7795
|
+
if (!events || !events.length) return null;
|
|
7796
|
+
return events.sort((a, b) => {
|
|
7797
|
+
if (b.created_at !== a.created_at) return b.created_at - a.created_at;
|
|
7798
|
+
return a.id.localeCompare(b.id);
|
|
7799
|
+
})[0];
|
|
7800
|
+
}
|
|
7801
|
+
/**
|
|
7802
|
+
* Query helper that returns only the freshest event matching the filter.
|
|
7803
|
+
* @param {import('nostr-tools').Filter} filter
|
|
7804
|
+
* @returns {Promise<import('nostr-tools').Event | null>}
|
|
7805
|
+
*/
|
|
7806
|
+
async _queryFreshest(filter) {
|
|
7807
|
+
const events = await this._query(filter);
|
|
7808
|
+
return this._freshestEvent(events);
|
|
7809
|
+
}
|
|
7714
7810
|
/**
|
|
7715
7811
|
* Resolves a service for a given pubkey and service identifier.
|
|
7716
7812
|
*
|
|
@@ -7720,8 +7816,8 @@ var NCC02Resolver = class {
|
|
|
7720
7816
|
* @param {boolean} [options.requireAttestation=false] - If true, fails if no trusted attestation is found.
|
|
7721
7817
|
* @param {string} [options.minLevel=null] - Minimum trust level ('self', 'verified', 'hardened').
|
|
7722
7818
|
* @param {string} [options.standard='nostr-service-trust-v0.1'] - Expected trust standard.
|
|
7723
|
-
|
|
7724
|
-
|
|
7819
|
+
* @throws {NCC02Error} If verification or policy checks fail.
|
|
7820
|
+
* @returns {Promise<ServiceStatus>} The service status including trust metadata.
|
|
7725
7821
|
*/
|
|
7726
7822
|
async resolve(pubkey, serviceId, options = {}) {
|
|
7727
7823
|
const {
|
|
@@ -7729,9 +7825,9 @@ var NCC02Resolver = class {
|
|
|
7729
7825
|
minLevel = null,
|
|
7730
7826
|
standard = "nostr-service-trust-v0.1"
|
|
7731
7827
|
} = options;
|
|
7732
|
-
let
|
|
7828
|
+
let serviceEvent;
|
|
7733
7829
|
try {
|
|
7734
|
-
|
|
7830
|
+
serviceEvent = await this._queryFreshest({
|
|
7735
7831
|
kinds: [KINDS.SERVICE_RECORD],
|
|
7736
7832
|
authors: [pubkey],
|
|
7737
7833
|
"#d": [serviceId]
|
|
@@ -7739,13 +7835,9 @@ var NCC02Resolver = class {
|
|
|
7739
7835
|
} catch (err) {
|
|
7740
7836
|
throw new NCC02Error("RELAY_ERROR", `Failed to query relay for ${serviceId}`, err);
|
|
7741
7837
|
}
|
|
7742
|
-
if (!
|
|
7838
|
+
if (!serviceEvent) {
|
|
7743
7839
|
throw new NCC02Error("NOT_FOUND", `No service record found for ${serviceId}`);
|
|
7744
7840
|
}
|
|
7745
|
-
const serviceEvent = serviceEvents.sort((a, b) => {
|
|
7746
|
-
if (b.created_at !== a.created_at) return b.created_at - a.created_at;
|
|
7747
|
-
return a.id.localeCompare(b.id);
|
|
7748
|
-
})[0];
|
|
7749
7841
|
if (!verifyEvent2(serviceEvent)) {
|
|
7750
7842
|
throw new NCC02Error("INVALID_SIGNATURE", "Service record signature verification failed");
|
|
7751
7843
|
}
|
|
@@ -7764,86 +7856,135 @@ var NCC02Resolver = class {
|
|
|
7764
7856
|
if (exp < now2) {
|
|
7765
7857
|
throw new NCC02Error("EXPIRED", "Service record has expired");
|
|
7766
7858
|
}
|
|
7767
|
-
let
|
|
7768
|
-
let revocations;
|
|
7859
|
+
let trustData;
|
|
7769
7860
|
try {
|
|
7770
|
-
|
|
7771
|
-
this._query({ kinds: [KINDS.ATTESTATION], "#e": [serviceEvent.id] }),
|
|
7772
|
-
this._query({ kinds: [KINDS.REVOCATION] })
|
|
7773
|
-
]);
|
|
7861
|
+
trustData = await this._buildTrustData(serviceEvent, { pubkey, serviceId, standard, minLevel });
|
|
7774
7862
|
} catch (err) {
|
|
7775
7863
|
throw new NCC02Error("RELAY_ERROR", "Failed to query relay for attestations/revocations", err);
|
|
7776
7864
|
}
|
|
7777
|
-
|
|
7778
|
-
for (const att of attestations) {
|
|
7779
|
-
if (this.trustedCAPubkeys.has(att.pubkey)) {
|
|
7780
|
-
const attTags = Object.fromEntries(att.tags);
|
|
7781
|
-
if (attTags.subj !== pubkey) continue;
|
|
7782
|
-
if (attTags.srv !== serviceId) continue;
|
|
7783
|
-
if (standard && attTags.std !== standard) continue;
|
|
7784
|
-
if (minLevel && !this._isLevelSufficient(attTags.lvl, minLevel)) continue;
|
|
7785
|
-
if (this._isAttestationValid(att, attTags, revocations)) {
|
|
7786
|
-
validAttestations.push({
|
|
7787
|
-
pubkey: att.pubkey,
|
|
7788
|
-
level: attTags.lvl,
|
|
7789
|
-
eventId: att.id
|
|
7790
|
-
});
|
|
7791
|
-
}
|
|
7792
|
-
}
|
|
7793
|
-
}
|
|
7794
|
-
if (requireAttestation && validAttestations.length === 0) {
|
|
7865
|
+
if (requireAttestation && trustData.validAttestations.length === 0) {
|
|
7795
7866
|
throw new NCC02Error("POLICY_FAILURE", `No trusted attestations meet the required policy for ${serviceId}`);
|
|
7796
7867
|
}
|
|
7797
7868
|
return {
|
|
7798
7869
|
endpoint: serviceTags.u,
|
|
7799
7870
|
fingerprint: serviceTags.k,
|
|
7800
7871
|
expiry: exp,
|
|
7801
|
-
attestations: validAttestations,
|
|
7872
|
+
attestations: trustData.validAttestations,
|
|
7873
|
+
attestationCount: trustData.validAttestations.length,
|
|
7874
|
+
isRevoked: trustData.isRevoked,
|
|
7802
7875
|
eventId: serviceEvent.id,
|
|
7803
|
-
pubkey: serviceEvent.pubkey
|
|
7876
|
+
pubkey: serviceEvent.pubkey,
|
|
7877
|
+
serviceEvent
|
|
7804
7878
|
};
|
|
7805
7879
|
}
|
|
7806
7880
|
/**
|
|
7807
|
-
* @param {
|
|
7808
|
-
* @param {
|
|
7881
|
+
* @param {any} serviceEvent
|
|
7882
|
+
* @param {Object} options
|
|
7883
|
+
* @param {string} options.pubkey
|
|
7884
|
+
* @param {string} options.serviceId
|
|
7885
|
+
* @param {string|null} options.standard
|
|
7886
|
+
* @param {string|null} options.minLevel
|
|
7809
7887
|
*/
|
|
7810
|
-
|
|
7811
|
-
const
|
|
7812
|
-
|
|
7813
|
-
|
|
7814
|
-
|
|
7888
|
+
async _buildTrustData(serviceEvent, options) {
|
|
7889
|
+
const attestations = await this._query({
|
|
7890
|
+
kinds: [KINDS.ATTESTATION],
|
|
7891
|
+
"#e": [serviceEvent.id]
|
|
7892
|
+
});
|
|
7893
|
+
const attestationIds = attestations.map((att) => att.id);
|
|
7894
|
+
let revocations = [];
|
|
7895
|
+
if (attestationIds.length) {
|
|
7896
|
+
revocations = await this._query({
|
|
7897
|
+
kinds: [KINDS.REVOCATION],
|
|
7898
|
+
"#e": attestationIds
|
|
7899
|
+
});
|
|
7900
|
+
}
|
|
7901
|
+
const revocationIndex = this._groupValidRevocations(revocations);
|
|
7902
|
+
const validAttestations = [];
|
|
7903
|
+
let isRevoked = false;
|
|
7904
|
+
for (const att of attestations) {
|
|
7905
|
+
if (!this.trustedCAPubkeys.has(att.pubkey)) continue;
|
|
7906
|
+
const attTags = Object.fromEntries(att.tags);
|
|
7907
|
+
if (attTags.subj !== options.pubkey) continue;
|
|
7908
|
+
if (attTags.srv !== options.serviceId) continue;
|
|
7909
|
+
if (options.standard && attTags.std !== options.standard) continue;
|
|
7910
|
+
const { valid, revoked } = this._evaluateAttestation(att, attTags, revocationIndex[att.id]);
|
|
7911
|
+
if (revoked) {
|
|
7912
|
+
isRevoked = true;
|
|
7913
|
+
continue;
|
|
7914
|
+
}
|
|
7915
|
+
if (!valid) continue;
|
|
7916
|
+
if (options.minLevel && !this._isLevelSufficient(attTags.lvl, options.minLevel)) continue;
|
|
7917
|
+
validAttestations.push({
|
|
7918
|
+
pubkey: att.pubkey,
|
|
7919
|
+
level: attTags.lvl,
|
|
7920
|
+
eventId: att.id
|
|
7921
|
+
});
|
|
7922
|
+
}
|
|
7923
|
+
return { validAttestations, isRevoked };
|
|
7924
|
+
}
|
|
7925
|
+
/**
|
|
7926
|
+
* @param {any[]} revocations
|
|
7927
|
+
* @returns {Record<string, any[]>}
|
|
7928
|
+
*/
|
|
7929
|
+
/**
|
|
7930
|
+
* @param {any[]} revocations
|
|
7931
|
+
* @returns {Record<string, any[]>}
|
|
7932
|
+
*/
|
|
7933
|
+
_groupValidRevocations(revocations) {
|
|
7934
|
+
const indexed = {};
|
|
7935
|
+
for (const rev of revocations) {
|
|
7936
|
+
if (!verifyEvent2(rev)) continue;
|
|
7937
|
+
const tags = Object.fromEntries(rev.tags);
|
|
7938
|
+
const targetId = tags.e;
|
|
7939
|
+
if (!targetId) continue;
|
|
7940
|
+
if (!indexed[targetId]) indexed[targetId] = [];
|
|
7941
|
+
indexed[targetId].push(rev);
|
|
7942
|
+
}
|
|
7943
|
+
return indexed;
|
|
7815
7944
|
}
|
|
7816
7945
|
/**
|
|
7817
|
-
* @param {any} att
|
|
7818
|
-
* @param {
|
|
7819
|
-
* @param {any[]} revocations
|
|
7946
|
+
* @param {any} att
|
|
7947
|
+
* @param {Record<string, string>} tags
|
|
7948
|
+
* @param {any[]} revocations
|
|
7949
|
+
*/
|
|
7950
|
+
/**
|
|
7951
|
+
* @param {any} att
|
|
7952
|
+
* @param {Record<string, string>} tags
|
|
7953
|
+
* @param {any[]} [revocations]
|
|
7820
7954
|
*/
|
|
7821
|
-
|
|
7822
|
-
|
|
7955
|
+
_evaluateAttestation(att, tags, revocations = []) {
|
|
7956
|
+
for (const rev of revocations) {
|
|
7957
|
+
if (rev.pubkey === att.pubkey) {
|
|
7958
|
+
return { valid: false, revoked: true };
|
|
7959
|
+
}
|
|
7960
|
+
}
|
|
7961
|
+
if (!verifyEvent2(att)) return { valid: false, revoked: false };
|
|
7823
7962
|
const now2 = Math.floor(Date.now() / 1e3);
|
|
7824
7963
|
if (tags.nbf) {
|
|
7825
|
-
const nbf = parseInt(tags.nbf);
|
|
7826
|
-
if (isNaN(nbf) || nbf > now2) return false;
|
|
7964
|
+
const nbf = parseInt(tags.nbf, 10);
|
|
7965
|
+
if (isNaN(nbf) || nbf > now2) return { valid: false, revoked: false };
|
|
7827
7966
|
}
|
|
7828
7967
|
if (tags.exp) {
|
|
7829
|
-
const exp = parseInt(tags.exp);
|
|
7830
|
-
if (isNaN(exp) || exp < now2) return false;
|
|
7831
|
-
}
|
|
7832
|
-
for (const rev of revocations) {
|
|
7833
|
-
const revTags = Object.fromEntries(rev.tags);
|
|
7834
|
-
if (revTags.e === att.id && rev.pubkey === att.pubkey) {
|
|
7835
|
-
if (verifyEvent2(rev)) {
|
|
7836
|
-
return false;
|
|
7837
|
-
}
|
|
7838
|
-
}
|
|
7968
|
+
const exp = parseInt(tags.exp, 10);
|
|
7969
|
+
if (isNaN(exp) || exp < now2) return { valid: false, revoked: false };
|
|
7839
7970
|
}
|
|
7840
|
-
return true;
|
|
7971
|
+
return { valid: true, revoked: false };
|
|
7972
|
+
}
|
|
7973
|
+
/**
|
|
7974
|
+
* @param {string | undefined} actual
|
|
7975
|
+
* @param {string} required
|
|
7976
|
+
*/
|
|
7977
|
+
_isLevelSufficient(actual, required) {
|
|
7978
|
+
const levels = { "self": 0, "verified": 1, "hardened": 2 };
|
|
7979
|
+
const actualVal = actual ? levels[actual] ?? -1 : -1;
|
|
7980
|
+
const requiredVal = levels[required] ?? 0;
|
|
7981
|
+
return actualVal >= requiredVal;
|
|
7841
7982
|
}
|
|
7842
7983
|
/**
|
|
7843
7984
|
* Verifies that the actual fingerprint found during transport-level connection
|
|
7844
7985
|
* matches the one declared in the signed service record.
|
|
7845
7986
|
*
|
|
7846
|
-
* @param {
|
|
7987
|
+
* @param {ServiceStatus} resolved - The object returned by resolve().
|
|
7847
7988
|
* @param {string} actualFingerprint - The fingerprint obtained from the service.
|
|
7848
7989
|
* @returns {boolean}
|
|
7849
7990
|
*/
|
|
@@ -7901,6 +8042,7 @@ var MockRelay = class {
|
|
|
7901
8042
|
NCC02Builder,
|
|
7902
8043
|
NCC02Error,
|
|
7903
8044
|
NCC02Resolver,
|
|
8045
|
+
isExpired,
|
|
7904
8046
|
verifyNCC02Event
|
|
7905
8047
|
});
|
|
7906
8048
|
/*! Bundled license information:
|