feat(reality): warn when target cert chain is too small for ML-DSA-65 (#6470)

* feat(reality): warn when target cert chain is too small for ML-DSA-65

Expose peer cert-chain DER size from the REALITY scanner and surface a UI
warning when ML-DSA-65 is enabled but the chain is under xray-core's 3500-byte
minimum, so silent fallback failures are easier to catch.

Fixes #5973

* fix(reality): gate scanner ML-DSA tag and sync docs OpenAPI

Only warn on short cert chains in the target scanner when ML-DSA-65 is
enabled. Copy frontend/public/openapi.json to docs/public/openapi.json
and fix oxfmt wrapping in the new test.

---------

Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
This commit is contained in:
mrchatam
2026-09-12 12:43:45 +03:30
committed by GitHub
parent 5fbd2b490c
commit b467d4c676
23 changed files with 221 additions and 5 deletions
@@ -5,11 +5,15 @@ import type { ColumnsType } from 'antd/es/table';
import type { RealityScanResult } from '@/generated/types';
// xray-core ML-DSA-65 REALITY min peer cert-chain size (not defined in this repo).
export const MLDSA65_MIN_CERT_CHAIN_BYTES = 3500;
interface RealityTargetScannerModalProps {
open: boolean;
onClose: () => void;
scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
onPick: (result: RealityScanResult) => void;
mldsa65Enabled?: boolean;
}
export default function RealityTargetScannerModal({
@@ -17,6 +21,7 @@ export default function RealityTargetScannerModal({
onClose,
scanRealityCandidates,
onPick,
mldsa65Enabled = false,
}: RealityTargetScannerModalProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
@@ -126,6 +131,28 @@ export default function RealityTargetScannerModal({
<Tag>{t('pages.inbounds.form.scanCertInvalid')}</Tag>
),
},
{
title: t('pages.inbounds.form.scanCertChain'),
dataIndex: 'certChainBytes',
key: 'certChainBytes',
width: 100,
render: (bytes: number) => {
if (!bytes) return '—';
if (mldsa65Enabled && bytes < MLDSA65_MIN_CERT_CHAIN_BYTES) {
return (
<Tooltip
title={t('pages.inbounds.form.scanMldsaCertChainTooSmall', {
length: bytes,
min: MLDSA65_MIN_CERT_CHAIN_BYTES,
})}
>
<Tag color="warning">{bytes} B</Tag>
</Tooltip>
);
}
return `${bytes} B`;
},
},
{
title: t('pages.inbounds.form.scanLatency'),
dataIndex: 'latencyMs',
@@ -165,7 +192,7 @@ export default function RealityTargetScannerModal({
</Button>,
]}
title={t('pages.inbounds.form.scanModalTitle')}
width={960}
width={1080}
>
<Space orientation="vertical" size="small" style={{ width: '100%' }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { useFormContext, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import {
Alert,
@@ -25,7 +25,9 @@ import {
validateRealityTarget,
} from '@/lib/xray/stream-wire-normalize';
import type { RealityScanResult } from '@/generated/types';
import RealityTargetScannerModal from './RealityTargetScannerModal';
import RealityTargetScannerModal, {
MLDSA65_MIN_CERT_CHAIN_BYTES,
} from './RealityTargetScannerModal';
interface RealityFormProps {
saving: boolean;
@@ -59,6 +61,18 @@ export default function RealityForm({
const { t } = useTranslation();
const { getFieldState, trigger } = useFormContext();
const [scannerOpen, setScannerOpen] = useState(false);
const mldsa65Seed = useWatch({ name: 'streamSettings.realitySettings.mldsa65Seed' });
const mldsa65Verify = useWatch({
name: 'streamSettings.realitySettings.settings.mldsa65Verify',
});
const mldsa65Enabled =
(typeof mldsa65Seed === 'string' && mldsa65Seed.trim() !== '') ||
(typeof mldsa65Verify === 'string' && mldsa65Verify.trim() !== '');
const mldsaChainTooSmall =
!!scanResult &&
mldsa65Enabled &&
scanResult.certChainBytes > 0 &&
scanResult.certChainBytes < MLDSA65_MIN_CERT_CHAIN_BYTES;
/*
* An untrusted certificate (self-signed fronting service on the LAN) is still
* worth reading, so subject/issuer stay visible and only the verdict is added.
@@ -130,7 +144,11 @@ export default function RealityForm({
{scanResult && (
<Form.Item label=" " colon={false}>
<Alert
type={scanResult.feasible && !scanResult.privateTarget ? 'success' : 'warning'}
type={
scanResult.feasible && !scanResult.privateTarget && !mldsaChainTooSmall
? 'success'
: 'warning'
}
showIcon
title={
scanResult.feasible
@@ -139,6 +157,14 @@ export default function RealityForm({
}
description={
<>
{mldsaChainTooSmall && (
<div style={{ marginBottom: 8 }}>
{t('pages.inbounds.form.scanMldsaCertChainTooSmall', {
length: scanResult.certChainBytes,
min: MLDSA65_MIN_CERT_CHAIN_BYTES,
})}
</div>
)}
{scanResult.privateTarget && (
<div style={{ marginBottom: 8 }}>{t('pages.inbounds.form.scanPrivateNote')}</div>
)}
@@ -159,6 +185,9 @@ export default function RealityForm({
? dayjs(scanResult.notAfter).format('YYYY-MM-DD HH:mm')
: '—'}
</Descriptions.Item>
<Descriptions.Item label={t('pages.inbounds.form.scanCertChain')}>
{scanResult.certChainBytes > 0 ? `${scanResult.certChainBytes} B` : '—'}
</Descriptions.Item>
<Descriptions.Item label={t('pages.inbounds.form.scanLatency')}>
{scanResult.latencyMs > 0 ? `${scanResult.latencyMs} ms` : '—'}
</Descriptions.Item>
@@ -330,6 +359,7 @@ export default function RealityForm({
onClose={() => setScannerOpen(false)}
scanRealityCandidates={scanRealityCandidates}
onPick={(r) => applyRealityScanResult(r, true)}
mldsa65Enabled={mldsa65Enabled}
/>
</>
);