Skip to content

Commit 829999f

Browse files
committed
Improve site cert generation further and drop node-forge entirely
This changes some specific details of the content of site certs to more closely match standard browser TLS baseline requirements wherever possible (currently: everything except AIA/CRL/OCSP which require public CA URLs). Note that this is a breaking change for generateSPKIFingerprint which is now async and returns a Promise for a string, not a plain string.
1 parent 8006cc9 commit 829999f

File tree

6 files changed

+201
-151
lines changed

6 files changed

+201
-151
lines changed

package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,6 @@
106106
"@types/lodash": "4.14.178",
107107
"@types/mocha": "8.2.3",
108108
"@types/native-duplexpair": "^1.0.0",
109-
"@types/node-forge": "1.0.0",
110109
"@types/request": "2.48.7",
111110
"@types/semver": "7.5.0",
112111
"@types/shelljs": "0.8.9",
@@ -193,7 +192,6 @@
193192
"lodash": "^4.16.4",
194193
"lru-cache": "^7.14.0",
195194
"native-duplexpair": "^1.0.0",
196-
"node-forge": "^1.2.1",
197195
"pac-proxy-agent": "^7.0.0",
198196
"parse-multipart-data": "^1.4.0",
199197
"performance-now": "^2.1.0",

src/server/http-combo-server.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ export async function createComboServer(options: ComboServerOptions): Promise<De
169169

