Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 65 additions & 4 deletions src/components/settings/proxy-settings-card.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -294,14 +318,19 @@ export class ProxySettingsCard extends React.Component<
</CollapsibleCardHeading>
</header>
<RestartApp
onClick={this.applySettingsAndReload}
disabled={this.restarting}
visible={
(this.isCurrentPortConfigValid && !this.isCurrentPortInRange) ||
http2Enabled !== http2CurrentlyEnabled ||
!_.isEqual(tlsInterceptionConfig, currentTlsInterceptionConfig) ||
keyLogFilePath !== currentKeyLogFilePath
keyLogFilePath !== currentKeyLogFilePath ||
certificateMode !== currentCertificateMode
}
/>

{this.restartError && <p role="alert">Could not apply proxy settings: {this.restartError}</p>}

<ProxyPortsContainer>
<ContentLabel>
Minimum port
Expand Down Expand Up @@ -344,6 +373,38 @@ export class ProxySettingsCard extends React.Component<
first free port above 8000 will be used instead.
</SettingsExplanation>

{certificateModes.includes('emudhra') && <>
<SettingsSubheadingRow>
<SettingsSubheading as="label" htmlFor="tls-signing-certificate">
TLS signing certificate
</SettingsSubheading>
{certificateMode !== currentCertificateMode &&
<UnsavedIcon title="Restart app to apply changes" />
}
</SettingsSubheadingRow>
<Http2Select
id="tls-signing-certificate"
style={{ display: 'block', marginBottom: 10 }}
value={certificateMode}
disabled={this.restarting}
onChange={action((event: React.ChangeEvent<HTMLSelectElement>) => {
proxyStore!.certificateMode = event.target.value as CertificateMode;
})}
>
<option value="local">HTTP Toolkit local CA (default)</option>
<option value="emudhra">Compromised eMudhra CA (modern trust stores)</option>
<option value="emudhra-old" disabled={!certificateModes.includes('emudhra-old')}>
Compromised eMudhra CA (legacy trust stores)
</option>
</Http2Select>
<SettingsExplanation>
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.
</SettingsExplanation>
</>}

{
versionSatisfies(serverVersion.value, TLS_PASSTHROUGH_SUPPORTED) && <>
<SettingsSubheadingRow>
Expand Down
20 changes: 20 additions & 0 deletions src/model/proxy-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> {
if (this.certificateMode !== this.currentCertificateMode) {
await setCertificateMode(this.certificateMode);
}
}

@persist @observable
http2Enabled: true | false | 'fallback' = 'fallback';
private _http2CurrentlyEnabled = this.http2Enabled;
Expand Down
4 changes: 4 additions & 0 deletions src/services/server-api-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 7 additions & 1 deletion src/services/server-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
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);
}
5 changes: 5 additions & 0 deletions src/services/server-rest-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { RawHeaders } from '../types';
import {
ServerConfig,
CertificateMode,
NetworkInterfaces,
ServerInterceptor,
ApiError
Expand Down Expand Up @@ -132,6 +133,10 @@ export class RestApiClient {
return response.config;
}

async setCertificateMode(certificateMode: CertificateMode): Promise<void> {
await this.apiJsonRequest('POST', '/config/certificate', {}, { certificateMode });
}

async getNetworkInterfaces() {
const response = await this.apiJsonRequest<{
networkInterfaces: NetworkInterfaces
Expand Down
94 changes: 94 additions & 0 deletions test/unit/components/settings/proxy-settings-card.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<ProxySettings proxyStore={store} ariaLabel="Proxy settings" />);
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(<ProxySettings proxyStore={store} ariaLabel="Proxy settings" />);
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(<ProxySettings proxyStore={store} ariaLabel="Proxy settings" />);
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(<ProxySettings proxyStore={store} ariaLabel="Proxy settings" />);
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(<ProxySettings proxyStore={store} ariaLabel="Proxy settings" />);
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(<ProxySettings proxyStore={store} ariaLabel="Proxy settings" />);
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();
});
});