/** * Copyright (c) Facebook, Inc. and its affiliates. * * 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 {internGraphPOSTAPIRequest} from '../../fb-stubs/user'; import ServerController from '../comms/ServerController'; import {promisify} from 'util'; import fs from 'fs-extra'; import { openssl, isInstalled as opensslInstalled, } from './openssl-wrapper-with-promises'; import path from 'path'; import tmp, {DirOptions, FileOptions} from 'tmp'; import iosUtil from '../devices/ios/iOSContainerUtility'; import {reportPlatformFailures} from '../../utils/metrics'; import {getAdbClient} from '../devices/android/adbClient'; import * as androidUtil from '../devices/android/androidContainerUtility'; import os from 'os'; import {Client as ADBClient} from 'adbkit'; import archiver from 'archiver'; import {timeout} from 'flipper-plugin'; import {v4 as uuid} from 'uuid'; import {isTest} from '../../utils/isProduction'; import {message} from 'antd'; export type CertificateExchangeMedium = 'FS_ACCESS' | 'WWW' | 'NONE'; 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 = /^[\w.-]+$/; 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; }; type CertificateProviderConfig = { idbPath: string; androidHome: string; enablePhysicalIOS: 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; config: CertificateProviderConfig; server: ServerController; constructor( server: ServerController, logger: Logger, config: CertificateProviderConfig, ) { this.logger = logger; // TODO: refactor this code to create promise lazily this.adb = getAdbClient(config).catch((e) => { // make sure initialization failure is already logged message.error({ duration: null, content: 'Failed to initialise ADB. Please check your Android settings, ANDROID_HOME and run the Setup Doctor. ' + e, }); console.error('Failed to initialise ADB', e); this.adb = Promise.reject(e); }) as Promise; if (isTest()) { this.certificateSetup = Promise.reject( new Error('Server certificates not available in test'), ); } else { this.certificateSetup = reportPlatformFailures( this.ensureServerCertExists(), 'ensureServerCertExists', ); // make sure initialization failure is already logged this.certificateSetup.catch((e) => { console.error('Failed to find or generate certificates', e); }); } this.config = config; this.server = server; } private uploadFiles = async ( zipPath: string, deviceID: string, ): Promise => { const buff = await fs.readFile(zipPath); const file = new File([buff], 'certs.zip'); return reportPlatformFailures( timeout( 5 * 60 * 1000, internGraphPOSTAPIRequest('flipper/certificates', { certificate_zip: file, device_id: deviceID, }), 'Timed out uploading Flipper certificates to WWW.', ), 'uploadCertificates', ); }; async processCertificateSigningRequest( unsanitizedCsr: string, os: string, appDirectory: string, medium: CertificateExchangeMedium, ): Promise<{deviceId: string}> { const csr = this.santitizeString(unsanitizedCsr); if (csr === '') { return Promise.reject(new Error(`Received empty CSR from ${os} device`)); } this.ensureOpenSSLIsAvailable(); const rootFolder = await promisify(tmp.dir)(); const certFolder = rootFolder + '/FlipperCerts/'; const certsZipPath = rootFolder + '/certs.zip'; await this.certificateSetup; const caCert = await this.getCACertificate(); await this.deployOrStageFileForMobileApp( appDirectory, deviceCAcertFile, caCert, csr, os, medium, certFolder, ); const clientCert = await this.generateClientCertificate(csr); await this.deployOrStageFileForMobileApp( appDirectory, deviceClientCertFile, clientCert, csr, os, medium, certFolder, ); const appName = await this.extractAppNameFromCSR(csr); const deviceId = medium === 'FS_ACCESS' ? await this.getTargetDeviceId(os, appName, appDirectory, csr) : uuid(); if (medium === 'WWW') { const zipPromise = new Promise((resolve, reject) => { const output = fs.createWriteStream(certsZipPath); const archive = archiver('zip', { zlib: {level: 9}, // Sets the compression level. }); archive.directory(certFolder, false); output.on('close', function () { resolve(certsZipPath); }); archive.on('warning', reject); archive.on('error', reject); archive.pipe(output); archive.finalize(); }); await reportPlatformFailures( zipPromise, 'www-certs-exchange-zipping-certs', ); await reportPlatformFailures( this.uploadFiles(certsZipPath, deviceId), 'www-certs-exchange-uploading-certs', ); } 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'); } private 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); } } private getCACertificate(): Promise { return fs.readFile(caCert, 'utf-8'); } private 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, }); }); } private 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); } private async deployOrStageFileForMobileApp( destination: string, filename: string, contents: string, csr: string, os: string, medium: CertificateExchangeMedium, certFolder: string, ): Promise { if (medium === 'WWW') { const certPathExists = await fs.pathExists(certFolder); if (!certPathExists) { await fs.mkdir(certFolder); } try { await fs.writeFile(certFolder + filename, contents); return; } catch (e) { throw new Error( `Failed to write ${filename} to temporary folder. Error: ${e}`, ); } } const appName = await this.extractAppNameFromCSR(csr); if (os === 'Android') { const deviceId = await this.getTargetAndroidDeviceId( appName, destination, csr, ); const adbClient = await this.adb; await androidUtil.push( adbClient, deviceId, appName, destination + filename, contents, ); } else if (os === 'iOS') { try { await fs.writeFile(destination + filename, contents); } catch (err) { // Writing directly to FS failed. It's probably a physical device. const relativePathInsideApp = this.getRelativePathInAppContainer(destination); const udid = await this.getTargetiOSDeviceId(appName, destination, csr); await this.pushFileToiOSDevice( udid, appName, relativePathInsideApp, filename, contents, ); } } else { throw new Error(`Unsupported device OS for Certificate Exchange: ${os}`); } } private async pushFileToiOSDevice( udid: string, bundleId: string, destination: string, filename: string, contents: string, ): Promise { const dir = await tmpDir({unsafeCleanup: true}); const filePath = path.resolve(dir, filename); await fs.writeFile(filePath, contents); await iosUtil.push( udid, filePath, bundleId, destination, this.config.idbPath, ); } private async getTargetAndroidDeviceId( appName: string, deviceCsrFilePath: string, csr: string, ): Promise { const devicesInAdb = await this.adb.then((client) => client.listDevices()); if (devicesInAdb.length === 0) { throw new Error('No Android devices found'); } const deviceMatchList = devicesInAdb.map(async (device) => { try { const result = await this.androidDeviceHasMatchingCSR( deviceCsrFilePath, device.id, appName, csr, ); return {id: device.id, ...result, error: null}; } catch (e) { console.warn( `Unable to check for matching CSR in ${device.id}:${appName}`, logTag, ); return {id: device.id, isMatch: false, foundCsr: null, error: e}; } }); const devices = await Promise.all(deviceMatchList); 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; } const foundCsrs = devices .filter((d) => d.foundCsr !== null) .map((d) => (d.foundCsr ? encodeURI(d.foundCsr) : 'null')); console.warn(`Looking for CSR (url encoded): ${encodeURI(this.santitizeString(csr))} Found these: ${foundCsrs.join('\n\n')}`); throw new Error(`No matching device found for app: ${appName}`); } if (matchingIds.length > 1) { console.warn( new Error('[conn] More than one matching device found for CSR'), csr, ); } return matchingIds[0]; } private async 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 matches[1]; } const targets = await iosUtil.targets( this.config.idbPath, this.config.enablePhysicalIOS, ); if (targets.length === 0) { throw new Error('No iOS devices found'); } const deviceMatchList = targets.map(async (target) => { const isMatch = await this.iOSDeviceHasMatchingCSR( deviceCsrFilePath, target.udid, appName, csr, ); return {id: target.udid, isMatch}; }); const devices = await Promise.all(deviceMatchList); 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}`); } if (matchingIds.length > 1) { console.warn(`Multiple devices found for app: ${appName}`); } return matchingIds[0]; } private async androidDeviceHasMatchingCSR( directory: string, deviceId: string, processName: string, csr: string, ): Promise<{isMatch: boolean; foundCsr: string}> { const adbClient = await this.adb; const deviceCsr = await androidUtil.pull( adbClient, deviceId, processName, directory + csrFileName, ); // Santitize both of the string before comparation // The csr string extraction on client side return string in both way const [sanitizedDeviceCsr, sanitizedClientCsr] = [ deviceCsr.toString(), csr, ].map((s) => this.santitizeString(s)); const isMatch = sanitizedDeviceCsr === sanitizedClientCsr; return {isMatch: isMatch, foundCsr: sanitizedDeviceCsr}; } private async iOSDeviceHasMatchingCSR( directory: string, deviceId: string, bundleId: string, csr: string, ): Promise { const originalFile = this.getRelativePathInAppContainer( path.resolve(directory, csrFileName), ); const dir = await tmpDir({unsafeCleanup: true}); await iosUtil.pull( deviceId, originalFile, bundleId, dir, this.config.idbPath, ); const items = await fs.readdir(dir); if (items.length > 1) { throw new Error('Conflict in temp dir'); } if (items.length === 0) { throw new Error('Failed to pull CSR from device'); } const fileName = items[0]; const copiedFile = path.resolve(dir, fileName); console.debug('Trying to read CSR from', copiedFile); const data = await fs.readFile(copiedFile); const csrFromDevice = this.santitizeString(data.toString()); return csrFromDevice === this.santitizeString(csr); } private santitizeString(csrString: string): string { return csrString.replace(/\r/g, '').trim(); } async extractAppNameFromCSR(csr: string): Promise { const path = await this.writeToTempFile(csr); const subject = await openssl('req', { in: path, noout: true, subject: true, nameopt: true, RFC2253: false, }); await fs.unlink(path); const matches = subject.trim().match(x509SubjectCNRegex); if (!matches || matches.length < 2) { throw new Error(`Cannot extract CN from ${subject}`); } const appName = matches[1]; if (!appName.match(allowedAppNameRegex)) { throw new Error( `Disallowed app name in CSR: ${appName}. Only alphanumeric characters and '.' allowed.`, ); } return appName; } async loadSecureServerConfig(): Promise { await this.certificateSetup; return { key: await fs.readFile(serverKey), cert: await fs.readFile(serverCert), ca: await fs.readFile(caCert), requestCert: true, rejectUnauthorized: true, // can be false if necessary as we don't strictly need to verify the client }; } async ensureCertificateAuthorityExists(): Promise { if (!(await fs.pathExists(caKey))) { return this.generateCertificateAuthority(); } return this.checkCertIsValid(caCert).catch(() => this.generateCertificateAuthority(), ); } private async checkCertIsValid(filename: string): Promise { if (!(await fs.pathExists(filename))) { throw 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. try { await openssl('x509', { checkend: minCertExpiryWindowSeconds, in: filename, }); } catch (e) { console.warn( `Checking if certificate expire soon: ${filename}`, logTag, e, ); const endDateOutput = await openssl('x509', { enddate: true, in: filename, noout: true, }); 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.'); } } } private async verifyServerCertWasIssuedByCA() { const options: { [key: string]: any; } = {CAfile: caCert}; options[serverCert] = false; const output = await openssl('verify', options); 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'); } } private async generateCertificateAuthority(): Promise { if (!(await fs.pathExists(getFilePath('')))) { await fs.mkdir(getFilePath('')); } console.log('Generating new CA', logTag); await openssl('genrsa', {out: caKey, '2048': false}); await openssl('req', { new: true, x509: true, subj: caSubject, key: caKey, out: caCert, }); } private async ensureServerCertExists(): Promise { const allExist = await Promise.all([ fs.pathExists(serverKey), fs.pathExists(serverCert), fs.pathExists(caCert), ]).then((exist) => exist.every(Boolean)); if (!allExist) { return this.generateServerCertificate(); } try { await this.checkCertIsValid(serverCert); await this.verifyServerCertWasIssuedByCA(); } catch (e) { console.warn('Not all certs are valid, generating new ones', e); await this.generateServerCertificate(); } } private async generateServerCertificate(): Promise { await this.ensureCertificateAuthorityExists(); console.warn('Creating new server cert', logTag); await openssl('genrsa', {out: serverKey, '2048': false}); await openssl('req', { new: true, key: serverKey, out: serverCsr, subj: serverSubject, }); await openssl('x509', { req: true, in: serverCsr, CA: caCert, CAkey: caKey, CAcreateserial: true, CAserial: serverSrl, out: serverCert, }); } private async writeToTempFile(content: string): Promise { const path = await tmpFile(); await fs.writeFile(path, content); return path; } } function getFilePath(fileName: string): string { return path.resolve(os.homedir(), '.flipper', 'certs', fileName); }