feat(tls): cipher suite presets and a uTLS fingerprint warning

Adds PattN's semi-python and real-firefox client cipher lists as presets
in the cipher suites picker. A preset replaces the whole list because the
order is part of the ClientHello.

The inbound TLS form now warns when cipher suites are set under a uTLS
fingerprint other than "unsafe": clients ignore them there, while an
empty fingerprint is already pinned to unsafe in share links.
This commit is contained in:
MHSanaei
2026-10-25 15:54:48 +01:00
parent c3d4a153b3
commit f992694712
19 changed files with 134 additions and 3 deletions
@@ -1,10 +1,17 @@
import { Select } from 'antd';
import type { SelectProps } from 'antd';
import { useTranslation } from 'react-i18next';
import { TLS_CIPHER_OPTION } from '@/schemas/primitives';
import { TLS_CIPHER_OPTION, TLS_CIPHER_PRESETS } from '@/schemas/primitives';
const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v }));
const PRESET_PREFIX = 'preset:';
const PRESET_OPTIONS = Object.keys(TLS_CIPHER_PRESETS).map((name) => ({
value: PRESET_PREFIX + name,
label: name,
}));
type CipherSuitesSelectProps = Omit<
SelectProps<string[]>,
'value' | 'onChange' | 'mode' | 'options'
@@ -14,6 +21,13 @@ type CipherSuitesSelectProps = Omit<
onChange?: (value: string) => void;
};
function expandPreset(next: string[]): string[] {
const picked = [...next].reverse().find((v) => v.startsWith(PRESET_PREFIX));
if (!picked) return next;
const name = picked.slice(PRESET_PREFIX.length) as keyof typeof TLS_CIPHER_PRESETS;
return TLS_CIPHER_PRESETS[name].split(':');
}
// xray splits cipherSuites on ':' into a list, so the picker edits tags while
// the stored value stays the single colon-joined string xray reads.
export default function CipherSuitesSelect({
@@ -21,6 +35,7 @@ export default function CipherSuitesSelect({
onChange,
...rest
}: CipherSuitesSelectProps) {
const { t } = useTranslation();
const suites = value
.split(':')
.map((s) => s.trim())
@@ -31,9 +46,12 @@ export default function CipherSuitesSelect({
tokenSeparators={[':', ',']}
{...rest}
mode="tags"
options={CIPHER_SUITE_OPTIONS}
options={[
{ label: t('pages.inbounds.form.presets'), options: PRESET_OPTIONS },
{ label: t('pages.inbounds.form.cipherSuites'), options: CIPHER_SUITE_OPTIONS },
]}
value={suites}
onChange={(next) => onChange?.(next.join(':'))}
onChange={(next) => onChange?.(expandPreset(next).join(':'))}
/>
);
}
@@ -325,6 +325,14 @@ export default function TlsForm({
control,
name: 'streamSettings.tlsSettings.certificates',
});
const cipherSuites = useWatch({ control, name: 'streamSettings.tlsSettings.cipherSuites' });
const fingerprint = useWatch({
control,
name: 'streamSettings.tlsSettings.settings.fingerprint',
});
// An empty fingerprint is fine: share links pin it to unsafe whenever cs is set.
const cipherSuitesIgnoredByClients =
Boolean(cipherSuites) && Boolean(fingerprint) && fingerprint !== UTLS_FINGERPRINT.UTLS_UNSAFE;
return (
<>
<FormField name={['streamSettings', 'tlsSettings', 'serverName']} label="SNI">
@@ -333,6 +341,13 @@ export default function TlsForm({
<FormField
name={['streamSettings', 'tlsSettings', 'cipherSuites']}
label={t('pages.inbounds.form.cipherSuites')}
extra={
cipherSuitesIgnoredByClients ? (
<Typography.Text type="warning" data-testid="cipher-suites-fingerprint-warning">
{t('pages.inbounds.form.cipherSuitesNeedUnsafe')}
</Typography.Text>
) : undefined
}
>
<CipherSuitesSelect placeholder={t('pages.inbounds.form.autoOption')} />
</FormField>
@@ -74,6 +74,37 @@ export const TLS_CIPHER_OPTION = Object.freeze({
ECDHE_RSA_CHACHA20_POLY1305: 'TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256',
});
// Whole ordered client lists (PattN's presets): the order is part of the ClientHello,
// and clients apply them only under the uTLS fingerprint "unsafe".
export const TLS_CIPHER_PRESETS = Object.freeze({
'semi-python': [
'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384',
'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384',
'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256',
'TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256',
'TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256',
'TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA',
'TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA',
'TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256',
'TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256',
].join(':'),
'real-firefox': [
'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256',
'TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256',
'TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256',
'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384',
'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384',
'TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA',
'TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA',
'TLS_RSA_WITH_AES_128_GCM_SHA256',
'TLS_RSA_WITH_AES_256_GCM_SHA384',
'TLS_RSA_WITH_AES_128_CBC_SHA',
'TLS_RSA_WITH_AES_256_CBC_SHA',
].join(':'),
});
export const USAGE_OPTION = Object.freeze({
ENCIPHERMENT: 'encipherment',
VERIFY: 'verify',
@@ -24,6 +24,22 @@ describe('CipherSuitesSelect', () => {
expect(onChange).toHaveBeenLastCalledWith('TLS_AES_256_GCM_SHA384:MY_CUSTOM_SUITE');
});
// A preset is a whole ordered list (the order is part of the ClientHello), so
// picking one replaces the suites instead of appending a tag named after it.
it('replaces the suites with the picked preset list', async () => {
const onChange = renderSelect('TLS_AES_256_GCM_SHA384');
fireEvent.mouseDown(screen.getByRole('combobox', { name: 'cipher suites' }));
fireEvent.click(await screen.findByText('real-firefox'));
expect(onChange).toHaveBeenLastCalledWith(
'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256:TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256:' +
'TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256:TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256:' +
'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384:TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384:' +
'TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA:TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA:' +
'TLS_RSA_WITH_AES_128_GCM_SHA256:TLS_RSA_WITH_AES_256_GCM_SHA384:' +
'TLS_RSA_WITH_AES_128_CBC_SHA:TLS_RSA_WITH_AES_256_CBC_SHA',
);
});
it('stores an empty string once every suite is removed', () => {
const onChange = renderSelect('TLS_AES_256_GCM_SHA384');
const remove = document.querySelector('.ant-select-selection-item-remove');
@@ -136,3 +136,26 @@ describe('inbound TLS dynamic ECH helper', () => {
expect(screen.getByTestId('ech-cdn-warning').textContent).toMatch(/Cloudflare CDN/i);
});
});
// Xray drops cipherSuites under every uTLS fingerprint but unsafe; an empty one
// is left out because share links pin it to unsafe.
describe('inbound TLS cipherSuites fingerprint warning', () => {
function tlsValues(cipherSuites: string, fingerprint: string) {
return { streamSettings: { tlsSettings: { cipherSuites, settings: { fingerprint } } } };
}
it('warns when cipher suites sit under a uTLS fingerprint', async () => {
renderTls(tlsValues('TLS_AES_128_GCM_SHA256', 'chrome'));
expect(await screen.findByTestId('cipher-suites-fingerprint-warning')).toBeTruthy();
});
it.each([
['unsafe fingerprint', 'TLS_AES_128_GCM_SHA256', 'unsafe'],
['empty fingerprint', 'TLS_AES_128_GCM_SHA256', ''],
['no cipher suites', '', 'chrome'],
])('stays silent with %s', async (_, cipherSuites, fingerprint) => {
renderTls(tlsValues(cipherSuites, fingerprint));
await screen.findByTestId('ech-dynamic-helper');
expect(screen.queryByTestId('cipher-suites-fingerprint-warning')).toBeNull();
});
});