microsoft/vscode-react-native

Public

mirrored from https://github.com/microsoft/vscode-react-nativeAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
indexed-sourcemap-null-section-issue

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

src/extension/networkInspector/certificateProvider.ts

634lines · modeblame

4bb0956eRedMickey5 years ago1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the MIT license. See LICENSE file in the project root for details.
3
09f6024fHeniker4 years ago4/* eslint-disable */
5/* eslint-enable prettier/prettier*/
6
4bb0956eRedMickey5 years ago7import { openssl, isInstalled as opensslInstalled } from "../../common/opensslWrapperWithPromises";
8import * as path from "path";
9import * as os from "os";
10import * as fs from "fs";
11import { FileSystem as fsUtils } from "../../common/node/fileSystem";
12import * as mkdirp from "mkdirp";
13import * as tmp from "tmp-promise";
14import { AdbHelper } from "../android/adb";
15import * as androidUtil from "../android/androidContainerUtility";
16import iosUtil from "../ios/iOSContainerUtility";
17import { v4 as uuid } from "uuid";
18import { OutputChannelLogger } from "../log/OutputChannelLogger";
19import { NETWORK_INSPECTOR_LOG_CHANNEL_NAME } from "./networkInspectorServer";
20import { ClientOS } from "./clientUtils";
21import * as nls from "vscode-nls";
22nls.config({
23messageFormat: nls.MessageFormat.bundle,
24bundleFormat: nls.BundleFormat.standalone,
25})();
26const localize = nls.loadMessageBundle();
27
28/**
29* @preserve
30* Start region: the code is borrowed from https://github.com/facebook/flipper/blob/v0.79.1/desktop/app/src/utils/CertificateProvider.tsx
31*
32* Copyright (c) Facebook, Inc. and its affiliates.
33*
34* This source code is licensed under the MIT license found in the
35* LICENSE file in the root directory of this source tree.
36*
37* @format
38*/
39
40export type CertificateExchangeMedium = "FS_ACCESS" | "WWW";
41
42// Desktop file paths
43const caKey = getFilePath("ca.key");
44const caCert = getFilePath("ca.crt");
45const serverKey = getFilePath("server.key");
46const serverCsr = getFilePath("server.csr");
47const serverSrl = getFilePath("server.srl");
48const serverCert = getFilePath("server.crt");
49
50// Device file paths
51const csrFileName = "app.csr";
52const deviceCAcertFile = "sonarCA.crt";
53const deviceClientCertFile = "device.crt";
54
55const caSubject = "/C=US/ST=CA/L=Redmond/O=Microsoft/CN=ReactNativeExtensionCA";
56const serverSubject = "/C=US/ST=CA/L=Redmond/O=Microsoft/CN=localhost";
57const minCertExpiryWindowSeconds = 24 * 60 * 60;
58const allowedAppNameRegex = /^[\w.-]+$/;
59
60/*
61* RFC2253 specifies the unamiguous x509 subject format.
62* However, even when specifying this, different openssl implementations
63* wrap it differently, e.g "subject=X" vs "subject= X".
64*/
65const x509SubjectCNRegex = /[=,]\s*CN=([^,]*)(,.*)?$/;
66
67export type SecureServerConfig = {
68key: Buffer;
69cert: Buffer;
70ca: Buffer;
71requestCert: boolean;
72rejectUnauthorized: boolean;
73};
74
75/*
76* This class is responsible for generating and deploying server and client
77* certificates to allow for secure communication between Flipper and apps.
78* It takes a Certificate Signing Request which was generated by the app,
79* using the app's public/private keypair.
80* With this CSR it uses the Flipper CA to sign a client certificate which it
81* deploys securely to the app.
82* It also deploys the Flipper CA cert to the app.
83* The app can trust a server if and only if it has a certificate signed by the
84* Flipper CA.
85*/
86export class CertificateProvider {
87private adbHelper: AdbHelper;
88private certificateSetup: Promise<void>;
89private logger: OutputChannelLogger;
90
91constructor(adbHelper: AdbHelper) {
92this.adbHelper = adbHelper;
93this.certificateSetup = this.ensureServerCertExists();
94this.logger = OutputChannelLogger.getChannel(NETWORK_INSPECTOR_LOG_CHANNEL_NAME);
95}
96
97public loadSecureServerConfig(): Promise<SecureServerConfig> {
98return this.certificateSetup.then(() => {
99return {
100key: fs.readFileSync(serverKey),
101cert: fs.readFileSync(serverCert),
102ca: fs.readFileSync(caCert),
103requestCert: true,
104rejectUnauthorized: true, // can be false if necessary as we don't strictly need to verify the client
105};
106});
107}
108
109public async processCertificateSigningRequest(
110unsanitizedCsr: string,
111os: ClientOS,
112appDirectory: string,
113medium: CertificateExchangeMedium,
114): Promise<{ deviceId: string }> {
115const csr = this.santitizeString(unsanitizedCsr);
116if (csr === "") {
117return Promise.reject(new Error(`Received empty CSR from ${os} device`));
118}
119this.ensureOpenSSLIsAvailable();
120const rootFolder = (await tmp.dir()).path;
121const certFolder = path.join(rootFolder, "FlipperCerts");
122return this.certificateSetup
123.then(() => this.getCACertificate())
124.then(caCert =>
125this.deployOrStageFileForMobileApp(
126appDirectory,
127deviceCAcertFile,
128caCert,
129csr,
130os,
131medium,
132certFolder,
133),
134)
135.then(() => this.generateClientCertificate(csr))
136.then(clientCert =>
137this.deployOrStageFileForMobileApp(
138appDirectory,
139deviceClientCertFile,
140clientCert,
141csr,
142os,
143medium,
144certFolder,
145),
146)
147.then(() => {
148return this.extractAppNameFromCSR(csr);
149})
150.then(appName => {
151if (medium === "FS_ACCESS") {
152return this.getTargetDeviceId(os, appName, appDirectory, csr);
153} else {
154return uuid();
155}
156})
157.then(deviceId => {
158return {
159deviceId,
160};
161});
162}
163
164public extractAppNameFromCSR(csr: string): Promise<string> {
165return this.writeToTempFile(csr)
166.then(path =>
167openssl("req", {
168in: path,
169noout: true,
170subject: true,
171nameopt: true,
172RFC2253: false,
173}).then(subject => {
174return [path, subject];
175}),
176)
177.then(([path, subject]) => {
178return new Promise<string>(function (resolve, reject) {
179fs.unlink(path, err => {
180if (err) {
181reject(err);
182} else {
183resolve(subject);
184}
185});
186});
187})
188.then(subject => {
189const matches = subject.trim().match(x509SubjectCNRegex);
190if (!matches || matches.length < 2) {
191throw new Error(`Cannot extract CN from ${subject}`);
192}
193return matches[1];
194})
195.then(appName => {
196if (!appName.match(allowedAppNameRegex)) {
197throw new Error(
198`Disallowed app name in CSR: ${appName}. Only alphanumeric characters and '.' allowed.`,
199);
200}
201return appName;
202});
203}
204
205public getTargetDeviceId(
206os: ClientOS,
207appName: string,
208appDirectory: string,
209csr: string,
210): Promise<string> {
211if (os === ClientOS.Android) {
212return this.getTargetAndroidDeviceId(appName, appDirectory, csr);
213} else if (os === ClientOS.iOS) {
214return this.getTargetiOSDeviceId(appName, appDirectory, csr);
215} else if (os == ClientOS.MacOS) {
216return Promise.resolve("");
217}
218return Promise.resolve("unknown");
219}
220
221private ensureOpenSSLIsAvailable(): void {
222if (!opensslInstalled()) {
223throw new Error(
224"It looks like you don't have OpenSSL installed globally. Please install it and add it to Path to continue.",
225);
226}
227}
228
229private getCACertificate(): Promise<string> {
230return new Promise((resolve, reject) => {
231fs.readFile(caCert, (err, data) => {
232if (err) {
233reject(err);
234} else {
235resolve(data.toString());
236}
237});
238});
239}
240
241private generateClientCertificate(csr: string): Promise<string> {
242return this.writeToTempFile(csr).then(path => {
243return openssl("x509", {
244req: true,
245in: path,
246CA: caCert,
247CAkey: caKey,
248CAcreateserial: true,
249CAserial: serverSrl,
250});
251});
252}
253
254private getRelativePathInAppContainer(absolutePath: string) {
255const matches = /Application\/[^/]+\/(.*)/.exec(absolutePath);
256if (matches && matches.length === 2) {
257return matches[1];
258}
259throw new Error("Path didn't match expected pattern: " + absolutePath);
260}
261
262private async deployOrStageFileForMobileApp(
263destination: string,
264filename: string,
265contents: string,
266csr: string,
267os: ClientOS,
268medium: CertificateExchangeMedium,
269certFolder: string,
270): Promise<void> {
271const appNamePromise = this.extractAppNameFromCSR(csr);
272
273if (medium === "WWW") {
274return fsUtils.writeFileToFolder(certFolder, filename, contents).catch(e => {
275throw new Error(`Failed to write ${filename} to temporary folder. Error: ${e}`);
276});
277}
278
279if (os === ClientOS.Android) {
280const deviceIdPromise = appNamePromise.then(app =>
281this.getTargetAndroidDeviceId(app, destination, csr),
282);
283return Promise.all([deviceIdPromise, appNamePromise]).then(([deviceId, appName]) => {
284if (process.platform === "win32") {
285return fsUtils
286.writeFileToFolder(certFolder, filename, contents)
287.then(() =>
288androidUtil.pushFile(
289this.adbHelper,
290deviceId,
291appName,
292destination + filename,
293path.join(certFolder, filename),
294this.logger,
295),
296);
297}
298return androidUtil.push(
299this.adbHelper,
300deviceId,
301appName,
302destination + filename,
303contents,
304this.logger,
305);
306});
307}
308if (os === ClientOS.iOS || os === ClientOS.Windows || os === ClientOS.MacOS) {
309return fs.promises.writeFile(destination + filename, contents).catch(err => {
310if (os === ClientOS.iOS) {
311// Writing directly to FS failed. It's probably a physical device.
312const relativePathInsideApp = this.getRelativePathInAppContainer(destination);
313return appNamePromise
314.then(appName => {
315return this.getTargetiOSDeviceId(appName, destination, csr);
316})
317.then(udid => {
318return appNamePromise.then(appName =>
319this.pushFileToiOSDevice(
320udid,
321appName,
322relativePathInsideApp,
323filename,
324contents,
325),
326);
327});
328}
329throw new Error(
330`Invalid appDirectory recieved from ${os} device: ${destination}: ` +
331err.toString(),
332);
333});
334}
335return Promise.reject(new Error(`Unsupported device os: ${os}`));
336}
337
338private pushFileToiOSDevice(
339udid: string,
340bundleId: string,
341destination: string,
342filename: string,
343contents: string,
344): Promise<void> {
345return tmp.dir({ unsafeCleanup: true }).then(dir => {
346const filePath = path.resolve(dir.path, filename);
347fs.promises
348.writeFile(filePath, contents)
349.then(() => iosUtil.push(udid, filePath, bundleId, destination, this.logger));
350});
351}
352
353private getTargetAndroidDeviceId(
354appName: string,
355deviceCsrFilePath: string,
356csr: string,
357): Promise<string> {
4cd25962JiglioNero4 years ago358return this.adbHelper.getOnlineTargets().then(devices => {
4bb0956eRedMickey5 years ago359if (devices.length === 0) {
360throw new Error("No Android devices found");
361}
362const deviceMatchList = devices.map(device =>
363this.androidDeviceHasMatchingCSR(deviceCsrFilePath, device.id, appName, csr)
364.then(result => {
365return { id: device.id, ...result, error: null };
366})
367.catch(e => {
368this.logger.error(
369`Unable to check for matching CSR in ${device.id}:${appName}`,
370);
371return { id: device.id, isMatch: false, foundCsr: null, error: e };
372}),
373);
374return Promise.all(deviceMatchList).then(devices => {
375const matchingIds = devices.filter(m => m.isMatch).map(m => m.id);
376if (matchingIds.length == 0) {
377const erroredDevice = devices.find(d => d.error);
378if (erroredDevice) {
379throw erroredDevice.error;
380}
381const foundCsrs = devices
382.filter(d => d.foundCsr !== null)
383.map(d => (d.foundCsr ? encodeURI(d.foundCsr) : "null"));
384this.logger.error(`Looking for CSR (url encoded):
385
386${encodeURI(this.santitizeString(csr))}
387
388Found these:
389
390${foundCsrs.join("\n\n")}`);
391throw new Error(`No matching device found for app: ${appName}`);
392}
393if (matchingIds.length > 1) {
394this.logger.error(`More than one matching device found for CSR:\n${csr}`);
395}
396return matchingIds[0];
397});
398});
399}
400
401private getTargetiOSDeviceId(
402appName: string,
403deviceCsrFilePath: string,
404csr: string,
405): Promise<string> {
406const matches = /\/Devices\/([^/]+)\//.exec(deviceCsrFilePath);
407if (matches && matches.length == 2) {
408// It's a simulator, the deviceId is in the filepath.
409return Promise.resolve(matches[1]);
410}
411return iosUtil.targets().then(targets => {
412if (targets.length === 0) {
413throw new Error("No iOS devices found");
414}
415const deviceMatchList = targets.map(target =>
416this.iOSDeviceHasMatchingCSR(deviceCsrFilePath, target.id, appName, csr).then(
417isMatch => {
418return { id: target.id, isMatch };
419},
420),
421);
422return Promise.all(deviceMatchList).then(devices => {
423const matchingIds = devices.filter(m => m.isMatch).map(m => m.id);
424if (matchingIds.length == 0) {
425throw new Error(`No matching device found for app: ${appName}`);
426}
427return matchingIds[0];
428});
429});
430}
431
432private androidDeviceHasMatchingCSR(
433directory: string,
434deviceId: string,
435processName: string,
436csr: string,
437): Promise<{ isMatch: boolean; foundCsr: string }> {
438return androidUtil
439.pull(this.adbHelper, deviceId, processName, directory + csrFileName, this.logger)
440.then(deviceCsr => {
441// Santitize both of the string before comparation
442// The csr string extraction on client side return string in both way
09f6024fHeniker4 years ago443const [sanitizedDeviceCsr, sanitizedClientCsr] = [deviceCsr.toString(), csr].map(
444s => this.santitizeString(s),
445);
4bb0956eRedMickey5 years ago446const isMatch = sanitizedDeviceCsr === sanitizedClientCsr;
447return { isMatch: isMatch, foundCsr: sanitizedDeviceCsr };
448});
449}
450
451private iOSDeviceHasMatchingCSR(
452directory: string,
453deviceId: string,
454bundleId: string,
455csr: string,
456): Promise<boolean> {
457const originalFile = this.getRelativePathInAppContainer(
458path.resolve(directory, csrFileName),
459);
460return tmp
461.dir({ unsafeCleanup: true })
462.then(dir => {
463return iosUtil
464.pull(
465deviceId,
466originalFile,
467bundleId,
468path.join(dir.path, csrFileName),
469this.logger,
470)
471.then(() => dir);
472})
473.then(dir => {
474return fs.promises
475.readdir(dir.path)
476.then(items => {
477if (items.length > 1) {
478throw new Error("Conflict in temp dir");
479}
480if (items.length === 0) {
481throw new Error("Failed to pull CSR from device");
482}
483return items[0];
484})
485.then(fileName => {
486const copiedFile = path.resolve(dir.path, fileName);
487return fs.promises
488.readFile(copiedFile)
489.then(data => this.santitizeString(data.toString()));
490});
491})
492.then(csrFromDevice => csrFromDevice === this.santitizeString(csr));
493}
494
495private santitizeString(csrString: string): string {
496return csrString.replace(/\r/g, "").trim();
497}
498
499private ensureCertificateAuthorityExists(): Promise<void> {
500if (!fs.existsSync(caKey)) {
501return this.generateCertificateAuthority();
502}
503return this.checkCertIsValid(caCert).catch(() => this.generateCertificateAuthority());
504}
505
506private checkCertIsValid(filename: string): Promise<void> {
507if (!fs.existsSync(filename)) {
508return Promise.reject(new Error(`${filename} does not exist`));
509}
510// openssl checkend is a nice feature but it only checks for certificates
511// expiring in the future, not those that have already expired.
512// So we need a separate check for certificates that have already expired
513// but since this involves parsing date outputs from openssl, which is less
514// reliable, keeping both checks for safety.
515return openssl("x509", {
516checkend: minCertExpiryWindowSeconds,
517in: filename,
518})
519.then(() => undefined)
520.catch(e => {
521this.logger.warning(
522localize(
523"NICertificateExpireSoon",
524"Certificate will expire soon: {0}",
525filename,
526),
527);
528throw e;
529})
530.then(() =>
531openssl("x509", {
532enddate: true,
533in: filename,
534noout: true,
535}),
536)
537.then(endDateOutput => {
538const dateString = endDateOutput.trim().split("=")[1].trim();
539const expiryDate = Date.parse(dateString);
540if (isNaN(expiryDate)) {
541this.logger.error("Unable to parse certificate expiry date: " + endDateOutput);
542throw new Error(
543"Cannot parse certificate expiry date. Assuming it has expired.",
544);
545}
546if (expiryDate <= Date.now() + minCertExpiryWindowSeconds * 1000) {
547throw new Error("Certificate has expired or will expire soon.");
548}
549});
550}
551
552private verifyServerCertWasIssuedByCA() {
553const options: {
554[key: string]: any;
555} = { CAfile: caCert };
556options[serverCert] = false;
557return openssl("verify", options).then(output => {
558const verified = output.match(/[^:]+: OK/);
559if (!verified) {
560// This should never happen, but if it does, we need to notice so we can
561// generate a valid one, or no clients will trust our server.
562throw new Error("Current server cert was not issued by current CA");
563}
564});
565}
566
567private generateCertificateAuthority(): Promise<void> {
568if (!fs.existsSync(getFilePath(""))) {
569mkdirp.sync(getFilePath(""));
570}
571return openssl("genrsa", { out: caKey, "2048": false })
572.then(() =>
573openssl("req", {
574new: true,
575x509: true,
576subj: caSubject,
577key: caKey,
578out: caCert,
579}),
580)
581.then(() => undefined);
582}
583
584private async ensureServerCertExists(): Promise<void> {
585this.ensureOpenSSLIsAvailable();
586if (!(fs.existsSync(serverKey) && fs.existsSync(serverCert) && fs.existsSync(caCert))) {
587return this.generateServerCertificate();
588}
589
590return this.checkCertIsValid(serverCert)
591.then(() => this.verifyServerCertWasIssuedByCA())
592.catch(() => this.generateServerCertificate());
593}
594
595private generateServerCertificate(): Promise<void> {
596return this.ensureCertificateAuthorityExists()
597.then(() => openssl("genrsa", { out: serverKey, "2048": false }))
598.then(() =>
599openssl("req", {
600new: true,
601key: serverKey,
602out: serverCsr,
603subj: serverSubject,
604}),
605)
606.then(() =>
607openssl("x509", {
608req: true,
609in: serverCsr,
610CA: caCert,
611CAkey: caKey,
612CAcreateserial: true,
613CAserial: serverSrl,
614out: serverCert,
615}),
616)
617.then(() => undefined);
618}
619
620private writeToTempFile(content: string): Promise<string> {
621return tmp
622.file()
623.then(path => fs.promises.writeFile(path.path, content).then(() => path.path));
624}
625}
626
627/**
628* @preserve
629* End region: https://github.com/facebook/flipper/blob/v0.79.1/desktop/app/src/utils/CertificateProvider.tsx
630*/
631
632function getFilePath(fileName: string): string {
633return path.resolve(os.homedir(), ".config", "vscode-react-native", "certs", fileName);
634}