/** * 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 LogManager from '../fb-stubs/Logger'; import {RecurringError} from './errors'; import {promisify} from 'util'; const fs = require('fs'); const adb = require('adbkit-fb'); import { openssl, isInstalled as opensslInstalled, } from './openssl-wrapper-with-promises'; const path = require('path'); const tmpFile = promisify(require('tmp').file); // Desktop file paths const os = require('os'); const caKey = getFilePath('ca.key'); const caCert = getFilePath('ca.crt'); const serverKey = getFilePath('server.key'); const serverCsr = getFilePath('server.csr'); 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 appNotDebuggableRegex = /debuggable/; const allowedAppNameRegex = /^[a-zA-Z0-9.\-]+$/; const operationNotPermittedRegex = /not permitted/; 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 sonar 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 sonar CA to sign a client certificate which it * deploys securely to the app. * It also deploys the sonar CA cert to the app. * The app can trust a server if and only if it has a certificate signed by the * sonar CA. */ export default class CertificateProvider { logger: LogManager; adb: any; certificateSetup: Promise; server: Server; constructor(server: Server, logger: LogManager) { this.logger = logger; this.adb = adb.createClient(); this.certificateSetup = this.ensureServerCertExists(); this.server = server; } processCertificateSigningRequest( csr: string, os: string, appDirectory: string, ): Promise { 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, ), ); } 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, }); }); } deployFileToMobileApp( destination: string, filename: string, contents: string, csr: string, os: string, ): Promise { if (os === 'Android') { const appNamePromise = this.extractAppNameFromCSR(csr); const deviceIdPromise = appNamePromise.then(app => this.getTargetDeviceId(app, destination, csr), ); return Promise.all([deviceIdPromise, appNamePromise]).then( ([deviceId, appName]) => this.pushFileToAndroidDevice( deviceId, appName, destination + filename, contents, ), ); } if (os === 'iOS' || os === 'windows') { return new Promise((resolve, reject) => { fs.writeFile(destination + filename, contents, err => { if (err) { reject( `Invalid appDirectory recieved from ${os} device: ${destination}: ` + err.toString(), ); } else { resolve(); } }); }); } return Promise.reject(new RecurringError(`Unsupported device os: ${os}`)); } getTargetDeviceId( appName: string, deviceCsrFilePath: string, csr: string, ): Promise { const client = adb.createClient(); return client.listDevices().then((devices: Array<{id: string}>) => { const deviceMatchList = devices.map(device => // To find out which device requested the cert, search them // all for a matching csr file. // It's not important to keep these secret from other apps. // Just need to make sure each app can find its own one. this.androidDeviceHasMatchingCSR( deviceCsrFilePath, device.id, appName, csr, ) .then(isMatch => { return {id: device.id, isMatch}; }) .catch(e => { console.error( `Unable to check for matching CSR in ${device.id}:${appName}`, logTag, ); return {id: device.id, isMatch: false}; }), ); return Promise.all(deviceMatchList).then(devices => { const matchingIds = devices.filter(m => m.isMatch).map(m => m.id); if (matchingIds.length == 0) { throw new RecurringError( `No matching device found for app: ${appName}`, ); } return matchingIds[0]; }); }); } androidDeviceHasMatchingCSR( directory: string, deviceId: string, processName: string, csr: string, ): Promise { return this.executeCommandOnAndroid( deviceId, processName, `cat ${directory + csrFileName}`, ) .then(deviceCsr => { return ( deviceCsr .toString() .replace(/\r/g, '') .trim() === csr.replace(/\r/g, '').trim() ); }) .catch(err => { console.error(err, logTag); return false; }); } pushFileToAndroidDevice( deviceId: string, app: string, filename: string, contents: string, ): Promise { console.debug(`Deploying ${filename} to ${deviceId}:${app}`, logTag); return this.executeCommandOnAndroid( deviceId, app, `echo "${contents}" > ${filename} && chmod 600 ${filename}`, ).then(output => undefined); } executeCommandOnAndroid( deviceId: string, user: string, command: string, ): Promise { if (!user.match(allowedAppNameRegex)) { return Promise.reject( new RecurringError(`Disallowed run-as user: ${user}`), ); } if (command.match(/[']/)) { return Promise.reject( new RecurringError(`Disallowed escaping command: ${command}`), ); } return this.adb .shell(deviceId, `echo '${command}' | run-as '${user}'`) .then(adb.util.readAll) .then(buffer => buffer.toString()) .then(output => { if (output.match(appNotDebuggableRegex)) { const e = new RecurringError( `Android app ${user} is not debuggable. To use it with sonar, add android:debuggable="true" to the application section of AndroidManifest.xml`, ); this.server.emit('error', e); throw e; } if (output.toLowerCase().match(operationNotPermittedRegex)) { const e = new RecurringError( `Your android device (${deviceId}) does not support the adb shell run-as command. We're tracking this at https://github.com/facebook/Sonar/issues/92`, ); this.server.emit('error', e); throw e; } return output; }); } 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 RecurringError(`Cannot extract CN from ${subject}`); } return matches[1]; }) .then(appName => { if (!appName.match(allowedAppNameRegex)) { throw new RecurringError( `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(e => this.generateCertificateAuthority(), ); } checkCertIsValid(filename: string): Promise { if (!fs.existsSync(filename)) { return Promise.reject(); } // 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(output => 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 = {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(e => 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, out: serverCert, }), ) .then(_ => undefined); } writeToTempFile(content: string): Promise { return tmpFile().then((path, fd, cleanupCallback) => promisify(fs.writeFile)(path, content).then(_ => path), ); } } function getFilePath(fileName: string): string { return path.resolve(os.homedir(), '.flipper', 'certs', fileName); }