Skip to content

第22章 VLESS Encryption ​

22.1 加密位置决定它解决什么问题 ​

基本VLESS头负责账户、命令和目标,不自行提供一套加密记录。第18—21章中,TLS或REALITY在承载连接上保护它;如果外层TLS终止在HTTP边缘,边缘就可能恢复承载里的VLESS字节。VLESS Encryption提供另一种端点保护位置:传输适配器先建立可读写连接,VLESS处理器再在这条连接之上执行自己的密钥交换和加密记录处理,最后才读写基本VLESS头。

本章继续固定Xray-core提交0888beb38a9ba45440557175e71ce21cf8ba3b13,直接依据proxy/vless/encryption中的client.go、server.go、common.go和xor.go。这里没有假定它是一份IETF标准,也不把其他软件声称支持同名选项当成已经互操作的证据。

客户端的实际顺序是:拨号器得到承载连接,若配置启用Encryption,先调用其Handshake;随后EncodeRequestHeader写入第19章的VLESS基本头。服务端相应先执行decryption.Handshake,成功得到CommonConn后,才进入基本VLESS请求解码。因此“请求头Version仍为00”与“线上头字段已被额外加密”可以同时成立,Encryption不需要凭空在基本头里增加一个密码套件字节。

text
外部网络及可选的外层安全连接
    HTTP、WebSocket或RAW等承载适配
        VLESS Encryption握手与记录
            VLESS基本请求/响应
                被代理应用字节

这是一份层次示意,不是承诺任意四种选项都能自由叠加。具体配置还要满足对应传输与Flow的组合约束。若HTTP边缘只终止外层TLS、不持有Encryption所需端点密钥,它能读取HTTP结构和其中的加密字节,却不能仅凭外层TLS密钥恢复基本VLESS目标。这种保护位置与REALITY直接认证安全握手端点不同;代价也包括额外交换、状态和记录处理。

Encryption握手分成两类材料。首先利用客户端预先配置的静态公钥、服务端对应私钥建立一份连接相关秘密,源码称为nfsKey。NFS在这里用于标识非前向安全的静态密钥阶段,不是网络文件系统协议。随后在完整握手中交换新生成的ML-KEM-768及X25519材料,形成pfsKey;两者按实现规定组合为UnitedKey,用于派生实际记录密钥。

为什么需要后一个阶段?每次对静态ML-KEM公钥重新封装,或对静态X25519公钥重新ECDH,虽然能生成新秘密,却仍可能在静态私钥日后泄露时被重算。第17章已经区分“秘密每次不同”和“长期密钥泄露后保住过去连接”。完整握手增加临时混合交换,是为后一个问题建立材料;具体安全结论还取决于密钥保存时间和恢复模式。

完整握手需要客户端先发材料,再收到服务端交换结果,所以称为这里的1-RTT路径。客户端若持有之前完整握手留下、尚在有效期内的会话秘密与票据,可以在新连接第一次发送时就带上加密VLESS字节,走这里的0-RTT路径。这不是TLS PSK恢复,也不是把任意首次连接改成没有服务器回应的前向安全交换;恢复关联来自此前建立并保存的状态。

还要拆开三个“看起来像TLS”的现象。第一,Encryption使用ML-KEM、X25519和AEAD等相同原语,不意味着它使用TLS的消息或密钥树。第二,数据记录前五字节模仿TLS应用记录头的形状,不意味着普通TLS库能够解密它。第三,某些模式再改变公开材料或记录头的外观,不等于额外的XOR本身替代了AEAD完整性保护。

本实现NewAEAD用BLAKE3的密钥派生模式,把指定上下文与UnitedKey等输入派生为32字节密钥,再选择AES-GCM或ChaCha20-Poly1305;不是第16章的HKDF-Expand-Label。它有自己的Nonce推进、双方向上下文、长度封装和恢复去重。后面会逐项给出,不能仅看到算法名称就套用TLS记录中的内部类型、Padding或KeyUpdate字段。

Encryption也不替代VLESS账户。知道入口公钥所建立的密钥交换材料,与服务端查找UUID的用户授权仍分属不同阶段。服务器是否预期、字节是否完整、用户是否允许、目标是否可达,需要分别成立。模块代码见客户端、服务端、共同记录层与外观变换。

22.2 配置字符串:算法族、外观、恢复策略与密钥 ​

在固定版本里,服务端使用VLESS入站settings中的decryption,客户端使用VLESS出站账户对应的encryption。服务端不会给clients里的每个用户分别放一个encryption字符串;构建器遇到这种字段位置会拒绝。加密握手在读UUID之前发生,也解释了为什么服务器先需要一套入口级材料。

两方向字符串具有相似外形,但第三段并不相同。下面仅是语法元表示,其中尖括号代表解释性占位,不是可直接使用的完整配置:

text
服务端:mlkem768x25519plus.<外观模式>.<有效期>.<可选填充分段>.<静态私钥段>...
客户端:mlkem768x25519plus.<外观模式>.<1rtt或0rtt>.<可选填充分段>.<静态公钥段>...

首段选择实现识别的算法族。完整交换中临时阶段固定包含ML-KEM-768和X25519;静态公钥段却可以是X25519公钥,也可以是ML-KEM-768公钥,不能根据名称断言每一个静态段都同时包含两种算法。基本VLESS UUID也不承担这里任何一个公钥角色。

第二段native、xorpub和random分别映射内部XorMode为0、1、2。它们调整公开交换材料和记录头的可见外观,不是三种不同强度的AEAD算法菜单。实际AES-GCM或ChaCha20-Poly1305由握手路径另行确定,下一节和22.5会解释。

服务端第三段可写秒数,允许末尾s,也可表达一个范围;它控制会话恢复材料的生命周期策略。例如600s被构建为SecondsFrom=600、SecondsTo=0,实际发给客户端的期限还经过服务端计算,不保证每张票据整整600秒。0表示不允许使用那条保存状态的恢复路径。数值最后还要装入握手的两字节长度形式,不能把配置解析能读某个大整数当成线上支持无上限有效期。

客户端第三段只能在1rtt与0rtt中选择。1rtt不使用客户端的旧pfsKey缓存恢复;0rtt把内部Seconds设为非零,表示允许在缓存有效时恢复。这个内部值1不是“一秒票据有效期”,实际有效期由服务器返回,客户端保存为Expire。即使配置0rtt,首次连接没有缓存,仍需完整交换。

