Skip to content

Commit b687ac6

Browse files
committed
feat: Implement ML-DSA-65 validation and enhance REALITY settings
- Added validation functions for ML-DSA-65 seed and verify keys, ensuring correct lengths and encoding. - Updated XRayConfig to enforce pairing rules for mldsa65Seed and mldsa65Verify, raising errors for mismatches. - Enhanced inbound dialog schema to include ML-DSA-65 fields and validation logic. - Updated frontend components to display hints and validation messages related to ML-DSA-65 settings. - Added tests for ML-DSA-65 validation to ensure robustness and correctness.
1 parent 363821d commit b687ac6

12 files changed

Lines changed: 328 additions & 12 deletions

File tree

app/core/xray.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
from app.models.core import CoreType
1111
from app.models.protocol import ProxyProtocol
12-
from app.utils.crypto import get_cert_SANs, get_x25519_public_key
12+
from app.utils.crypto import get_cert_SANs, get_x25519_public_key, validate_mldsa65_seed, validate_mldsa65_verify
1313

1414

1515
def _protocols_from_inbounds_by_tag(inbounds_by_tag: dict[str, dict]) -> frozenset[ProxyProtocol]:
@@ -238,7 +238,27 @@ def _handle_reality_settings(self, tls_settings: dict, settings: dict, inbound_t
238238
except Exception:
239239
settings["spx"] = ""
240240

241-
settings["mldsa65Verify"] = tls_settings.get("mldsa65Verify")
241+
mldsa65_seed = tls_settings.get("mldsa65Seed")
242+
mldsa65_verify = tls_settings.get("mldsa65Verify")
243+
seed_set = isinstance(mldsa65_seed, str) and bool(mldsa65_seed.strip())
244+
verify_set = isinstance(mldsa65_verify, str) and bool(mldsa65_verify.strip())
245+
246+
if seed_set or verify_set:
247+
# Client pqv without a matching server seed causes silent REALITY auth failure.
248+
if verify_set and not seed_set:
249+
raise ValueError(
250+
f"mldsa65Verify is set without mldsa65Seed in realitySettings of {inbound_tag}. "
251+
"Set both (matching pair) or clear both."
252+
)
253+
if seed_set:
254+
validate_mldsa65_seed(mldsa65_seed)
255+
if verify_set:
256+
settings["mldsa65Verify"] = validate_mldsa65_verify(mldsa65_verify)
257+
else:
258+
# Seed-only is valid (server signs; clients without pqv still connect).
259+
settings["mldsa65Verify"] = None
260+
else:
261+
settings["mldsa65Verify"] = None
242262

243263
def _handle_network_settings(self, net: str, net_settings: dict, settings: dict, inbound_tag: str):
244264
"""Handle network-specific settings."""

app/subscription/clash.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,6 @@ def _mihomo_download_settings_from_xray(self, download_settings: dict) -> dict:
248248
"reality-opts": {
249249
"public-key": tls_settings.get("publicKey"),
250250
"short-id": tls_settings.get("shortId") or "",
251-
"support-x25519mlkem768": bool(tls_settings.get("mldsa65Verify")),
252251
}
253252
if security == "reality" and tls_settings.get("publicKey")
254253
else None,
@@ -310,10 +309,10 @@ def _apply_mihomo_download_tls(self, node: dict, tls_config: TLSConfig):
310309
node["client-fingerprint"] = tls_config.fingerprint
311310

312311
if tls_config.tls == "reality" and tls_config.reality_public_key:
312+
# Do not map mldsa65Verify → support-x25519mlkem768; those are different PQ features.
313313
node["reality-opts"] = {
314314
"public-key": tls_config.reality_public_key,
315315
"short-id": tls_config.reality_short_id or "",
316-
"support-x25519mlkem768": bool(tls_config.mldsa65_verify),
317316
}
318317

319318
@staticmethod
@@ -614,10 +613,10 @@ def _apply_tls(self, node: dict, tls_config: TLSConfig, protocol: str):
614613

615614
# Add Reality opts
616615
if tls_config.tls == "reality" and tls_config.reality_public_key:
616+
# Do not map mldsa65Verify → support-x25519mlkem768; those are different PQ features.
617617
node["reality-opts"] = {
618618
"public-key": tls_config.reality_public_key,
619619
"short-id": tls_config.reality_short_id or "",
620-
"support-x25519mlkem768": bool(tls_config.mldsa65_verify),
621620
}
622621

623622
def _build_vless(self, remark: str, address: str, inbound: SubscriptionInboundData, settings: dict) -> dict:

app/utils/crypto.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,39 @@ def add_base64_padding(b64_string: str) -> str:
2626
return b64_string + ("=" * (4 - missing_padding)) if missing_padding else b64_string
2727

2828

29+
MLDSA65_SEED_LENGTH = 32
30+
MLDSA65_VERIFY_LENGTH = 1952 # FIPS 204 ML-DSA-65 public key
31+
32+
33+
def _decode_urlsafe_b64(value: str) -> bytes:
34+
try:
35+
return base64.urlsafe_b64decode(add_base64_padding(value.strip()))
36+
except (ValueError, binascii.Error) as exc:
37+
raise ValueError("Invalid Base64 encoding.") from exc
38+
39+
40+
def validate_mldsa65_seed(seed_b64: str) -> str:
41+
"""Validate REALITY mldsa65Seed (32-byte URL-safe Base64, no padding)."""
42+
seed = seed_b64.strip()
43+
if not seed:
44+
raise ValueError("Invalid mldsa65Seed.")
45+
seed_bytes = _decode_urlsafe_b64(seed)
46+
if len(seed_bytes) != MLDSA65_SEED_LENGTH:
47+
raise ValueError(f"Invalid mldsa65Seed length. Must be {MLDSA65_SEED_LENGTH} bytes after decoding.")
48+
return seed
49+
50+
51+
def validate_mldsa65_verify(verify_b64: str) -> str:
52+
"""Validate REALITY mldsa65Verify (1952-byte URL-safe Base64, no padding)."""
53+
verify = verify_b64.strip()
54+
if not verify:
55+
raise ValueError("Invalid mldsa65Verify.")
56+
verify_bytes = _decode_urlsafe_b64(verify)
57+
if len(verify_bytes) != MLDSA65_VERIFY_LENGTH:
58+
raise ValueError(f"Invalid mldsa65Verify length. Must be {MLDSA65_VERIFY_LENGTH} bytes after decoding.")
59+
return verify
60+
61+
2962
def get_x25519_public_key(private_key_b64: str) -> str:
3063
"""
3164
Converts an X25519 private key (URL-safe Base64) into a public key (URL-safe Base64 format).

dashboard/public/statics/locales/en.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2247,6 +2247,7 @@
22472247
"copyMldsa65Verify": "Copy Verify Key",
22482248
"mldsa65SeedCopied": "Seed copied to clipboard",
22492249
"mldsa65VerifyCopied": "Verify key copied to clipboard",
2250+
"mldsa65DestCertHint": "Optional. Requires a matching seed+verify pair. The REALITY target should use a large (typically RSA) certificate — ECDSA targets often fail silently with no useful client/server logs.",
22502251
"vlessHandshakeLabel": "Handshake method",
22512252
"vlessEncryptionLabel": "Encryption method",
22522253
"vlessEncryptionHint": "Choose the wire format for encrypted payloads.",
@@ -2706,7 +2707,12 @@
27062707
"destinationPortRange": "Destination port must be a whole number from 0 to 65535.",
27072708
"realityServerNamesRequired": "REALITY requires at least one server name.",
27082709
"realityServerNamesFormat": "REALITY server names must be a list of non-empty strings.",
2709-
"realityServerNamesNoEmpty": "Each REALITY server name must be non-empty; remove blank lines or extra commas."
2710+
"realityServerNamesNoEmpty": "Each REALITY server name must be non-empty; remove blank lines or extra commas.",
2711+
"mldsa65SeedInvalid": "ML-DSA-65 seed must be a 32-byte URL-safe Base64 value.",
2712+
"mldsa65VerifyInvalid": "ML-DSA-65 verify must be a 1952-byte URL-safe Base64 public key.",
2713+
"mldsa65VerifyRequiresSeed": "ML-DSA-65 verify requires a matching seed on the server. Generate a pair or clear verify.",
2714+
"mldsa65SeedReusesPrivateKey": "ML-DSA-65 seed must not be the same as the REALITY private key.",
2715+
"mldsa65PairMismatch": "ML-DSA-65 verify does not match the seed. Generate a new pair or paste matching values."
27102716
},
27112717
"discardDraftTitle": "Discard new inbound?",
27122718
"discardDraftDescription": "This inbound is not in the list yet. Close without adding it will discard your changes.",

dashboard/public/statics/locales/fa.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2162,6 +2162,7 @@
21622162
"copyMldsa65Verify": "کپی Verify",
21632163
"mldsa65SeedCopied": "Seed در کلیپ‌بورد کپی شد",
21642164
"mldsa65VerifyCopied": "Verify در کلیپ‌بورد کپی شد",
2165+
"mldsa65DestCertHint": "اختیاری. به جفت seed+verify هم‌خوان نیاز دارد. هدف REALITY باید گواهی بزرگ (معمولاً RSA) داشته باشد — اهداف ECDSA اغلب بدون لاگ مفید در کلاینت/سرور به‌صورت خاموش شکست می‌خورند.",
21652166
"vlessHandshakeLabel": "روش دست‌دهی",
21662167
"vlessEncryptionLabel": "روش رمزنگاری",
21672168
"vlessEncryptionHint": "قالب داده‌ی رمزگذاری‌شده را انتخاب کنید.",
@@ -2619,7 +2620,12 @@
26192620
"destinationPortRange": "پورت مقصد باید عدد صحیح از ۰ تا ۶۵۵۳۵ باشد.",
26202621
"realityServerNamesRequired": "REALITY حداقل به یک نام سرور نیاز دارد.",
26212622
"realityServerNamesFormat": "نام‌های سرور REALITY باید فهرستی از رشته‌های غیرخالی باشد.",
2622-
"realityServerNamesNoEmpty": "هر نام سرور REALITY باید غیرخالی باشد؛ خطوط خالی یا ویرگول‌های اضافی را حذف کنید."
2623+
"realityServerNamesNoEmpty": "هر نام سرور REALITY باید غیرخالی باشد؛ خطوط خالی یا ویرگول‌های اضافی را حذف کنید.",
2624+
"mldsa65SeedInvalid": "Seed مربوط به ML-DSA-65 باید مقدار Base64 URL-safe به‌طول ۳۲ بایت باشد.",
2625+
"mldsa65VerifyInvalid": "Verify مربوط به ML-DSA-65 باید کلید عمومی Base64 URL-safe به‌طول ۱۹۵۲ بایت باشد.",
2626+
"mldsa65VerifyRequiresSeed": "Verify مربوط به ML-DSA-65 به seed هم‌خوان در سرور نیاز دارد. جفت را تولید کنید یا Verify را پاک کنید.",
2627+
"mldsa65SeedReusesPrivateKey": "Seed مربوط به ML-DSA-65 نباید با کلید خصوصی REALITY یکی باشد.",
2628+
"mldsa65PairMismatch": "Verify مربوط به ML-DSA-65 با seed هم‌خوان نیست. جفت جدید تولید کنید یا مقادیر هم‌خوان وارد کنید."
26232629
},
26242630
"discardDraftTitle": "ورودی جدید را دور بیندازید؟",
26252631
"discardDraftDescription": "این ورودی هنوز در فهرست نیست. بستن بدون افزودن، تغییرات را دور می‌اندازد.",

dashboard/public/statics/locales/ru.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2137,6 +2137,7 @@
21372137
"copyMldsa65Verify": "Копировать Verify",
21382138
"mldsa65SeedCopied": "Seed скопирован в буфер обмена",
21392139
"mldsa65VerifyCopied": "Verify скопирован в буфер обмена",
2140+
"mldsa65DestCertHint": "Необязательно. Нужна согласованная пара seed+verify. Цель REALITY должна использовать крупный (обычно RSA) сертификат — цели с ECDSA часто молча ломаются без полезных логов на клиенте и сервере.",
21402141
"vlessHandshakeLabel": "Метод рукопожатия",
21412142
"vlessEncryptionLabel": "Метод шифрования",
21422143
"vlessEncryptionHint": "Выберите формат для зашифрованных данных.",
@@ -2594,7 +2595,12 @@
25942595
"destinationPortRange": "Порт назначения должен быть целым числом от 0 до 65535.",
25952596
"realityServerNamesRequired": "Для REALITY нужно хотя бы одно имя сервера (server name).",
25962597
"realityServerNamesFormat": "Имена серверов REALITY должны быть списком непустых строк.",
2597-
"realityServerNamesNoEmpty": "Каждое имя сервера REALITY должно быть непустым; удалите пустые строки или лишние запятые."
2598+
"realityServerNamesNoEmpty": "Каждое имя сервера REALITY должно быть непустым; удалите пустые строки или лишние запятые.",
2599+
"mldsa65SeedInvalid": "Seed ML-DSA-65 должен быть URL-safe Base64 длиной 32 байта.",
2600+
"mldsa65VerifyInvalid": "Verify ML-DSA-65 должен быть URL-safe Base64 публичным ключом длиной 1952 байта.",
2601+
"mldsa65VerifyRequiresSeed": "Verify ML-DSA-65 требует matching seed на сервере. Создайте пару или очистите verify.",
2602+
"mldsa65SeedReusesPrivateKey": "Seed ML-DSA-65 не должен совпадать с private key REALITY.",
2603+
"mldsa65PairMismatch": "Verify ML-DSA-65 не соответствует seed. Создайте новую пару или вставьте согласованные значения."
25982604
},
25992605
"discardDraftTitle": "Отменить новый входящий?",
26002606
"discardDraftDescription": "Этот входящий ещё не в списке. Закрытие без добавления отменит изменения.",

dashboard/public/statics/locales/zh.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2206,6 +2206,7 @@
22062206
"copyMldsa65Verify": "复制 Verify",
22072207
"mldsa65SeedCopied": "Seed 已复制到剪贴板",
22082208
"mldsa65VerifyCopied": "Verify 已复制到剪贴板",
2209+
"mldsa65DestCertHint": "可选。需要匹配的 seed+verify 密钥对。REALITY 目标站点应使用较大(通常为 RSA)的证书——ECDSA 目标常会静默失败,且客户端/服务端几乎没有有用日志。",
22092210
"vlessHandshakeLabel": "握手方式",
22102211
"vlessEncryptionLabel": "加密方式",
22112212
"vlessEncryptionHint": "选择加密负载使用的线路格式。",
@@ -2663,7 +2664,12 @@
26632664
"destinationPortRange": "目标端口必须为 0 至 65535 之间的整数。",
26642665
"realityServerNamesRequired": "REALITY 至少需要填写一个 server name。",
26652666
"realityServerNamesFormat": "REALITY 的 server names 必须为非空字符串的列表。",
2666-
"realityServerNamesNoEmpty": "每个 REALITY server name 都必须非空;请删除空行或多余逗号。"
2667+
"realityServerNamesNoEmpty": "每个 REALITY server name 都必须非空;请删除空行或多余逗号。",
2668+
"mldsa65SeedInvalid": "ML-DSA-65 seed 必须是 32 字节的 URL-safe Base64 值。",
2669+
"mldsa65VerifyInvalid": "ML-DSA-65 verify 必须是 1952 字节的 URL-safe Base64 公钥。",
2670+
"mldsa65VerifyRequiresSeed": "ML-DSA-65 verify 需要服务端有匹配的 seed。请生成密钥对或清空 verify。",
2671+
"mldsa65SeedReusesPrivateKey": "ML-DSA-65 seed 不能与 REALITY 私钥相同。",
2672+
"mldsa65PairMismatch": "ML-DSA-65 verify 与 seed 不匹配。请重新生成密钥对或粘贴匹配的值。"
26672673
},
26682674
"discardDraftTitle": "放弃新入站?",
26692675
"discardDraftDescription": "此入站尚未加入列表。关闭而不添加将丢弃更改。",

dashboard/src/features/core-editor/components/xray/xray-inbounds-section.tsx

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@ import { XrayStreamFinalmaskInboundAccordion } from '@/features/core-editor/comp
2222
import { InboundFallbacksEditor } from '@/features/core-editor/components/xray/inbound-fallbacks-editor'
2323
import { useSectionHeaderAddPulseEffect, type SectionHeaderAddPulse } from '@/features/core-editor/hooks/use-section-header-add-pulse'
2424
import { useXrayPersistModifyGuard } from '@/features/core-editor/hooks/use-xray-persist-modify-guard'
25-
import { createInboundDialogSchema, realityInboundZodTriggerFieldNames } from '@/features/core-editor/kit/inbound-dialog-schema'
25+
import {
26+
createInboundDialogSchema,
27+
INBOUND_FORM_FIELD_SEC_MLDSA65_SEED,
28+
INBOUND_FORM_FIELD_SEC_MLDSA65_VERIFY,
29+
realityInboundZodTriggerFieldNames,
30+
} from '@/features/core-editor/kit/inbound-dialog-schema'
2631
import { getInboundSecuritySelectOptions, getInboundTransportSelectOptions, transportCompatibleWithReality } from '@/features/core-editor/kit/inbound-form-options'
2732
import { profileDuplicateTagMessage, profileTagHasDuplicateUsage } from '@/features/core-editor/kit/profile-tag-uniqueness'
2833
import { remapIndexAfterArrayMove } from '@/features/core-editor/kit/remap-index-after-move'
@@ -46,6 +51,7 @@ import {
4651
vlessInboundEncryptionRawForForm,
4752
type VlessBuilderOptions,
4853
} from '@/lib/xray-generation'
54+
import { mldsa65PairMatches, validateMldsa65Seed, validateMldsa65Verify } from '@/utils/mldsa65'
4955
import { generateWireGuardKeyPair, getWireGuardPublicKey } from '@/utils/wireguard'
5056
import { arrayMove } from '@dnd-kit/sortable'
5157
import { zodResolver } from '@hookform/resolvers/zod'
@@ -1589,6 +1595,28 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo
15891595
finalizeDetailClose()
15901596
}
15911597

1598+
const assertMldsa65PairForCommit = async (): Promise<boolean> => {
1599+
if (form.getValues('security') !== 'reality') return true
1600+
const seed = String(form.getValues(INBOUND_FORM_FIELD_SEC_MLDSA65_SEED) ?? '').trim()
1601+
const verify = String(form.getValues(INBOUND_FORM_FIELD_SEC_MLDSA65_VERIFY) ?? '').trim()
1602+
if (!seed && !verify) return true
1603+
if (!seed || !verify) return true // Zod covers verify-without-seed / empty half-pairs
1604+
if (!validateMldsa65Seed(seed).ok || !validateMldsa65Verify(verify).ok) return true
1605+
1606+
const matches = await mldsa65PairMatches(seed, verify)
1607+
if (matches) {
1608+
form.clearErrors(INBOUND_FORM_FIELD_SEC_MLDSA65_VERIFY)
1609+
return true
1610+
}
1611+
form.setError(INBOUND_FORM_FIELD_SEC_MLDSA65_VERIFY, {
1612+
type: 'manual',
1613+
message: t('coreEditor.inbound.validation.mldsa65PairMismatch', {
1614+
defaultValue: 'ML-DSA-65 verify does not match the seed. Generate a new pair or paste matching values.',
1615+
}),
1616+
})
1617+
return false
1618+
}
1619+
15921620
const commitAddInbound = async () => {
15931621
if (!profile) return
15941622
if (!draftInbound || draftInbound.protocol === 'unmanaged') return
@@ -1611,6 +1639,7 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo
16111639
]
16121640
const ok = await form.trigger(fields, { shouldFocus: true })
16131641
if (!ok) return
1642+
if (!(await assertMldsa65PairForCommit())) return
16141643
if (!validateWireguardInboundForCommit(draftInbound)) return
16151644
const insertAt = profile.inbounds.length
16161645
updateXrayProfile(p => ({ ...p, inbounds: [...p.inbounds, draftInbound] }))
@@ -1629,6 +1658,7 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo
16291658
const fields = ['protocol', 'tag', 'port', ...(form.getValues('security') === 'reality' ? realityInboundZodTriggerFieldNames() : [])]
16301659
const ok = await form.trigger(fields, { shouldFocus: true })
16311660
if (!ok) return
1661+
if (!(await assertMldsa65PairForCommit())) return
16321662
} else if (inbound.protocol !== 'unmanaged' && isTunnelInboundProtocol(inbound.protocol)) {
16331663
const ok = await form.trigger(['protocol', 'tag', 'port', 'tunnelRewriteAddress', 'tunnelRewritePort'], { shouldFocus: true })
16341664
if (!ok) return
@@ -4144,7 +4174,7 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo
41444174
</div>
41454175
)}
41464176
{isReality && jsonKey === 'fingerprint' && (
4147-
<div className="w-full min-w-0 sm:col-span-2">
4177+
<div className="w-full min-w-0 space-y-2 sm:col-span-2">
41484178
<LoaderButton
41494179
type="button"
41504180
onClick={() => void handleGenerateMldsa65()}
@@ -4154,6 +4184,12 @@ export function XrayInboundsSection({ headerAddPulse, headerAddEpoch }: XrayInbo
41544184
>
41554185
<span className="flex items-center gap-2 truncate">{t('coreConfigModal.generateMldsa65')}</span>
41564186
</LoaderButton>
4187+
<p className="text-muted-foreground text-xs leading-relaxed">
4188+
{t('coreConfigModal.mldsa65DestCertHint', {
4189+
defaultValue:
4190+
'Optional. Requires a matching seed+verify pair. The REALITY target should use a large (typically RSA) certificate — ECDSA targets often fail silently with no useful client/server logs.',
4191+
})}
4192+
</p>
41574193
</div>
41584194
)}
41594195
{isReality && jsonKey === 'target' && (

0 commit comments

Comments
 (0)