omy-core 0.0.1

omy 加密文件格式的核心实现:格式读写、密钥体系、分块 AEAD。零 FFmpeg 依赖。
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
//! 顶层封装:完整的加密文件读写。
//!
//! 把 header、slot、TLV、载荷各层串成实际可用的 API。
//!
//! # 读取顺序不可调换
//!
//! 严格遵循规范 §9:
//!
//! 1. 校验 magic 与版本 → 2. 解析固定头 → 3. 解包 FEK →
//! 4. **验证 header MAC** → 5. 检查 CRITICAL TLV → 6. 读载荷
//!
//! 第 4 步必须在第 5、6 步**之前**。原因:TLV 与载荷的解读方式(是否压缩、
//! chunk_size、plaintext_size)全部来自头部字段,若不先验证 MAC,攻击者可以
//! 篡改这些字段诱导错误解读。MAC 验证失败必须立即终止。

use crate::crypto::{Argon2Params, CipherId, Fek, Kek, SecretKey, content_hash, header_mac, verify_header_mac};
use crate::error::{Error, Result};
use crate::payload;
use crate::header::{
    COMPRESS_NONE, COMPRESS_ZSTD, DEFAULT_CHUNK_SIZE, FIXED_HEADER_LEN, FixedHeader,
    HEADER_MAC_LEN, KDF_ARGON2ID, SLOT_AREA_LEN, SLOT_AREA_OFFSET, SLOT_COUNT, SLOT_COUNT_U8,
    TLV_AREA_OFFSET, VERSION_MAJOR, VERSION_MINOR, flags, validate_chunk_size,
};
use crate::slot::{build_slot_area, unwrap_fek};
use crate::tlv::{
    CompressionIndexEntry, TlvEntry, TlvSet, decode_compression_index, encode_compression_index,
    encrypt_entry, pad_filename, tlv_flags, types, unpad_filename,
};
use crate::util::{Writer, random_16};

/// 加密选项。
#[derive(Debug, Clone)]
pub struct EncryptOptions {
    /// 原始文件名。为 `None` 时不写入文件名 TLV。
    pub filename: Option<String>,
    /// 是否以明文保留后缀。便于在不解密的情况下按类型筛选,但会泄露文件类型。
    pub preserve_extension: bool,
    /// 是否启用 zstd 压缩。
    pub compress: bool,
    /// zstd 压缩级别(1–22)。
    pub zstd_level: i32,
    /// 明文分块大小。
    pub chunk_size: u32,
    /// AEAD 算法。
    pub cipher: CipherId,
    /// Argon2 参数。
    pub argon2: Argon2Params,
    /// 是否写入内容哈希以支持解密后自检。
    pub write_content_hash: bool,
    /// 加密缩略图数据(WebP/JPEG),建议不超过 32 KB。
    pub thumbnail: Option<Vec<u8>>,
    /// 媒体元信息 JSON。
    pub media_meta: Option<Vec<u8>>,
    /// MP4 的 `moov` box 副本,用于加速起播(规范 §4.4 的 `0x0006`)。
    ///
    /// 播放时先从这里取元数据,省掉「seek 到尾部读 moov」的两次额外往返。
    /// 收益最大的两个场景:
    ///
    /// - **局域网远程播放**:省掉真实网络 RTT
    /// - **缺片播放**:每个分片的冗余 header 都带一份,只要任意一片存在就能起播
    ///   (文档 05 §7.4)
    ///
    /// 与 `TRANSCODED` 不同,这个副本**不改动载荷**,原文件仍可 bit-for-bit 还原。
    pub moov_cache: Option<Vec<u8>>,
    /// 目录容器索引(`ContainerIndex::encode` 的输出)。
    ///
    /// 置位时会写入 CRITICAL+ENCRYPTED 的 `TLV_FOLDER_INDEX` 并设置
    /// `CONTAINER` flag。CRITICAL 是必需的:不认识该 TLV 的实现必须拒绝打开,
    /// 否则会把容器误当作普通文件解出一堆拼接的字节。
    pub folder_index: Option<Vec<u8>>,
    /// 槽位目录(可管理模式,`SlotDirectory::encode` 的输出,固定 24 字节)。
    ///
    /// 置位时写入 CRITICAL+ENCRYPTED 的 `TLV_SLOT_DIRECTORY` 并设置
    /// `SLOT_DIRECTORY` flag,即规范 §3.5 的「可管理模式」。
    ///
    /// 为 `None` 时是默认的可否认模式:槽位不可枚举,连文件的主人也分不清
    /// 哪个槽是谁的。两种模式在加密时二选一,同一个 vault 内不应混用——
    /// 同目录下 5 个可否认文件混 1 个可管理文件,那 1 个的存在本身就是信号。
    pub slot_directory: Option<Vec<u8>>,
}

impl Default for EncryptOptions {
    fn default() -> Self {
        Self {
            filename: None,
            preserve_extension: false,
            compress: false,
            zstd_level: 3,
            chunk_size: DEFAULT_CHUNK_SIZE,
            cipher: CipherId::ChaCha20Poly1305,
            argon2: Argon2Params::default(),
            write_content_hash: true,
            thumbnail: None,
            media_meta: None,
            moov_cache: None,
            folder_index: None,
            slot_directory: None,
        }
    }
}

