/** * 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'; import fsExtra 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 './iOSContainerUtility'; import {reportPlatformFailures} from './metrics'; import {getAdbClient} from './adbClient'; import * as androidUtil from './androidContainerUtility'; import os from 'os'; import {Client as ADBClient} from 'adbkit'; import {Store} from '../reducers/index'; import archiver from 'archiver'; import promiseTimeout from '../utils/promiseTimeout'; import {v4 as uuid} from 'uuid'; export type CertificateExchangeMedium = 'FS_ACCESS' | 'WWW'; 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; }; /* * 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; store: Store; server: ServerController; constructor(server: ServerController, logger: Logger, store: Store) { this.logger = logger; this.adb = getAdbClient(store); this.certificateSetup = reportPlatformFailures( this.ensureServerCertExists(), 'ensureServerCertExists', ); this.store = store; this.server = server; } uploadFiles = async (zipPath: string, deviceID: string): Promise => { const buff = await fsExtra.readFile(zipPath); const file = new File([buff], 'certs.zip'); return reportPlatformFailures( promiseTimeout( 5 * 60 * 1000, internGraphPOSTAPIRequest('flipper/certificates', { certificate_zip: file, device_id: deviceID, }), 'Timed out uploading Flipper export.', ), 'uploadCertificates', ).catch((e) => console.error(`Failed to upload certificates due to ${e}`)); }; 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'; return this.certificateSetup .then((_) => this.getCACertificate()) .then((caCert) => this.deployOrStageFileForMobileApp( appDirectory, deviceCAcertFile, caCert, csr, os, medium, certFolder, ), ) .then((_) => this.generateClientCertificate(csr)) .then((clientCert) => this.deployOrStageFileForMobileApp( appDirectory, deviceClientCertFile, clientCert, csr, os, medium, certFolder, ), ) .then((_) => { return this.extractAppNameFromCSR(csr); }) .then((appName) => { if (medium === 'FS_ACCESS') { return this.getTargetDeviceId(os, appName, appDirectory, csr); } else { return uuid(); } }) .then(async (deviceId) => { 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'); } 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); } async deployOrStageFileForMobileApp( destination: string, filename: string, contents: string, csr: string, os: string, medium: CertificateExchangeMedium, certFolder: string, ): Promise { const appNamePromise = this.extractAppNameFromCSR(csr); if (medium === 'WWW') { const certPathExists = await fsExtra.pathExists(certFolder); if (!certPathExists) { await fsExtra.mkdir(certFolder); } return promisify(fs.writeFile)(certFolder + filename, contents).catch( (e) => { throw new Error( `Failed to write ${filename} to temporary folder. Error: ${e}`, ); }, ); } if (os === 'Android') { const deviceIdPromise = appNamePromise.then((app) => this.getTargetAndroidDeviceId(app, destination, csr), ); return Promise.all([deviceIdPromise, appNamePromise, this.adb]).then( ([deviceId, appName, adbClient]) => androidUtil.push( adbClient, 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) => { return 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, this.store.getState().settingsState.idbPath, ), ); }); } 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((result) => { 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}; }), ); 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; } 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('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( this.store.getState().settingsState.idbPath, this.store.getState().settingsState.enablePhysicalIOS, ) .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<{isMatch: boolean; foundCsr: string}> { return this.adb .then((adbClient) => androidUtil.pull( adbClient, deviceId, processName, directory + csrFileName, ), ) .then((deviceCsr) => { // 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}; }); } 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, path.join(dir, csrFileName), this.store.getState().settingsState.idbPath, ) .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 === this.santitizeString(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); }