170170
if (options.https) {
171171
const ca = await getCA(options.https);
172-
const defaultCert = ca.generateCertificate(options.https.defaultDomain ?? 'localhost');
172+
const defaultCert = await ca.generateCertificate(options.https.defaultDomain ?? 'localhost');
173173

174174
const serverProtocolPreferences = options.http2 === true
175175
? ['h2', 'http/1.1', 'http 1.1'] // 'http 1.1' is non-standard, but used by https-proxy-agent
@@ -203,11 +203,11 @@ export async function createComboServer(options: ComboServerOptions): Promise<De
203203
ca: [defaultCert.ca],
204204
...ALPNOption,
205205
...(options.https?.tlsServerOptions || {}),
206-
SNICallback: (domain: string, cb: Function) => {
206+
SNICallback: async (domain: string, cb: Function) => {
207207
if (options.debug) console.log(`Generating certificate for ${domain}`);
208208

209209
try {
210-
const generatedCert = ca.generateCertificate(domain);
210+
const generatedCert = await ca.generateCertificate(domain);
211211
cb(null, tls.createSecureContext({
212212
key: generatedCert.key,
213213
cert: generatedCert.cert,

src/util/tls.ts

Lines changed: 104 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,6 @@ import * as x509 from '@peculiar/x509';
66
import * as asn1X509 from '@peculiar/asn1-x509';
77
import * as asn1Schema from '@peculiar/asn1-schema';
88

9-
import * as forge from 'node-forge';
10-
const { asn1, pki, md, util } = forge;
11-
129
const crypto = globalThis.crypto;
1310

1411
export type CAOptions = (CertDataOptions | CertPathOptions);
@@ -73,6 +70,17 @@ function arrayBufferToPem(buffer: ArrayBuffer, label: string): string {
7370
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
7471
}
7572

73+
async function pemToCryptoKey(pem: string) {
74+
const derKey = x509.PemConverter.decodeFirst(pem);
75+
return await crypto.subtle.importKey(
76+
"pkcs8",
77+
derKey,
78+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
79+
true, // Extractable
80+
["sign"]
81+
);
82+
}
83+
7684
/**
7785
* Generate a CA certificate for mocking HTTPS.
7886
*
@@ -202,16 +210,10 @@ export async function generateCACertificate(options: {
202210
};
203211
}
204212

205-
206-
export function generateSPKIFingerprint(certPem: PEM) {
207-
let cert = pki.certificateFromPem(certPem.toString('utf8'));
208-
return util.encode64(
209-
pki.getPublicKeyFingerprint(cert.publicKey, {
210-
type: 'SubjectPublicKeyInfo',
211-
md: md.sha256.create(),
212-
encoding: 'binary'
213-
})
214-
);
213+
export async function generateSPKIFingerprint(certPem: string): Promise<string> {
214+
const cert = new x509.X509Certificate(certPem);
215+
const hashBuffer = await crypto.subtle.digest('SHA-256', cert.publicKey.rawData);
216+
return Buffer.from(hashBuffer).toString('base64');
215217
}
216218

217219
// Generates a unique serial number for a certificate as a hex string:
@@ -249,39 +251,49 @@ export async function getCA(options: CAOptions): Promise<CA> {
249251
// This would be a terrible idea for a real server, but for a mock server
250252
// it's ok - if anybody can steal this, they can steal the CA cert anyway.
251253
let KEY_PAIR: {
252-
publicKey: forge.pki.rsa.PublicKey,
253-
privateKey: forge.pki.rsa.PrivateKey,
254+
value: Promise<CryptoKeyPair>,
254255
length: number
255256
} | undefined;
257+
const KEY_PAIR_ALGO = {
258+
name: "RSASSA-PKCS1-v1_5",
259+
hash: "SHA-256",
260+
publicExponent: new Uint8Array([1, 0, 1])
261+
};
256262

257263
export class CA {
258-
private caCert: forge.pki.Certificate;
259-
private caKey: forge.pki.PrivateKey;
264+
private caCert: x509.X509Certificate;
265+
private caKey: Promise<CryptoKey>;
260266
private options: CertDataOptions;
261267

262268
private certCache: { [domain: string]: GeneratedCertificate };
263269

264270
constructor(options: CertDataOptions) {
265-
this.caKey = pki.privateKeyFromPem(options.key.toString());
266-
this.caCert = pki.certificateFromPem(options.cert.toString());
271+
this.caKey = pemToCryptoKey(options.key.toString());
272+
this.caCert = new x509.X509Certificate(options.cert.toString());
267273
this.certCache = {};
268274
this.options = options ?? {};
269275

270276
const keyLength = options.keyLength || 2048;
271277

272278
if (!KEY_PAIR || KEY_PAIR.length < keyLength) {
273279
// If we have no key, or not a long enough one, generate one.
274-
KEY_PAIR = Object.assign(
275-
pki.rsa.generateKeyPair(keyLength),
276-
{ length: keyLength }
277-
);
280+
KEY_PAIR = {
281+
length: keyLength,
282+
value: crypto.subtle.generateKey(
283+
{ ...KEY_PAIR_ALGO, modulusLength: keyLength },
284+
true,
285+
["sign", "verify"]
286+
)
287+
};
278288
}
279289
}
280290

281-
generateCertificate(domain: string): GeneratedCertificate {
291+
async generateCertificate(domain: string): Promise<GeneratedCertificate> {
282292
// TODO: Expire domains from the cache? Based on their actual expiry?
283293
if (this.certCache[domain]) return this.certCache[domain];
284294

295+
const leafKeyPair = await KEY_PAIR!.value;
296+
285297
if (domain.includes('_')) {
286298
// TLS certificates cannot cover domains with underscores, bizarrely. More info:
287299
// https://www.digicert.com/kb/ssl-support/underscores-not-allowed-in-fqdns.htm
@@ -300,70 +312,80 @@ export class CA {
300312
domain = `*.${otherParts.join('.')}`;
301313
}
302314

303-
let cert = pki.createCertificate();
315+
const subjectJsonNameParams: x509.JsonNameParams = [];
316+
const subjectAttributes: Record<string, string> = {};
304317

305-
cert.publicKey = KEY_PAIR!.publicKey;
306-
cert.serialNumber = generateSerialNumber();
307-
308-
cert.validity.notBefore = new Date();
309-
// Make it valid for the last 24h - helps in cases where clocks slightly disagree.
310-
cert.validity.notBefore.setDate(cert.validity.notBefore.getDate() - 1);
318+
if (domain[0] !== '*') { // Skip this for wildcards as CN cannot use them
319+
subjectAttributes['commonName'] = domain;
320+
}
321+
subjectAttributes['countryName'] = this.options.countryName ?? 'XX';
322+
// Most other subject attributes aren't allowed here by BR.
323+
324+
// Apply BR-required order
325+
const orderedSubjectKeys = ["countryName", "organizationName", "localityName", "commonName"];
326+
for (const key of orderedSubjectKeys) {
327+
if (subjectAttributes[key]) {
328+
const mappedKey = SUBJECT_NAME_MAP[key] || key;
329+
subjectJsonNameParams.push({ [mappedKey]: [subjectAttributes[key]] });
330+
}
331+
}
332+
const subjectDistinguishedName = new x509.Name(subjectJsonNameParams).toString();
333+
const issuerDistinguishedName = this.caCert.subject;
311334

312-
cert.validity.notAfter = new Date();
313-
// Valid for the next year by default. TODO: Shorten (and expire the cache) automatically.
314-
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1);
335+
const notBefore = new Date();
336+
notBefore.setDate(notBefore.getDate() - 1); // Valid from 24 hours ago
315337

316-
cert.setSubject([
317-
...(domain[0] === '*'
318-
? [] // We skip the CN (deprecated, rarely used) for wildcards, since they can't be used here.
319-
: [{ name: 'commonName', value: domain }]
320-
),
321-
{ name: 'countryName', value: this.options?.countryName ?? 'XX' }, // ISO-3166-1 alpha-2 'unknown country' code
322-
{ name: 'localityName', value: this.options?.localityName ?? 'Unknown' },
323-
{ name: 'organizationName', value: this.options?.organizationName ?? 'Mockttp Cert - DO NOT TRUST' }
324-
]);
325-
cert.setIssuer(this.caCert.subject.attributes);
326-
327-
const policyList = forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
328-
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
329-
forge.asn1.create(
330-
forge.asn1.Class.UNIVERSAL,
331-
forge.asn1.Type.OID,
332-
false,
333-
forge.asn1.oidToDer('2.5.29.32.0').getBytes() // Mark all as Domain Verified
334-
)
335-
])
336-
]);
337-
338-
cert.setExtensions([
339-
{ name: 'basicConstraints', cA: false, critical: true },
340-
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true, critical: true },
341-
{ name: 'extKeyUsage', serverAuth: true, clientAuth: true },
342-
{
343-
name: 'subjectAltName',
344-
altNames: [{
345-
type: 2,
346-
value: domain
347-
}]
348-
},
349-
{ name: 'certificatePolicies', value: policyList },
350-
{ name: 'subjectKeyIdentifier' },
351-
{
352-
name: 'authorityKeyIdentifier',
353-
// We have to calculate this ourselves due to
354-
// https://github.com/digitalbazaar/forge/issues/462
355-
keyIdentifier: (
356-
this.caCert as any // generateSubjectKeyIdentifier is missing from node-forge types
357-
).generateSubjectKeyIdentifier().getBytes()
358-
}
359-
]);
338+
const notAfter = new Date();
339+
notAfter.setFullYear(notAfter.getFullYear() + 1); // Valid for 1 year
360340

361-
cert.sign(this.caKey, md.sha256.create());
341+
const extensions: x509.Extension[] = [];
342+
extensions.push(new x509.BasicConstraintsExtension(false, undefined, true));
343+
extensions.push(new x509.KeyUsagesExtension(
344+
x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment,
345+
true
346+
));
347+
extensions.push(new x509.ExtendedKeyUsageExtension(
348+
[asn1X509.id_kp_serverAuth, asn1X509.id_kp_clientAuth],
349+
false
350+
));
351+
352+
extensions.push(new x509.SubjectAlternativeNameExtension(
353+
[{ type: "dns", value: domain }],
354+
false
355+
));
356+
357+
const policyInfo = new asn1X509.PolicyInformation({
358+
policyIdentifier: '2.23.140.1.2.1' // Domain validated
359+
});
360+
const certificatePoliciesValue = new asn1X509.CertificatePolicies([policyInfo]);
361+
extensions.push(new x509.Extension(
362+
asn1X509.id_ce_certificatePolicies,
363+
false,
364+
asn1Schema.AsnConvert.serialize(certificatePoliciesValue)
365+
));
366+
367+
// We don't include SubjectKeyIdentifierExtension as that's no longer recommended
368+
extensions.push(await x509.AuthorityKeyIdentifierExtension.create(this.caCert, false));
369+
370+
const certificate = await x509.X509CertificateGenerator.create({
371+
serialNumber: generateSerialNumber(),
372+
subject: subjectDistinguishedName,
373+
issuer: issuerDistinguishedName,
374+
notBefore,
375+
notAfter,
376+
signingAlgorithm: KEY_PAIR_ALGO,
377+
publicKey: leafKeyPair.publicKey,
378+
signingKey: await this.caKey,
379+
extensions
380+
});
362381

363382
const generatedCertificate = {
364-
key: pki.privateKeyToPem(KEY_PAIR!.privateKey),
365-
cert: pki.certificateToPem(cert),
366-
ca: pki.certificateToPem(this.caCert)
383+
key: arrayBufferToPem(
384+
await crypto.subtle.exportKey("pkcs8", leafKeyPair.privateKey as CryptoKey),
385+
"RSA PRIVATE KEY"
386+
),
387+
cert: certificate.toString("pem"),
388+
ca: this.caCert.toString("pem")
367389
};
368390

369391
this.certCache[domain] = generatedCertificate;

0 commit comments

Comments
 (0)