静态材料用无填充Base64URL表示,解码长度决定类型:

位置解码长度对应材料
客户端公钥段32字节X25519公钥
客户端公钥段1184字节ML-KEM-768封装公钥
服务端私钥段32字节X25519私钥
服务端私钥段64字节此Go接口接受的ML-KEM-768种子形式

最后一项不是第17章2400字节展开私钥的直接复制,也不是64字节公钥。固定服务端将它交给mlkem.NewDecapsulationKey768,由种子重建相应解封装密钥及公钥;客户端需要对应1184字节封装公钥。编码形式必须与所调用库的接口匹配,不能因为都叫“私钥”就互换文本格式。

构建器按点分段,对较短段先按填充描述归类,长度不少于20的段再检查Base64URL解码后的密钥长度;运行时Init还要验证实际密钥结构。因此“JSON语法合法”“配置初步构建通过”“密钥能够初始化”“两端对应”是四个层次。带点分段不是任意插入注释的空间,填充描述也必须在密钥前按解析约定排列。

若给出多个静态密钥段,客户端按顺序生成相应静态交换材料,服务端按顺序处理匹配的私钥。中间存在下一公钥的BLAKE3摘要和基于前一秘密的绑定变换,最后一段形成后续使用的nfsKey。它不是简单地把所有静态共享秘密拼在一起。22.4会展开这个绑定,不能先把它当成一份已获证明的任意多算法组合器。

源码中将这部分数组称为relays,但本VLESS入站的循环不会依据每段密钥自动拨号一台新服务器。只在字符串里多放一个公钥,不会自动创建一条用户配置的多跳代理链;网络连接由外层拨号和路由职责决定。密码材料的链式处理与第8章的网络节点链必须分开理解。

Encryption与Flow也使用不同配置。Flow=xtls-rprx-vision选择正文流控;encryption/decryption建立本章的保护连接。原有基本VLESS配置的none明确表示不启用本模块,不能同时把它解释为“采用本模块但不做混淆”。服务端还禁止将启用Encryption的decryption与VLESS fallbacks组合;REALITY自己的非授权目标处理不受这一个字段同义覆盖。

位置和解析以infra/conf/vless.go为准,材料初始化见ClientInstance.Init与ServerInstance.Init。这些配置字符串属于所列实现版本的入口格式,不是TLS扩展格式或基本VLESS头里的明文算法名。

22.3 完整1-RTT交换:先静态入口,再交换临时混合材料 ​

先限定最容易逐字节分析的情况:native外观、一个静态公钥段、没有可用恢复缓存。客户端选1rtt,或者选0rtt但首次连接,都进入这条完整路径。为避免混淆,下面称Encryption自己的两个握手方向为客户端握手消息和服务端握手消息;源码变量虽叫clientHello和serverHello,它们并不是TLS ClientHello或ServerHello。

客户端生成16字节随机IV,然后生成静态阶段交换材料。若配置公钥为X25519,材料是这次新生成的32字节X25519公钥,客户端用对应私钥与服务器静态公钥计算nfsKey。若配置的是ML-KEM-768公钥,材料是1088字节封装密文,封装同时得到32字节nfsKey。服务器用自己的静态私钥做对应ECDH或解封装,得到相同秘密。

IV与nfsKey交给NewAEAD建立静态阶段AEAD状态。接下来客户端另生成临时ML-KEM-768密钥对和临时X25519密钥对,拼接其公钥:1184字节ML-KEM封装公钥在前,32字节X25519公钥在后,合计1216字节。注意静态X25519交换与这里的临时X25519交换分别生成密钥,不能因为都是32字节而画成同一把私钥。

设客户端全部握手填充占Pc字节。其消息结构为:

顺序线上长度解释
116随机IV
232或1088本例单段静态交换材料
318两字节大端长度1232经AEAD加16字节标签
41232临时公钥1216字节经AEAD加16字节标签
518两字节填充密文长度Pc−18经AEAD加标签
6Pc−18填充明文Pc−34字节经AEAD加标签

第一处长度明文为04 d0,即1232,它计算随后的临时公钥密文,已包括标签;不是公钥明文长度04 c0。四次客户端静态阶段AEAD调用按自己的Nonce状态顺序进行,具体计数在22.5说明。IV及交换材料前面没有TLS式五字节记录头,也没有基本VLESS版本或UUID。

临时公钥交换部分共18+1232=1250字节。因此单段静态X25519的客户端消息为1298+Pc字节;单段静态ML-KEM为2354+Pc字节。Pc含自己的加密长度与标签,不能再重复加一次18。默认填充还可能分段并等待发送,网络一次Read不必取得完整握手。

服务器读取并验证18字节长度块后,确认后续交换密文足够长,解密得到临时公钥。它对客户端ML-KEM公钥作封装,得到32字节K_M和1088字节密文;另生成自己的临时X25519私钥,与客户端临时X25519公钥计算32字节Z_X。两端最终组合:

text
pfsKey    = K_M(32字节) || Z_X(32字节)
UnitedKey = pfsKey(64字节) || nfsKey(32字节)

客户端通过自己的临时ML-KEM解封装私钥和临时X25519私钥重建同一pfsKey。这个顺序恰好也是ML-KEM秘密在前,但不能据此把整个握手说成第17章TLS混合组;本协议外面还有静态阶段保护、不同派生上下文、票据与记录。

服务端回应结构如下,Ps表示这一方向全部握手填充:

顺序线上长度解释
11136ML-KEM密文1088与临时X25519公钥32拼接后,用静态阶段AEAD保护,标签16
232会话票据16字节,使用新建的服务端方向AEAD保护,标签16
318Ps−18的两字节长度,用该方向AEAD保护
4Ps−18填充内容及其16字节标签

回应第一块不再发送长度,因为客户端固定读取1136字节。该调用显式使用12字节全ff的Nonce,与静态阶段客户端顺序Nonce分开。其内部1120字节又成为服务端方向记录密钥派生的上下文。票据、填充长度和填充块已使用UnitedKey建立的AEAD,不能继续用仅基于nfsKey的状态解密。

服务端消息共1168+Ps字节。票据前两字节编码恢复有效期,其余内容与保存状态关联,22.8再展开。即使客户端选择1rtt,仍读取这个消息字段;选择1rtt影响是否保存用于后续恢复,不表示线上把票据字段删掉。

