diff --git a/src/components/settings/proxy-settings-card.tsx b/src/components/settings/proxy-settings-card.tsx index bba413b3f..487fb4e91 100644 --- a/src/components/settings/proxy-settings-card.tsx +++ b/src/components/settings/proxy-settings-card.tsx @@ -1,10 +1,11 @@ import * as _ from 'lodash'; import * as React from 'react'; -import { observable, action, computed, runInAction } from 'mobx'; +import { observable, action, computed, flow, runInAction } from 'mobx'; import { observer, inject } from "mobx-react"; import { styled } from '../../styles'; import { isWindows } from '../../util/ui'; +import { asError } from '../../util/error'; import { WarningIcon, Icon } from '../../icons'; import { isValidPortConfiguration, ProxyStore } from '../../model/proxy-store'; @@ -18,6 +19,7 @@ import { KEY_LOG_FILE_SUPPORTED, DESKTOP_SELECT_SAVE_FILE_SUPPORTED } from '../../services/service-versions'; +import { ApiError, CertificateMode } from '../../services/server-api-types'; import { DesktopApi } from '../../services/desktop-api'; import { inputValidation } from '../component-utils'; @@ -38,8 +40,7 @@ import { import { StringSettingsList } from './string-settings-list'; const RestartApp = styled(SettingsButton).attrs(() => ({ - children: 'Restart app to activate', - onClick: () => window.location.reload() + children: 'Restart app to activate' }))` position: absolute; top: 18px; @@ -267,10 +268,33 @@ export class ProxySettingsCard extends React.Component< this.props.proxyStore!.keyLogFilePath = undefined; } + @observable + restarting = false; + + @observable + restartError: string | undefined; + + applySettingsAndReload = flow(function * (this: ProxySettingsCard) { + this.restarting = true; + this.restartError = undefined; + try { + yield this.props.proxyStore!.applyCertificateMode(); + window.location.reload(); + } catch (error) { + this.restartError = error instanceof ApiError && error.apiError?.message + ? error.apiError.message + : asError(error).message; + this.restarting = false; + } + }).bind(this); + render() { const { proxyStore, ...cardProps } = this.props; const { httpProxyPort, + certificateModes, + certificateMode, + currentCertificateMode, http2Enabled, http2CurrentlyEnabled, @@ -294,14 +318,19 @@ export class ProxySettingsCard extends React.Component< + {this.restartError &&

Could not apply proxy settings: {this.restartError}

} + Minimum port @@ -344,6 +373,38 @@ export class ProxySettingsCard extends React.Component< first free port above 8000 will be used instead. + {certificateModes.includes('emudhra') && <> + + + TLS signing certificate + + {certificateMode !== currentCertificateMode && + + } + + ) => { + proxyStore!.certificateMode = event.target.value as CertificateMode; + })} + > + + + + + + Sign intercepted HTTPS certificates with the local CA or the compromised + eMudhra CA. Modern prioritizes the emSign chain; legacy prioritizes + AAA Certificate Services. Restart the app to apply, then relaunch + intercepted applications. + + } + { versionSatisfies(serverVersion.value, TLS_PASSTHROUGH_SUPPORTED) && <> diff --git a/src/model/proxy-store.ts b/src/model/proxy-store.ts index d8f01998c..db3473c96 100644 --- a/src/model/proxy-store.ts +++ b/src/model/proxy-store.ts @@ -24,10 +24,12 @@ import { } from '../types'; import { getConfig, + setCertificateMode, announceServerReady, getNetworkInterfaces, NetworkInterfaces, } from '../services/server-api'; +import { CertificateMode } from '../services/server-api-types'; import { AccountStore } from './account/account-store'; import { delay } from '../util/promise'; @@ -236,6 +238,9 @@ export class ProxyStore { console.log('Server started'); yield getConfig(this.httpProxyPort).then((config) => { + this.certificateModes = config.certificateModes ?? []; + this.certificateMode = config.certificateMode ?? 'local'; + this.currentCertificateMode = this.certificateMode; this.certPath = config.certificatePath; this.certContent = config.certificateContent; this.certFingerprint = config.certificateFingerprint; @@ -313,6 +318,21 @@ export class ProxyStore { return this.adminClient.metadata.http.port; } + @observable + certificateModes: CertificateMode[] = []; + + @observable + certificateMode: CertificateMode = 'local'; + + @observable + currentCertificateMode: CertificateMode = 'local'; + + async applyCertificateMode(): Promise { + if (this.certificateMode !== this.currentCertificateMode) { + await setCertificateMode(this.certificateMode); + } + } + @persist @observable http2Enabled: true | false | 'fallback' = 'fallback'; private _http2CurrentlyEnabled = this.http2Enabled; diff --git a/src/services/server-api-types.ts b/src/services/server-api-types.ts index 198eab031..1d4711bfd 100644 --- a/src/services/server-api-types.ts +++ b/src/services/server-api-types.ts @@ -14,7 +14,11 @@ export interface NetworkInterfaces { [index: string]: NetworkInterfaceInfo[]; } +export type CertificateMode = 'local' | 'emudhra' | 'emudhra-old'; + export interface ServerConfig { + certificateMode?: CertificateMode; + certificateModes?: CertificateMode[]; certificatePath: string; certificateContent?: string; certificateFingerprint?: string; diff --git a/src/services/server-api.ts b/src/services/server-api.ts index 893e3be8a..bd239c5ec 100644 --- a/src/services/server-api.ts +++ b/src/services/server-api.ts @@ -4,7 +4,7 @@ import { SERVER_REST_API_SUPPORTED } from './service-versions'; -import { type ServerConfig, type NetworkInterfaces, type ServerInterceptor, ApiError, ActivationFailure, ActivationNonSuccess } from './server-api-types'; +import { type CertificateMode, type ServerConfig, type NetworkInterfaces, type ServerInterceptor, ApiError, ActivationFailure, ActivationNonSuccess } from './server-api-types'; export type { ServerConfig, NetworkInterfaces, ServerInterceptor }; import { GraphQLApiClient } from './server-graphql-api'; @@ -130,3 +130,9 @@ export async function triggerServerUpdate() { // We ignore all errors, this trigger is just advisory .catch(console.log); } + +export async function setCertificateMode(mode: CertificateMode): Promise { + const client = await getApiClient(); + if (!(client instanceof RestApiClient)) throw new Error('Update the HTTP Toolkit server to select a TLS signing certificate'); + await client.setCertificateMode(mode); +} diff --git a/src/services/server-rest-api.ts b/src/services/server-rest-api.ts index 382f6a26e..535d2c77a 100644 --- a/src/services/server-rest-api.ts +++ b/src/services/server-rest-api.ts @@ -9,6 +9,7 @@ import { import { RawHeaders } from '../types'; import { ServerConfig, + CertificateMode, NetworkInterfaces, ServerInterceptor, ApiError @@ -132,6 +133,10 @@ export class RestApiClient { return response.config; } + async setCertificateMode(certificateMode: CertificateMode): Promise { + await this.apiJsonRequest('POST', '/config/certificate', {}, { certificateMode }); + } + async getNetworkInterfaces() { const response = await this.apiJsonRequest<{ networkInterfaces: NetworkInterfaces diff --git a/test/unit/components/settings/proxy-settings-card.spec.tsx b/test/unit/components/settings/proxy-settings-card.spec.tsx new file mode 100644 index 000000000..9df42871f --- /dev/null +++ b/test/unit/components/settings/proxy-settings-card.spec.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { observable } from 'mobx'; +import { expect } from '../../../test-setup'; +import { ProxyStore } from '../../../../src/model/proxy-store'; +import { ApiError } from '../../../../src/services/server-api-types'; +import { ProxySettingsCard } from '../../../../src/components/settings/proxy-settings-card'; + +const ProxySettings = (ProxySettingsCard as any).wrappedComponent; + +describe('TLS signing certificate settings', () => { + const makeStore = () => observable({ + portConfig: undefined, + httpProxyPort: 8000, + http2Enabled: 'fallback', + http2CurrentlyEnabled: 'fallback', + tlsInterceptionConfig: { tlsPassthrough: [] }, + currentTlsInterceptionConfig: { tlsPassthrough: [] }, + keyLogFilePath: undefined, + currentKeyLogFilePath: undefined, + certificateModes: ['local', 'emudhra', 'emudhra-old'], + certificateMode: 'local', + currentCertificateMode: 'local', + applyCertificateMode: async () => { + throw new ApiError('unexpected 500', 'POST /config/certificate', 500, { + message: 'Could not save certificate settings' + }); + } + }) as unknown as ProxyStore; + + it('shows the active CA and stages a change before applying', () => { + const store = makeStore(); + const view = shallow(); + expect(view.find({ id: 'tls-signing-certificate' }).prop('value')).to.equal('local'); + expect(view.findWhere(n => n.prop('visible') !== undefined).prop('visible')).to.equal(false); + view.find({ id: 'tls-signing-certificate' }).simulate('change', { target: { value: 'emudhra' } }); + expect(store.certificateMode).to.equal('emudhra'); + expect(store.currentCertificateMode).to.equal('local'); + view.update(); + expect(view.findWhere(n => n.prop('visible') !== undefined).prop('visible')).to.equal(true); + view.unmount(); + }); + + it('offers modern and legacy chains and can stage the legacy choice', () => { + const store = makeStore(); + const view = shallow(); + expect(view.find('option[value="emudhra"]').text()).to.contain('modern trust stores'); + expect(view.find('option[value="emudhra-old"]').text()).to.contain('legacy trust stores'); + expect(view.find('option[value="emudhra-old"]').prop('disabled')).to.equal(false); + view.find({ id: 'tls-signing-certificate' }).simulate('change', { target: { value: 'emudhra-old' } }); + expect(store.certificateMode).to.equal('emudhra-old'); + expect(store.currentCertificateMode).to.equal('local'); + view.unmount(); + }); + + it('disables only the legacy choice when a server supports the modern chain alone', () => { + const store = makeStore(); + store.certificateModes = ['local', 'emudhra']; + const view = shallow(); + expect(view.find({ id: 'tls-signing-certificate' }).prop('disabled')).to.equal(false); + expect(view.find('option[value="emudhra-old"]').prop('disabled')).to.equal(true); + view.unmount(); + }); + + it('still offers restart for other proxy changes when the CA is unchanged', () => { + const store = makeStore(); + store.http2Enabled = false; + const view = shallow(); + expect(store.certificateMode).to.equal(store.currentCertificateMode); + expect(view.findWhere(n => n.prop('visible') !== undefined).prop('visible')).to.equal(true); + view.unmount(); + }); + + it('hides the setting for older servers', () => { + const store = makeStore(); + store.certificateModes = []; + const view = shallow(); + expect(view.find({ id: 'tls-signing-certificate' }).exists()).to.equal(false); + expect(view.findWhere(n => n.prop('visible') !== undefined).prop('visible')).to.equal(false); + view.unmount(); + }); + + it('keeps the app open and shows an error if saving fails', async () => { + const store = makeStore(); + store.certificateMode = 'emudhra'; + const view = shallow(); + await (view.instance() as any).applySettingsAndReload(); + view.update(); + expect(view.find({ role: 'alert' }).text()).to.contain('Could not save certificate settings'); + expect(view.findWhere(n => n.prop('visible') !== undefined).prop('disabled')).to.equal(false); + expect(store.currentCertificateMode).to.equal('local'); + view.unmount(); + }); +});