Skip to content

Commit 8006cc9

Browse files
committed
Tighten up CA cert generation, using @peculiar/x509 over node-forge
This migrates CA generation (but not yet site cert generation) away from node-forge (now unmaintained) and improves our matching to official TLS baseline requirements: extends the CA lifespan, orders CA subject fields, drops nonRepudiation etc. This now passes the BR TLS server root requirement linting checks.
1 parent e14e1f7 commit 8006cc9

File tree

5 files changed

+137
-115
lines changed

5 files changed

+137
-115
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ jobs:
2323

2424
- run: npm run ci-tests
2525
env:
26-
# The new type stripping breaks our existing ts-node testing set up, so disable it for Node 22+
27-
NODE_OPTIONS: ${{ !startsWith(matrix.node-version, '20') && !startsWith(matrix.node-version, '18') && '--no-experimental-strip-types' || '' }}
26+
# Node v18 needs webcrypto, Node v22+ needs no strip-types (because we use ts-node for full TS instead)
27+
NODE_OPTIONS: >-
28+
${{ startsWith(matrix.node-version, '18') && '--experimental-global-webcrypto' ||
29+
(!startsWith(matrix.node-version, '20') && !startsWith(matrix.node-version, '18') && '--no-experimental-strip-types') || '' }}
2830
2931
- name: Deploy docs
3032
if: github.ref == 'refs/heads/main' && matrix.node-version == 'v22.14.0'

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@
165165
"@httptoolkit/subscriptions-transport-ws": "^0.11.2",
166166
"@httptoolkit/util": "^0.1.6",
167167
"@httptoolkit/websocket-stream": "^6.0.1",
168+
"@peculiar/asn1-schema": "^2.3.15",
169+
"@peculiar/asn1-x509": "^2.3.15",
170+
"@peculiar/x509": "^1.12.3",
168171
"@types/cors": "^2.8.6",
169172
"@types/node": "*",
170173
"async-mutex": "^0.5.0",

src/util/tls.ts

Lines changed: 117 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
import * as _ from 'lodash';
22
import * as fs from 'fs/promises';
33
import { v4 as uuid } from "uuid";
4-
import * as forge from 'node-forge';
54

5+
import * as x509 from '@peculiar/x509';
6+
import * as asn1X509 from '@peculiar/asn1-x509';
7+
import * as asn1Schema from '@peculiar/asn1-schema';
8+
9+
import * as forge from 'node-forge';
610
const { asn1, pki, md, util } = forge;
711

12+
const crypto = globalThis.crypto;
13+
814
export type CAOptions = (CertDataOptions | CertPathOptions);
915

