Summary: Bumps [prettier](https://github.com/prettier/prettier) from 2.2.1 to 2.3.0. <details> <summary>Release notes</summary> <p><em>Sourced from <a href="https://github.com/prettier/prettier/releases">prettier's releases</a>.</em></p> <blockquote> <h2>2.3.0</h2> <p><a href="https://github.com/prettier/prettier/compare/2.2.1...2.3.0">diff</a></p> <p>{emoji:1f517} <a href="https://prettier.io/blog/2021/05/09/2.3.0.html">Release Notes</a></p> </blockquote> </details> <details> <summary>Changelog</summary> <p><em>Sourced from <a href="https://github.com/prettier/prettier/blob/main/CHANGELOG.md">prettier's changelog</a>.</em></p> <blockquote> <h1>2.3.0</h1> <p><a href="https://github.com/prettier/prettier/compare/2.2.1...2.3.0">diff</a></p> <p>{emoji:1f517} <a href="https://prettier.io/blog/2021/05/09/2.3.0.html">Release Notes</a></p> </blockquote> </details> <details> <summary>Commits</summary> <ul> <li><a href="2afc3b9ae6"><code>2afc3b9</code></a> Release 2.3.0</li> <li><a href="7cfa9aa89b"><code>7cfa9aa</code></a> Fix pre-commit hook setup command (<a href="https://github-redirect.dependabot.com/prettier/prettier/issues/10710">#10710</a>)</li> <li><a href="c8c02b4753"><code>c8c02b4</code></a> Build(deps-dev): Bump concurrently from 6.0.2 to 6.1.0 in /website (<a href="https://github-redirect.dependabot.com/prettier/prettier/issues/10834">#10834</a>)</li> <li><a href="6506e0f50e"><code>6506e0f</code></a> Build(deps-dev): Bump webpack-cli from 4.6.0 to 4.7.0 in /website (<a href="https://github-redirect.dependabot.com/prettier/prettier/issues/10836">#10836</a>)</li> <li><a href="69fae9c291"><code>69fae9c</code></a> Build(deps): Bump flow-parser from 0.150.0 to 0.150.1 (<a href="https://github-redirect.dependabot.com/prettier/prettier/issues/10839">#10839</a>)</li> <li><a href="164a6e2351"><code>164a6e2</code></a> Switch CLI to async (<a href="https://github-redirect.dependabot.com/prettier/prettier/issues/10804">#10804</a>)</li> <li><a href="d3e7e2f634"><code>d3e7e2f</code></a> Build(deps): Bump codecov/codecov-action from v1.4.1 to v1.5.0 (<a href="https://github-redirect.dependabot.com/prettier/prettier/issues/10833">#10833</a>)</li> <li><a href="9e09845da0"><code>9e09845</code></a> Build(deps): Bump <code>@angular/compiler</code> from 11.2.12 to 11.2.13 (<a href="https://github-redirect.dependabot.com/prettier/prettier/issues/10838">#10838</a>)</li> <li><a href="1bfab3d045"><code>1bfab3d</code></a> Build(deps-dev): Bump eslint from 7.25.0 to 7.26.0 (<a href="https://github-redirect.dependabot.com/prettier/prettier/issues/10840">#10840</a>)</li> <li><a href="387fce4ed8"><code>387fce4</code></a> Minor formatting tweaks (<a href="https://github-redirect.dependabot.com/prettier/prettier/issues/10807">#10807</a>)</li> <li>Additional commits viewable in <a href="https://github.com/prettier/prettier/compare/2.2.1...2.3.0">compare view</a></li> </ul> </details> <br /> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `dependabot rebase` will rebase this PR - `dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `dependabot merge` will merge this PR after your CI passes on it - `dependabot squash and merge` will squash and merge this PR after your CI passes on it - `dependabot cancel merge` will cancel a previously requested merge and block automerging - `dependabot reopen` will reopen this PR if it is closed - `dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> Pull Request resolved: https://github.com/facebook/flipper/pull/2300 Reviewed By: passy Differential Revision: D28323849 Pulled By: cekkaewnumchai fbshipit-source-id: 1842877ccc9a9587af7f0d9ff9432c2075c8ee22
716 lines
21 KiB
TypeScript
716 lines
21 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 Server from '../server';
|
|
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 './iOSContainerUtility';
|
|
import {reportPlatformFailures} from './metrics';
|
|
import {getAdbClient} from './adbClient';
|
|
import * as androidUtil from './androidContainerUtility';
|
|
import os from 'os';
|
|
import {Client as ADBClient} from 'adbkit';
|
|
import {Store} from '../reducers/index';
|
|
import archiver from 'archiver';
|
|
import promiseTimeout from '../utils/promiseTimeout';
|
|
import {v4 as uuid} from 'uuid';
|
|
|
|
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;
|
|
};
|
|
|
|
/*
|
|
* 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>;
|
|
store: Store;
|
|
server: Server;
|
|
|
|
constructor(server: Server, logger: Logger, store: Store) {
|
|
this.logger = logger;
|
|
this.adb = getAdbClient(store);
|
|
this.certificateSetup = reportPlatformFailures(
|
|
this.ensureServerCertExists(),
|
|
'ensureServerCertExists',
|
|
);
|
|
this.store = store;
|
|
this.server = server;
|
|
}
|
|
|
|
uploadFiles = async (zipPath: string, deviceID: string): Promise<void> => {
|
|
const buff = await fsExtra.readFile(zipPath);
|
|
const file = new File([buff], 'certs.zip');
|
|
return reportPlatformFailures(
|
|
promiseTimeout(
|
|
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 = 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');
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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 promisify(fs.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 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) => {
|
|
return 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,
|
|
this.store.getState().settingsState.idbPath,
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
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];
|
|
});
|
|
});
|
|
}
|
|
|
|
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.store.getState().settingsState.idbPath,
|
|
this.store.getState().settingsState.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];
|
|
});
|
|
});
|
|
}
|
|
|
|
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};
|
|
});
|
|
}
|
|
|
|
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.store.getState().settingsState.idbPath,
|
|
)
|
|
.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 === this.santitizeString(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);
|
|
}
|