Extract certificate utils
Summary: Extract utilities for certificate server-side certificate handling Reviewed By: lawrencelomax Differential Revision: D33820263 fbshipit-source-id: 21f1a9ed5f3b83b8350151bdf6d8862aa0b18e8f
This commit is contained in:
committed by
Facebook GitHub Bot
parent
e686766e5f
commit
b1f19ecd68
@@ -7,10 +7,7 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {CertificateExchangeMedium} from '../utils/CertificateProvider';
|
||||||
CertificateExchangeMedium,
|
|
||||||
SecureServerConfig,
|
|
||||||
} from '../utils/CertificateProvider';
|
|
||||||
import {ClientConnection} from './ClientConnection';
|
import {ClientConnection} from './ClientConnection';
|
||||||
import {transformCertificateExchangeMediumToType} from './Utilities';
|
import {transformCertificateExchangeMediumToType} from './Utilities';
|
||||||
import {
|
import {
|
||||||
@@ -18,6 +15,7 @@ import {
|
|||||||
ClientQuery,
|
ClientQuery,
|
||||||
SignCertificateMessage,
|
SignCertificateMessage,
|
||||||
} from 'flipper-common';
|
} from 'flipper-common';
|
||||||
|
import {SecureServerConfig} from '../utils/certificateUtils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ClientCsrQuery defines a client query with CSR
|
* ClientCsrQuery defines a client query with CSR
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ import {
|
|||||||
getServerPortsConfig,
|
getServerPortsConfig,
|
||||||
getFlipperServerConfig,
|
getFlipperServerConfig,
|
||||||
} from '../FlipperServerConfig';
|
} from '../FlipperServerConfig';
|
||||||
|
import {
|
||||||
|
extractAppNameFromCSR,
|
||||||
|
loadSecureServerConfig,
|
||||||
|
} from '../utils/certificateUtils';
|
||||||
|
|
||||||
type ClientTimestampTracker = {
|
type ClientTimestampTracker = {
|
||||||
insecureStart?: number;
|
insecureStart?: number;
|
||||||
@@ -115,7 +119,7 @@ class ServerController extends EventEmitter implements ServerEventsListener {
|
|||||||
}
|
}
|
||||||
const {insecure, secure} = getServerPortsConfig().serverPorts;
|
const {insecure, secure} = getServerPortsConfig().serverPorts;
|
||||||
|
|
||||||
const options = await this.certificateProvider.loadSecureServerConfig();
|
const options = await loadSecureServerConfig();
|
||||||
|
|
||||||
console.info('[conn] secure server listening at port: ', secure);
|
console.info('[conn] secure server listening at port: ', secure);
|
||||||
this.secureServer = await createServer(secure, this, options);
|
this.secureServer = await createServer(secure, this, options);
|
||||||
@@ -343,9 +347,7 @@ class ServerController extends EventEmitter implements ServerEventsListener {
|
|||||||
|
|
||||||
// For Android, device id might change
|
// For Android, device id might change
|
||||||
if (csr_path && csr && query.os === 'Android') {
|
if (csr_path && csr && query.os === 'Android') {
|
||||||
const app_name = await this.certificateProvider.extractAppNameFromCSR(
|
const app_name = await extractAppNameFromCSR(csr);
|
||||||
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.certificateProvider.getTargetDeviceId(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {SecureServerConfig} from '../utils/CertificateProvider';
|
import {SecureServerConfig} from '../utils/certificateUtils';
|
||||||
import ServerAdapter, {ServerEventsListener} from './ServerAdapter';
|
import ServerAdapter, {ServerEventsListener} from './ServerAdapter';
|
||||||
import ServerRSocket from './ServerRSocket';
|
import ServerRSocket from './ServerRSocket';
|
||||||
import SecureServerWebSocket from './SecureServerWebSocket';
|
import SecureServerWebSocket from './SecureServerWebSocket';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {SecureServerConfig} from '../utils/CertificateProvider';
|
import {SecureServerConfig} from '../utils/certificateUtils';
|
||||||
import ServerAdapter, {
|
import ServerAdapter, {
|
||||||
SecureClientQuery,
|
SecureClientQuery,
|
||||||
ServerEventsListener,
|
ServerEventsListener,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
parseMessageToJson,
|
parseMessageToJson,
|
||||||
verifyClientQueryComesFromCertExchangeSupportedOS,
|
verifyClientQueryComesFromCertExchangeSupportedOS,
|
||||||
} from './Utilities';
|
} from './Utilities';
|
||||||
import {SecureServerConfig} from '../utils/CertificateProvider';
|
import {SecureServerConfig} from '../utils/certificateUtils';
|
||||||
import {Server} from 'net';
|
import {Server} from 'net';
|
||||||
import {serializeError} from 'serialize-error';
|
import {serializeError} from 'serialize-error';
|
||||||
import {WSCloseCode} from '../utils/WSCloseCode';
|
import {WSCloseCode} from '../utils/WSCloseCode';
|
||||||
|
|||||||
@@ -11,64 +11,34 @@ import ServerController from '../comms/ServerController';
|
|||||||
import {promisify} from 'util';
|
import {promisify} from 'util';
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
|
|
||||||
import {
|
|
||||||
openssl,
|
|
||||||
isInstalled as opensslInstalled,
|
|
||||||
} from './openssl-wrapper-with-promises';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import tmp, {DirOptions, FileOptions} from 'tmp';
|
import tmp, {DirOptions} from 'tmp';
|
||||||
import iosUtil from '../devices/ios/iOSContainerUtility';
|
import iosUtil from '../devices/ios/iOSContainerUtility';
|
||||||
import {reportPlatformFailures} from 'flipper-common';
|
import {reportPlatformFailures} from 'flipper-common';
|
||||||
import {getAdbClient} from '../devices/android/adbClient';
|
import {getAdbClient} from '../devices/android/adbClient';
|
||||||
import * as androidUtil from '../devices/android/androidContainerUtility';
|
import * as androidUtil from '../devices/android/androidContainerUtility';
|
||||||
import os from 'os';
|
|
||||||
import archiver from 'archiver';
|
import archiver from 'archiver';
|
||||||
import {timeout, isTest} 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 {SERVICE_FLIPPER} from '../FlipperServerImpl';
|
import {SERVICE_FLIPPER} from '../FlipperServerImpl';
|
||||||
import {getIdbConfig} from '../devices/ios/idbConfig';
|
import {getIdbConfig} from '../devices/ios/idbConfig';
|
||||||
import {assertNotNull} from '../comms/Utilities';
|
import {assertNotNull} from '../comms/Utilities';
|
||||||
|
import {
|
||||||
|
csrFileName,
|
||||||
|
deviceCAcertFile,
|
||||||
|
deviceClientCertFile,
|
||||||
|
ensureOpenSSLIsAvailable,
|
||||||
|
extractAppNameFromCSR,
|
||||||
|
generateClientCertificate,
|
||||||
|
getCACertificate,
|
||||||
|
} from './certificateUtils';
|
||||||
|
|
||||||
export type CertificateExchangeMedium = 'FS_ACCESS' | 'WWW' | 'NONE';
|
export type CertificateExchangeMedium = 'FS_ACCESS' | 'WWW' | 'NONE';
|
||||||
|
|
||||||
const tmpFile = promisify(tmp.file) as (
|
|
||||||
options?: FileOptions,
|
|
||||||
) => Promise<string>;
|
|
||||||
const tmpDir = promisify(tmp.dir) as (options?: DirOptions) => Promise<string>;
|
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';
|
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
|
* This class is responsible for generating and deploying server and client
|
||||||
@@ -125,17 +95,6 @@ export default class CertificateProvider {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
async certificateSetup() {
|
|
||||||
if (isTest()) {
|
|
||||||
throw new Error('Server certificates not available in test');
|
|
||||||
} else {
|
|
||||||
await reportPlatformFailures(
|
|
||||||
this.ensureServerCertExists(),
|
|
||||||
'ensureServerCertExists',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async processCertificateSigningRequest(
|
async processCertificateSigningRequest(
|
||||||
unsanitizedCsr: string,
|
unsanitizedCsr: string,
|
||||||
os: string,
|
os: string,
|
||||||
@@ -146,11 +105,11 @@ export default class CertificateProvider {
|
|||||||
if (csr === '') {
|
if (csr === '') {
|
||||||
return Promise.reject(new Error(`Received empty CSR from ${os} device`));
|
return Promise.reject(new Error(`Received empty CSR from ${os} device`));
|
||||||
}
|
}
|
||||||
await this.ensureOpenSSLIsAvailable();
|
await ensureOpenSSLIsAvailable();
|
||||||
const rootFolder = await promisify(tmp.dir)();
|
const rootFolder = await promisify(tmp.dir)();
|
||||||
const certFolder = rootFolder + '/FlipperCerts/';
|
const certFolder = rootFolder + '/FlipperCerts/';
|
||||||
const certsZipPath = rootFolder + '/certs.zip';
|
const certsZipPath = rootFolder + '/certs.zip';
|
||||||
const caCert = await this.getCACertificate();
|
const caCert = await getCACertificate();
|
||||||
await this.deployOrStageFileForMobileApp(
|
await this.deployOrStageFileForMobileApp(
|
||||||
appDirectory,
|
appDirectory,
|
||||||
deviceCAcertFile,
|
deviceCAcertFile,
|
||||||
@@ -160,7 +119,7 @@ export default class CertificateProvider {
|
|||||||
medium,
|
medium,
|
||||||
certFolder,
|
certFolder,
|
||||||
);
|
);
|
||||||
const clientCert = await this.generateClientCertificate(csr);
|
const clientCert = await generateClientCertificate(csr);
|
||||||
await this.deployOrStageFileForMobileApp(
|
await this.deployOrStageFileForMobileApp(
|
||||||
appDirectory,
|
appDirectory,
|
||||||
deviceClientCertFile,
|
deviceClientCertFile,
|
||||||
@@ -170,7 +129,7 @@ export default class CertificateProvider {
|
|||||||
medium,
|
medium,
|
||||||
certFolder,
|
certFolder,
|
||||||
);
|
);
|
||||||
const appName = await this.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(os, appName, appDirectory, csr)
|
||||||
@@ -221,33 +180,6 @@ export default class CertificateProvider {
|
|||||||
return Promise.resolve('unknown');
|
return Promise.resolve('unknown');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureOpenSSLIsAvailable(): Promise<void> {
|
|
||||||
if (!(await opensslInstalled())) {
|
|
||||||
throw new Error(
|
|
||||||
"It looks like you don't have OpenSSL installed. Please install it to continue.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCACertificate(): Promise<string> {
|
|
||||||
return fs.readFile(caCert, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateClientCertificate(csr: string): Promise<string> {
|
|
||||||
console.debug('Creating new client cert', logTag);
|
|
||||||
|
|
||||||
return this.writeToTempFile(csr).then((path) => {
|
|
||||||
return openssl('x509', {
|
|
||||||
req: true,
|
|
||||||
in: path,
|
|
||||||
CA: caCert,
|
|
||||||
CAkey: caKey,
|
|
||||||
CAcreateserial: true,
|
|
||||||
CAserial: serverSrl,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getRelativePathInAppContainer(absolutePath: string) {
|
private getRelativePathInAppContainer(absolutePath: string) {
|
||||||
const matches = /Application\/[^/]+\/(.*)/.exec(absolutePath);
|
const matches = /Application\/[^/]+\/(.*)/.exec(absolutePath);
|
||||||
if (matches && matches.length === 2) {
|
if (matches && matches.length === 2) {
|
||||||
@@ -280,7 +212,7 @@ export default class CertificateProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const appName = await this.extractAppNameFromCSR(csr);
|
const appName = await extractAppNameFromCSR(csr);
|
||||||
|
|
||||||
if (os === 'Android') {
|
if (os === 'Android') {
|
||||||
assertNotNull(this.adb);
|
assertNotNull(this.adb);
|
||||||
@@ -542,168 +474,4 @@ export default class CertificateProvider {
|
|||||||
private santitizeString(csrString: string): string {
|
private santitizeString(csrString: string): string {
|
||||||
return csrString.replace(/\r/g, '').trim();
|
return csrString.replace(/\r/g, '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async extractAppNameFromCSR(csr: string): Promise<string> {
|
|
||||||
const path = await this.writeToTempFile(csr);
|
|
||||||
const subject = await openssl('req', {
|
|
||||||
in: path,
|
|
||||||
noout: true,
|
|
||||||
subject: true,
|
|
||||||
nameopt: true,
|
|
||||||
RFC2253: false,
|
|
||||||
});
|
|
||||||
await fs.unlink(path);
|
|
||||||
const matches = subject.trim().match(x509SubjectCNRegex);
|
|
||||||
if (!matches || matches.length < 2) {
|
|
||||||
throw new Error(`Cannot extract CN from ${subject}`);
|
|
||||||
}
|
|
||||||
const appName = matches[1];
|
|
||||||
if (!appName.match(allowedAppNameRegex)) {
|
|
||||||
throw new Error(
|
|
||||||
`Disallowed app name in CSR: ${appName}. Only alphanumeric characters and '.' allowed.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return appName;
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadSecureServerConfig(): Promise<SecureServerConfig> {
|
|
||||||
await this.ensureOpenSSLIsAvailable();
|
|
||||||
await this.certificateSetup();
|
|
||||||
return {
|
|
||||||
key: await fs.readFile(serverKey),
|
|
||||||
cert: await fs.readFile(serverCert),
|
|
||||||
ca: await fs.readFile(caCert),
|
|
||||||
requestCert: true,
|
|
||||||
rejectUnauthorized: true, // can be false if necessary as we don't strictly need to verify the client
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async ensureCertificateAuthorityExists(): Promise<void> {
|
|
||||||
if (!(await fs.pathExists(caKey))) {
|
|
||||||
return this.generateCertificateAuthority();
|
|
||||||
}
|
|
||||||
return this.checkCertIsValid(caCert).catch(() =>
|
|
||||||
this.generateCertificateAuthority(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async checkCertIsValid(filename: string): Promise<void> {
|
|
||||||
if (!(await fs.pathExists(filename))) {
|
|
||||||
throw new Error(`${filename} does not exist`);
|
|
||||||
}
|
|
||||||
// openssl checkend is a nice feature but it only checks for certificates
|
|
||||||
// expiring in the future, not those that have already expired.
|
|
||||||
// So we need a separate check for certificates that have already expired
|
|
||||||
// but since this involves parsing date outputs from openssl, which is less
|
|
||||||
// reliable, keeping both checks for safety.
|
|
||||||
try {
|
|
||||||
await openssl('x509', {
|
|
||||||
checkend: minCertExpiryWindowSeconds,
|
|
||||||
in: filename,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(
|
|
||||||
`Checking if certificate expire soon: ${filename}`,
|
|
||||||
logTag,
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
const endDateOutput = await openssl('x509', {
|
|
||||||
enddate: true,
|
|
||||||
in: filename,
|
|
||||||
noout: true,
|
|
||||||
});
|
|
||||||
const dateString = endDateOutput.trim().split('=')[1].trim();
|
|
||||||
const expiryDate = Date.parse(dateString);
|
|
||||||
if (isNaN(expiryDate)) {
|
|
||||||
console.error(
|
|
||||||
'Unable to parse certificate expiry date: ' + endDateOutput,
|
|
||||||
);
|
|
||||||
throw new Error(
|
|
||||||
'Cannot parse certificate expiry date. Assuming it has expired.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (expiryDate <= Date.now() + minCertExpiryWindowSeconds * 1000) {
|
|
||||||
throw new Error('Certificate has expired or will expire soon.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async verifyServerCertWasIssuedByCA() {
|
|
||||||
const options: {
|
|
||||||
[key: string]: any;
|
|
||||||
} = {CAfile: caCert};
|
|
||||||
options[serverCert] = false;
|
|
||||||
const output = await openssl('verify', options);
|
|
||||||
const verified = output.match(/[^:]+: OK/);
|
|
||||||
if (!verified) {
|
|
||||||
// This should never happen, but if it does, we need to notice so we can
|
|
||||||
// generate a valid one, or no clients will trust our server.
|
|
||||||
throw new Error('Current server cert was not issued by current CA');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async generateCertificateAuthority(): Promise<void> {
|
|
||||||
if (!(await fs.pathExists(getFilePath('')))) {
|
|
||||||
await fs.mkdir(getFilePath(''));
|
|
||||||
}
|
|
||||||
console.log('Generating new CA', logTag);
|
|
||||||
await openssl('genrsa', {out: caKey, '2048': false});
|
|
||||||
await openssl('req', {
|
|
||||||
new: true,
|
|
||||||
x509: true,
|
|
||||||
subj: caSubject,
|
|
||||||
key: caKey,
|
|
||||||
out: caCert,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ensureServerCertExists(): Promise<void> {
|
|
||||||
const allExist = await Promise.all([
|
|
||||||
fs.pathExists(serverKey),
|
|
||||||
fs.pathExists(serverCert),
|
|
||||||
fs.pathExists(caCert),
|
|
||||||
]).then((exist) => exist.every(Boolean));
|
|
||||||
if (!allExist) {
|
|
||||||
return this.generateServerCertificate();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.checkCertIsValid(serverCert);
|
|
||||||
await this.verifyServerCertWasIssuedByCA();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Not all certs are valid, generating new ones', e);
|
|
||||||
await this.generateServerCertificate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async generateServerCertificate(): Promise<void> {
|
|
||||||
await this.ensureCertificateAuthorityExists();
|
|
||||||
console.warn('Creating new server cert', logTag);
|
|
||||||
await openssl('genrsa', {out: serverKey, '2048': false});
|
|
||||||
await openssl('req', {
|
|
||||||
new: true,
|
|
||||||
key: serverKey,
|
|
||||||
out: serverCsr,
|
|
||||||
subj: serverSubject,
|
|
||||||
});
|
|
||||||
await openssl('x509', {
|
|
||||||
req: true,
|
|
||||||
in: serverCsr,
|
|
||||||
CA: caCert,
|
|
||||||
CAkey: caKey,
|
|
||||||
CAcreateserial: true,
|
|
||||||
CAserial: serverSrl,
|
|
||||||
out: serverCert,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async writeToTempFile(content: string): Promise<string> {
|
|
||||||
const path = await tmpFile();
|
|
||||||
await fs.writeFile(path, content);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFilePath(fileName: string): string {
|
|
||||||
return path.resolve(os.homedir(), '.flipper', 'certs', fileName);
|
|
||||||
}
|
}
|
||||||
|
|||||||
256
desktop/flipper-server-core/src/utils/certificateUtils.tsx
Normal file
256
desktop/flipper-server-core/src/utils/certificateUtils.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* 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 {promisify} from 'util';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import {
|
||||||
|
openssl,
|
||||||
|
isInstalled as opensslInstalled,
|
||||||
|
} from './openssl-wrapper-with-promises';
|
||||||
|
import path from 'path';
|
||||||
|
import tmp, {FileOptions} from 'tmp';
|
||||||
|
import {reportPlatformFailures} from 'flipper-common';
|
||||||
|
import os from 'os';
|
||||||
|
import {isTest} from 'flipper-common';
|
||||||
|
|
||||||
|
const tmpFile = promisify(tmp.file) as (
|
||||||
|
options?: FileOptions,
|
||||||
|
) => Promise<string>;
|
||||||
|
|
||||||
|
const getFilePath = (fileName: string): string => {
|
||||||
|
return path.resolve(os.homedir(), '.flipper', 'certs', fileName);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Desktop file paths
|
||||||
|
export const caKey = getFilePath('ca.key');
|
||||||
|
export const caCert = getFilePath('ca.crt');
|
||||||
|
export const serverKey = getFilePath('server.key');
|
||||||
|
export const serverCsr = getFilePath('server.csr');
|
||||||
|
export const serverSrl = getFilePath('server.srl');
|
||||||
|
export const serverCert = getFilePath('server.crt');
|
||||||
|
|
||||||
|
// Device file paths
|
||||||
|
export const csrFileName = 'app.csr';
|
||||||
|
export const deviceCAcertFile = 'sonarCA.crt';
|
||||||
|
export 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 = 'certificateUtils';
|
||||||
|
/*
|
||||||
|
* 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ensureOpenSSLIsAvailable = async (): Promise<void> => {
|
||||||
|
if (!(await opensslInstalled())) {
|
||||||
|
throw new Error(
|
||||||
|
"It looks like you don't have OpenSSL installed. Please install it to continue.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadSecureServerConfig = async (): Promise<SecureServerConfig> => {
|
||||||
|
await ensureOpenSSLIsAvailable();
|
||||||
|
await certificateSetup();
|
||||||
|
return {
|
||||||
|
key: await fs.readFile(serverKey),
|
||||||
|
cert: await fs.readFile(serverCert),
|
||||||
|
ca: await fs.readFile(caCert),
|
||||||
|
requestCert: true,
|
||||||
|
rejectUnauthorized: true, // can be false if necessary as we don't strictly need to verify the client
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractAppNameFromCSR = async (csr: string): Promise<string> => {
|
||||||
|
const path = await writeToTempFile(csr);
|
||||||
|
const subject = await openssl('req', {
|
||||||
|
in: path,
|
||||||
|
noout: true,
|
||||||
|
subject: true,
|
||||||
|
nameopt: true,
|
||||||
|
RFC2253: false,
|
||||||
|
});
|
||||||
|
await fs.unlink(path);
|
||||||
|
const matches = subject.trim().match(x509SubjectCNRegex);
|
||||||
|
if (!matches || matches.length < 2) {
|
||||||
|
throw new Error(`Cannot extract CN from ${subject}`);
|
||||||
|
}
|
||||||
|
const appName = matches[1];
|
||||||
|
if (!appName.match(allowedAppNameRegex)) {
|
||||||
|
throw new Error(
|
||||||
|
`Disallowed app name in CSR: ${appName}. Only alphanumeric characters and '.' allowed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return appName;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateClientCertificate = async (
|
||||||
|
csr: string,
|
||||||
|
): Promise<string> => {
|
||||||
|
console.debug('Creating new client cert', logTag);
|
||||||
|
|
||||||
|
return writeToTempFile(csr).then((path) => {
|
||||||
|
return openssl('x509', {
|
||||||
|
req: true,
|
||||||
|
in: path,
|
||||||
|
CA: caCert,
|
||||||
|
CAkey: caKey,
|
||||||
|
CAcreateserial: true,
|
||||||
|
CAserial: serverSrl,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCACertificate = async (): Promise<string> => {
|
||||||
|
return fs.readFile(caCert, 'utf-8');
|
||||||
|
};
|
||||||
|
|
||||||
|
const certificateSetup = async () => {
|
||||||
|
if (isTest()) {
|
||||||
|
throw new Error('Server certificates not available in test');
|
||||||
|
} else {
|
||||||
|
await reportPlatformFailures(
|
||||||
|
ensureServerCertExists(),
|
||||||
|
'ensureServerCertExists',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureServerCertExists = async (): Promise<void> => {
|
||||||
|
const allExist = await Promise.all([
|
||||||
|
fs.pathExists(serverKey),
|
||||||
|
fs.pathExists(serverCert),
|
||||||
|
fs.pathExists(caCert),
|
||||||
|
]).then((exist) => exist.every(Boolean));
|
||||||
|
if (!allExist) {
|
||||||
|
return generateServerCertificate();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await checkCertIsValid(serverCert);
|
||||||
|
await verifyServerCertWasIssuedByCA();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Not all certs are valid, generating new ones', e);
|
||||||
|
await generateServerCertificate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateServerCertificate = async (): Promise<void> => {
|
||||||
|
await ensureCertificateAuthorityExists();
|
||||||
|
console.warn('Creating new server cert', logTag);
|
||||||
|
await openssl('genrsa', {out: serverKey, '2048': false});
|
||||||
|
await openssl('req', {
|
||||||
|
new: true,
|
||||||
|
key: serverKey,
|
||||||
|
out: serverCsr,
|
||||||
|
subj: serverSubject,
|
||||||
|
});
|
||||||
|
await openssl('x509', {
|
||||||
|
req: true,
|
||||||
|
in: serverCsr,
|
||||||
|
CA: caCert,
|
||||||
|
CAkey: caKey,
|
||||||
|
CAcreateserial: true,
|
||||||
|
CAserial: serverSrl,
|
||||||
|
out: serverCert,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureCertificateAuthorityExists = async (): Promise<void> => {
|
||||||
|
if (!(await fs.pathExists(caKey))) {
|
||||||
|
return generateCertificateAuthority();
|
||||||
|
}
|
||||||
|
return checkCertIsValid(caCert).catch(() => generateCertificateAuthority());
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateCertificateAuthority = async (): Promise<void> => {
|
||||||
|
if (!(await fs.pathExists(getFilePath('')))) {
|
||||||
|
await fs.mkdir(getFilePath(''));
|
||||||
|
}
|
||||||
|
console.log('Generating new CA', logTag);
|
||||||
|
await openssl('genrsa', {out: caKey, '2048': false});
|
||||||
|
await openssl('req', {
|
||||||
|
new: true,
|
||||||
|
x509: true,
|
||||||
|
subj: caSubject,
|
||||||
|
key: caKey,
|
||||||
|
out: caCert,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkCertIsValid = async (filename: string): Promise<void> => {
|
||||||
|
if (!(await fs.pathExists(filename))) {
|
||||||
|
throw new Error(`${filename} does not exist`);
|
||||||
|
}
|
||||||
|
// openssl checkend is a nice feature but it only checks for certificates
|
||||||
|
// expiring in the future, not those that have already expired.
|
||||||
|
// So we need a separate check for certificates that have already expired
|
||||||
|
// but since this involves parsing date outputs from openssl, which is less
|
||||||
|
// reliable, keeping both checks for safety.
|
||||||
|
try {
|
||||||
|
await openssl('x509', {
|
||||||
|
checkend: minCertExpiryWindowSeconds,
|
||||||
|
in: filename,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`Checking if certificate expire soon: ${filename}`, logTag, e);
|
||||||
|
const endDateOutput = await openssl('x509', {
|
||||||
|
enddate: true,
|
||||||
|
in: filename,
|
||||||
|
noout: true,
|
||||||
|
});
|
||||||
|
const dateString = endDateOutput.trim().split('=')[1].trim();
|
||||||
|
const expiryDate = Date.parse(dateString);
|
||||||
|
if (isNaN(expiryDate)) {
|
||||||
|
console.error(
|
||||||
|
'Unable to parse certificate expiry date: ' + endDateOutput,
|
||||||
|
);
|
||||||
|
throw new Error(
|
||||||
|
'Cannot parse certificate expiry date. Assuming it has expired.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (expiryDate <= Date.now() + minCertExpiryWindowSeconds * 1000) {
|
||||||
|
throw new Error('Certificate has expired or will expire soon.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyServerCertWasIssuedByCA = async () => {
|
||||||
|
const options: {
|
||||||
|
[key: string]: any;
|
||||||
|
} = {CAfile: caCert};
|
||||||
|
options[serverCert] = false;
|
||||||
|
const output = await openssl('verify', options);
|
||||||
|
const verified = output.match(/[^:]+: OK/);
|
||||||
|
if (!verified) {
|
||||||
|
// This should never happen, but if it does, we need to notice so we can
|
||||||
|
// generate a valid one, or no clients will trust our server.
|
||||||
|
throw new Error('Current server cert was not issued by current CA');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeToTempFile = async (content: string): Promise<string> => {
|
||||||
|
const path = await tmpFile();
|
||||||
|
await fs.writeFile(path, content);
|
||||||
|
return path;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user