1016
export interface CertDataOptions extends BaseCAOptions {
@@ -50,6 +56,23 @@ export type GeneratedCertificate = {
5056
ca: string
5157
};
5258

59+
const SUBJECT_NAME_MAP: { [key: string]: string } = {
60+
commonName: "CN",
61+
organizationName: "O",
62+
organizationalUnitName: "OU",
63+
countryName: "C",
64+
localityName: "L",
65+
stateOrProvinceName: "ST",
66+
domainComponent: "DC",
67+
serialNumber: "2.5.4.5"
68+
};
69+
70+
function arrayBufferToPem(buffer: ArrayBuffer, label: string): string {
71+
const base64 = Buffer.from(buffer).toString('base64');
72+
const lines = base64.match(/.{1,64}/g) || [];
73+
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
74+
}
75+
5376
/**
5477
* Generate a CA certificate for mocking HTTPS.
5578
*
@@ -68,123 +91,118 @@ export async function generateCACertificate(options: {
6891
},
6992
bits?: number,
7093
nameConstraints?: {
94+
/**
95+
* Array of permitted domains
96+
*/
7197
permitted?: string[]
7298
}
7399
} = {}) {
74-
options = _.defaults({}, options, {
100+
options = {
75101
bits: 2048,
76-
});
77-
78-
const subjectOptions = _.defaults({}, options.subject, {
79-
// These subject fields are required for a fully valid CA cert that will be
80-
// accepted when imported anywhere:
81-
commonName: 'Mockttp Testing CA - DO NOT TRUST - TESTING ONLY',
82-
organizationName: 'Mockttp',
83-
countryName: 'XX', // ISO-3166-1 alpha-2 'unknown country' code
84-
});
102+
...options,
103+
subject: {
104+
commonName: 'Mockttp Testing CA - DO NOT TRUST - TESTING ONLY',
105+
organizationName: 'Mockttp',
106+
countryName: 'XX', // ISO-3166-1 alpha-2 'unknown country' code
107+
...options.subject
108+
},
109+
};
85110

86-
const keyPair = await new Promise<forge.pki.rsa.KeyPair>((resolve, reject) => {
87-
pki.rsa.generateKeyPair({ bits: options.bits }, (error, keyPair) => {
88-
if (error) reject(error);
89-
else resolve(keyPair);
90-
});
91-
});
111+
// We use RSA for now for maximum compatibility
112+
const keyAlgorithm = {
113+
name: "RSASSA-PKCS1-v1_5",
114+
modulusLength: options.bits,
115+
publicExponent: new Uint8Array([1, 0, 1]), // Standard 65537 fixed value
116+
hash: "SHA-256"
117+
};
92118

93-
const cert = pki.createCertificate();
94-
cert.publicKey = keyPair.publicKey;
95-
cert.serialNumber = generateSerialNumber();
119+
const keyPair = await crypto.subtle.generateKey(
120+
keyAlgorithm,
121+
true, // Key should be extractable to be exportable
122+
["sign", "verify"]
123+
) as CryptoKeyPair;
124+
125+
// Baseline requirements set a specific order for standard CA fields:
126+
const orderedKeys = ["countryName", "organizationName", "organizationalUnitName", "commonName"];
127+
const subjectNameParts: x509.JsonNameParams = [];
128+
129+
for (const key of orderedKeys) {
130+
const value = options.subject![key];
131+
if (!value) continue;
132+
const mappedKey = SUBJECT_NAME_MAP[key] || key;
133+
subjectNameParts.push({ [mappedKey]: [value] });
134+
}
135+
for (const key in options.subject) {
136+
if (orderedKeys.includes(key)) continue; // Already added above
137+
const value = options.subject[key]!;
138+
const mappedKey = SUBJECT_NAME_MAP[key] || key;
139+
subjectNameParts.push({ [mappedKey]: [value] });
140+
}
141+
const subjectDistinguishedName = new x509.Name(subjectNameParts).toString();
96142

97-
cert.validity.notBefore = new Date();
143+
const notBefore = new Date();
98144
// Make it valid for the last 24h - helps in cases where clocks slightly disagree
99-
cert.validity.notBefore.setDate(cert.validity.notBefore.getDate() - 1);
100-
101-
cert.validity.notAfter = new Date();
102-
// Valid for the next year by default.
103-
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1);
104-
105-
cert.setSubject(Object.entries(subjectOptions).map(([key, value]) => ({
106-
name: key,
107-
value: value
108-
})));
109-
110-
const extensions: any[] = [
111-
{ name: 'basicConstraints', cA: true, critical: true },
112-
{ name: 'keyUsage', keyCertSign: true, digitalSignature: true, nonRepudiation: true, cRLSign: true, critical: true },
113-
{ name: 'subjectKeyIdentifier' },
145+
notBefore.setDate(notBefore.getDate() - 1);
146+
147+
const notAfter = new Date();
148+
// Valid for the next 10 years by default (BR sets an 8 year minimum)
149+
notAfter.setFullYear(notAfter.getFullYear() + 10);
150+
151+
const extensions: x509.Extension[] = [
152+
new x509.BasicConstraintsExtension(
153+
true, // cA = true
154+
undefined, // We don't set any path length constraint (should we? Not required by BR)
155+
true
156+
),
157+
new x509.KeyUsagesExtension(
158+
x509.KeyUsageFlags.keyCertSign |
159+
x509.KeyUsageFlags.digitalSignature |
160+
x509.KeyUsageFlags.cRLSign,
161+
true
162+
),
163+
await x509.SubjectKeyIdentifierExtension.create(keyPair.publicKey as CryptoKey, false),
164+
await x509.AuthorityKeyIdentifierExtension.create(keyPair.publicKey as CryptoKey, false)
114165
];
166+
115167
const permittedDomains = options.nameConstraints?.permitted || [];
116-
if(permittedDomains.length > 0) {
117-
extensions.push({
118-
critical: true,
119-
id: '2.5.29.30',
120-
name: 'nameConstraints',
121-
value: generateNameConstraints({
122-
permitted: permittedDomains,
123-
}),
124-
})
168+
if (permittedDomains.length > 0) {
169+
const permittedSubtrees = permittedDomains.map(domain => {
170+
const generalName = new asn1X509.GeneralName({ dNSName: domain });
171+
return new asn1X509.GeneralSubtree({ base: generalName });
172+
});
173+
const nameConstraints = new asn1X509.NameConstraints({
174+
permittedSubtrees: new asn1X509.GeneralSubtrees(permittedSubtrees)
175+
});
176+
extensions.push(new x509.Extension(
177+
asn1X509.id_ce_nameConstraints,
178+
true,
179+
asn1Schema.AsnConvert.serialize(nameConstraints))
180+
);
125181
}
126-
cert.setExtensions(extensions);
127182

128-
// Self-issued too
129-
cert.setIssuer(cert.subject.attributes);
183+
const certificate = await x509.X509CertificateGenerator.create({
184+
serialNumber: generateSerialNumber(),
185+
subject: subjectDistinguishedName,
186+
issuer: subjectDistinguishedName, // Self-signed
187+
notBefore,
188+
notAfter,
189+
signingAlgorithm: keyAlgorithm,
190+
publicKey: keyPair.publicKey as CryptoKey,
191+
signingKey: keyPair.privateKey as CryptoKey,
192+
extensions
193+
});
130194

131-
// Self-sign the certificate - we're the root
132-
cert.sign(keyPair.privateKey, md.sha256.create());
195+
const privateKeyBuffer = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey as CryptoKey);
196+
const privateKeyPem = arrayBufferToPem(privateKeyBuffer, "RSA PRIVATE KEY");
197+
const certificatePem = certificate.toString("pem");
133198

134199
return {
135-
key: pki.privateKeyToPem(keyPair.privateKey),
136-
cert: pki.certificateToPem(cert)
200+
key: privateKeyPem,
201+
cert: certificatePem
137202
};
138203
}
139204

