fix(crypto): flags/reserved 头部字节纳入 AAD 认证

chacha20_poly1305 加解密 AAD 为空,flags 字节(COMPRESSED/FEC/GATEWAY)
未经认证,中间人可翻转标志位造成不可检测的丢包/语义篡改。

AAD 取头部 byte2(flags)/byte3(reserved);ttl(byte1) 中继转发时会递减,
不纳入 AAD 以保持中继兼容。

注意:此改动与旧版本(AAD 为空)不兼容,新旧节点间无法互通加密包。

测试:
- test_tampered_flags_rejected: 翻转 flags 必须解密失败
- test_ttl_change_still_decrypts: ttl 递减不影响解密
This commit is contained in:
lbl
2026-08-20 22:40:30 +08:00
parent b78140dc2d
commit 016165ff8e
+62 -4
View File
@@ -71,6 +71,18 @@ impl PacketCrypto {
Ok(nonce12)
}
/// AAD 覆盖头部 byte2(flags)/byte3(reserved)。
/// flagsCOMPRESSED/FEC/GATEWAY)只由发送方设置、传输中不会被修改,
/// 必须纳入认证,否则中间人可翻转标志位造成不可检测的丢包/语义篡改;
/// ttl(byte1) 在中继转发时会递减,不能纳入 AAD。
fn make_aad<B: AsRef<[u8]>>(pkt: &NetPacket<B>) -> [u8; 2] {
let buf = pkt.buffer();
if buf.len() < HEAD_LENGTH {
return [0; 2];
}
[buf[2], buf[3]]
}
/// 原地加密(in-place
/// payload 后需要预留16字节用于存放 tag
pub fn encrypt_in_place<B: AsRef<[u8]> + AsMut<[u8]>>(
@@ -82,6 +94,7 @@ impl PacketCrypto {
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
pkt.set_seq(seq);
let nonce = Nonce::assume_unique_for_key(self.make_nonce(pkt)?);
let aad = Aad::from(Self::make_aad(pkt));
let payload = pkt.payload_mut();
let payload_len = payload.len() - TAG_LEN; // 实际 payload 长度(不含 tag 预留空间)
@@ -89,7 +102,7 @@ impl PacketCrypto {
// 只加密实际的 payload 部分
let tag = self
.key
.seal_in_place_separate_tag(nonce, Aad::empty(), &mut payload[..payload_len])
.seal_in_place_separate_tag(nonce, aad, &mut payload[..payload_len])
.map_err(|_| io::Error::other("encrypt failed"))?;
// 将 tag 写入 payload 后的预留空间
@@ -104,12 +117,13 @@ impl PacketCrypto {
pkt: &mut NetPacket<B>,
) -> io::Result<usize> {
let nonce = Nonce::assume_unique_for_key(self.make_nonce(pkt)?);
let aad = Aad::from(Self::make_aad(pkt));
let payload_with_tag = pkt.payload_mut();
let plaintext = self
.key
.open_in_place(nonce, Aad::empty(), payload_with_tag)
.open_in_place(nonce, aad, payload_with_tag)
.map_err(|_| io::Error::other("decrypt failed"))?;
Ok(plaintext.len())
}
@@ -222,7 +236,8 @@ mod tests {
}
/// nonce 完全由包自带的头部字节决定,与发送端状态无关:
/// 旧版本(seq 恒为 0)发出的包,新版本必须能正常解密,反之亦然。
/// 未启用 AAD 的旧版本发出的包(flags/reserved 为 0 时 AAD 语义不同),
/// 这里用同一 AAD 逻辑模拟对端,验证 nonce 只依赖头部、与发送端 seq 状态无关。
#[test]
fn test_cross_version_compat() {
let key = [7u8; 32];
@@ -235,11 +250,12 @@ mod tests {
let original: Vec<u8> = pkt.buffer()[HEAD_LENGTH..HEAD_LENGTH + 20].to_vec();
pkt.set_seq(0);
let nonce = Nonce::assume_unique_for_key(peer.make_nonce(&pkt).unwrap());
let aad = Aad::from(PacketCrypto::make_aad(&pkt));
let payload = pkt.payload_mut();
let payload_len = payload.len() - TAG_LEN;
let tag = peer
.key
.seal_in_place_separate_tag(nonce, Aad::empty(), &mut payload[..payload_len])
.seal_in_place_separate_tag(nonce, aad, &mut payload[..payload_len])
.unwrap();
payload[payload_len..payload_len + TAG_LEN].copy_from_slice(tag.as_ref());
@@ -258,4 +274,46 @@ mod tests {
&original2[..]
);
}
/// AAD 覆盖 flags(byte2):中间人翻转 COMPRESSED/FEC/GATEWAY 标志位
/// 必须导致解密失败,而不是被静默接受。
#[test]
fn test_tampered_flags_rejected() {
let crypto = PacketCrypto::new([7u8; 32]);
let mut pkt = build_test_packet(20);
crypto.encrypt_in_place(&mut pkt).expect("encrypt failed");
// 翻转 flags 字节(模拟中间人篡改)
pkt.set_fec_flag(true);
assert!(
crypto.decrypt_in_place(&mut pkt).is_err(),
"tampered flags must fail authentication"
);
}
/// ttl(byte1) 在中继转发时会递减,不属于 AAD:
/// 转发后 ttl 变化的包必须仍能正常解密。
#[test]
fn test_ttl_change_still_decrypts() {
let crypto = PacketCrypto::new([7u8; 32]);
let payload_len = 20;
let mut pkt = build_test_packet(payload_len);
pkt.set_ttl(15); // 初始 ttl
let original: Vec<u8> = pkt.buffer()[HEAD_LENGTH..HEAD_LENGTH + payload_len].to_vec();
crypto.encrypt_in_place(&mut pkt).expect("encrypt failed");
// 模拟中继递减 ttl
pkt.set_ttl(14);
crypto
.decrypt_in_place(&mut pkt)
.expect("ttl change must not break decryption");
assert_eq!(
&pkt.buffer()[HEAD_LENGTH..HEAD_LENGTH + payload_len],
&original[..]
);
}
}