microsoft/vscode-react-native

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
1.5.1

Branches

Tags

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

Clone

HTTPS

Download ZIP

src/extension/networkInspector/certificateProvider.ts

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