microsoft/vscode-react-native

Public

mirrored fromhttps://github.com/microsoft/vscode-react-nativeAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
dev/v-peq/removeNode10TodoComments

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/extension/networkInspector/certificateProvider.ts

634lines · modecode

1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the MIT license. See LICENSE file in the project root for details.
3
4/* eslint-disable */
5/* eslint-enable prettier/prettier*/
6
7import { 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({
23 messageFormat: nls.MessageFormat.bundle,
24 bundleFormat: 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 = {
68 key: Buffer;
69 cert: Buffer;
70 ca: Buffer;
71 requestCert: boolean;
72 rejectUnauthorized: 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 {
87 private adbHelper: AdbHelper;
88 private certificateSetup: Promise<void>;
89 private logger: OutputChannelLogger;
90
91 constructor(adbHelper: AdbHelper) {
92 this.adbHelper = adbHelper;
93 this.certificateSetup = this.ensureServerCertExists();
94 this.logger = OutputChannelLogger.getChannel(NETWORK_INSPECTOR_LOG_CHANNEL_NAME);
95 }
96
97 public loadSecureServerConfig(): Promise<SecureServerConfig> {
98 return this.certificateSetup.then(() => {
99 return {
100 key: fs.readFileSync(serverKey),
101 cert: fs.readFileSync(serverCert),
102 ca: fs.readFileSync(caCert),
103 requestCert: true,
104 rejectUnauthorized: true, // can be false if necessary as we don't strictly need to verify the client
105 };
106 });
107 }
108
109 public async processCertificateSigningRequest(
110 unsanitizedCsr: string,
111 os: ClientOS,
112 appDirectory: string,
113 medium: CertificateExchangeMedium,
114 ): Promise<{ deviceId: string }> {
115 const csr = this.santitizeString(unsanitizedCsr);
116 if (csr === "") {
117 return Promise.reject(new Error(`Received empty CSR from ${os} device`));
118 }
119 this.ensureOpenSSLIsAvailable();
120 const rootFolder = (await tmp.dir()).path;
121 const certFolder = path.join(rootFolder, "FlipperCerts");
122 return this.certificateSetup
123 .then(() => this.getCACertificate())
124 .then(caCert =>
125 this.deployOrStageFileForMobileApp(
126 appDirectory,
127 deviceCAcertFile,
128 caCert,
129 csr,
130 os,
131 medium,
132 certFolder,
133 ),
134 )
135 .then(() => this.generateClientCertificate(csr))
136 .then(clientCert =>
137 this.deployOrStageFileForMobileApp(
138 appDirectory,
139 deviceClientCertFile,
140 clientCert,
141 csr,
142 os,
143 medium,
144 certFolder,
145 ),
146 )
147 .then(() => {
148 return this.extractAppNameFromCSR(csr);
149 })
150 .then(appName => {
151 if (medium === "FS_ACCESS") {
152 return this.getTargetDeviceId(os, appName, appDirectory, csr);
153 } else {
154 return uuid();
155 }
156 })
157 .then(deviceId => {
158 return {
159 deviceId,
160 };
161 });
162 }
163
164 public extractAppNameFromCSR(csr: string): Promise<string> {
165 return this.writeToTempFile(csr)
166 .then(path =>
167 openssl("req", {
168 in: path,
169 noout: true,
170 subject: true,
171 nameopt: true,
172 RFC2253: false,
173 }).then(subject => {
174 return [path, subject];
175 }),
176 )
177 .then(([path, subject]) => {
178 return new Promise<string>(function (resolve, reject) {
179 fs.unlink(path, err => {
180 if (err) {
181 reject(err);
182 } else {
183 resolve(subject);
184 }
185 });
186 });
187 })
188 .then(subject => {
189 const matches = subject.trim().match(x509SubjectCNRegex);
190 if (!matches || matches.length < 2) {
191 throw new Error(`Cannot extract CN from ${subject}`);
192 }
193 return matches[1];
194 })
195 .then(appName => {
196 if (!appName.match(allowedAppNameRegex)) {
197 throw new Error(
198 `Disallowed app name in CSR: ${appName}. Only alphanumeric characters and '.' allowed.`,
199 );
200 }
201 return appName;
202 });
203 }
204
205 public getTargetDeviceId(
206 os: ClientOS,
207 appName: string,
208 appDirectory: string,
209 csr: string,
210 ): Promise<string> {
211 if (os === ClientOS.Android) {
212 return this.getTargetAndroidDeviceId(appName, appDirectory, csr);
213 } else if (os === ClientOS.iOS) {
214 return this.getTargetiOSDeviceId(appName, appDirectory, csr);
215 } else if (os == ClientOS.MacOS) {
216 return Promise.resolve("");
217 }
218 return Promise.resolve("unknown");
219 }
220
221 private ensureOpenSSLIsAvailable(): void {
222 if (!opensslInstalled()) {
223 throw 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
229 private getCACertificate(): Promise<string> {
230 return new Promise((resolve, reject) => {
231 fs.readFile(caCert, (err, data) => {
232 if (err) {
233 reject(err);
234 } else {
235 resolve(data.toString());
236 }
237 });
238 });
239 }
240
241 private generateClientCertificate(csr: string): Promise<string> {
242 return this.writeToTempFile(csr).then(path => {
243 return openssl("x509", {
244 req: true,
245 in: path,
246 CA: caCert,
247 CAkey: caKey,
248 CAcreateserial: true,
249 CAserial: serverSrl,
250 });
251 });
252 }
253
254 private getRelativePathInAppContainer(absolutePath: string) {
255 const matches = /Application\/[^/]+\/(.*)/.exec(absolutePath);
256 if (matches && matches.length === 2) {
257 return matches[1];
258 }
259 throw new Error("Path didn't match expected pattern: " + absolutePath);
260 }
261
262 private async deployOrStageFileForMobileApp(
263 destination: string,
264 filename: string,
265 contents: string,
266 csr: string,
267 os: ClientOS,
268 medium: CertificateExchangeMedium,
269 certFolder: string,
270 ): Promise<void> {
271 const appNamePromise = this.extractAppNameFromCSR(csr);
272
273 if (medium === "WWW") {
274 return fsUtils.writeFileToFolder(certFolder, filename, contents).catch(e => {
275 throw new Error(`Failed to write ${filename} to temporary folder. Error: ${e}`);
276 });
277 }
278
279 if (os === ClientOS.Android) {
280 const deviceIdPromise = appNamePromise.then(app =>
281 this.getTargetAndroidDeviceId(app, destination, csr),
282 );
283 return Promise.all([deviceIdPromise, appNamePromise]).then(([deviceId, appName]) => {
284 if (process.platform === "win32") {
285 return fsUtils
286 .writeFileToFolder(certFolder, filename, contents)
287 .then(() =>
288 androidUtil.pushFile(
289 this.adbHelper,
290 deviceId,
291 appName,
292 destination + filename,
293 path.join(certFolder, filename),
294 this.logger,
295 ),
296 );
297 }
298 return androidUtil.push(
299 this.adbHelper,
300 deviceId,
301 appName,
302 destination + filename,
303 contents,
304 this.logger,
305 );
306 });
307 }
308 if (os === ClientOS.iOS || os === ClientOS.Windows || os === ClientOS.MacOS) {
309 return fs.promises.writeFile(destination + filename, contents).catch(err => {
310 if (os === ClientOS.iOS) {
311 // Writing directly to FS failed. It's probably a physical device.
312 const relativePathInsideApp = this.getRelativePathInAppContainer(destination);
313 return appNamePromise
314 .then(appName => {
315 return this.getTargetiOSDeviceId(appName, destination, csr);
316 })
317 .then(udid => {
318 return appNamePromise.then(appName =>
319 this.pushFileToiOSDevice(
320 udid,
321 appName,
322 relativePathInsideApp,
323 filename,
324 contents,
325 ),
326 );
327 });
328 }
329 throw new Error(
330 `Invalid appDirectory recieved from ${os} device: ${destination}: ` +
331 err.toString(),
332 );
333 });
334 }
335 return Promise.reject(new Error(`Unsupported device os: ${os}`));
336 }
337
338 private pushFileToiOSDevice(
339 udid: string,
340 bundleId: string,
341 destination: string,
342 filename: string,
343 contents: string,
344 ): Promise<void> {
345 return tmp.dir({ unsafeCleanup: true }).then(dir => {
346 const filePath = path.resolve(dir.path, filename);
347 fs.promises
348 .writeFile(filePath, contents)
349 .then(() => iosUtil.push(udid, filePath, bundleId, destination, this.logger));
350 });
351 }
352
353 private getTargetAndroidDeviceId(
354 appName: string,
355 deviceCsrFilePath: string,
356 csr: string,
357 ): Promise<string> {
358 return this.adbHelper.getOnlineTargets().then(devices => {
359 if (devices.length === 0) {
360 throw new Error("No Android devices found");
361 }
362 const deviceMatchList = devices.map(device =>
363 this.androidDeviceHasMatchingCSR(deviceCsrFilePath, device.id, appName, csr)
364 .then(result => {
365 return { id: device.id, ...result, error: null };
366 })
367 .catch(e => {
368 this.logger.error(
369 `Unable to check for matching CSR in ${device.id}:${appName}`,
370 );
371 return { id: device.id, isMatch: false, foundCsr: null, error: e };
372 }),
373 );
374 return Promise.all(deviceMatchList).then(devices => {
375 const matchingIds = devices.filter(m => m.isMatch).map(m => m.id);
376 if (matchingIds.length == 0) {
377 const erroredDevice = devices.find(d => d.error);
378 if (erroredDevice) {
379 throw erroredDevice.error;
380 }
381 const foundCsrs = devices
382 .filter(d => d.foundCsr !== null)
383 .map(d => (d.foundCsr ? encodeURI(d.foundCsr) : "null"));
384 this.logger.error(`Looking for CSR (url encoded):
385
386 ${encodeURI(this.santitizeString(csr))}
387
388 Found these:
389
390 ${foundCsrs.join("\n\n")}`);
391 throw new Error(`No matching device found for app: ${appName}`);
392 }
393 if (matchingIds.length > 1) {
394 this.logger.error(`More than one matching device found for CSR:\n${csr}`);
395 }
396 return matchingIds[0];
397 });
398 });
399 }
400
401 private getTargetiOSDeviceId(
402 appName: string,
403 deviceCsrFilePath: string,
404 csr: string,
405 ): Promise<string> {
406 const matches = /\/Devices\/([^/]+)\//.exec(deviceCsrFilePath);
407 if (matches && matches.length == 2) {
408 // It's a simulator, the deviceId is in the filepath.
409 return Promise.resolve(matches[1]);
410 }
411 return iosUtil.targets().then(targets => {
412 if (targets.length === 0) {
413 throw new Error("No iOS devices found");
414 }
415 const deviceMatchList = targets.map(target =>
416 this.iOSDeviceHasMatchingCSR(deviceCsrFilePath, target.id, appName, csr).then(
417 isMatch => {
418 return { id: target.id, isMatch };
419 },
420 ),
421 );
422 return Promise.all(deviceMatchList).then(devices => {
423 const matchingIds = devices.filter(m => m.isMatch).map(m => m.id);
424 if (matchingIds.length == 0) {
425 throw new Error(`No matching device found for app: ${appName}`);
426 }
427 return matchingIds[0];
428 });
429 });
430 }
431
432 private androidDeviceHasMatchingCSR(
433 directory: string,
434 deviceId: string,
435 processName: string,
436 csr: string,
437 ): Promise<{ isMatch: boolean; foundCsr: string }> {
438 return 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
443 const [sanitizedDeviceCsr, sanitizedClientCsr] = [deviceCsr.toString(), csr].map(
444 s => this.santitizeString(s),
445 );
446 const isMatch = sanitizedDeviceCsr === sanitizedClientCsr;
447 return { isMatch: isMatch, foundCsr: sanitizedDeviceCsr };
448 });
449 }
450
451 private iOSDeviceHasMatchingCSR(
452 directory: string,
453 deviceId: string,
454 bundleId: string,
455 csr: string,
456 ): Promise<boolean> {
457 const originalFile = this.getRelativePathInAppContainer(
458 path.resolve(directory, csrFileName),
459 );
460 return tmp
461 .dir({ unsafeCleanup: true })
462 .then(dir => {
463 return iosUtil
464 .pull(
465 deviceId,
466 originalFile,
467 bundleId,
468 path.join(dir.path, csrFileName),
469 this.logger,
470 )
471 .then(() => dir);
472 })
473 .then(dir => {
474 return fs.promises
475 .readdir(dir.path)
476 .then(items => {
477 if (items.length > 1) {
478 throw new Error("Conflict in temp dir");
479 }
480 if (items.length === 0) {
481 throw new Error("Failed to pull CSR from device");
482 }
483 return items[0];
484 })
485 .then(fileName => {
486 const copiedFile = path.resolve(dir.path, fileName);
487 return fs.promises
488 .readFile(copiedFile)
489 .then(data => this.santitizeString(data.toString()));
490 });
491 })
492 .then(csrFromDevice => csrFromDevice === this.santitizeString(csr));
493 }
494
495 private santitizeString(csrString: string): string {
496 return csrString.replace(/\r/g, "").trim();
497 }
498
499 private ensureCertificateAuthorityExists(): Promise<void> {
500 if (!fs.existsSync(caKey)) {
501 return this.generateCertificateAuthority();
502 }
503 return this.checkCertIsValid(caCert).catch(() => this.generateCertificateAuthority());
504 }
505
506 private checkCertIsValid(filename: string): Promise<void> {
507 if (!fs.existsSync(filename)) {
508 return 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.
515 return openssl("x509", {
516 checkend: minCertExpiryWindowSeconds,
517 in: filename,
518 })
519 .then(() => undefined)
520 .catch(e => {
521 this.logger.warning(
522 localize(
523 "NICertificateExpireSoon",
524 "Certificate will expire soon: {0}",
525 filename,
526 ),
527 );
528 throw e;
529 })
530 .then(() =>
531 openssl("x509", {
532 enddate: true,
533 in: filename,
534 noout: true,
535 }),
536 )
537 .then(endDateOutput => {
538 const dateString = endDateOutput.trim().split("=")[1].trim();
539 const expiryDate = Date.parse(dateString);
540 if (isNaN(expiryDate)) {
541 this.logger.error("Unable to parse certificate expiry date: " + endDateOutput);
542 throw new Error(
543 "Cannot parse certificate expiry date. Assuming it has expired.",
544 );
545 }
546 if (expiryDate <= Date.now() + minCertExpiryWindowSeconds * 1000) {
547 throw new Error("Certificate has expired or will expire soon.");
548 }
549 });
550 }
551
552 private verifyServerCertWasIssuedByCA() {
553 const options: {
554 [key: string]: any;
555 } = { CAfile: caCert };
556 options[serverCert] = false;
557 return openssl("verify", options).then(output => {
558 const verified = output.match(/[^:]+: OK/);
559 if (!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.
562 throw new Error("Current server cert was not issued by current CA");
563 }
564 });
565 }
566
567 private generateCertificateAuthority(): Promise<void> {
568 if (!fs.existsSync(getFilePath(""))) {
569 mkdirp.sync(getFilePath(""));
570 }
571 return openssl("genrsa", { out: caKey, "2048": false })
572 .then(() =>
573 openssl("req", {
574 new: true,
575 x509: true,
576 subj: caSubject,
577 key: caKey,
578 out: caCert,
579 }),
580 )
581 .then(() => undefined);
582 }
583
584 private async ensureServerCertExists(): Promise<void> {
585 this.ensureOpenSSLIsAvailable();
586 if (!(fs.existsSync(serverKey) && fs.existsSync(serverCert) && fs.existsSync(caCert))) {
587 return this.generateServerCertificate();
588 }
589
590 return this.checkCertIsValid(serverCert)
591 .then(() => this.verifyServerCertWasIssuedByCA())
592 .catch(() => this.generateServerCertificate());
593 }
594
595 private generateServerCertificate(): Promise<void> {
596 return this.ensureCertificateAuthorityExists()
597 .then(() => openssl("genrsa", { out: serverKey, "2048": false }))
598 .then(() =>
599 openssl("req", {
600 new: true,
601 key: serverKey,
602 out: serverCsr,
603 subj: serverSubject,
604 }),
605 )
606 .then(() =>
607 openssl("x509", {
608 req: true,
609 in: serverCsr,
610 CA: caCert,
611 CAkey: caKey,
612 CAcreateserial: true,
613 CAserial: serverSrl,
614 out: serverCert,
615 }),
616 )
617 .then(() => undefined);
618 }
619
620 private writeToTempFile(content: string): Promise<string> {
621 return 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 {
633 return path.resolve(os.homedir(), ".config", "vscode-react-native", "certs", fileName);
634}
635