/// 加密所需的随机材料。
///
/// 单独抽出来是为了让加密过程**可确定性复现**——测试向量验证要求「相同输入加相同
/// 随机源产出逐字节相同输出」,若随机数在函数内部生成就无法做到。
#[derive(Debug, Clone)]
pub struct RandomMaterial {
    /// 文件唯一标识。
    pub file_uuid: [u8; 16],
    /// 载荷 nonce 前缀。
    pub base_nonce: [u8; 7],
    /// 未使用 slot 的填充字节。为 `None` 时现场生成随机值。
    pub slot_padding: Option<Vec<u8>>,
}

impl RandomMaterial {
    /// 从系统 CSPRNG 生成。
    #[must_use]
    pub fn generate() -> Self {
        let mut bn = [0u8; 7];
        crate::util::fill_random(&mut bn);
        Self { file_uuid: random_16(), base_nonce: bn, slot_padding: None }
    }
}

/// 已加密文件的完整字节与其头部信息。
#[derive(Debug)]
pub struct EncryptedFile {
    /// 完整文件字节。
    pub bytes: Vec<u8>,
    /// 解析好的固定头。
    pub header: FixedHeader,
}

/// 加密数据,产出完整的 omy 文件字节。
///
/// `vault_salt` 应在同一 vault 内保持一致,这样多个文件可复用同一次 Argon2 计算。
///
/// # Errors
///
/// - [`Error::TooManySlots`]:KEK 超过 8 个
/// - [`Error::InvalidChunkSize`]:分块大小超出允许范围
/// - [`Error::Compression`]:压缩失败
pub fn encrypt(
    plaintext: &[u8],
    keks: &[Kek],
    vault_salt: &[u8; 16],
    opts: &EncryptOptions,
    rnd: &RandomMaterial,
) -> Result<EncryptedFile> {
    encrypt_with_progress(plaintext, keks, vault_salt, opts, rnd, None)
}

/// 与 [`encrypt`] 相同,但分块过程中回报进度。
///
/// 大文件加密是纯 CPU 循环,没有进度反馈时用户无法区分「在算」和
/// 「卡死了」。回调语义见 [`crate::payload::ProgressFn`]。
///
/// # Errors
///
/// 与 [`encrypt`] 一致。
pub fn encrypt_with_progress(
    plaintext: &[u8],
    keks: &[Kek],
    vault_salt: &[u8; 16],
    opts: &EncryptOptions,
    rnd: &RandomMaterial,
    progress: Option<payload::ProgressFn<'_>>,
) -> Result<EncryptedFile> {
    if keks.is_empty() {
        return Err(Error::TooManySlots { got: 0, max: SLOT_COUNT });
    }
    validate_chunk_size(opts.chunk_size)?;

    let fek = Fek::random();
    encrypt_with_fek_and_progress(plaintext, keks, vault_salt, opts, rnd, &fek, progress)
}

/// 用指定 FEK 加密。
///
/// 这是**底层 API**:不做 `chunk_size` 的推荐范围校验,只要求非零。
/// 因此可用于复现测试向量(v3/v4 使用 4096 / 2048 字节的小块)以及处理
/// 其它实现产出的合法文件。面向用户的入口请用 [`encrypt`],它会做策略校验。
///
/// # Errors
///
/// - [`Error::MalformedHeader`]:`chunk_size` 为 0,或字段溢出
/// - [`Error::Compression`]:压缩失败
pub fn encrypt_with_fek(
    plaintext: &[u8],
    keks: &[Kek],
    vault_salt: &[u8; 16],
    opts: &EncryptOptions,
    rnd: &RandomMaterial,
    fek: &Fek,
) -> Result<EncryptedFile> {
    encrypt_with_fek_and_progress(plaintext, keks, vault_salt, opts, rnd, fek, None)
}

/// 与 [`encrypt_with_fek`] 相同,但分块过程中回报进度。
///
/// # Errors
///
/// 与 [`encrypt_with_fek`] 一致。
pub fn encrypt_with_fek_and_progress(
    plaintext: &[u8],
    keks: &[Kek],
    vault_salt: &[u8; 16],
    opts: &EncryptOptions,
    rnd: &RandomMaterial,
    fek: &Fek,
    progress: Option<payload::ProgressFn<'_>>,
) -> Result<EncryptedFile> {
    if opts.chunk_size == 0 {
        return Err(Error::MalformedHeader { reason: "chunk_size must not be zero" });
    }

    let mut header = new_header(opts, rnd, vault_salt, plaintext.len() as u64);
    let payload_key = fek.derive_payload_key(&header.file_uuid);
    let (payload, index) = payload::encrypt_payload_with_progress(
        &header,
        &payload_key,
        plaintext,
        opts.zstd_level,
        progress,
    )?;

    let hash = opts.write_content_hash.then(|| content_hash(plaintext));
    let tlvs = build_tlvs(opts, fek, hash.as_ref(), &index, opts.folder_index.as_deref())?;
    let head = seal_header(&mut header, &tlvs, keks, fek, rnd, opts.cipher)?;

    let mut out = Writer::with_capacity(head.len().saturating_add(payload.len()));
    out.bytes(&head).bytes(&payload);

    let bytes = out.into_vec();
    debug_assert_eq!(
        bytes.len(),
        (header.header_len as usize).saturating_add(payload.len()),
        "输出长度与 header_len + payload 不符"
    );

    Ok(EncryptedFile { bytes, header })
}

