mockttp
Version:
Mock HTTP server for testing HTTP clients and stubbing webservices
291 lines • 13.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.generateCACertificate = generateCACertificate;
exports.generateSPKIFingerprint = generateSPKIFingerprint;
exports.getCA = getCA;
const buffer_1 = require("buffer");
const fs = require("fs/promises");
const _ = require("lodash");
const x509 = require("@peculiar/x509");
const asn1X509 = require("@peculiar/asn1-x509");
const asn1Schema = require("@peculiar/asn1-schema");
// Import for PKCS#8 structure
const asn1_pkcs8_1 = require("@peculiar/asn1-pkcs8");
const crypto = globalThis.crypto;
;
const SUBJECT_NAME_MAP = {
commonName: "CN",
organizationName: "O",
organizationalUnitName: "OU",
countryName: "C",
localityName: "L",
stateOrProvinceName: "ST",
domainComponent: "DC",
serialNumber: "2.5.4.5"
};
function arrayBufferToPem(buffer, label) {
const base64 = buffer_1.Buffer.from(buffer).toString('base64');
const lines = base64.match(/.{1,64}/g) || [];
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
}
// OID for rsaEncryption - used to wrap PKCS#1 keys into PKCS#8 below:
const rsaEncryptionOid = "1.2.840.113549.1.1.1";
async function pemToCryptoKey(pem) {
// The PEM might be PKCS#8 ("BEGIN PRIVATE KEY") or PKCS#1 ("BEGIN
// RSA PRIVATE KEY"). We want to transparently accept both, but
// we can only import PKCS#8, so we detect & convert if required.
const keyData = x509.PemConverter.decodeFirst(pem);
let pkcs8KeyData;
try {
// Try to parse the PEM as PKCS#8 PrivateKeyInfo - if it works,
// we can just use it directly as-is:
asn1Schema.AsnConvert.parse(keyData, asn1_pkcs8_1.PrivateKeyInfo);
pkcs8KeyData = keyData;
}
catch (e) {
// If parsing as PKCS#8 fails, assume it's PKCS#1 (RSAPrivateKey)
// and proceed to wrap it as an RSA key in a PrivateKeyInfo structure.
const rsaPrivateKeyDer = keyData;
try {
const privateKeyInfo = new asn1_pkcs8_1.PrivateKeyInfo({
version: 0,
privateKeyAlgorithm: new asn1X509.AlgorithmIdentifier({
algorithm: rsaEncryptionOid
}),
privateKey: new asn1Schema.OctetString(rsaPrivateKeyDer)
});
pkcs8KeyData = asn1Schema.AsnConvert.serialize(privateKeyInfo);
}
catch (conversionError) {
throw new Error(`Unsupported or malformed key format. Failed to parse as PKCS#8 with ${e.message || e.toString()} and failed to convert to PKCS#1 with ${conversionError.message || conversionError.toString()}`);
}
}
return await crypto.subtle.importKey("pkcs8", // N.b, pkcs1 is not supported, which is why we need the above
pkcs8KeyData, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, true, // Extractable
["sign"]);
}
/**
* 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 = {
bits: 2048,
...options,
subject: {
commonName: 'Mockttp Testing CA - DO NOT TRUST - TESTING ONLY',
organizationName: 'Mockttp',
countryName: 'XX', // ISO-3166-1 alpha-2 'unknown country' code
...options.subject
},
};
// We use RSA for now for maximum compatibility
const keyAlgorithm = {
name: "RSASSA-PKCS1-v1_5",
modulusLength: options.bits,
publicExponent: new Uint8Array([1, 0, 1]), // Standard 65537 fixed value
hash: "SHA-256"
};
const keyPair = await crypto.subtle.generateKey(keyAlgorithm, true, // Key should be extractable to be exportable
["sign", "verify"]);
// Baseline requirements set a specific order for standard CA fields:
const orderedKeys = ["countryName", "organizationName", "organizationalUnitName", "commonName"];
const subjectNameParts = [];
for (const key of orderedKeys) {
const value = options.subject[key];
if (!value)
continue;
const mappedKey = SUBJECT_NAME_MAP[key] || key;
subjectNameParts.push({ [mappedKey]: [value] });
}
for (const key in options.subject) {
if (orderedKeys.includes(key))
continue; // Already added above
const value = options.subject[key];
const mappedKey = SUBJECT_NAME_MAP[key] || key;
subjectNameParts.push({ [mappedKey]: [value] });
}
const subjectDistinguishedName = new x509.Name(subjectNameParts).toString();
const notBefore = new Date();
// Make it valid for the last 24h - helps in cases where clocks slightly disagree
notBefore.setDate(notBefore.getDate() - 1);
const notAfter = new Date();
// Valid for the next 10 years by default (BR sets an 8 year minimum)
notAfter.setFullYear(notAfter.getFullYear() + 10);
const extensions = [
new x509.BasicConstraintsExtension(true, // cA = true
undefined, // We don't set any path length constraint (should we? Not required by BR)
true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign |
x509.KeyUsageFlags.digitalSignature |
x509.KeyUsageFlags.cRLSign, true),
await x509.SubjectKeyIdentifierExtension.create(keyPair.publicKey, false),
await x509.AuthorityKeyIdentifierExtension.create(keyPair.publicKey, false)
];
const permittedDomains = options.nameConstraints?.permitted || [];
if (permittedDomains.length > 0) {
const permittedSubtrees = permittedDomains.map(domain => {
const generalName = new asn1X509.GeneralName({ dNSName: domain });
return new asn1X509.GeneralSubtree({ base: generalName });
});
const nameConstraints = new asn1X509.NameConstraints({
permittedSubtrees: new asn1X509.GeneralSubtrees(permittedSubtrees)
});
extensions.push(new x509.Extension(asn1X509.id_ce_nameConstraints, true, asn1Schema.AsnConvert.serialize(nameConstraints)));
}
const certificate = await x509.X509CertificateGenerator.create({
serialNumber: generateSerialNumber(),
subject: subjectDistinguishedName,
issuer: subjectDistinguishedName, // Self-signed
notBefore,
notAfter,
signingAlgorithm: keyAlgorithm,
publicKey: keyPair.publicKey,
signingKey: keyPair.privateKey,
extensions
});
const privateKeyBuffer = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey);
const privateKeyPem = arrayBufferToPem(privateKeyBuffer, "PRIVATE KEY");
const certificatePem = certificate.toString("pem");
return {
key: privateKeyPem,
cert: certificatePem
};
}
async function generateSPKIFingerprint(certPem) {
const cert = new x509.X509Certificate(certPem);
const hashBuffer = await crypto.subtle.digest('SHA-256', cert.publicKey.rawData);
return buffer_1.Buffer.from(hashBuffer).toString('base64');
}
// Generates a unique serial number for a certificate as a hex string:
function generateSerialNumber() {
return 'A' + crypto.randomUUID().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.');
}
const caCert = new x509.X509Certificate(certOptions.cert.toString());
const caKey = await pemToCryptoKey(certOptions.key.toString());
return new CA(caCert, caKey, options);
}
// 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;
const KEY_PAIR_ALGO = {
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256",
publicExponent: new Uint8Array([1, 0, 1])
};
class CA {
constructor(caCert, caKey, options) {
this.caCert = caCert;
this.caKey = caKey;
this.certCache = {};
this.options = options ?? {};
const keyLength = this.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 = {
length: keyLength,
value: crypto.subtle.generateKey({ ...KEY_PAIR_ALGO, modulusLength: keyLength }, true, ["sign", "verify"])
};
}
}
async generateCertificate(domain) {
// TODO: Expire domains from the cache? Based on their actual expiry?
if (this.certCache[domain])
return this.certCache[domain];
const leafKeyPair = await KEY_PAIR.value;
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('.')}`;
}
const subjectJsonNameParams = [];
const subjectAttributes = {};
if (domain[0] !== '*') { // Skip this for wildcards as CN cannot use them
subjectAttributes['commonName'] = domain;
}
subjectAttributes['countryName'] = this.options.countryName ?? 'XX';
// Most other subject attributes aren't allowed here by BR.
// Apply BR-required order
const orderedSubjectKeys = ["countryName", "organizationName", "localityName", "commonName"];
for (const key of orderedSubjectKeys) {
if (subjectAttributes[key]) {
const mappedKey = SUBJECT_NAME_MAP[key] || key;
subjectJsonNameParams.push({ [mappedKey]: [subjectAttributes[key]] });
}
}
const subjectDistinguishedName = new x509.Name(subjectJsonNameParams).toString();
const issuerDistinguishedName = this.caCert.subject;
const notBefore = new Date();
notBefore.setDate(notBefore.getDate() - 1); // Valid from 24 hours ago
const notAfter = new Date();
notAfter.setFullYear(notAfter.getFullYear() + 1); // Valid for 1 year
const extensions = [];
extensions.push(new x509.BasicConstraintsExtension(false, undefined, true));
extensions.push(new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment, true));
extensions.push(new x509.ExtendedKeyUsageExtension([asn1X509.id_kp_serverAuth, asn1X509.id_kp_clientAuth], false));
extensions.push(new x509.SubjectAlternativeNameExtension([{ type: "dns", value: domain }], false));
const policyInfo = new asn1X509.PolicyInformation({
policyIdentifier: '2.23.140.1.2.1' // Domain validated
});
const certificatePoliciesValue = new asn1X509.CertificatePolicies([policyInfo]);
extensions.push(new x509.Extension(asn1X509.id_ce_certificatePolicies, false, asn1Schema.AsnConvert.serialize(certificatePoliciesValue)));
// We don't include SubjectKeyIdentifierExtension as that's no longer recommended
extensions.push(await x509.AuthorityKeyIdentifierExtension.create(this.caCert, false));
const certificate = await x509.X509CertificateGenerator.create({
serialNumber: generateSerialNumber(),
subject: subjectDistinguishedName,
issuer: issuerDistinguishedName,
notBefore,
notAfter,
signingAlgorithm: KEY_PAIR_ALGO,
publicKey: leafKeyPair.publicKey,
signingKey: this.caKey,
extensions
});
const generatedCertificate = {
key: arrayBufferToPem(await crypto.subtle.exportKey("pkcs8", leafKeyPair.privateKey), "PRIVATE KEY"),
cert: certificate.toString("pem"),
ca: this.caCert.toString("pem")
};
this.certCache[domain] = generatedCertificate;
return generatedCertificate;
}
}
//# sourceMappingURL=certificates.js.map