mirror of
https://github.com/cypress-io/cypress.git
synced 2026-01-10 00:30:37 -06:00
* chore: convert @packages/https-proxy to TypeScript and use vitest instead of mocha * chore: address comments from code review * Update packages/https-proxy/vitest.config.ts
361 lines
8.7 KiB
TypeScript
361 lines
8.7 KiB
TypeScript
import _ from 'lodash'
|
|
import { promisify } from 'util'
|
|
import debugModule from 'debug'
|
|
import os from 'os'
|
|
import path from 'path'
|
|
import { pki, md } from 'node-forge'
|
|
import fs from 'fs-extra'
|
|
|
|
const debug = debugModule('cypress:https-proxy:ca')
|
|
|
|
// if this is higher than the user's cached CA version, the Cypress
|
|
// certificate cache will be cleared so that new certificates can
|
|
// supersede older ones
|
|
const CA_VERSION = 1
|
|
|
|
const generateKeyPairAsync = promisify(pki.rsa.generateKeyPair)
|
|
|
|
const ipAddressRe = /^[\d\.]+$/
|
|
|
|
const CAattrs = [{
|
|
name: 'commonName',
|
|
value: 'CypressProxyCA',
|
|
}, {
|
|
name: 'countryName',
|
|
value: 'Internet',
|
|
}, {
|
|
shortName: 'ST',
|
|
value: 'Internet',
|
|
}, {
|
|
name: 'localityName',
|
|
value: 'Internet',
|
|
}, {
|
|
name: 'organizationName',
|
|
value: 'Cypress.io',
|
|
}, {
|
|
shortName: 'OU',
|
|
value: 'CA',
|
|
}]
|
|
|
|
const CAextensions = [{
|
|
name: 'basicConstraints',
|
|
cA: true,
|
|
}, {
|
|
name: 'keyUsage',
|
|
keyCertSign: true,
|
|
digitalSignature: true,
|
|
nonRepudiation: true,
|
|
keyEncipherment: true,
|
|
dataEncipherment: true,
|
|
}, {
|
|
name: 'extKeyUsage',
|
|
serverAuth: true,
|
|
clientAuth: true,
|
|
codeSigning: true,
|
|
emailProtection: true,
|
|
timeStamping: true,
|
|
}, {
|
|
name: 'nsCertType',
|
|
client: true,
|
|
server: true,
|
|
email: true,
|
|
objsign: true,
|
|
sslCA: true,
|
|
emailCA: true,
|
|
objCA: true,
|
|
}, {
|
|
name: 'subjectKeyIdentifier',
|
|
}]
|
|
|
|
const ServerAttrs = [{
|
|
name: 'countryName',
|
|
value: 'Internet',
|
|
}, {
|
|
shortName: 'ST',
|
|
value: 'Internet',
|
|
}, {
|
|
name: 'localityName',
|
|
value: 'Internet',
|
|
}, {
|
|
name: 'organizationName',
|
|
value: 'Cypress Proxy CA',
|
|
}, {
|
|
shortName: 'OU',
|
|
value: 'Cypress Proxy Server Certificate',
|
|
}]
|
|
|
|
const ServerExtensions = [{
|
|
name: 'basicConstraints',
|
|
cA: false,
|
|
}, {
|
|
name: 'keyUsage',
|
|
keyCertSign: false,
|
|
digitalSignature: true,
|
|
nonRepudiation: false,
|
|
keyEncipherment: true,
|
|
dataEncipherment: true,
|
|
}, {
|
|
name: 'extKeyUsage',
|
|
serverAuth: true,
|
|
clientAuth: true,
|
|
codeSigning: false,
|
|
emailProtection: false,
|
|
timeStamping: false,
|
|
}, {
|
|
name: 'nsCertType',
|
|
client: true,
|
|
server: true,
|
|
email: false,
|
|
objsign: false,
|
|
sslCA: false,
|
|
emailCA: false,
|
|
objCA: false,
|
|
}, {
|
|
name: 'subjectKeyIdentifier',
|
|
}]
|
|
|
|
function hostnameToFilename (hostname: string) {
|
|
return hostname.replace(/\*/g, '_')
|
|
}
|
|
|
|
export class CA {
|
|
baseCAFolder: string
|
|
certsFolder: string
|
|
keysFolder: string
|
|
CAcert: pki.Certificate
|
|
CAkeys: pki.rsa.KeyPair
|
|
|
|
constructor (caFolder?: string) {
|
|
if (!caFolder) {
|
|
caFolder = path.join(os.tmpdir(), 'cy-ca')
|
|
}
|
|
|
|
this.baseCAFolder = caFolder
|
|
this.certsFolder = path.join(this.baseCAFolder, 'certs')
|
|
this.keysFolder = path.join(this.baseCAFolder, 'keys')
|
|
}
|
|
|
|
async removeAll () {
|
|
try {
|
|
await fs.remove(this.baseCAFolder)
|
|
} catch (err) {
|
|
if (err.code !== 'ENOENT') {
|
|
throw err
|
|
}
|
|
}
|
|
}
|
|
|
|
randomSerialNumber () {
|
|
// generate random 16 bytes hex string
|
|
let sn = ''
|
|
|
|
for (let i = 1; i <= 4; i++) {
|
|
sn += (`00000000${Math.floor(Math.random() * Math.pow(256, 4)).toString(16)}`).slice(-8)
|
|
}
|
|
|
|
return sn
|
|
}
|
|
|
|
async generateCA (): Promise<void> {
|
|
const keys = await generateKeyPairAsync({ bits: 2048 })
|
|
|
|
const cert = pki.createCertificate()
|
|
|
|
cert.publicKey = keys.publicKey
|
|
cert.serialNumber = this.randomSerialNumber()
|
|
|
|
cert.validity.notBefore = new Date()
|
|
cert.validity.notAfter = new Date()
|
|
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10)
|
|
cert.setSubject(CAattrs)
|
|
cert.setIssuer(CAattrs)
|
|
cert.setExtensions(CAextensions)
|
|
cert.sign(keys.privateKey, md.sha256.create())
|
|
|
|
this.CAcert = cert
|
|
this.CAkeys = keys
|
|
|
|
const certPem = pki.certificateToPem(cert)
|
|
const keyPrivatePem = pki.privateKeyToPem(keys.privateKey)
|
|
const keyPublicPem = pki.publicKeyToPem(keys.publicKey)
|
|
|
|
await Promise.all([
|
|
fs.outputFile(this.getCACertPath(), certPem),
|
|
fs.outputFile(this.getCAPrivateKeyPath(), keyPrivatePem),
|
|
fs.outputFile(this.getCAPublicKeyPath(), keyPublicPem),
|
|
this.writeCAVersion(),
|
|
])
|
|
|
|
return undefined
|
|
}
|
|
|
|
async loadCA (): Promise<void> {
|
|
const [certPEM, keyPrivatePEM, keyPublicPEM] = await Promise.all([
|
|
fs.readFile(this.getCACertPath(), 'utf-8'),
|
|
fs.readFile(this.getCAPrivateKeyPath(), 'utf-8'),
|
|
fs.readFile(this.getCAPublicKeyPath(), 'utf-8'),
|
|
])
|
|
|
|
this.CAcert = pki.certificateFromPem(certPEM)
|
|
|
|
this.CAkeys = {
|
|
privateKey: pki.privateKeyFromPem(keyPrivatePEM),
|
|
publicKey: pki.publicKeyFromPem(keyPublicPEM),
|
|
}
|
|
|
|
return undefined
|
|
}
|
|
|
|
async generateServerCertificateKeys (hostsArg: string): Promise<[string, string]> {
|
|
const hosts: string[] = [].concat(hostsArg)
|
|
|
|
const mainHost = hosts[0]
|
|
const keysServer = pki.rsa.generateKeyPair(2048)
|
|
const certServer = pki.createCertificate()
|
|
|
|
certServer.publicKey = keysServer.publicKey
|
|
certServer.serialNumber = this.randomSerialNumber()
|
|
certServer.validity.notBefore = new Date
|
|
certServer.validity.notAfter = new Date
|
|
certServer.validity.notAfter.setFullYear(certServer.validity.notBefore.getFullYear() + 2)
|
|
|
|
const attrsServer = _.clone(ServerAttrs)
|
|
|
|
attrsServer.unshift({
|
|
name: 'commonName',
|
|
value: mainHost,
|
|
})
|
|
|
|
certServer.setSubject(attrsServer)
|
|
certServer.setIssuer(this.CAcert.issuer.attributes)
|
|
certServer.setExtensions(ServerExtensions.concat([{
|
|
name: 'subjectAltName',
|
|
// @ts-expect-error
|
|
altNames: hosts.map((host: string) => {
|
|
if (host.match(ipAddressRe)) {
|
|
return { type: 7, ip: host }
|
|
}
|
|
|
|
return { type: 2, value: host }
|
|
}),
|
|
}]))
|
|
|
|
certServer.sign(this.CAkeys.privateKey, md.sha256.create())
|
|
|
|
const certPem = pki.certificateToPem(certServer)
|
|
const keyPrivatePem = pki.privateKeyToPem(keysServer.privateKey)
|
|
const keyPublicPem = pki.publicKeyToPem(keysServer.publicKey)
|
|
|
|
const baseFilename = hostnameToFilename(mainHost)
|
|
|
|
await Promise.all([
|
|
fs.outputFile(this.getCertPath(baseFilename), certPem),
|
|
fs.outputFile(this.getPrivateKeyPath(baseFilename), keyPrivatePem),
|
|
fs.outputFile(this.getPublicKeyPath(baseFilename), keyPublicPem),
|
|
])
|
|
|
|
return [certPem, keyPrivatePem]
|
|
}
|
|
|
|
async clearDataForHostname (hostname: string): Promise<void> {
|
|
const baseFilename = hostnameToFilename(hostname)
|
|
|
|
await Promise.all([
|
|
fs.remove(this.getCertPath(baseFilename)),
|
|
fs.remove(this.getPrivateKeyPath(baseFilename)),
|
|
fs.remove(this.getPublicKeyPath(baseFilename)),
|
|
])
|
|
|
|
return undefined
|
|
}
|
|
|
|
async getCertificateKeysForHostname (hostname: string): Promise<[string, string]> {
|
|
const baseFilename = hostnameToFilename(hostname)
|
|
|
|
const [certPem, keyPrivatePem] = await Promise.all([
|
|
fs.readFile(this.getCertPath(baseFilename)),
|
|
fs.readFile(this.getPrivateKeyPath(baseFilename)),
|
|
])
|
|
|
|
return [certPem.toString(), keyPrivatePem.toString()]
|
|
}
|
|
|
|
getPrivateKeyPath (baseFilename: string) {
|
|
return path.join(this.keysFolder, `${baseFilename}.key`)
|
|
}
|
|
|
|
getPublicKeyPath (baseFilename: string) {
|
|
return path.join(this.keysFolder, `${baseFilename}.public.key`)
|
|
}
|
|
|
|
getCertPath (baseFilename: string) {
|
|
return path.join(this.certsFolder, `${baseFilename}.pem`)
|
|
}
|
|
|
|
getCACertPath () {
|
|
return path.join(this.certsFolder, 'ca.pem')
|
|
}
|
|
|
|
getCAPrivateKeyPath () {
|
|
return path.join(this.keysFolder, 'ca.private.key')
|
|
}
|
|
|
|
getCAPublicKeyPath () {
|
|
return path.join(this.keysFolder, 'ca.public.key')
|
|
}
|
|
|
|
getCAVersionPath () {
|
|
return path.join(this.baseCAFolder, 'ca_version.txt')
|
|
}
|
|
|
|
async getCAVersion () {
|
|
try {
|
|
const version = await fs.readFile(this.getCAVersionPath())
|
|
|
|
return Number(version)
|
|
} catch (err) {
|
|
debug('error reading cached CA version: %o', { err })
|
|
|
|
return 0
|
|
}
|
|
}
|
|
|
|
writeCAVersion () {
|
|
return fs.outputFile(this.getCAVersionPath(), String(CA_VERSION))
|
|
}
|
|
|
|
async assertMinimumCAVersion () {
|
|
const actualVersion = await this.getCAVersion()
|
|
|
|
debug('checking CA version %o', { actualVersion, CA_VERSION })
|
|
if (actualVersion >= CA_VERSION) {
|
|
return
|
|
}
|
|
|
|
throw new Error(`expected ca_version to be >= ${CA_VERSION}, but it was ${actualVersion}`)
|
|
}
|
|
|
|
static async create (caFolder?: string) {
|
|
const ca = new CA(caFolder)
|
|
|
|
try {
|
|
await fs.stat(ca.getCACertPath())
|
|
try {
|
|
await ca.assertMinimumCAVersion()
|
|
} catch (err) {
|
|
debug('CA version mismatch or is missing, removing all certs and keys in the CA folder')
|
|
await ca.removeAll()
|
|
throw new Error('CA version mismatch or is missing, catch below and regenerate certs and keys')
|
|
}
|
|
|
|
await ca.loadCA()
|
|
|
|
return ca
|
|
} catch (err) {
|
|
await ca.generateCA()
|
|
}
|
|
|
|
return ca
|
|
}
|
|
}
|