正在绘制图表…

查看图表原文
sequenceDiagram
    participant C as Encryption客户端
    participant P as Encryption服务端
    C->>P: IV、静态交换材料、加密临时混合公钥、填充
    P->>P: 得到nfsKey,生成K_M与Z_X,形成UnitedKey
    P-->>C: 静态阶段保护的回复、新密钥保护的票据与填充
    C->>C: 解封装与ECDH,形成相同UnitedKey
    C->>P: 新记录内的VLESS请求与正文
    P->>P: 验证记录,再解析VLESS

它不额外复制TLS的客户端Finished消息。进入上层后的首批记录仍要通过AEAD验证,服务器才会取得真实VLESS字节。不能在图中凭TLS直觉加一个本协议不存在的握手类型。

客户端Handshake读到服务端加密填充长度后,可先保存待消费的PeerPadding,在后续Read中再读满并验证;它不必等填充正文全部到达才允许上行Write。服务端则在返回CommonConn前读完并验证客户端填充。这种交叠及人工发送间隔会影响真实延迟,所以1-RTT描述的是交换依赖,不保证观测时间恰好只有一个路径RTT。

客户端根据AES-GCM硬件支持选择初始AEAD;服务端先尝试AES-GCM打开第一份18字节长度,失败后重新建状态尝试ChaCha20-Poly1305。这里没有发送一个独立算法编号,也没有明文套件协商列表。必须同时追踪初始长度认证与后续共同状态,不能从服务器初始UseAES=true就断言所有连接都只能用AES。

上述消息顺序、常数与延后填充消费直接见client.go、server.go及CommonConn.Read。后面继续解释同一交换如何绑定静态入口、分离记录方向并恢复旧会话。

22.4 共享秘密如何变成有方向的记录密钥 ​

交换算法输出的是共享秘密,AEAD需要的是某个用途的密钥。不能将“都为32字节”当作可以直接混用的理由。固定实现的NewAEAD先执行BLAKE3 DeriveKey,输出32字节,再用其初始化AES-256-GCM或ChaCha20-Poly1305。

BLAKE3有普通散列、带密钥散列与密钥派生等模式。密钥派生先用专门上下文标记处理context,形成内部派生状态,再用另一标记处理输入密钥材料,输出所需长度;它不是把context和secret直接拼接后做一次普通散列。本章用D(context, material)表示这个32字节输出。

本实现依赖lukechampine.com/blake3 v1.4.1。其DeriveKey把context字符串转换回字节后处理;Xray NewAEAD将收到的原始ctx字节直接转成Go string传入,因此上下文是原始二进制内容,不是十六进制文字,也没有UTF-8正规化。BLAKE3通用接口文档建议使用固定、唯一的应用上下文,而这里的握手使用动态二进制上下文;本书忠实描述这一调用选择,不将接口建议改写成该协议已经采用的固定标签。所依赖DeriveKey实现、BLAKE3官方资料

完整握手中最重要的三组派生为:

用途context输入密钥材料
静态阶段交换保护客户端16字节IVnfsKey,32字节
客户端到服务端记录客户端临时公钥拼接,1216字节UnitedKey,96字节
服务端到客户端记录服务端ML-KEM密文及X25519公钥拼接,1120字节同一UnitedKey,96字节

第二行用于客户端AEAD和服务端PeerAEAD,第三行用于服务端AEAD和客户端PeerAEAD。同一方向两端必须使用完全相同的context,反方向使用不同context。长度与内容都属于派生输入的实际字节,不能把1216字节公钥先取一个未经定义的摘要再代入。

分离方向使两方向即使从相同数值的Nonce开始,也不会直接在同一密钥下复用Nonce。第二行与第三行还把记录密钥绑定到这次交换的公开材料;把服务端返回材料替换成另一条连接的材料,不会保持原密钥。恢复路径又换一套上下文,留到22.8比较。

UnitedKey的96字节并不是“三把AES密钥”。前64字节为临时ML-KEM秘密与临时X25519秘密,后32字节为静态阶段这次连接相关nfsKey;D对整体进行派生。至少需要相应密码假设、正确材料验证和派生行为,才能讨论组件失效时保留何种安全性。不能只用96字节乘8,声称有768位有效安全强度。

静态阶段若配置多个密钥,还会在到达最后nfsKey前绑定前面各段。设两个静态X25519公钥为S1、S2,客户端各自生成临时公钥R1、R2和共享秘密K1、K2;暂不叠加xorpub的公开材料变换。线上静态部分在IV后排列:

text
R1(32) | 用K1相关CTR状态变换的Hash(S2)(32)
         | 同一CTR状态继续变换的R2前32字节(32)

Hash为BLAKE3 Sum256,输入是下一静态公钥原始字节。CTR不是直接拿K1当AES密钥,而是先以固定context为VLESS、输入K1作BLAKE3派生,再用客户端IV初始化AES-CTR。前32字节密钥流作用于摘要,接下来的32字节作用于后一份交换材料开头,状态不能在两处重新从零开始。

服务器从R1与自己的第一静态私钥恢复K1,解开并检查下一公钥摘要,再恢复R2开头,取得K2。最终nfsKey为K2,而非K1||K2。这条链将后续静态材料的解释依赖于前面秘密,体现实现中的绑定意图;它没有在每个中间段都发送独立AEAD标签,也不能仅凭“有摘要”就赋予一份本书未证明的通用组合安全定理。

若某段为静态ML-KEM,则对应R长度变为1088,链式CTR仍只变换下一段的前32字节;其余部分按具体模式处理。一般静态数组长度是所有交换材料长度之和,加上每两个相邻静态段之间的32字节摘要,IV另计16。两个X25519段因此为96字节,加IV后112;X25519再ML-KEM为1152字节,加IV后1168。

这几组长度也解释为什么不能给握手设置一个脱离静态配置的固定总长。接收方先根据自己已配置的密钥数组计算应读多少静态字节,然后恢复秘密,才有能力打开随后的18字节长度块。它不是先读一个任意远端声明的“密钥类型字符串”再选择全部解析规则。

派生与方向映射见NewAEAD,链式材料生成和恢复见client.go及server.go,CTR派生见xor.go。下一节在这些方向密钥之上逐次推进Nonce并解释记录。

22.5 加密记录:TLS形状的五字节头,不同的明文规则 ​

