UNPKG

mockttp-mvs

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

285 lines (242 loc) 10.7 kB
import * as _ from 'lodash'; import * as fs from 'fs/promises'; import { v4 as uuid } from "uuid"; import * as forge from 'node-forge'; const { pki, md, util: { encode64 } } = forge; export type CAOptions = (CertDataOptions | CertPathOptions); export interface CertDataOptions extends BaseCAOptions { key: string; cert: string; }; export interface CertPathOptions extends BaseCAOptions { keyPath: string; certPath: string; } export interface BaseCAOptions { /** * Minimum key length when generating certificates. Defaults to 2048. */ keyLength?: number; /** * The countryName that will be used in the certificate for incoming TLS * connections. */ countryName?: string; /** * The localityName that will be used in the certificate for incoming TLS * connections. */ localityName?: string; /** * The organizationName that will be used in the certificate for incoming TLS * connections. */ organizationName?: string; } export type PEM = string | string[] | Buffer | Buffer[]; export type GeneratedCertificate = { key: string, cert: string, ca: string }; /** * 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. */ export async function generateCACertificate(options: { commonName?: string, organizationName?: string, countryName?: string, bits?: number } = {}) { options = _.defaults({}, options, { commonName: 'Mockttp Testing CA - DO NOT TRUST - TESTING ONLY', organizationName: 'Mockttp', countryName: 'XX', // ISO-3166-1 alpha-2 'unknown country' code bits: 2048, }); const keyPair = await new Promise<forge.pki.rsa.KeyPair>((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) }; } export function generateSPKIFingerprint(certPem: PEM) { let cert = pki.certificateFromPem(certPem.toString('utf8')); return encode64( pki.getPublicKeyFingerprint(cert.publicKey, { type: 'SubjectPublicKeyInfo', md: md.sha256.create(), encoding: 'binary' }) ); } // Generates a unique serial number for a certificate as a hex string: function generateSerialNumber() { return 'A' + uuid().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). } export async function getCA(options: CAOptions): Promise<CA> { let certOptions: CertDataOptions; 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); } // 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: { publicKey: forge.pki.rsa.PublicKey, privateKey: forge.pki.rsa.PrivateKey, length: number } | undefined; export class CA { private caCert: forge.pki.Certificate; private caKey: forge.pki.PrivateKey; private options: CertDataOptions; private certCache: { [domain: string]: GeneratedCertificate }; constructor(options: CertDataOptions) { 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: string): GeneratedCertificate { // 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' }, // ISO-3166-1 alpha-2 'unknown country' code { 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 as any // 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; } }