/// 按选项算出文件 flags。内存版与流式版共用,两边的 flag 不可能不一致。
fn file_flags(opts: &EncryptOptions) -> u32 {
    let mut file_flags = 0u32;
    if opts.filename.is_some() {
        file_flags |= flags::FILENAME_ENCRYPTED;
    }
    if opts.preserve_extension {
        file_flags |= flags::EXT_PRESERVED;
    }
    if opts.compress {
        file_flags |= flags::COMPRESSED;
    }
    if opts.thumbnail.is_some() {
        file_flags |= flags::HAS_THUMBNAIL;
    }
    if opts.slot_directory.is_some() {
        file_flags |= flags::SLOT_DIRECTORY;
    }
    if opts.folder_index.is_some() {
        file_flags |= flags::CONTAINER;
    }
    file_flags
}

/// 构造固定头(`header_len` / `tlv_len` 由 [`seal_header`] 回填)。
fn new_header(
    opts: &EncryptOptions,
    rnd: &RandomMaterial,
    vault_salt: &[u8; 16],
    plaintext_size: u64,
) -> FixedHeader {
    FixedHeader {
        version_major: VERSION_MAJOR,
        version_minor: VERSION_MINOR,
        header_len: 0,
        file_uuid: rnd.file_uuid,
        vault_salt: *vault_salt,
        flags: file_flags(opts),
        cipher_id: opts.cipher,
        kdf_id: KDF_ARGON2ID,
        compress_id: if opts.compress { COMPRESS_ZSTD } else { COMPRESS_NONE },
        slot_count: SLOT_COUNT_U8,
        argon2_m_kib: opts.argon2.m_kib,
        argon2_t: opts.argon2.t,
        argon2_p: opts.argon2.p,
        chunk_size: opts.chunk_size,
        plaintext_size,
        base_nonce: rnd.base_nonce,
        chunk_version: 0,
        tlv_len: 0,
    }
}

/// 按固定顺序构造 TLV 集合。
///
/// 内容哈希与压缩索引要等载荷处理完才知道,由调用方传入;`folder_index`
/// 单独传入而不读 `opts`,是为了让流式打包能先用同长度的占位、最后换成真值。
/// 顺序必须与规范及测试向量一致,所以只此一处。
fn build_tlvs(
    opts: &EncryptOptions,
    fek: &Fek,
    hash: Option<&[u8; 32]>,
    index: &[CompressionIndexEntry],
    folder_index: Option<&[u8]>,
) -> Result<TlvSet> {
    let mut tlvs = TlvSet::new();

    if let Some(name) = &opts.filename {
        let padded = pad_filename(name)?;
        tlvs.push(encrypt_entry(
            types::FILENAME,
            tlv_flags::CRITICAL,
            &padded,
            fek,
            opts.cipher,
        )?);
    }
    if opts.preserve_extension {
        let ext = opts
            .filename
            .as_deref()
            .and_then(|n| n.rsplit_once('.').map(|(_, e)| e))
            .unwrap_or("");
        tlvs.push(TlvEntry::new(types::PLAIN_EXT, 0, ext.as_bytes().to_vec()));
    }
    if let Some(t) = &opts.thumbnail {
        tlvs.push(encrypt_entry(types::THUMBNAIL, 0, t, fek, opts.cipher)?);
    }
    if let Some(m) = &opts.media_meta {
        tlvs.push(encrypt_entry(types::MEDIA_META, 0, m, fek, opts.cipher)?);
    }
    if let Some(mv) = &opts.moov_cache {
        // 非 CRITICAL:这纯粹是起播优化,不认识它的实现照样能正确解密播放,
        // 只是要多做两次 seek。标 CRITICAL 会让旧版实现无谓地拒绝打开。
        tlvs.push(encrypt_entry(types::MOOV_CACHE, 0, mv, fek, opts.cipher)?);
    }
    if let Some(idx) = folder_index {
        // CRITICAL:容器的载荷是多个文件的拼接,不认识索引就不该打开
        tlvs.push(encrypt_entry(
            types::FOLDER_INDEX,
            tlv_flags::CRITICAL,
            idx,
            fek,
            opts.cipher,
        )?);
    }
    if let Some(dir) = &opts.slot_directory {
        // CRITICAL:不认识此 TLV 的实现必须拒绝打开。否则老版本会把可管理
        // 模式的文件当普通文件改密码,槽位目录与实际 slot 区就此对不上——
        // 而这种损坏不会立刻显现,等到用户依赖目录去删某个协作者时才暴露,
        // 那时已无法判断哪份记录是对的。
        //
        // ENCRYPTED 由 encrypt_entry 保证:明文目录等于把「谁能打开这个
        // 文件」白送给任何拿到文件的人。
        tlvs.push(encrypt_entry(
            types::SLOT_DIRECTORY,
            tlv_flags::CRITICAL,
            dir,
            fek,
            opts.cipher,
        )?);
    }
    if let Some(h) = hash {
        tlvs.push(encrypt_entry(types::CONTENT_HASH, 0, h, fek, opts.cipher)?);
    }
    // 压缩模式必须写 CRITICAL 索引 TLV,否则无法定位块。
    // 索引里的 ct_offset 相对载荷起点,不受 header_len 影响,不会循环依赖
    if opts.compress {
        let enc = encode_compression_index(index);
        tlvs.push(encrypt_entry(
            types::COMPRESSION_INDEX,
            tlv_flags::CRITICAL,
            &enc,
            fek,
            opts.cipher,
        )?);
    }
    Ok(tlvs)
}