完整握手交出CommonConn后,上层写入VLESS头或正文,CommonConn.Write把这些字节分段加密。固定实现每段最多8192字节;写入更长内容时循环输出多条记录,零长度Write不产生记录。明文内容是什么由上层决定,记录层不区分哪几字节是UUID、哪几字节是网站TLS。

普通native、xorpub外观的数据记录为:

text
17 03 03 | 两字节大端长度L | AEAD密文及16字节标签
   3字节          2字节                  L字节

L为明文长度n+16,头本身不计入L。五字节完整头作为AAD,密文来自VLESS实际输入;没有TLSInnerPlaintext的内部ContentType字节,也没有自动插入TLS零Padding。五字节hello因此得到L=21,头为17 03 03 00 15,整个记录26字节。若明文是第19章39字节请求,记录长60,而不是按TLS的39+22算成61。

接收方读满五字节头,检查前三字节与长度,再读满L字节,并用接收方向AEAD验证及解密。固定DecodeHeader接受17—16640的L,正常写入分支最大为8192+16=8208;接收范围大于本端正常发送范围,是实现接受条件与本端生成策略的区别。它不表示真实TLS库会把这些记录作为合法TLS应用内容解密。

接收应用的缓冲可能比一份解密内容小,CommonConn用内部bytes.Reader保存余下明文,后续Read继续取。也可能直接将验证成功的明文写入调用者缓冲,减少额外复制。这不改变消息契约:CommonConn提供字节流,8192字节分段不是必须暴露给VLESS或浏览器的应用消息。

Nonce不在每条记录中显式发送。NewAEAD创建全零的12字节Nonce状态;Seal或Open收到nil Nonce时,先把状态作为一个96位大端计数递增,再使用。第一次为末字节01,第二次为02,依次类推。两端必须按相同顺序调用,不能跳过一份握手密文再希望记录解密自行同步。

完整握手的主要计数如下:

AEAD状态第一次第二次第三次第四次及后续
基于IV与nfsKey的静态阶段客户端交换长度客户端临时公钥客户端填充长度客户端填充内容
客户端发送方向,新UnitedKey第一份VLESS数据记录后续数据记录后续数据记录后续数据记录
服务端发送方向,新UnitedKey票据服务端填充长度服务端填充内容第一份VLESS下行记录从04开始

静态阶段的服务端交换回复显式指定MaxNonce,即12字节ff;这个显式调用不推进其顺序计数。接收方也使用同一显式值,不应把它当成“客户端第五次顺序调用”。握手里的长度与内容各自是一份AEAD输出,标签不能随意合并;它们在这一阶段通常没有五字节记录AAD。

这与TLS的Nonce形成方式不同:TLS常用方向IV异或补零后的记录序号,这里使用AEAD包装器递增后的12字节值;没有再做那次XOR。共同点是同一密钥下必须避免重复Nonce,且发送和接收状态分别维护。TCP重传的是已形成的字节,并不会让CommonConn为同一段网络重传再调用一次Seal。

固定实现还有一个极远计数边界的换钥分支:若调用前状态已经为全ff,接下来的隐式调用先回绕到全零,用旧密钥处理这一条,再以这一条完整头和密文作为新context、仍以UnitedKey为材料重建AEAD,下一条从新状态01开始。接收方在解密前保存同一份旧记录字节用于相同派生,验证后跟进。不能把这个实现顺序改写为“在全ff之前就发送TLS KeyUpdate”。

这一计数分支也不是可以安全发送到2的96次方条记录的用量保证。AEAD安全界限还取决于算法、消息数与总数据量,不能只看Nonce空间;本模块所列代码未在这里实现第16章TLS标准那套用量更新策略。UnitedKey也没有因为重新派生而重新进行一次临时密钥交换,不能把它称为新一轮PFS。

记录完整性有范围:修改长度、密文或标签应使相应验证失败,乱插或删除记录通常也会破坏两端Nonce同步;但记录层没有一个与TLS close_notify同名的认证结束消息。底层EOF不能单独证明网站应用内容完整,仍要依赖上层长度、结束规则或应用确认。AEAD验证过的前半份文件,与完整取得文件是两件事。

全部字节和状态顺序见common.go的Write、Read、NewAEAD及IncreaseNonce;握手对方向AEAD的提前使用见client.go和server.go。下一节说明random模式如何在这份记录上改变可见头,而不改变AEAD验证对象。

22.6 三种外观模式:公开材料变换与秘密记录头变换 ​

native、xorpub、random在相同核心密钥交换与AEAD保护之外,选择不同的线上呈现方式。比较它们应先指出哪些字节被处理,再讨论处理材料由谁知道;不能把XOR出现次数当成密码强度等级。

模式静态交换材料变换普通数据记录五字节头
native保留相应原始形式,仍受多静态段之间的绑定影响可见17 03 03及长度
xorpub额外用静态公钥与IV派生的CTR处理仍可见17 03 03及长度
random与xorpub相同的公开材料处理再用UnitedKey相关CTR变换

单段静态X25519的native握手直接暴露一份X25519公钥。它不是任意均匀32字节:标准输出的最高位有特定约束。固定服务端还拒绝接收公钥最后字节最高位为1的表示,避免某种不改变曲线解析结果的改位。ML-KEM密文也有自己的编码与生成分布;不能因为没有明文应用字段,就宣称所有握手字节都可按均匀随机理解。

xorpub和random先用对应静态公钥原始字节作为材料,经过D(ASCII的VLESS, 公钥字节)派生AES密钥,再以这次16字节IV建立AES-CTR,将X25519公钥的32字节或ML-KEM密文的1088字节作XOR。服务端能从私钥恢复同一公钥,做相反的相同XOR来还原。

这个变换不需要服务器私钥:持有客户端配置中那份静态公钥及公开IV的人,也能计算并还原。因此它改变的是不了解相应配置者所见的材料形式,不应被称为一份独立的私钥认证或AEAD加密。知道公钥与能算出静态交换共享秘密仍是两件事;可逆地还原公开材料,并不自动得到nfsKey。

多个静态段时,还叠加22.4的前段秘密绑定。公开材料CTR与基于前段共享秘密的CTR不是同一个状态:前者可由对应公钥配置恢复,后者依赖前段这次交换秘密。把所有XOR统一描述成“用一个固定密码异或整个握手”,会丢失这一差异。

