@int-loop/core 0.1.0 → 0.3.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/dist/x509.js ADDED
@@ -0,0 +1,329 @@
1
+ // X.509 certificates, as far as checking a timestamp authority's signature needs:
2
+ // parsing, signature verification with WebCrypto, and building a chain from the
3
+ // TSA's signing certificate to a trusted root at the moment of the timestamp.
4
+ //
5
+ // Trust anchors are matched by name and public key, not by exact bytes, because TSAs
6
+ // send cross-signed copies of their roots alongside the chain.
7
+ //
8
+ // Not checked here: revocation (CRLs, OCSP). An evidence pack records the chain as the
9
+ // TSA presented it; see DECISIONS.md, 2026-11-09.
10
+ import { bytesEqual, concatBytes, toBase64Url } from "./hash.js";
11
+ import { children, expectTag, readBitStringBytes, readBoolean, readDer, readIntegerBytes, readOctetString, readOid, readSmallInt, readTime, seq, TAG, contextTag } from "./der.js";
12
+ export const OID = {
13
+ rsaEncryption: "1.2.840.113549.1.1.1",
14
+ sha256WithRSA: "1.2.840.113549.1.1.11",
15
+ sha384WithRSA: "1.2.840.113549.1.1.12",
16
+ sha512WithRSA: "1.2.840.113549.1.1.13",
17
+ ecPublicKey: "1.2.840.10045.2.1",
18
+ ecdsaWithSHA256: "1.2.840.10045.4.3.2",
19
+ ecdsaWithSHA384: "1.2.840.10045.4.3.3",
20
+ ecdsaWithSHA512: "1.2.840.10045.4.3.4",
21
+ p256: "1.2.840.10045.3.1.7",
22
+ p384: "1.3.132.0.34",
23
+ p521: "1.3.132.0.35",
24
+ sha1: "1.3.14.3.2.26",
25
+ sha256: "2.16.840.1.101.3.4.2.1",
26
+ sha384: "2.16.840.1.101.3.4.2.2",
27
+ sha512: "2.16.840.1.101.3.4.2.3",
28
+ commonName: "2.5.4.3",
29
+ organisation: "2.5.4.10",
30
+ subjectKeyIdentifier: "2.5.29.14",
31
+ keyUsage: "2.5.29.15",
32
+ subjectAltName: "2.5.29.17",
33
+ basicConstraints: "2.5.29.19",
34
+ crlDistributionPoints: "2.5.29.31",
35
+ certificatePolicies: "2.5.29.32",
36
+ authorityKeyIdentifier: "2.5.29.35",
37
+ extKeyUsage: "2.5.29.37",
38
+ authorityInfoAccess: "1.3.6.1.5.5.7.1.1",
39
+ kpTimeStamping: "1.3.6.1.5.5.7.3.8",
40
+ anyExtendedKeyUsage: "2.5.29.37.0",
41
+ };
42
+ export const HASH_BY_OID = {
43
+ [OID.sha1]: "SHA-1",
44
+ [OID.sha256]: "SHA-256",
45
+ [OID.sha384]: "SHA-384",
46
+ [OID.sha512]: "SHA-512",
47
+ };
48
+ /** Signature algorithms accepted, with the hash each implies. */
49
+ const SIGNATURE_ALGS = {
50
+ [OID.sha256WithRSA]: { kind: "rsa", hash: "SHA-256" },
51
+ [OID.sha384WithRSA]: { kind: "rsa", hash: "SHA-384" },
52
+ [OID.sha512WithRSA]: { kind: "rsa", hash: "SHA-512" },
53
+ [OID.ecdsaWithSHA256]: { kind: "ecdsa", hash: "SHA-256" },
54
+ [OID.ecdsaWithSHA384]: { kind: "ecdsa", hash: "SHA-384" },
55
+ [OID.ecdsaWithSHA512]: { kind: "ecdsa", hash: "SHA-512" },
56
+ };
57
+ const CURVES = {
58
+ [OID.p256]: { name: "P-256", size: 32 },
59
+ [OID.p384]: { name: "P-384", size: 48 },
60
+ [OID.p521]: { name: "P-521", size: 66 },
61
+ };
62
+ /** Extensions this code understands, or that do not bear on trusting a timestamp. Any other critical extension fails the certificate. */
63
+ const HANDLED_EXTENSIONS = new Set([
64
+ OID.subjectKeyIdentifier,
65
+ OID.keyUsage,
66
+ OID.subjectAltName,
67
+ OID.basicConstraints,
68
+ OID.crlDistributionPoints,
69
+ OID.certificatePolicies,
70
+ OID.authorityKeyIdentifier,
71
+ OID.extKeyUsage,
72
+ OID.authorityInfoAccess,
73
+ ]);
74
+ const KEY_USAGE_KEY_CERT_SIGN = 5;
75
+ export function parseCertificate(bytes) {
76
+ const top = readDer(bytes);
77
+ const [tbsEl, algEl, sigEl, ...extra] = seq(top, "certificate");
78
+ if (extra.length)
79
+ throw new Error("certificate has trailing elements");
80
+ const tbs = seq(tbsEl, "tbsCertificate");
81
+ let i = 0;
82
+ if (tbs[0]?.tag === contextTag(0)) {
83
+ const [v] = children(tbs[0]);
84
+ if (readIntegerBytes(v, "version")[0] !== 2)
85
+ throw new Error("only X.509 v3 certificates are supported");
86
+ i++;
87
+ }
88
+ else {
89
+ throw new Error("only X.509 v3 certificates are supported");
90
+ }
91
+ const serial = readIntegerBytes(tbs[i++], "serial number");
92
+ const innerAlg = readOid(seq(tbs[i++], "signature algorithm")[0]);
93
+ const issuer = expectTag(tbs[i++], TAG.SEQUENCE, "issuer").raw;
94
+ const [nb, na] = seq(tbs[i++], "validity");
95
+ const subjectEl = expectTag(tbs[i++], TAG.SEQUENCE, "subject");
96
+ const spki = expectTag(tbs[i++], TAG.SEQUENCE, "subjectPublicKeyInfo").raw;
97
+ const extensions = new Map();
98
+ for (; i < tbs.length; i++) {
99
+ const el = tbs[i];
100
+ if (el.tag === contextTag(1, false) || el.tag === contextTag(2, false))
101
+ continue; // unique identifiers
102
+ if (el.tag !== contextTag(3))
103
+ throw new Error("unexpected element in tbsCertificate");
104
+ for (const ext of seq(children(el)[0], "extensions")) {
105
+ const parts = seq(ext, "extension");
106
+ const oid = readOid(parts[0]);
107
+ if (parts.length !== 2 && parts.length !== 3)
108
+ throw new Error("malformed extension");
109
+ const critical = parts.length === 3 ? readBoolean(parts[1], "critical") : false;
110
+ if (extensions.has(oid))
111
+ throw new Error(`duplicate extension ${oid}`);
112
+ extensions.set(oid, { critical, value: readOctetString(parts[parts.length - 1], "extension value") });
113
+ }
114
+ }
115
+ const signatureAlg = readOid(seq(algEl, "signatureAlgorithm")[0]);
116
+ if (signatureAlg !== innerAlg)
117
+ throw new Error("the two signature algorithm fields disagree");
118
+ return {
119
+ raw: top.raw,
120
+ tbs: tbsEl.raw,
121
+ signatureAlg,
122
+ signature: readBitStringBytes(sigEl, "signature"),
123
+ serial,
124
+ issuer,
125
+ subject: subjectEl.raw,
126
+ name: readableName(subjectEl),
127
+ notBefore: readTime(nb, "notBefore"),
128
+ notAfter: readTime(na, "notAfter"),
129
+ spki,
130
+ extensions,
131
+ };
132
+ }
133
+ function readableName(name) {
134
+ const attrs = new Map();
135
+ for (const rdn of seq(name, "name")) {
136
+ for (const atv of seq(rdn, "relative distinguished name", TAG.SET)) {
137
+ const [type, value] = seq(atv, "attribute");
138
+ attrs.set(readOid(type), new TextDecoder().decode(value.content));
139
+ }
140
+ }
141
+ return attrs.get(OID.commonName) ?? attrs.get(OID.organisation) ?? "(unnamed)";
142
+ }
143
+ /** basicConstraints: whether this is a CA, and how many intermediate CAs may follow it (null: no limit). */
144
+ export function basicConstraints(cert) {
145
+ const bc = cert.extensions.get(OID.basicConstraints);
146
+ if (!bc)
147
+ return { ca: false, pathLen: null };
148
+ const parts = seq(readDer(bc.value), "basicConstraints");
149
+ let i = 0;
150
+ const ca = parts[0]?.tag === TAG.BOOLEAN ? readBoolean(parts[i++], "cA") : false;
151
+ const pathLen = parts[i] ? readSmallInt(parts[i++], "pathLenConstraint") : null;
152
+ if (i !== parts.length)
153
+ throw new Error("malformed basicConstraints");
154
+ return { ca, pathLen };
155
+ }
156
+ export function isCa(cert) {
157
+ return basicConstraints(cert).ca;
158
+ }
159
+ /** Whether the key usage extension allows `bit`. An absent extension permits every use. */
160
+ export function keyUsageAllows(cert, bit) {
161
+ const ku = cert.extensions.get(OID.keyUsage);
162
+ if (!ku)
163
+ return true;
164
+ const c = expectTag(readDer(ku.value), TAG.BIT_STRING, "keyUsage").content;
165
+ const byte = c[1 + Math.floor(bit / 8)] ?? 0;
166
+ return (byte & (0x80 >> bit % 8)) !== 0;
167
+ }
168
+ export function extendedKeyUsage(cert) {
169
+ const eku = cert.extensions.get(OID.extKeyUsage);
170
+ if (!eku)
171
+ return null;
172
+ return { critical: eku.critical, purposes: seq(readDer(eku.value), "extKeyUsage").map((p) => readOid(p)) };
173
+ }
174
+ export function subjectKeyIdentifier(cert) {
175
+ const ski = cert.extensions.get(OID.subjectKeyIdentifier);
176
+ return ski ? readOctetString(readDer(ski.value), "subjectKeyIdentifier") : null;
177
+ }
178
+ /**
179
+ * Verifies `signature` over `data` with the key in `spki`. `alg` is a signature algorithm OID,
180
+ * or rsaEncryption / ecPublicKey with the hash given separately (as CMS signer infos do).
181
+ */
182
+ export async function verifySignature(spki, alg, signature, data, hash) {
183
+ const known = SIGNATURE_ALGS[alg];
184
+ const kind = known?.kind ?? (alg === OID.rsaEncryption ? "rsa" : alg === OID.ecPublicKey ? "ecdsa" : null);
185
+ const useHash = known?.hash ?? hash;
186
+ if (!kind || !useHash)
187
+ throw new Error(`unsupported signature algorithm ${alg}`);
188
+ if (useHash === "SHA-1")
189
+ throw new Error("SHA-1 signatures are not accepted");
190
+ const [algId] = seq(readDer(spki), "subjectPublicKeyInfo");
191
+ const [keyAlg, params] = seq(algId, "public key algorithm");
192
+ const keyOid = readOid(keyAlg);
193
+ if (kind === "rsa") {
194
+ if (keyOid !== OID.rsaEncryption)
195
+ throw new Error("signature algorithm does not match the key type");
196
+ const key = await crypto.subtle.importKey("spki", spki, { name: "RSASSA-PKCS1-v1_5", hash: useHash }, false, ["verify"]);
197
+ if (key.algorithm.modulusLength < 2048)
198
+ throw new Error("RSA keys under 2048 bits are not accepted");
199
+ return crypto.subtle.verify({ name: "RSASSA-PKCS1-v1_5" }, key, new Uint8Array(signature), data);
200
+ }
201
+ if (keyOid !== OID.ecPublicKey)
202
+ throw new Error("signature algorithm does not match the key type");
203
+ const curve = CURVES[readOid(params, "curve")];
204
+ if (!curve)
205
+ throw new Error("unsupported elliptic curve");
206
+ const key = await crypto.subtle.importKey("spki", spki, { name: "ECDSA", namedCurve: curve.name }, false, ["verify"]);
207
+ return crypto.subtle.verify({ name: "ECDSA", hash: useHash }, key, ecdsaDerToRaw(signature, curve.size), data);
208
+ }
209
+ /** X.509 and CMS carry ECDSA signatures as DER SEQUENCE { r, s }; WebCrypto wants r || s. */
210
+ export function ecdsaDerToRaw(der, size) {
211
+ const [r, s, ...extra] = seq(readDer(der), "ECDSA signature");
212
+ if (extra.length)
213
+ throw new Error("ECDSA signature has trailing elements");
214
+ const fixed = (el) => {
215
+ let int = readIntegerBytes(el, "ECDSA signature value");
216
+ // r and s are positive; a negative encoding would let one signature be written two ways.
217
+ if ((int[0] & 0x80) !== 0)
218
+ throw new Error("negative ECDSA signature value");
219
+ while (int.length > size && int[0] === 0)
220
+ int = int.subarray(1);
221
+ if (int.length > size)
222
+ throw new Error("ECDSA signature value too long");
223
+ const out = new Uint8Array(size);
224
+ out.set(int, size - int.length);
225
+ return out;
226
+ };
227
+ return concatBytes(fixed(r), fixed(s));
228
+ }
229
+ const MAX_CHAIN = 6;
230
+ /** Every signature checked while building one chain. Hostile input could otherwise make the search exponential. */
231
+ const MAX_SIGNATURE_CHECKS = 24;
232
+ /**
233
+ * Builds a chain from `leaf` to one of `anchors`, through `intermediates`, with every
234
+ * certificate, the anchor included, valid at `at` (the timestamp's own time). The anchor is
235
+ * reached when a certificate's issuer has an anchor's name and the anchor's key verifies
236
+ * it, or when a certificate has an anchor's name and key itself (a cross-signed copy).
237
+ *
238
+ * Every issuer, the anchor included, must be a CA allowed to sign certificates, within its
239
+ * path length constraint, and either without an extended key usage or with one that allows
240
+ * timestamping. The search is bounded: at most MAX_CHAIN deep, each (name, key) once per
241
+ * path, and MAX_SIGNATURE_CHECKS signatures in all.
242
+ */
243
+ export async function buildChain(leaf, intermediates, anchors, at) {
244
+ let checks = 0;
245
+ const signedBy = async (cert, issuer) => {
246
+ if (++checks > MAX_SIGNATURE_CHECKS)
247
+ throw new ChainBudgetError();
248
+ try {
249
+ return await verifySignature(issuer.spki, cert.signatureAlg, cert.signature, cert.tbs);
250
+ }
251
+ catch {
252
+ return false;
253
+ }
254
+ };
255
+ const sameKey = (a, b) => bytesEqual(a.subject, b.subject) && bytesEqual(a.spki, b.spki);
256
+ // depth: the leaf is 0, its issuer 1, and so on. An issuer at depth d has d - 1 intermediate CAs below it.
257
+ const visit = async (cert, depth, path, onPath) => {
258
+ const problem = certificateProblem(cert, at, depth);
259
+ if (problem)
260
+ return { ok: false, reason: `${cert.name}: ${problem}` };
261
+ const copyOf = anchors.find((a) => sameKey(a, cert));
262
+ if (copyOf) {
263
+ const anchorProblem = certificateProblem(copyOf, at, depth);
264
+ return anchorProblem ? { ok: false, reason: `${copyOf.name}: ${anchorProblem}` } : { ok: true, path, anchor: copyOf };
265
+ }
266
+ for (const anchor of anchors) {
267
+ if (!bytesEqual(anchor.subject, cert.issuer) || !(await signedBy(cert, anchor)))
268
+ continue;
269
+ const anchorProblem = certificateProblem(anchor, at, depth + 1);
270
+ if (anchorProblem)
271
+ return { ok: false, reason: `${anchor.name}: ${anchorProblem}` };
272
+ return { ok: true, path: [...path, anchor.name], anchor };
273
+ }
274
+ if (depth + 1 >= MAX_CHAIN)
275
+ return { ok: false, reason: "certificate chain too long" };
276
+ let last = { ok: false, reason: `${cert.name}: issuer is not a trusted timestamp root and no issuing certificate was supplied` };
277
+ for (const parent of intermediates) {
278
+ if (onPath.some((c) => sameKey(c, parent)) || !bytesEqual(parent.subject, cert.issuer))
279
+ continue;
280
+ if (!(await signedBy(cert, parent))) {
281
+ last = { ok: false, reason: `${cert.name}: signature does not verify against ${parent.name}` };
282
+ continue;
283
+ }
284
+ const result = await visit(parent, depth + 1, [...path, parent.name], [...onPath, parent]);
285
+ if (result.ok)
286
+ return result;
287
+ last = result;
288
+ }
289
+ return last;
290
+ };
291
+ try {
292
+ return await visit(leaf, 0, [leaf.name], [leaf]);
293
+ }
294
+ catch (err) {
295
+ if (err instanceof ChainBudgetError)
296
+ return { ok: false, reason: "too many candidate certificates to check" };
297
+ throw err;
298
+ }
299
+ }
300
+ class ChainBudgetError extends Error {
301
+ }
302
+ /**
303
+ * Why `cert` cannot be at `depth` in a chain at time `at`, or null. Depth 0 is the leaf, whose
304
+ * purpose the caller checks; anything above it issues certificates.
305
+ */
306
+ function certificateProblem(cert, at, depth) {
307
+ if (at < cert.notBefore || at > cert.notAfter)
308
+ return `not valid at ${new Date(at).toISOString()}`;
309
+ for (const [oid, ext] of cert.extensions)
310
+ if (ext.critical && !HANDLED_EXTENSIONS.has(oid))
311
+ return `unsupported critical extension ${oid}`;
312
+ if (depth === 0)
313
+ return null;
314
+ const bc = basicConstraints(cert);
315
+ if (!bc.ca)
316
+ return "is not a certificate authority";
317
+ if (!keyUsageAllows(cert, KEY_USAGE_KEY_CERT_SIGN))
318
+ return "is not allowed to sign certificates";
319
+ if (bc.pathLen !== null && depth - 1 > bc.pathLen)
320
+ return `its path length constraint (${bc.pathLen}) does not allow this chain`;
321
+ const eku = extendedKeyUsage(cert);
322
+ if (eku && !eku.purposes.includes(OID.kpTimeStamping) && !eku.purposes.includes(OID.anyExtendedKeyUsage))
323
+ return "is not authorised for timestamping (extended key usage)";
324
+ return null;
325
+ }
326
+ /** SHA-256 fingerprint, base64url, for reports and pinning. */
327
+ export async function certificateFingerprint(cert) {
328
+ return toBase64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", cert.raw)));
329
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@int-loop/core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Schemas, canonicalisation, hashing, passkey binding and ledger and receipt verification shared by the int-loop service and int-loop-verify.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://int-loop.com",