/// 回填 `header_len` / `tlv_len`,构造 slot 区与头部 MAC,返回完整头部字节。
fn seal_header(
    header: &mut FixedHeader,
    tlvs: &TlvSet,
    keks: &[Kek],
    fek: &Fek,
    rnd: &RandomMaterial,
    cipher: CipherId,
) -> Result<Vec<u8>> {
    let tlv_blob = tlvs.to_bytes();
    header.tlv_len = u32::try_from(tlv_blob.len())
        .map_err(|_| Error::MalformedHeader { reason: "tlv area exceeds u32" })?;
    header.header_len = u32::try_from(
        TLV_AREA_OFFSET
            .saturating_add(tlv_blob.len())
            .saturating_add(HEADER_MAC_LEN),
    )
    .map_err(|_| Error::MalformedHeader { reason: "header_len exceeds u32" })?;

    let fixed = header.to_bytes()?;
    let slot_area = if let Some(pad) = &rnd.slot_padding {
        build_slot_area_with_padding(keks, fek, &header.file_uuid, cipher, pad)?
    } else {
        build_slot_area(keks, fek, &header.file_uuid, cipher)?
    };

    // MAC 覆盖 fixed_header || slot_area || tlv_blob
    let mut covered = Writer::with_capacity(header.header_len as usize);
    covered.bytes(&fixed).bytes(&slot_area).bytes(&tlv_blob);
    let mac_key = fek.derive_header_mac_key(&header.file_uuid);
    let mac = header_mac(&mac_key, covered.as_slice());
    let mut out = covered.into_vec();
    out.extend_from_slice(&mac);
    Ok(out)
}

/// 流式加密的「最后定稿」回调:载荷写完后返回真正的 FOLDER_INDEX 内容。
///
/// 容器的索引里有逐文件哈希,要读完文件才算得出;而它与内容无关的长度
/// 在开头就已确定。流式加密先按 `opts.folder_index`(同长度占位)留出头部
/// 空间,写完载荷再调用它换成真值。两者长度不同会报错——那会让 header_len
/// 与已写出的载荷错位。
pub type FinalizeFolderIndex<'a> = &'a mut dyn FnMut() -> Result<Vec<u8>>;

/// 流式加密的结果。
#[derive(Debug, Clone)]
pub struct StreamedFile {
    /// 解析好的固定头。
    pub header: FixedHeader,
    /// 完整头部字节(含 MAC)。调用方据此做写盘后的自检(例如按密码重新打开),
    /// 不必再从磁盘读回。
    pub head: Vec<u8>,
    /// 写出的总字节数(头部 + 载荷)。
    pub len: u64,
}

/// 流式加密:从 `input` 读恰好 `plaintext_len` 字节明文,把完整的 omy 文件写进 `out`。
///
/// 与 [`encrypt_with_progress`] 产出**逐字节相同**的文件(给定相同 FEK 与随机材料
/// 时),但内存占用与文件大小无关:原来要把整个明文读进内存、再在内存里拼出
/// 整个密文,几 GB 的视频峰值是文件大小的两倍以上。
///
/// 做法:头部的长度只取决于各 TLV 的长度(与内容无关),所以先写同长度的
/// 零占位,逐块加密写载荷,同时累计内容哈希与压缩索引;最后 seek 回开头写入
/// 真正的头部。头部 MAC 覆盖的都是这时才确定的字段,占位不会进入 MAC。
///
/// `input` 实际长度与 `plaintext_len` 不符(文件在加密过程中被改动)时报错,
/// 而不是写出一个与头部声明不一致的文件。
///
/// # Errors
///
/// 与 [`encrypt_with_progress`] 一致;另外读写失败返回 [`Error::Io`],
/// 定稿的 FOLDER_INDEX 长度与占位不同返回 [`Error::MalformedHeader`]。
#[allow(clippy::too_many_arguments)]
pub fn encrypt_stream<R: std::io::Read, W: std::io::Write + std::io::Seek>(
    input: &mut R,
    plaintext_len: u64,
    keks: &[Kek],
    vault_salt: &[u8; 16],
    opts: &EncryptOptions,
    rnd: &RandomMaterial,
    out: &mut W,
    progress: Option<payload::ProgressFn<'_>>,
    finalize_folder_index: Option<FinalizeFolderIndex<'_>>,
) -> Result<StreamedFile> {
    if keks.is_empty() {
        return Err(Error::TooManySlots { got: 0, max: SLOT_COUNT });
    }
    validate_chunk_size(opts.chunk_size)?;
    let fek = Fek::random();
    encrypt_stream_with_fek(
        input,
        plaintext_len,
        keks,
        vault_salt,
        opts,
        rnd,
        &fek,
        out,
        progress,
        finalize_folder_index,
    )
}

