Files
flipper/desktop/app/src/server/utils/CertificateProvider.tsx
Pascal Hartig f60429cab5 Small refactors in CertificateProvider
Summary:
- Remove `fs` dependency in favour of `fs-extra`.
- Replaced `Sync` variants with async wherever possible.
- Removed some unnecessary Promise constructions.

Reviewed By: timur-valiev

Differential Revision: D30411434

fbshipit-source-id: 9faebbc1f9fb2283fec895ce3397918bc85a6c51
2021-08-20 03:52:31 -07:00

725 lines
22 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';
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 '../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';
export type CertificateExchangeMedium = 'FS_ACCESS' | 'WWW';
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;
this.adb = getAdbClient(config);
if (isTest()) {
this.certificateSetup = Promise.reject(
new Error('Server certificates not available in test'),
);
} else {
this.certificateSetup = reportPlatformFailures(
this.ensureServerCertExists(),
'ensureServerCertExists',
);
}
this.config = config;
this.server = server;
}
private uploadFiles = async (
zipPath: string,
deviceID: string,
): Promise<void> => {
const buff = await fsExtra.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 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 = fsExtra.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 new Promise((resolve, reject) => {
fs.readFile(caCert, (err, data) => {
if (err) {
reject(err);
} else {
resolve(data.toString());
}
});
});
}
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> {
const appNamePromise = this.extractAppNameFromCSR(csr);
if (medium === 'WWW') {
const certPathExists = await fsExtra.pathExists(certFolder);
if (!certPathExists) {
await fsExtra.mkdir(certFolder);
}
return fsExtra.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 fsExtra
.writeFile(destination + filename, contents)
.catch(async (err) => {
if (os === 'iOS') {
// Writing directly to FS failed. It's probably a physical device.
const relativePathInsideApp =
this.getRelativePathInAppContainer(destination);
const appName = await appNamePromise;
const udid = await this.getTargetiOSDeviceId(
appName,
destination,
csr,
);
return await 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}`));
}
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 fsExtra.writeFile(filePath, contents);
return await iosUtil.push(
udid,
filePath,
bundleId,
destination,
this.config.idbPath,
);
}
private getTargetAndroidDeviceId(
appName: string,
deviceCsrFilePath: string,
csr: string,
): Promise<string> {
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];
});
});
}
private 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 Promise.resolve(matches[1]);
}
return iosUtil
.targets(this.config.idbPath, this.config.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];
});
});
}
private 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};
});
}
private iOSDeviceHasMatchingCSR(
directory: string,
deviceId: string,
bundleId: string,
csr: string,
): Promise<boolean> {
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.config.idbPath,
)
.then(() => dir);
})
.then((dir) => {
return fsExtra
.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 fsExtra
.readFile(copiedFile)
.then((data) => this.santitizeString(data.toString()));
});
})
.then((csrFromDevice) => csrFromDevice === this.santitizeString(csr));
}
private santitizeString(csrString: string): string {
return csrString.replace(/\r/g, '').trim();
}
extractAppNameFromCSR(csr: string): Promise<string> {
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<string>(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;
});
}
async loadSecureServerConfig(): Promise<SecureServerConfig> {
await this.certificateSetup;
return {
key: await fsExtra.readFile(serverKey),
cert: await fsExtra.readFile(serverCert),
ca: await fsExtra.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 fsExtra.pathExists(caKey))) {
return this.generateCertificateAuthority();
}
return this.checkCertIsValid(caCert).catch(() =>
this.generateCertificateAuthority(),
);
}
private async checkCertIsValid(filename: string): Promise<void> {
if (!(await fsExtra.pathExists(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.');
}
});
}
private 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');
}
});
}
private async generateCertificateAuthority(): Promise<void> {
if (!(await fsExtra.pathExists(getFilePath('')))) {
await fsExtra.mkdir(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);
}
private async ensureServerCertExists(): Promise<void> {
const allExist = Promise.all([
fsExtra.existsSync(serverKey),
fsExtra.existsSync(serverCert),
fsExtra.existsSync(caCert),
]).then((exist) => exist.every(Boolean));
if (!allExist) {
return this.generateServerCertificate();
}
return this.checkCertIsValid(serverCert)
.then(() => this.verifyServerCertWasIssuedByCA())
.catch(() => this.generateServerCertificate());
}
private generateServerCertificate(): Promise<void> {
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);
}
private writeToTempFile(content: string): Promise<string> {
return tmpFile().then((path) =>
fsExtra.writeFile(path, content).then((_) => path),
);
}
}
function getFilePath(fileName: string): string {
return path.resolve(os.homedir(), '.flipper', 'certs', fileName);
}