Skip to main content

omy_core/
reencrypt.rs

1//! 真正的密钥轮换:换掉 FEK 并重新加密载荷。
2//!
3//! # 与 [`crate::keyslot`] 的分工
4//!
5//! [`keyslot::rewrite_slots`](crate::keyslot::rewrite_slots) 改的是「谁能打开
6//! 这个文件」,FEK 与载荷密文一字节不变。这有一个用户很容易误解的后果:
7//! **移除一个密码只影响这一份文件**。攻击者若留有旧副本,仍能用被移除的密码
8//! 打开那个副本——因为那份副本的 slot 区还是旧的,里面包裹的还是同一个 FEK。
9//!
10//! 本模块解决的正是这件事:换一个全新的 FEK、全新的 `file_uuid` 与
11//! `base_nonce`,把载荷整个重新加密一遍。此后旧密码不但打不开这个文件,
12//! 旧密码派生出的 KEK 也不再对应任何有效的 FEK。
13//!
14//! # 代价必须如实告知
15//!
16//! 成本与文件大小成正比(文档 03 §4.3),不是毫秒级:
17//!
18//! - 全部密文字节都变了。对外部观察者是一个全新文件,增量备份要重传整份,
19//!   跨文件去重失效;
20//! - 大文件是数分钟到数小时的读写。
21//!
22//! 所以这**不能**做成密码管理的默认行为,只能是用户明确选择的另一个操作。
23//!
24//! # 它仍然挡不住什么
25//!
26//! 轮换只保证「今后拿到这一份文件的人」用旧密码打不开。已经流出去的旧副本
27//! 是一份独立的密文,它的 FEK 早已被旧密码包裹好,本操作对它无能为力。
28//! 唯一诚实的表述是:轮换让这份文件从此与旧密码无关,**但不能收回已经泄露
29//! 的副本**。UI 不能把它说成「彻底作废旧密码」。
30
31use crate::crypto::{Fek, Kek, header_mac};
32use crate::error::{Error, Result};
33use crate::file::{EncryptOptions, EncryptedFile, RandomMaterial};
34use crate::header::{FixedHeader, HEADER_MAC_LEN, SLOT_AREA_LEN, SLOT_AREA_OFFSET, flags};
35use crate::payload::ProgressFn;
36use crate::tlv::types;
37
38/// 轮换的结果。
39#[derive(Debug)]
40pub struct RotateOutcome {
41    /// 重新加密后的完整文件字节。
42    pub bytes: Vec<u8>,
43    /// 解开原文件时命中的 slot 下标。
44    pub opened_slot: u16,
45    /// 轮换后占用的 slot 数量。
46    pub slot_used: usize,
47    /// 明文字节数,供调用方展示「重写了多少数据」。
48    pub plaintext_size: u64,
49}
50
51/// 外部编辑后需要随新内容重建的媒体附加信息。
52///
53/// 三个字段使用 `Option` 表示“新文件没有该信息”,而不是“沿用旧值”。
54/// 外部编辑可能把视频改成文本;沿用旧缩略图或 moov 会让头部描述另一份内容。
55#[derive(Debug, Clone, Default)]
56pub struct ReplacementMetadata {
57    /// 新内容的缩略图。
58    pub thumbnail: Option<Vec<u8>>,
59    /// 新内容的媒体元信息。
60    pub media_meta: Option<Vec<u8>>,
61    /// 新内容的 MP4 moov 副本。
62    pub moov_cache: Option<Vec<u8>>,
63}
64
65/// 用新明文替换单文件载荷,同时逐字节保留原槽区。
66///
67/// 这是外部编辑回写用的格式操作。它与 [`rotate_fek`] 的目标相反:轮换要让旧
68/// FEK/旧密码失效,这里必须让原有密码、设备密钥、恢复码全部继续有效。因此保留
69/// `file_uuid`、FEK 和完整 slot area,只生成新的 `base_nonce` 并重建载荷、内容
70/// 哈希及媒体 TLV。新 nonce 是硬约束;同一 `(FEK, file_uuid)` 下复用旧 nonce
71/// 加密不同明文会破坏 AEAD 安全性。
72///
73/// 目录容器不能走这条路径:第三方编辑器拿到的是一个普通文件,无法表达容器索引
74/// 与多文件载荷的同步修改。
75///
76/// # Errors
77///
78/// 原文件打不开、是目录容器、槽区或头部截断,以及重新加密失败时返回错误。
79pub fn replace_plaintext_preserving_slots(
80    data: &[u8],
81    unlock: &[Kek],
82    plaintext: &[u8],
83    metadata: &ReplacementMetadata,
84    new_base_nonce: [u8; 7],
85) -> Result<EncryptedFile> {
86    let opened = crate::file::open(data, unlock)?;
87    if opened.is_container() {
88        return Err(Error::MalformedHeader {
89            reason: "container payload cannot be replaced as a single file",
90        });
91    }
92    let old = FixedHeader::parse(data)?;
93    let slot_end = SLOT_AREA_OFFSET.saturating_add(SLOT_AREA_LEN);
94    let old_slots = data
95        .get(SLOT_AREA_OFFSET..slot_end)
96        .ok_or(Error::Truncated {
97            context: "key slot area",
98            need: slot_end,
99            got: data.len(),
100        })?;
101
102    let mut opts = rebuild_options(&opened, &old, 1)?;
103    // FEK 没变,槽位目录也必须逐字节保留原类型;不能像轮换那样按 keep 重建。
104    opts.slot_directory = if opened.is_slot_managed() {
105        Some(opened.raw_slot_directory()?)
106    } else {
107        None
108    };
109    opts.thumbnail = metadata.thumbnail.clone();
110    opts.media_meta = metadata.media_meta.clone();
111    opts.moov_cache = metadata.moov_cache.clone();
112
113    // encrypt_with_fek 需要一个 KEK 来造临时槽区;该槽区马上会被原槽区覆盖。
114    // 使用实际命中的 KEK,避免传入一个不相关候选使中间产物不可自检。
115    let keeper = unlock
116        .get(opened.kek_index)
117        .ok_or(Error::NoMatchingSlot)?
118        .duplicate();
119    let rnd = RandomMaterial {
120        file_uuid: old.file_uuid,
121        base_nonce: new_base_nonce,
122        slot_padding: None,
123    };
124    let mut out = crate::file::encrypt_with_fek(
125        plaintext,
126        &[keeper],
127        &old.vault_salt,
128        &opts,
129        &rnd,
130        opened.fek(),
131    )?;
132
133    let new_header = FixedHeader::parse(&out.bytes)?;
134    let new_slot_end = SLOT_AREA_OFFSET.saturating_add(SLOT_AREA_LEN);
135    let output_len = out.bytes.len();
136    let dst = out
137        .bytes
138        .get_mut(SLOT_AREA_OFFSET..new_slot_end)
139        .ok_or(Error::Truncated {
140            context: "replacement key slot area",
141            need: new_slot_end,
142            got: output_len,
143        })?;
144    dst.copy_from_slice(old_slots);
145
146    // 槽区属于 header MAC 覆盖范围,换回原字节后必须重算。MAC 本身位于
147    // header_len 末尾,covered 恰好是它之前的全部固定头、槽区与 TLV。
148    let mac_pos = (new_header.header_len as usize).saturating_sub(HEADER_MAC_LEN);
149    let covered = out.bytes.get(..mac_pos).ok_or(Error::Truncated {
150        context: "replacement header",
151        need: mac_pos,
152        got: out.bytes.len(),
153    })?;
154    let mac_key = opened.fek().derive_header_mac_key(&old.file_uuid);
155    let mac = header_mac(&mac_key, covered);
156    let total_len = out.bytes.len();
157    let mac_dst = out
158        .bytes
159        .get_mut(mac_pos..new_header.header_len as usize)
160        .ok_or(Error::Truncated {
161            context: "replacement header mac",
162            need: new_header.header_len as usize,
163            got: total_len,
164        })?;
165    mac_dst.copy_from_slice(&mac);
166    out.header = new_header;
167    Ok(out)
168}
169
170/// 换掉 FEK 并重新加密整个载荷。
171///
172/// `unlock` 用于解开现有文件(其中一个命中即可);`keep` 是轮换后应当能打开
173/// 该文件的全部 KEK。`rnd` 提供新的 `file_uuid` 与 `base_nonce`——**必须是
174/// 新生成的**,复用旧值会让 nonce 在两份不同密文间重复。
175///
176/// # 保留了什么
177///
178/// 文件名、明文后缀、缩略图、媒体元信息、`moov` 缓存、容器索引、压缩设置、
179/// 分块大小、AEAD 算法、Argon2 参数全部按原样重建,所以轮换前后**打开后看到
180/// 的东西完全一样**,只有密钥和密文变了。
181///
182/// 这里逐项搬运而不是走 `EncryptOptions::default()`:漏掉任何一项都会静默
183/// 丢数据——丢了容器索引,一个加密文件夹会变成一堆拼接的字节;丢了 Argon2
184/// 参数,头部记录的参数与派生 KEK 用的参数不一致,文件当场打不开。
185///
186/// # Errors
187///
188/// - [`Error::NoMatchingSlot`]:`unlock` 里没有能解开该文件的 KEK
189/// - [`Error::HeaderMacMismatch`]:原文件头部已被篡改
190/// - [`Error::ContentHashMismatch`]:原文件载荷已损坏
191/// - [`Error::MalformedHeader`]:`keep` 为空
192/// - [`Error::TooManySlots`]:`keep` 超过 8 个
193pub fn rotate_fek(
194    data: &[u8],
195    unlock: &[Kek],
196    keep: &[Kek],
197    rnd: &RandomMaterial,
198) -> Result<RotateOutcome> {
199    rotate_fek_with_progress(data, unlock, keep, rnd, None, None)
200}
201
202/// 与 [`rotate_fek`] 相同,但分别回报解密与加密两个阶段的进度。
203///
204/// 分成两个回调而不是合成一条总进度:轮换要把载荷**读一遍再写一遍**,
205/// 合成一条会让进度条在中点莫名减速,用户以为卡住了。
206///
207/// # Errors
208///
209/// 与 [`rotate_fek`] 一致。
210pub fn rotate_fek_with_progress(
211    data: &[u8],
212    unlock: &[Kek],
213    keep: &[Kek],
214    rnd: &RandomMaterial,
215    decrypting: Option<ProgressFn<'_>>,
216    encrypting: Option<ProgressFn<'_>>,
217) -> Result<RotateOutcome> {
218    if keep.is_empty() {
219        return Err(Error::MalformedHeader {
220            reason: "refusing to leave a file with zero key slots; it could never be opened again",
221        });
222    }
223
224    // 完整 open:验头部 MAC。跳过的话会把篡改后的字段连同新 MAC 一起签进去。
225    let opened = crate::file::open(data, unlock)?;
226    let header = FixedHeader::parse(data)?;
227
228    // decrypt_all 会在文件写有内容哈希时自动校验。这一步必须在换 FEK 之前
229    // 做:若原文件载荷已经坏了,重新加密只会把损坏的明文用新密钥固定下来,
230    // 而且旧密文被覆盖后再也无从追查。
231    let plain = opened.decrypt_all_with_progress(data, decrypting)?;
232
233    let opts = rebuild_options(&opened, &header, keep.len())?;
234
235    // 新 FEK。file_uuid 与 base_nonce 由调用方通过 rnd 传入新值——
236    // 载荷密钥是 HKDF(FEK, file_uuid),两者都换才能保证新旧密文之间
237    // 不共用任何 (key, nonce)
238    let fek = Fek::random();
239    let enc: EncryptedFile = crate::file::encrypt_with_fek_and_progress(
240        &plain,
241        keep,
242        &header.vault_salt,
243        &opts,
244        rnd,
245        &fek,
246        encrypting,
247    )?;
248
249    Ok(RotateOutcome {
250        bytes: enc.bytes,
251        opened_slot: opened.slot_index,
252        slot_used: keep.len(),
253        plaintext_size: plain.len() as u64,
254    })
255}
256
257/// 从已打开的文件里把加密选项原样重建出来。
258///
259/// # 为什么不能只挑「重要的几项」
260///
261/// 每一项漏掉都是静默的数据丢失,而且症状离原因很远:
262///
263/// - 丢 `folder_index`:加密文件夹变成一堆拼接字节,且 `CONTAINER` flag 没了,
264///   解密方不会报错,只会解出一个内容诡异的「文件」;
265/// - 丢 `argon2`:头部记录的参数与 KEK 实际派生用的参数不一致,文件当场
266///   打不开(`encrypt.rs` 里有同一个坑的记录);
267/// - 丢 `chunk_size` / `cipher`:能打开,但与原文件的可比性没了,
268///   分片、远程播放的偏移假设也随之失效。
269fn rebuild_options(
270    opened: &crate::file::OpenedFile,
271    header: &FixedHeader,
272    kept: usize,
273) -> Result<EncryptOptions> {
274    // 文件名:有 FILENAME TLV 才取。注意不能用「解不出来就算没有」——
275    // 解不出来说明文件坏了或密钥不对,那时应该报错而不是悄悄丢掉文件名
276    let filename = if header.has_flag(flags::FILENAME_ENCRYPTED) {
277        Some(opened.filename()?)
278    } else {
279        None
280    };
281
282    let thumbnail = if opened.has_thumbnail() {
283        Some(opened.thumbnail()?)
284    } else {
285        None
286    };
287    let media_meta = if opened.has_media_meta() {
288        Some(opened.media_meta()?)
289    } else {
290        None
291    };
292    let moov_cache = if opened.has_moov_cache() {
293        Some(opened.moov_cache()?)
294    } else {
295        None
296    };
297    let folder_index = if opened.is_container() {
298        // 取解密后的原始字节而不是 parse 再 encode:后者会把索引重新
299        // 序列化一遍,任何编码差异都会变成「轮换后容器打不开」
300        Some(opened.raw_folder_index()?)
301    } else {
302        None
303    };
304
305    // 槽位目录要**按 keep 重建**,不能原样搬。
306    //
307    // 轮换换掉了 FEK,旧槽全部作废,能打开新文件的只有 keep 里那些。
308    // 原样搬过去会让目录说「恢复码还在 slot 2」,而它其实已经废了——
309    // 用户据此不再另存恢复码,等真忘密码那天才发现兜底早没了。实测过。
310    //
311    // 不带目录也不行:那会让文件从可管理模式静默退回可否认模式,
312    // 用户收不到提示,只在下次想精确删协作者时发现能力没了。
313    //
314    // 顺序与 build_area 一致:前 n 个槽对应 keep[0..n]。类型一律记成
315    // Vault——这一层拿不到「哪个是恢复码」的信息,调用方(key recovery
316    // 等)若需要更准的类型,应在轮换后自己改写目录。宁可少说一点,
317    // 也不能凭猜写一个可能是错的类型
318    let slot_directory = if opened.is_slot_managed() {
319        let mut dir = crate::slotdir::SlotDirectory::new();
320        for i in 0..kept.min(crate::header::SLOT_COUNT) {
321            dir.set(
322                i,
323                crate::slotdir::SlotEntry::of(crate::slotdir::SlotKind::Vault),
324            )?;
325        }
326        Some(dir.encode())
327    } else {
328        None
329    };
330
331    Ok(EncryptOptions {
332        filename,
333        slot_directory,
334        preserve_extension: header.has_flag(flags::EXT_PRESERVED),
335        compress: header.has_flag(flags::COMPRESSED),
336        // 压缩级别无法从文件里读出来(格式不记录它,只记录压缩后的块索引)。
337        // 用默认值:级别只影响体积,不影响可解密性与还原结果
338        zstd_level: EncryptOptions::default().zstd_level,
339        chunk_size: header.chunk_size,
340        cipher: header.cipher_id,
341        argon2: header.argon2_params(),
342        // 原文件写了哈希就继续写。不能一律写:那会让「原本没有自检」的
343        // 文件在轮换后多出一个 TLV,破坏「除密钥外内容等价」
344        write_content_hash: opened.tlvs.find(types::CONTENT_HASH).is_some(),
345        thumbnail,
346        media_meta,
347        moov_cache,
348        folder_index,
349    })
350}
351
352#[cfg(test)]
353mod tests {
354    #![allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::panic)]
355
356    use super::*;
357    use crate::container::EntryMeta;
358    use crate::crypto::Argon2Params;
359    use crate::file::{RandomMaterial, encrypt};
360
361    fn kek(pw: &str, salt: &[u8; 16]) -> Kek {
362        Kek::from_password(pw.as_bytes(), salt, Argon2Params::TEST_WEAK).unwrap()
363    }
364
365    /// 造一个带尽量多元数据的文件,用来验证轮换不丢东西。
366    fn sample(salt: &[u8; 16], pw: &[&str], plain: &[u8]) -> Vec<u8> {
367        let keks: Vec<Kek> = pw.iter().map(|p| kek(p, salt)).collect();
368        let opts = EncryptOptions {
369            filename: Some(String::from("报告.txt")),
370            preserve_extension: true,
371            argon2: Argon2Params::TEST_WEAK,
372            // 各不相同的字节:若按错误偏移拼接更容易被测出来
373            thumbnail: Some((0..64u32).map(|i| (i % 251) as u8).collect()),
374            media_meta: Some(br#"{"w":1920}"#.to_vec()),
375            ..EncryptOptions::default()
376        };
377        encrypt(plain, &keks, salt, &opts, &RandomMaterial::generate())
378            .unwrap()
379            .bytes
380    }
381
382    #[test]
383    fn rotated_file_opens_with_kept_password_and_returns_same_plaintext() {
384        // 不这样会怎样:轮换若把明文写错(例如漏了解压、或用错 payload key),
385        // 文件仍然「能打开」,但内容是垃圾——只断言能打开抓不到
386        let salt = [0x31u8; 16];
387        let plain = b"rotate me".repeat(500);
388        let data = sample(&salt, &["pw-old"], &plain);
389
390        let out = rotate_fek(
391            &data,
392            &[kek("pw-old", &salt)],
393            &[kek("pw-new", &salt)],
394            &RandomMaterial::generate(),
395        )
396        .unwrap();
397
398        let re = crate::file::open(&out.bytes, &[kek("pw-new", &salt)]).unwrap();
399        assert_eq!(
400            re.decrypt_all(&out.bytes).unwrap(),
401            plain,
402            "轮换后必须还原出原明文"
403        );
404        assert_eq!(out.plaintext_size, plain.len() as u64);
405    }
406
407    #[test]
408    fn rotation_replaces_fek_and_uuid_so_old_ciphertext_is_gone() {
409        let salt = [0x32u8; 16];
410        let data = sample(&salt, &["pw-old"], b"payload bytes here");
411
412        let before = FixedHeader::parse(&data).unwrap();
413        let out = rotate_fek(
414            &data,
415            &[kek("pw-old", &salt)],
416            &[kek("pw-old", &salt)],
417            &RandomMaterial::generate(),
418        )
419        .unwrap();
420        let after = FixedHeader::parse(&out.bytes).unwrap();
421
422        assert_ne!(before.file_uuid, after.file_uuid, "必须换 file_uuid");
423        assert_ne!(before.base_nonce, after.base_nonce, "必须换 base_nonce");
424        assert_ne!(
425            data.get(before.header_len as usize..),
426            out.bytes.get(before.header_len as usize..),
427            "载荷密文必须全部改变,否则等于没轮换"
428        );
429    }
430
431    #[test]
432    fn old_fek_cannot_derive_new_payload_key() {
433        // 这条才是轮换与 keyslot 改写的**实质区别**,也是整个模块的意义。
434        //
435        // 不这样会怎样:上面那条测试的三个断言全部由调用方传入的 rnd 决定
436        // ——即使实现复用旧 FEK,file_uuid / base_nonce / 密文照样都会变
437        // (载荷密钥是 HKDF(FEK, file_uuid),uuid 换了密文自然全变)。变异
438        // 测试确认「复用旧 FEK」这个缺陷能从那条测试下存活。
439        //
440        // 而复用旧 FEK 是真漏洞:攻击者用旧副本 + 旧密码解出 FEK_old,
441        // 新文件头里的 file_uuid 是明文,于是 HKDF(FEK_old, uuid_new) 就是
442        // 新文件的载荷密钥——他不需要新密码就能解开新文件。
443        //
444        // 断言写成「攻击不成立」而不是「FEK 字节变了」:比字节只能证明
445        // 变了,写成攻击才能证明旧的用不了。
446        let salt = [0x3Eu8; 16];
447        let data = sample(&salt, &["pw-old"], b"payload bytes here");
448
449        let old_opened = crate::file::open(&data, &[kek("pw-old", &salt)]).unwrap();
450        let out = rotate_fek(
451            &data,
452            &[kek("pw-old", &salt)],
453            &[kek("pw-new", &salt)],
454            &RandomMaterial::generate(),
455        )
456        .unwrap();
457        let new_opened = crate::file::open(&out.bytes, &[kek("pw-new", &salt)]).unwrap();
458
459        let forged = old_opened
460            .fek()
461            .derive_payload_key(&new_opened.header.file_uuid);
462        assert_ne!(
463            forged.as_bytes(),
464            new_opened.payload_key().as_bytes(),
465            "旧 FEK 不得能推出新文件的载荷密钥,否则持有旧副本的人不需要新密码就能解开新文件"
466        );
467    }
468
469    #[test]
470    fn old_password_cannot_open_rotated_file() {
471        let salt = [0x33u8; 16];
472        let data = sample(&salt, &["pw-old"], b"secret");
473        let out = rotate_fek(
474            &data,
475            &[kek("pw-old", &salt)],
476            &[kek("pw-new", &salt)],
477            &RandomMaterial::generate(),
478        )
479        .unwrap();
480        assert!(crate::file::open(&out.bytes, &[kek("pw-old", &salt)]).is_err());
481    }
482
483    #[test]
484    fn rotation_preserves_filename_thumbnail_and_media_meta() {
485        // 不这样会怎样:rebuild_options 漏一项就静默丢数据。文件照样能打开、
486        // 明文照样正确,只是缩略图不见了——最容易漏、也最难发现
487        let salt = [0x34u8; 16];
488        let data = sample(&salt, &["pw"], b"x");
489        let out = rotate_fek(
490            &data,
491            &[kek("pw", &salt)],
492            &[kek("pw", &salt)],
493            &RandomMaterial::generate(),
494        )
495        .unwrap();
496
497        let re = crate::file::open(&out.bytes, &[kek("pw", &salt)]).unwrap();
498        assert_eq!(re.filename().unwrap(), "报告.txt");
499        assert_eq!(re.plain_extension().as_deref(), Some("txt"));
500        assert_eq!(
501            re.thumbnail().unwrap(),
502            (0..64u32).map(|i| (i % 251) as u8).collect::<Vec<u8>>()
503        );
504        assert_eq!(re.media_meta().unwrap(), br#"{"w":1920}"#.to_vec());
505        assert!(
506            re.header.has_flag(flags::HAS_THUMBNAIL),
507            "flag 也要跟着保留"
508        );
509    }
510
511    #[test]
512    fn rotation_preserves_format_parameters() {
513        let salt = [0x35u8; 16];
514        let data = sample(&salt, &["pw"], b"y");
515        let before = FixedHeader::parse(&data).unwrap();
516        let out = rotate_fek(
517            &data,
518            &[kek("pw", &salt)],
519            &[kek("pw", &salt)],
520            &RandomMaterial::generate(),
521        )
522        .unwrap();
523        let after = FixedHeader::parse(&out.bytes).unwrap();
524
525        assert_eq!(before.chunk_size, after.chunk_size);
526        assert_eq!(before.cipher_id, after.cipher_id);
527        assert_eq!(
528            before.vault_salt, after.vault_salt,
529            "同一个 vault,salt 不能变"
530        );
531        // Argon2 参数错了文件当场打不开,且症状极具迷惑性(见 encrypt.rs 的记录)
532        assert_eq!(before.argon2_m_kib, after.argon2_m_kib);
533        assert_eq!(before.argon2_t, after.argon2_t);
534        assert_eq!(before.argon2_p, after.argon2_p);
535        assert_eq!(before.flags, after.flags, "flags 整体必须一致");
536    }
537
538    #[test]
539    fn rotation_can_change_password_set_at_the_same_time() {
540        // 轮换和改密码是一次操作:先轮换再改密码等于把载荷写两遍
541        let salt = [0x36u8; 16];
542        let data = sample(&salt, &["pw-a"], b"z");
543        let out = rotate_fek(
544            &data,
545            &[kek("pw-a", &salt)],
546            &[kek("pw-b", &salt), kek("pw-c", &salt)],
547            &RandomMaterial::generate(),
548        )
549        .unwrap();
550
551        assert_eq!(out.slot_used, 2);
552        for p in ["pw-b", "pw-c"] {
553            assert!(
554                crate::file::open(&out.bytes, &[kek(p, &salt)]).is_ok(),
555                "{p} 应能打开"
556            );
557        }
558        assert!(crate::file::open(&out.bytes, &[kek("pw-a", &salt)]).is_err());
559    }
560
561    #[test]
562    fn empty_keep_is_rejected() {
563        let salt = [0x37u8; 16];
564        let data = sample(&salt, &["pw"], b"w");
565        assert!(rotate_fek(&data, &[kek("pw", &salt)], &[], &RandomMaterial::generate()).is_err());
566    }
567
568    #[test]
569    fn wrong_unlock_password_fails() {
570        let salt = [0x38u8; 16];
571        let data = sample(&salt, &["pw"], b"v");
572        assert!(
573            rotate_fek(
574                &data,
575                &[kek("nope", &salt)],
576                &[kek("pw", &salt)],
577                &RandomMaterial::generate()
578            )
579            .is_err()
580        );
581    }
582
583    #[test]
584    fn tampered_header_is_rejected_before_rewriting() {
585        // 不这样会怎样:跳过 MAC 校验就会给篡改后的头部算一个新的有效 MAC,
586        // 等于替攻击者洗白
587        let salt = [0x39u8; 16];
588        let mut data = sample(&salt, &["pw"], b"u");
589        data[64] ^= 0xFF; // plaintext_size 所在偏移
590        assert!(
591            rotate_fek(
592                &data,
593                &[kek("pw", &salt)],
594                &[kek("pw", &salt)],
595                &RandomMaterial::generate()
596            )
597            .is_err()
598        );
599    }
600
601    #[test]
602    fn rotation_preserves_compression() {
603        let salt = [0x3Au8; 16];
604        let keks = [kek("pw", &salt)];
605        // 高度可压缩的数据,确保压缩确实生效
606        let plain = b"aaaaaaaaaaaaaaaa".repeat(4096);
607        let opts = EncryptOptions {
608            compress: true,
609            argon2: Argon2Params::TEST_WEAK,
610            ..EncryptOptions::default()
611        };
612        let data = encrypt(&plain, &keks, &salt, &opts, &RandomMaterial::generate())
613            .unwrap()
614            .bytes;
615        assert!(
616            FixedHeader::parse(&data)
617                .unwrap()
618                .has_flag(flags::COMPRESSED)
619        );
620
621        let out = rotate_fek(&data, &keks, &keks, &RandomMaterial::generate()).unwrap();
622        let after = FixedHeader::parse(&out.bytes).unwrap();
623        assert!(after.has_flag(flags::COMPRESSED), "压缩设置必须保留");
624        // 压缩块索引也必须重建,否则解密时无法定位块边界
625        let re = crate::file::open(&out.bytes, &keks).unwrap();
626        assert_eq!(re.decrypt_all(&out.bytes).unwrap(), plain);
627    }
628
629    #[test]
630    fn rotation_preserves_container_flag_and_index() {
631        // 丢了容器索引不会报错,只会把加密文件夹解成一堆拼接字节——
632        // 这是最隐蔽的一种丢失
633        let salt = [0x3Bu8; 16];
634        let keks = [kek("pw", &salt)];
635        let idx_bytes = {
636            let mut b = crate::container::ContainerBuilder::new("folder");
637            b.add_file(vec![String::from("a.txt")], 3, None, EntryMeta::default())
638                .unwrap();
639            b.finish().unwrap().encode()
640        };
641        let opts = EncryptOptions {
642            folder_index: Some(idx_bytes.clone()),
643            argon2: Argon2Params::TEST_WEAK,
644            ..EncryptOptions::default()
645        };
646        let data = encrypt(b"abc", &keks, &salt, &opts, &RandomMaterial::generate())
647            .unwrap()
648            .bytes;
649
650        let out = rotate_fek(&data, &keks, &keks, &RandomMaterial::generate()).unwrap();
651        let re = crate::file::open(&out.bytes, &keks).unwrap();
652        assert!(re.is_container(), "CONTAINER flag 必须保留");
653        assert_eq!(
654            re.raw_folder_index().unwrap(),
655            idx_bytes,
656            "索引字节必须逐字节一致"
657        );
658    }
659
660    #[test]
661    fn rotation_keeps_content_hash_absent_when_original_had_none() {
662        // 不能一律写哈希:那会让原本没有自检的文件多出一个 TLV,
663        // 破坏「除密钥外内容等价」
664        let salt = [0x3Cu8; 16];
665        let keks = [kek("pw", &salt)];
666        let opts = EncryptOptions {
667            write_content_hash: false,
668            argon2: Argon2Params::TEST_WEAK,
669            ..EncryptOptions::default()
670        };
671        let data = encrypt(b"t", &keks, &salt, &opts, &RandomMaterial::generate())
672            .unwrap()
673            .bytes;
674
675        let out = rotate_fek(&data, &keks, &keks, &RandomMaterial::generate()).unwrap();
676        let re = crate::file::open(&out.bytes, &keks).unwrap();
677        assert!(
678            re.stored_content_hash().is_err(),
679            "原文件没有哈希,轮换后也不该有"
680        );
681    }
682
683    #[test]
684    fn replacement_preserves_all_slots_and_uses_new_plaintext() {
685        // 不这样会怎样:外部编辑一次就可能让另一个密码或恢复码失效,用户直到
686        // 真需要兜底时才发现;或者复用旧 nonce,让两版不同明文使用同一 AEAD nonce。
687        let salt = core::array::from_fn(|i| (i as u8).wrapping_mul(11) ^ 0x4D);
688        let keys = [kek("pw-a", &salt), kek("pw-b", &salt)];
689        let opts = EncryptOptions {
690            filename: Some(String::from("报告.txt")),
691            argon2: Argon2Params::TEST_WEAK,
692            ..EncryptOptions::default()
693        };
694        let original = encrypt(b"before", &keys, &salt, &opts, &RandomMaterial::generate())
695            .unwrap()
696            .bytes;
697        let before = FixedHeader::parse(&original).unwrap();
698        let slots_before = original
699            .get(SLOT_AREA_OFFSET..SLOT_AREA_OFFSET + SLOT_AREA_LEN)
700            .unwrap()
701            .to_vec();
702        let nonce = core::array::from_fn(|i| (i as u8).wrapping_mul(17) ^ 0xA3);
703
704        let replaced = replace_plaintext_preserving_slots(
705            &original,
706            &keys,
707            b"after external edit",
708            &ReplacementMetadata::default(),
709            nonce,
710        )
711        .unwrap();
712        let after = FixedHeader::parse(&replaced.bytes).unwrap();
713
714        assert_eq!(
715            before.file_uuid, after.file_uuid,
716            "槽位派生依赖 file_uuid,不能变"
717        );
718        assert_eq!(after.base_nonce, nonce, "必须采用新 nonce");
719        assert_ne!(before.base_nonce, after.base_nonce, "不得复用旧 nonce");
720        assert_eq!(
721            replaced
722                .bytes
723                .get(SLOT_AREA_OFFSET..SLOT_AREA_OFFSET + SLOT_AREA_LEN)
724                .unwrap(),
725            slots_before,
726            "所有已知和未知槽都必须逐字节保留",
727        );
728        for key in &keys {
729            let opened = crate::file::open(&replaced.bytes, &[key.duplicate()]).unwrap();
730            assert_eq!(
731                opened.decrypt_all(&replaced.bytes).unwrap(),
732                b"after external edit",
733                "每把旧钥匙都要能打开修改后的新内容",
734            );
735            assert_eq!(opened.filename().unwrap(), "报告.txt");
736        }
737    }
738
739    #[test]
740    fn replacement_rejects_container() {
741        let salt = core::array::from_fn(|i| (i as u8).wrapping_mul(5) ^ 0x71);
742        let keys = [kek("pw", &salt)];
743        let mut b = crate::container::ContainerBuilder::new("folder");
744        b.add_file(vec![String::from("a.txt")], 3, None, EntryMeta::default())
745            .unwrap();
746        let opts = EncryptOptions {
747            folder_index: Some(b.finish().unwrap().encode()),
748            argon2: Argon2Params::TEST_WEAK,
749            ..EncryptOptions::default()
750        };
751        let original = encrypt(b"abc", &keys, &salt, &opts, &RandomMaterial::generate())
752            .unwrap()
753            .bytes;
754        assert!(
755            replace_plaintext_preserving_slots(
756                &original,
757                &keys,
758                b"flat file",
759                &ReplacementMetadata::default(),
760                [9; 7],
761            )
762            .is_err(),
763            "容器不能被第三方单文件编辑静默拍平",
764        );
765    }
766
767    #[test]
768    fn corrupted_payload_is_rejected_instead_of_being_re_signed() {
769        // 载荷坏了还去重新加密,等于用新密钥把损坏内容固定下来,
770        // 而且覆盖原文件后再也无从追查
771        let salt = [0x3Du8; 16];
772        let keks = [kek("pw", &salt)];
773        let mut data = sample(&salt, &["pw"], &b"payload".repeat(100));
774        let last = data.len().saturating_sub(1);
775        data[last] ^= 0xFF;
776        assert!(rotate_fek(&data, &keks, &keks, &RandomMaterial::generate()).is_err());
777    }
778}