/// 与 [`encrypt_stream`] 相同,但用指定 FEK(测试与复现用)。
///
/// # Errors
///
/// 与 [`encrypt_stream`] 一致。
#[allow(clippy::too_many_arguments)]
pub fn encrypt_stream_with_fek<R: std::io::Read, W: std::io::Write + std::io::Seek>(
    input: &mut R,
    plaintext_len: u64,
    keks: &[Kek],
    vault_salt: &[u8; 16],
    opts: &EncryptOptions,
    rnd: &RandomMaterial,
    fek: &Fek,
    out: &mut W,
    progress: Option<payload::ProgressFn<'_>>,
    finalize_folder_index: Option<FinalizeFolderIndex<'_>>,
) -> Result<StreamedFile> {
    use std::io::SeekFrom;
    if opts.chunk_size == 0 {
        return Err(Error::MalformedHeader { reason: "chunk_size must not be zero" });
    }
    let mut header = new_header(opts, rnd, vault_salt, plaintext_len);
    let n_chunks = usize::try_from(header.n_chunks())
        .map_err(|_| Error::MalformedHeader { reason: "chunk count exceeds usize" })?;

    // 占位头部:各 TLV 用与真值同长度的零内容,只为算出头部长度。
    // 占位的密文只在内存里,写进文件的是全零
    let zero_hash = [0u8; 32];
    let placeholder_index = vec![
        CompressionIndexEntry { ct_offset: 0, ct_len: 0, plain_len: 0 };
        if opts.compress { n_chunks } else { 0 }
    ];
    let placeholder = build_tlvs(
        opts,
        fek,
        opts.write_content_hash.then_some(&zero_hash),
        &placeholder_index,
        opts.folder_index.as_deref(),
    )?;
    let reserved = TLV_AREA_OFFSET
        .saturating_add(placeholder.to_bytes().len())
        .saturating_add(HEADER_MAC_LEN);
    let base = out.stream_position().map_err(Error::Io)?;
    out.write_all(&vec![0u8; reserved]).map_err(Error::Io)?;

    let payload_key = fek.derive_payload_key(&header.file_uuid);
    let (hash, index) = payload::encrypt_payload_stream(
        &header,
        &payload_key,
        input,
        opts.zstd_level,
        &mut |ct| out.write_all(ct).map_err(Error::Io),
        progress,
    )?;

    let folder_index = match (finalize_folder_index, opts.folder_index.as_deref()) {
        (Some(f), Some(placeholder_idx)) => {
            let real = f()?;
            if real.len() != placeholder_idx.len() {
                return Err(Error::MalformedHeader {
                    reason: "finalized folder index length differs from placeholder",
                });
            }
            Some(real)
        }
        (_, other) => other.map(<[u8]>::to_vec),
    };
    let tlvs = build_tlvs(
        opts,
        fek,
        opts.write_content_hash.then_some(&hash),
        &index,
        folder_index.as_deref(),
    )?;
    let head = seal_header(&mut header, &tlvs, keks, fek, rnd, opts.cipher)?;
    if head.len() != reserved {
        return Err(Error::MalformedHeader { reason: "streamed header length changed" });
    }
    let end = out.stream_position().map_err(Error::Io)?;
    out.seek(SeekFrom::Start(base)).map_err(Error::Io)?;
    out.write_all(&head).map_err(Error::Io)?;
    out.seek(SeekFrom::Start(end)).map_err(Error::Io)?;
    out.flush().map_err(Error::Io)?;
    Ok(StreamedFile { header, head, len: end.saturating_sub(base) })
}

/// 用指定的填充字节构建 slot 区,供确定性复现使用。
fn build_slot_area_with_padding(
    keks: &[Kek],
    fek: &Fek,
    file_uuid: &[u8; 16],
    cipher: CipherId,
    padding: &[u8],
) -> Result<Vec<u8>> {
    use crate::crypto::ZERO_NONCE;
    use crate::header::SLOT_LEN;

    if keks.len() > SLOT_COUNT {
        return Err(Error::TooManySlots { got: keks.len(), max: SLOT_COUNT });
    }
    let mut w = Writer::with_capacity(SLOT_AREA_LEN);
    for (i, kek) in keks.iter().enumerate() {
        let idx = u16::try_from(i)
            .map_err(|_| Error::TooManySlots { got: keks.len(), max: SLOT_COUNT })?;
        let wk = kek.derive_slot_key(file_uuid, idx);
        w.bytes(&cipher.encrypt(&wk, &ZERO_NONCE, fek.as_key().as_bytes(), &[])?);
    }
    let need = SLOT_AREA_LEN.saturating_sub(keks.len().saturating_mul(SLOT_LEN));
    let pad = padding.get(..need).ok_or(Error::MalformedHeader {
        reason: "provided slot padding shorter than required",
    })?;
    w.bytes(pad);
    Ok(w.into_vec())
}

