Просмотр исходного кода

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 <[email protected]>
mrchatam 3 часов назад
Родитель
Сommit
b467d4c676

+ 8 - 0
docs/public/openapi.json

@@ -3710,6 +3710,11 @@
             "example": "h2",
             "example": "h2",
             "type": "string"
             "type": "string"
           },
           },
+          "certChainBytes": {
+            "description": "CertChainBytes is the sum of DER lengths of the presented peer chain.\nxray-core ML-DSA-65 REALITY needs >= 3500 bytes (constant lives in xray-core).",
+            "example": 3427,
+            "type": "integer"
+          },
           "certChainValid": {
           "certChainValid": {
             "description": "CertChainValid ignores the name: a trusted chain presented for other names\nstill has serverNames the panel can offer instead of the failing SNI.",
             "description": "CertChainValid ignores the name: a trusted chain presented for other names\nstill has serverNames the panel can offer instead of the failing SNI.",
             "example": true,
             "example": true,
@@ -3792,6 +3797,7 @@
         },
         },
         "required": [
         "required": [
           "alpn",
           "alpn",
+          "certChainBytes",
           "certChainValid",
           "certChainValid",
           "certIssuer",
           "certIssuer",
           "certSubject",
           "certSubject",
@@ -7240,6 +7246,7 @@
                   "success": true,
                   "success": true,
                   "obj": {
                   "obj": {
                     "alpn": "h2",
                     "alpn": "h2",
+                    "certChainBytes": 3427,
                     "certChainValid": true,
                     "certChainValid": true,
                     "certIssuer": "Google Trust Services",
                     "certIssuer": "Google Trust Services",
                     "certSubject": "cloudflare.com",
                     "certSubject": "cloudflare.com",
@@ -7319,6 +7326,7 @@
                   "obj": [
                   "obj": [
                     {
                     {
                       "alpn": "h2",
                       "alpn": "h2",
+                      "certChainBytes": 3427,
                       "certChainValid": true,
                       "certChainValid": true,
                       "certIssuer": "Google Trust Services",
                       "certIssuer": "Google Trust Services",
                       "certSubject": "cloudflare.com",
                       "certSubject": "cloudflare.com",

+ 8 - 0
frontend/public/openapi.json

@@ -3710,6 +3710,11 @@
             "example": "h2",
             "example": "h2",
             "type": "string"
             "type": "string"
           },
           },
+          "certChainBytes": {
+            "description": "CertChainBytes is the sum of DER lengths of the presented peer chain.\nxray-core ML-DSA-65 REALITY needs >= 3500 bytes (constant lives in xray-core).",
+            "example": 3427,
+            "type": "integer"
+          },
           "certChainValid": {
           "certChainValid": {
             "description": "CertChainValid ignores the name: a trusted chain presented for other names\nstill has serverNames the panel can offer instead of the failing SNI.",
             "description": "CertChainValid ignores the name: a trusted chain presented for other names\nstill has serverNames the panel can offer instead of the failing SNI.",
             "example": true,
             "example": true,
@@ -3792,6 +3797,7 @@
         },
         },
         "required": [
         "required": [
           "alpn",
           "alpn",
+          "certChainBytes",
           "certChainValid",
           "certChainValid",
           "certIssuer",
           "certIssuer",
           "certSubject",
           "certSubject",
@@ -7240,6 +7246,7 @@
                   "success": true,
                   "success": true,
                   "obj": {
                   "obj": {
                     "alpn": "h2",
                     "alpn": "h2",
+                    "certChainBytes": 3427,
                     "certChainValid": true,
                     "certChainValid": true,
                     "certIssuer": "Google Trust Services",
                     "certIssuer": "Google Trust Services",
                     "certSubject": "cloudflare.com",
                     "certSubject": "cloudflare.com",
@@ -7319,6 +7326,7 @@
                   "obj": [
                   "obj": [
                     {
                     {
                       "alpn": "h2",
                       "alpn": "h2",
+                      "certChainBytes": 3427,
                       "certChainValid": true,
                       "certChainValid": true,
                       "certIssuer": "Google Trust Services",
                       "certIssuer": "Google Trust Services",
                       "certSubject": "cloudflare.com",
                       "certSubject": "cloudflare.com",

+ 1 - 0
frontend/src/generated/examples.ts

@@ -916,6 +916,7 @@ export const EXAMPLES: Record<string, unknown> = {
   },
   },
   "RealityScanResult": {
   "RealityScanResult": {
     "alpn": "h2",
     "alpn": "h2",
+    "certChainBytes": 3427,
     "certChainValid": true,
     "certChainValid": true,
     "certIssuer": "Google Trust Services",
     "certIssuer": "Google Trust Services",
     "certSubject": "cloudflare.com",
     "certSubject": "cloudflare.com",

+ 6 - 0
frontend/src/generated/schemas.ts

@@ -3684,6 +3684,11 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": "h2",
         "example": "h2",
         "type": "string"
         "type": "string"
       },
       },
+      "certChainBytes": {
+        "description": "CertChainBytes is the sum of DER lengths of the presented peer chain.\nxray-core ML-DSA-65 REALITY needs \u003e= 3500 bytes (constant lives in xray-core).",
+        "example": 3427,
+        "type": "integer"
+      },
       "certChainValid": {
       "certChainValid": {
         "description": "CertChainValid ignores the name: a trusted chain presented for other names\nstill has serverNames the panel can offer instead of the failing SNI.",
         "description": "CertChainValid ignores the name: a trusted chain presented for other names\nstill has serverNames the panel can offer instead of the failing SNI.",
         "example": true,
         "example": true,
@@ -3766,6 +3771,7 @@ export const SCHEMAS: Record<string, unknown> = {
     },
     },
     "required": [
     "required": [
       "alpn",
       "alpn",
+      "certChainBytes",
       "certChainValid",
       "certChainValid",
       "certIssuer",
       "certIssuer",
       "certSubject",
       "certSubject",

+ 1 - 0
frontend/src/generated/types.ts

@@ -835,6 +835,7 @@ export interface ProbeResultUI {
 
 
 export interface RealityScanResult {
 export interface RealityScanResult {
   alpn: string;
   alpn: string;
+  certChainBytes: number;
   certChainValid: boolean;
   certChainValid: boolean;
   certIssuer: string;
   certIssuer: string;
   certSubject: string;
   certSubject: string;

+ 1 - 0
frontend/src/generated/zod.ts

@@ -892,6 +892,7 @@ export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
 
 
 export const RealityScanResultSchema = z.object({
 export const RealityScanResultSchema = z.object({
   alpn: z.string(),
   alpn: z.string(),
+  certChainBytes: z.number().int(),
   certChainValid: z.boolean(),
   certChainValid: z.boolean(),
   certIssuer: z.string(),
   certIssuer: z.string(),
   certSubject: z.string(),
   certSubject: z.string(),

+ 28 - 1
frontend/src/pages/inbounds/form/security/RealityTargetScannerModal.tsx

@@ -5,11 +5,15 @@ import type { ColumnsType } from 'antd/es/table';
 
 
 import type { RealityScanResult } from '@/generated/types';
 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 {
 interface RealityTargetScannerModalProps {
   open: boolean;
   open: boolean;
   onClose: () => void;
   onClose: () => void;
   scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
   scanRealityCandidates: (targets?: string) => Promise<RealityScanResult[]>;
   onPick: (result: RealityScanResult) => void;
   onPick: (result: RealityScanResult) => void;
+  mldsa65Enabled?: boolean;
 }
 }
 
 
 export default function RealityTargetScannerModal({
 export default function RealityTargetScannerModal({
@@ -17,6 +21,7 @@ export default function RealityTargetScannerModal({
   onClose,
   onClose,
   scanRealityCandidates,
   scanRealityCandidates,
   onPick,
   onPick,
+  mldsa65Enabled = false,
 }: RealityTargetScannerModalProps) {
 }: RealityTargetScannerModalProps) {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const [loading, setLoading] = useState(false);
   const [loading, setLoading] = useState(false);
@@ -126,6 +131,28 @@ export default function RealityTargetScannerModal({
           <Tag>{t('pages.inbounds.form.scanCertInvalid')}</Tag>
           <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'),
       title: t('pages.inbounds.form.scanLatency'),
       dataIndex: 'latencyMs',
       dataIndex: 'latencyMs',
@@ -165,7 +192,7 @@ export default function RealityTargetScannerModal({
         </Button>,
         </Button>,
       ]}
       ]}
       title={t('pages.inbounds.form.scanModalTitle')}
       title={t('pages.inbounds.form.scanModalTitle')}
-      width={960}
+      width={1080}
     >
     >
       <Space orientation="vertical" size="small" style={{ width: '100%' }}>
       <Space orientation="vertical" size="small" style={{ width: '100%' }}>
         <Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>
         <Typography.Paragraph type="secondary" style={{ marginBottom: 0 }}>

+ 33 - 3
frontend/src/pages/inbounds/form/security/reality.tsx

@@ -1,5 +1,5 @@
 import { useState } from 'react';
 import { useState } from 'react';
-import { useFormContext } from 'react-hook-form';
+import { useFormContext, useWatch } from 'react-hook-form';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import {
 import {
   Alert,
   Alert,
@@ -25,7 +25,9 @@ import {
   validateRealityTarget,
   validateRealityTarget,
 } from '@/lib/xray/stream-wire-normalize';
 } from '@/lib/xray/stream-wire-normalize';
 import type { RealityScanResult } from '@/generated/types';
 import type { RealityScanResult } from '@/generated/types';
-import RealityTargetScannerModal from './RealityTargetScannerModal';
+import RealityTargetScannerModal, {
+  MLDSA65_MIN_CERT_CHAIN_BYTES,
+} from './RealityTargetScannerModal';
 
 
 interface RealityFormProps {
 interface RealityFormProps {
   saving: boolean;
   saving: boolean;
@@ -59,6 +61,18 @@ export default function RealityForm({
   const { t } = useTranslation();
   const { t } = useTranslation();
   const { getFieldState, trigger } = useFormContext();
   const { getFieldState, trigger } = useFormContext();
   const [scannerOpen, setScannerOpen] = useState(false);
   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
    * 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.
    * worth reading, so subject/issuer stay visible and only the verdict is added.
@@ -130,7 +144,11 @@ export default function RealityForm({
       {scanResult && (
       {scanResult && (
         <Form.Item label=" " colon={false}>
         <Form.Item label=" " colon={false}>
           <Alert
           <Alert
-            type={scanResult.feasible && !scanResult.privateTarget ? 'success' : 'warning'}
+            type={
+              scanResult.feasible && !scanResult.privateTarget && !mldsaChainTooSmall
+                ? 'success'
+                : 'warning'
+            }
             showIcon
             showIcon
             title={
             title={
               scanResult.feasible
               scanResult.feasible
@@ -139,6 +157,14 @@ export default function RealityForm({
             }
             }
             description={
             description={
               <>
               <>
+                {mldsaChainTooSmall && (
+                  <div style={{ marginBottom: 8 }}>
+                    {t('pages.inbounds.form.scanMldsaCertChainTooSmall', {
+                      length: scanResult.certChainBytes,
+                      min: MLDSA65_MIN_CERT_CHAIN_BYTES,
+                    })}
+                  </div>
+                )}
                 {scanResult.privateTarget && (
                 {scanResult.privateTarget && (
                   <div style={{ marginBottom: 8 }}>{t('pages.inbounds.form.scanPrivateNote')}</div>
                   <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')
                       ? dayjs(scanResult.notAfter).format('YYYY-MM-DD HH:mm')
                       : '—'}
                       : '—'}
                   </Descriptions.Item>
                   </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')}>
                   <Descriptions.Item label={t('pages.inbounds.form.scanLatency')}>
                     {scanResult.latencyMs > 0 ? `${scanResult.latencyMs} ms` : '—'}
                     {scanResult.latencyMs > 0 ? `${scanResult.latencyMs} ms` : '—'}
                   </Descriptions.Item>
                   </Descriptions.Item>
@@ -330,6 +359,7 @@ export default function RealityForm({
         onClose={() => setScannerOpen(false)}
         onClose={() => setScannerOpen(false)}
         scanRealityCandidates={scanRealityCandidates}
         scanRealityCandidates={scanRealityCandidates}
         onPick={(r) => applyRealityScanResult(r, true)}
         onPick={(r) => applyRealityScanResult(r, true)}
+        mldsa65Enabled={mldsa65Enabled}
       />
       />
     </>
     </>
   );
   );

+ 102 - 0
frontend/src/test/reality-mldsa-cert-chain.test.tsx

@@ -0,0 +1,102 @@
+import { describe, it, expect } from 'vitest';
+import { Form } from 'antd';
+import type { ReactNode } from 'react';
+import { FormProvider, useForm } from 'react-hook-form';
+
+import { RealityForm } from '@/pages/inbounds/form/security';
+import RealityTargetScannerModal from '@/pages/inbounds/form/security/RealityTargetScannerModal';
+import type { InboundFormValues } from '@/schemas/forms/inbound-form';
+import type { RealityScanResult } from '@/generated/types';
+import { renderWithProviders } from './test-utils';
+
+const smallChain: RealityScanResult = {
+  alpn: 'h2',
+  certChainBytes: 3427,
+  certChainValid: true,
+  certIssuer: 'Google Trust Services',
+  certSubject: 'cloudflare.com',
+  certValid: true,
+  curveID: 'X25519',
+  feasible: true,
+  h2: true,
+  host: 'www.cloudflare.com',
+  ip: '104.16.124.96',
+  latencyMs: 180,
+  notAfter: '2026-08-01T00:00:00Z',
+  port: 443,
+  privateTarget: false,
+  reason: '',
+  serverNames: ['www.cloudflare.com'],
+  target: 'www.cloudflare.com:443',
+  tls13: true,
+  tlsVersion: '1.3',
+  x25519: true,
+};
+
+function FormHarness({
+  children,
+  defaultValues,
+}: {
+  children: ReactNode;
+  defaultValues?: Record<string, unknown>;
+}) {
+  const methods = useForm<InboundFormValues>({ defaultValues: defaultValues as never });
+  return (
+    <FormProvider {...methods}>
+      <Form>{children}</Form>
+    </FormProvider>
+  );
+}
+
+const noop = () => {};
+
+function renderRealityForm(
+  scanResult: RealityScanResult | null,
+  defaultValues?: Record<string, unknown>,
+) {
+  return renderWithProviders(
+    <FormHarness defaultValues={defaultValues}>
+      <RealityForm
+        saving={false}
+        scanning={false}
+        scanResult={scanResult}
+        scanRealityTarget={noop}
+        scanRealityCandidates={async () => []}
+        applyRealityScanResult={noop}
+        randomizeShortIds={noop}
+        randomizeSpiderX={noop}
+        genRealityKeypair={noop}
+        clearRealityKeypair={noop}
+        genMldsa65={noop}
+        clearMldsa65={noop}
+      />
+    </FormHarness>,
+  );
+}
+
+describe('ML-DSA-65 cert chain warning', () => {
+  it('warns on the inbound form when ML-DSA-65 is on and the scanned chain is under 3500 bytes', () => {
+    const { getByText } = renderRealityForm(smallChain, {
+      streamSettings: { realitySettings: { mldsa65Seed: 'seed' } },
+    });
+    expect(getByText(/below the 3500-byte minimum required for ML-DSA-65/)).toBeTruthy();
+  });
+
+  it('does not warn on the inbound form when ML-DSA-65 is off', () => {
+    const { queryByText } = renderRealityForm(smallChain);
+    expect(queryByText(/below the 3500-byte minimum required for ML-DSA-65/)).toBeNull();
+  });
+
+  it('tags scanner rows whose cert chain is too small for ML-DSA-65', async () => {
+    const { findByText } = renderWithProviders(
+      <RealityTargetScannerModal
+        open
+        onClose={noop}
+        scanRealityCandidates={async () => [smallChain]}
+        onPick={noop}
+        mldsa65Enabled
+      />,
+    );
+    expect(await findByText('3427 B')).toBeTruthy();
+  });
+});

+ 7 - 1
internal/web/service/reality_scan.go

@@ -57,7 +57,10 @@ type RealityScanResult struct {
 	CertValid     bool   `json:"certValid" example:"true"`
 	CertValid     bool   `json:"certValid" example:"true"`
 	// CertChainValid ignores the name: a trusted chain presented for other names
 	// CertChainValid ignores the name: a trusted chain presented for other names
 	// still has serverNames the panel can offer instead of the failing SNI.
 	// still has serverNames the panel can offer instead of the failing SNI.
-	CertChainValid bool     `json:"certChainValid" example:"true"`
+	CertChainValid bool `json:"certChainValid" example:"true"`
+	// CertChainBytes is the sum of DER lengths of the presented peer chain.
+	// xray-core ML-DSA-65 REALITY needs >= 3500 bytes (constant lives in xray-core).
+	CertChainBytes int      `json:"certChainBytes" example:"3427"`
 	CertSubject    string   `json:"certSubject" example:"cloudflare.com"`
 	CertSubject    string   `json:"certSubject" example:"cloudflare.com"`
 	CertIssuer     string   `json:"certIssuer" example:"Google Trust Services"`
 	CertIssuer     string   `json:"certIssuer" example:"Google Trust Services"`
 	NotAfter       string   `json:"notAfter" example:"2026-08-01T00:00:00Z"`
 	NotAfter       string   `json:"notAfter" example:"2026-08-01T00:00:00Z"`
@@ -253,6 +256,9 @@ func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string,
 	verifyHost := sni
 	verifyHost := sni
 	if len(st.PeerCertificates) > 0 {
 	if len(st.PeerCertificates) > 0 {
 		leaf := st.PeerCertificates[0]
 		leaf := st.PeerCertificates[0]
+		for _, cert := range st.PeerCertificates {
+			res.CertChainBytes += len(cert.Raw)
+		}
 		res.CertSubject = leaf.Subject.CommonName
 		res.CertSubject = leaf.Subject.CommonName
 		if res.CertSubject == "" && len(leaf.DNSNames) > 0 {
 		if res.CertSubject == "" && len(leaf.DNSNames) > 0 {
 			res.CertSubject = leaf.DNSNames[0]
 			res.CertSubject = leaf.DNSNames[0]

+ 2 - 0
internal/web/translation/ar-EG.json

@@ -628,6 +628,8 @@
         "scanCert": "الشهادة",
         "scanCert": "الشهادة",
         "scanCertInvalid": "غير موثوق",
         "scanCertInvalid": "غير موثوق",
         "scanCertExpiry": "انتهاء صلاحية الشهادة",
         "scanCertExpiry": "انتهاء صلاحية الشهادة",
+        "scanCertChain": "سلسلة الشهادات",
+        "scanMldsaCertChainTooSmall": "سلسلة شهادات الهدف {length} بايت، وهي أقل من الحد الأدنى {min} بايت المطلوب لـ ML-DSA-65. سيؤدي ذلك إلى فشل الاتصال بصمت. يُرجى اختيار هدف مختلف أو تعطيل ML-DSA-65.",
         "scanSniUsed": "SNI المستخدم",
         "scanSniUsed": "SNI المستخدم",
         "scanPrivateNote": "تم الفحص عبر شبكة خاصة/محلية — هذا العنوان غير قابل للوصول من الإنترنت.",
         "scanPrivateNote": "تم الفحص عبر شبكة خاصة/محلية — هذا العنوان غير قابل للوصول من الإنترنت.",
         "scanPrivateConfirmTitle": "الهدف في شبكة محلية",
         "scanPrivateConfirmTitle": "الهدف في شبكة محلية",

+ 2 - 0
internal/web/translation/en-US.json

@@ -642,6 +642,8 @@
         "scanCert": "Certificate",
         "scanCert": "Certificate",
         "scanCertInvalid": "Not trusted",
         "scanCertInvalid": "Not trusted",
         "scanCertExpiry": "Certificate expires",
         "scanCertExpiry": "Certificate expires",
+        "scanCertChain": "Cert chain",
+        "scanMldsaCertChainTooSmall": "Target certificate chain is {length} bytes, which is below the {min}-byte minimum required for ML-DSA-65. This will cause silent connection failures. Please choose a different target or disable ML-DSA-65.",
         "scanSniUsed": "SNI used",
         "scanSniUsed": "SNI used",
         "scanPrivateNote": "Checked over a private/local network — this address is not reachable from the internet.",
         "scanPrivateNote": "Checked over a private/local network — this address is not reachable from the internet.",
         "scanPrivateConfirmTitle": "Target on a local network",
         "scanPrivateConfirmTitle": "Target on a local network",

+ 2 - 0
internal/web/translation/es-ES.json

@@ -649,6 +649,8 @@
         "scanCert": "Certificado",
         "scanCert": "Certificado",
         "scanCertInvalid": "No confiable",
         "scanCertInvalid": "No confiable",
         "scanCertExpiry": "El certificado caduca",
         "scanCertExpiry": "El certificado caduca",
+        "scanCertChain": "Cadena de certificados",
+        "scanMldsaCertChainTooSmall": "La cadena de certificados del destino mide {length} bytes, por debajo del mínimo de {min} bytes que requiere ML-DSA-65. Las conexiones fallarán en silencio. Elige otro destino o desactiva ML-DSA-65.",
         "scanSniUsed": "SNI utilizado",
         "scanSniUsed": "SNI utilizado",
         "scanPrivateNote": "Comprobado en una red privada/local: esta dirección no es accesible desde internet.",
         "scanPrivateNote": "Comprobado en una red privada/local: esta dirección no es accesible desde internet.",
         "scanPrivateConfirmTitle": "Destino en una red local",
         "scanPrivateConfirmTitle": "Destino en una red local",

+ 2 - 0
internal/web/translation/fa-IR.json

@@ -640,6 +640,8 @@
         "scanCert": "گواهی",
         "scanCert": "گواهی",
         "scanCertInvalid": "نامعتبر",
         "scanCertInvalid": "نامعتبر",
         "scanCertExpiry": "انقضای گواهی",
         "scanCertExpiry": "انقضای گواهی",
+        "scanCertChain": "زنجیره گواهی",
+        "scanMldsaCertChainTooSmall": "زنجیره گواهی هدف {length} بایت است که کمتر از حداقل {min} بایت مورد نیاز ML-DSA-65 است. این باعث شکست بی‌صدای اتصال می‌شود. هدف دیگری انتخاب کنید یا ML-DSA-65 را غیرفعال کنید.",
         "scanSniUsed": "SNI استفاده‌شده",
         "scanSniUsed": "SNI استفاده‌شده",
         "scanPrivateNote": "بررسی از طریق شبکهٔ خصوصی/محلی انجام شد — این نشانی از اینترنت قابل دسترسی نیست.",
         "scanPrivateNote": "بررسی از طریق شبکهٔ خصوصی/محلی انجام شد — این نشانی از اینترنت قابل دسترسی نیست.",
         "scanPrivateConfirmTitle": "هدف در شبکهٔ محلی",
         "scanPrivateConfirmTitle": "هدف در شبکهٔ محلی",

+ 2 - 0
internal/web/translation/id-ID.json

@@ -628,6 +628,8 @@
         "scanCert": "Sertifikat",
         "scanCert": "Sertifikat",
         "scanCertInvalid": "Tidak tepercaya",
         "scanCertInvalid": "Tidak tepercaya",
         "scanCertExpiry": "Sertifikat kedaluwarsa",
         "scanCertExpiry": "Sertifikat kedaluwarsa",
+        "scanCertChain": "Rantai sertifikat",
+        "scanMldsaCertChainTooSmall": "Rantai sertifikat target berukuran {length} byte, di bawah minimum {min} byte yang dibutuhkan ML-DSA-65. Ini akan menyebabkan koneksi gagal tanpa pesan error. Pilih target lain atau nonaktifkan ML-DSA-65.",
         "scanSniUsed": "SNI yang dipakai",
         "scanSniUsed": "SNI yang dipakai",
         "scanPrivateNote": "Diperiksa melalui jaringan privat/lokal — alamat ini tidak dapat dijangkau dari internet.",
         "scanPrivateNote": "Diperiksa melalui jaringan privat/lokal — alamat ini tidak dapat dijangkau dari internet.",
         "scanPrivateConfirmTitle": "Target di jaringan lokal",
         "scanPrivateConfirmTitle": "Target di jaringan lokal",

+ 2 - 0
internal/web/translation/ja-JP.json

@@ -649,6 +649,8 @@
         "scanCert": "証明書",
         "scanCert": "証明書",
         "scanCertInvalid": "信頼できません",
         "scanCertInvalid": "信頼できません",
         "scanCertExpiry": "証明書の有効期限",
         "scanCertExpiry": "証明書の有効期限",
+        "scanCertChain": "証明書チェーン",
+        "scanMldsaCertChainTooSmall": "ターゲットの証明書チェーンは {length} バイトで、ML-DSA-65 に必要な下限 {min} バイトを下回っています。接続はエラーなく失敗します。別のターゲットを選ぶか ML-DSA-65 を無効にしてください。",
         "scanSniUsed": "使用した SNI",
         "scanSniUsed": "使用した SNI",
         "scanPrivateNote": "プライベート/ローカルネットワーク経由で確認しました。このアドレスはインターネットからは到達できません。",
         "scanPrivateNote": "プライベート/ローカルネットワーク経由で確認しました。このアドレスはインターネットからは到達できません。",
         "scanPrivateConfirmTitle": "ローカルネットワーク上のターゲット",
         "scanPrivateConfirmTitle": "ローカルネットワーク上のターゲット",

+ 2 - 0
internal/web/translation/pt-BR.json

@@ -649,6 +649,8 @@
         "scanCert": "Certificado",
         "scanCert": "Certificado",
         "scanCertInvalid": "Não confiável",
         "scanCertInvalid": "Não confiável",
         "scanCertExpiry": "Certificado expira",
         "scanCertExpiry": "Certificado expira",
+        "scanCertChain": "Cadeia de certificados",
+        "scanMldsaCertChainTooSmall": "A cadeia de certificados do destino tem {length} bytes, abaixo do mínimo de {min} bytes exigido pelo ML-DSA-65. Isso causará falhas silenciosas de conexão. Escolha outro destino ou desative o ML-DSA-65.",
         "scanSniUsed": "SNI utilizado",
         "scanSniUsed": "SNI utilizado",
         "scanPrivateNote": "Verificado em uma rede privada/local — este endereço não é acessível pela internet.",
         "scanPrivateNote": "Verificado em uma rede privada/local — este endereço não é acessível pela internet.",
         "scanPrivateConfirmTitle": "Destino em uma rede local",
         "scanPrivateConfirmTitle": "Destino em uma rede local",

+ 2 - 0
internal/web/translation/ru-RU.json

@@ -651,6 +651,8 @@
         "scanCert": "Сертификат",
         "scanCert": "Сертификат",
         "scanCertInvalid": "Не доверенный",
         "scanCertInvalid": "Не доверенный",
         "scanCertExpiry": "Сертификат истекает",
         "scanCertExpiry": "Сертификат истекает",
+        "scanCertChain": "Цепочка сертификатов",
+        "scanMldsaCertChainTooSmall": "Цепочка сертификатов цели занимает {length} байт — меньше минимума {min} байт, требуемого для ML-DSA-65. Соединения будут молча уходить в fallback. Выберите другую цель или отключите ML-DSA-65.",
         "scanSniUsed": "Использованный SNI",
         "scanSniUsed": "Использованный SNI",
         "scanPrivateNote": "Проверено во внутренней (локальной) сети — этот адрес недоступен из интернета.",
         "scanPrivateNote": "Проверено во внутренней (локальной) сети — этот адрес недоступен из интернета.",
         "scanPrivateConfirmTitle": "Цель в локальной сети",
         "scanPrivateConfirmTitle": "Цель в локальной сети",

+ 2 - 0
internal/web/translation/tr-TR.json

@@ -628,6 +628,8 @@
         "scanCert": "Sertifika",
         "scanCert": "Sertifika",
         "scanCertInvalid": "Güvenilmez",
         "scanCertInvalid": "Güvenilmez",
         "scanCertExpiry": "Sertifika bitiş tarihi",
         "scanCertExpiry": "Sertifika bitiş tarihi",
+        "scanCertChain": "Sertifika zinciri",
+        "scanMldsaCertChainTooSmall": "Hedef sertifika zinciri {length} bayt; ML-DSA-65 için gereken {min} bayt minimumunun altında. Bu, bağlantıların sessizce başarısız olmasına yol açar. Farklı bir hedef seçin veya ML-DSA-65'i kapatın.",
         "scanSniUsed": "Kullanılan SNI",
         "scanSniUsed": "Kullanılan SNI",
         "scanPrivateNote": "Özel/yerel ağ üzerinden kontrol edildi — bu adrese internetten erişilemez.",
         "scanPrivateNote": "Özel/yerel ağ üzerinden kontrol edildi — bu adrese internetten erişilemez.",
         "scanPrivateConfirmTitle": "Hedef yerel ağda",
         "scanPrivateConfirmTitle": "Hedef yerel ağda",

+ 2 - 0
internal/web/translation/uk-UA.json

@@ -628,6 +628,8 @@
         "scanCert": "Сертифікат",
         "scanCert": "Сертифікат",
         "scanCertInvalid": "Ненадійний",
         "scanCertInvalid": "Ненадійний",
         "scanCertExpiry": "Сертифікат діє до",
         "scanCertExpiry": "Сертифікат діє до",
+        "scanCertChain": "Ланцюжок сертифікатів",
+        "scanMldsaCertChainTooSmall": "Ланцюжок сертифікатів цілі має {length} байт, що менше мінімуму {min} байт для ML-DSA-65. З’єднання мовчки потраплятимуть у fallback. Оберіть іншу ціль або вимкніть ML-DSA-65.",
         "scanSniUsed": "Використаний SNI",
         "scanSniUsed": "Використаний SNI",
         "scanPrivateNote": "Перевірено у внутрішній (локальній) мережі — ця адреса недоступна з інтернету.",
         "scanPrivateNote": "Перевірено у внутрішній (локальній) мережі — ця адреса недоступна з інтернету.",
         "scanPrivateConfirmTitle": "Ціль у локальній мережі",
         "scanPrivateConfirmTitle": "Ціль у локальній мережі",

+ 2 - 0
internal/web/translation/vi-VN.json

@@ -649,6 +649,8 @@
         "scanCert": "Chứng chỉ",
         "scanCert": "Chứng chỉ",
         "scanCertInvalid": "Không tin cậy",
         "scanCertInvalid": "Không tin cậy",
         "scanCertExpiry": "Chứng chỉ hết hạn",
         "scanCertExpiry": "Chứng chỉ hết hạn",
+        "scanCertChain": "Chuỗi chứng chỉ",
+        "scanMldsaCertChainTooSmall": "Chuỗi chứng chỉ của đích dài {length} byte, thấp hơn mức tối thiểu {min} byte mà ML-DSA-65 yêu cầu. Kết nối sẽ thất bại im lặng. Hãy chọn đích khác hoặc tắt ML-DSA-65.",
         "scanSniUsed": "SNI đã dùng",
         "scanSniUsed": "SNI đã dùng",
         "scanPrivateNote": "Đã kiểm tra qua mạng riêng/nội bộ — địa chỉ này không truy cập được từ internet.",
         "scanPrivateNote": "Đã kiểm tra qua mạng riêng/nội bộ — địa chỉ này không truy cập được từ internet.",
         "scanPrivateConfirmTitle": "Đích trong mạng nội bộ",
         "scanPrivateConfirmTitle": "Đích trong mạng nội bộ",

+ 2 - 0
internal/web/translation/zh-CN.json

@@ -648,6 +648,8 @@
         "scanCert": "证书",
         "scanCert": "证书",
         "scanCertInvalid": "不受信任",
         "scanCertInvalid": "不受信任",
         "scanCertExpiry": "证书有效期至",
         "scanCertExpiry": "证书有效期至",
+        "scanCertChain": "证书链",
+        "scanMldsaCertChainTooSmall": "目标证书链为 {length} 字节,低于 ML-DSA-65 所需的 {min} 字节下限。这会导致连接静默失败。请更换目标或关闭 ML-DSA-65。",
         "scanSniUsed": "使用的 SNI",
         "scanSniUsed": "使用的 SNI",
         "scanPrivateNote": "已通过内网/本地网络检测 — 该地址无法从互联网访问。",
         "scanPrivateNote": "已通过内网/本地网络检测 — 该地址无法从互联网访问。",
         "scanPrivateConfirmTitle": "目标位于本地网络",
         "scanPrivateConfirmTitle": "目标位于本地网络",

+ 2 - 0
internal/web/translation/zh-TW.json

@@ -628,6 +628,8 @@
         "scanCert": "憑證",
         "scanCert": "憑證",
         "scanCertInvalid": "不受信任",
         "scanCertInvalid": "不受信任",
         "scanCertExpiry": "憑證有效期限",
         "scanCertExpiry": "憑證有效期限",
+        "scanCertChain": "憑證鏈",
+        "scanMldsaCertChainTooSmall": "目標憑證鏈為 {length} 位元組,低於 ML-DSA-65 所需的 {min} 位元組下限。這會導致連線靜默失敗。請更換目標或關閉 ML-DSA-65。",
         "scanSniUsed": "使用的 SNI",
         "scanSniUsed": "使用的 SNI",
         "scanPrivateNote": "已透過內網/本機網路檢測 — 此位址無法從網際網路存取。",
         "scanPrivateNote": "已透過內網/本機網路檢測 — 此位址無法從網際網路存取。",
         "scanPrivateConfirmTitle": "目標位於本機網路",
         "scanPrivateConfirmTitle": "目標位於本機網路",