random在完整握手建立后,用XorConn包住底层连接,处理后续记录头。CommonConn先按22.5生成原始头和AEAD输出,再经XorConn发送。XorConn从原始五字节头读出L,用CTR只变换头,跳过后面L字节密文与标签,然后继续处理下一头。它不对整段正文再执行一次CTR,也不在跳过正文时额外消耗相同数量的CTR密钥流。

接收方向反过来,先用CTR恢复五字节头,再按恢复后的L跳过相应正文,交给CommonConn。一次底层Read若只取得头的前两字节,XorConn会保存这部分状态,下一次继续;它不能假定每次Read都从记录边界开始。头变换不增加字节,L也没有变成长整数。

在完整1-RTT路径,上行头CTR的材料为UnitedKey、IV为客户端16字节IV;下行头CTR也用UnitedKey,但IV取解密后的16字节票据。这些CTR密钥通过固定VLESS上下文派生,与普通记录AEAD所用的1216或1120字节上下文不同。对只知道静态公钥的人,它们不再是可立即重建的公开材料CTR。

握手里的原始交换与Padding已经在安装XorConn之前发送;客户端还可能没读完服务端Padding。实现用InSkip、OutSkip跳过这些残余原始字节和恢复前缀,确保头CTR只在相应记录处启动。不能把random开关理解成对连接从第一个IV字节开始连续异或,否则两端状态会完全不同。

AEAD仍负责记录完整性。CommonConn认证的是恢复后的原始五字节头,任何对线上头的改变在还原后仍需通过长度检查和标签验证;CTR自己没有新添一份认证标签。知道或推测原始17 03 03,并不能仅靠“已知明文”直接推出UnitedKey,但观察者仍可利用时序、长度和连接结构,random这个名字不构成统计不可区分证明。

外观还影响工程优化。固定VLESS的Vision路径遇到XorConn会标记不能穿透到底层直接复制,因为接收端仍需要维持头变换状态;不能一边选择random,一边假定所有数据都能绕过这一层走同样的直通路径。第23章会将这个限制放回Vision状态机。

精确处理见xor.go、client.go、server.go及VLESS出站的CanSpliceCopy条件。

22.7 Padding不只是多塞字节,还改变发送节奏 ​

固定完整握手带有加密Padding,并允许把握手字节拆成不同批次,中间插入等待。它要调整的是长度与时间分布,不是增加新的密钥交换熵。Padding使用加密记录保护,不能把它理解为直接在线上发送若干个00。

设一个方向总Padding长P。这个总量包含18字节加密长度块,以及后面的加密Padding内容:

text
总长P = 长度明文2+标签16 + 内容明文(P−34)+标签16

所以长度块中的数值为P−18,读完它还要读取相应数量的字节;解密得到的实际填充内容长P−34。握手消息的内存初始分配为零,这些填充内容可以来自那份零区,经AEAD后呈现为密文。它们不是重新生成的ML-KEM材料,也没有进入UnitedKey。

配置以点分段,每段为三个用连字符分隔的整数;按位置交替解释为长度描述和间隔描述。每个三元组由选择阈值、下界、上界组成。若阈值满足代码的随机比较,就在区间内选择对应字节数或毫秒数,否则该项取零。第一项必须能保证基础Padding存在,解析器要求其阈值至少100、两端长度至少35。

准确区间由RandBetween决定:它在正常升序时取下界包含、上界不包含的整数,跨度为1则只得到下界。选择比较又是阈值 >= RandBetween(0,100),后者取0—99,因此阈值50对应51个选中值,阈值75对应76个选中值。不能不看比较符号,就把这两个整数机械写成恰好50%和75%。这里描述实现算术,实际生成分布还包括选中后恰好得到零的情形。

不显式给Padding时,固定默认长度三元组为(100,111,1111)与(50,0,3333),中间间隔三元组为(75,0,111)。第一段为111—1110字节,第二段可能为零或选取0—3332字节,间隔可能为零或选取0—110毫秒。它是当前实现默认分布,不能推广成永久协议常量。

用一个完全确定的构造来观察范围:取长度200、间隔40毫秒、长度300,总Padding为500。对应三元组可由区间[200,201)、[40,41)、[300,301)选出,两段阈值均为100。若静态阶段只有一个X25519段,则客户端非Padding部分1298字节被加到第一段,发送过程为:

批次发送量随后行为
第一批1298+200=1498字节等待40毫秒
第二批300字节进入后续握手读取

整份客户端握手1798字节。这里第一、第二批是对同一份握手字节切片,不是分别给两批再各加一组长度和标签。Padding长度块明文为482,即01 e2;实际Padding内容明文为466字节。由于后一份标签位于内容结尾,接收方不能只拿第一批的一部分,就宣称整份Padding已验证。

发送分批也不等于线上有两个TCP段。TCP和外层承载可以再分片或缓冲;若上层是HTTP,请求体缓冲还可能抹掉部分时间差。反过来,40毫秒等待来自本地策略,与网络往返无关,不能计作“多了一次RTT”。在低延迟链路上它仍可能显著影响短连接。

解析器把所描述最大Padding总量限制在18+65535之内,对应两字节密文长度容量。该限制只控制这一份Padding对象,不代表系统总内存或总握手并发已受约束。对端仍要等待并验证内容;握手截止时间、上层取消和承载接口对超时的实际支持,都影响资源何时释放。

1-RTT客户端还特意将服务端Padding的正文消费推迟到CommonConn.Read,使上行数据可以与服务端慢发Padding交叠。0-RTT恢复分支的前缀却不按这份完整握手结构发送Padding,不能把本节500字节固定附加到每个恢复请求。模式、缓存命中与发送节奏要一起解释。

准确三元组解析、默认值与阈值见common.go,区间边界见RandBetween,切片发送与等待见客户端及服务端。这些机制能够改变一些可观察维度,没有提供对所有识别方法都有效的证明或百分比效果。

22.8 600s与0rtt:恢复的是哪份状态 ​

完整握手结束时,客户端可以保留64字节pfsKey、16字节票据Ticket和本地到期时刻Expire;服务端以票据为索引保存ServerSession,其中有同一pfsKey和已使用nfsKey集合。票据不是直接包含加密后的整份会话秘密,不是VLESS UUID,也不是一张能交给任意TLS服务器使用的恢复票据。