/// 已打开的加密文件:头部已验证,FEK 已解包。
///
/// 持有 FEK 与载荷密钥,可按需解密 TLV 与载荷块。析构时密钥自动擦除。
#[derive(Debug)]
pub struct OpenedFile {
    /// 固定头。
    pub header: FixedHeader,
    /// TLV 集合(value 仍为原始形态,加密条目未解密)。
    pub tlvs: TlvSet,
    /// 命中的 KEK 在候选列表中的下标。
    pub kek_index: usize,
    /// 命中的 slot 序号。
    pub slot_index: u16,
    fek: Fek,
    payload_key: SecretKey,
}

impl OpenedFile {
    /// 载荷密钥。
    #[must_use]
    pub const fn payload_key(&self) -> &SecretKey {
        &self.payload_key
    }

    /// 文件加密密钥。
    #[must_use]
    pub const fn fek(&self) -> &Fek {
        &self.fek
    }

    /// 解密并还原原始文件名。
    ///
    /// # Errors
    ///
    /// - [`Error::MissingTlv`]:文件未存储文件名
    /// - [`Error::MalformedTlv`]:解密失败或内容非法
    pub fn filename(&self) -> Result<String> {
        let padded = self.tlvs.decrypt_value(types::FILENAME, &self.fek, self.header.cipher_id)?;
        unpad_filename(&padded)
    }

    /// 明文后缀(若存在)。
    #[must_use]
    pub fn plain_extension(&self) -> Option<String> {
        self.tlvs
            .find(types::PLAIN_EXT)
            .map(|e| String::from_utf8_lossy(&e.value).into_owned())
    }

    /// 解密缩略图。
    ///
    /// # Errors
    ///
    /// 条目不存在或解密失败时返回错误。
    pub fn thumbnail(&self) -> Result<Vec<u8>> {
        self.tlvs.decrypt_value(types::THUMBNAIL, &self.fek, self.header.cipher_id)
    }

    /// 解密媒体元信息。
    ///
    /// # Errors
    ///
    /// 条目不存在或解密失败时返回错误。
    pub fn media_meta(&self) -> Result<Vec<u8>> {
        self.tlvs.decrypt_value(types::MEDIA_META, &self.fek, self.header.cipher_id)
    }

    /// 解密 MP4 的 `moov` box 副本。
    ///
    /// 起播时优先用它,可省掉「seek 到文件尾部读 moov」的额外往返。
    ///
    /// # Errors
    ///
    /// 条目不存在或解密失败时返回错误。缺失是**正常情况**(非 MP4 文件、
    /// 或加密时未启用该优化),调用方应把错误当作「无缓存」而非故障。
    pub fn moov_cache(&self) -> Result<Vec<u8>> {
        self.tlvs.decrypt_value(types::MOOV_CACHE, &self.fek, self.header.cipher_id)
    }

    /// 是否带有 `moov` 缓存。
    ///
    /// 与 [`Self::moov_cache`] 不同,这里只看条目存在性,不做解密,
    /// 因此可用于列表页的快速判断。
    #[must_use]
    pub fn has_moov_cache(&self) -> bool {
        self.tlvs.find(types::MOOV_CACHE).is_some()
    }

    /// 是否带有媒体元信息。
    #[must_use]
    pub fn has_media_meta(&self) -> bool {
        self.tlvs.find(types::MEDIA_META).is_some()
    }

    /// 是否带有缩略图。
    ///
    /// 只看条目存在性,不做解密。判断「有没有」不能靠解密是否成功:
    /// 解密失败也可能是文件损坏,那样会把损坏静默当成「没有缩略图」。
    #[must_use]
    pub fn has_thumbnail(&self) -> bool {
        self.tlvs.find(types::THUMBNAIL).is_some()
    }

    /// 记录在 TLV 中的原始明文哈希。
    ///
    /// # Errors
    ///
    /// 条目不存在、解密失败或长度不是 32 字节时返回错误。
    pub fn stored_content_hash(&self) -> Result<[u8; 32]> {
        let v = self.tlvs.decrypt_value(types::CONTENT_HASH, &self.fek, self.header.cipher_id)?;
        let s = v.get(..32).ok_or(Error::MalformedTlv {
            tlv_type: types::CONTENT_HASH,
            reason: "content hash must be 32 bytes",
        })?;
        let mut out = [0u8; 32];
        out.copy_from_slice(s);
        Ok(out)
    }

    /// 压缩索引表(仅压缩文件存在)。
    ///
    /// # Errors
    ///
    /// 压缩文件缺少索引,或索引内容非法时返回错误。
    pub fn compression_index(&self) -> Result<Vec<CompressionIndexEntry>> {
        if !self.header.has_flag(flags::COMPRESSED) {
            return Ok(Vec::new());
        }
        let raw =
            self.tlvs.decrypt_value(types::COMPRESSION_INDEX, &self.fek, self.header.cipher_id)?;
        decode_compression_index(&raw)
    }

