@val-protocol/qes-validator 0.1.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.
@@ -0,0 +1,805 @@
1
+ "use strict";
2
+ /**
3
+ * @val-protocol/qes-validator — eIDAS QES validation for VAL Profile C (ADR 0063).
4
+ *
5
+ * The `validateQes` entry point a relying party runs THEMSELVES (so verification stays trustless), plus
6
+ * the `QesValidationReport` contract the zero-dep `@val-protocol/chain-verifier` consumes
7
+ * (`options.qesValidation.reports`). Structurally a superset of the core's `QesVerdict`
8
+ * ({ qualified, signerIdentity?, reportRef? }) so it drops into the seam unchanged, PLUS `signatureRef`
9
+ * (per-signature keying the WS6 verifier PR / §11.4 item 5 will match on) and `status`/`reason`
10
+ * (conclusive-vs-indeterminate, never collapsed into a bare boolean for the operator).
11
+ *
12
+ * ── BACKEND DECISION (supersedes the scaffold's "S1: wrap DSS") ────────────────────────────────────
13
+ * The closeout build prompt (operator-ratified, 2026-06-29) makes this a **no-DSS, no-server, pure-JS**
14
+ * validator — that is the entire point of "the auditor's trustless tool": a relying party verifies a VAL
15
+ * grant's qualified signature WITHOUT standing up a Java DSS. The earlier scaffold wrapped a self-hosted
16
+ * DSS over REST; that defeated trustlessness and is replaced here. (`@val-protocol/chain-verifier` stays
17
+ * zero-dep; this package carries the X.509/LOTL logic + reuses `@val-protocol/anchor-lotl-resolver`.)
18
+ *
19
+ * ── DOCUMENTED SUBSET (honest scope, ETSI) ─────────────────────────────────────────────────────────
20
+ * Implements a SUBSET of the ETSI validation chain, labelled per document:
21
+ * (1) JWS/JAdES parse (ETSI TS 119 182-1);
22
+ * (2) signature-value verification over the JWS signing input via the embedded x5c — ETSI TS 119 102-1;
23
+ * (3) certificate-path build + verification leaf→issuer→trust-anchor — ETSI TS 119 102-1;
24
+ * (4) qualification-status determination — ETSI TS 119 615: QcStatements in the signing cert
25
+ * (QcCompliance 0.4.0.1862.1.1 AND QcType-eSign 0.4.0.1862.1.6.1) AND the issuer resolves to a
26
+ * Trusted-List service of type CA/QC, granted-for-eSignatures, granted at signing time.
27
+ * NOT in scope (this is DSS's job, not reimplemented here): per-certificate OCSP/CRL revocation, full
28
+ * AdES-LTA/archive-timestamp LTV. Revocation here is at the Trusted-List SERVICE-STATUS granularity
29
+ * (granted/withdrawn at signing time), NOT per-cert OCSP. Anything this subset cannot conclude returns
30
+ * `indeterminate` — never a silent `qualified:true`.
31
+ *
32
+ * PRECONDITION (cert path): the full chain leaf → … → TL-listed granted CA/QC service MUST be present in
33
+ * `x5c` or `trust.intermediateHintsDer`. There is NO AIA/caIssuers chasing (it would need network + break
34
+ * the offline model). A partial `x5c` (leaf only) ⇒ `not_qualified`/`CHAIN_INCOMPLETE` (supply the chain),
35
+ * NOT a false `qualified`, and distinct from `ANCHOR_NOT_ON_TRUSTED_LIST` (complete chain, top not on the TL).
36
+ *
37
+ * Honesty: this validator — not the core — is the authority for "qualified". `qualified` is TRUE only on
38
+ * a conclusive positive determination; anything else is `not_qualified` (conclusive negative) or
39
+ * `indeterminate` (could not conclude). The core treats only `qualified` as the gate, so neither a null
40
+ * identity nor an indeterminate verdict can fake a green.
41
+ */
42
+ Object.defineProperty(exports, "__esModule", { value: true });
43
+ exports.validateQes = validateQes;
44
+ exports.matchGrantedCaQc = matchGrantedCaQc;
45
+ const node_crypto_1 = require("node:crypto");
46
+ /**
47
+ * Resolve the member-state TSL location for a country from the EU LOTL. Inlined (was reused from
48
+ * `@val-protocol/anchor-lotl-resolver`) so this package is SELF-CONTAINED and consumable from a CommonJS
49
+ * backend without an unpublished `.mjs` transitive dependency. Kept byte-identical to the resolver's
50
+ * function; the live-LOTL differential test still cross-checks against the resolver + DSS.
51
+ */
52
+ function findTslPointer(lotlXml, country) {
53
+ const re = /<(?:[a-z0-9]+:)?OtherTSLPointer>([\s\S]*?)<\/(?:[a-z0-9]+:)?OtherTSLPointer>/gi;
54
+ let m;
55
+ while ((m = re.exec(lotlXml))) {
56
+ const b = m[1];
57
+ if (new RegExp(`<(?:[a-z0-9]+:)?SchemeTerritory>${country}</`, 'i').test(b)) {
58
+ const loc = /<(?:[a-z0-9]+:)?TSLLocation>([\s\S]*?\.xml)<\/(?:[a-z0-9]+:)?TSLLocation>/i.exec(b);
59
+ if (loc)
60
+ return loc[1].trim();
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+ // ── eIDAS / ETSI constants ────────────────────────────────────────────────────────────────────────
66
+ const OID_QC_STATEMENTS = '1.3.6.1.5.5.7.1.3'; // id-pe-qcStatements (RFC 3739)
67
+ const OID_QC_COMPLIANCE = '0.4.0.1862.1.1'; // esi4-qcStatement-1 (QcCompliance)
68
+ const OID_QC_SSCD = '0.4.0.1862.1.4'; // esi4-qcStatement-4 (QcSSCD)
69
+ const OID_QC_TYPE = '0.4.0.1862.1.6'; // esi4-qcStatement-6 (QcType)
70
+ const OID_QC_TYPE_ESIGN = '0.4.0.1862.1.6.1'; // id-etsi-qct-esign
71
+ const OID_BASIC_CONSTRAINTS = '2.5.29.19'; // RFC 5280 §4.2.1.9
72
+ const OID_KEY_USAGE = '2.5.29.15'; // RFC 5280 §4.2.1.3 (keyCertSign = bit 5)
73
+ const MAX_PATH_DEPTH = 12; // loop backstop for the certification-path walk
74
+ const SVCTYPE_CA_QC = 'http://uri.etsi.org/TrstSvc/Svctype/CA/QC';
75
+ const SVCTYPE_TSA_QTST = 'http://uri.etsi.org/TrstSvc/Svctype/TSA/QTST';
76
+ const ASI_FOR_ESIGNATURES = 'http://uri.etsi.org/TrstSvc/TrustedList/SvcInfoExt/ForeSignatures';
77
+ const STATUS_GRANTED = 'http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/granted';
78
+ const EU_LOTL_URL = 'https://ec.europa.eu/tools/lotl/eu-lotl.xml';
79
+ /**
80
+ * crit-header allow-list (RFC 7515 §4.1.11). MUST cover EVERY crit param the producer/DSS legitimately
81
+ * emits at the current AdES baseline, or we reject our own valid signatures. Today: only `sigD` (JAdES
82
+ * detached, confirmed by the DSS-emitted artifact's `crit:["sigD"]`), which our binding step processes.
83
+ * When moving to JAdES-BASELINE-T/-LT, DSS may add crit params (e.g. timestamp-related) — extend this
84
+ * list DELIBERATELY at that bump so a new baseline updates the allow-list instead of breaking silently.
85
+ */
86
+ const UNDERSTOOD_CRIT = new Set(['sigD']);
87
+ const indeterminate = (reason, subIndication, signatureRef, adesLevel = null) => ({
88
+ qualified: false,
89
+ status: 'indeterminate',
90
+ reason,
91
+ signatureRef,
92
+ signerIdentity: null,
93
+ indication: 'INDETERMINATE',
94
+ subIndication,
95
+ adesLevel,
96
+ reportRef: null,
97
+ backend: 'offline-js',
98
+ });
99
+ const notQualified = (reason, subIndication, signatureRef, adesLevel = null, indication = 'TOTAL-FAILED') => ({
100
+ qualified: false,
101
+ status: 'not_qualified',
102
+ reason,
103
+ signatureRef,
104
+ signerIdentity: null,
105
+ indication,
106
+ subIndication,
107
+ adesLevel,
108
+ reportRef: null,
109
+ backend: 'offline-js',
110
+ });
111
+ /**
112
+ * Validate a QES → a reproducible `QesValidationReport`, with NO DSS and NO server (pure JS). The relying
113
+ * party runs this THEMSELVES; its output feeds `verifyValChain({ qesValidation: { reports: [...] } })`,
114
+ * so the core never trusts RIGA for "qualified" (Charter §II claim #12). See the file header for the
115
+ * documented ETSI subset and the never-silently-upgrade discipline.
116
+ */
117
+ async function validateQes(input) {
118
+ const signatureBytes = extractSignatureBytes(input.signature);
119
+ const signatureRef = signatureBytes ? sha256Hex(signatureBytes) : null;
120
+ if (!signatureBytes) {
121
+ return indeterminate('unrecognized signature shape (expected ValQesSignature { alg, signature } or base64 JAdES/CAdES)', 'FORMAT_FAILURE', null);
122
+ }
123
+ // FRONT-END (format-specific): produce the normalized { x5cB64, signedBytes-bound, signatureValue,
124
+ // signingTime } by parsing + binding to the canonical + verifying the signature value. Everything AFTER
125
+ // is format-agnostic (anchorAndQualify). JAdES = JWS compact; CAdES = detached CMS/PKCS#7 (DER).
126
+ const fe = parseFrontEnd(signatureBytes, input, signatureRef);
127
+ if (!fe.ok)
128
+ return fe.verdict;
129
+ // SHARED downstream (format-agnostic): RFC 5280 §6 path → TL anchor → QcStatements → verdict.
130
+ return anchorAndQualify(fe, input, signatureRef);
131
+ }
132
+ /** Detect the format from structure (JWS compact → JAdES; CMS SignedData DER → CAdES) and route. */
133
+ function parseFrontEnd(signatureBytes, input, signatureRef) {
134
+ let jws = null;
135
+ try {
136
+ jws = parseCompactJades(signatureBytes);
137
+ }
138
+ catch {
139
+ jws = null;
140
+ }
141
+ if (jws)
142
+ return jadesFrontEnd(jws, input, signatureRef);
143
+ let der = null;
144
+ try {
145
+ der = Buffer.from(signatureBytes.trim(), 'base64');
146
+ }
147
+ catch {
148
+ der = null;
149
+ }
150
+ if (der && der.length > 1 && der[0] === 0x30 && looksLikeSignedData(der)) {
151
+ return cadesFrontEnd(der, input, signatureRef);
152
+ }
153
+ return { ok: false, verdict: indeterminate('unrecognized signature format — expected JAdES (JWS compact) or CAdES (detached CMS/PKCS#7 DER); PAdES/XAdES out of scope', 'FORMAT_FAILURE', signatureRef) };
154
+ }
155
+ /** JAdES front-end: crit enforcement + x5c + sigD/payload binding + JWS signature-value verification. */
156
+ function jadesFrontEnd(jws, input, signatureRef) {
157
+ const adesLevel = jws.header.sigT ? 'JAdES-BASELINE-T(approx)' : 'JAdES-BASELINE-B(approx)';
158
+ const signingTimeMs = input.validationTime ? Date.parse(input.validationTime) : Date.now();
159
+ const critUnknown = (jws.header.crit ?? []).filter((p) => !UNDERSTOOD_CRIT.has(p));
160
+ if (critUnknown.length > 0) {
161
+ return { ok: false, verdict: indeterminate(`unsupported critical header param(s) ${JSON.stringify(critUnknown)} — RFC 7515 requires rejecting a signature whose crit set is not fully understood`, 'FORMAT_FAILURE', signatureRef, adesLevel) };
162
+ }
163
+ if (!jws.header.x5c || jws.header.x5c.length === 0) {
164
+ return { ok: false, verdict: indeterminate('JAdES header carries no x5c certificate chain (cannot identify the signer)', 'NO_SIGNING_CERTIFICATE_FOUND', signatureRef, adesLevel) };
165
+ }
166
+ const bind = bindsToCanonical(jws, input.signedCanonical);
167
+ if (!bind.ok) {
168
+ return { ok: false, verdict: notQualified(`signature does not bind the supplied canonical bytes: ${bind.reason}`, 'HASH_FAILURE', signatureRef, adesLevel) };
169
+ }
170
+ let leaf;
171
+ try {
172
+ leaf = new node_crypto_1.X509Certificate(Buffer.from(jws.header.x5c[0], 'base64'));
173
+ }
174
+ catch (e) {
175
+ return { ok: false, verdict: indeterminate(`leaf certificate (x5c[0]) is not a valid X.509: ${e.message}`, 'FORMAT_FAILURE', signatureRef, adesLevel) };
176
+ }
177
+ const sigOk = verifyJwsSignature(jws, leaf);
178
+ if (!sigOk.ok) {
179
+ return { ok: false, verdict: notQualified(`signature-value verification failed: ${sigOk.reason}`, 'SIG_CRYPTO_FAILURE', signatureRef, adesLevel) };
180
+ }
181
+ return { ok: true, x5cB64: jws.header.x5c, signingTimeMs, adesLevel };
182
+ }
183
+ /** Shared downstream: resolve the TL once, build+validate the RFC 5280 path to a TL anchor, QcStatements. */
184
+ async function anchorAndQualify(fe, input, signatureRef) {
185
+ const { x5cB64, signingTimeMs, adesLevel } = fe;
186
+ const leaf = new node_crypto_1.X509Certificate(Buffer.from(x5cB64[0], 'base64'));
187
+ // Resolve the Trusted List ONCE (single trust source; ruling 1). The same tslXml + single signingTimeMs
188
+ // feed path-validity, cert-validity, and TL-granted-at-time (ruling 3 — one reference instant).
189
+ const tslRes = await resolveTslXml(input.trust, leaf);
190
+ if (tslRes.indeterminate) {
191
+ return indeterminate(`Trusted List unavailable: ${tslRes.reason}`, 'CERTIFICATE_CHAIN_GENERAL_FAILURE', signatureRef, adesLevel);
192
+ }
193
+ const path = buildAndAnchorPath({ x5cB64, hintsDer: input.trust.intermediateHintsDer ?? [], tslXml: tslRes.xml, signingTimeMs });
194
+ if (!path.ok) {
195
+ return notQualified(`certificate path: ${path.reason}`, path.subIndication, signatureRef, adesLevel);
196
+ }
197
+ const qc = extractQcStatements(leaf);
198
+ if (!qc.qcCompliance || !qc.qcTypeEsign) {
199
+ const miss = [!qc.qcCompliance && 'QcCompliance', !qc.qcTypeEsign && 'QcType-eSign'].filter(Boolean).join(' + ');
200
+ return notQualified(`signing certificate lacks required QcStatements (${miss}) — not a qualified e-signature certificate`, 'CHAIN_CONSTRAINTS_FAILURE', signatureRef, adesLevel);
201
+ }
202
+ return {
203
+ qualified: true,
204
+ status: 'qualified',
205
+ reason: `valid QES (${adesLevel}): signature verified, path validated (RFC 5280, depth ${path.depth}) to TL-granted CA/QC-for-eSignatures "${path.anchorServiceName}", QcStatements present (QcCompliance${qc.qcSscd ? ' + QcSSCD' : ''} + QcType-eSign) at signing time`,
206
+ signatureRef,
207
+ signerIdentity: extractSignerIdentity(leaf),
208
+ indication: 'TOTAL-PASSED',
209
+ subIndication: null,
210
+ adesLevel,
211
+ reportRef: `offline-js:${signatureRef?.slice(0, 16)}`,
212
+ backend: 'offline-js',
213
+ anchorFingerprint: path.anchorCertDer ? sha256Hex(Buffer.from(path.anchorCertDer, 'base64'), 'buffer') : null,
214
+ };
215
+ }
216
+ function b64urlToBuf(s) {
217
+ return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
218
+ }
219
+ /** Parse JAdES compact serialization: `BASE64URL(protected).BASE64URL(payload).BASE64URL(signature)`. */
220
+ function parseCompactJades(blob) {
221
+ // The blob may itself be base64 (the producer returns DSS `bytes` base64) wrapping the compact JWS.
222
+ let compact = blob.trim();
223
+ if (!compact.includes('.')) {
224
+ const decoded = Buffer.from(compact, 'base64').toString('utf8');
225
+ if (decoded.includes('.'))
226
+ compact = decoded.trim();
227
+ }
228
+ const parts = compact.split('.');
229
+ if (parts.length !== 3)
230
+ throw new Error(`expected 3 JWS compact segments, got ${parts.length}`);
231
+ const [protectedB64, payloadB64, sigB64] = parts;
232
+ let header;
233
+ try {
234
+ header = JSON.parse(b64urlToBuf(protectedB64).toString('utf8'));
235
+ }
236
+ catch (e) {
237
+ throw new Error(`protected header is not JSON: ${e.message}`);
238
+ }
239
+ if (typeof header.alg !== 'string')
240
+ throw new Error('protected header missing "alg"');
241
+ return { protectedB64, payloadB64, signature: b64urlToBuf(sigB64), header };
242
+ }
243
+ /** Detached JAdES binds via sigD (ObjectIdByURIHash): one hashV must equal BASE64URL(sha256(canonical)). */
244
+ function bindsToCanonical(jws, canonical) {
245
+ const want = (0, node_crypto_1.createHash)('sha256').update(canonical, 'utf8').digest('base64url');
246
+ const sigD = jws.header.sigD;
247
+ if (sigD && Array.isArray(sigD.hashV) && sigD.hashV.length > 0) {
248
+ const hit = sigD.hashV.some((h) => normalizeB64url(h) === want);
249
+ return hit ? { ok: true } : { ok: false, reason: 'no sigD hashV matches sha256(canonical)' };
250
+ }
251
+ // Attached: the payload itself must be the canonical bytes.
252
+ if (jws.payloadB64) {
253
+ const payload = b64urlToBuf(jws.payloadB64).toString('utf8');
254
+ return payload === canonical ? { ok: true } : { ok: false, reason: 'attached payload != canonical bytes' };
255
+ }
256
+ return { ok: false, reason: 'detached signature carries no sigD hashV and no attached payload to bind' };
257
+ }
258
+ function normalizeB64url(s) {
259
+ return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
260
+ }
261
+ // ── CAdES front-end: detached CMS/PKCS#7 (.p7m) → the same { x5c, signingTime } the JAdES parser yields ──
262
+ // CAdES is what most EU QTSPs + a notary's clé REAL emit. This is a FRONT-END only: it extracts the signer
263
+ // cert chain, verifies the signature over the DER-encoded SignedAttributes, and BINDS via the messageDigest
264
+ // attribute (= SHA-256 of the content, which MUST equal SHA-256(grant canonical)). Everything downstream
265
+ // (path → TL anchor → QcStatements) is the shared, format-agnostic pipeline.
266
+ const OID_SIGNED_DATA = '1.2.840.113549.1.7.2';
267
+ const OID_MESSAGE_DIGEST = '1.2.840.113549.1.9.4';
268
+ const OID_SIGNING_TIME = '1.2.840.113549.1.9.5';
269
+ const CMS_SIG_ALG = {
270
+ '1.2.840.10045.4.3.2': { hash: 'sha256', type: 'ec' },
271
+ '1.2.840.10045.4.3.3': { hash: 'sha384', type: 'ec' },
272
+ '1.2.840.10045.4.3.4': { hash: 'sha512', type: 'ec' },
273
+ '1.2.840.113549.1.1.11': { hash: 'sha256', type: 'rsa' },
274
+ '1.2.840.113549.1.1.12': { hash: 'sha384', type: 'rsa' },
275
+ '1.2.840.113549.1.1.13': { hash: 'sha512', type: 'rsa' },
276
+ '1.2.840.113549.1.1.10': { hash: 'sha256', type: 'rsa-pss' },
277
+ };
278
+ // CMS SignerInfo digestAlgorithm (the algorithm the messageDigest attribute was computed with). The QTSP
279
+ // chooses this (SHA-256/384/512 are all eIDAS-valid) — the bind MUST hash the canonical with THIS algorithm,
280
+ // never a hardcoded SHA-256. (SHA-1 deliberately absent → unsupported → reject.)
281
+ const CMS_DIGEST_ALG = {
282
+ '2.16.840.1.101.3.4.2.1': 'sha256',
283
+ '2.16.840.1.101.3.4.2.2': 'sha384',
284
+ '2.16.840.1.101.3.4.2.3': 'sha512',
285
+ };
286
+ function looksLikeSignedData(der) {
287
+ try {
288
+ const ci = readTLV(der, 0);
289
+ const first = children(der, ci.vStart, ci.vEnd)[0];
290
+ return first.tag === 0x06 && decodeOid(der, first.vStart, first.vEnd) === OID_SIGNED_DATA;
291
+ }
292
+ catch {
293
+ return false;
294
+ }
295
+ }
296
+ function parseAsn1Time(der, node) {
297
+ const s = der.subarray(node.vStart, node.vEnd).toString('ascii');
298
+ if (node.tag === 0x17) {
299
+ const m = /^(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(s);
300
+ if (!m)
301
+ return NaN;
302
+ const yy = +m[1];
303
+ return Date.UTC(yy < 50 ? 2000 + yy : 1900 + yy, +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
304
+ }
305
+ if (node.tag === 0x18) {
306
+ const m = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(s);
307
+ if (!m)
308
+ return NaN;
309
+ return Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
310
+ }
311
+ return NaN;
312
+ }
313
+ function cadesFrontEnd(der, input, signatureRef) {
314
+ const fail = (reason, sub, status = 'not_qualified') => ({
315
+ ok: false,
316
+ verdict: status === 'indeterminate' ? indeterminate(reason, sub, signatureRef, 'CAdES-BES(approx)') : notQualified(reason, sub, signatureRef, 'CAdES-BES(approx)'),
317
+ });
318
+ try {
319
+ const ci = readTLV(der, 0);
320
+ const ciCh = children(der, ci.vStart, ci.vEnd);
321
+ const content0 = children(der, ciCh[1].vStart, ciCh[1].vEnd)[0]; // [0] EXPLICIT → SignedData
322
+ const sdCh = children(der, content0.vStart, content0.vEnd);
323
+ // encapContentInfo (first SEQUENCE in SignedData): ENVELOPED CMS embeds eContent [0]; DETACHED omits it.
324
+ let embeddedContent = null;
325
+ const encap = sdCh.find((t) => t.tag === 0x30);
326
+ if (encap) {
327
+ const eContent = children(der, encap.vStart, encap.vEnd).find((t) => t.tag === 0xa0); // [0] EXPLICIT
328
+ if (eContent) {
329
+ const oct = children(der, eContent.vStart, eContent.vEnd)[0]; // OCTET STRING (primitive; BER-chunked = open-set)
330
+ if (oct && oct.tag === 0x04)
331
+ embeddedContent = Buffer.from(der.subarray(oct.vStart, oct.vEnd));
332
+ }
333
+ }
334
+ const certsNode = sdCh.find((t) => t.tag === 0xa0); // certificates [0] IMPLICIT
335
+ if (!certsNode)
336
+ return fail('CMS carries no certificates', 'NO_SIGNING_CERTIFICATE_FOUND', 'indeterminate');
337
+ const certs = children(der, certsNode.vStart, certsNode.vEnd)
338
+ .filter((t) => t.tag === 0x30)
339
+ .map((t) => new node_crypto_1.X509Certificate(der.subarray(t.hStart, t.vEnd)));
340
+ const siSet = [...sdCh].reverse().find((t) => t.tag === 0x31); // signerInfos is the last SET
341
+ if (!siSet)
342
+ return fail('CMS has no signerInfos', 'FORMAT_FAILURE', 'indeterminate');
343
+ const si = children(der, siSet.vStart, siSet.vEnd).find((t) => t.tag === 0x30);
344
+ if (!si)
345
+ return fail('CMS signerInfos empty', 'FORMAT_FAILURE', 'indeterminate');
346
+ const siCh = children(der, si.vStart, si.vEnd);
347
+ const saIdx = siCh.findIndex((t) => t.tag === 0xa0); // signedAttrs [0] IMPLICIT
348
+ if (saIdx < 0)
349
+ return fail('CMS SignerInfo has no signed attributes (CAdES messageDigest binding required)', 'FORMAT_FAILURE', 'indeterminate');
350
+ const signedAttrs = siCh[saIdx];
351
+ const sigAlgNode = siCh.slice(saIdx + 1).find((t) => t.tag === 0x30);
352
+ const sigNode = siCh.slice(saIdx + 1).find((t) => t.tag === 0x04);
353
+ if (!sigAlgNode || !sigNode)
354
+ return fail('CMS SignerInfo missing signatureAlgorithm or signature', 'FORMAT_FAILURE', 'indeterminate');
355
+ const sigAlgOid = decodeOid(der, children(der, sigAlgNode.vStart, sigAlgNode.vEnd)[0].vStart, children(der, sigAlgNode.vStart, sigAlgNode.vEnd)[0].vEnd);
356
+ const alg = CMS_SIG_ALG[sigAlgOid];
357
+ if (!alg)
358
+ return fail(`unsupported CMS signature algorithm ${sigAlgOid}`, 'FORMAT_FAILURE', 'indeterminate');
359
+ const signature = der.subarray(sigNode.vStart, sigNode.vEnd);
360
+ // signed attribute lookup (SET OF Attribute; each = SEQUENCE { OID, SET attrValues })
361
+ const attrs = children(der, signedAttrs.vStart, signedAttrs.vEnd);
362
+ const attrVal = (oid) => {
363
+ for (const a of attrs) {
364
+ const ac = children(der, a.vStart, a.vEnd);
365
+ if (ac[0]?.tag === 0x06 && decodeOid(der, ac[0].vStart, ac[0].vEnd) === oid && ac[1]) {
366
+ return children(der, ac[1].vStart, ac[1].vEnd)[0] ?? null;
367
+ }
368
+ }
369
+ return null;
370
+ };
371
+ // THE BINDING TRAP — check (1) "the signature binds to THESE bytes": messageDigest == H_cms(canonical),
372
+ // where H_cms is the CMS-DECLARED digestAlgorithm (NOT a hardcoded SHA-256 — the QTSP may use
373
+ // SHA-256/384/512, all eIDAS-valid). check (2) "those bytes are the grant's canonical" is the caller's
374
+ // job: `input.signedCanonical` IS canonicalJsonStringify(grant), identified upstream. A qualified
375
+ // signature over the wrong bytes (some uploaded doc) must NOT validate as a bound Profile-C signature.
376
+ const digestAlgNode = siCh.find((t, i) => i >= 2 && i < saIdx && t.tag === 0x30); // version, sid, digestAlgorithm, …
377
+ const digestOid = digestAlgNode ? decodeOid(der, children(der, digestAlgNode.vStart, digestAlgNode.vEnd)[0].vStart, children(der, digestAlgNode.vStart, digestAlgNode.vEnd)[0].vEnd) : null;
378
+ const hCms = digestOid ? CMS_DIGEST_ALG[digestOid] : null;
379
+ if (!hCms)
380
+ return fail(`unsupported or missing CMS digestAlgorithm ${digestOid ?? '(none)'} (expected SHA-256/384/512)`, 'FORMAT_FAILURE', 'indeterminate');
381
+ const mdNode = attrVal(OID_MESSAGE_DIGEST);
382
+ if (!mdNode)
383
+ return fail('CMS has no messageDigest signed attribute (cannot bind to the grant)', 'HASH_FAILURE');
384
+ const messageDigest = der.subarray(mdNode.vStart, mdNode.vEnd);
385
+ const canonicalBuf = Buffer.from(input.signedCanonical, 'utf8');
386
+ // The bytes the signature is over: the EMBEDDED content (enveloped) or the supplied canonical (detached).
387
+ const signedBytes = embeddedContent ?? canonicalBuf;
388
+ const wantDigest = (0, node_crypto_1.createHash)(hCms).update(signedBytes).digest(); // (1) H_cms(signed content) — algorithm-agnostic
389
+ if (!messageDigest.equals(wantDigest)) {
390
+ return fail(`CMS messageDigest does not equal ${hCms.toUpperCase()}(signed content) — the signature is not over these bytes`, 'HASH_FAILURE');
391
+ }
392
+ // (2) those bytes ARE the grant canonical. Detached: signedBytes === canonical by construction. Enveloped:
393
+ // the embedded content MUST equal the grant canonical, else a qualified sig over a DIFFERENT document slips in.
394
+ if (embeddedContent && !embeddedContent.equals(canonicalBuf)) {
395
+ return fail('enveloped CMS embedded content does not equal the grant canonical bytes — signature is over a different document', 'HASH_FAILURE');
396
+ }
397
+ // signer cert: the issuerAndSerialNumber sid → match the embedded cert by serial; else the non-CA leaf.
398
+ let leaf = certs.find((c) => c.ca === false) ?? certs[0];
399
+ const sid = siCh[1];
400
+ if (sid?.tag === 0x30) {
401
+ const serialNode = children(der, sid.vStart, sid.vEnd).find((t) => t.tag === 0x02);
402
+ if (serialNode) {
403
+ const serial = der.subarray(serialNode.vStart, serialNode.vEnd).toString('hex').toUpperCase().replace(/^0+/, '');
404
+ const m = certs.find((c) => c.serialNumber.toUpperCase().replace(/^0+/, '') === serial);
405
+ if (m)
406
+ leaf = m;
407
+ }
408
+ }
409
+ // signature-value verification over the DER-encoded SignedAttributes RE-TAGGED as SET (RFC 5652 §5.4):
410
+ // CAdES signs SET OF Attribute, but it travels [0] IMPLICIT — swap the tag byte 0xA0 → 0x31 to verify.
411
+ const saDer = Buffer.from(der.subarray(signedAttrs.hStart, signedAttrs.vEnd));
412
+ saDer[0] = 0x31;
413
+ const verifyKey = alg.type === 'ec'
414
+ ? { key: leaf.publicKey, dsaEncoding: 'der' }
415
+ : alg.type === 'rsa-pss'
416
+ ? { key: leaf.publicKey, padding: 6, saltLength: 32 }
417
+ : leaf.publicKey;
418
+ let sigOk = false;
419
+ try {
420
+ sigOk = (0, node_crypto_1.verify)(alg.hash, saDer, verifyKey, signature);
421
+ }
422
+ catch {
423
+ sigOk = false;
424
+ }
425
+ if (!sigOk)
426
+ return fail('CMS signature does not verify over the signed attributes', 'SIG_CRYPTO_FAILURE');
427
+ const stNode = attrVal(OID_SIGNING_TIME);
428
+ const signingTimeMs = input.validationTime
429
+ ? Date.parse(input.validationTime)
430
+ : stNode
431
+ ? parseAsn1Time(der, stNode)
432
+ : Date.now();
433
+ // leaf first, then the rest of the bundle (untrusted path-building hints for the shared walker).
434
+ const x5cB64 = [leaf.raw.toString('base64'), ...certs.filter((c) => c !== leaf).map((c) => c.raw.toString('base64'))];
435
+ return { ok: true, x5cB64, signingTimeMs: Number.isFinite(signingTimeMs) ? signingTimeMs : Date.now(), adesLevel: 'CAdES-BES(approx)' };
436
+ }
437
+ catch (e) {
438
+ return fail(`CAdES parse failed: ${e.message}`, 'FORMAT_FAILURE', 'indeterminate');
439
+ }
440
+ }
441
+ // ── (2) signature-value verification (ETSI TS 119 102-1) ────────────────────────────────────────────
442
+ function verifyJwsSignature(jws, leaf) {
443
+ const signingInput = Buffer.from(`${jws.protectedB64}.${jws.payloadB64}`, 'ascii');
444
+ const pub = leaf.publicKey;
445
+ const alg = jws.header.alg;
446
+ try {
447
+ if (/^ES\d{3}$/.test(alg)) {
448
+ const hash = { ES256: 'sha256', ES384: 'sha384', ES512: 'sha512' }[alg];
449
+ if (!hash)
450
+ return { ok: false, reason: `unsupported ECDSA alg ${alg}` };
451
+ const ok = (0, node_crypto_1.verify)(hash, signingInput, { key: pub, dsaEncoding: 'ieee-p1363' }, jws.signature);
452
+ return ok ? { ok: true } : { ok: false, reason: `${alg} signature invalid` };
453
+ }
454
+ if (/^RS\d{3}$/.test(alg)) {
455
+ const hash = { RS256: 'sha256', RS384: 'sha384', RS512: 'sha512' }[alg];
456
+ if (!hash)
457
+ return { ok: false, reason: `unsupported RSA alg ${alg}` };
458
+ const ok = (0, node_crypto_1.verify)(hash, signingInput, pub, jws.signature);
459
+ return ok ? { ok: true } : { ok: false, reason: `${alg} signature invalid` };
460
+ }
461
+ if (/^PS\d{3}$/.test(alg)) {
462
+ const hash = { PS256: 'sha256', PS384: 'sha384', PS512: 'sha512' }[alg];
463
+ if (!hash)
464
+ return { ok: false, reason: `unsupported RSA-PSS alg ${alg}` };
465
+ const ok = (0, node_crypto_1.verify)(hash, signingInput, { key: pub, padding: 6 /* RSA_PKCS1_PSS_PADDING */, saltLength: 32 }, jws.signature);
466
+ return ok ? { ok: true } : { ok: false, reason: `${alg} signature invalid` };
467
+ }
468
+ return { ok: false, reason: `unsupported alg "${alg}" (QES expects ES*/RS*/PS*)` };
469
+ }
470
+ catch (e) {
471
+ return { ok: false, reason: `verify error: ${e.message}` };
472
+ }
473
+ }
474
+ /** Validity of a cert at the (single) reference time. */
475
+ function validAt(cert, ms) {
476
+ const vfrom = Date.parse(cert.validFrom);
477
+ const vto = Date.parse(cert.validTo);
478
+ if (Number.isFinite(vfrom) && ms < vfrom)
479
+ return { ok: false, reason: `not yet valid at signing time (notBefore ${new Date(vfrom).toISOString()})`, sub: 'NOT_YET_VALID' };
480
+ if (Number.isFinite(vto) && ms > vto)
481
+ return { ok: false, reason: `expired at signing time (notAfter ${new Date(vto).toISOString()})`, sub: 'EXPIRED' };
482
+ return { ok: true };
483
+ }
484
+ /** RFC 5280 §4.2.1.9 basicConstraints (node exposes `ca` but NOT pathLenConstraint → DER-parse). */
485
+ function parseBasicConstraints(certDer) {
486
+ const ext = findExtension(certDer, OID_BASIC_CONSTRAINTS);
487
+ if (!ext)
488
+ return { cA: false, pathLen: null };
489
+ const seq = readTLV(ext, 0);
490
+ let cA = false;
491
+ let pathLen = null;
492
+ for (const t of children(ext, seq.vStart, seq.vEnd)) {
493
+ if (t.tag === 0x01)
494
+ cA = ext[t.vStart] !== 0x00; // BOOLEAN
495
+ else if (t.tag === 0x02) {
496
+ let v = 0;
497
+ for (let i = t.vStart; i < t.vEnd; i++)
498
+ v = (v << 8) | ext[i]; // INTEGER pathLenConstraint
499
+ pathLen = v;
500
+ }
501
+ }
502
+ return { cA, pathLen };
503
+ }
504
+ /** RFC 5280 §4.2.1.3 keyUsage (node `keyUsage` is undefined here → DER-parse the BIT STRING; keyCertSign = bit 5). */
505
+ function parseKeyUsage(certDer) {
506
+ const ext = findExtension(certDer, OID_KEY_USAGE);
507
+ if (!ext)
508
+ return { present: false, keyCertSign: false };
509
+ const bs = readTLV(ext, 0); // BIT STRING: [unusedBits][bit bytes…]
510
+ const firstBitByte = ext[bs.vStart + 1] ?? 0;
511
+ return { present: true, keyCertSign: (firstBitByte & 0x04) !== 0 }; // bit 5 from MSB
512
+ }
513
+ function buildAndAnchorPath(args) {
514
+ const fail = (reason, sub, broken = false, notAnchored = false) => ({ ok: false, broken, notAnchored, reason, subIndication: sub, depth: 0, anchorCertDer: '', anchorServiceName: null });
515
+ const parse = (b) => {
516
+ try {
517
+ return new node_crypto_1.X509Certificate(Buffer.from(b, 'base64'));
518
+ }
519
+ catch {
520
+ return null;
521
+ }
522
+ };
523
+ const x5c = args.x5cB64.map(parse);
524
+ if (x5c.some((c) => c == null))
525
+ return fail('an x5c entry is not a valid certificate', 'FORMAT_FAILURE', true);
526
+ // x5c + hints are UNTRUSTED path-building material (never a trust source).
527
+ const pool = [...x5c, ...args.hintsDer.map(parse).filter((c) => c != null)];
528
+ const leaf = x5c[0];
529
+ const lv = validAt(leaf, args.signingTimeMs);
530
+ if (!lv.ok)
531
+ return fail(`signing certificate ${lv.reason}`, lv.sub, true);
532
+ let current = leaf;
533
+ let caBelow = 0; // CA certs already in the path below the issuer under test (pathLenConstraint accounting)
534
+ let lastMatchReason = ''; // most-informative resolver reason (e.g. "matched only a TSA/QTST service")
535
+ const seen = new Set([fp256(leaf)]);
536
+ for (let depth = 0; depth < MAX_PATH_DEPTH; depth++) {
537
+ // Anchor test (ruling 2): is THIS cert a granted CA/QC-for-eSig service on the TL at signing time?
538
+ const m = matchGrantedCaQc(args.tslXml, fp256(current), args.signingTimeMs);
539
+ if (m.matched) {
540
+ return { ok: true, broken: false, notAnchored: false, reason: `anchored at TL-granted CA/QC service "${m.serviceName ?? ''}"`, subIndication: null, depth, anchorCertDer: current.raw.toString('base64'), anchorServiceName: m.serviceName ?? null };
541
+ }
542
+ if (m.reason)
543
+ lastMatchReason = m.reason;
544
+ // Find the issuer among the untrusted pool (name + key-id chaining).
545
+ const issuer = pool.find((c) => fp256(c) !== fp256(current) && safeCheckIssued(current, c));
546
+ if (!issuer) {
547
+ // Two distinct operator stories (do not collapse):
548
+ // • self-issued/top cert reached but NOT on the TL → ANCHOR_NOT_ON_TRUSTED_LIST (this isn't qualified).
549
+ // • a non-self-issued cert whose issuer is absent from x5c/hints → CHAIN_INCOMPLETE (fix the producer's
550
+ // x5c). The validator does NOT fetch missing issuers (no AIA chasing — offline/trustless by design).
551
+ if (current.subject === current.issuer) {
552
+ return fail(`path reached a self-issued top certificate that is NOT a granted CA/QC-for-eSignatures service on the EU Trusted List — the x5c top is NOT a trust anchor (${lastMatchReason || 'not on the TL'})`, 'ANCHOR_NOT_ON_TRUSTED_LIST', false, true);
553
+ }
554
+ return fail(`certificate path is incomplete: the issuer of "${current.subject}" is not present in x5c/intermediateHintsDer and the validator does not fetch it (no AIA chasing) — supply the full chain leaf→TL-listed CA`, 'CHAIN_INCOMPLETE', false, true);
555
+ }
556
+ if (seen.has(fp256(issuer)))
557
+ return fail('certificate path loops', 'CHAIN_CONSTRAINTS_FAILURE', true);
558
+ // ── RFC 5280 §6 link validation: current ← issuer ──
559
+ if (!safeVerify(current, issuer))
560
+ return fail('a certificate signature does not verify under its issuer public key', 'SIG_CRYPTO_FAILURE', true);
561
+ const iv = validAt(issuer, args.signingTimeMs);
562
+ if (!iv.ok)
563
+ return fail(`a CA certificate ${iv.reason}`, iv.sub, true);
564
+ const bc = parseBasicConstraints(issuer.raw);
565
+ if (!bc.cA)
566
+ return fail('a path certificate used as a CA has basicConstraints cA=FALSE', 'CHAIN_CONSTRAINTS_FAILURE', true);
567
+ if (bc.pathLen != null && bc.pathLen < caBelow)
568
+ return fail(`pathLenConstraint violated (CA pathLen=${bc.pathLen} < ${caBelow} intermediate CA(s) below it)`, 'CHAIN_CONSTRAINTS_FAILURE', true);
569
+ const ku = parseKeyUsage(issuer.raw);
570
+ if (ku.present && !ku.keyCertSign)
571
+ return fail('a path CA certificate lacks keyUsage keyCertSign', 'CHAIN_CONSTRAINTS_FAILURE', true);
572
+ seen.add(fp256(issuer));
573
+ caBelow++;
574
+ current = issuer;
575
+ }
576
+ return fail(`certificate path exceeds maximum depth ${MAX_PATH_DEPTH}`, 'CHAIN_CONSTRAINTS_FAILURE', true);
577
+ }
578
+ function safeCheckIssued(subject, issuer) {
579
+ try {
580
+ return subject.checkIssued(issuer);
581
+ }
582
+ catch {
583
+ return false;
584
+ }
585
+ }
586
+ function safeVerify(subject, issuer) {
587
+ try {
588
+ return subject.verify(issuer.publicKey);
589
+ }
590
+ catch {
591
+ return false;
592
+ }
593
+ }
594
+ function fp256(c) {
595
+ return c.fingerprint256.replace(/:/g, '').toLowerCase();
596
+ }
597
+ function extractQcStatements(cert) {
598
+ const out = { qcCompliance: false, qcSscd: false, qcTypeEsign: false };
599
+ const der = findExtension(cert.raw, OID_QC_STATEMENTS);
600
+ if (!der)
601
+ return out;
602
+ // `der` = the extnValue OCTET STRING content = DER SEQUENCE OF QCStatement.
603
+ const seq = readTLV(der, 0);
604
+ for (const st of children(der, seq.vStart, seq.vEnd)) {
605
+ // each QCStatement = SEQUENCE { statementId OID, statementInfo OPTIONAL }
606
+ const stCh = children(der, st.vStart, st.vEnd);
607
+ const idNode = stCh[0];
608
+ if (!idNode || idNode.tag !== 0x06)
609
+ continue;
610
+ const oid = decodeOid(der, idNode.vStart, idNode.vEnd);
611
+ if (oid === OID_QC_COMPLIANCE)
612
+ out.qcCompliance = true;
613
+ else if (oid === OID_QC_SSCD)
614
+ out.qcSscd = true;
615
+ else if (oid === OID_QC_TYPE) {
616
+ const info = stCh[1]; // SEQUENCE OF QcType (OID)
617
+ if (info && info.tag === 0x30) {
618
+ for (const t of children(der, info.vStart, info.vEnd)) {
619
+ if (t.tag === 0x06 && decodeOid(der, t.vStart, t.vEnd) === OID_QC_TYPE_ESIGN)
620
+ out.qcTypeEsign = true;
621
+ }
622
+ }
623
+ }
624
+ }
625
+ return out;
626
+ }
627
+ /** Walk a Certificate DER to the extensions and return the raw extnValue OCTET-STRING content for `oid`. */
628
+ function findExtension(certDer, oid) {
629
+ const cert = readTLV(certDer, 0); // Certificate SEQUENCE
630
+ const tbs = children(certDer, cert.vStart, cert.vEnd)[0]; // TBSCertificate SEQUENCE
631
+ const tbsCh = children(certDer, tbs.vStart, tbs.vEnd);
632
+ const extsCtx = tbsCh.find((t) => t.tag === 0xa3); // extensions [3] EXPLICIT
633
+ if (!extsCtx)
634
+ return null;
635
+ const extsSeq = children(certDer, extsCtx.vStart, extsCtx.vEnd)[0]; // SEQUENCE OF Extension
636
+ for (const ext of children(certDer, extsSeq.vStart, extsSeq.vEnd)) {
637
+ const ec = children(certDer, ext.vStart, ext.vEnd);
638
+ const idNode = ec[0];
639
+ if (!idNode || idNode.tag !== 0x06)
640
+ continue;
641
+ if (decodeOid(certDer, idNode.vStart, idNode.vEnd) !== oid)
642
+ continue;
643
+ const octet = ec.find((t, i) => i > 0 && t.tag === 0x04); // extnValue OCTET STRING (after optional critical)
644
+ if (!octet)
645
+ return null;
646
+ return certDer.subarray(octet.vStart, octet.vEnd);
647
+ }
648
+ return null;
649
+ }
650
+ function matchGrantedCaQc(tslXml, issuerCaFingerprintHex, signingTimeMs) {
651
+ const want = (issuerCaFingerprintHex || '').replace(/:/g, '').toLowerCase();
652
+ const serviceRe = /<(?:[a-z0-9]+:)?TSPService>([\s\S]*?)<\/(?:[a-z0-9]+:)?TSPService>/gi;
653
+ let m;
654
+ let sawTsaForThisCa = false;
655
+ while ((m = serviceRe.exec(tslXml))) {
656
+ const s = m[1];
657
+ const svcType = pick(s, 'ServiceTypeIdentifier');
658
+ const isCaQc = svcType === SVCTYPE_CA_QC;
659
+ const isTsa = svcType === SVCTYPE_TSA_QTST;
660
+ if (!isCaQc && !isTsa)
661
+ continue;
662
+ if (!certMatches(s, want))
663
+ continue;
664
+ if (isTsa) {
665
+ sawTsaForThisCa = true; // recorded but NEVER accepted as a qualified-eSig hit (refinement 3)
666
+ continue;
667
+ }
668
+ // CA/QC: require granted, granted-at-signing-time, and ForeSignatures (eSignatures, not seals/web-auth).
669
+ if (pick(s, 'ServiceStatus') !== STATUS_GRANTED)
670
+ continue;
671
+ if (!s.includes(ASI_FOR_ESIGNATURES))
672
+ continue;
673
+ const startMs = parseIsoOrNaN(pick(s, 'StatusStartingTime'));
674
+ if (Number.isFinite(startMs) && startMs > signingTimeMs) {
675
+ return { matched: false, statusStartingTimeMs: startMs, reason: `CA/QC service granted only from ${new Date(startMs).toISOString()}, after signing time ${new Date(signingTimeMs).toISOString()}` };
676
+ }
677
+ return { matched: true, serviceName: pickName(s) ?? undefined, statusStartingTimeMs: startMs };
678
+ }
679
+ return {
680
+ matched: false,
681
+ reason: sawTsaForThisCa
682
+ ? 'issuer CA matched only a TSA/QTST (timestamping) service, not a CA/QC-for-eSignatures service — not qualified for e-signatures'
683
+ : 'no granted CA/QC-for-eSignatures service certificate matched the issuer CA at signing time',
684
+ };
685
+ }
686
+ // ── identity (minimum dataset, verbatim from the cert subject) ─────────────────────────────────────
687
+ function extractSignerIdentity(leaf) {
688
+ const dn = leaf.subject || '';
689
+ const given = subjectField(dn, 'GN') ?? subjectField(dn, 'givenName');
690
+ const family = subjectField(dn, 'SN') ?? subjectField(dn, 'surname');
691
+ const cn = subjectField(dn, 'CN') ?? '';
692
+ const cnParts = cn.split(/\s+/);
693
+ return {
694
+ given_name: given ?? cnParts[0] ?? '',
695
+ family_name: family ?? cnParts.slice(1).join(' '),
696
+ date_of_birth: null, // not in the test cert subject; extracted from a dedicated attribute when present
697
+ persistent_id: subjectField(dn, 'serialNumber'),
698
+ country: subjectField(dn, 'C'),
699
+ };
700
+ }
701
+ // ── trust-list resolution ───────────────────────────────────────────────────────────────────────────
702
+ async function resolveTslXml(trust, leaf) {
703
+ if (trust.tslXml)
704
+ return { xml: trust.tslXml };
705
+ if (!trust.fetchLive)
706
+ return { indeterminate: true, reason: 'no tslXml supplied and fetchLive not set' };
707
+ const country = subjectField(leaf.issuer || leaf.subject, 'C');
708
+ if (!country)
709
+ return { indeterminate: true, reason: 'cannot determine issuer country for member-state TSL lookup' };
710
+ const fetchImpl = trust.fetchImpl ?? fetch;
711
+ try {
712
+ const lotl = await (await fetchImpl(trust.lotlUrl ?? EU_LOTL_URL)).text();
713
+ const tslUrl = findTslPointer(lotl, country);
714
+ if (!tslUrl)
715
+ return { indeterminate: true, reason: `no TSL pointer for territory ${country} in the EU LOTL` };
716
+ const xml = await (await fetchImpl(tslUrl)).text();
717
+ return { xml };
718
+ }
719
+ catch (e) {
720
+ return { indeterminate: true, reason: `LOTL/TSL fetch failed: ${e.message}` };
721
+ }
722
+ }
723
+ // ── shared helpers ─────────────────────────────────────────────────────────────────────────────────
724
+ function extractSignatureBytes(signature) {
725
+ if (typeof signature === 'string')
726
+ return signature;
727
+ if (signature && typeof signature === 'object' && typeof signature.signature === 'string') {
728
+ return signature.signature;
729
+ }
730
+ return null;
731
+ }
732
+ function sha256Hex(s, mode) {
733
+ const h = (0, node_crypto_1.createHash)('sha256');
734
+ return mode === 'buffer' ? h.update(s).digest('hex') : h.update(s, 'utf8').digest('hex');
735
+ }
736
+ function readTLV(buf, off) {
737
+ const tag = buf[off];
738
+ let i = off + 1;
739
+ let len = buf[i++];
740
+ if (len & 0x80) {
741
+ const n = len & 0x7f;
742
+ len = 0;
743
+ for (let k = 0; k < n; k++)
744
+ len = (len << 8) | buf[i++];
745
+ }
746
+ return { tag, hStart: off, vStart: i, vEnd: i + len };
747
+ }
748
+ function children(buf, vStart, vEnd) {
749
+ const out = [];
750
+ let off = vStart;
751
+ while (off < vEnd) {
752
+ const t = readTLV(buf, off);
753
+ out.push(t);
754
+ off = t.vEnd;
755
+ }
756
+ return out;
757
+ }
758
+ function decodeOid(buf, vStart, vEnd) {
759
+ const bytes = buf.subarray(vStart, vEnd);
760
+ const first = bytes[0];
761
+ const parts = [Math.floor(first / 40), first % 40];
762
+ let val = 0;
763
+ for (let k = 1; k < bytes.length; k++) {
764
+ val = (val << 7) | (bytes[k] & 0x7f);
765
+ if (!(bytes[k] & 0x80)) {
766
+ parts.push(val);
767
+ val = 0;
768
+ }
769
+ }
770
+ return parts.join('.');
771
+ }
772
+ // TSL XML helpers (regex; zero-dep — a relying party MAY substitute a full XAdES/TSL-signature validator).
773
+ function pick(xml, tag) {
774
+ const m = new RegExp(`<(?:[a-z0-9]+:)?${tag}>([\\s\\S]*?)</(?:[a-z0-9]+:)?${tag}>`, 'i').exec(xml);
775
+ return m ? m[1].trim() : null;
776
+ }
777
+ function pickName(xml) {
778
+ const block = pick(xml, 'ServiceName');
779
+ if (!block)
780
+ return null;
781
+ const m = /<(?:[a-z0-9]+:)?Name[^>]*>([\s\S]*?)<\/(?:[a-z0-9]+:)?Name>/i.exec(block);
782
+ return m ? m[1].trim() : null;
783
+ }
784
+ function certMatches(serviceXml, wantFpHex) {
785
+ const re = /<(?:[a-z0-9]+:)?X509Certificate>([\s\S]*?)<\/(?:[a-z0-9]+:)?X509Certificate>/gi;
786
+ let m;
787
+ while ((m = re.exec(serviceXml))) {
788
+ const fp = (0, node_crypto_1.createHash)('sha256').update(Buffer.from(m[1].replace(/\s+/g, ''), 'base64')).digest('hex');
789
+ if (fp === wantFpHex)
790
+ return true;
791
+ }
792
+ return false;
793
+ }
794
+ function parseIsoOrNaN(s) {
795
+ if (!s)
796
+ return NaN;
797
+ const t = Date.parse(s);
798
+ return Number.isNaN(t) ? NaN : t;
799
+ }
800
+ function subjectField(dn, key) {
801
+ if (!dn)
802
+ return null;
803
+ const m = new RegExp(`(?:^|[,\\n])\\s*${key}=([^,\\n]+)`).exec(dn);
804
+ return m ? m[1].trim() : null;
805
+ }