140205

141-
type GenerateNameConstraintsInput = {
142-
/**
143-
* Array of permitted domains
144-
*/
145-
permitted?: string[];
146-
};
147-
148-
/**
149-
* Generate name constraints in conformance with
150-
* [RFC 5280 § 4.2.1.10](https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.10)
151-
*/
152-
function generateNameConstraints(
153-
input: GenerateNameConstraintsInput
154-
): forge.asn1.Asn1 {
155-
const domainsToSequence = (ips: string[]) =>
156-
ips.map((domain) => {
157-
return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, [
158-
asn1.create(
159-
asn1.Class.CONTEXT_SPECIFIC,
160-
2,
161-
false,
162-
util.encodeUtf8(domain)
163-
),
164-
]);
165-
});
166-
167-
const permittedAndExcluded: forge.asn1.Asn1[] = [];
168-
169-
if (input.permitted && input.permitted.length > 0) {
170-
permittedAndExcluded.push(
171-
asn1.create(
172-
asn1.Class.CONTEXT_SPECIFIC,
173-
0,
174-
true,
175-
domainsToSequence(input.permitted)
176-
)
177-
);
178-
}
179-
180-
return asn1.create(
181-
asn1.Class.UNIVERSAL,
182-
asn1.Type.SEQUENCE,
183-
true,
184-
permittedAndExcluded
185-
);
186-
}
187-
188206
export function generateSPKIFingerprint(certPem: PEM) {
189207
let cert = pki.certificateFromPem(certPem.toString('utf8'));
190208
return util.encode64(

test/ca.spec.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ nodeOnly(() => {
9191
await new Promise<void>((resolve) => server.listen(4430, resolve));
9292

9393
const req = localhostRequest({hostname: "hello.other.com", port: 4430});
94-
return new Promise<void>((resolve, reject) => {
94+
return new Promise<void>((resolve) => {
9595
req.on("error", (err) => {
9696
expect(err.message).to.equal("permitted subtree violation");
9797
resolve();
@@ -117,9 +117,9 @@ nodeOnly(() => {
117117
const caCertificate = await caCertificatePromise;
118118

119119
expect(caCertificate.cert.length).to.be.greaterThan(1000);
120-
expect(caCertificate.cert.split('\r\n')[0]).to.equal('-----BEGIN CERTIFICATE-----');
120+
expect(caCertificate.cert.split('\n')[0]).to.equal('-----BEGIN CERTIFICATE-----');
121121
expect(caCertificate.key.length).to.be.greaterThan(1000);
122-
expect(caCertificate.key.split('\r\n')[0]).to.equal('-----BEGIN RSA PRIVATE KEY-----');
122+
expect(caCertificate.key.split('\n')[0]).to.equal('-----BEGIN RSA PRIVATE KEY-----');
123123
});
124124

125125
it("should generate a CA certificate that can be used to create domain certificates", async () => {
@@ -128,10 +128,10 @@ nodeOnly(() => {
128128

129129
const { cert, key } = ca.generateCertificate('localhost');
130130

131-
expect(caCertificate.cert.length).to.be.greaterThan(1000);
132-
expect(caCertificate.cert.split('\r\n')[0]).to.equal('-----BEGIN CERTIFICATE-----');
133-
expect(caCertificate.key.length).to.be.greaterThan(1000);
134-
expect(caCertificate.key.split('\r\n')[0]).to.equal('-----BEGIN RSA PRIVATE KEY-----');
131+
expect(cert.length).to.be.greaterThan(1000);
132+
expect(cert.split('\r\n')[0]).to.equal('-----BEGIN CERTIFICATE-----');
133+
expect(key.length).to.be.greaterThan(1000);
134+
expect(key.split('\r\n')[0]).to.equal('-----BEGIN RSA PRIVATE KEY-----');
135135
});
136136

137137
it("should be able to generate a CA certificate that passes lintcert checks", async function () {
@@ -149,7 +149,7 @@ nodeOnly(() => {
149149
'b64input': cert,
150150
'format': 'json',
151151
'severity': 'warning',
152-
'profile': 'autodetect'
152+
'profile': 'tbr_root_tlsserver' // TLS Baseline root CA
153153
})
154154
}),
155155
{ context: this }
@@ -193,8 +193,7 @@ nodeOnly(() => {
193193
});
194194

195195
it("should generate wildcard certs that pass lintcert checks for invalid subdomain names", async function () {
196-
this.timeout(5000); // Large cert + remote request can make this slow
197-
this.retries(3); // Remote server can be unreliable
196+
this.timeout(10_000); // Large cert + remote request can make this slow
198197

199198
const caCertificate = await caCertificatePromise;
200199
const ca = new CA({ key: caCertificate.key, cert: caCertificate.cert, keyLength: 2048 });
@@ -210,7 +209,7 @@ nodeOnly(() => {
210209
headers: { 'content-type': 'application/x-www-form-urlencoded' },
211210
body: new URLSearchParams({'b64cert': cert})
212211
}),
213-
{ context: this }
212+
{ context: this, timeout: 9000 }
214213
);
215214

216215
expect(response.status).to.equal(200);
@@ -257,8 +256,8 @@ nodeOnly(() => {
257256
body: new URLSearchParams({
258257
'b64input': cert,
259258
'format': 'json',
260-
'severity': 'warning',
261-
'profile': 'autodetect'
259+
'severity': 'error',
260+
'profile': 'tbr_root_tlsserver' // TLS Baseline root CA
262261
})
263262
}),
264263
{ context: this }

test/test-utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export async function ignoreNetworkError<T extends RequestPromise | Promise<Resp
124124

125125
const result = await Promise.race([
126126
request,
127-
delay(options.timeout ?? 1000).then(() => { throw TimeoutError; })
127+
delay(options.timeout ?? 1500).then(() => { throw TimeoutError; })
128128
]).catch(error => {
129129
console.log(error);
130130
if (error === TimeoutError || error.name === 'FetchError') {

0 commit comments

Comments
 (0)