1use 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#[derive(Debug)]
40pub struct RotateOutcome {
41 pub bytes: Vec<u8>,
43 pub opened_slot: u16,
45 pub slot_used: usize,
47 pub plaintext_size: u64,
49}
50
51#[derive(Debug, Clone, Default)]
56pub struct ReplacementMetadata {
57 pub thumbnail: Option<Vec<u8>>,
59 pub media_meta: Option<Vec<u8>>,
61 pub moov_cache: Option<Vec<u8>>,
63}
64
65pub 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 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 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 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
170pub 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
202pub 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 let opened = crate::file::open(data, unlock)?;
226 let header = FixedHeader::parse(data)?;
227
228 let plain = opened.decrypt_all_with_progress(data, decrypting)?;
232
233 let opts = rebuild_options(&opened, &header, keep.len())?;
234
235 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
257fn rebuild_options(
270 opened: &crate::file::OpenedFile,
271 header: &FixedHeader,
272 kept: usize,
273) -> Result<EncryptOptions> {
274 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 Some(opened.raw_folder_index()?)
301 } else {
302 None
303 };
304
305 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 zstd_level: EncryptOptions::default().zstd_level,
339 chunk_size: header.chunk_size,
340 cipher: header.cipher_id,
341 argon2: header.argon2_params(),
342 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 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 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 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 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 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 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 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 let salt = [0x39u8; 16];
588 let mut data = sample(&salt, &["pw"], b"u");
589 data[64] ^= 0xFF; 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 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 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 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 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 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 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}