@moonglade/provenance 0.0.0-stage → 2.6.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 +21 -0
- package/NOTICE.md +9 -0
- package/README.md +76 -2
- package/SPEC.md +176 -0
- package/TIMESTAMP.md +21 -0
- package/dist/artifact-cli.d.ts +2 -0
- package/dist/artifact-cli.js +108 -0
- package/dist/artifactEnvelope.d.ts +38 -0
- package/dist/artifactEnvelope.js +61 -0
- package/dist/artifactHook.d.ts +30 -0
- package/dist/artifactHook.js +114 -0
- package/dist/badge.d.ts +83 -0
- package/dist/badge.js +403 -0
- package/dist/index.d.ts +228 -0
- package/dist/index.js +782 -0
- package/dist/mcp.d.ts +87 -0
- package/dist/mcp.js +352 -0
- package/dist/merkle.d.ts +71 -0
- package/dist/merkle.js +176 -0
- package/dist/self-test.d.ts +21 -0
- package/dist/self-test.js +280 -0
- package/dist/timestamp.d.ts +274 -0
- package/dist/timestamp.js +1103 -0
- package/package.json +75 -4
- package/trust/README.md +19 -0
- package/trust/registry.json +12 -0
- package/trust/root-public.asc +10 -0
- package/wasm/mgc_wasm.d.ts +53 -0
- package/wasm/mgc_wasm.js +389 -0
- package/wasm/mgc_wasm_bg.wasm +0 -0
- package/wasm/mgc_wasm_bg.wasm.d.ts +20 -0
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.PKI_STATUS_TEXT = exports.globalCircuitBreaker = exports.EndpointCircuitBreaker = exports.DEFAULT_SERVER_POLICIES = exports.DEFAULT_NETWORK_HOSTS = exports.DEFAULT_FALLBACK_TSAS = exports.DEFAULT_PRIMARY_TSAS = exports.TIER_CONFIGS = void 0;
|
|
37
|
+
exports.encodeDerLength = encodeDerLength;
|
|
38
|
+
exports.encodeDerSequence = encodeDerSequence;
|
|
39
|
+
exports.decodeDerLength = decodeDerLength;
|
|
40
|
+
exports.parsePkiStatus = parsePkiStatus;
|
|
41
|
+
exports.validateTimeStampResp = validateTimeStampResp;
|
|
42
|
+
exports.verifyTimeStampToken = verifyTimeStampToken;
|
|
43
|
+
exports.buildTimeStampReq = buildTimeStampReq;
|
|
44
|
+
exports.extractGenTime = extractGenTime;
|
|
45
|
+
exports.queryTsa = queryTsa;
|
|
46
|
+
exports.probeTsa = probeTsa;
|
|
47
|
+
exports.parseHttpDate = parseHttpDate;
|
|
48
|
+
exports.probeNetworkTime = probeNetworkTime;
|
|
49
|
+
exports.resolveNetworkConsensus = resolveNetworkConsensus;
|
|
50
|
+
exports.concurrentDegradingTimestamp = concurrentDegradingTimestamp;
|
|
51
|
+
const http = __importStar(require("http"));
|
|
52
|
+
const https = __importStar(require("https"));
|
|
53
|
+
const crypto = __importStar(require("crypto"));
|
|
54
|
+
const pkijs = __importStar(require("pkijs"));
|
|
55
|
+
const asn1js = __importStar(require("asn1js"));
|
|
56
|
+
exports.TIER_CONFIGS = {
|
|
57
|
+
L1: {
|
|
58
|
+
default: 'http://timestamp.digicert.com',
|
|
59
|
+
fallbacks: [
|
|
60
|
+
'http://timestamp.sectigo.com',
|
|
61
|
+
'http://timestamp.globalsign.com/tsa/r6advanced1',
|
|
62
|
+
'http://timestamp.entrust.net/TSS/RFC3161sha2TS',
|
|
63
|
+
'http://time.certum.pl'
|
|
64
|
+
]
|
|
65
|
+
},
|
|
66
|
+
L2: {
|
|
67
|
+
default: 'http://timestamp.apple.com/ts01',
|
|
68
|
+
fallbacks: [
|
|
69
|
+
'http://ts.quovadisglobal.com/eu',
|
|
70
|
+
'https://freetsa.org/tsr'
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
L3: {
|
|
74
|
+
default: 'cloudflare.com',
|
|
75
|
+
fallbacks: [
|
|
76
|
+
'google.com',
|
|
77
|
+
'apple.com',
|
|
78
|
+
'amazon.com'
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
exports.DEFAULT_PRIMARY_TSAS = [
|
|
83
|
+
exports.TIER_CONFIGS.L1.default,
|
|
84
|
+
...exports.TIER_CONFIGS.L1.fallbacks
|
|
85
|
+
];
|
|
86
|
+
exports.DEFAULT_FALLBACK_TSAS = [
|
|
87
|
+
exports.TIER_CONFIGS.L2.default,
|
|
88
|
+
...exports.TIER_CONFIGS.L2.fallbacks
|
|
89
|
+
];
|
|
90
|
+
exports.DEFAULT_NETWORK_HOSTS = [
|
|
91
|
+
exports.TIER_CONFIGS.L3.default,
|
|
92
|
+
...exports.TIER_CONFIGS.L3.fallbacks
|
|
93
|
+
];
|
|
94
|
+
exports.DEFAULT_SERVER_POLICIES = [
|
|
95
|
+
// Tier 1 Primary
|
|
96
|
+
{ url: 'http://timestamp.digicert.com', name: 'DigiCert', tier: 'L1_CRYPTO_PRIMARY', minIntervalMs: 1000 },
|
|
97
|
+
{ url: 'http://timestamp.sectigo.com', name: 'Sectigo', tier: 'L1_CRYPTO_PRIMARY', minIntervalMs: 1000 },
|
|
98
|
+
{ url: 'http://timestamp.globalsign.com/tsa/r6advanced1', name: 'GlobalSign', tier: 'L1_CRYPTO_PRIMARY', minIntervalMs: 1000 },
|
|
99
|
+
{ url: 'http://timestamp.entrust.net/TSS/RFC3161sha2TS', name: 'Entrust', tier: 'L1_CRYPTO_PRIMARY', minIntervalMs: 1000 },
|
|
100
|
+
{ url: 'http://time.certum.pl', name: 'Certum', tier: 'L1_CRYPTO_PRIMARY', minIntervalMs: 1000 },
|
|
101
|
+
// Tier 2 Fallback
|
|
102
|
+
{ url: 'http://timestamp.apple.com/ts01', name: 'Apple', tier: 'L2_CRYPTO_FALLBACK', minIntervalMs: 1000 },
|
|
103
|
+
{ url: 'http://ts.quovadisglobal.com/eu', name: 'QuoVadis', tier: 'L2_CRYPTO_FALLBACK', minIntervalMs: 1000 },
|
|
104
|
+
{ url: 'https://freetsa.org/tsr', name: 'FreeTSA', tier: 'L2_CRYPTO_FALLBACK', minIntervalMs: 15000 }, // FreeTSA 15s policy
|
|
105
|
+
// Tier 3 Network Consensus
|
|
106
|
+
{ url: 'cloudflare.com', name: 'Cloudflare', tier: 'L3_NETWORK_CONSENSUS', minIntervalMs: 500 },
|
|
107
|
+
{ url: 'google.com', name: 'Google', tier: 'L3_NETWORK_CONSENSUS', minIntervalMs: 500 }
|
|
108
|
+
];
|
|
109
|
+
/**
|
|
110
|
+
* Circuit breaker & Server Pool Manager.
|
|
111
|
+
* Manages:
|
|
112
|
+
* 1. Endpoint health and consecutive failure tracking
|
|
113
|
+
* 2. Exponential backoff for failing/down servers
|
|
114
|
+
* 3. Individual server policies (e.g. FreeTSA 15s rate-limit cooldown)
|
|
115
|
+
* 4. Fair round-robin rotation queue ("who's next")
|
|
116
|
+
* 5. Telemetry for ready vs disabled/cooldown servers
|
|
117
|
+
*/
|
|
118
|
+
class EndpointCircuitBreaker {
|
|
119
|
+
health = new Map();
|
|
120
|
+
policies = new Map();
|
|
121
|
+
queuePointers = new Map();
|
|
122
|
+
baseBackoffMs;
|
|
123
|
+
maxBackoffMs;
|
|
124
|
+
constructor(options = {}) {
|
|
125
|
+
this.baseBackoffMs = options.baseBackoffMs ?? 5000; // 5s initial backoff
|
|
126
|
+
this.maxBackoffMs = options.maxBackoffMs ?? 300000; // 5m max backoff
|
|
127
|
+
const initialPolicies = options.policies || exports.DEFAULT_SERVER_POLICIES;
|
|
128
|
+
for (const p of initialPolicies) {
|
|
129
|
+
this.policies.set(p.url, p);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
getPolicy(url) {
|
|
133
|
+
return this.policies.get(url) || {
|
|
134
|
+
url,
|
|
135
|
+
name: url,
|
|
136
|
+
tier: 'L1_CRYPTO_PRIMARY',
|
|
137
|
+
minIntervalMs: 1000
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
setPolicy(policy) {
|
|
141
|
+
this.policies.set(policy.url, policy);
|
|
142
|
+
}
|
|
143
|
+
isAvailable(url, now = Date.now()) {
|
|
144
|
+
const entry = this.health.get(url);
|
|
145
|
+
if (!entry)
|
|
146
|
+
return true;
|
|
147
|
+
return now >= entry.nextAvailableTime;
|
|
148
|
+
}
|
|
149
|
+
getServerStatus(url, now = Date.now()) {
|
|
150
|
+
const entry = this.health.get(url);
|
|
151
|
+
if (!entry || now >= entry.nextAvailableTime) {
|
|
152
|
+
return 'READY';
|
|
153
|
+
}
|
|
154
|
+
if (entry.consecutiveFailures > 0) {
|
|
155
|
+
return 'BACKOFF';
|
|
156
|
+
}
|
|
157
|
+
return 'COOLDOWN';
|
|
158
|
+
}
|
|
159
|
+
recordRequest(url, now = Date.now()) {
|
|
160
|
+
const policy = this.getPolicy(url);
|
|
161
|
+
const prev = this.health.get(url) || { consecutiveFailures: 0, nextAvailableTime: 0 };
|
|
162
|
+
this.health.set(url, {
|
|
163
|
+
...prev,
|
|
164
|
+
url,
|
|
165
|
+
name: policy.name,
|
|
166
|
+
lastRequestTime: now,
|
|
167
|
+
// Enforce server policy rate cooldown
|
|
168
|
+
nextAvailableTime: Math.max(prev.nextAvailableTime, now + policy.minIntervalMs)
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
recordSuccess(url, now = Date.now()) {
|
|
172
|
+
const policy = this.getPolicy(url);
|
|
173
|
+
const prev = this.health.get(url);
|
|
174
|
+
const cooldownUntil = (prev?.lastRequestTime || 0) + policy.minIntervalMs;
|
|
175
|
+
this.health.set(url, {
|
|
176
|
+
url,
|
|
177
|
+
name: policy.name,
|
|
178
|
+
consecutiveFailures: 0,
|
|
179
|
+
lastSuccessTime: now,
|
|
180
|
+
lastRequestTime: prev?.lastRequestTime,
|
|
181
|
+
// Clear failure backoff; retain policy rate cooldown only if cooldownUntil > now
|
|
182
|
+
nextAvailableTime: cooldownUntil > now ? cooldownUntil : 0
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
recordFailure(url, error, now = Date.now()) {
|
|
186
|
+
const policy = this.getPolicy(url);
|
|
187
|
+
const prev = this.health.get(url) || { consecutiveFailures: 0, nextAvailableTime: 0 };
|
|
188
|
+
const consecutiveFailures = prev.consecutiveFailures + 1;
|
|
189
|
+
// Exponential backoff: base * 2^(failures - 1)
|
|
190
|
+
const backoffMs = Math.min(this.maxBackoffMs, this.baseBackoffMs * Math.pow(2, consecutiveFailures - 1));
|
|
191
|
+
this.health.set(url, {
|
|
192
|
+
url,
|
|
193
|
+
name: policy.name,
|
|
194
|
+
consecutiveFailures,
|
|
195
|
+
nextAvailableTime: now + backoffMs,
|
|
196
|
+
lastError: error,
|
|
197
|
+
lastRequestTime: prev.lastRequestTime,
|
|
198
|
+
lastSuccessTime: prev.lastSuccessTime
|
|
199
|
+
});
|
|
200
|
+
return backoffMs;
|
|
201
|
+
}
|
|
202
|
+
getHealth(url) {
|
|
203
|
+
return this.health.get(url);
|
|
204
|
+
}
|
|
205
|
+
reset() {
|
|
206
|
+
this.health.clear();
|
|
207
|
+
this.queuePointers.clear();
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Return ordered candidate queue starting with "who's next".
|
|
211
|
+
*/
|
|
212
|
+
getQueue(urls, queueKey = 'default') {
|
|
213
|
+
if (urls.length === 0)
|
|
214
|
+
return [];
|
|
215
|
+
const ptr = (this.queuePointers.get(queueKey) || 0) % urls.length;
|
|
216
|
+
return [...urls.slice(ptr), ...urls.slice(0, ptr)];
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Return servers currently on cooldown or in failure backoff.
|
|
220
|
+
*/
|
|
221
|
+
getDisabledEndpoints(urls, now = Date.now()) {
|
|
222
|
+
return urls
|
|
223
|
+
.filter(u => !this.isAvailable(u, now))
|
|
224
|
+
.map(u => {
|
|
225
|
+
const h = this.health.get(u);
|
|
226
|
+
const status = this.getServerStatus(u, now);
|
|
227
|
+
return {
|
|
228
|
+
url: u,
|
|
229
|
+
name: this.getPolicy(u).name,
|
|
230
|
+
status,
|
|
231
|
+
consecutiveFailures: h.consecutiveFailures,
|
|
232
|
+
remainingCooldownMs: Math.max(0, h.nextAvailableTime - now),
|
|
233
|
+
lastError: h.lastError
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Return servers currently READY to serve requests immediately.
|
|
239
|
+
*/
|
|
240
|
+
getReadyEndpoints(urls, now = Date.now()) {
|
|
241
|
+
return urls
|
|
242
|
+
.filter(u => this.isAvailable(u, now))
|
|
243
|
+
.map(u => {
|
|
244
|
+
const h = this.health.get(u);
|
|
245
|
+
return {
|
|
246
|
+
url: u,
|
|
247
|
+
name: this.getPolicy(u).name,
|
|
248
|
+
status: 'READY',
|
|
249
|
+
consecutiveFailures: h?.consecutiveFailures || 0,
|
|
250
|
+
remainingCooldownMs: 0
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Fair rotating endpoint selection.
|
|
256
|
+
* Cycles through the array in round-robin order, picking healthy/available endpoints,
|
|
257
|
+
* skipping those in failure backoff or policy cooldown, and advancing the queue pointer.
|
|
258
|
+
*/
|
|
259
|
+
selectRotatingEndpoints(urls, count = 1, queueKey = 'default', now = Date.now()) {
|
|
260
|
+
if (urls.length === 0)
|
|
261
|
+
return [];
|
|
262
|
+
const len = urls.length;
|
|
263
|
+
const currentPtr = (this.queuePointers.get(queueKey) || 0) % len;
|
|
264
|
+
const selected = [];
|
|
265
|
+
// 1. Scan in round-robin order starting at currentPtr for available endpoints
|
|
266
|
+
let checkIdx = currentPtr;
|
|
267
|
+
for (let i = 0; i < len && selected.length < count; i++) {
|
|
268
|
+
const candidate = urls[checkIdx];
|
|
269
|
+
if (this.isAvailable(candidate, now)) {
|
|
270
|
+
selected.push(candidate);
|
|
271
|
+
}
|
|
272
|
+
checkIdx = (checkIdx + 1) % len;
|
|
273
|
+
}
|
|
274
|
+
// 2. If not enough available endpoints, pick those with shortest remaining cooldown
|
|
275
|
+
if (selected.length < count) {
|
|
276
|
+
const remaining = urls.filter(u => !selected.includes(u));
|
|
277
|
+
remaining.sort((a, b) => {
|
|
278
|
+
const ta = this.health.get(a)?.nextAvailableTime || 0;
|
|
279
|
+
const tb = this.health.get(b)?.nextAvailableTime || 0;
|
|
280
|
+
return ta - tb;
|
|
281
|
+
});
|
|
282
|
+
for (const candidate of remaining) {
|
|
283
|
+
if (selected.length >= count)
|
|
284
|
+
break;
|
|
285
|
+
selected.push(candidate);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Advance queue pointer for the next request
|
|
289
|
+
this.queuePointers.set(queueKey, (currentPtr + 1) % len);
|
|
290
|
+
return selected;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Prioritized selection (used for targeted or non-rotating requests).
|
|
294
|
+
*/
|
|
295
|
+
selectEndpoints(urls, maxCount = urls.length, now = Date.now()) {
|
|
296
|
+
return this.selectRotatingEndpoints(urls, maxCount, 'legacy', now);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
exports.EndpointCircuitBreaker = EndpointCircuitBreaker;
|
|
300
|
+
exports.globalCircuitBreaker = new EndpointCircuitBreaker();
|
|
301
|
+
/**
|
|
302
|
+
* Helper to encode ASN.1 DER length octets.
|
|
303
|
+
*/
|
|
304
|
+
function encodeDerLength(len) {
|
|
305
|
+
if (len < 128) {
|
|
306
|
+
return Buffer.from([len]);
|
|
307
|
+
}
|
|
308
|
+
const octets = [];
|
|
309
|
+
let temp = len;
|
|
310
|
+
while (temp > 0) {
|
|
311
|
+
octets.unshift(temp & 0xff);
|
|
312
|
+
temp >>= 8;
|
|
313
|
+
}
|
|
314
|
+
return Buffer.from([0x80 | octets.length, ...octets]);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Helper to wrap elements in an ASN.1 DER SEQUENCE (tag 0x30).
|
|
318
|
+
*/
|
|
319
|
+
function encodeDerSequence(items) {
|
|
320
|
+
const body = Buffer.concat(items);
|
|
321
|
+
return Buffer.concat([Buffer.from([0x30]), encodeDerLength(body.length), body]);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Helper to decode ASN.1 DER length octets from a buffer at a given offset.
|
|
325
|
+
* Returns the parsed length and the number of octets consumed by the length header.
|
|
326
|
+
*/
|
|
327
|
+
function decodeDerLength(buf, offset) {
|
|
328
|
+
if (offset >= buf.length) {
|
|
329
|
+
throw new Error('Offset out of bounds reading DER length');
|
|
330
|
+
}
|
|
331
|
+
const first = buf[offset];
|
|
332
|
+
if (first < 0x80) {
|
|
333
|
+
return { length: first, headerLength: 1 };
|
|
334
|
+
}
|
|
335
|
+
const octetCount = first & 0x7f;
|
|
336
|
+
if (offset + 1 + octetCount > buf.length) {
|
|
337
|
+
throw new Error('Malformed DER length: exceeds buffer bounds');
|
|
338
|
+
}
|
|
339
|
+
let len = 0;
|
|
340
|
+
for (let i = 0; i < octetCount; i++) {
|
|
341
|
+
len = (len << 8) | buf[offset + 1 + i];
|
|
342
|
+
}
|
|
343
|
+
return { length: len, headerLength: 1 + octetCount };
|
|
344
|
+
}
|
|
345
|
+
exports.PKI_STATUS_TEXT = {
|
|
346
|
+
0: 'granted',
|
|
347
|
+
1: 'grantedWithMods',
|
|
348
|
+
2: 'rejection',
|
|
349
|
+
3: 'waiting',
|
|
350
|
+
4: 'revocationWarning',
|
|
351
|
+
5: 'revocationNotification'
|
|
352
|
+
};
|
|
353
|
+
/**
|
|
354
|
+
* Parse the RFC 3161 PKIStatusInfo integer code from TimeStampResp.
|
|
355
|
+
* RFC 3161 §2.4.2:
|
|
356
|
+
* TimeStampResp ::= SEQUENCE {
|
|
357
|
+
* status PKIStatusInfo,
|
|
358
|
+
* timeStampToken TimeStampToken OPTIONAL
|
|
359
|
+
* }
|
|
360
|
+
* PKIStatusInfo ::= SEQUENCE {
|
|
361
|
+
* status PKIStatus,
|
|
362
|
+
* statusString PKIFreeText OPTIONAL,
|
|
363
|
+
* failInfo PKIFailureInfo OPTIONAL
|
|
364
|
+
* }
|
|
365
|
+
*/
|
|
366
|
+
function parsePkiStatus(respBuffer) {
|
|
367
|
+
if (!respBuffer || respBuffer.length < 5) {
|
|
368
|
+
throw new Error('Response buffer too short for RFC 3161 TimeStampResp');
|
|
369
|
+
}
|
|
370
|
+
if (respBuffer[0] !== 0x30) {
|
|
371
|
+
throw new Error(`Expected SEQUENCE tag (0x30) at start of TimeStampResp, got 0x${respBuffer[0].toString(16)}`);
|
|
372
|
+
}
|
|
373
|
+
const topLen = decodeDerLength(respBuffer, 1);
|
|
374
|
+
const statusInfoOffset = 1 + topLen.headerLength;
|
|
375
|
+
if (statusInfoOffset >= respBuffer.length || respBuffer[statusInfoOffset] !== 0x30) {
|
|
376
|
+
throw new Error(`Expected PKIStatusInfo SEQUENCE (0x30) at offset ${statusInfoOffset}`);
|
|
377
|
+
}
|
|
378
|
+
const statusInfoLen = decodeDerLength(respBuffer, statusInfoOffset + 1);
|
|
379
|
+
const statusIntOffset = statusInfoOffset + 1 + statusInfoLen.headerLength;
|
|
380
|
+
if (statusIntOffset >= respBuffer.length || respBuffer[statusIntOffset] !== 0x02) {
|
|
381
|
+
throw new Error(`Expected INTEGER tag (0x02) for PKIStatus at offset ${statusIntOffset}`);
|
|
382
|
+
}
|
|
383
|
+
const statusIntLen = decodeDerLength(respBuffer, statusIntOffset + 1);
|
|
384
|
+
const valOffset = statusIntOffset + 1 + statusIntLen.headerLength;
|
|
385
|
+
if (valOffset + statusIntLen.length > respBuffer.length) {
|
|
386
|
+
throw new Error('Truncated PKIStatus integer value in DER buffer');
|
|
387
|
+
}
|
|
388
|
+
let status = 0;
|
|
389
|
+
for (let i = 0; i < statusIntLen.length; i++) {
|
|
390
|
+
status = (status << 8) | respBuffer[valOffset + i];
|
|
391
|
+
}
|
|
392
|
+
return {
|
|
393
|
+
status,
|
|
394
|
+
statusText: exports.PKI_STATUS_TEXT[status] || `unknown_status_${status}`
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* In-band RFC 3161 TimeStampResp validator.
|
|
399
|
+
* Enforces:
|
|
400
|
+
* 1. PKIStatusInfo is granted (0) or grantedWithMods (1)
|
|
401
|
+
* 2. TimeStampToken presence
|
|
402
|
+
* 3. MessageImprint digest equality (asserts TSA stamped the exact requested file hash)
|
|
403
|
+
* 4. Cryptographic nonce echo match (asserts replay attack protection)
|
|
404
|
+
*/
|
|
405
|
+
function validateTimeStampResp(respBuffer, options = {}) {
|
|
406
|
+
let status = -1, statusText = 'parse_error';
|
|
407
|
+
try {
|
|
408
|
+
if (!respBuffer.length || respBuffer.length > 512 * 1024 || asn1js.fromBER(toArrayBuffer(respBuffer)).offset !== respBuffer.length)
|
|
409
|
+
throw new Error('Invalid or oversized response');
|
|
410
|
+
({ status, statusText } = parsePkiStatus(respBuffer));
|
|
411
|
+
if (![0, 1].includes(status))
|
|
412
|
+
throw new Error(`RFC 3161 PKIStatus rejected: ${status} (${statusText})`);
|
|
413
|
+
const resp = pkijs.TimeStampResp.fromBER(toArrayBuffer(respBuffer));
|
|
414
|
+
if (!resp.timeStampToken || resp.timeStampToken.contentType !== '1.2.840.113549.1.7.2')
|
|
415
|
+
throw new Error('Missing CMS timestamp token');
|
|
416
|
+
const signed = new pkijs.SignedData({ schema: resp.timeStampToken.content });
|
|
417
|
+
if (signed.encapContentInfo.eContentType !== '1.2.840.113549.1.9.16.1.4')
|
|
418
|
+
throw new Error('Expected TSTInfo');
|
|
419
|
+
const bytes = Buffer.from(signed.encapContentInfo.eContent.getValue());
|
|
420
|
+
if (asn1js.fromBER(toArrayBuffer(bytes)).offset !== bytes.length)
|
|
421
|
+
throw new Error('Trailing TSTInfo data');
|
|
422
|
+
const info = pkijs.TSTInfo.fromBER(toArrayBuffer(bytes));
|
|
423
|
+
if (options.expectedDigest && (options.expectedDigest.length !== 32 || info.messageImprint.hashAlgorithm.algorithmId !== '2.16.840.1.101.3.4.2.1' || !Buffer.from(info.messageImprint.hashedMessage.valueBlock.valueHexView).equals(options.expectedDigest)))
|
|
424
|
+
throw new Error('TimeStampToken does not contain expected message imprint digest');
|
|
425
|
+
if (options.expectedNonce !== undefined) {
|
|
426
|
+
const expected = Buffer.isBuffer(options.expectedNonce) ? BigInt('0x' + (options.expectedNonce.toString('hex') || '0')) : options.expectedNonce;
|
|
427
|
+
const nonce = info.nonce ? BigInt('0x' + (Buffer.from(info.nonce.valueBlock.valueHexView).toString('hex') || '0')) : undefined;
|
|
428
|
+
if (nonce !== expected)
|
|
429
|
+
throw new Error('TimeStampToken nonce does not match request nonce');
|
|
430
|
+
}
|
|
431
|
+
return { valid: true, status, statusText, genTime: info.genTime.toISOString() };
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
return { valid: false, status, statusText, error: error.message };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function toArrayBuffer(buf) {
|
|
438
|
+
const ab = new ArrayBuffer(buf.byteLength);
|
|
439
|
+
const view = new Uint8Array(ab);
|
|
440
|
+
view.set(buf);
|
|
441
|
+
return ab;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Cryptographically verifies an RFC 3161 TimeStampToken or TimeStampResp buffer using PKI.js.
|
|
445
|
+
* Validates CMS SignedData signature over signedAttrs (including 0x31 SET OF re-encoding),
|
|
446
|
+
* confirms message-digest matches the encapsulated TSTInfo, and asserts the stamped
|
|
447
|
+
* document digest matches documentBytes.
|
|
448
|
+
*/
|
|
449
|
+
async function verifyTimeStampToken(tsrBuffer, documentBytes, options = {}) {
|
|
450
|
+
try {
|
|
451
|
+
if (!tsrBuffer.length || tsrBuffer.length > 512 * 1024)
|
|
452
|
+
throw new Error('Timestamp input exceeds size limit');
|
|
453
|
+
const parsed = asn1js.fromBER(toArrayBuffer(tsrBuffer));
|
|
454
|
+
if (parsed.offset !== tsrBuffer.length)
|
|
455
|
+
throw new Error('Invalid or trailing timestamp DER');
|
|
456
|
+
let signed;
|
|
457
|
+
const children = parsed.result.valueBlock.value;
|
|
458
|
+
if (!Array.isArray(children))
|
|
459
|
+
throw new Error('Invalid timestamp structure');
|
|
460
|
+
if (children[0] instanceof asn1js.ObjectIdentifier) {
|
|
461
|
+
const ci = new pkijs.ContentInfo({ schema: parsed.result });
|
|
462
|
+
if (ci.contentType !== '1.2.840.113549.1.7.2')
|
|
463
|
+
throw new Error('Expected CMS SignedData');
|
|
464
|
+
signed = new pkijs.SignedData({ schema: ci.content });
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
const resp = new pkijs.TimeStampResp({ schema: parsed.result });
|
|
468
|
+
if (![0, 1].includes(resp.status.status) || !resp.timeStampToken || resp.timeStampToken.contentType !== '1.2.840.113549.1.7.2')
|
|
469
|
+
throw new Error('Timestamp response not granted or missing token');
|
|
470
|
+
signed = new pkijs.SignedData({ schema: resp.timeStampToken.content });
|
|
471
|
+
}
|
|
472
|
+
if (signed.signerInfos.length !== 1 || signed.encapContentInfo.eContentType !== '1.2.840.113549.1.9.16.1.4')
|
|
473
|
+
throw new Error('Expected one TSA signer and TSTInfo content');
|
|
474
|
+
const content = signed.encapContentInfo.eContent;
|
|
475
|
+
if (!(content instanceof asn1js.OctetString))
|
|
476
|
+
throw new Error('Expected primitive DER TSTInfo octets');
|
|
477
|
+
const tstBytes = Buffer.from(content.getValue());
|
|
478
|
+
if (asn1js.fromBER(toArrayBuffer(tstBytes)).offset !== tstBytes.length)
|
|
479
|
+
throw new Error('Trailing TSTInfo bytes');
|
|
480
|
+
const info = pkijs.TSTInfo.fromBER(toArrayBuffer(tstBytes));
|
|
481
|
+
if (info.version !== 1 || info.messageImprint.hashAlgorithm.algorithmId !== '2.16.840.1.101.3.4.2.1')
|
|
482
|
+
throw new Error('Timestamp requires v1 SHA-256 imprint');
|
|
483
|
+
const expected = options.expectedDigest || crypto.createHash('sha256').update(documentBytes).digest();
|
|
484
|
+
if (expected.length !== 32 || info.messageImprint.hashedMessage.valueBlock.valueHexView.length !== 32)
|
|
485
|
+
throw new Error('SHA-256 imprint must be 32 bytes');
|
|
486
|
+
if (!Buffer.from(info.messageImprint.hashedMessage.valueBlock.valueHexView).equals(expected))
|
|
487
|
+
throw new Error('TSTInfo verification is failed: imprint mismatch');
|
|
488
|
+
const signer = signed.signerInfos[0];
|
|
489
|
+
const certificates = (signed.certificates || []).filter((c) => c instanceof pkijs.Certificate);
|
|
490
|
+
const cert = certificates.find(c => {
|
|
491
|
+
if (signer.sid instanceof pkijs.IssuerAndSerialNumber)
|
|
492
|
+
return c.issuer.isEqual(signer.sid.issuer) && c.serialNumber.isEqual(signer.sid.serialNumber);
|
|
493
|
+
const sid = signer.sid;
|
|
494
|
+
const value = sid.idBlock.isConstructed ? sid.valueBlock.value[0].valueBlock.valueHexView : sid.valueBlock.valueHexView;
|
|
495
|
+
return crypto.createHash('sha1').update(Buffer.from(c.subjectPublicKeyInfo.subjectPublicKey.valueBlock.valueHexView)).digest().equals(Buffer.from(value));
|
|
496
|
+
});
|
|
497
|
+
if (!cert || !signer.signedAttrs)
|
|
498
|
+
throw new Error('Missing TSA certificate or signed attributes');
|
|
499
|
+
const attributes = signer.signedAttrs.attributes;
|
|
500
|
+
const attr = (oid) => {
|
|
501
|
+
const matches = attributes.filter(a => a.type === oid);
|
|
502
|
+
if (matches.length !== 1 || matches[0].values.length !== 1)
|
|
503
|
+
throw new Error('Missing or duplicate timestamp attribute ' + oid);
|
|
504
|
+
return matches[0].values[0];
|
|
505
|
+
};
|
|
506
|
+
const type = attr('1.2.840.113549.1.9.3');
|
|
507
|
+
if (!(type instanceof asn1js.ObjectIdentifier) || type.valueBlock.toString() !== signed.encapContentInfo.eContentType)
|
|
508
|
+
throw new Error('Signed content type mismatch');
|
|
509
|
+
const hashes = { '2.16.840.1.101.3.4.2.1': 'sha256', '2.16.840.1.101.3.4.2.2': 'sha384', '2.16.840.1.101.3.4.2.3': 'sha512' };
|
|
510
|
+
const hash = hashes[signer.digestAlgorithm.algorithmId];
|
|
511
|
+
if (!hash)
|
|
512
|
+
throw new Error('Unsupported timestamp digest algorithm');
|
|
513
|
+
const digest = attr('1.2.840.113549.1.9.4');
|
|
514
|
+
if (!(digest instanceof asn1js.OctetString) || !Buffer.from(digest.valueBlock.valueHexView).equals(crypto.createHash(hash).update(tstBytes).digest()))
|
|
515
|
+
throw new Error('Signed content digest mismatch');
|
|
516
|
+
const certBytes = Buffer.from(cert.toSchema(true).toBER(false));
|
|
517
|
+
const x509 = new crypto.X509Certificate(certBytes);
|
|
518
|
+
const sigAlg = signer.signatureAlgorithm.algorithmId;
|
|
519
|
+
const allowedSignatureAlgorithms = {
|
|
520
|
+
rsa: ['1.2.840.113549.1.1.1', { sha256: '1.2.840.113549.1.1.11', sha384: '1.2.840.113549.1.1.12', sha512: '1.2.840.113549.1.1.13' }[hash]],
|
|
521
|
+
ec: [{ sha256: '1.2.840.10045.4.3.2', sha384: '1.2.840.10045.4.3.3', sha512: '1.2.840.10045.4.3.4' }[hash]]
|
|
522
|
+
};
|
|
523
|
+
if (!allowedSignatureAlgorithms[x509.publicKey.asymmetricKeyType || '']?.includes(sigAlg))
|
|
524
|
+
throw new Error('Unsupported timestamp signature algorithm');
|
|
525
|
+
const attrs = Buffer.from(signer.signedAttrs.encodedValue);
|
|
526
|
+
if (!attrs.length)
|
|
527
|
+
throw new Error('Missing signed attribute encoding');
|
|
528
|
+
attrs[0] = 0x31;
|
|
529
|
+
if (!crypto.verify(hash, attrs, x509.publicKey, Buffer.from(signer.signature.valueBlock.valueHexView)))
|
|
530
|
+
throw new Error('Timestamp CMS signature failed');
|
|
531
|
+
if (!Number.isFinite(info.genTime.getTime()) || info.genTime < cert.notBefore.value || info.genTime > cert.notAfter.value)
|
|
532
|
+
throw new Error('TSA certificate invalid at token generation time');
|
|
533
|
+
const eku = cert.extensions?.filter(e => e.extnID === '2.5.29.37');
|
|
534
|
+
if (!eku || eku.length !== 1 || !eku[0].critical)
|
|
535
|
+
throw new Error('TSA requires critical timestamping EKU');
|
|
536
|
+
const usage = pkijs.ExtKeyUsage.fromBER(toArrayBuffer(Buffer.from(eku[0].extnValue.valueBlock.valueHexView)));
|
|
537
|
+
if (usage.keyPurposes.length !== 1 || usage.keyPurposes[0] !== '1.3.6.1.5.5.7.3.8')
|
|
538
|
+
throw new Error('TSA EKU must be timestamping only');
|
|
539
|
+
const keyUsage = cert.extensions?.filter(e => e.extnID === '2.5.29.15') || [];
|
|
540
|
+
if (keyUsage.length > 1)
|
|
541
|
+
throw new Error('Duplicate TSA key usage');
|
|
542
|
+
if (keyUsage.length) {
|
|
543
|
+
const ku = asn1js.fromBER(toArrayBuffer(Buffer.from(keyUsage[0].extnValue.valueBlock.valueHexView))).result;
|
|
544
|
+
if (!(ku instanceof asn1js.BitString) || !(ku.valueBlock.valueHexView[0] & 0xc0))
|
|
545
|
+
throw new Error('TSA key usage forbids signatures');
|
|
546
|
+
}
|
|
547
|
+
const anchored = options.trustAnchors !== undefined;
|
|
548
|
+
const essV2 = attributes.filter(a => a.type === '1.2.840.113549.1.9.16.2.47');
|
|
549
|
+
// Legacy ESSCertID is accepted only for integrity-only checks. Anchored release policy requires v2.
|
|
550
|
+
const ess = attr(essV2.length ? '1.2.840.113549.1.9.16.2.47' : '1.2.840.113549.1.9.16.2.12');
|
|
551
|
+
const certIds = ess.valueBlock.value[0];
|
|
552
|
+
const fields = certIds.valueBlock.value[0].valueBlock.value;
|
|
553
|
+
let certHashAlgorithm = essV2.length ? 'sha256' : 'sha1';
|
|
554
|
+
let index = 0;
|
|
555
|
+
if (fields[0] instanceof asn1js.Sequence) {
|
|
556
|
+
if (!essV2.length)
|
|
557
|
+
throw new Error('Malformed ESSCertID');
|
|
558
|
+
certHashAlgorithm = hashes[new pkijs.AlgorithmIdentifier({ schema: fields[index++] }).algorithmId];
|
|
559
|
+
}
|
|
560
|
+
const certHash = fields[index++];
|
|
561
|
+
if (!certHashAlgorithm || !(certHash instanceof asn1js.OctetString) || !Buffer.from(certHash.valueBlock.valueHexView).equals(crypto.createHash(certHashAlgorithm).update(certBytes).digest()))
|
|
562
|
+
throw new Error('ESS certificate hash mismatch');
|
|
563
|
+
if (fields[index]) {
|
|
564
|
+
const issuer = new pkijs.IssuerSerial({ schema: fields[index++] });
|
|
565
|
+
if (!issuer.serialNumber.isEqual(cert.serialNumber) || !issuer.issuer.names.some(n => n.type === 4 && n.value.isEqual(cert.issuer)))
|
|
566
|
+
throw new Error('ESS issuer/serial mismatch');
|
|
567
|
+
}
|
|
568
|
+
if (index !== fields.length)
|
|
569
|
+
throw new Error('Malformed ESS certificate identifier');
|
|
570
|
+
if (anchored) {
|
|
571
|
+
if (!essV2.length || !options.trustAnchors?.length)
|
|
572
|
+
throw new Error('Anchored timestamp requires ESSCertIDv2 and explicit trust anchors');
|
|
573
|
+
const trustedCerts = options.trustAnchors.map(pem => {
|
|
574
|
+
if (!/^-----BEGIN CERTIFICATE-----[\s\S]+-----END CERTIFICATE-----\s*$/.test(pem))
|
|
575
|
+
throw new Error('Malformed trust anchor PEM');
|
|
576
|
+
return pkijs.Certificate.fromBER(toArrayBuffer(new crypto.X509Certificate(pem).raw));
|
|
577
|
+
});
|
|
578
|
+
const chain = new pkijs.CertificateChainValidationEngine({ trustedCerts, certs: [...certificates.filter(c => c !== cert), cert], checkDate: info.genTime });
|
|
579
|
+
const result = await chain.verify({ passedWhenNotRevValues: true });
|
|
580
|
+
if (!result.result)
|
|
581
|
+
throw new Error('TSA chain validation failed: ' + result.resultMessage);
|
|
582
|
+
}
|
|
583
|
+
return { verified: true, signerVerified: true, trusted: anchored, trust: anchored ? 'ANCHORED' : 'INTEGRITY_ONLY', signerSubject: x509.subject, genTime: info.genTime.toISOString() };
|
|
584
|
+
}
|
|
585
|
+
catch (err) {
|
|
586
|
+
return { verified: false, trusted: false, error: err?.message || String(err) };
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Build an RFC 3161 TimeStampReq ASN.1 DER structure.
|
|
591
|
+
* RFC 3161 §2.4.1:
|
|
592
|
+
* TimeStampReq ::= SEQUENCE {
|
|
593
|
+
* version INTEGER { v1(1) },
|
|
594
|
+
* messageImprint MessageImprint,
|
|
595
|
+
* nonce INTEGER OPTIONAL,
|
|
596
|
+
* certReq BOOLEAN DEFAULT FALSE
|
|
597
|
+
* }
|
|
598
|
+
*/
|
|
599
|
+
function buildTimeStampReq(sha256Digest, options = {}) {
|
|
600
|
+
if (sha256Digest.length !== 32) {
|
|
601
|
+
throw new Error(`TimeStampReq requires a 32-byte SHA-256 digest, received ${sha256Digest.length} bytes`);
|
|
602
|
+
}
|
|
603
|
+
const version = Buffer.from([0x02, 0x01, 0x01]); // INTEGER 1
|
|
604
|
+
// AlgorithmIdentifier for SHA-256 (OID: 2.16.840.1.101.3.4.2.1) + NULL parameters
|
|
605
|
+
const sha256Oid = Buffer.from([0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01]);
|
|
606
|
+
const nullParams = Buffer.from([0x05, 0x00]);
|
|
607
|
+
const algId = encodeDerSequence([sha256Oid, nullParams]);
|
|
608
|
+
// MessageImprint
|
|
609
|
+
const octetDigest = Buffer.concat([Buffer.from([0x04, 0x20]), sha256Digest]);
|
|
610
|
+
const messageImprint = encodeDerSequence([algId, octetDigest]);
|
|
611
|
+
const elements = [version, messageImprint];
|
|
612
|
+
// Nonce (comes before certReq in RFC 3161 §2.4.1)
|
|
613
|
+
if (options.nonce !== undefined) {
|
|
614
|
+
let nonceBuf;
|
|
615
|
+
if (Buffer.isBuffer(options.nonce)) {
|
|
616
|
+
nonceBuf = options.nonce;
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
let hex = options.nonce.toString(16);
|
|
620
|
+
if (hex.length % 2 !== 0)
|
|
621
|
+
hex = '0' + hex;
|
|
622
|
+
nonceBuf = Buffer.from(hex, 'hex');
|
|
623
|
+
}
|
|
624
|
+
// Ensure positive integer in DER (prepend 0x00 if highest bit is set)
|
|
625
|
+
if (nonceBuf.length > 0 && (nonceBuf[0] & 0x80)) {
|
|
626
|
+
nonceBuf = Buffer.concat([Buffer.from([0x00]), nonceBuf]);
|
|
627
|
+
}
|
|
628
|
+
elements.push(Buffer.concat([Buffer.from([0x02]), encodeDerLength(nonceBuf.length), nonceBuf]));
|
|
629
|
+
}
|
|
630
|
+
// certReq: true
|
|
631
|
+
if (options.certReq !== false) {
|
|
632
|
+
elements.push(Buffer.from([0x01, 0x01, 0xff]));
|
|
633
|
+
}
|
|
634
|
+
return encodeDerSequence(elements);
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Extract GeneralizedTime (tag 0x18) from ASN.1 DER TimeStampResp buffer.
|
|
638
|
+
* Returns ISO 8601 formatted string or null if not found.
|
|
639
|
+
*/
|
|
640
|
+
function extractGenTime(tsrBuffer) {
|
|
641
|
+
for (let i = 0; i < tsrBuffer.length - 16; i++) {
|
|
642
|
+
if (tsrBuffer[i] === 0x18) {
|
|
643
|
+
const len = tsrBuffer[i + 1];
|
|
644
|
+
if (len >= 13 && len <= 24 && i + 2 + len <= tsrBuffer.length) {
|
|
645
|
+
const str = tsrBuffer.slice(i + 2, i + 2 + len).toString('ascii');
|
|
646
|
+
// Matches YYYYMMDDHHMMSS...Z
|
|
647
|
+
if (/^\d{14}/.test(str)) {
|
|
648
|
+
const year = str.slice(0, 4);
|
|
649
|
+
const month = str.slice(4, 6);
|
|
650
|
+
const day = str.slice(6, 8);
|
|
651
|
+
const hour = str.slice(8, 10);
|
|
652
|
+
const min = str.slice(10, 12);
|
|
653
|
+
const sec = str.slice(12, 14);
|
|
654
|
+
return `${year}-${month}-${day}T${hour}:${min}:${sec}Z`;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Execute HTTP POST query to an RFC 3161 TSA endpoint.
|
|
663
|
+
*/
|
|
664
|
+
function queryTsa(tsaUrl, tsqBuffer, timeoutMs = 5000) {
|
|
665
|
+
return new Promise((resolve, reject) => {
|
|
666
|
+
const url = new URL(tsaUrl);
|
|
667
|
+
if (!['http:', 'https:'].includes(url.protocol) || !Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000)
|
|
668
|
+
return reject(new Error('Invalid TSA URL or timeout'));
|
|
669
|
+
const req = (url.protocol === 'https:' ? https : http).request(url, { method: 'POST', headers: { 'Content-Type': 'application/timestamp-query', 'Content-Length': tsqBuffer.length } });
|
|
670
|
+
const timer = setTimeout(() => req.destroy(new Error('TSA absolute deadline exceeded')), timeoutMs);
|
|
671
|
+
const fail = (error) => { clearTimeout(timer); reject(error); };
|
|
672
|
+
req.on('error', fail);
|
|
673
|
+
req.on('response', res => {
|
|
674
|
+
const chunks = [];
|
|
675
|
+
let size = 0;
|
|
676
|
+
res.on('error', fail);
|
|
677
|
+
res.on('aborted', () => fail(new Error('TSA response aborted')));
|
|
678
|
+
res.on('data', chunk => {
|
|
679
|
+
size += chunk.length;
|
|
680
|
+
if (size > 512 * 1024) {
|
|
681
|
+
res.destroy();
|
|
682
|
+
req.destroy(new Error('TSA response exceeds size limit'));
|
|
683
|
+
}
|
|
684
|
+
else
|
|
685
|
+
chunks.push(Buffer.from(chunk));
|
|
686
|
+
});
|
|
687
|
+
res.on('end', () => {
|
|
688
|
+
clearTimeout(timer);
|
|
689
|
+
if (res.statusCode !== 200)
|
|
690
|
+
reject(new Error('TSA HTTP status ' + res.statusCode));
|
|
691
|
+
else
|
|
692
|
+
resolve(Buffer.concat(chunks));
|
|
693
|
+
});
|
|
694
|
+
});
|
|
695
|
+
req.end(tsqBuffer);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Probe a single TSA server and return a structured telemetry record.
|
|
700
|
+
* Automatically performs in-band RFC 3161 verification on successful responses:
|
|
701
|
+
* - Checks PKIStatus is granted (0) or grantedWithMods (1)
|
|
702
|
+
* - Verifies TimeStampToken exists
|
|
703
|
+
* - Asserts messageImprint digest matches expectedDigest (if provided)
|
|
704
|
+
* - Asserts returned nonce matches expectedNonce (if provided)
|
|
705
|
+
*/
|
|
706
|
+
async function probeTsa(tsaUrl, tsqBuffer, tier, timeoutMs = 4000, expectedDigest, expectedNonce, circuitBreaker = exports.globalCircuitBreaker) {
|
|
707
|
+
// Fast path: if circuit breaker has this endpoint in backoff, skip network query
|
|
708
|
+
if (!circuitBreaker.isAvailable(tsaUrl)) {
|
|
709
|
+
const health = circuitBreaker.getHealth(tsaUrl);
|
|
710
|
+
const waitRemaining = Math.max(0, (health?.nextAvailableTime || 0) - Date.now());
|
|
711
|
+
const status = circuitBreaker.getServerStatus(tsaUrl);
|
|
712
|
+
return {
|
|
713
|
+
tier,
|
|
714
|
+
method: 'RFC3161',
|
|
715
|
+
server: tsaUrl,
|
|
716
|
+
status: 'ERROR',
|
|
717
|
+
error: `Endpoint in ${status.toLowerCase()} (${Math.round(waitRemaining / 1000)}s remaining): ${health?.lastError || 'circuit breaker active'}`
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
circuitBreaker.recordRequest(tsaUrl);
|
|
721
|
+
try {
|
|
722
|
+
const data = await queryTsa(tsaUrl, tsqBuffer, timeoutMs);
|
|
723
|
+
if (data && data.length > 32) {
|
|
724
|
+
const validation = validateTimeStampResp(data, { expectedDigest, expectedNonce });
|
|
725
|
+
if (!validation.valid) {
|
|
726
|
+
circuitBreaker.recordFailure(tsaUrl, validation.error || 'RFC 3161 response validation failed');
|
|
727
|
+
return {
|
|
728
|
+
tier,
|
|
729
|
+
method: 'RFC3161',
|
|
730
|
+
server: tsaUrl,
|
|
731
|
+
status: 'ERROR',
|
|
732
|
+
error: validation.error || 'RFC 3161 response validation failed'
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
circuitBreaker.recordSuccess(tsaUrl);
|
|
736
|
+
const verification = await verifyTimeStampToken(data, Buffer.alloc(0), { expectedDigest });
|
|
737
|
+
if (!verification.verified || !verification.genTime)
|
|
738
|
+
throw new Error(verification.error || 'Timestamp time missing');
|
|
739
|
+
const genTime = verification.genTime;
|
|
740
|
+
return {
|
|
741
|
+
tier,
|
|
742
|
+
method: 'RFC3161',
|
|
743
|
+
server: tsaUrl,
|
|
744
|
+
status: 'SUCCESS',
|
|
745
|
+
timestamp_iso: genTime,
|
|
746
|
+
token_b64: data.toString('base64'),
|
|
747
|
+
token_bytes: data
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
circuitBreaker.recordFailure(tsaUrl, 'Empty or invalid response from TSA');
|
|
751
|
+
return {
|
|
752
|
+
tier,
|
|
753
|
+
method: 'RFC3161',
|
|
754
|
+
server: tsaUrl,
|
|
755
|
+
status: 'ERROR',
|
|
756
|
+
error: 'Empty or invalid response from TSA'
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
catch (err) {
|
|
760
|
+
const isTimeout = /timed? out/i.test(err?.message || '');
|
|
761
|
+
circuitBreaker.recordFailure(tsaUrl, err?.message || String(err));
|
|
762
|
+
return {
|
|
763
|
+
tier,
|
|
764
|
+
method: 'RFC3161',
|
|
765
|
+
server: tsaUrl,
|
|
766
|
+
status: isTimeout ? 'TIMEOUT' : 'ERROR',
|
|
767
|
+
error: err?.message || String(err)
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
const HTTP_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
772
|
+
const HTTP_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
773
|
+
const HTTP_DAYS_LONG = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
774
|
+
const IMF_FIXDATE = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
|
|
775
|
+
const RFC850_DATE = /^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/;
|
|
776
|
+
const ASCTIME_DATE = /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/;
|
|
777
|
+
/**
|
|
778
|
+
* Parse an RFC 7231 section 7.1.1.1 HTTP-date: IMF-fixdate, or the obsolete RFC 850 and asctime
|
|
779
|
+
* forms a recipient must still accept. The grammar is case-sensitive, the day name must agree
|
|
780
|
+
* with the date, and the date must exist (no 31 February, no 24:00:00, no leap second).
|
|
781
|
+
* Returns epoch milliseconds, or null for anything else - including ISO-8601 and bare numbers
|
|
782
|
+
* that `new Date(...)` would happily accept.
|
|
783
|
+
*/
|
|
784
|
+
function parseHttpDate(value, now = Date.now()) {
|
|
785
|
+
if (typeof value !== 'string')
|
|
786
|
+
return null;
|
|
787
|
+
let dayName;
|
|
788
|
+
let day, month, year, hh, mm, ss;
|
|
789
|
+
let long = false;
|
|
790
|
+
let m = IMF_FIXDATE.exec(value);
|
|
791
|
+
if (m) {
|
|
792
|
+
dayName = m[1];
|
|
793
|
+
day = +m[2];
|
|
794
|
+
month = HTTP_MONTHS.indexOf(m[3]);
|
|
795
|
+
year = +m[4];
|
|
796
|
+
hh = +m[5];
|
|
797
|
+
mm = +m[6];
|
|
798
|
+
ss = +m[7];
|
|
799
|
+
}
|
|
800
|
+
else if ((m = RFC850_DATE.exec(value))) {
|
|
801
|
+
long = true;
|
|
802
|
+
dayName = m[1];
|
|
803
|
+
day = +m[2];
|
|
804
|
+
month = HTTP_MONTHS.indexOf(m[3]);
|
|
805
|
+
year = 2000 + +m[4];
|
|
806
|
+
// RFC 7231: a two-digit year more than 50 years in the future means the past century.
|
|
807
|
+
if (year > new Date(now).getUTCFullYear() + 50)
|
|
808
|
+
year -= 100;
|
|
809
|
+
hh = +m[5];
|
|
810
|
+
mm = +m[6];
|
|
811
|
+
ss = +m[7];
|
|
812
|
+
}
|
|
813
|
+
else if ((m = ASCTIME_DATE.exec(value))) {
|
|
814
|
+
dayName = m[1];
|
|
815
|
+
month = HTTP_MONTHS.indexOf(m[2]);
|
|
816
|
+
day = +m[3].trim();
|
|
817
|
+
hh = +m[4];
|
|
818
|
+
mm = +m[5];
|
|
819
|
+
ss = +m[6];
|
|
820
|
+
year = +m[7];
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
if (hh > 23 || mm > 59 || ss > 59 || day < 1)
|
|
826
|
+
return null;
|
|
827
|
+
const d = new Date(0);
|
|
828
|
+
d.setUTCFullYear(year, month, day);
|
|
829
|
+
d.setUTCHours(hh, mm, ss, 0);
|
|
830
|
+
if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month || d.getUTCDate() !== day)
|
|
831
|
+
return null;
|
|
832
|
+
const expected = (long ? HTTP_DAYS_LONG : HTTP_DAYS)[d.getUTCDay()];
|
|
833
|
+
if (dayName !== expected)
|
|
834
|
+
return null;
|
|
835
|
+
return d.getTime();
|
|
836
|
+
}
|
|
837
|
+
/** Identity of a network time source for de-duplication: scheme, path, case and trailing dot ignored. */
|
|
838
|
+
function normalizeNetworkHost(host) {
|
|
839
|
+
return host.trim().toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, '').replace(/\/.*$/, '').replace(/\.+$/, '');
|
|
840
|
+
}
|
|
841
|
+
function uniqueNetworkHosts(hosts) {
|
|
842
|
+
const seen = new Set();
|
|
843
|
+
return hosts.filter(h => {
|
|
844
|
+
const key = normalizeNetworkHost(h);
|
|
845
|
+
if (!key || seen.has(key))
|
|
846
|
+
return false;
|
|
847
|
+
seen.add(key);
|
|
848
|
+
return true;
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Probe network time via HTTP Date header. The header must be an RFC 7231 HTTP-date.
|
|
853
|
+
*/
|
|
854
|
+
function probeNetworkTime(host, timeoutMs = 3000) {
|
|
855
|
+
return new Promise((resolve) => {
|
|
856
|
+
const req = https.request(`https://${host}`, {
|
|
857
|
+
method: 'HEAD',
|
|
858
|
+
headers: { 'User-Agent': 'Moonglade-DTA/2.0' },
|
|
859
|
+
timeout: timeoutMs
|
|
860
|
+
}, (res) => {
|
|
861
|
+
const dateStr = res.headers['date'];
|
|
862
|
+
if (typeof dateStr === 'string') {
|
|
863
|
+
const ms = parseHttpDate(dateStr);
|
|
864
|
+
if (ms !== null) {
|
|
865
|
+
resolve({
|
|
866
|
+
tier: 'L3_NETWORK_CONSENSUS',
|
|
867
|
+
method: 'HTTP_DATE',
|
|
868
|
+
server: host,
|
|
869
|
+
status: 'SUCCESS',
|
|
870
|
+
timestamp_iso: new Date(ms).toISOString()
|
|
871
|
+
});
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
resolve({
|
|
876
|
+
tier: 'L3_NETWORK_CONSENSUS',
|
|
877
|
+
method: 'HTTP_DATE',
|
|
878
|
+
server: host,
|
|
879
|
+
status: 'ERROR',
|
|
880
|
+
error: 'No valid Date header returned'
|
|
881
|
+
});
|
|
882
|
+
});
|
|
883
|
+
req.on('timeout', () => {
|
|
884
|
+
req.destroy();
|
|
885
|
+
resolve({
|
|
886
|
+
tier: 'L3_NETWORK_CONSENSUS',
|
|
887
|
+
method: 'HTTP_DATE',
|
|
888
|
+
server: host,
|
|
889
|
+
status: 'TIMEOUT',
|
|
890
|
+
error: `Network probe to ${host} timed out`
|
|
891
|
+
});
|
|
892
|
+
});
|
|
893
|
+
req.on('error', (err) => {
|
|
894
|
+
resolve({
|
|
895
|
+
tier: 'L3_NETWORK_CONSENSUS',
|
|
896
|
+
method: 'HTTP_DATE',
|
|
897
|
+
server: host,
|
|
898
|
+
status: 'ERROR',
|
|
899
|
+
error: err.message
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
req.end();
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Evaluates whether successful L3 network probes form a Byzantine-tolerant quorum.
|
|
907
|
+
* Requirements:
|
|
908
|
+
* 1. At least 2 successful HTTP Date probes (or >= quorumThreshold).
|
|
909
|
+
* 2. Absolute time drift between min and max probe timestamps is within maxDriftMs (default: 3000ms).
|
|
910
|
+
* Returns the median timestamp and participating servers if quorum is reached, otherwise null.
|
|
911
|
+
*/
|
|
912
|
+
function resolveNetworkConsensus(probes, quorumThreshold = 2, maxDriftMs = 3000) {
|
|
913
|
+
// Configuration fails closed. Only a number >= 1 can lower or raise the default quorum of 2, and a
|
|
914
|
+
// fraction rounds UP; 0, negatives, NaN and non-numbers leave the default, and Infinity can never be
|
|
915
|
+
// met. A single host therefore reaches L3 only when the caller asks for exactly 1.
|
|
916
|
+
let quorum = 2;
|
|
917
|
+
if (typeof quorumThreshold === 'number' && quorumThreshold >= 1) {
|
|
918
|
+
if (!Number.isFinite(quorumThreshold))
|
|
919
|
+
return null;
|
|
920
|
+
quorum = Math.ceil(quorumThreshold);
|
|
921
|
+
}
|
|
922
|
+
// A NaN, negative, infinite or non-numeric drift limit must not disable the drift check.
|
|
923
|
+
const driftLimit = typeof maxDriftMs === 'number' && Number.isFinite(maxDriftMs) && maxDriftMs >= 0 ? maxDriftMs : 3000;
|
|
924
|
+
// A quorum counts distinct sources. The same host listed twice (or spelled two ways) is one
|
|
925
|
+
// participant, so repeating a server cannot manufacture agreement; its first answer is kept.
|
|
926
|
+
const distinct = new Map();
|
|
927
|
+
for (const p of probes) {
|
|
928
|
+
if (p.tier !== 'L3_NETWORK_CONSENSUS' || p.status !== 'SUCCESS' || !p.timestamp_iso)
|
|
929
|
+
continue;
|
|
930
|
+
if (typeof p.server !== 'string')
|
|
931
|
+
continue;
|
|
932
|
+
const key = normalizeNetworkHost(p.server);
|
|
933
|
+
if (key && !distinct.has(key))
|
|
934
|
+
distinct.set(key, p);
|
|
935
|
+
}
|
|
936
|
+
const successful = [...distinct.values()];
|
|
937
|
+
if (successful.length < quorum) {
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
const times = successful
|
|
941
|
+
.map(p => ({
|
|
942
|
+
server: p.server,
|
|
943
|
+
iso: p.timestamp_iso,
|
|
944
|
+
ms: new Date(p.timestamp_iso).getTime()
|
|
945
|
+
}))
|
|
946
|
+
.filter(t => !isNaN(t.ms));
|
|
947
|
+
if (times.length < quorum) {
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
times.sort((a, b) => a.ms - b.ms);
|
|
951
|
+
const minTime = times[0].ms;
|
|
952
|
+
const maxTime = times[times.length - 1].ms;
|
|
953
|
+
const drift = Math.abs(maxTime - minTime);
|
|
954
|
+
if (drift > driftLimit) {
|
|
955
|
+
return null;
|
|
956
|
+
}
|
|
957
|
+
// Median selection
|
|
958
|
+
const mid = Math.floor(times.length / 2);
|
|
959
|
+
const median = times[mid];
|
|
960
|
+
const serverList = times.map(t => t.server).join('+');
|
|
961
|
+
return {
|
|
962
|
+
consensusTime: median.iso,
|
|
963
|
+
server: `consensus(${serverList})`
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Execute Concurrent Multi-Source Degrading Timestamping Architecture (DTA) fan-out.
|
|
968
|
+
* Default pattern: simultaneously seeks 1 probe from each tier (L1, L2, L3, L4).
|
|
969
|
+
* If specifiedServer or specifiedTier is requested, executes targeted query instead.
|
|
970
|
+
*/
|
|
971
|
+
async function concurrentDegradingTimestamp(sha256Digest, options = {}) {
|
|
972
|
+
const cb = options.circuitBreaker || exports.globalCircuitBreaker;
|
|
973
|
+
const timeoutMs = options.timeoutMs || 4000;
|
|
974
|
+
const nonce = options.nonce || crypto.randomBytes(8);
|
|
975
|
+
const tsq = buildTimeStampReq(sha256Digest, { certReq: options.certReq !== false, nonce });
|
|
976
|
+
const hostProbe = {
|
|
977
|
+
tier: 'L4_HOST_UNAUTHENTICATED',
|
|
978
|
+
method: 'KERNEL_UTC',
|
|
979
|
+
server: 'localhost',
|
|
980
|
+
status: 'SUCCESS',
|
|
981
|
+
timestamp_iso: new Date().toISOString()
|
|
982
|
+
};
|
|
983
|
+
// Case 1: Specifically requested server
|
|
984
|
+
if (options.specifiedServer) {
|
|
985
|
+
const s = options.specifiedServer;
|
|
986
|
+
if (s.startsWith('http://') || s.startsWith('https://')) {
|
|
987
|
+
const tier = exports.DEFAULT_FALLBACK_TSAS.includes(s) ? 'L2_CRYPTO_FALLBACK' : 'L1_CRYPTO_PRIMARY';
|
|
988
|
+
const probe = await probeTsa(s, tsq, tier, timeoutMs, sha256Digest, nonce, cb);
|
|
989
|
+
const allProbes = [probe, hostProbe];
|
|
990
|
+
if (probe.status === 'SUCCESS') {
|
|
991
|
+
return {
|
|
992
|
+
tier: 'INTEGRITY_ONLY',
|
|
993
|
+
tsa: probe.server,
|
|
994
|
+
tsa_time: probe.timestamp_iso,
|
|
995
|
+
token_b64: probe.token_b64,
|
|
996
|
+
token_bytes: probe.token_bytes,
|
|
997
|
+
probes: allProbes
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
return {
|
|
1001
|
+
tier: 'L4_HOST_UNAUTHENTICATED',
|
|
1002
|
+
tsa: 'localhost',
|
|
1003
|
+
tsa_time: hostProbe.timestamp_iso,
|
|
1004
|
+
probes: allProbes
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
else {
|
|
1008
|
+
// One host cannot form a quorum. It is L3 only if the caller explicitly lowers the
|
|
1009
|
+
// threshold to 1; otherwise the probe is kept as evidence and the result is L4.
|
|
1010
|
+
const probe = await probeNetworkTime(s, timeoutMs);
|
|
1011
|
+
const allProbes = [probe, hostProbe];
|
|
1012
|
+
const single = resolveNetworkConsensus([probe], options.quorumThreshold ?? 2, options.maxDriftMs ?? 3000);
|
|
1013
|
+
if (single) {
|
|
1014
|
+
return {
|
|
1015
|
+
tier: 'L3_NETWORK_CONSENSUS',
|
|
1016
|
+
tsa: single.server,
|
|
1017
|
+
tsa_time: single.consensusTime,
|
|
1018
|
+
probes: allProbes
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
return {
|
|
1022
|
+
tier: 'L4_HOST_UNAUTHENTICATED',
|
|
1023
|
+
tsa: 'localhost',
|
|
1024
|
+
tsa_time: hostProbe.timestamp_iso,
|
|
1025
|
+
probes: allProbes
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
// Case 2: Specifically requested tier
|
|
1030
|
+
if (options.specifiedTier === 'L4_HOST_UNAUTHENTICATED') {
|
|
1031
|
+
return {
|
|
1032
|
+
tier: 'L4_HOST_UNAUTHENTICATED',
|
|
1033
|
+
tsa: 'localhost',
|
|
1034
|
+
tsa_time: hostProbe.timestamp_iso,
|
|
1035
|
+
probes: [hostProbe]
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
const probesPerTier = options.probesPerTier ?? options.maxParallelProbes ?? 1;
|
|
1039
|
+
const rotate = options.rotate !== false; // Default: true fair rotation
|
|
1040
|
+
const primaryPool = options.primaryEndpoints || exports.DEFAULT_PRIMARY_TSAS;
|
|
1041
|
+
const fallbackPool = options.fallbackEndpoints || exports.DEFAULT_FALLBACK_TSAS;
|
|
1042
|
+
const networkHosts = uniqueNetworkHosts(options.networkEndpoints || exports.DEFAULT_NETWORK_HOSTS);
|
|
1043
|
+
const networkProbes = options.probesPerTier ?? options.maxParallelProbes ?? Math.min(3, networkHosts.length);
|
|
1044
|
+
const probePromises = [];
|
|
1045
|
+
if (!options.specifiedTier || options.specifiedTier === 'L1_CRYPTO_PRIMARY') {
|
|
1046
|
+
const selectedPrimary = rotate
|
|
1047
|
+
? cb.selectRotatingEndpoints(primaryPool, probesPerTier, 'L1_PRIMARY')
|
|
1048
|
+
: cb.selectEndpoints(primaryPool, probesPerTier);
|
|
1049
|
+
probePromises.push(...selectedPrimary.map(url => probeTsa(url, tsq, 'L1_CRYPTO_PRIMARY', timeoutMs, sha256Digest, nonce, cb)));
|
|
1050
|
+
}
|
|
1051
|
+
if (!options.specifiedTier || options.specifiedTier === 'L2_CRYPTO_FALLBACK') {
|
|
1052
|
+
const selectedFallback = rotate
|
|
1053
|
+
? cb.selectRotatingEndpoints(fallbackPool, probesPerTier, 'L2_FALLBACK')
|
|
1054
|
+
: cb.selectEndpoints(fallbackPool, probesPerTier);
|
|
1055
|
+
probePromises.push(...selectedFallback.map(url => probeTsa(url, tsq, 'L2_CRYPTO_FALLBACK', timeoutMs, sha256Digest, nonce, cb)));
|
|
1056
|
+
}
|
|
1057
|
+
if (!options.specifiedTier || options.specifiedTier === 'L3_NETWORK_CONSENSUS') {
|
|
1058
|
+
const selectedNetwork = rotate
|
|
1059
|
+
? cb.selectRotatingEndpoints(networkHosts, networkProbes, 'L3_NETWORK')
|
|
1060
|
+
: cb.selectEndpoints(networkHosts, networkProbes);
|
|
1061
|
+
probePromises.push(...selectedNetwork.map(host => probeNetworkTime(host, Math.min(timeoutMs, 3000))));
|
|
1062
|
+
}
|
|
1063
|
+
const results = await Promise.all(probePromises);
|
|
1064
|
+
const allProbes = [...results, hostProbe];
|
|
1065
|
+
// Selection precedence: L1_CRYPTO_PRIMARY > L2_CRYPTO_FALLBACK > L3_NETWORK_CONSENSUS > L4_HOST_UNAUTHENTICATED
|
|
1066
|
+
const successfulL1 = results.find(p => p.tier === 'L1_CRYPTO_PRIMARY' && p.status === 'SUCCESS');
|
|
1067
|
+
if (successfulL1) {
|
|
1068
|
+
return {
|
|
1069
|
+
tier: 'INTEGRITY_ONLY',
|
|
1070
|
+
tsa: successfulL1.server,
|
|
1071
|
+
tsa_time: successfulL1.timestamp_iso,
|
|
1072
|
+
token_b64: successfulL1.token_b64,
|
|
1073
|
+
token_bytes: successfulL1.token_bytes,
|
|
1074
|
+
probes: allProbes
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
const successfulL2 = results.find(p => p.tier === 'L2_CRYPTO_FALLBACK' && p.status === 'SUCCESS');
|
|
1078
|
+
if (successfulL2) {
|
|
1079
|
+
return {
|
|
1080
|
+
tier: 'INTEGRITY_ONLY',
|
|
1081
|
+
tsa: successfulL2.server,
|
|
1082
|
+
tsa_time: successfulL2.timestamp_iso,
|
|
1083
|
+
token_b64: successfulL2.token_b64,
|
|
1084
|
+
token_bytes: successfulL2.token_bytes,
|
|
1085
|
+
probes: allProbes
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
const consensus = resolveNetworkConsensus(results, options.quorumThreshold ?? 2, options.maxDriftMs ?? 3000);
|
|
1089
|
+
if (consensus) {
|
|
1090
|
+
return {
|
|
1091
|
+
tier: 'L3_NETWORK_CONSENSUS',
|
|
1092
|
+
tsa: consensus.server,
|
|
1093
|
+
tsa_time: consensus.consensusTime,
|
|
1094
|
+
probes: allProbes
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
return {
|
|
1098
|
+
tier: 'L4_HOST_UNAUTHENTICATED',
|
|
1099
|
+
tsa: 'localhost',
|
|
1100
|
+
tsa_time: hostProbe.timestamp_iso,
|
|
1101
|
+
probes: allProbes
|
|
1102
|
+
};
|
|
1103
|
+
}
|