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();
+ });
+});