@int-loop/core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tsp.js ADDED
@@ -0,0 +1,228 @@
1
+ // RFC 3161 timestamps: building a request, reading the reply and verifying the token.
2
+ //
3
+ // A token is a CMS SignedData whose content is a TSTInfo: the hash that was stamped, the
4
+ // time, a serial number and the nonce we sent. Verifying it means checking the hash and
5
+ // nonce, the CMS signature over the signed attributes, the binding of the signing
6
+ // certificate (ESS signingCertificate), the certificate's timestamping purpose, and a
7
+ // chain to a trusted root valid at the stamped time. Pure: the nonce is passed in.
8
+ import { bytesEqual, concatBytes, toHex } from "./hash.js";
9
+ import { children, contextTag, encodeBoolean, encodeNull, encodeOctetString, encodeOid, encodeSequence, encodeSmallInt, encodeUnsignedInteger, expectTag, readDer, readIntegerBytes, readOctetString, readOid, readSmallInt, readTime, seq, TAG, } from "./der.js";
10
+ import { buildChain, extendedKeyUsage, HASH_BY_OID, keyUsageAllows, OID, parseCertificate, subjectKeyIdentifier, verifySignature } from "./x509.js";
11
+ const CMS = {
12
+ signedData: "1.2.840.113549.1.7.2",
13
+ tstInfo: "1.2.840.113549.1.9.16.1.4",
14
+ contentType: "1.2.840.113549.1.9.3",
15
+ messageDigest: "1.2.840.113549.1.9.4",
16
+ signingCertificate: "1.2.840.113549.1.9.16.2.12",
17
+ signingCertificateV2: "1.2.840.113549.1.9.16.2.47",
18
+ };
19
+ const KEY_USAGE_DIGITAL_SIGNATURE = 0;
20
+ /** Real TSAs send three or four; more is not a token worth reading. */
21
+ const MAX_CERTIFICATES = 8;
22
+ const KEY_USAGE_NON_REPUDIATION = 1;
23
+ /** A TimeStampReq for a SHA-256 digest, asking for the signing certificate to be included. */
24
+ export function buildTimestampRequest(digest, nonce) {
25
+ if (digest.length !== 32)
26
+ throw new Error("expected a 32-byte SHA-256 digest");
27
+ return encodeSequence(encodeSmallInt(1), encodeSequence(encodeSequence(encodeOid(OID.sha256), encodeNull()), encodeOctetString(digest)), encodeUnsignedInteger(nonce), encodeBoolean(true));
28
+ }
29
+ /** Reads a TimeStampResp. A refusal is a result, not an error; malformed bytes throw. */
30
+ export function parseTimestampReply(bytes) {
31
+ const [statusInfo, token, ...extra] = seq(readDer(bytes), "TimeStampResp");
32
+ if (extra.length)
33
+ throw new Error("TimeStampResp has trailing elements");
34
+ const [status, ...rest] = seq(statusInfo, "PKIStatusInfo");
35
+ const code = readSmallInt(status, "PKIStatus");
36
+ if (code !== 0 && code !== 1) {
37
+ const text = rest[0]?.tag === TAG.SEQUENCE ? children(rest[0]).map((s) => new TextDecoder().decode(s.content)).join("; ") : "";
38
+ return { granted: false, reason: `the TSA refused the request (status ${code}${text ? `: ${text}` : ""})` };
39
+ }
40
+ if (!token)
41
+ throw new Error("the TSA granted the request but sent no token");
42
+ return { granted: true, token: token.raw };
43
+ }
44
+ /** Verifies a TimeStampToken. Never throws: anything malformed is a failure. */
45
+ export async function verifyTimestampToken(token, expect) {
46
+ try {
47
+ return await verifyUnsafe(token, expect);
48
+ }
49
+ catch (err) {
50
+ return { ok: false, reason: `malformed timestamp token: ${err instanceof Error ? err.message : String(err)}` };
51
+ }
52
+ }
53
+ async function verifyUnsafe(token, expect) {
54
+ const fail = (reason) => ({ ok: false, reason });
55
+ // ContentInfo { id-signedData, [0] EXPLICIT SignedData }
56
+ const [ctype, wrapped, ...ciExtra] = seq(readDer(token), "ContentInfo");
57
+ if (ciExtra.length || readOid(ctype) !== CMS.signedData)
58
+ return fail("not a CMS SignedData");
59
+ const [signedData, ...wrapExtra] = children(expectTag(wrapped, contextTag(0), "SignedData wrapper"));
60
+ if (wrapExtra.length)
61
+ return fail("SignedData wrapper has trailing elements");
62
+ const sd = seq(signedData, "SignedData");
63
+ const encap = seq(sd[2], "encapContentInfo");
64
+ if (encap.length !== 2)
65
+ return fail("encapContentInfo must carry exactly the TSTInfo");
66
+ if (readOid(encap[0]) !== CMS.tstInfo)
67
+ return fail("the signed content is not a TSTInfo");
68
+ const [eContentEl] = children(expectTag(encap[1], contextTag(0), "eContent wrapper"));
69
+ const eContent = readOctetString(eContentEl, "eContent");
70
+ const certificates = [];
71
+ let k = 3;
72
+ if (sd[k]?.tag === contextTag(0)) {
73
+ const certs = children(sd[k++]);
74
+ if (certs.length > MAX_CERTIFICATES)
75
+ return fail(`the token carries ${certs.length} certificates; at most ${MAX_CERTIFICATES} are read`);
76
+ certificates.push(...certs.map((c) => parseCertificate(c.raw)));
77
+ }
78
+ if (sd[k]?.tag === contextTag(1))
79
+ k++; // CRLs: not used
80
+ const signerInfos = seq(sd[k++], "signerInfos", TAG.SET);
81
+ if (k !== sd.length)
82
+ return fail("SignedData has trailing elements");
83
+ if (signerInfos.length !== 1)
84
+ return fail(`expected one signer, found ${signerInfos.length}`);
85
+ // TSTInfo
86
+ const tst = parseTstInfo(eContent);
87
+ if (tst.imprintAlg !== OID.sha256)
88
+ return fail("the stamped hash is not SHA-256");
89
+ if (!bytesEqual(tst.imprint, expect.digest))
90
+ return fail("the timestamp is for a different hash");
91
+ if (expect.nonce !== undefined && (tst.nonce === null || !bytesEqual(tst.nonce, readIntegerBytes(readDer(encodeUnsignedInteger(expect.nonce)))))) {
92
+ return fail("the nonce does not match the request");
93
+ }
94
+ // SignerInfo
95
+ const si = seq(signerInfos[0], "SignerInfo");
96
+ const sid = si[1];
97
+ const digestAlg = readOid(seq(si[2], "digestAlgorithm")[0]);
98
+ const hash = HASH_BY_OID[digestAlg];
99
+ if (!hash || hash === "SHA-1")
100
+ return fail(`unsupported digest algorithm ${digestAlg}`);
101
+ const signedAttrsEl = expectTag(si[3], contextTag(0), "signedAttrs");
102
+ const sigAlg = readOid(seq(si[4], "signatureAlgorithm")[0]);
103
+ const signature = readOctetString(si[5], "signature");
104
+ if (si.length > 7 || (si.length === 7 && si[6].tag !== contextTag(1)))
105
+ return fail("SignerInfo has unexpected elements");
106
+ const attrs = new Map();
107
+ for (const attr of children(signedAttrsEl)) {
108
+ const [type, values] = seq(attr, "attribute");
109
+ const oid = readOid(type);
110
+ const vs = seq(values, "attribute values", TAG.SET);
111
+ if (attrs.has(oid) || vs.length !== 1)
112
+ return fail(`signed attribute ${oid} must appear once with one value`);
113
+ attrs.set(oid, vs[0]);
114
+ }
115
+ if (readOid(attrs.get(CMS.contentType), "contentType attribute") !== CMS.tstInfo)
116
+ return fail("contentType attribute is not TSTInfo");
117
+ const contentDigest = new Uint8Array(await crypto.subtle.digest(hash, eContent));
118
+ if (!bytesEqual(readOctetString(attrs.get(CMS.messageDigest), "messageDigest attribute"), contentDigest))
119
+ return fail("messageDigest does not match the TSTInfo");
120
+ // The signer's certificate: identified by the SignerInfo and bound by ESS signingCertificate.
121
+ const signer = certificates.find((c) => matchesSid(c, sid));
122
+ if (!signer)
123
+ return fail("the token does not include the signing certificate");
124
+ const essProblem = await checkSigningCertificate(signer, attrs);
125
+ if (essProblem)
126
+ return fail(essProblem);
127
+ // The signature covers the DER of the signed attributes, re-tagged as a SET.
128
+ const signed = concatBytes(Uint8Array.of(TAG.SET), signedAttrsEl.raw.subarray(1));
129
+ let valid = false;
130
+ try {
131
+ valid = await verifySignature(signer.spki, sigAlg, signature, signed, hash);
132
+ }
133
+ catch (err) {
134
+ return fail(`signature cannot be checked: ${err instanceof Error ? err.message : String(err)}`);
135
+ }
136
+ if (!valid)
137
+ return fail("the TSA's signature does not verify");
138
+ // RFC 3161 2.3: the signing certificate is for timestamping only, and says so critically.
139
+ const eku = extendedKeyUsage(signer);
140
+ if (!eku || !eku.critical || eku.purposes.length !== 1 || eku.purposes[0] !== OID.kpTimeStamping) {
141
+ return fail(`${signer.name} is not a certificate for timestamping only (critical extended key usage id-kp-timeStamping)`);
142
+ }
143
+ if (!keyUsageAllows(signer, KEY_USAGE_DIGITAL_SIGNATURE) && !keyUsageAllows(signer, KEY_USAGE_NON_REPUDIATION)) {
144
+ return fail(`${signer.name} is not allowed to make signatures`);
145
+ }
146
+ const roots = expect.roots.map((r) => parseCertificate(r));
147
+ const chain = await buildChain(signer, certificates.filter((c) => c !== signer), roots, tst.genTime);
148
+ if (!chain.ok)
149
+ return fail(`the TSA's certificate does not chain to a trusted root: ${chain.reason}`);
150
+ const rootHash = toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", chain.anchor.raw)));
151
+ return { ok: true, gen_time: new Date(tst.genTime).toISOString(), serial: toHex(tst.serial), policy: tst.policy, chain: chain.path, root_sha256: rootHash };
152
+ }
153
+ function parseTstInfo(bytes) {
154
+ const t = seq(readDer(bytes), "TSTInfo");
155
+ if (readSmallInt(t[0], "TSTInfo version") !== 1)
156
+ throw new Error("unsupported TSTInfo version");
157
+ const policy = readOid(t[1], "policy");
158
+ const [alg, hashed, ...imprintExtra] = seq(t[2], "messageImprint");
159
+ if (imprintExtra.length)
160
+ throw new Error("messageImprint has trailing elements");
161
+ const algParts = seq(alg, "hashAlgorithm");
162
+ if (algParts.length > 2 || (algParts[1] && algParts[1].tag !== TAG.NULL))
163
+ throw new Error("unexpected hashAlgorithm parameters");
164
+ const imprintAlg = readOid(algParts[0]);
165
+ const imprint = readOctetString(hashed, "hashedMessage");
166
+ const serial = readIntegerBytes(t[3], "serialNumber");
167
+ const genTime = readTime(expectTag(t[4], TAG.GENERALIZED_TIME, "genTime"), "genTime");
168
+ let nonce = null;
169
+ for (const el of t.slice(5)) {
170
+ if (el.tag === TAG.INTEGER) {
171
+ if (nonce !== null)
172
+ throw new Error("TSTInfo has two nonces");
173
+ nonce = readIntegerBytes(el, "nonce");
174
+ }
175
+ // A critical extension we do not understand might change what the token means.
176
+ if (el.tag === contextTag(1)) {
177
+ for (const ext of children(el)) {
178
+ const parts = seq(ext, "TSTInfo extension");
179
+ if (parts.length === 3 && parts[1].tag === TAG.BOOLEAN && parts[1].content[0] === 0xff)
180
+ throw new Error("TSTInfo has a critical extension");
181
+ }
182
+ }
183
+ // accuracy, ordering and the tsa name do not affect what is checked here
184
+ }
185
+ return { policy, imprintAlg, imprint, serial, genTime, nonce };
186
+ }
187
+ /** SignerIdentifier: IssuerAndSerialNumber, or [0] SubjectKeyIdentifier. */
188
+ function matchesSid(cert, sid) {
189
+ if (sid.tag === TAG.SEQUENCE) {
190
+ const [issuer, serial] = children(sid);
191
+ return bytesEqual(issuer.raw, cert.issuer) && bytesEqual(readIntegerBytes(serial), cert.serial);
192
+ }
193
+ if (sid.tag === contextTag(0, false)) {
194
+ const ski = subjectKeyIdentifier(cert);
195
+ return ski !== null && bytesEqual(ski, sid.content);
196
+ }
197
+ return false;
198
+ }
199
+ /** RFC 2634 / RFC 5035: the signed attributes name the signing certificate by hash. At least one form is required. */
200
+ async function checkSigningCertificate(signer, attrs) {
201
+ const v2 = attrs.get(CMS.signingCertificateV2);
202
+ const v1 = attrs.get(CMS.signingCertificate);
203
+ if (!v1 && !v2)
204
+ return "the token does not bind its signing certificate (no ESS signingCertificate attribute)";
205
+ if (v2) {
206
+ const [certs] = seq(v2, "SigningCertificateV2");
207
+ const first = seq(certs, "ESSCertIDv2 list")[0];
208
+ const parts = seq(first, "ESSCertIDv2");
209
+ let alg = OID.sha256;
210
+ let i = 0;
211
+ if (parts[0]?.tag === TAG.SEQUENCE)
212
+ alg = readOid(seq(parts[i++], "hashAlgorithm")[0]);
213
+ const hashName = HASH_BY_OID[alg];
214
+ if (!hashName)
215
+ return `unsupported signingCertificateV2 hash ${alg}`;
216
+ const actual = new Uint8Array(await crypto.subtle.digest(hashName, signer.raw));
217
+ if (!bytesEqual(readOctetString(parts[i], "certHash"), actual))
218
+ return "signingCertificateV2 names a different certificate";
219
+ }
220
+ if (v1) {
221
+ const [certs] = seq(v1, "SigningCertificate");
222
+ const first = seq(certs, "ESSCertID list")[0];
223
+ const actual = new Uint8Array(await crypto.subtle.digest("SHA-1", signer.raw));
224
+ if (!bytesEqual(readOctetString(seq(first, "ESSCertID")[0], "certHash"), actual))
225
+ return "signingCertificate names a different certificate";
226
+ }
227
+ return null;
228
+ }
package/dist/x509.d.ts ADDED
@@ -0,0 +1,93 @@
1
+ export declare const OID: {
2
+ readonly rsaEncryption: "1.2.840.113549.1.1.1";
3
+ readonly sha256WithRSA: "1.2.840.113549.1.1.11";
4
+ readonly sha384WithRSA: "1.2.840.113549.1.1.12";
5
+ readonly sha512WithRSA: "1.2.840.113549.1.1.13";
6
+ readonly ecPublicKey: "1.2.840.10045.2.1";
7
+ readonly ecdsaWithSHA256: "1.2.840.10045.4.3.2";
8
+ readonly ecdsaWithSHA384: "1.2.840.10045.4.3.3";
9
+ readonly ecdsaWithSHA512: "1.2.840.10045.4.3.4";
10
+ readonly p256: "1.2.840.10045.3.1.7";
11
+ readonly p384: "1.3.132.0.34";
12
+ readonly p521: "1.3.132.0.35";
13
+ readonly sha1: "1.3.14.3.2.26";
14
+ readonly sha256: "2.16.840.1.101.3.4.2.1";
15
+ readonly sha384: "2.16.840.1.101.3.4.2.2";
16
+ readonly sha512: "2.16.840.1.101.3.4.2.3";
17
+ readonly commonName: "2.5.4.3";
18
+ readonly organisation: "2.5.4.10";
19
+ readonly subjectKeyIdentifier: "2.5.29.14";
20
+ readonly keyUsage: "2.5.29.15";
21
+ readonly subjectAltName: "2.5.29.17";
22
+ readonly basicConstraints: "2.5.29.19";
23
+ readonly crlDistributionPoints: "2.5.29.31";
24
+ readonly certificatePolicies: "2.5.29.32";
25
+ readonly authorityKeyIdentifier: "2.5.29.35";
26
+ readonly extKeyUsage: "2.5.29.37";
27
+ readonly authorityInfoAccess: "1.3.6.1.5.5.7.1.1";
28
+ readonly kpTimeStamping: "1.3.6.1.5.5.7.3.8";
29
+ readonly anyExtendedKeyUsage: "2.5.29.37.0";
30
+ };
31
+ export type HashName = "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512";
32
+ export declare const HASH_BY_OID: Record<string, HashName>;
33
+ export type Certificate = {
34
+ raw: Uint8Array<ArrayBuffer>;
35
+ tbs: Uint8Array<ArrayBuffer>;
36
+ signatureAlg: string;
37
+ signature: Uint8Array<ArrayBuffer>;
38
+ serial: Uint8Array<ArrayBuffer>;
39
+ issuer: Uint8Array<ArrayBuffer>;
40
+ subject: Uint8Array<ArrayBuffer>;
41
+ /** A readable form of the subject, for reports: CN, else O. */
42
+ name: string;
43
+ notBefore: number;
44
+ notAfter: number;
45
+ spki: Uint8Array<ArrayBuffer>;
46
+ extensions: Map<string, {
47
+ critical: boolean;
48
+ value: Uint8Array<ArrayBuffer>;
49
+ }>;
50
+ };
51
+ export declare function parseCertificate(bytes: Uint8Array): Certificate;
52
+ /** basicConstraints: whether this is a CA, and how many intermediate CAs may follow it (null: no limit). */
53
+ export declare function basicConstraints(cert: Certificate): {
54
+ ca: boolean;
55
+ pathLen: number | null;
56
+ };
57
+ export declare function isCa(cert: Certificate): boolean;
58
+ /** Whether the key usage extension allows `bit`. An absent extension permits every use. */
59
+ export declare function keyUsageAllows(cert: Certificate, bit: number): boolean;
60
+ export declare function extendedKeyUsage(cert: Certificate): {
61
+ critical: boolean;
62
+ purposes: string[];
63
+ } | null;
64
+ export declare function subjectKeyIdentifier(cert: Certificate): Uint8Array<ArrayBuffer> | null;
65
+ /**
66
+ * Verifies `signature` over `data` with the key in `spki`. `alg` is a signature algorithm OID,
67
+ * or rsaEncryption / ecPublicKey with the hash given separately (as CMS signer infos do).
68
+ */
69
+ export declare function verifySignature(spki: Uint8Array<ArrayBuffer>, alg: string, signature: Uint8Array, data: Uint8Array<ArrayBuffer>, hash?: HashName): Promise<boolean>;
70
+ /** X.509 and CMS carry ECDSA signatures as DER SEQUENCE { r, s }; WebCrypto wants r || s. */
71
+ export declare function ecdsaDerToRaw(der: Uint8Array, size: number): Uint8Array<ArrayBuffer>;
72
+ export type ChainResult = {
73
+ ok: true;
74
+ path: string[];
75
+ anchor: Certificate;
76
+ } | {
77
+ ok: false;
78
+ reason: string;
79
+ };
80
+ /**
81
+ * Builds a chain from `leaf` to one of `anchors`, through `intermediates`, with every
82
+ * certificate, the anchor included, valid at `at` (the timestamp's own time). The anchor is
83
+ * reached when a certificate's issuer has an anchor's name and the anchor's key verifies
84
+ * it, or when a certificate has an anchor's name and key itself (a cross-signed copy).
85
+ *
86
+ * Every issuer, the anchor included, must be a CA allowed to sign certificates, within its
87
+ * path length constraint, and either without an extended key usage or with one that allows
88
+ * timestamping. The search is bounded: at most MAX_CHAIN deep, each (name, key) once per
89
+ * path, and MAX_SIGNATURE_CHECKS signatures in all.
90
+ */
91
+ export declare function buildChain(leaf: Certificate, intermediates: readonly Certificate[], anchors: readonly Certificate[], at: number): Promise<ChainResult>;
92
+ /** SHA-256 fingerprint, base64url, for reports and pinning. */
93
+ export declare function certificateFingerprint(cert: Certificate): Promise<string>;
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.2.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",