    /// 目录容器索引(仅容器文件存在)。
    ///
    /// 解析时会校验路径安全与载荷区间自洽——见
    /// [`ContainerIndex::parse`](crate::container::ContainerIndex::parse)。
    ///
    /// # Errors
    ///
    /// 非容器文件返回 [`Error::MissingTlv`];索引内容非法时返回解析错误。
    pub fn folder_index(&self) -> Result<crate::container::ContainerIndex> {
        if !self.header.has_flag(flags::CONTAINER) {
            return Err(Error::MissingTlv { tlv_type: types::FOLDER_INDEX });
        }
        let raw = self
            .tlvs
            .decrypt_value(types::FOLDER_INDEX, &self.fek, self.header.cipher_id)?;
        crate::container::ContainerIndex::parse(&raw)
    }

    /// 是否为目录容器。
    #[must_use]
    pub const fn is_container(&self) -> bool {
        self.header.has_flag(flags::CONTAINER)
    }

    /// 是否为槽位可管理模式(规范 §3.5)。
    #[must_use]
    pub const fn is_slot_managed(&self) -> bool {
        self.header.has_flag(flags::SLOT_DIRECTORY)
    }

    /// 解出槽位目录。
    ///
    /// 只有可管理模式的文件有。可否认模式下槽位**设计上**就不可枚举,
    /// 那不是缺功能——见 [`crate::slotdir`] 模块文档。
    ///
    /// # Errors
    ///
    /// 可否认模式的文件返回 [`Error::MissingTlv`];目录长度不对时返回
    /// [`Error::MalformedTlv`]。
    pub fn slot_directory(&self) -> Result<crate::slotdir::SlotDirectory> {
        if !self.is_slot_managed() {
            return Err(Error::MissingTlv { tlv_type: types::SLOT_DIRECTORY });
        }
        let raw = self
            .tlvs
            .decrypt_value(types::SLOT_DIRECTORY, &self.fek, self.header.cipher_id)?;
        crate::slotdir::SlotDirectory::decode(&raw)
    }

    /// 槽位目录的**原始字节**,用于原样搬运。
    ///
    /// 与 [`Self::slot_directory`] 的区别同 `raw_folder_index`:改写 slot 区
    /// 时要把目录带过去,走 `decode` → `encode` 往返只会引入编码差异的风险。
    ///
    /// # Errors
    ///
    /// 可否认模式的文件返回 [`Error::MissingTlv`]。
    pub fn raw_slot_directory(&self) -> Result<Vec<u8>> {
        if !self.is_slot_managed() {
            return Err(Error::MissingTlv { tlv_type: types::SLOT_DIRECTORY });
        }
        self.tlvs
            .decrypt_value(types::SLOT_DIRECTORY, &self.fek, self.header.cipher_id)
    }

    /// 解密后的容器索引**原始字节**。
    ///
    /// 与 [`Self::folder_index`] 的区别:那个返回解析后的结构,用于读取内容;
    /// 这个返回未解析的字节,用于原样搬运(重新加密时)。走
    /// `parse` → `encode` 往返会把索引重新序列化一遍,任何编码差异都会变成
    /// 「轮换后容器打不开」——而这种缺陷只在带容器的文件上出现,极难定位。
    ///
    /// # Errors
    ///
    /// 非容器文件返回 [`Error::MissingTlv`];解密失败时返回相应错误。
    pub fn raw_folder_index(&self) -> Result<Vec<u8>> {
        if !self.header.has_flag(flags::CONTAINER) {
            return Err(Error::MissingTlv { tlv_type: types::FOLDER_INDEX });
        }
        self.tlvs.decrypt_value(types::FOLDER_INDEX, &self.fek, self.header.cipher_id)
    }

    /// 解密全部载荷。
    ///
    /// 若文件写有内容哈希,会自动校验;不匹配返回 [`Error::ContentHashMismatch`]。
    ///
    /// # Errors
    ///
    /// - [`Error::ChunkAuthFailed`]:某块认证失败
    /// - [`Error::ContentHashMismatch`]:内容哈希校验失败
    pub fn decrypt_all(&self, file_bytes: &[u8]) -> Result<Vec<u8>> {
        self.decrypt_all_with_progress(file_bytes, None)
    }

    /// 与 [`Self::decrypt_all`] 相同,但分块过程中回报进度。
    ///
    /// # Errors
    ///
    /// 与 [`Self::decrypt_all`] 一致。
    pub fn decrypt_all_with_progress(
        &self,
        file_bytes: &[u8],
        progress: Option<payload::ProgressFn<'_>>,
    ) -> Result<Vec<u8>> {
        let start = self.header.header_len as usize;
        let payload = file_bytes.get(start..).ok_or(Error::Truncated {
            context: "payload",
            need: start,
            got: file_bytes.len(),
        })?;

        let idx = self.compression_index()?;
        let idx_ref = if idx.is_empty() { None } else { Some(idx.as_slice()) };
        let plain = payload::decrypt_payload_with_progress(
            &self.header,
            &self.payload_key,
            payload,
            idx_ref,
            progress,
        )?;

        // 有哈希就校验——这是可还原性承诺的自检手段
        if let Ok(expected) = self.stored_content_hash() {
            use subtle::ConstantTimeEq;
            let actual = content_hash(&plain);
            if !bool::from(actual.ct_eq(&expected)) {
                return Err(Error::ContentHashMismatch);
            }
        }
        Ok(plain)
    }
}

