mockttp-mvs
Version:
Mock HTTP server for testing HTTP clients and stubbing webservices
192 lines • 9.14 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.CA = exports.getCA = exports.generateSPKIFingerprint = exports.generateCACertificate = void 0;
const _ = require("lodash");
const fs = require("fs/promises");
const uuid_1 = require("uuid");
const forge = require("node-forge");
const { pki, md, util: { encode64 } } = forge;
;
/**
* Generate a CA certificate for mocking HTTPS.
*
* Returns a promise, for an object with key and cert properties,
* containing the generated private key and certificate in PEM format.
*
* These can be saved to disk, and their paths passed
* as HTTPS options to a Mockttp server.
*/
async function generateCACertificate(options = {}) {
options = _.defaults({}, options, {
commonName: 'Mockttp Testing CA - DO NOT TRUST - TESTING ONLY',
organizationName: 'Mockttp',
countryName: 'XX',
bits: 2048,
});
const keyPair = await new Promise((resolve, reject) => {
pki.rsa.generateKeyPair({ bits: options.bits }, (error, keyPair) => {
if (error)
reject(error);
else
resolve(keyPair);
});
});
const cert = pki.createCertificate();
cert.publicKey = keyPair.publicKey;
cert.serialNumber = generateSerialNumber();
cert.validity.notBefore = new Date();
// Make it valid for the last 24h - helps in cases where clocks slightly disagree
cert.validity.notBefore.setDate(cert.validity.notBefore.getDate() - 1);
cert.validity.notAfter = new Date();
// Valid for the next year by default.
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1);
cert.setSubject([
// All of these are required for a fully valid CA cert that will be accepted when imported anywhere:
{ name: 'commonName', value: options.commonName },
{ name: 'countryName', value: options.countryName },
{ name: 'organizationName', value: options.organizationName }
]);
cert.setExtensions([
{ name: 'basicConstraints', cA: true, critical: true },
{ name: 'keyUsage', keyCertSign: true, digitalSignature: true, nonRepudiation: true, cRLSign: true, critical: true },
{ name: 'subjectKeyIdentifier' }
]);
// Self-issued too
cert.setIssuer(cert.subject.attributes);
// Self-sign the certificate - we're the root
cert.sign(keyPair.privateKey, md.sha256.create());
return {
key: pki.privateKeyToPem(keyPair.privateKey),
cert: pki.certificateToPem(cert)
};
}
exports.generateCACertificate = generateCACertificate;
function generateSPKIFingerprint(certPem) {
let cert = pki.certificateFromPem(certPem.toString('utf8'));
return encode64(pki.getPublicKeyFingerprint(cert.publicKey, {
type: 'SubjectPublicKeyInfo',
md: md.sha256.create(),
encoding: 'binary'
}));
}
exports.generateSPKIFingerprint = generateSPKIFingerprint;
// Generates a unique serial number for a certificate as a hex string:
function generateSerialNumber() {
return 'A' + (0, uuid_1.v4)().replace(/-/g, '');
// We add a leading 'A' to ensure it's always positive (not 'F') and always
// valid (e.g. leading 000 is bad padding, and would be unparseable).
}
async function getCA(options) {
let certOptions;
if ('key' in options && 'cert' in options) {
certOptions = options;
}
else if ('keyPath' in options && 'certPath' in options) {
certOptions = await Promise.all([
fs.readFile(options.keyPath, 'utf8'),
fs.readFile(options.certPath, 'utf8')
]).then(([keyContents, certContents]) => ({
..._.omit(options, ['keyPath', 'certPath']),
key: keyContents,
cert: certContents
}));
}
else {
throw new Error('Unrecognized https options: you need to provide either a keyPath & certPath, or a key & cert.');
}
return new CA(certOptions);
}
exports.getCA = getCA;
// We share a single keypair across all certificates in this process, and
// instantiate it once when the first CA is created, because it can be
// expensive (depending on the key length).
// This would be a terrible idea for a real server, but for a mock server
// it's ok - if anybody can steal this, they can steal the CA cert anyway.
let KEY_PAIR;
class CA {
constructor(options) {
this.caKey = pki.privateKeyFromPem(options.key.toString());
this.caCert = pki.certificateFromPem(options.cert.toString());
this.certCache = {};
this.options = options ?? {};
const keyLength = options.keyLength || 2048;
if (!KEY_PAIR || KEY_PAIR.length < keyLength) {
// If we have no key, or not a long enough one, generate one.
KEY_PAIR = Object.assign(pki.rsa.generateKeyPair(keyLength), { length: keyLength });
}
}
generateCertificate(domain) {
// TODO: Expire domains from the cache? Based on their actual expiry?
if (this.certCache[domain])
return this.certCache[domain];
if (domain.includes('_')) {
// TLS certificates cannot cover domains with underscores, bizarrely. More info:
// https://www.digicert.com/kb/ssl-support/underscores-not-allowed-in-fqdns.htm
// To fix this, we use wildcards instead. This is only possible for one level of
// certificate, and only for subdomains, so our options are a little limited, but
// this should be very rare (because it's not supported elsewhere either).
const [, ...otherParts] = domain.split('.');
if (otherParts.length <= 1 || // *.com is never valid
otherParts.some(p => p.includes('_'))) {
throw new Error(`Cannot generate certificate for domain due to underscores: ${domain}`);
}
// Replace the first part with a wildcard to solve the problem:
domain = `*.${otherParts.join('.')}`;
}
let cert = pki.createCertificate();
cert.publicKey = KEY_PAIR.publicKey;
cert.serialNumber = generateSerialNumber();
cert.validity.notBefore = new Date();
// Make it valid for the last 24h - helps in cases where clocks slightly disagree.
cert.validity.notBefore.setDate(cert.validity.notBefore.getDate() - 1);
cert.validity.notAfter = new Date();
// Valid for the next year by default. TODO: Shorten (and expire the cache) automatically.
cert.validity.notAfter.setFullYear(cert.validity.notAfter.getFullYear() + 1);
cert.setSubject([
...(domain[0] === '*'
? [] // We skip the CN (deprecated, rarely used) for wildcards, since they can't be used here.
: [{ name: 'commonName', value: domain }]),
{ name: 'countryName', value: this.options?.countryName ?? 'XX' },
{ name: 'localityName', value: this.options?.localityName ?? 'Unknown' },
{ name: 'organizationName', value: this.options?.organizationName ?? 'Mockttp Cert - DO NOT TRUST' }
]);
cert.setIssuer(this.caCert.subject.attributes);
const policyList = forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.SEQUENCE, true, [
forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OID, false, forge.asn1.oidToDer('2.5.29.32.0').getBytes() // Mark all as Domain Verified
)
])
]);
cert.setExtensions([
{ name: 'basicConstraints', cA: false, critical: true },
{ name: 'keyUsage', digitalSignature: true, keyEncipherment: true, critical: true },
{ name: 'extKeyUsage', serverAuth: true, clientAuth: true },
{
name: 'subjectAltName',
altNames: [{
type: 2,
value: domain
}]
},
{ name: 'certificatePolicies', value: policyList },
{ name: 'subjectKeyIdentifier' },
{
name: 'authorityKeyIdentifier',
// We have to calculate this ourselves due to
// https://github.com/digitalbazaar/forge/issues/462
keyIdentifier: this.caCert // generateSubjectKeyIdentifier is missing from node-forge types
.generateSubjectKeyIdentifier().getBytes()
}
]);
cert.sign(this.caKey, md.sha256.create());
const generatedCertificate = {
key: pki.privateKeyToPem(KEY_PAIR.privateKey),
cert: pki.certificateToPem(cert),
ca: pki.certificateToPem(this.caCert)
};
this.certCache[domain] = generatedCertificate;
return generatedCertificate;
}
}
exports.CA = CA;
//# sourceMappingURL=tls.js.map