UNPKG

mockttp

Version:

Mock HTTP server for testing HTTP clients and stubbing webservices

442 lines (381 loc) 15.9 kB
import { Buffer } from 'buffer'; import * as fs from 'fs/promises'; import * as _ from 'lodash'; import * as x509 from '@peculiar/x509'; import * as asn1X509 from '@peculiar/asn1-x509'; import * as asn1Schema from '@peculiar/asn1-schema'; // Import for PKCS#8 structure import { PrivateKeyInfo } from '@peculiar/asn1-pkcs8'; const crypto = globalThis.crypto; 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 }; const SUBJECT_NAME_MAP: { [key: string]: string } = { commonName: "CN", organizationName: "O", organizationalUnitName: "OU", countryName: "C", localityName: "L", stateOrProvinceName: "ST", domainComponent: "DC", serialNumber: "2.5.4.5" }; function arrayBufferToPem(buffer: ArrayBuffer, label: string): string { const base64 = 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: string) { // 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: ArrayBuffer; 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, PrivateKeyInfo); pkcs8KeyData = keyData; } catch (e: any) { // 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 PrivateKeyInfo({ version: 0, privateKeyAlgorithm: new asn1X509.AlgorithmIdentifier({ algorithm: rsaEncryptionOid }), privateKey: new asn1Schema.OctetString(rsaPrivateKeyDer) }); pkcs8KeyData = asn1Schema.AsnConvert.serialize(privateKeyInfo); } catch (conversionError: any) { 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. */ export async function generateCACertificate(options: { subject?: { commonName?: string, organizationName?: string, countryName?: string, [key: string]: string | undefined // Add any other subject field you like }, bits?: number, nameConstraints?: { /** * Array of permitted domains */ permitted?: string[] } } = {}) { 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"] ) as CryptoKeyPair; // Baseline requirements set a specific order for standard CA fields: const orderedKeys = ["countryName", "organizationName", "organizationalUnitName", "commonName"]; const subjectNameParts: x509.JsonNameParams = []; 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: x509.Extension[] = [ 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 as CryptoKey, false), await x509.AuthorityKeyIdentifierExtension.create(keyPair.publicKey as CryptoKey, 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 as CryptoKey, signingKey: keyPair.privateKey as CryptoKey, extensions }); const privateKeyBuffer = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey as CryptoKey); const privateKeyPem = arrayBufferToPem(privateKeyBuffer, "PRIVATE KEY"); const certificatePem = certificate.toString("pem"); return { key: privateKeyPem, cert: certificatePem }; } export async function generateSPKIFingerprint(certPem: string): Promise<string> { const cert = new x509.X509Certificate(certPem); const hashBuffer = await crypto.subtle.digest('SHA-256', cert.publicKey.rawData); return 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). } 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.') } 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: { value: Promise<CryptoKeyPair>, length: number } | undefined; const KEY_PAIR_ALGO = { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256", publicExponent: new Uint8Array([1, 0, 1]) }; export type { CA }; class CA { private options: BaseCAOptions; private certCache: { [domain: string]: GeneratedCertificate }; constructor( private caCert: x509.X509Certificate, private caKey: CryptoKey, options?: BaseCAOptions ) { 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: string): Promise<GeneratedCertificate> { // 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: x509.JsonNameParams = []; const subjectAttributes: Record<string, string> = {}; 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: x509.Extension[] = []; 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 as CryptoKey), "PRIVATE KEY" ), cert: certificate.toString("pem"), ca: this.caCert.toString("pem") }; this.certCache[domain] = generatedCertificate; return generatedCertificate; } }