From b1f19ecd687d03735a277023ab2b6943de33824c Mon Sep 17 00:00:00 2001 From: Andrey Goncharov Date: Wed, 2 Feb 2022 03:05:34 -0800 Subject: [PATCH] Extract certificate utils Summary: Extract utilities for certificate server-side certificate handling Reviewed By: lawrencelomax Differential Revision: D33820263 fbshipit-source-id: 21f1a9ed5f3b83b8350151bdf6d8862aa0b18e8f --- .../src/comms/ServerAdapter.tsx | 6 +- .../src/comms/ServerController.tsx | 10 +- .../src/comms/ServerFactory.tsx | 2 +- .../src/comms/ServerRSocket.tsx | 2 +- .../src/comms/ServerWebSocket.tsx | 2 +- .../src/utils/CertificateProvider.tsx | 264 ++---------------- .../src/utils/certificateUtils.tsx | 256 +++++++++++++++++ 7 files changed, 283 insertions(+), 259 deletions(-) create mode 100644 desktop/flipper-server-core/src/utils/certificateUtils.tsx diff --git a/desktop/flipper-server-core/src/comms/ServerAdapter.tsx b/desktop/flipper-server-core/src/comms/ServerAdapter.tsx index 1d05fee6f..57892896e 100644 --- a/desktop/flipper-server-core/src/comms/ServerAdapter.tsx +++ b/desktop/flipper-server-core/src/comms/ServerAdapter.tsx @@ -7,10 +7,7 @@ * @format */ -import { - CertificateExchangeMedium, - SecureServerConfig, -} from '../utils/CertificateProvider'; +import {CertificateExchangeMedium} from '../utils/CertificateProvider'; import {ClientConnection} from './ClientConnection'; import {transformCertificateExchangeMediumToType} from './Utilities'; import { @@ -18,6 +15,7 @@ import { ClientQuery, SignCertificateMessage, } from 'flipper-common'; +import {SecureServerConfig} from '../utils/certificateUtils'; /** * ClientCsrQuery defines a client query with CSR diff --git a/desktop/flipper-server-core/src/comms/ServerController.tsx b/desktop/flipper-server-core/src/comms/ServerController.tsx index c91131864..1b04d653c 100644 --- a/desktop/flipper-server-core/src/comms/ServerController.tsx +++ b/desktop/flipper-server-core/src/comms/ServerController.tsx @@ -41,6 +41,10 @@ import { getServerPortsConfig, getFlipperServerConfig, } from '../FlipperServerConfig'; +import { + extractAppNameFromCSR, + loadSecureServerConfig, +} from '../utils/certificateUtils'; type ClientTimestampTracker = { insecureStart?: number; @@ -115,7 +119,7 @@ class ServerController extends EventEmitter implements ServerEventsListener { } const {insecure, secure} = getServerPortsConfig().serverPorts; - const options = await this.certificateProvider.loadSecureServerConfig(); + const options = await loadSecureServerConfig(); console.info('[conn] secure server listening at port: ', secure); this.secureServer = await createServer(secure, this, options); @@ -343,9 +347,7 @@ class ServerController extends EventEmitter implements ServerEventsListener { // For Android, device id might change if (csr_path && csr && query.os === 'Android') { - const app_name = await this.certificateProvider.extractAppNameFromCSR( - csr, - ); + const app_name = await extractAppNameFromCSR(csr); // TODO: allocate new object, kept now as is to keep changes minimal (query as any).device_id = await this.certificateProvider.getTargetDeviceId( diff --git a/desktop/flipper-server-core/src/comms/ServerFactory.tsx b/desktop/flipper-server-core/src/comms/ServerFactory.tsx index 1922ed396..70db544ee 100644 --- a/desktop/flipper-server-core/src/comms/ServerFactory.tsx +++ b/desktop/flipper-server-core/src/comms/ServerFactory.tsx @@ -7,7 +7,7 @@ * @format */ -import {SecureServerConfig} from '../utils/CertificateProvider'; +import {SecureServerConfig} from '../utils/certificateUtils'; import ServerAdapter, {ServerEventsListener} from './ServerAdapter'; import ServerRSocket from './ServerRSocket'; import SecureServerWebSocket from './SecureServerWebSocket'; diff --git a/desktop/flipper-server-core/src/comms/ServerRSocket.tsx b/desktop/flipper-server-core/src/comms/ServerRSocket.tsx index ce91636cb..d3b5fdc4a 100644 --- a/desktop/flipper-server-core/src/comms/ServerRSocket.tsx +++ b/desktop/flipper-server-core/src/comms/ServerRSocket.tsx @@ -7,7 +7,7 @@ * @format */ -import {SecureServerConfig} from '../utils/CertificateProvider'; +import {SecureServerConfig} from '../utils/certificateUtils'; import ServerAdapter, { SecureClientQuery, ServerEventsListener, diff --git a/desktop/flipper-server-core/src/comms/ServerWebSocket.tsx b/desktop/flipper-server-core/src/comms/ServerWebSocket.tsx index a966e0a1a..93f8df822 100644 --- a/desktop/flipper-server-core/src/comms/ServerWebSocket.tsx +++ b/desktop/flipper-server-core/src/comms/ServerWebSocket.tsx @@ -24,7 +24,7 @@ import { parseMessageToJson, verifyClientQueryComesFromCertExchangeSupportedOS, } from './Utilities'; -import {SecureServerConfig} from '../utils/CertificateProvider'; +import {SecureServerConfig} from '../utils/certificateUtils'; import {Server} from 'net'; import {serializeError} from 'serialize-error'; import {WSCloseCode} from '../utils/WSCloseCode'; diff --git a/desktop/flipper-server-core/src/utils/CertificateProvider.tsx b/desktop/flipper-server-core/src/utils/CertificateProvider.tsx index f0c7f2b90..50ad7c13d 100644 --- a/desktop/flipper-server-core/src/utils/CertificateProvider.tsx +++ b/desktop/flipper-server-core/src/utils/CertificateProvider.tsx @@ -11,64 +11,34 @@ import ServerController from '../comms/ServerController'; 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, {DirOptions, FileOptions} from 'tmp'; +import tmp, {DirOptions} from 'tmp'; import iosUtil from '../devices/ios/iOSContainerUtility'; import {reportPlatformFailures} from 'flipper-common'; import {getAdbClient} from '../devices/android/adbClient'; import * as androidUtil from '../devices/android/androidContainerUtility'; -import os from 'os'; import archiver from 'archiver'; -import {timeout, isTest} from 'flipper-common'; +import {timeout} from 'flipper-common'; import {v4 as uuid} from 'uuid'; import {internGraphPOSTAPIRequest} from '../fb-stubs/internRequests'; import {SERVICE_FLIPPER} from '../FlipperServerImpl'; import {getIdbConfig} from '../devices/ios/idbConfig'; import {assertNotNull} from '../comms/Utilities'; +import { + csrFileName, + deviceCAcertFile, + deviceClientCertFile, + ensureOpenSSLIsAvailable, + extractAppNameFromCSR, + generateClientCertificate, + getCACertificate, +} from './certificateUtils'; export type CertificateExchangeMedium = 'FS_ACCESS' | 'WWW' | 'NONE'; -const tmpFile = promisify(tmp.file) as ( - options?: FileOptions, -) => Promise; const tmpDir = promisify(tmp.dir) as (options?: DirOptions) => Promise; -// 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 @@ -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( unsanitizedCsr: string, os: string, @@ -146,11 +105,11 @@ export default class CertificateProvider { if (csr === '') { return Promise.reject(new Error(`Received empty CSR from ${os} device`)); } - await this.ensureOpenSSLIsAvailable(); + await ensureOpenSSLIsAvailable(); const rootFolder = await promisify(tmp.dir)(); const certFolder = rootFolder + '/FlipperCerts/'; const certsZipPath = rootFolder + '/certs.zip'; - const caCert = await this.getCACertificate(); + const caCert = await getCACertificate(); await this.deployOrStageFileForMobileApp( appDirectory, deviceCAcertFile, @@ -160,7 +119,7 @@ export default class CertificateProvider { medium, certFolder, ); - const clientCert = await this.generateClientCertificate(csr); + const clientCert = await generateClientCertificate(csr); await this.deployOrStageFileForMobileApp( appDirectory, deviceClientCertFile, @@ -170,7 +129,7 @@ export default class CertificateProvider { medium, certFolder, ); - const appName = await this.extractAppNameFromCSR(csr); + const appName = await extractAppNameFromCSR(csr); const deviceId = medium === 'FS_ACCESS' ? await this.getTargetDeviceId(os, appName, appDirectory, csr) @@ -221,33 +180,6 @@ export default class CertificateProvider { return Promise.resolve('unknown'); } - private async ensureOpenSSLIsAvailable(): Promise { - if (!(await opensslInstalled())) { - throw new Error( - "It looks like you don't have OpenSSL installed. Please install it to continue.", - ); - } - } - - private getCACertificate(): Promise { - return fs.readFile(caCert, 'utf-8'); - } - - private generateClientCertificate(csr: string): Promise { - console.debug('Creating new client cert', logTag); - - return this.writeToTempFile(csr).then((path) => { - return openssl('x509', { - req: true, - in: path, - CA: caCert, - CAkey: caKey, - CAcreateserial: true, - CAserial: serverSrl, - }); - }); - } - private getRelativePathInAppContainer(absolutePath: string) { const matches = /Application\/[^/]+\/(.*)/.exec(absolutePath); if (matches && matches.length === 2) { @@ -280,7 +212,7 @@ export default class CertificateProvider { } } - const appName = await this.extractAppNameFromCSR(csr); + const appName = await extractAppNameFromCSR(csr); if (os === 'Android') { assertNotNull(this.adb); @@ -542,168 +474,4 @@ export default class CertificateProvider { private santitizeString(csrString: string): string { return csrString.replace(/\r/g, '').trim(); } - - async extractAppNameFromCSR(csr: string): Promise { - 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 { - 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 { - if (!(await fs.pathExists(caKey))) { - return this.generateCertificateAuthority(); - } - return this.checkCertIsValid(caCert).catch(() => - this.generateCertificateAuthority(), - ); - } - - private async checkCertIsValid(filename: string): Promise { - 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 { - 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 { - 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 { - 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 { - const path = await tmpFile(); - await fs.writeFile(path, content); - return path; - } -} - -function getFilePath(fileName: string): string { - return path.resolve(os.homedir(), '.flipper', 'certs', fileName); } diff --git a/desktop/flipper-server-core/src/utils/certificateUtils.tsx b/desktop/flipper-server-core/src/utils/certificateUtils.tsx new file mode 100644 index 000000000..773a70adc --- /dev/null +++ b/desktop/flipper-server-core/src/utils/certificateUtils.tsx @@ -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; + +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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + if (!(await fs.pathExists(caKey))) { + return generateCertificateAuthority(); + } + return checkCertIsValid(caCert).catch(() => generateCertificateAuthority()); +}; + +const generateCertificateAuthority = async (): Promise => { + 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 => { + 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 => { + const path = await tmpFile(); + await fs.writeFile(path, content); + return path; +};