Files
flipper/desktop/app/src/server/utils/CertificateProvider.tsx
Michel Weststrate 94a7b4360d add error handing to ADB client creation
Summary:
Noticed there are uncaught exception if adb cannot be found, that don't make it to the UI, but just cause no devices to be detected.

While testing, found that the unsafeClient fallback we use doesn't find the devices either. So it is unclear to me if it is helping in any case, and figured we'd better provide early signal to the user to set up adb correctly, rather than jumping through hoops and trying to work without it.

Changelog: Handle the absence of ADB better

Reviewed By: timur-valiev

Differential Revision: D30957220

fbshipit-source-id: 4d9a89bffefa96d3861d3f26224b4c74c19abd37
2021-09-15 14:46:11 -07:00

668 lines
20 KiB
TypeScript

/**
* 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<string>;
const tmpDir = promisify(tmp.dir) as (options?: DirOptions) => Promise<string>;
// 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<ADBClient>;
certificateSetup: Promise<void>;
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<ADBClient>;
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<void> => {
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<string> {
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<string> {
return fs.readFile(caCert, 'utf-8');
}
private generateClientCertificate(csr: string): Promise<string> {
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<void> {
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<void> {
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<string> {
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<string> {
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<boolean> {
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<string> {
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<SecureServerConfig> {
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<void> {
if (!(await fs.pathExists(caKey))) {
return this.generateCertificateAuthority();
}
return this.checkCertIsValid(caCert).catch(() =>
this.generateCertificateAuthority(),
);
}
private async checkCertIsValid(filename: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<string> {
const path = await tmpFile();
await fs.writeFile(path, content);
return path;
}
}
function getFilePath(fileName: string): string {
return path.resolve(os.homedir(), '.flipper', 'certs', fileName);
}