/// 仅解析头部而不尝试解密,用于快速识别文件。
///
/// 扫描目录时先用它判断「这是不是 omy 文件」,避免对每个文件都做密钥尝试。
///
/// # Errors
///
/// - [`Error::BadMagic`]:不是 omy 文件
/// - [`Error::UnsupportedVersion`]:版本过高
/// - [`Error::Truncated`]:数据不足
pub fn peek_header(data: &[u8]) -> Result<FixedHeader> {
    FixedHeader::parse(data)
}

/// 判断字节流是否以 omy 主文件 magic 开头。
///
/// 只看前 8 字节,是扫描时最廉价的过滤手段。
#[must_use]
pub fn is_omy_file(data: &[u8]) -> bool {
    data.get(..8).is_some_and(|m| m == crate::header::MAGIC_FILE)
}

/// 打开加密文件:解包 FEK 并验证头部完整性。
///
/// # 顺序不可调换
///
/// 严格按规范 §9:解包 FEK → **立即验证 MAC** → 再检查 CRITICAL TLV。
/// 若在 MAC 验证前就依据头部字段解读 TLV 或载荷,攻击者可篡改字段诱导错误解读。
///
/// # Errors
///
/// - [`Error::NoMatchingSlot`]:没有 KEK 能解开任何 slot。**这不代表文件损坏**,
///   也可能只是该文件不属于当前解锁的密码
/// - [`Error::HeaderMacMismatch`]:文件被篡改
/// - [`Error::UnknownCriticalTlv`]:存在无法理解的必需字段
pub fn open(data: &[u8], keks: &[Kek]) -> Result<OpenedFile> {
    let header = FixedHeader::parse(data)?;

    let hlen = header.header_len as usize;
    if data.len() < hlen {
        return Err(Error::Truncated { context: "header", need: hlen, got: data.len() });
    }

    let slot_end = SLOT_AREA_OFFSET.saturating_add(SLOT_AREA_LEN);
    let slot_area = data.get(SLOT_AREA_OFFSET..slot_end).ok_or(Error::Truncated {
        context: "key slot area",
        need: slot_end,
        got: data.len(),
    })?;

    let tlv_end = TLV_AREA_OFFSET.saturating_add(header.tlv_len as usize);
    let tlv_blob = data.get(TLV_AREA_OFFSET..tlv_end).ok_or(Error::Truncated {
        context: "tlv area",
        need: tlv_end,
        got: data.len(),
    })?;

    let mac_end = tlv_end.saturating_add(HEADER_MAC_LEN);
    let stored_mac = data.get(tlv_end..mac_end).ok_or(Error::Truncated {
        context: "header mac",
        need: mac_end,
        got: data.len(),
    })?;

    // 步骤 5:解包 FEK
    let (fek, kek_index, slot_index) =
        unwrap_fek(slot_area, keks, &header.file_uuid, header.cipher_id)?;

    // 步骤 6:立即验证 MAC,失败必须终止
    let covered = data.get(..tlv_end).ok_or(Error::Truncated {
        context: "mac-covered region",
        need: tlv_end,
        got: data.len(),
    })?;
    let mac_key = fek.derive_header_mac_key(&header.file_uuid);
    let mut expected = [0u8; HEADER_MAC_LEN];
    let src = stored_mac.get(..HEADER_MAC_LEN).ok_or(Error::Truncated {
        context: "header mac",
        need: HEADER_MAC_LEN,
        got: stored_mac.len(),
    })?;
    expected.copy_from_slice(src);
    if !verify_header_mac(&mac_key, covered, &expected) {
        return Err(Error::HeaderMacMismatch);
    }

    // 步骤 7:MAC 已确认可信,现在可以安全解读 TLV
    let tlvs = TlvSet::parse(tlv_blob)?;
    tlvs.check_critical()?;

    // 压缩文件必须带索引,否则无法定位块
    if header.has_flag(flags::COMPRESSED) && tlvs.find(types::COMPRESSION_INDEX).is_none() {
        return Err(Error::MissingTlv { tlv_type: types::COMPRESSION_INDEX });
    }

    let payload_key = fek.derive_payload_key(&header.file_uuid);
    Ok(OpenedFile { header, tlvs, kek_index, slot_index, fek, payload_key })
}

/// 便捷函数:用单个密码打开文件。
///
/// # 性能警告
///
/// 每次调用都会跑一次 Argon2id(默认参数约 180 ms)。**批量扫描时不要用它**——
/// 应先用 [`Kek::from_password`] 派生一次并缓存,再对每个文件调用 [`open`]。
///
/// # Errors
///
/// 同 [`open`],另加 Argon2 派生失败。
pub fn open_with_password(data: &[u8], password: &[u8]) -> Result<OpenedFile> {
    let header = FixedHeader::parse(data)?;
    let params = Argon2Params {
        m_kib: header.argon2_m_kib,
        t: header.argon2_t,
        p: header.argon2_p,
    };
    let kek = Kek::from_password(password, &header.vault_salt, params)?;
    open(data, &[kek])
}

/// 固定头长度,便于调用方按需分批读取。
pub const PEEK_SIZE: usize = FIXED_HEADER_LEN;