@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.
@@ -0,0 +1,150 @@
1
+ // Timestamp authority roots that int-loop and int-loop-verify trust by default. An auditor
2
+ // can replace this list with their own (int-loop-verify --tsa-roots roots.pem).
3
+ //
4
+ // Provenance, checked 2026-11-09 (SHA-256 fingerprints):
5
+ // FreeTSA Root CA a6379e7cecc05faa3cbf076013d745e327bbbaa38c0b9af22469d4701d18aabc
6
+ // from https://freetsa.org/files/cacert.pem, identical to the root FreeTSA sends
7
+ // DigiCert Trusted Root G4 552f7bdcf1a7af9e6ce672017f4f12abf77240c78e761ac203d1d9d20ac89988
8
+ // from the macOS system root store
9
+ // Sectigo Public Time Stamping Root R46
10
+ // 4941b001b8a97e961b7817c9d9e960ec4b056bfc915a8c1aabf6ef6b3ac046a5
11
+ // from the macOS system root store
12
+ //
13
+ // tests/core/tsp.test.ts checks each fingerprint, so a change here cannot go unnoticed.
14
+ import { fromPem } from "./der.js";
15
+ export const KNOWN_TSA_ROOTS = [
16
+ {
17
+ id: "freetsa",
18
+ name: "FreeTSA Root CA",
19
+ sha256: "a6379e7cecc05faa3cbf076013d745e327bbbaa38c0b9af22469d4701d18aabc",
20
+ pem: `
21
+ -----BEGIN CERTIFICATE-----
22
+ MIIH/zCCBeegAwIBAgIJAMHphhYNqOmAMA0GCSqGSIb3DQEBDQUAMIGVMREwDwYD
23
+ VQQKEwhGcmVlIFRTQTEQMA4GA1UECxMHUm9vdCBDQTEYMBYGA1UEAxMPd3d3LmZy
24
+ ZWV0c2Eub3JnMSIwIAYJKoZIhvcNAQkBFhNidXNpbGV6YXNAZ21haWwuY29tMRIw
25
+ EAYDVQQHEwlXdWVyemJ1cmcxDzANBgNVBAgTBkJheWVybjELMAkGA1UEBhMCREUw
26
+ HhcNMTYwMzEzMDE1MjEzWhcNNDEwMzA3MDE1MjEzWjCBlTERMA8GA1UEChMIRnJl
27
+ ZSBUU0ExEDAOBgNVBAsTB1Jvb3QgQ0ExGDAWBgNVBAMTD3d3dy5mcmVldHNhLm9y
28
+ ZzEiMCAGCSqGSIb3DQEJARYTYnVzaWxlemFzQGdtYWlsLmNvbTESMBAGA1UEBxMJ
29
+ V3VlcnpidXJnMQ8wDQYDVQQIEwZCYXllcm4xCzAJBgNVBAYTAkRFMIICIjANBgkq
30
+ hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtgKODjAy8REQ2WTNqUudAnjhlCrpE6ql
31
+ mQfNppeTmVvZrH4zutn+NwTaHAGpjSGv4/WRpZ1wZ3BRZ5mPUBZyLgq0YrIfQ5Fx
32
+ 0s/MRZPzc1r3lKWrMR9sAQx4mN4z11xFEO529L0dFJjPF9MD8Gpd2feWzGyptlel
33
+ b+PqT+++fOa2oY0+NaMM7l/xcNHPOaMz0/2olk0i22hbKeVhvokPCqhFhzsuhKsm
34
+ q4Of/o+t6dI7sx5h0nPMm4gGSRhfq+z6BTRgCrqQG2FOLoVFgt6iIm/BnNffUr7V
35
+ DYd3zZmIwFOj/H3DKHoGik/xK3E82YA2ZulVOFRW/zj4ApjPa5OFbpIkd0pmzxzd
36
+ EcL479hSA9dFiyVmSxPtY5ze1P+BE9bMU1PScpRzw8MHFXxyKqW13Qv7LWw4sbk3
37
+ SciB7GACbQiVGzgkvXG6y85HOuvWNvC5GLSiyP9GlPB0V68tbxz4JVTRdw/Xn/XT
38
+ FNzRBM3cq8lBOAVt/PAX5+uFcv1S9wFE8YjaBfWCP1jdBil+c4e+0tdywT2oJmYB
39
+ BF/kEt1wmGwMmHunNEuQNzh1FtJY54hbUfiWi38mASE7xMtMhfj/C4SvapiDN837
40
+ gYaPfs8x3KZxbX7C3YAsFnJinlwAUss1fdKar8Q/YVs7H/nU4c4Ixxxz4f67fcVq
41
+ M2ITKentbCMCAwEAAaOCAk4wggJKMAwGA1UdEwQFMAMBAf8wDgYDVR0PAQH/BAQD
42
+ AgHGMB0GA1UdDgQWBBT6VQ2MNGZRQ0z357OnbJWveuaklzCBygYDVR0jBIHCMIG/
43
+ gBT6VQ2MNGZRQ0z357OnbJWveuakl6GBm6SBmDCBlTERMA8GA1UEChMIRnJlZSBU
44
+ U0ExEDAOBgNVBAsTB1Jvb3QgQ0ExGDAWBgNVBAMTD3d3dy5mcmVldHNhLm9yZzEi
45
+ MCAGCSqGSIb3DQEJARYTYnVzaWxlemFzQGdtYWlsLmNvbTESMBAGA1UEBxMJV3Vl
46
+ cnpidXJnMQ8wDQYDVQQIEwZCYXllcm4xCzAJBgNVBAYTAkRFggkAwemGFg2o6YAw
47
+ MwYDVR0fBCwwKjAooCagJIYiaHR0cDovL3d3dy5mcmVldHNhLm9yZy9yb290X2Nh
48
+ LmNybDCBzwYDVR0gBIHHMIHEMIHBBgorBgEEAYHyJAEBMIGyMDMGCCsGAQUFBwIB
49
+ FidodHRwOi8vd3d3LmZyZWV0c2Eub3JnL2ZyZWV0c2FfY3BzLmh0bWwwMgYIKwYB
50
+ BQUHAgEWJmh0dHA6Ly93d3cuZnJlZXRzYS5vcmcvZnJlZXRzYV9jcHMucGRmMEcG
51
+ CCsGAQUFBwICMDsaOUZyZWVUU0EgdHJ1c3RlZCB0aW1lc3RhbXBpbmcgU29mdHdh
52
+ cmUgYXMgYSBTZXJ2aWNlIChTYWFTKTA3BggrBgEFBQcBAQQrMCkwJwYIKwYBBQUH
53
+ MAGGG2h0dHA6Ly93d3cuZnJlZXRzYS5vcmc6MjU2MDANBgkqhkiG9w0BAQ0FAAOC
54
+ AgEAaK9+v5OFYu9M6ztYC+L69sw1omdyli89lZAfpWMMh9CRmJhM6KBqM/ipwoLt
55
+ nxyxGsbCPhcQjuTvzm+ylN6VwTMmIlVyVSLKYZcdSjt/eCUN+41K7sD7GVmxZBAF
56
+ ILnBDmTGJmLkrU0KuuIpj8lI/E6Z6NnmuP2+RAQSHsfBQi6sssnXMo4HOW5gtPO7
57
+ gDrUpVXID++1P4XndkoKn7Svw5n0zS9fv1hxBcYIHPPQUze2u30bAQt0n0iIyRLz
58
+ aWuhtpAtd7ffwEbASgzB7E+NGF4tpV37e8KiA2xiGSRqT5ndu28fgpOY87gD3ArZ
59
+ DctZvvTCfHdAS5kEO3gnGGeZEVLDmfEsv8TGJa3AljVa5E40IQDsUXpQLi8G+UC4
60
+ 1DWZu8EVT4rnYaCw1VX7ShOR1PNCCvjb8S8tfdudd9zhU3gEB0rxdeTy1tVbNLXW
61
+ 99y90xcwr1ZIDUwM/xQ/noO8FRhm0LoPC73Ef+J4ZBdrvWwauF3zJe33d4ibxEcb
62
+ 8/pz5WzFkeixYM2nsHhqHsBKw7JPouKNXRnl5IAE1eFmqDyC7G/VT7OF669xM6hb
63
+ Ut5G21JE4cNK6NNucS+fzg1JPX0+3VhsYZjj7D5uljRvQXrJ8iHgr/M6j2oLHvTA
64
+ I2MLdq2qjZFDOCXsxBxJpbmLGBx9ow6ZerlUxzws2AWv2pk=
65
+ -----END CERTIFICATE-----
66
+ `,
67
+ },
68
+ {
69
+ id: "digicert-g4",
70
+ name: "DigiCert Trusted Root G4",
71
+ sha256: "552f7bdcf1a7af9e6ce672017f4f12abf77240c78e761ac203d1d9d20ac89988",
72
+ pem: `
73
+ -----BEGIN CERTIFICATE-----
74
+ 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
104
+ -----END CERTIFICATE-----
105
+ `,
106
+ },
107
+ {
108
+ id: "sectigo-r46",
109
+ name: "Sectigo Public Time Stamping Root R46",
110
+ sha256: "4941b001b8a97e961b7817c9d9e960ec4b056bfc915a8c1aabf6ef6b3ac046a5",
111
+ pem: `
112
+ -----BEGIN CERTIFICATE-----
113
+ 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
142
+ XftzTXTX48FRiJ6PA9o=
143
+ -----END CERTIFICATE-----
144
+ `,
145
+ },
146
+ ];
147
+ /** The default roots, as DER. */
148
+ export function knownTsaRootsDer() {
149
+ return KNOWN_TSA_ROOTS.flatMap((r) => fromPem(r.pem));
150
+ }
package/dist/tsp.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ /** A TimeStampReq for a SHA-256 digest, asking for the signing certificate to be included. */
2
+ export declare function buildTimestampRequest(digest: Uint8Array, nonce: Uint8Array): Uint8Array<ArrayBuffer>;
3
+ export type TimestampReply = {
4
+ granted: true;
5
+ token: Uint8Array<ArrayBuffer>;
6
+ } | {
7
+ granted: false;
8
+ reason: string;
9
+ };
10
+ /** Reads a TimeStampResp. A refusal is a result, not an error; malformed bytes throw. */
11
+ export declare function parseTimestampReply(bytes: Uint8Array): TimestampReply;
12
+ export type TimestampExpectation = {
13
+ /** The SHA-256 digest that should have been stamped. */
14
+ digest: Uint8Array;
15
+ /** The nonce sent with the request, when checking a fresh reply. */
16
+ nonce?: Uint8Array;
17
+ /** Trusted TSA roots, as DER certificates. */
18
+ roots: readonly Uint8Array[];
19
+ };
20
+ export type TimestampInfo = {
21
+ /** The stamped time, as the TSA states it. */
22
+ gen_time: string;
23
+ serial: string;
24
+ policy: string;
25
+ /** Signing certificate first, trusted root last. */
26
+ chain: string[];
27
+ /** SHA-256 (hex) of the trusted root the chain ends at: which authority vouches for the time. */
28
+ root_sha256: string;
29
+ };
30
+ export type TimestampResult = ({
31
+ ok: true;
32
+ } & TimestampInfo) | {
33
+ ok: false;
34
+ reason: string;
35
+ };
36
+ /** Verifies a TimeStampToken. Never throws: anything malformed is a failure. */
37
+ export declare function verifyTimestampToken(token: Uint8Array, expect: TimestampExpectation): Promise<TimestampResult>;
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>;