microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
dev/v-peq/issue-2711-androidContainerUtility_tests

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/extension/networkInspector/certificateProvider.ts

665lines · 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 // Validate app name from CSR before any filesystem operations
272 await this.extractAppNameFromCSR(csr);
273
274 if (medium === "WWW") {
275 return fsUtils.writeFileToFolder(certFolder, filename, contents).catch(e => {
276 throw new Error(`Failed to write ${filename} to temporary folder. Error: ${e}`);
277 });
278 }
279
280 if (os === ClientOS.Android) {
281 const appName = await this.extractAppNameFromCSR(csr);
282 const deviceId = await this.getTargetAndroidDeviceId(appName, destination, csr);
283 if (process.platform === "win32") {
284 await fsUtils.writeFileToFolder(certFolder, filename, contents);
285 return androidUtil.pushFile(
286 this.adbHelper,
287 deviceId,
288 appName,
289 destination + filename,
290 path.join(certFolder, filename),
291 this.logger,
292 );
293 }
294 return androidUtil.push(
295 this.adbHelper,
296 deviceId,
297 appName,
298 destination + filename,
299 contents,
300 this.logger,
301 );
302 }
303 if (os === ClientOS.iOS || os === ClientOS.Windows || os === ClientOS.MacOS) {
304 this.validateDestinationPath(destination, os);
305 return fs.promises.writeFile(destination + filename, contents).catch(err => {
306 if (os === ClientOS.iOS) {
307 // Writing directly to FS failed. It's probably a physical device.
308 const relativePathInsideApp = this.getRelativePathInAppContainer(destination);
309 return this.extractAppNameFromCSR(csr)
310 .then(appName => {
311 return this.getTargetiOSDeviceId(appName, destination, csr);
312 })
313 .then(udid => {
314 return this.extractAppNameFromCSR(csr).then(appName =>
315 this.pushFileToiOSDevice(
316 udid,
317 appName,
318 relativePathInsideApp,
319 filename,
320 contents,
321 ),
322 );
323 });
324 }
325 throw new Error(
326 `Invalid appDirectory recieved from ${os} device: ${destination}: ` +
327 err.toString(),
328 );
329 });
330 }
331 return Promise.reject(new Error(`Unsupported device os: ${os}`));
332 }
333
334 private validateDestinationPath(destination: string, os: ClientOS): void {
335 if (destination.split(path.sep).includes("..")) {
336 throw new Error(`Path traversal not allowed in destination: ${destination}`);
337 }
338
339 const resolved = path.resolve(destination);
340 const allowedPrefixes: string[] = [];
341 if (os === ClientOS.iOS) {
342 allowedPrefixes.push(
343 path.join(
344 process.env.HOME || "/Users",
345 "Library",
346 "Developer",
347 "CoreSimulator",
348 "Devices",
349 ),
350 );
351 } else if (os === ClientOS.MacOS) {
352 allowedPrefixes.push(path.join(process.env.HOME || "/Users", "Library"));
353 } else if (os === ClientOS.Windows) {
354 allowedPrefixes.push(path.join(process.env.LOCALAPPDATA || "C:\\Users"));
355 }
356
357 if (
358 allowedPrefixes.length > 0 &&
359 !allowedPrefixes.some(prefix => resolved.startsWith(prefix))
360 ) {
361 throw new Error(`Destination path is not within an allowed directory: ${destination}`);
362 }
363 }
364
365 private pushFileToiOSDevice(
366 udid: string,
367 bundleId: string,
368 destination: string,
369 filename: string,
370 contents: string,
371 ): Promise<void> {
372 return tmp.dir({ unsafeCleanup: true }).then(dir => {
373 const filePath = path.resolve(dir.path, filename);
374 fs.promises
375 .writeFile(filePath, contents)
376 .then(() => iosUtil.push(udid, filePath, bundleId, destination, this.logger));
377 });
378 }
379
380 private getTargetAndroidDeviceId(
381 appName: string,
382 deviceCsrFilePath: string,
383 csr: string,
384 ): Promise<string> {
385 return this.adbHelper.getOnlineTargets().then(devices => {
386 if (devices.length === 0) {
387 throw new Error("No Android devices found");
388 }
389 const deviceMatchList = devices.map(device =>
390 this.androidDeviceHasMatchingCSR(deviceCsrFilePath, device.id, appName, csr)
391 .then(result => {
392 return { id: device.id, ...result, error: null };
393 })
394 .catch(e => {
395 this.logger.error(
396 `Unable to check for matching CSR in ${device.id}:${appName}`,
397 );
398 return { id: device.id, isMatch: false, foundCsr: null, error: e };
399 }),
400 );
401 return Promise.all(deviceMatchList).then(devices => {
402 const matchingIds = devices.filter(m => m.isMatch).map(m => m.id);
403 if (matchingIds.length == 0) {
404 const erroredDevice = devices.find(d => d.error);
405 if (erroredDevice) {
406 throw erroredDevice.error;
407 }
408 const foundCsrs = devices
409 .filter(d => d.foundCsr !== null)
410 .map(d => (d.foundCsr ? encodeURI(d.foundCsr) : "null"));
411 this.logger.error(`Looking for CSR (url encoded):
412
413 ${encodeURI(this.santitizeString(csr))}
414
415 Found these:
416
417 ${foundCsrs.join("\n\n")}`);
418 throw new Error(`No matching device found for app: ${appName}`);
419 }
420 if (matchingIds.length > 1) {
421 this.logger.error(`More than one matching device found for CSR:\n${csr}`);
422 }
423 return matchingIds[0];
424 });
425 });
426 }
427
428 private getTargetiOSDeviceId(
429 appName: string,
430 deviceCsrFilePath: string,
431 csr: string,
432 ): Promise<string> {
433 const matches = /\/Devices\/([^/]+)\//.exec(deviceCsrFilePath);
434 if (matches && matches.length == 2) {
435 const udid = matches[1];
436 if (!/^[A-F0-9-]{25,40}$/i.test(udid)) {
437 return Promise.reject(new Error(`Invalid iOS device UDID: ${udid}`));
438 }
439 // It's a simulator, the deviceId is in the filepath.
440 return Promise.resolve(udid);
441 }
442 return iosUtil.targets().then(targets => {
443 if (targets.length === 0) {
444 throw new Error("No iOS devices found");
445 }
446 const deviceMatchList = targets.map(target =>
447 this.iOSDeviceHasMatchingCSR(deviceCsrFilePath, target.id, appName, csr).then(
448 isMatch => {
449 return { id: target.id, isMatch };
450 },
451 ),
452 );
453 return Promise.all(deviceMatchList).then(devices => {
454 const matchingIds = devices.filter(m => m.isMatch).map(m => m.id);
455 if (matchingIds.length == 0) {
456 throw new Error(`No matching device found for app: ${appName}`);
457 }
458 return matchingIds[0];
459 });
460 });
461 }
462
463 private androidDeviceHasMatchingCSR(
464 directory: string,
465 deviceId: string,
466 processName: string,
467 csr: string,
468 ): Promise<{ isMatch: boolean; foundCsr: string }> {
469 return androidUtil
470 .pull(this.adbHelper, deviceId, processName, directory + csrFileName, this.logger)
471 .then(deviceCsr => {
472 // Santitize both of the string before comparation
473 // The csr string extraction on client side return string in both way
474 const [sanitizedDeviceCsr, sanitizedClientCsr] = [deviceCsr.toString(), csr].map(
475 s => this.santitizeString(s),
476 );
477 const isMatch = sanitizedDeviceCsr === sanitizedClientCsr;
478 return { isMatch: isMatch, foundCsr: sanitizedDeviceCsr };
479 });
480 }
481
482 private iOSDeviceHasMatchingCSR(
483 directory: string,
484 deviceId: string,
485 bundleId: string,
486 csr: string,
487 ): Promise<boolean> {
488 const originalFile = this.getRelativePathInAppContainer(
489 path.resolve(directory, csrFileName),
490 );
491 return tmp
492 .dir({ unsafeCleanup: true })
493 .then(dir => {
494 return iosUtil
495 .pull(
496 deviceId,
497 originalFile,
498 bundleId,
499 path.join(dir.path, csrFileName),
500 this.logger,
501 )
502 .then(() => dir);
503 })
504 .then(dir => {
505 return fs.promises
506 .readdir(dir.path)
507 .then(items => {
508 if (items.length > 1) {
509 throw new Error("Conflict in temp dir");
510 }
511 if (items.length === 0) {
512 throw new Error("Failed to pull CSR from device");
513 }
514 return items[0];
515 })
516 .then(fileName => {
517 const copiedFile = path.resolve(dir.path, fileName);
518 return fs.promises
519 .readFile(copiedFile)
520 .then(data => this.santitizeString(data.toString()));
521 });
522 })
523 .then(csrFromDevice => csrFromDevice === this.santitizeString(csr));
524 }
525
526 private santitizeString(csrString: string): string {
527 return csrString.replace(/\r/g, "").trim();
528 }
529
530 private ensureCertificateAuthorityExists(): Promise<void> {
531 if (!fs.existsSync(caKey)) {
532 return this.generateCertificateAuthority();
533 }
534 return this.checkCertIsValid(caCert).catch(() => this.generateCertificateAuthority());
535 }
536
537 private checkCertIsValid(filename: string): Promise<void> {
538 if (!fs.existsSync(filename)) {
539 return Promise.reject(new Error(`${filename} does not exist`));
540 }
541 // openssl checkend is a nice feature but it only checks for certificates
542 // expiring in the future, not those that have already expired.
543 // So we need a separate check for certificates that have already expired
544 // but since this involves parsing date outputs from openssl, which is less
545 // reliable, keeping both checks for safety.
546 return openssl("x509", {
547 checkend: minCertExpiryWindowSeconds,
548 in: filename,
549 })
550 .then(() => undefined)
551 .catch(e => {
552 this.logger.warning(
553 localize(
554 "NICertificateExpireSoon",
555 "Certificate will expire soon: {0}",
556 filename,
557 ),
558 );
559 throw e;
560 })
561 .then(() =>
562 openssl("x509", {
563 enddate: true,
564 in: filename,
565 noout: true,
566 }),
567 )
568 .then(endDateOutput => {
569 const dateString = endDateOutput.trim().split("=")[1].trim();
570 const expiryDate = Date.parse(dateString);
571 if (isNaN(expiryDate)) {
572 this.logger.error("Unable to parse certificate expiry date: " + endDateOutput);
573 throw new Error(
574 "Cannot parse certificate expiry date. Assuming it has expired.",
575 );
576 }
577 if (expiryDate <= Date.now() + minCertExpiryWindowSeconds * 1000) {
578 throw new Error("Certificate has expired or will expire soon.");
579 }
580 });
581 }
582
583 private verifyServerCertWasIssuedByCA() {
584 const options: {
585 [key: string]: any;
586 } = { CAfile: caCert };
587 options[serverCert] = false;
588 return openssl("verify", options).then(output => {
589 const verified = output.match(/[^:]+: OK/);
590 if (!verified) {
591 // This should never happen, but if it does, we need to notice so we can
592 // generate a valid one, or no clients will trust our server.
593 throw new Error("Current server cert was not issued by current CA");
594 }
595 });
596 }
597
598 private generateCertificateAuthority(): Promise<void> {
599 if (!fs.existsSync(getFilePath(""))) {
600 mkdirp.sync(getFilePath(""));
601 }
602 return openssl("genrsa", { out: caKey, "2048": false })
603 .then(() =>
604 openssl("req", {
605 new: true,
606 x509: true,
607 subj: caSubject,
608 key: caKey,
609 out: caCert,
610 }),
611 )
612 .then(() => undefined);
613 }
614
615 private async ensureServerCertExists(): Promise<void> {
616 this.ensureOpenSSLIsAvailable();
617 if (!(fs.existsSync(serverKey) && fs.existsSync(serverCert) && fs.existsSync(caCert))) {
618 return this.generateServerCertificate();
619 }
620
621 return this.checkCertIsValid(serverCert)
622 .then(() => this.verifyServerCertWasIssuedByCA())
623 .catch(() => this.generateServerCertificate());
624 }
625
626 private generateServerCertificate(): Promise<void> {
627 return this.ensureCertificateAuthorityExists()
628 .then(() => openssl("genrsa", { out: serverKey, "2048": false }))
629 .then(() =>
630 openssl("req", {
631 new: true,
632 key: serverKey,
633 out: serverCsr,
634 subj: serverSubject,
635 }),
636 )
637 .then(() =>
638 openssl("x509", {
639 req: true,
640 in: serverCsr,
641 CA: caCert,
642 CAkey: caKey,
643 CAcreateserial: true,
644 CAserial: serverSrl,
645 out: serverCert,
646 }),
647 )
648 .then(() => undefined);
649 }
650
651 private writeToTempFile(content: string): Promise<string> {
652 return tmp
653 .file()
654 .then(path => fs.promises.writeFile(path.path, content).then(() => path.path));
655 }
656}
657
658/**
659 * @preserve
660 * End region: https://github.com/facebook/flipper/blob/v0.79.1/desktop/app/src/utils/CertificateProvider.tsx
661 */
662
663function getFilePath(fileName: string): string {
664 return path.resolve(os.homedir(), ".config", "vscode-react-native", "certs", fileName);
665}
666