/** * Copyright 2018-present Facebook. * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * @format */ import {Logger} from '../fb-interfaces/Logger'; import Server from '../server'; import {promisify} from 'util'; import fs from 'fs'; import { openssl, isInstalled as opensslInstalled, } from './openssl-wrapper-with-promises'; import path from 'path'; import tmp, {DirOptions, FileOptions} from 'tmp'; import iosUtil from '../fb-stubs/iOSContainerUtility'; import {reportPlatformFailures} from './metrics'; import {getAdbClient} from './adbClient'; import * as androidUtil from './androidContainerUtility'; import os from 'os'; import {Client as ADBClient} from 'adbkit'; const tmpFile = promisify(tmp.file) as ( options?: FileOptions, ) => Promise; const tmpDir = promisify(tmp.dir) as (options?: DirOptions) => Promise; // Desktop file paths const caKey = getFilePath('ca.key'); const caCert = getFilePath('ca.crt'); const serverKey = getFilePath('server.key'); const serverCsr = getFilePath('server.csr'); const serverSrl = getFilePath('server.srl'); const serverCert = getFilePath('server.crt'); // Device file paths const csrFileName = 'app.csr'; const deviceCAcertFile = 'sonarCA.crt'; const deviceClientCertFile = 'device.crt'; const caSubject = '/C=US/ST=CA/L=Menlo Park/O=Sonar/CN=SonarCA'; const serverSubject = '/C=US/ST=CA/L=Menlo Park/O=Sonar/CN=localhost'; const minCertExpiryWindowSeconds = 24 * 60 * 60; const allowedAppNameRegex = /^[a-zA-Z0-9._\-]+$/; const logTag = 'CertificateProvider'; /* * RFC2253 specifies the unamiguous x509 subject format. * However, even when specifying this, different openssl implementations * wrap it differently, e.g "subject=X" vs "subject= X". */ const x509SubjectCNRegex = /[=,]\s*CN=([^,]*)(,.*)?$/; export type SecureServerConfig = { key: Buffer; cert: Buffer; ca: Buffer; requestCert: boolean; rejectUnauthorized: boolean; }; /* * This class is responsible for generating and deploying server and client * certificates to allow for secure communication between Flipper and apps. * It takes a Certificate Signing Request which was generated by the app, * using the app's public/private keypair. * With this CSR it uses the Flipper CA to sign a client certificate which it * deploys securely to the app. * It also deploys the Flipper CA cert to the app. * The app can trust a server if and only if it has a certificate signed by the * Flipper CA. */ export default class CertificateProvider { logger: Logger; adb: Promise; certificateSetup: Promise; server: Server; constructor(server: Server, logger: Logger) { this.logger = logger; this.adb = getAdbClient(); this.certificateSetup = reportPlatformFailures( this.ensureServerCertExists(), 'ensureServerCertExists', ); this.server = server; } processCertificateSigningRequest( unsanitizedCsr: string, os: string, appDirectory: string, ): Promise<{deviceId: string}> { const csr = this.santitizeString(unsanitizedCsr); if (csr === '') { return Promise.reject(new Error(`Received empty CSR from ${os} device`)); } this.ensureOpenSSLIsAvailable(); return this.certificateSetup .then(_ => this.getCACertificate()) .then(caCert => this.deployFileToMobileApp( appDirectory, deviceCAcertFile, caCert, csr, os, ), ) .then(_ => this.generateClientCertificate(csr)) .then(clientCert => this.deployFileToMobileApp( appDirectory, deviceClientCertFile, clientCert, csr, os, ), ) .then(_ => this.extractAppNameFromCSR(csr)) .then(appName => this.getTargetDeviceId(os, appName, appDirectory, csr)) .then(deviceId => { return { deviceId, }; }); } getTargetDeviceId( os: string, appName: string, appDirectory: string, csr: string, ): Promise { if (os === 'Android') { return this.getTargetAndroidDeviceId(appName, appDirectory, csr); } else if (os === 'iOS') { return this.getTargetiOSDeviceId(appName, appDirectory, csr); } else if (os == 'MacOS') { return Promise.resolve(''); } return Promise.resolve('unknown'); } ensureOpenSSLIsAvailable(): void { if (!opensslInstalled()) { const e = Error( "It looks like you don't have OpenSSL installed. Please install it to continue.", ); this.server.emit('error', e); } } getCACertificate(): Promise { return new Promise((resolve, reject) => { fs.readFile(caCert, (err, data) => { if (err) { reject(err); } else { resolve(data.toString()); } }); }); } generateClientCertificate(csr: string): Promise { console.debug('Creating new client cert', logTag); return this.writeToTempFile(csr).then(path => { return openssl('x509', { req: true, in: path, CA: caCert, CAkey: caKey, CAcreateserial: true, CAserial: serverSrl, }); }); } getRelativePathInAppContainer(absolutePath: string) { const matches = /Application\/[^/]+\/(.*)/.exec(absolutePath); if (matches && matches.length === 2) { return matches[1]; } throw new Error("Path didn't match expected pattern: " + absolutePath); } deployFileToMobileApp( destination: string, filename: string, contents: string, csr: string, os: string, ): Promise { const appNamePromise = this.extractAppNameFromCSR(csr); if (os === 'Android') { const deviceIdPromise = appNamePromise.then(app => this.getTargetAndroidDeviceId(app, destination, csr), ); return Promise.all([deviceIdPromise, appNamePromise]).then( ([deviceId, appName]) => androidUtil.push(deviceId, appName, destination + filename, contents), ); } if (os === 'iOS' || os === 'windows' || os == 'MacOS') { return promisify(fs.writeFile)(destination + filename, contents).catch( err => { if (os === 'iOS') { // Writing directly to FS failed. It's probably a physical device. const relativePathInsideApp = this.getRelativePathInAppContainer( destination, ); return appNamePromise .then(appName => this.getTargetiOSDeviceId(appName, destination, csr), ) .then(udid => { return appNamePromise.then(appName => this.pushFileToiOSDevice( udid, appName, relativePathInsideApp, filename, contents, ), ); }); } throw new Error( `Invalid appDirectory recieved from ${os} device: ${destination}: ` + err.toString(), ); }, ); } return Promise.reject(new Error(`Unsupported device os: ${os}`)); } pushFileToiOSDevice( udid: string, bundleId: string, destination: string, filename: string, contents: string, ): Promise { return tmpDir({unsafeCleanup: true}).then(dir => { const filePath = path.resolve(dir, filename); promisify(fs.writeFile)(filePath, contents).then(() => iosUtil.push(udid, filePath, bundleId, destination), ); }); } getTargetAndroidDeviceId( appName: string, deviceCsrFilePath: string, csr: string, ): Promise { return this.adb .then(client => client.listDevices()) .then(devices => { if (devices.length === 0) { throw new Error('No Android devices found'); } const deviceMatchList = devices.map(device => this.androidDeviceHasMatchingCSR( deviceCsrFilePath, device.id, appName, csr, ) .then(isMatch => { return {id: device.id, isMatch, error: null}; }) .catch(e => { console.error( `Unable to check for matching CSR in ${device.id}:${appName}`, logTag, ); return {id: device.id, isMatch: false, error: e}; }), ); return Promise.all(deviceMatchList).then(devices => { const matchingIds = devices.filter(m => m.isMatch).map(m => m.id); if (matchingIds.length == 0) { const erroredDevice = devices.find(d => d.error); if (erroredDevice) { throw erroredDevice.error; } throw new Error(`No matching device found for app: ${appName}`); } if (matchingIds.length > 1) { console.error( new Error('More than one matching device found for CSR'), csr, ); } return matchingIds[0]; }); }); } getTargetiOSDeviceId( appName: string, deviceCsrFilePath: string, csr: string, ): Promise { const matches = /\/Devices\/([^/]+)\//.exec(deviceCsrFilePath); if (matches && matches.length == 2) { // It's a simulator, the deviceId is in the filepath. return Promise.resolve(matches[1]); } return iosUtil.targets().then(targets => { if (targets.length === 0) { throw new Error('No iOS devices found'); } const deviceMatchList = targets.map(target => this.iOSDeviceHasMatchingCSR( deviceCsrFilePath, target.udid, appName, csr, ).then(isMatch => { return {id: target.udid, isMatch}; }), ); return Promise.all(deviceMatchList).then(devices => { const matchingIds = devices.filter(m => m.isMatch).map(m => m.id); if (matchingIds.length == 0) { throw new Error(`No matching device found for app: ${appName}`); } return matchingIds[0]; }); }); } androidDeviceHasMatchingCSR( directory: string, deviceId: string, processName: string, csr: string, ): Promise { return androidUtil .pull(deviceId, processName, directory + csrFileName) .then(deviceCsr => { return this.santitizeString(deviceCsr.toString()) === csr; }); } iOSDeviceHasMatchingCSR( directory: string, deviceId: string, bundleId: string, csr: string, ): Promise { const originalFile = this.getRelativePathInAppContainer( path.resolve(directory, csrFileName), ); return tmpDir({unsafeCleanup: true}) .then(dir => { return iosUtil .pull(deviceId, originalFile, bundleId, dir) .then(() => dir); }) .then(dir => { return promisify(fs.readdir)(dir) .then(items => { if (items.length > 1) { throw new Error('Conflict in temp dir'); } if (items.length === 0) { throw new Error('Failed to pull CSR from device'); } return items[0]; }) .then(fileName => { const copiedFile = path.resolve(dir, fileName); return promisify(fs.readFile)(copiedFile).then(data => this.santitizeString(data.toString()), ); }); }) .then(csrFromDevice => csrFromDevice === csr); } santitizeString(csrString: string): string { return csrString.replace(/\r/g, '').trim(); } extractAppNameFromCSR(csr: string): Promise { return this.writeToTempFile(csr) .then(path => openssl('req', { in: path, noout: true, subject: true, nameopt: true, RFC2253: false, }).then(subject => { return [path, subject]; }), ) .then(([path, subject]) => { return new Promise(function(resolve, reject) { fs.unlink(path, err => { if (err) { reject(err); } else { resolve(subject); } }); }); }) .then(subject => { const matches = subject.trim().match(x509SubjectCNRegex); if (!matches || matches.length < 2) { throw new Error(`Cannot extract CN from ${subject}`); } return matches[1]; }) .then(appName => { if (!appName.match(allowedAppNameRegex)) { throw new Error( `Disallowed app name in CSR: ${appName}. Only alphanumeric characters and '.' allowed.`, ); } return appName; }); } loadSecureServerConfig(): Promise { return this.certificateSetup.then(() => { return { key: fs.readFileSync(serverKey), cert: fs.readFileSync(serverCert), ca: fs.readFileSync(caCert), requestCert: true, rejectUnauthorized: true, // can be false if necessary as we don't strictly need to verify the client }; }); } ensureCertificateAuthorityExists(): Promise { if (!fs.existsSync(caKey)) { return this.generateCertificateAuthority(); } return this.checkCertIsValid(caCert).catch(() => this.generateCertificateAuthority(), ); } checkCertIsValid(filename: string): Promise { if (!fs.existsSync(filename)) { return Promise.reject(new Error(`${filename} does not exist`)); } // openssl checkend is a nice feature but it only checks for certificates // expiring in the future, not those that have already expired. // So we need a separate check for certificates that have already expired // but since this involves parsing date outputs from openssl, which is less // reliable, keeping both checks for safety. return openssl('x509', { checkend: minCertExpiryWindowSeconds, in: filename, }) .then(() => undefined) .catch(e => { console.warn(`Certificate will expire soon: ${filename}`, logTag); throw e; }) .then(_ => openssl('x509', { enddate: true, in: filename, noout: true, }), ) .then(endDateOutput => { const dateString = endDateOutput .trim() .split('=')[1] .trim(); const expiryDate = Date.parse(dateString); if (isNaN(expiryDate)) { console.error( 'Unable to parse certificate expiry date: ' + endDateOutput, ); throw new Error( 'Cannot parse certificate expiry date. Assuming it has expired.', ); } if (expiryDate <= Date.now() + minCertExpiryWindowSeconds * 1000) { throw new Error('Certificate has expired or will expire soon.'); } }); } verifyServerCertWasIssuedByCA() { const options: { [key: string]: any; } = {CAfile: caCert}; options[serverCert] = false; return openssl('verify', options).then(output => { const verified = output.match(/[^:]+: OK/); if (!verified) { // This should never happen, but if it does, we need to notice so we can // generate a valid one, or no clients will trust our server. throw new Error('Current server cert was not issued by current CA'); } }); } generateCertificateAuthority(): Promise { if (!fs.existsSync(getFilePath(''))) { fs.mkdirSync(getFilePath('')); } console.log('Generating new CA', logTag); return openssl('genrsa', {out: caKey, '2048': false}) .then(_ => openssl('req', { new: true, x509: true, subj: caSubject, key: caKey, out: caCert, }), ) .then(_ => undefined); } ensureServerCertExists(): Promise { if ( !( fs.existsSync(serverKey) && fs.existsSync(serverCert) && fs.existsSync(caCert) ) ) { return this.generateServerCertificate(); } return this.checkCertIsValid(serverCert) .then(() => this.verifyServerCertWasIssuedByCA()) .catch(() => this.generateServerCertificate()); } generateServerCertificate(): Promise { return this.ensureCertificateAuthorityExists() .then(_ => { console.warn('Creating new server cert', logTag); }) .then(_ => openssl('genrsa', {out: serverKey, '2048': false})) .then(_ => openssl('req', { new: true, key: serverKey, out: serverCsr, subj: serverSubject, }), ) .then(_ => openssl('x509', { req: true, in: serverCsr, CA: caCert, CAkey: caKey, CAcreateserial: true, CAserial: serverSrl, out: serverCert, }), ) .then(_ => undefined); } writeToTempFile(content: string): Promise { return tmpFile().then(path => promisify(fs.writeFile)(path, content).then(_ => path), ); } } function getFilePath(fileName: string): string { return path.resolve(os.homedir(), '.flipper', 'certs', fileName); }