Add device-specific Certificate Providers
Reviewed By: mweststrate Differential Revision: D33821880 fbshipit-source-id: c75c71db4d7dc680f75cf41ba2d5dad009a5fd03
This commit is contained in:
committed by
Facebook GitHub Bot
parent
b9aeaa9339
commit
29f6d0e711
@@ -45,6 +45,7 @@ import {
|
|||||||
extractAppNameFromCSR,
|
extractAppNameFromCSR,
|
||||||
loadSecureServerConfig,
|
loadSecureServerConfig,
|
||||||
} from '../utils/certificateUtils';
|
} from '../utils/certificateUtils';
|
||||||
|
import DesktopCertificateProvider from '../devices/desktop/DesktopCertificateProvider';
|
||||||
|
|
||||||
type ClientTimestampTracker = {
|
type ClientTimestampTracker = {
|
||||||
insecureStart?: number;
|
insecureStart?: number;
|
||||||
@@ -83,7 +84,6 @@ class ServerController extends EventEmitter implements ServerEventsListener {
|
|||||||
altInsecureServer: ServerAdapter | null = null;
|
altInsecureServer: ServerAdapter | null = null;
|
||||||
browserServer: ServerAdapter | null = null;
|
browserServer: ServerAdapter | null = null;
|
||||||
|
|
||||||
certificateProvider: CertificateProvider;
|
|
||||||
connectionTracker: ConnectionTracker;
|
connectionTracker: ConnectionTracker;
|
||||||
|
|
||||||
flipperServer: FlipperServerImpl;
|
flipperServer: FlipperServerImpl;
|
||||||
@@ -93,7 +93,6 @@ class ServerController extends EventEmitter implements ServerEventsListener {
|
|||||||
constructor(flipperServer: FlipperServerImpl) {
|
constructor(flipperServer: FlipperServerImpl) {
|
||||||
super();
|
super();
|
||||||
this.flipperServer = flipperServer;
|
this.flipperServer = flipperServer;
|
||||||
this.certificateProvider = new CertificateProvider(this);
|
|
||||||
this.connectionTracker = new ConnectionTracker(this.logger);
|
this.connectionTracker = new ConnectionTracker(this.logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,9 +277,35 @@ class ServerController extends EventEmitter implements ServerEventsListener {
|
|||||||
appDirectory: string,
|
appDirectory: string,
|
||||||
medium: CertificateExchangeMedium,
|
medium: CertificateExchangeMedium,
|
||||||
): Promise<{deviceId: string}> {
|
): Promise<{deviceId: string}> {
|
||||||
|
let certificateProvider: CertificateProvider;
|
||||||
|
switch (clientQuery.os) {
|
||||||
|
case 'Android': {
|
||||||
|
certificateProvider = this.flipperServer.android.certificateProvider;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'iOS': {
|
||||||
|
certificateProvider = this.flipperServer.ios.certificateProvider;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Used by Spark AR studio (search for SkylightFlipperClient)
|
||||||
|
// See D30992087
|
||||||
|
case 'MacOS':
|
||||||
|
case 'Windows': {
|
||||||
|
certificateProvider = new DesktopCertificateProvider(
|
||||||
|
this.flipperServer.keytarManager,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new Error(
|
||||||
|
`ServerController.onProcessCSR -> os ${clientQuery.os} does not support certificate exchange.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
reportPlatformFailures(
|
reportPlatformFailures(
|
||||||
this.certificateProvider.processCertificateSigningRequest(
|
certificateProvider.processCertificateSigningRequest(
|
||||||
unsanitizedCSR,
|
unsanitizedCSR,
|
||||||
clientQuery.os,
|
clientQuery.os,
|
||||||
appDirectory,
|
appDirectory,
|
||||||
@@ -350,8 +375,7 @@ class ServerController extends EventEmitter implements ServerEventsListener {
|
|||||||
const app_name = await extractAppNameFromCSR(csr);
|
const app_name = await extractAppNameFromCSR(csr);
|
||||||
// TODO: allocate new object, kept now as is to keep changes minimal
|
// TODO: allocate new object, kept now as is to keep changes minimal
|
||||||
(query as any).device_id =
|
(query as any).device_id =
|
||||||
await this.certificateProvider.getTargetDeviceId(
|
await this.flipperServer.android.certificateProvider.getTargetDeviceId(
|
||||||
query.os,
|
|
||||||
app_name,
|
app_name,
|
||||||
csr_path,
|
csr_path,
|
||||||
csr,
|
csr,
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and 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 CertificateProvider from '../../utils/CertificateProvider';
|
||||||
|
import {Client} from 'adbkit';
|
||||||
|
import {KeytarManager} from '../../utils/keytar';
|
||||||
|
import * as androidUtil from './androidContainerUtility';
|
||||||
|
import {csrFileName} from '../../utils/certificateUtils';
|
||||||
|
|
||||||
|
const logTag = 'AndroidCertificateProvider';
|
||||||
|
|
||||||
|
export default class AndroidCertificateProvider extends CertificateProvider {
|
||||||
|
constructor(keytarManager: KeytarManager, private adb: Client) {
|
||||||
|
super(keytarManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTargetDeviceId(
|
||||||
|
appName: string,
|
||||||
|
appDirectory: string,
|
||||||
|
csr: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const devicesInAdb = await this.adb.listDevices();
|
||||||
|
if (devicesInAdb.length === 0) {
|
||||||
|
throw new Error('No Android devices found');
|
||||||
|
}
|
||||||
|
const deviceMatchList = devicesInAdb.map(async (device) => {
|
||||||
|
try {
|
||||||
|
const result = await this.androidDeviceHasMatchingCSR(
|
||||||
|
appDirectory,
|
||||||
|
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,
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async handleFSBasedDeploy(
|
||||||
|
destination: string,
|
||||||
|
filename: string,
|
||||||
|
contents: string,
|
||||||
|
csr: string,
|
||||||
|
appName: string,
|
||||||
|
) {
|
||||||
|
const deviceId = await this.getTargetDeviceId(appName, destination, csr);
|
||||||
|
await androidUtil.push(
|
||||||
|
this.adb,
|
||||||
|
deviceId,
|
||||||
|
appName,
|
||||||
|
destination + filename,
|
||||||
|
contents,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async androidDeviceHasMatchingCSR(
|
||||||
|
directory: string,
|
||||||
|
deviceId: string,
|
||||||
|
processName: string,
|
||||||
|
csr: string,
|
||||||
|
): Promise<{isMatch: boolean; foundCsr: string}> {
|
||||||
|
const deviceCsr = await androidUtil.pull(
|
||||||
|
this.adb,
|
||||||
|
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};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,10 +19,24 @@ import {
|
|||||||
getServerPortsConfig,
|
getServerPortsConfig,
|
||||||
getFlipperServerConfig,
|
getFlipperServerConfig,
|
||||||
} from '../../FlipperServerConfig';
|
} from '../../FlipperServerConfig';
|
||||||
|
import AndroidCertificateProvider from './AndroidCertificateProvider';
|
||||||
|
import {assertNotNull} from '../../comms/Utilities';
|
||||||
|
|
||||||
export class AndroidDeviceManager {
|
export class AndroidDeviceManager {
|
||||||
|
private adbClient?: ADBClient;
|
||||||
constructor(public flipperServer: FlipperServerImpl) {}
|
constructor(public flipperServer: FlipperServerImpl) {}
|
||||||
|
|
||||||
|
public get certificateProvider() {
|
||||||
|
assertNotNull(
|
||||||
|
this.adbClient,
|
||||||
|
'AndroidDeviceManager.certificateProvider -> missing adbClient',
|
||||||
|
);
|
||||||
|
return new AndroidCertificateProvider(
|
||||||
|
this.flipperServer.keytarManager,
|
||||||
|
this.adbClient,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private createDevice(
|
private createDevice(
|
||||||
adbClient: ADBClient,
|
adbClient: ADBClient,
|
||||||
device: Device,
|
device: Device,
|
||||||
@@ -177,6 +191,8 @@ export class AndroidDeviceManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.adbClient = client;
|
||||||
|
|
||||||
client
|
client
|
||||||
.trackDevices()
|
.trackDevices()
|
||||||
.then((tracker) => {
|
.then((tracker) => {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and 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 CertificateProvider from '../../utils/CertificateProvider';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
|
||||||
|
export default class DesktopCertificateProvider extends CertificateProvider {
|
||||||
|
async getTargetDeviceId(): Promise<string> {
|
||||||
|
// TODO: Could we use some real device serial? Currently, '' corresponds to a local device.
|
||||||
|
// Whats if some app connects from a remote device?
|
||||||
|
// What if two apps connect from several different remote devices?
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async handleFSBasedDeploy(
|
||||||
|
destination: string,
|
||||||
|
filename: string,
|
||||||
|
contents: string,
|
||||||
|
) {
|
||||||
|
await fs.writeFile(destination + filename, contents);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and 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 {IdbConfig} from './idbConfig';
|
||||||
|
import {KeytarManager} from '../../utils/keytar';
|
||||||
|
import CertificateProvider from '../../utils/CertificateProvider';
|
||||||
|
import iosUtil from './iOSContainerUtility';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import {promisify} from 'util';
|
||||||
|
import tmp, {DirOptions} from 'tmp';
|
||||||
|
import {csrFileName} from '../../utils/certificateUtils';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const tmpDir = promisify(tmp.dir) as (options?: DirOptions) => Promise<string>;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
export default class iOSCertificateProvider extends CertificateProvider {
|
||||||
|
constructor(keytarManager: KeytarManager, private idbConfig: IdbConfig) {
|
||||||
|
super(keytarManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTargetDeviceId(
|
||||||
|
appName: string,
|
||||||
|
appDirectory: string,
|
||||||
|
csr: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const matches = /\/Devices\/([^/]+)\//.exec(appName);
|
||||||
|
if (matches && matches.length == 2) {
|
||||||
|
// It's a simulator, the deviceId is in the filepath.
|
||||||
|
return matches[1];
|
||||||
|
}
|
||||||
|
const targets = await iosUtil.targets(
|
||||||
|
this.idbConfig.idbPath,
|
||||||
|
this.idbConfig.enablePhysicalIOS,
|
||||||
|
);
|
||||||
|
if (targets.length === 0) {
|
||||||
|
throw new Error('No iOS devices found');
|
||||||
|
}
|
||||||
|
const deviceMatchList = targets.map(async (target) => {
|
||||||
|
const isMatch = await this.iOSDeviceHasMatchingCSR(
|
||||||
|
appDirectory,
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async handleFSBasedDeploy(
|
||||||
|
destination: string,
|
||||||
|
filename: string,
|
||||||
|
contents: string,
|
||||||
|
csr: string,
|
||||||
|
appName: string,
|
||||||
|
) {
|
||||||
|
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.getTargetDeviceId(appName, destination, csr);
|
||||||
|
await this.pushFileToiOSDevice(
|
||||||
|
udid,
|
||||||
|
appName,
|
||||||
|
relativePathInsideApp,
|
||||||
|
filename,
|
||||||
|
contents,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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.idbConfig.idbPath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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});
|
||||||
|
|
||||||
|
// Workaround for idb weirdness
|
||||||
|
// Originally started at D27590885
|
||||||
|
// Re-appared at https://github.com/facebook/flipper/issues/3009
|
||||||
|
//
|
||||||
|
// People reported various workarounds. None of them worked consistently for everyone.
|
||||||
|
// Usually, the workarounds included re-building idb from source or re-installing it.
|
||||||
|
//
|
||||||
|
// The only more or less reasonable explanation I was able to find is that the final behavior depends on whether the idb_companion is local or not.
|
||||||
|
//
|
||||||
|
// This is how idb_companion sets its locality
|
||||||
|
// https://github.com/facebook/idb/blob/main/idb_companion/Server/FBIDBServiceHandler.mm#L1507
|
||||||
|
// idb sends a connection request and provides a file path to a temporary file. idb_companion checks if it can access that file.
|
||||||
|
//
|
||||||
|
// So when it is "local", the pulled filed is written directly to the destination path
|
||||||
|
// https://github.com/facebook/idb/blob/main/idb/grpc/client.py#L698
|
||||||
|
// So it is expected that the destination path ends with a file to write to.
|
||||||
|
// However, if the companion is remote, then we seem to get here https://github.com/facebook/idb/blob/71791652efa2d5e6f692cb8985ff0d26b69bf08f/idb/common/tar.py#L232
|
||||||
|
// Where we create a tree of directories and write the file stream there.
|
||||||
|
//
|
||||||
|
// So the only explanation I could come up with is that somehow, by re-installing idb and playing with the env, people could affect the locality of the idb_companion.
|
||||||
|
//
|
||||||
|
// The ultimate workaround is to try pulling the cert file without the cert name attached first, if it fails, try to append it.
|
||||||
|
try {
|
||||||
|
await iosUtil.pull(
|
||||||
|
deviceId,
|
||||||
|
originalFile,
|
||||||
|
bundleId,
|
||||||
|
dir,
|
||||||
|
this.idbConfig.idbPath,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(
|
||||||
|
'Original idb pull failed. Most likely it is a physical device that requires us to handle the dest path dirrently. Forcing a re-try with the updated dest path. See D32106952 for details. Original error:',
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
await iosUtil.pull(
|
||||||
|
deviceId,
|
||||||
|
originalFile,
|
||||||
|
bundleId,
|
||||||
|
path.join(dir, csrFileName),
|
||||||
|
this.idbConfig.idbPath,
|
||||||
|
);
|
||||||
|
console.info(
|
||||||
|
'Subsequent idb pull succeeded. Nevermind previous wranings.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
import {FlipperServerImpl} from '../../FlipperServerImpl';
|
||||||
import {getFlipperServerConfig} from '../../FlipperServerConfig';
|
import {getFlipperServerConfig} from '../../FlipperServerConfig';
|
||||||
import {IdbConfig, setIdbConfig} from './idbConfig';
|
import {IdbConfig, setIdbConfig} from './idbConfig';
|
||||||
|
import {assertNotNull} from '../../comms/Utilities';
|
||||||
|
import iOSCertificateProvider from './iOSCertificateProvider';
|
||||||
|
|
||||||
export class IOSDeviceManager {
|
export class IOSDeviceManager {
|
||||||
private portForwarders: Array<ChildProcess> = [];
|
private portForwarders: Array<ChildProcess> = [];
|
||||||
@@ -39,6 +41,17 @@ export class IOSDeviceManager {
|
|||||||
|
|
||||||
constructor(private flipperServer: FlipperServerImpl) {}
|
constructor(private flipperServer: FlipperServerImpl) {}
|
||||||
|
|
||||||
|
public get certificateProvider() {
|
||||||
|
assertNotNull(
|
||||||
|
this.idbConfig,
|
||||||
|
'IOSDeviceManager.certificateProvider -> missing idbConfig',
|
||||||
|
);
|
||||||
|
return new iOSCertificateProvider(
|
||||||
|
this.flipperServer.keytarManager,
|
||||||
|
this.idbConfig,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private forwardPort(port: number, multiplexChannelPort: number) {
|
private forwardPort(port: number, multiplexChannelPort: number) {
|
||||||
const child = childProcess.execFile(
|
const child = childProcess.execFile(
|
||||||
this.portforwardingClient,
|
this.portforwardingClient,
|
||||||
|
|||||||
@@ -7,24 +7,15 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import ServerController from '../comms/ServerController';
|
|
||||||
import {promisify} from 'util';
|
import {promisify} from 'util';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
|
import tmp from 'tmp';
|
||||||
import path from 'path';
|
|
||||||
import tmp, {DirOptions} from 'tmp';
|
|
||||||
import iosUtil from '../devices/ios/iOSContainerUtility';
|
|
||||||
import {reportPlatformFailures} from 'flipper-common';
|
import {reportPlatformFailures} from 'flipper-common';
|
||||||
import {getAdbClient} from '../devices/android/adbClient';
|
|
||||||
import * as androidUtil from '../devices/android/androidContainerUtility';
|
|
||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
import {timeout} from 'flipper-common';
|
import {timeout} from 'flipper-common';
|
||||||
import {v4 as uuid} from 'uuid';
|
import {v4 as uuid} from 'uuid';
|
||||||
import {internGraphPOSTAPIRequest} from '../fb-stubs/internRequests';
|
import {internGraphPOSTAPIRequest} from '../fb-stubs/internRequests';
|
||||||
import {getIdbConfig} from '../devices/ios/idbConfig';
|
|
||||||
import {assertNotNull} from '../comms/Utilities';
|
|
||||||
import {
|
import {
|
||||||
csrFileName,
|
|
||||||
deviceCAcertFile,
|
deviceCAcertFile,
|
||||||
deviceClientCertFile,
|
deviceClientCertFile,
|
||||||
ensureOpenSSLIsAvailable,
|
ensureOpenSSLIsAvailable,
|
||||||
@@ -32,39 +23,12 @@ import {
|
|||||||
generateClientCertificate,
|
generateClientCertificate,
|
||||||
getCACertificate,
|
getCACertificate,
|
||||||
} from './certificateUtils';
|
} from './certificateUtils';
|
||||||
import {SERVICE_FLIPPER} from './keytar';
|
import {KeytarManager, SERVICE_FLIPPER} from './keytar';
|
||||||
|
|
||||||
export type CertificateExchangeMedium = 'FS_ACCESS' | 'WWW' | 'NONE';
|
export type CertificateExchangeMedium = 'FS_ACCESS' | 'WWW' | 'NONE';
|
||||||
|
|
||||||
const tmpDir = promisify(tmp.dir) as (options?: DirOptions) => Promise<string>;
|
export default abstract class CertificateProvider {
|
||||||
|
constructor(private readonly keytarManager: KeytarManager) {}
|
||||||
const logTag = 'CertificateProvider';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 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 {
|
|
||||||
private server: ServerController;
|
|
||||||
|
|
||||||
constructor(server: ServerController) {
|
|
||||||
this.server = server;
|
|
||||||
}
|
|
||||||
|
|
||||||
private get adb() {
|
|
||||||
return getAdbClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
private get idbConfig() {
|
|
||||||
return getIdbConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
private uploadFiles = async (
|
private uploadFiles = async (
|
||||||
zipPath: string,
|
zipPath: string,
|
||||||
@@ -85,9 +49,7 @@ export default class CertificateProvider {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{timeout: 5 * 60 * 1000},
|
{timeout: 5 * 60 * 1000},
|
||||||
await this.server.flipperServer.keytarManager.retrieveToken(
|
await this.keytarManager.retrieveToken(SERVICE_FLIPPER),
|
||||||
SERVICE_FLIPPER,
|
|
||||||
),
|
|
||||||
).then(() => {}),
|
).then(() => {}),
|
||||||
'Timed out uploading Flipper certificates to WWW.',
|
'Timed out uploading Flipper certificates to WWW.',
|
||||||
),
|
),
|
||||||
@@ -110,29 +72,27 @@ export default class CertificateProvider {
|
|||||||
const certFolder = rootFolder + '/FlipperCerts/';
|
const certFolder = rootFolder + '/FlipperCerts/';
|
||||||
const certsZipPath = rootFolder + '/certs.zip';
|
const certsZipPath = rootFolder + '/certs.zip';
|
||||||
const caCert = await getCACertificate();
|
const caCert = await getCACertificate();
|
||||||
await this.deployOrStageFileForMobileApp(
|
await this.deployOrStageFileForDevice(
|
||||||
appDirectory,
|
appDirectory,
|
||||||
deviceCAcertFile,
|
deviceCAcertFile,
|
||||||
caCert,
|
caCert,
|
||||||
csr,
|
csr,
|
||||||
os,
|
|
||||||
medium,
|
medium,
|
||||||
certFolder,
|
certFolder,
|
||||||
);
|
);
|
||||||
const clientCert = await generateClientCertificate(csr);
|
const clientCert = await generateClientCertificate(csr);
|
||||||
await this.deployOrStageFileForMobileApp(
|
await this.deployOrStageFileForDevice(
|
||||||
appDirectory,
|
appDirectory,
|
||||||
deviceClientCertFile,
|
deviceClientCertFile,
|
||||||
clientCert,
|
clientCert,
|
||||||
csr,
|
csr,
|
||||||
os,
|
|
||||||
medium,
|
medium,
|
||||||
certFolder,
|
certFolder,
|
||||||
);
|
);
|
||||||
const appName = await extractAppNameFromCSR(csr);
|
const appName = await extractAppNameFromCSR(csr);
|
||||||
const deviceId =
|
const deviceId =
|
||||||
medium === 'FS_ACCESS'
|
medium === 'FS_ACCESS'
|
||||||
? await this.getTargetDeviceId(os, appName, appDirectory, csr)
|
? await this.getTargetDeviceId(appName, appDirectory, csr)
|
||||||
: uuid();
|
: uuid();
|
||||||
if (medium === 'WWW') {
|
if (medium === 'WWW') {
|
||||||
const zipPromise = new Promise((resolve, reject) => {
|
const zipPromise = new Promise((resolve, reject) => {
|
||||||
@@ -164,36 +124,17 @@ export default class CertificateProvider {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getTargetDeviceId(
|
abstract getTargetDeviceId(
|
||||||
os: string,
|
_appName: string,
|
||||||
appName: string,
|
_appDirectory: string,
|
||||||
appDirectory: string,
|
_csr: string,
|
||||||
csr: string,
|
): Promise<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 getRelativePathInAppContainer(absolutePath: string) {
|
private async deployOrStageFileForDevice(
|
||||||
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,
|
destination: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
contents: string,
|
contents: string,
|
||||||
csr: string,
|
csr: string,
|
||||||
os: string,
|
|
||||||
medium: CertificateExchangeMedium,
|
medium: CertificateExchangeMedium,
|
||||||
certFolder: string,
|
certFolder: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -213,265 +154,18 @@ export default class CertificateProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const appName = await extractAppNameFromCSR(csr);
|
const appName = await extractAppNameFromCSR(csr);
|
||||||
|
this.handleFSBasedDeploy(destination, filename, contents, csr, appName);
|
||||||
if (os === 'Android') {
|
|
||||||
assertNotNull(this.adb);
|
|
||||||
|
|
||||||
const deviceId = await this.getTargetAndroidDeviceId(
|
|
||||||
appName,
|
|
||||||
destination,
|
|
||||||
csr,
|
|
||||||
);
|
|
||||||
await androidUtil.push(
|
|
||||||
this.adb,
|
|
||||||
deviceId,
|
|
||||||
appName,
|
|
||||||
destination + filename,
|
|
||||||
contents,
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
os === 'iOS' ||
|
|
||||||
os === 'windows' ||
|
|
||||||
os == 'MacOS' /* Used by Spark AR?! */
|
|
||||||
) {
|
|
||||||
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(
|
protected abstract handleFSBasedDeploy(
|
||||||
udid: string,
|
_destination: string,
|
||||||
bundleId: string,
|
_filename: string,
|
||||||
destination: string,
|
_contents: string,
|
||||||
filename: string,
|
_csr: string,
|
||||||
contents: string,
|
_appName: string,
|
||||||
): Promise<void> {
|
): Promise<void>;
|
||||||
assertNotNull(this.idbConfig);
|
|
||||||
|
|
||||||
const dir = await tmpDir({unsafeCleanup: true});
|
protected santitizeString(csrString: string): string {
|
||||||
const filePath = path.resolve(dir, filename);
|
|
||||||
await fs.writeFile(filePath, contents);
|
|
||||||
|
|
||||||
await iosUtil.push(
|
|
||||||
udid,
|
|
||||||
filePath,
|
|
||||||
bundleId,
|
|
||||||
destination,
|
|
||||||
this.idbConfig.idbPath,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getTargetAndroidDeviceId(
|
|
||||||
appName: string,
|
|
||||||
deviceCsrFilePath: string,
|
|
||||||
csr: string,
|
|
||||||
): Promise<string> {
|
|
||||||
assertNotNull(this.adb);
|
|
||||||
|
|
||||||
const devicesInAdb = await this.adb.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,
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
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> {
|
|
||||||
assertNotNull(this.idbConfig);
|
|
||||||
|
|
||||||
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.idbConfig.idbPath,
|
|
||||||
this.idbConfig.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}> {
|
|
||||||
assertNotNull(this.adb);
|
|
||||||
|
|
||||||
const deviceCsr = await androidUtil.pull(
|
|
||||||
this.adb,
|
|
||||||
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> {
|
|
||||||
assertNotNull(this.idbConfig);
|
|
||||||
|
|
||||||
const originalFile = this.getRelativePathInAppContainer(
|
|
||||||
path.resolve(directory, csrFileName),
|
|
||||||
);
|
|
||||||
const dir = await tmpDir({unsafeCleanup: true});
|
|
||||||
|
|
||||||
// Workaround for idb weirdness
|
|
||||||
// Originally started at D27590885
|
|
||||||
// Re-appared at https://github.com/facebook/flipper/issues/3009
|
|
||||||
//
|
|
||||||
// People reported various workarounds. None of them worked consistently for everyone.
|
|
||||||
// Usually, the workarounds included re-building idb from source or re-installing it.
|
|
||||||
//
|
|
||||||
// The only more or less reasonable explanation I was able to find is that the final behavior depends on whether the idb_companion is local or not.
|
|
||||||
//
|
|
||||||
// This is how idb_companion sets its locality
|
|
||||||
// https://github.com/facebook/idb/blob/main/idb_companion/Server/FBIDBServiceHandler.mm#L1507
|
|
||||||
// idb sends a connection request and provides a file path to a temporary file. idb_companion checks if it can access that file.
|
|
||||||
//
|
|
||||||
// So when it is "local", the pulled filed is written directly to the destination path
|
|
||||||
// https://github.com/facebook/idb/blob/main/idb/grpc/client.py#L698
|
|
||||||
// So it is expected that the destination path ends with a file to write to.
|
|
||||||
// However, if the companion is remote, then we seem to get here https://github.com/facebook/idb/blob/71791652efa2d5e6f692cb8985ff0d26b69bf08f/idb/common/tar.py#L232
|
|
||||||
// Where we create a tree of directories and write the file stream there.
|
|
||||||
//
|
|
||||||
// So the only explanation I could come up with is that somehow, by re-installing idb and playing with the env, people could affect the locality of the idb_companion.
|
|
||||||
//
|
|
||||||
// The ultimate workaround is to try pulling the cert file without the cert name attached first, if it fails, try to append it.
|
|
||||||
try {
|
|
||||||
await iosUtil.pull(
|
|
||||||
deviceId,
|
|
||||||
originalFile,
|
|
||||||
bundleId,
|
|
||||||
dir,
|
|
||||||
this.idbConfig.idbPath,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(
|
|
||||||
'Original idb pull failed. Most likely it is a physical device that requires us to handle the dest path dirrently. Forcing a re-try with the updated dest path. See D32106952 for details. Original error:',
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
await iosUtil.pull(
|
|
||||||
deviceId,
|
|
||||||
originalFile,
|
|
||||||
bundleId,
|
|
||||||
path.join(dir, csrFileName),
|
|
||||||
this.idbConfig.idbPath,
|
|
||||||
);
|
|
||||||
console.info(
|
|
||||||
'Subsequent idb pull succeeded. Nevermind previous wranings.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
return csrString.replace(/\r/g, '').trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user