服务端先生成16字节随机票据,再把前两字节覆写为有效秒数,故其余14字节保留随机关联值。票据整体经服务端方向AEAD保护后成为32字节字段,客户端解密取回16字节。票据的可预测期限部分与随机部分应分别理解,不能把16字节全都算作128位随机熵,更不能只凭票据长度就认作独立密钥。

固定600s的实际计算是600 × RandBetween(50,100) ÷ 100,整数运算且随机区间为50—99。因此发出的期限是300、306、312,直到594秒这些值,并非必定600秒,也不是均匀选择300—599的每一秒。若配置为300-600s,走另一个区间分支,才会选择300—599秒。名称相似的两种配置有不同分布。

客户端只在允许0rtt且服务端返回秒数非零时保存恢复材料,以当前时间加收到的秒数设置Expire。它不随每次缓存命中自动滑动延长;服务端恢复分支也没有再发一张新完整握手票据。客户端配置1rtt时,即使服务器允许保存状态,本地也不进入这条缓存使用路径。

下一条连接到来,客户端仍先生成新16字节IV及新的静态阶段交换材料,得到新的nfsKey,但不再发送新的临时ML-KEM公钥或进行一轮新临时混合交换。它取旧pfsKey,形成UnitedKey = 旧pfsKey || 新nfsKey。恢复前缀为:

顺序长度作用
IV及静态材料16+R与完整握手同类的静态阶段,R由配置决定
加密长度18明文00 20,声明随后密文长32
加密票据32旧Ticket的16字节,用本次静态阶段AEAD加16字节标签
第一份数据记录依负载决定用旧pfsKey和新nfsKey组合保护VLESS字节

18字节长度块与32字节票据用静态阶段Nonce 01、02,AAD均为空。长度明文32是区分恢复分支的重要值;完整交换的对应值至少1232。单个静态X25519段的恢复前缀为16+32+18+32=98字节,静态ML-KEM段为1154字节。它们都没有完整握手的1250字节临时公钥块及那份Padding结构。

客户端不必先发这98字节并等待。它把前缀放入PreWrite,Handshake直接返回,直到第一次非空CommonConn.Write,才把前缀与首条加密记录一起写出。因此0-RTT的“零”是指发送这批上层数据前,不需要本次服务端交换回应;它不消除底层TCP或外层TLS握手,也不保证调用Handshake时已经在网络上发出了数据。

恢复的方向上下文也变化了:上行AEAD以本次32字节加密票据作为context,而非解密后的16字节Ticket;服务端收到的同一密文字节可重建它。下行由服务器新生成16字节serverRandom,使用它作为context建立发送AEAD,并把这16字节在首条下行记录之前发送。客户端首次Read先读serverRandom,再建立接收AEAD。两方向从各自01计数开始。

服务端解密票据后查会话表,查到才取旧pfsKey,并对本次nfsKey做去重检查;下一节解释这个重放边界。没有命中的票据不能自行恢复秘密,服务器发送用于触发失败处理的噪声并返回expired ticket错误。客户端在特定的恢复记录头错误路径会把缓存标为过期、报告需要新握手;这段代码没有承诺所有失败都自动无损重发应用数据,不能把错误提示当成一份通用应用重试策略。

缓存还受到实际进程状态影响。服务器重启丢失内存中的Sessions,另一台未共享该状态的后端也不能只凭同一静态私钥恢复票据。客户端需要重新完整交换。将恢复请求负载均衡到不同进程时,应分析状态归属;这与无状态票据可以由共享解密密钥恢复的设计不同。

服务端清理以分钟为单位分组,按配置期限上界另留余量后回收相应票据及会话;它不是每次请求都将票据头中的秒数与一个绝对时间戳比较。客户端Expire控制正常客户端何时停止使用,服务端表何时实际移除是另一个生命周期。安全分析不能把两者都简化为“600秒后精确删除所有内存”。

恢复前缀、保存与查找见client.go、server.go,PreWrite、首次下行读取及缓存失效分支见common.go。0-RTT节省一次本层交换的等待,同时把之前保存秘密和去重状态带入了新连接的安全假设。

22.9 恢复去重不是TLS 0-RTT结论的照搬 ​

TLS 0-RTT允许性与重放问题,不能不加检查地整体移植到VLESS Encryption。两者都在新连接收到完整新交换结果前发送数据,但本模块额外保存每个恢复会话已经使用的nfsKey,具有自己的去重范围与状态依赖。

服务端先从静态交换取得nfsKey,解开票据,查到ServerSession。随后对该会话的NfsKeys执行原子的LoadOrStore,以32字节nfsKey为键;若已存在,直接返回replay detected,不把连接交给VLESS。首次插入成功后,才组合旧pfsKey和本次nfsKey、建立CommonConn。

所以对同一票据完整照抄一次恢复前缀,即使重复发生在不同TCP连接,也会导向相同nfsKey,第二个到达去重检查的尝试被拒绝。只更换IV不会使这个ECDH或KEM共享秘密自动改变:IV参与派生和表示,不是nfsKey计算中替代临时私钥或封装随机性的材料。正常客户端通过重新生成静态阶段交换材料取得新的nfsKey。

去重检查不等待第一份应用记录成功认证。因此它决定的是哪次尝试先占用了这一nfsKey,而不是保证先占用者已经提交一份合法网站操作。副本竞争可能使其中一条连接无法继续,不能由“重复不会两次进入上层”推出抵御所有资源消耗或竞争中断。会话表还需要为这些已用键占用内存,直到相关状态回收。

这是一份ServerSession内、当前进程中的检查。若后端完全没有票据状态,它会拒绝恢复;这通常促使重新完整交换,而非自动接受重放。若另一个设计把pfsKey和票据复制到多个独立进程,却不共享原子去重状态,同一恢复请求就可能分别在两个空集合中首次插入成功。这是改变架构后必须重新分析的边界,不是所列单进程源码已经实现分布式去重。

同样,状态回滚、进程快照或复制策略可能使“已经用过”的事实丢失。只恢复票据密钥而不恢复其已用集合,与恢复完整安全状态不是同一操作。时间有效期限制能缩短需要保留的范围,却不能替代这个集合,也不能仅靠两端时钟大致一致恢复它。

应用语义还在更上一层。一个已授权客户端知道自己的恢复材料,可以使用新nfsKey再发一次相同HTTP请求;这不属于nfsKey重放,记录层不认识“同一笔订单”。网络中断后应用重新创建新连接,也可能重新提交操作。因此代理层去重不能代替应用幂等键、业务事务或操作确认。

