Initial commit 🎉
fbshipit-source-id: b6fc29740c6875d2e78953b8a7123890a67930f2 Co-authored-by: Sebastian McKenzie <sebmck@fb.com> Co-authored-by: John Knox <jknox@fb.com> Co-authored-by: Emil Sjölander <emilsj@fb.com> Co-authored-by: Pritesh Nandgaonkar <prit91@fb.com>
This commit is contained in:
387
src/utils/CertificateProvider.js
Normal file
387
src/utils/CertificateProvider.js
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Copyright 2018-present Facebook.
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* @format
|
||||
*/
|
||||
|
||||
import LogManager from '../fb-stubs/Logger';
|
||||
const fs = require('fs');
|
||||
const adb = require('adbkit-fb');
|
||||
import {openssl} from './openssl-wrapper-with-promises';
|
||||
const path = require('path');
|
||||
|
||||
// Desktop file paths
|
||||
const os = require('os');
|
||||
const caKey = getFilePath('ca.key');
|
||||
const caCert = getFilePath('ca.crt');
|
||||
const serverKey = getFilePath('server.key');
|
||||
const serverCsr = getFilePath('server.csr');
|
||||
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 appNotDebuggableRegex = /debuggable/;
|
||||
const allowedAppNameRegex = /^[a-zA-Z0-9.\-]+$/;
|
||||
const allowedAppDirectoryRegex = /^\/[ a-zA-Z0-9.\-\/]+$/;
|
||||
|
||||
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 sonar 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 sonar CA to sign a client certificate which it
|
||||
* deploys securely to the app.
|
||||
* It also deploys the sonar CA cert to the app.
|
||||
* The app can trust a server if and only if it has a certificate signed by the
|
||||
* sonar CA.
|
||||
*/
|
||||
export default class CertificateProvider {
|
||||
logger: LogManager;
|
||||
adb: any;
|
||||
certificateSetup: Promise<void>;
|
||||
server: Server;
|
||||
|
||||
constructor(server: Server, logger: LogManager) {
|
||||
this.logger = logger;
|
||||
this.adb = adb.createClient();
|
||||
this.certificateSetup = this.ensureServerCertExists();
|
||||
this.server = server;
|
||||
}
|
||||
|
||||
processCertificateSigningRequest(
|
||||
csr: string,
|
||||
os: string,
|
||||
appDirectory: string,
|
||||
): Promise<void> {
|
||||
if (!appDirectory.match(allowedAppDirectoryRegex)) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`Invalid appDirectory recieved from ${os} device: ${appDirectory}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return this.certificateSetup
|
||||
.then(_ => this.getCACertificate())
|
||||
.then(caCert =>
|
||||
this.deployFileToMobileApp(
|
||||
appDirectory,
|
||||
deviceCAcertFile,
|
||||
caCert,
|
||||
csr,
|
||||
os,
|
||||
),
|
||||
)
|
||||
.then(_ => this.generateClientCertificate(csr))
|
||||
.then(clientCert =>
|
||||
this.deployFileToMobileApp(
|
||||
appDirectory,
|
||||
deviceClientCertFile,
|
||||
clientCert,
|
||||
csr,
|
||||
os,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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> {
|
||||
this.logger.warn('Creating new client cert', 'CertificateProvider');
|
||||
const csrFile = this.writeToTempFile(csr);
|
||||
// Create a certificate for the client, using the details in the CSR.
|
||||
return openssl('x509', {
|
||||
req: true,
|
||||
in: csrFile,
|
||||
CA: caCert,
|
||||
CAkey: caKey,
|
||||
CAcreateserial: true,
|
||||
}).then(cert => {
|
||||
fs.unlink(csrFile);
|
||||
return cert;
|
||||
});
|
||||
}
|
||||
|
||||
deployFileToMobileApp(
|
||||
destination: string,
|
||||
filename: string,
|
||||
contents: string,
|
||||
csr: string,
|
||||
os: string,
|
||||
) {
|
||||
if (os === 'Android') {
|
||||
this.extractAppNameFromCSR(csr).then(app => {
|
||||
const client = adb.createClient();
|
||||
client.listDevices().then((devices: Array<{id: string}>) => {
|
||||
devices.forEach(d =>
|
||||
// To find out which device requested the cert, search them
|
||||
// all for a matching csr file.
|
||||
// It's not important to keep these secret from other apps.
|
||||
// Just need to make sure each app can find it's own one.
|
||||
this.androidDeviceHasMatchingCSR(destination, d.id, app, csr)
|
||||
.catch(e =>
|
||||
this.logger.error(
|
||||
`Unable to check for matching CSR in ${d.id}:${app}`,
|
||||
'CertificateProvider',
|
||||
),
|
||||
)
|
||||
.then(isMatch => {
|
||||
if (isMatch) {
|
||||
this.pushFileToAndroidDevice(
|
||||
d.id,
|
||||
app,
|
||||
destination + filename,
|
||||
contents,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (os === 'iOS') {
|
||||
fs.writeFileSync(destination + filename, contents);
|
||||
}
|
||||
}
|
||||
|
||||
androidDeviceHasMatchingCSR(
|
||||
directory: string,
|
||||
deviceId: string,
|
||||
processName: string,
|
||||
csr: string,
|
||||
): Promise<boolean> {
|
||||
return this.executeCommandOnAndroid(
|
||||
deviceId,
|
||||
processName,
|
||||
`cat ${directory + csrFileName}`,
|
||||
).then(deviceCsr => {
|
||||
return (
|
||||
deviceCsr
|
||||
.toString()
|
||||
.replace(/\r/g, '')
|
||||
.trim() === csr.replace(/\r/g, '').trim()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
pushFileToAndroidDevice(
|
||||
deviceId: string,
|
||||
app: string,
|
||||
filename: string,
|
||||
contents: string,
|
||||
): Promise<void> {
|
||||
this.logger.warn(
|
||||
`Deploying sonar certificate to ${deviceId}:${app}`,
|
||||
'CertificateProvider',
|
||||
);
|
||||
return this.executeCommandOnAndroid(
|
||||
deviceId,
|
||||
app,
|
||||
`echo "${contents}" > ${filename} && chmod 600 ${filename}`,
|
||||
).then(output => undefined);
|
||||
}
|
||||
|
||||
executeCommandOnAndroid(
|
||||
deviceId: string,
|
||||
user: string,
|
||||
command: string,
|
||||
): Promise<string> {
|
||||
if (!user.match(allowedAppNameRegex)) {
|
||||
return Promise.reject(new Error(`Disallowed run-as user: ${user}`));
|
||||
}
|
||||
if (command.match(/[']/)) {
|
||||
return Promise.reject(
|
||||
new Error(`Disallowed escaping command: ${command}`),
|
||||
);
|
||||
}
|
||||
return this.adb
|
||||
.shell(deviceId, `echo '${command}' | run-as '${user}'`)
|
||||
.then(adb.util.readAll)
|
||||
.then(buffer => buffer.toString())
|
||||
.then(output => {
|
||||
const matches = output.match(appNotDebuggableRegex);
|
||||
if (matches) {
|
||||
const e = new Error(
|
||||
`Android app ${user} is not debuggable. To use it with sonar, add android:debuggable="true" to the application section of AndroidManifest.xml`,
|
||||
);
|
||||
this.server.emit('error', e);
|
||||
throw e;
|
||||
}
|
||||
return output;
|
||||
});
|
||||
}
|
||||
|
||||
extractAppNameFromCSR(csr: string): Promise<string> {
|
||||
const csrFile = this.writeToTempFile(csr);
|
||||
return openssl('req', {in: csrFile, noout: true, subject: true})
|
||||
.then(subject => {
|
||||
fs.unlink(csrFile);
|
||||
return subject;
|
||||
})
|
||||
.then(subject => {
|
||||
return subject
|
||||
.split('/')
|
||||
.filter(part => {
|
||||
return part.startsWith('CN=');
|
||||
})
|
||||
.map(part => {
|
||||
return part.split('=')[1].trim();
|
||||
})[0];
|
||||
})
|
||||
.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(e =>
|
||||
this.generateCertificateAuthority(),
|
||||
);
|
||||
}
|
||||
|
||||
checkCertIsValid(filename: string): Promise<void> {
|
||||
if (!fs.existsSync(filename)) {
|
||||
return Promise.reject();
|
||||
}
|
||||
return openssl('x509', {
|
||||
checkend: minCertExpiryWindowSeconds,
|
||||
in: filename,
|
||||
})
|
||||
.then(output => undefined)
|
||||
.catch(e => {
|
||||
this.logger.warn(
|
||||
`Certificate will expire soon: ${filename}`,
|
||||
'CertificateProvider',
|
||||
);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
verifyServerCertWasIssuedByCA() {
|
||||
const options = {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(''));
|
||||
}
|
||||
this.logger.info('Generating new CA', 'CertificateProvider');
|
||||
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(e => this.generateServerCertificate());
|
||||
}
|
||||
|
||||
generateServerCertificate(): Promise<void> {
|
||||
return this.ensureCertificateAuthorityExists()
|
||||
.then(_ => {
|
||||
this.logger.warn('Creating new server cert', 'CertificateProvider');
|
||||
})
|
||||
.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,
|
||||
out: serverCert,
|
||||
}),
|
||||
)
|
||||
.then(_ => undefined);
|
||||
}
|
||||
|
||||
writeToTempFile(content: string): string {
|
||||
const fileName = getFilePath(`deviceCSR-${Math.random() * 1000000}`);
|
||||
fs.writeFileSync(fileName, content);
|
||||
return fileName;
|
||||
}
|
||||
}
|
||||
|
||||
function getFilePath(fileName: string): string {
|
||||
return path.resolve(os.homedir(), '.sonar', 'certs', fileName);
|
||||
}
|
||||
Reference in New Issue
Block a user