Summary: It appears the certificate provider is generating a deviceId which is then sent down to the flipper client to store (under CONNECTION_CONFIG_FILE). For MacOS, this ends up being "unknown". Yet when we attempt to connect to the flipper app, it expects empty string. This diff ensures that for MacOS, we're not sending down "unknown" for the client to store into its config file. Reviewed By: danielbuechele Differential Revision: D17190130 fbshipit-source-id: e0d9825f1f0330a03bcb3cf8d606d5bd9fcc1d7f
586 lines
17 KiB
TypeScript
586 lines
17 KiB
TypeScript
/**
|
|
* 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 {Logger} from '../fb-interfaces/Logger';
|
|
import Server from '../server';
|
|
import {promisify} from 'util';
|
|
import fs from 'fs';
|
|
import {
|
|
openssl,
|
|
isInstalled as opensslInstalled,
|
|
} from './openssl-wrapper-with-promises';
|
|
import path from 'path';
|
|
import tmp, {DirOptions, FileOptions} from 'tmp';
|
|
import iosUtil from '../fb-stubs/iOSContainerUtility';
|
|
import {reportPlatformFailures} from './metrics';
|
|
import {getAdbClient} from './adbClient';
|
|
import * as androidUtil from './androidContainerUtility';
|
|
import os from 'os';
|
|
|
|
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 = /^[a-zA-Z0-9._\-]+$/;
|
|
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<any>;
|
|
certificateSetup: Promise<void>;
|
|
server: Server;
|
|
|
|
constructor(server: Server, logger: Logger) {
|
|
this.logger = logger;
|
|
this.adb = getAdbClient();
|
|
this.certificateSetup = reportPlatformFailures(
|
|
this.ensureServerCertExists(),
|
|
'ensureServerCertExists',
|
|
);
|
|
this.server = server;
|
|
}
|
|
|
|
processCertificateSigningRequest(
|
|
unsanitizedCsr: string,
|
|
os: string,
|
|
appDirectory: string,
|
|
): Promise<{deviceId: string}> {
|
|
const csr = this.santitizeString(unsanitizedCsr);
|
|
if (csr === '') {
|
|
return Promise.reject(new Error(`Received empty CSR from ${os} device`));
|
|
}
|
|
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,
|
|
),
|
|
)
|
|
.then(_ => this.extractAppNameFromCSR(csr))
|
|
.then(appName => this.getTargetDeviceId(os, appName, appDirectory, csr))
|
|
.then(deviceId => {
|
|
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');
|
|
}
|
|
|
|
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<string> {
|
|
return new Promise((resolve, reject) => {
|
|
fs.readFile(caCert, (err, data) => {
|
|
if (err) {
|
|
reject(err);
|
|
} else {
|
|
resolve(data.toString());
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
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,
|
|
});
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
deployFileToMobileApp(
|
|
destination: string,
|
|
filename: string,
|
|
contents: string,
|
|
csr: string,
|
|
os: string,
|
|
): Promise<void> {
|
|
const appNamePromise = this.extractAppNameFromCSR(csr);
|
|
|
|
if (os === 'Android') {
|
|
const deviceIdPromise = appNamePromise.then(app =>
|
|
this.getTargetAndroidDeviceId(app, destination, csr),
|
|
);
|
|
return Promise.all([deviceIdPromise, appNamePromise]).then(
|
|
([deviceId, appName]) =>
|
|
androidUtil.push(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 =>
|
|
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<void> {
|
|
return tmpDir({unsafeCleanup: true}).then(dir => {
|
|
const filePath = path.resolve(dir, filename);
|
|
promisify(fs.writeFile)(filePath, contents).then(() =>
|
|
iosUtil.push(udid, filePath, bundleId, destination),
|
|
);
|
|
});
|
|
}
|
|
|
|
getTargetAndroidDeviceId(
|
|
appName: string,
|
|
deviceCsrFilePath: string,
|
|
csr: string,
|
|
): Promise<string> {
|
|
return this.adb
|
|
.then(client => client.listDevices())
|
|
.then((devices: Array<{id: string}>) => {
|
|
if (devices.length === 0) {
|
|
throw new Error('No Android devices found');
|
|
}
|
|
const deviceMatchList = devices.map(device =>
|
|
this.androidDeviceHasMatchingCSR(
|
|
deviceCsrFilePath,
|
|
device.id,
|
|
appName,
|
|
csr,
|
|
)
|
|
.then(isMatch => {
|
|
return {id: device.id, isMatch, error: null};
|
|
})
|
|
.catch(e => {
|
|
console.error(
|
|
`Unable to check for matching CSR in ${device.id}:${appName}`,
|
|
logTag,
|
|
);
|
|
return {id: device.id, isMatch: false, 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;
|
|
}
|
|
throw new Error(`No matching device found for app: ${appName}`);
|
|
}
|
|
if (matchingIds.length > 1) {
|
|
console.error(
|
|
new Error('More than one matching device found for CSR'),
|
|
csr,
|
|
);
|
|
}
|
|
return matchingIds[0];
|
|
});
|
|
});
|
|
}
|
|
|
|
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().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<boolean> {
|
|
return androidUtil
|
|
.pull(deviceId, processName, directory + csrFileName)
|
|
.then(deviceCsr => {
|
|
return this.santitizeString(deviceCsr.toString()) === csr;
|
|
});
|
|
}
|
|
|
|
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, dir)
|
|
.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 === csr);
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|
|
|
|
loadSecureServerConfig(): Promise<SecureServerConfig> {
|
|
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<void> {
|
|
if (!fs.existsSync(caKey)) {
|
|
return this.generateCertificateAuthority();
|
|
}
|
|
return this.checkCertIsValid(caCert).catch(() =>
|
|
this.generateCertificateAuthority(),
|
|
);
|
|
}
|
|
|
|
checkCertIsValid(filename: string): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<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);
|
|
}
|
|
|
|
writeToTempFile(content: string): Promise<string> {
|
|
return tmpFile().then(path =>
|
|
promisify(fs.writeFile)(path, content).then(_ => path),
|
|
);
|
|
}
|
|
}
|
|
|
|
function getFilePath(fileName: string): string {
|
|
return path.resolve(os.homedir(), '.flipper', 'certs', fileName);
|
|
}
|