PFS则要按秘密的产生时刻来评价。完整握手新生成的临时ML-KEM和X25519材料,有机会在长期静态私钥事后泄露时保护过去UnitedKey;只要临时秘密已安全丢弃且相关假设成立,解开公开交换材料仍不足以计算临时混合秘密。恢复路径直接复用旧pfsKey,没有为每条恢复连接重新引入一份来自服务器的临时秘密。

新nfsKey仍然使恢复连接各有不同UnitedKey,保护不能简单说成“所有恢复连接使用同一AEAD密钥”。但新密钥不同,并不自动取得每连接全新临时混合交换的相同泄露边界。若服务器在缓存有效期间失陷,攻击者同时取得静态私钥和该会话pfsKey,就能结合保存的恢复交换重建相关连接的UnitedKey;这与仅在所有相关缓存及临时秘密销毁后取得静态私钥不同。

后量子属性也分阶段。完整握手的pfsKey含临时ML-KEM,静态nfsKey则由配置决定;恢复不重新执行那次临时ML-KEM交换,而是继承旧pfsKey。新下行serverRandom公开发送,只用于上下文分离,不是又一份只有服务端知道的新共享秘密。不能把它当作每次恢复都刷新PFS的证据。

因此0-RTT的合理比较是:在既有会话秘密仍被保存的条件下,用新静态阶段秘密形成新连接密钥,并在服务端同一会话范围内拒绝已见nfsKey;它用状态与生命周期换取少一次等待。确切去重位置、共享范围和清理行为见ServerSession与恢复分支,客户端缓存与新静态交换见client.go。

22.10 同样叫私钥泄露,发生时刻与材料范围不同 ​

先用单静态段讨论一条完整连接F及其后两条恢复连接R1、R2。三条连接共享由F建立的缓存pfsKey,但分别具有nfsKey_F、nfsKey_R1、nfsKey_R2。三个UnitedKey通常不同;“共享缓存”与“共享同一记录密钥”不能画等号。

假设攻击者保存了三条连接的全部线上字节。之后发生不同泄露:

攻击者另外得到的材料对保存流量可直接重建什么还缺少什么
只有静态私钥相应静态交换的nfsKey及被它保护的临时公开交换材料已安全丢失的临时混合pfsKey
只有缓存pfsKey三条组合秘密的共同前64字节每条连接各自的nfsKey
静态私钥及这份pfsKey相应F、R1、R2的UnitedKey和方向派生输入不需要再凭空破解记录AEAD
某条连接的UnitedKey该连接在已知上下文下的方向密钥及同材料派生状态其他连接的独立nfsKey或pfsKey

表中第一行依赖“临时秘密和缓存秘密已无法取得”的条件,第三行也只覆盖使用这份pfsKey的关联范围。不能把有限缓存组失陷直接说成全服务器历史流量必然失陷;也不能只看不同连接密钥不同,就忽略它们背后共享的缓存材料。

过去被动解密与未来主动仿冒又有区别。静态私钥泄露后,攻击者可以为未来客户端生成一份自己掌握的临时交换,完成静态阶段所需保护,危及客户端对预期服务器的绑定。过去完整连接的临时混合秘密即使仍未被恢复,也不能据此保证未来连接的身份安全。修复身份信任需要让客户端信任的入口材料重新与合法服务器对应,单靠记录Nonce继续递增不会恢复这份身份边界。

同样,若客户端最初拿到的静态公钥就来自不可信替换,后续数学计算完全正确,仍可能把VLESS ID与目标安全地发给错误服务器。这是固定公钥信任模型的起点问题,不是再加一个更长的AEAD标签能解决的问题。没有公共CA并不表示没有信任根,而是可信配置中的公钥承担了相关角色。

后量子条件也能放进这条时间线。静态段若为X25519,未来能够破解这段交换的攻击者可重建nfsKey、打开被保护的临时公钥和服务端回复;但在仅保存过去流量的模型中,临时ML-KEM仍可能保护pfsKey。若攻击者在握手当时已有足够主动破解和仿冒能力,就应另行分析服务器认证,不能借过去被动解密的结论保证在线身份。

采用静态ML-KEM又改变静态阶段假设,但仍不把临时秘密、恢复缓存与主机失陷从模型中删除。ML-KEM是KEM,不能像数字签名一样仅凭“使用了后量子算法”就给任意对象做身份声明;本协议的对端确认来自可信静态公钥、对应秘密、受保护回复与后续记录共同组成的路径。

最后,软件里的过期和物理销毁不是同义词。固定客户端只用Expire判断是否复用;到期不会在那一刻把PfsKey数组逐字节清零。服务端分钟清理删除映射,也不等于对内存中所有副本执行安全擦除;活跃CommonConn仍持有UnitedKey。对象不再被查到、垃圾回收后内存如何处理,以及攻击者是否能取得历史内存副本,是不同层次。

因此本书的PFS陈述采用明确条件:长期静态材料在会话结束后泄露,而相关临时、缓存与记录秘密已经不再可得。所读源码提供了密钥材料分工与一定生命周期管理,却没有让“600s”成为一个精确到秒的全部秘密擦除证明。分析真实进程失陷时,必须检查当时仍活跃的连接和仍存留的恢复材料。

相应保存范围可沿ClientInstance缓存和CommonConn创建、ServerSession与回收及CommonConn.UnitedKey核对。这个时间线说明的是所列材料组合下的影响,不声称完成了整个实现的安全审计。

22.11 一条完整记录与两种握手成本 ​

构造恢复连接的第一条下行记录,选择native外观和AES-GCM。这里只复算记录层,不假装完成实际KEM交换:给定一份96字节UnitedKey,其字节依次为00、01,直到5f。前64字节代表已给定的pfsKey,后32字节代表已给定的nfsKey;它们全部公开,只用于计算说明。

服务端新生成的16字节serverRandom在本例固定取ASCII文本0123456789abcdef。这里每个字符各占一字节,实际内容为30 31 32……65 66,不是把这16个十六进制字符解码成8字节。这种可打印输入只是为了让上下文可以简洁而精确地复现,不是要求真实服务器使用固定随机数。

执行22.4的D,得到32字节记录密钥:

text
351df0bbf4b0d777c958171ee47054771efe88cecdf8ac7e1940bbd1cfffeb77

