Move certificate exchange bits to app-connectivity
Summary: Bit of reorganising as utils is a bit too generic and crowded. Reviewed By: passy Differential Revision: D47186536 fbshipit-source-id: 7b1dd26db95aef00778ff4f23d91f7371c4d07ad
This commit is contained in:
committed by
Facebook GitHub Bot
parent
20d7b57dbe
commit
f63e5d440a
@@ -17,7 +17,7 @@ import {
|
||||
reportPlatformFailures,
|
||||
FlipperServerEvents,
|
||||
} from 'flipper-common';
|
||||
import CertificateProvider from '../utils/CertificateProvider';
|
||||
import CertificateProvider from './certificate-exchange/CertificateProvider';
|
||||
import {ClientConnection, ConnectionStatus} from './ClientConnection';
|
||||
import {EventEmitter} from 'events';
|
||||
import invariant from 'invariant';
|
||||
@@ -44,7 +44,7 @@ import {
|
||||
import {
|
||||
extractAppNameFromCSR,
|
||||
loadSecureServerConfig,
|
||||
} from '../utils/certificateUtils';
|
||||
} from './certificate-exchange/certificate-utils';
|
||||
import DesktopCertificateProvider from '../devices/desktop/DesktopCertificateProvider';
|
||||
import WWWCertificateProvider from '../fb-stubs/WWWCertificateProvider';
|
||||
import {tracker} from '../utils/tracker';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {SecureServerConfig} from '../utils/certificateUtils';
|
||||
import {SecureServerConfig} from './certificate-exchange/certificate-utils';
|
||||
import ServerWebSocketBase, {ServerEventsListener} from './ServerWebSocketBase';
|
||||
import ServerRSocket from './ServerRSocket';
|
||||
import SecureServerWebSocket from './SecureServerWebSocket';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {SecureServerConfig} from '../utils/certificateUtils';
|
||||
import {SecureServerConfig} from './certificate-exchange/certificate-utils';
|
||||
import ServerWebSocketBase, {
|
||||
SecureClientQuery,
|
||||
ServerEventsListener,
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
parseMessageToJson,
|
||||
verifyClientQueryComesFromCertExchangeSupportedOS,
|
||||
} from './Utilities';
|
||||
import {SecureServerConfig} from '../utils/certificateUtils';
|
||||
import {SecureServerConfig} from './certificate-exchange/certificate-utils';
|
||||
import {Server} from 'net';
|
||||
import {serializeError} from 'serialize-error';
|
||||
import {WSCloseCode} from '../utils/WSCloseCode';
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
ClientQuery,
|
||||
SignCertificateMessage,
|
||||
} from 'flipper-common';
|
||||
import {SecureServerConfig} from '../utils/certificateUtils';
|
||||
import {SecureServerConfig} from './certificate-exchange/certificate-utils';
|
||||
|
||||
/**
|
||||
* ClientCsrQuery defines a client query with 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 {CertificateExchangeMedium} from 'flipper-common';
|
||||
import {
|
||||
deviceCAcertFile,
|
||||
deviceClientCertFile,
|
||||
ensureOpenSSLIsAvailable,
|
||||
extractAppNameFromCSR,
|
||||
generateClientCertificate,
|
||||
getCACertificate,
|
||||
} from './certificate-utils';
|
||||
|
||||
export default abstract class CertificateProvider {
|
||||
abstract medium: CertificateExchangeMedium;
|
||||
abstract name: string;
|
||||
|
||||
verifyMedium(medium: CertificateExchangeMedium) {
|
||||
if (this.medium !== medium) {
|
||||
throw new Error(`${this.name} does not support medium ${medium}`);
|
||||
}
|
||||
}
|
||||
|
||||
async processCertificateSigningRequest(
|
||||
unsanitizedCsr: string,
|
||||
os: string,
|
||||
appDirectory: string,
|
||||
): Promise<{deviceId: string}> {
|
||||
console.debug(
|
||||
`${this.constructor.name}.processCertificateSigningRequest`,
|
||||
unsanitizedCsr,
|
||||
os,
|
||||
appDirectory,
|
||||
);
|
||||
const csr = this.santitizeString(unsanitizedCsr);
|
||||
if (csr === '') {
|
||||
return Promise.reject(new Error(`Received empty CSR from ${os} device`));
|
||||
}
|
||||
console.debug(
|
||||
`${this.constructor.name}.processCertificateSigningRequest -> ensureOpenSSLIsAvailable`,
|
||||
os,
|
||||
appDirectory,
|
||||
);
|
||||
await ensureOpenSSLIsAvailable();
|
||||
const caCert = await getCACertificate();
|
||||
console.debug(
|
||||
`${this.constructor.name}.processCertificateSigningRequest -> deploy caCert`,
|
||||
os,
|
||||
appDirectory,
|
||||
);
|
||||
await this.deployOrStageFileForDevice(
|
||||
appDirectory,
|
||||
deviceCAcertFile,
|
||||
caCert,
|
||||
csr,
|
||||
);
|
||||
console.debug(
|
||||
`${this.constructor.name}.processCertificateSigningRequest -> generateClientCertificate`,
|
||||
os,
|
||||
appDirectory,
|
||||
);
|
||||
const clientCert = await generateClientCertificate(csr);
|
||||
console.debug(
|
||||
`${this.constructor.name}.processCertificateSigningRequest -> deploy clientCert`,
|
||||
os,
|
||||
appDirectory,
|
||||
);
|
||||
await this.deployOrStageFileForDevice(
|
||||
appDirectory,
|
||||
deviceClientCertFile,
|
||||
clientCert,
|
||||
csr,
|
||||
);
|
||||
const appName = await extractAppNameFromCSR(csr);
|
||||
console.debug(
|
||||
`${this.constructor.name}.processCertificateSigningRequest -> getTargetDeviceId`,
|
||||
os,
|
||||
appDirectory,
|
||||
appName,
|
||||
);
|
||||
const deviceId = await this.getTargetDeviceId(appName, appDirectory, csr);
|
||||
console.debug(
|
||||
`${this.constructor.name}.processCertificateSigningRequest -> done`,
|
||||
os,
|
||||
appDirectory,
|
||||
appName,
|
||||
deviceId,
|
||||
);
|
||||
return {
|
||||
deviceId,
|
||||
};
|
||||
}
|
||||
|
||||
abstract getTargetDeviceId(
|
||||
_appName: string,
|
||||
_appDirectory: string,
|
||||
_csr: string,
|
||||
): Promise<string>;
|
||||
|
||||
protected abstract deployOrStageFileForDevice(
|
||||
destination: string,
|
||||
filename: string,
|
||||
contents: string,
|
||||
csr: string,
|
||||
): Promise<void>;
|
||||
|
||||
protected santitizeString(csrString: string): string {
|
||||
return csrString.replace(/\r/g, '').trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 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 os from 'os';
|
||||
import {
|
||||
openssl,
|
||||
isInstalled as opensslInstalled,
|
||||
} from './openssl-wrapper-with-promises';
|
||||
import path from 'path';
|
||||
import tmp, {FileOptions} from 'tmp';
|
||||
import {FlipperServerConfig, reportPlatformFailures} from 'flipper-common';
|
||||
import {isTest} from 'flipper-common';
|
||||
import {flipperDataFolder} from '../../utils/paths';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import {getFlipperServerConfig} from '../../FlipperServerConfig';
|
||||
|
||||
const tmpFile = promisify(tmp.file) as (
|
||||
options?: FileOptions,
|
||||
) => Promise<string>;
|
||||
|
||||
const getFilePath = (filename: string): string => {
|
||||
return path.resolve(flipperDataFolder, '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');
|
||||
export const serverAuthToken = getFilePath('auth.token');
|
||||
|
||||
// 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.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let serverConfig: SecureServerConfig | undefined;
|
||||
export const loadSecureServerConfig = async (): Promise<SecureServerConfig> => {
|
||||
if (serverConfig) {
|
||||
return serverConfig;
|
||||
}
|
||||
|
||||
await ensureOpenSSLIsAvailable();
|
||||
await certificateSetup();
|
||||
await generateAuthToken();
|
||||
serverConfig = {
|
||||
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
|
||||
};
|
||||
return serverConfig;
|
||||
};
|
||||
|
||||
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(''), {recursive: true});
|
||||
}
|
||||
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;
|
||||
};
|
||||
|
||||
const manifestFilename = 'manifest.json';
|
||||
const getManifestPath = (config: FlipperServerConfig): string => {
|
||||
return path.resolve(config.paths.staticPath, manifestFilename);
|
||||
};
|
||||
|
||||
const exportTokenToManifest = async (
|
||||
config: FlipperServerConfig,
|
||||
token: string,
|
||||
) => {
|
||||
const manifestPath = getManifestPath(config);
|
||||
try {
|
||||
const manifestData = await fs.readFile(manifestPath, {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
const manifest = JSON.parse(manifestData);
|
||||
manifest.token = token;
|
||||
|
||||
const newManifestData = JSON.stringify(manifest, null, 4);
|
||||
|
||||
await fs.writeFile(manifestPath, newManifestData);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
'Unable to export authentication token to manifest, may be non existent.',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const generateAuthToken = async () => {
|
||||
const config = getFlipperServerConfig();
|
||||
|
||||
const privateKey = await fs.readFile(serverKey);
|
||||
const token = jwt.sign({unixname: os.userInfo().username}, privateKey, {
|
||||
algorithm: 'RS256',
|
||||
expiresIn: '21 days',
|
||||
});
|
||||
|
||||
await fs.writeFile(serverAuthToken, token);
|
||||
|
||||
if (config.environmentInfo.isHeadlessBuild) {
|
||||
await exportTokenToManifest(config, token);
|
||||
}
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
export const getAuthToken = async (): Promise<string> => {
|
||||
if (!(await hasAuthToken())) {
|
||||
return generateAuthToken();
|
||||
}
|
||||
|
||||
const token = await fs.readFile(serverAuthToken);
|
||||
return token.toString();
|
||||
};
|
||||
|
||||
export const hasAuthToken = async (): Promise<boolean> => {
|
||||
return fs.pathExists(serverAuthToken);
|
||||
};
|
||||
|
||||
export const validateAuthToken = (token: string) => {
|
||||
if (!serverConfig) {
|
||||
throw new Error(
|
||||
'Unable to validate auth token as no server configuration is available',
|
||||
);
|
||||
}
|
||||
|
||||
jwt.verify(token, serverConfig.cert);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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 {exec as opensslWithCallback, Action} from 'openssl-wrapper';
|
||||
import {spawn} from 'promisify-child-process';
|
||||
|
||||
export function openssl(action: Action, options: {}): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
opensslWithCallback(action, options, (err, buffer) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else if (buffer) {
|
||||
resolve(buffer.toString());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function isInstalled(): Promise<boolean> {
|
||||
try {
|
||||
const result = await spawn('openssl', ['version']);
|
||||
return result.code === 0;
|
||||
} catch (_e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user