第一条下行使用Nonce 00 00 00 00 00 00 00 00 00 00 00 01。明文为两字节VLESS响应头00 00,加五字节hello,共7字节:00 00 68 65 6c 6c 6f。因此L=7+16=23,AAD头为17 03 03 00 17。完整28字节记录分成:

text
17 03 03 00 17 | 52 30 9c 4e b4 1b 01
    AAD头          7字节密文

2d 2d 88 ce 0f 1c 41 38 16 d5 ec 81 23 44 a1 49
                 16字节标签

在线上这三部分连续,没有空行或文字标签。0-RTT下行还在这份记录之前发送16字节serverRandom,所以首批下行在本例为44字节。客户端先读这16字节派生接收密钥,再读五字节头和23字节密文,验证成功后,VLESS消费00 00,将hello交给对应上层。

这里没有“7字节TLS明文加内部17类型”这一步。长度23与内容第一字节00也是不同对象;若把五字节头误计入AAD明文,或把serverRandom前缀误当成一份TLS记录,都得不到相同结果。random模式还会变换这五字节头,不能直接拿上述头当成该模式所有线上数据的固定前缀。

作者内部以BLAKE3 1.0.8 Python绑定计算派生,再使用密码库复算AES-GCM与ChaCha20-Poly1305两种分支的加解密,检查修改头、密文和Nonce均不能通过相应认证。这里只展示AES结果;两算法输出不同,不可以相互替代。检查没有运行完整Xray握手,向量也不是项目官方发布的互操作向量。

再把握手成本放在同一组明确条件下计算。静态段只有一个X25519,完整握手两方向Padding都取22.7构造的500字节,不启用外观额外字节或其他承载开销:

项目完整握手或首批字节数
客户端完整握手1298+500=1798
服务端完整握手1168+500=1668
两方向完整握手合计3466
恢复上行前缀98
恢复下行前缀16

若完整握手后紧接第19章39字节上行请求,且它恰好占一条记录,还要再加60字节;3466不是包含所有VLESS内容的会话总量。恢复上行则可将98字节前缀与这60字节记录一起写出,共158字节。前缀与记录合批减少写入次数,却不删掉其中任一字段。

在持续数据阶段,普通Write按最多8192字节分段,每条增加21字节;一次8193字节写入通常产生8192和1两段,共增加42字节,结果8235。若本来分成8193次单字节Write且没有上层合批,记录费用会完全不同。Encryption本层分段、HTTP承载合批和TCP分段应分别算,不能把一个8192常数当成全路径MTU。

所有数值都排除了外层TLS、HTTP、TCP/IP、确认与重传;Padding也固定为教学构造,不是默认平均值或分位数。准确成本来自具体状态和字节路径,不能把158与3466直接相比就声称任意应用“加速二十多倍”。前者发送时依赖之前已经建立并保存的完整会话状态。

记录及成本依据CommonConn、完整与恢复客户端和服务端。固定输入与给出的密文都是本书计算例,没有使用真实密钥或生产流量。

22.12 验证顺序与源码证据的实际边界 ​

字段图通常画成“解密、验证、继续”,但实现是否检查了每次返回结果,还要逐调用核对。以本章固定客户端完整握手为例:它读满1136字节服务端交换回复后,调用静态阶段AEAD.Open,并显式给MaxNonce;这一处没有接收Open返回的错误。随后仍尝试解封装、解释X25519公钥,形成UnitedKey,再读取并验证32字节票据。

因此不能将该路径写成“第一块回复标签不对,客户端就在这一行立即退出”。它可能继续进入后续密钥材料解析和票据验证,实际失败位置与输入、底层库行为有关。后面票据Open确实检查错误,普通数据记录Open也检查错误;同样不能仅凭前面没有检查返回值,就反过来宣称未认证的任意VLESS正文已被接受。

这是代码审查应保留的区别:协议意图依靠哪些认证,代码在哪些位置执行调用,哪些返回值参与控制流,以及完整路径是否存在可利用影响,需要不同证据。本书确认前述返回值处理事实,没有构造漏洞利用,也没有完成足以给整个实现下漏洞结论的安全审计。

一些其他边界已经可以从调用链直接确定:

检查位置所列实现行为解释时应保留的限制
服务端第一长度块AES失败后换ChaCha重新尝试,仍失败则退出不能凭默认UseAES判断唯一算法
服务端临时交换检查长度、AEAD与公钥解析结果KEM的隐式拒绝与结构错误不是同一分支
恢复票据查会话,并原子记录本次nfsKey状态作用域不是全球所有后端
普通数据记录读完整头与密文,检查Open错误后返回明文不构成应用完整结束证明
极限Nonce分支用旧记录字节与UnitedKey重新派生不等于TLS用量限制或新临时交换
会话过期停止正常复用或删除服务端映射不保证全部副本被安全擦除

未来版本若修正某个返回值检查,仍要同时确认双方消息格式、上下文和计数未发生变化。源码中的同名函数、日志文本和结构字段不能单独充当线协议规范;配置构建器、密钥初始化、握手与记录四处必须彼此一致。本章采用固定提交,是为了让这些判断能够重复定位。

安全比较也不能停在“有ML-KEM,所以更安全”或“外面又有TLS,所以里面怎样都一样”。前者忽略认证与缓存生命周期,后者忽略外层可能在中间节点终止,或内外密钥的失陷范围不同。正确的比较要先画端点,再列可信材料、主动与被动攻击能力、缓存与并发范围,最后才评价某个组件失效后的剩余保证。

工程上还有一个直接影响后续章节的事实:CommonConn给Vision暴露了可检查的缓冲与连接状态。某些满足条件的路径能在识别到内层受保护TLS流量后改变外层处理,另一些由于random头变换、非RAW承载或额外安全包装而不能穿透。不能把本章正常CommonConn.Write的“每8192字节加21字节”直接当成所有Vision阶段的永久数据路径。

下一章从双层加密的实际工作开始,追踪Vision怎样识别流量、加Padding、发送切换命令、排空已有缓冲,并在条件允许时使用不同复制路径。它既不能凭名称消除浏览器与网站的TLS,也不能在没有检查状态时绕过还必须处理的加密或承载层。

本节的返回值与记录检查直接见固定client.go、server.go及common.go。本章的数值核对和条件分析,不替代独立密码学审查、运行时互操作验证或全实现安全证明。

从字节与状态机出发,理解每一层的职责。