Skip to main content

omy_core/
file.rs

1//! 顶层封装:完整的加密文件读写。
2//!
3//! 把 header、slot、TLV、载荷各层串成实际可用的 API。
4//!
5//! # 读取顺序不可调换
6//!
7//! 严格遵循规范 §9:
8//!
9//! 1. 校验 magic 与版本 → 2. 解析固定头 → 3. 解包 FEK →
10//! 4. **验证 header MAC** → 5. 检查 CRITICAL TLV → 6. 读载荷
11//!
12//! 第 4 步必须在第 5、6 步**之前**。原因:TLV 与载荷的解读方式(是否压缩、
13//! chunk_size、plaintext_size)全部来自头部字段,若不先验证 MAC,攻击者可以
14//! 篡改这些字段诱导错误解读。MAC 验证失败必须立即终止。
15
16use crate::crypto::{Argon2Params, CipherId, Fek, Kek, SecretKey, content_hash, header_mac, verify_header_mac};
17use crate::error::{Error, Result};
18use crate::payload;
19use crate::header::{
20    COMPRESS_NONE, COMPRESS_ZSTD, DEFAULT_CHUNK_SIZE, FIXED_HEADER_LEN, FixedHeader,
21    HEADER_MAC_LEN, KDF_ARGON2ID, SLOT_AREA_LEN, SLOT_AREA_OFFSET, SLOT_COUNT, SLOT_COUNT_U8,
22    TLV_AREA_OFFSET, VERSION_MAJOR, VERSION_MINOR, flags, validate_chunk_size,
23};
24use crate::slot::{build_slot_area, unwrap_fek};
25use crate::tlv::{
26    CompressionIndexEntry, TlvEntry, TlvSet, decode_compression_index, encode_compression_index,
27    encrypt_entry, pad_filename, tlv_flags, types, unpad_filename,
28};
29use crate::util::{Writer, random_16};
30
31/// 加密选项。
32#[derive(Debug, Clone)]
33pub struct EncryptOptions {
34    /// 原始文件名。为 `None` 时不写入文件名 TLV。
35    pub filename: Option<String>,
36    /// 是否以明文保留后缀。便于在不解密的情况下按类型筛选,但会泄露文件类型。
37    pub preserve_extension: bool,
38    /// 是否启用 zstd 压缩。
39    pub compress: bool,
40    /// zstd 压缩级别(1–22)。
41    pub zstd_level: i32,
42    /// 明文分块大小。
43    pub chunk_size: u32,
44    /// AEAD 算法。
45    pub cipher: CipherId,
46    /// Argon2 参数。
47    pub argon2: Argon2Params,
48    /// 是否写入内容哈希以支持解密后自检。
49    pub write_content_hash: bool,
50    /// 加密缩略图数据(WebP/JPEG),建议不超过 32 KB。
51    pub thumbnail: Option<Vec<u8>>,
52    /// 媒体元信息 JSON。
53    pub media_meta: Option<Vec<u8>>,
54    /// MP4 的 `moov` box 副本,用于加速起播(规范 §4.4 的 `0x0006`)。
55    ///
56    /// 播放时先从这里取元数据,省掉「seek 到尾部读 moov」的两次额外往返。
57    /// 收益最大的两个场景:
58    ///
59    /// - **局域网远程播放**:省掉真实网络 RTT
60    /// - **缺片播放**:每个分片的冗余 header 都带一份,只要任意一片存在就能起播
61    ///   (文档 05 §7.4)
62    ///
63    /// 与 `TRANSCODED` 不同,这个副本**不改动载荷**,原文件仍可 bit-for-bit 还原。
64    pub moov_cache: Option<Vec<u8>>,
65    /// 目录容器索引(`ContainerIndex::encode` 的输出)。
66    ///
67    /// 置位时会写入 CRITICAL+ENCRYPTED 的 `TLV_FOLDER_INDEX` 并设置
68    /// `CONTAINER` flag。CRITICAL 是必需的:不认识该 TLV 的实现必须拒绝打开,
69    /// 否则会把容器误当作普通文件解出一堆拼接的字节。
70    pub folder_index: Option<Vec<u8>>,
71    /// 槽位目录(可管理模式,`SlotDirectory::encode` 的输出,固定 24 字节)。
72    ///
73    /// 置位时写入 CRITICAL+ENCRYPTED 的 `TLV_SLOT_DIRECTORY` 并设置
74    /// `SLOT_DIRECTORY` flag,即规范 §3.5 的「可管理模式」。
75    ///
76    /// 为 `None` 时是默认的可否认模式:槽位不可枚举,连文件的主人也分不清
77    /// 哪个槽是谁的。两种模式在加密时二选一,同一个 vault 内不应混用——
78    /// 同目录下 5 个可否认文件混 1 个可管理文件,那 1 个的存在本身就是信号。
79    pub slot_directory: Option<Vec<u8>>,
80}
81
82impl Default for EncryptOptions {
83    fn default() -> Self {
84        Self {
85            filename: None,
86            preserve_extension: false,
87            compress: false,
88            zstd_level: 3,
89            chunk_size: DEFAULT_CHUNK_SIZE,
90            cipher: CipherId::ChaCha20Poly1305,
91            argon2: Argon2Params::default(),
92            write_content_hash: true,
93            thumbnail: None,
94            media_meta: None,
95            moov_cache: None,
96            folder_index: None,
97            slot_directory: None,
98        }
99    }
100}
101
102/// 加密所需的随机材料。
103///
104/// 单独抽出来是为了让加密过程**可确定性复现**——测试向量验证要求「相同输入加相同
105/// 随机源产出逐字节相同输出」,若随机数在函数内部生成就无法做到。
106#[derive(Debug, Clone)]
107pub struct RandomMaterial {
108    /// 文件唯一标识。
109    pub file_uuid: [u8; 16],
110    /// 载荷 nonce 前缀。
111    pub base_nonce: [u8; 7],
112    /// 未使用 slot 的填充字节。为 `None` 时现场生成随机值。
113    pub slot_padding: Option<Vec<u8>>,
114}
115
116impl RandomMaterial {
117    /// 从系统 CSPRNG 生成。
118    #[must_use]
119    pub fn generate() -> Self {
120        let mut bn = [0u8; 7];
121        crate::util::fill_random(&mut bn);
122        Self { file_uuid: random_16(), base_nonce: bn, slot_padding: None }
123    }
124}
125
126/// 已加密文件的完整字节与其头部信息。
127#[derive(Debug)]
128pub struct EncryptedFile {
129    /// 完整文件字节。
130    pub bytes: Vec<u8>,
131    /// 解析好的固定头。
132    pub header: FixedHeader,
133}
134
135/// 加密数据,产出完整的 omy 文件字节。
136///
137/// `vault_salt` 应在同一 vault 内保持一致,这样多个文件可复用同一次 Argon2 计算。
138///
139/// # Errors
140///
141/// - [`Error::TooManySlots`]:KEK 超过 8 个
142/// - [`Error::InvalidChunkSize`]:分块大小超出允许范围
143/// - [`Error::Compression`]:压缩失败
144pub fn encrypt(
145    plaintext: &[u8],
146    keks: &[Kek],
147    vault_salt: &[u8; 16],
148    opts: &EncryptOptions,
149    rnd: &RandomMaterial,
150) -> Result<EncryptedFile> {
151    encrypt_with_progress(plaintext, keks, vault_salt, opts, rnd, None)
152}
153
154/// 与 [`encrypt`] 相同,但分块过程中回报进度。
155///
156/// 大文件加密是纯 CPU 循环,没有进度反馈时用户无法区分「在算」和
157/// 「卡死了」。回调语义见 [`crate::payload::ProgressFn`]。
158///
159/// # Errors
160///
161/// 与 [`encrypt`] 一致。
162pub fn encrypt_with_progress(
163    plaintext: &[u8],
164    keks: &[Kek],
165    vault_salt: &[u8; 16],
166    opts: &EncryptOptions,
167    rnd: &RandomMaterial,
168    progress: Option<payload::ProgressFn<'_>>,
169) -> Result<EncryptedFile> {
170    if keks.is_empty() {
171        return Err(Error::TooManySlots { got: 0, max: SLOT_COUNT });
172    }
173    validate_chunk_size(opts.chunk_size)?;
174
175    let fek = Fek::random();
176    encrypt_with_fek_and_progress(plaintext, keks, vault_salt, opts, rnd, &fek, progress)
177}
178
179/// 用指定 FEK 加密。
180///
181/// 这是**底层 API**:不做 `chunk_size` 的推荐范围校验,只要求非零。
182/// 因此可用于复现测试向量(v3/v4 使用 4096 / 2048 字节的小块)以及处理
183/// 其它实现产出的合法文件。面向用户的入口请用 [`encrypt`],它会做策略校验。
184///
185/// # Errors
186///
187/// - [`Error::MalformedHeader`]:`chunk_size` 为 0,或字段溢出
188/// - [`Error::Compression`]:压缩失败
189pub fn encrypt_with_fek(
190    plaintext: &[u8],
191    keks: &[Kek],
192    vault_salt: &[u8; 16],
193    opts: &EncryptOptions,
194    rnd: &RandomMaterial,
195    fek: &Fek,
196) -> Result<EncryptedFile> {
197    encrypt_with_fek_and_progress(plaintext, keks, vault_salt, opts, rnd, fek, None)
198}
199
200/// 与 [`encrypt_with_fek`] 相同,但分块过程中回报进度。
201///
202/// # Errors
203///
204/// 与 [`encrypt_with_fek`] 一致。
205pub fn encrypt_with_fek_and_progress(
206    plaintext: &[u8],
207    keks: &[Kek],
208    vault_salt: &[u8; 16],
209    opts: &EncryptOptions,
210    rnd: &RandomMaterial,
211    fek: &Fek,
212    progress: Option<payload::ProgressFn<'_>>,
213) -> Result<EncryptedFile> {
214    if opts.chunk_size == 0 {
215        return Err(Error::MalformedHeader { reason: "chunk_size must not be zero" });
216    }
217
218    let mut header = new_header(opts, rnd, vault_salt, plaintext.len() as u64);
219    let payload_key = fek.derive_payload_key(&header.file_uuid);
220    let (payload, index) = payload::encrypt_payload_with_progress(
221        &header,
222        &payload_key,
223        plaintext,
224        opts.zstd_level,
225        progress,
226    )?;
227
228    let hash = opts.write_content_hash.then(|| content_hash(plaintext));
229    let tlvs = build_tlvs(opts, fek, hash.as_ref(), &index, opts.folder_index.as_deref())?;
230    let head = seal_header(&mut header, &tlvs, keks, fek, rnd, opts.cipher)?;
231
232    let mut out = Writer::with_capacity(head.len().saturating_add(payload.len()));
233    out.bytes(&head).bytes(&payload);
234
235    let bytes = out.into_vec();
236    debug_assert_eq!(
237        bytes.len(),
238        (header.header_len as usize).saturating_add(payload.len()),
239        "输出长度与 header_len + payload 不符"
240    );
241
242    Ok(EncryptedFile { bytes, header })
243}
244
245/// 按选项算出文件 flags。内存版与流式版共用,两边的 flag 不可能不一致。
246fn file_flags(opts: &EncryptOptions) -> u32 {
247    let mut file_flags = 0u32;
248    if opts.filename.is_some() {
249        file_flags |= flags::FILENAME_ENCRYPTED;
250    }
251    if opts.preserve_extension {
252        file_flags |= flags::EXT_PRESERVED;
253    }
254    if opts.compress {
255        file_flags |= flags::COMPRESSED;
256    }
257    if opts.thumbnail.is_some() {
258        file_flags |= flags::HAS_THUMBNAIL;
259    }
260    if opts.slot_directory.is_some() {
261        file_flags |= flags::SLOT_DIRECTORY;
262    }
263    if opts.folder_index.is_some() {
264        file_flags |= flags::CONTAINER;
265    }
266    file_flags
267}
268
269/// 构造固定头(`header_len` / `tlv_len` 由 [`seal_header`] 回填)。
270fn new_header(
271    opts: &EncryptOptions,
272    rnd: &RandomMaterial,
273    vault_salt: &[u8; 16],
274    plaintext_size: u64,
275) -> FixedHeader {
276    FixedHeader {
277        version_major: VERSION_MAJOR,
278        version_minor: VERSION_MINOR,
279        header_len: 0,
280        file_uuid: rnd.file_uuid,
281        vault_salt: *vault_salt,
282        flags: file_flags(opts),
283        cipher_id: opts.cipher,
284        kdf_id: KDF_ARGON2ID,
285        compress_id: if opts.compress { COMPRESS_ZSTD } else { COMPRESS_NONE },
286        slot_count: SLOT_COUNT_U8,
287        argon2_m_kib: opts.argon2.m_kib,
288        argon2_t: opts.argon2.t,
289        argon2_p: opts.argon2.p,
290        chunk_size: opts.chunk_size,
291        plaintext_size,
292        base_nonce: rnd.base_nonce,
293        chunk_version: 0,
294        tlv_len: 0,
295    }
296}
297
298/// 按固定顺序构造 TLV 集合。
299///
300/// 内容哈希与压缩索引要等载荷处理完才知道,由调用方传入;`folder_index`
301/// 单独传入而不读 `opts`,是为了让流式打包能先用同长度的占位、最后换成真值。
302/// 顺序必须与规范及测试向量一致,所以只此一处。
303fn build_tlvs(
304    opts: &EncryptOptions,
305    fek: &Fek,
306    hash: Option<&[u8; 32]>,
307    index: &[CompressionIndexEntry],
308    folder_index: Option<&[u8]>,
309) -> Result<TlvSet> {
310    let mut tlvs = TlvSet::new();
311
312    if let Some(name) = &opts.filename {
313        let padded = pad_filename(name)?;
314        tlvs.push(encrypt_entry(
315            types::FILENAME,
316            tlv_flags::CRITICAL,
317            &padded,
318            fek,
319            opts.cipher,
320        )?);
321    }
322    if opts.preserve_extension {
323        let ext = opts
324            .filename
325            .as_deref()
326            .and_then(|n| n.rsplit_once('.').map(|(_, e)| e))
327            .unwrap_or("");
328        tlvs.push(TlvEntry::new(types::PLAIN_EXT, 0, ext.as_bytes().to_vec()));
329    }
330    if let Some(t) = &opts.thumbnail {
331        tlvs.push(encrypt_entry(types::THUMBNAIL, 0, t, fek, opts.cipher)?);
332    }
333    if let Some(m) = &opts.media_meta {
334        tlvs.push(encrypt_entry(types::MEDIA_META, 0, m, fek, opts.cipher)?);
335    }
336    if let Some(mv) = &opts.moov_cache {
337        // 非 CRITICAL:这纯粹是起播优化,不认识它的实现照样能正确解密播放,
338        // 只是要多做两次 seek。标 CRITICAL 会让旧版实现无谓地拒绝打开。
339        tlvs.push(encrypt_entry(types::MOOV_CACHE, 0, mv, fek, opts.cipher)?);
340    }
341    if let Some(idx) = folder_index {
342        // CRITICAL:容器的载荷是多个文件的拼接,不认识索引就不该打开
343        tlvs.push(encrypt_entry(
344            types::FOLDER_INDEX,
345            tlv_flags::CRITICAL,
346            idx,
347            fek,
348            opts.cipher,
349        )?);
350    }
351    if let Some(dir) = &opts.slot_directory {
352        // CRITICAL:不认识此 TLV 的实现必须拒绝打开。否则老版本会把可管理
353        // 模式的文件当普通文件改密码,槽位目录与实际 slot 区就此对不上——
354        // 而这种损坏不会立刻显现,等到用户依赖目录去删某个协作者时才暴露,
355        // 那时已无法判断哪份记录是对的。
356        //
357        // ENCRYPTED 由 encrypt_entry 保证:明文目录等于把「谁能打开这个
358        // 文件」白送给任何拿到文件的人。
359        tlvs.push(encrypt_entry(
360            types::SLOT_DIRECTORY,
361            tlv_flags::CRITICAL,
362            dir,
363            fek,
364            opts.cipher,
365        )?);
366    }
367    if let Some(h) = hash {
368        tlvs.push(encrypt_entry(types::CONTENT_HASH, 0, h, fek, opts.cipher)?);
369    }
370    // 压缩模式必须写 CRITICAL 索引 TLV,否则无法定位块。
371    // 索引里的 ct_offset 相对载荷起点,不受 header_len 影响,不会循环依赖
372    if opts.compress {
373        let enc = encode_compression_index(index);
374        tlvs.push(encrypt_entry(
375            types::COMPRESSION_INDEX,
376            tlv_flags::CRITICAL,
377            &enc,
378            fek,
379            opts.cipher,
380        )?);
381    }
382    Ok(tlvs)
383}
384
385/// 回填 `header_len` / `tlv_len`,构造 slot 区与头部 MAC,返回完整头部字节。
386fn seal_header(
387    header: &mut FixedHeader,
388    tlvs: &TlvSet,
389    keks: &[Kek],
390    fek: &Fek,
391    rnd: &RandomMaterial,
392    cipher: CipherId,
393) -> Result<Vec<u8>> {
394    let tlv_blob = tlvs.to_bytes();
395    header.tlv_len = u32::try_from(tlv_blob.len())
396        .map_err(|_| Error::MalformedHeader { reason: "tlv area exceeds u32" })?;
397    header.header_len = u32::try_from(
398        TLV_AREA_OFFSET
399            .saturating_add(tlv_blob.len())
400            .saturating_add(HEADER_MAC_LEN),
401    )
402    .map_err(|_| Error::MalformedHeader { reason: "header_len exceeds u32" })?;
403
404    let fixed = header.to_bytes()?;
405    let slot_area = if let Some(pad) = &rnd.slot_padding {
406        build_slot_area_with_padding(keks, fek, &header.file_uuid, cipher, pad)?
407    } else {
408        build_slot_area(keks, fek, &header.file_uuid, cipher)?
409    };
410
411    // MAC 覆盖 fixed_header || slot_area || tlv_blob
412    let mut covered = Writer::with_capacity(header.header_len as usize);
413    covered.bytes(&fixed).bytes(&slot_area).bytes(&tlv_blob);
414    let mac_key = fek.derive_header_mac_key(&header.file_uuid);
415    let mac = header_mac(&mac_key, covered.as_slice());
416    let mut out = covered.into_vec();
417    out.extend_from_slice(&mac);
418    Ok(out)
419}
420
421/// 流式加密的「最后定稿」回调:载荷写完后返回真正的 FOLDER_INDEX 内容。
422///
423/// 容器的索引里有逐文件哈希,要读完文件才算得出;而它与内容无关的长度
424/// 在开头就已确定。流式加密先按 `opts.folder_index`(同长度占位)留出头部
425/// 空间,写完载荷再调用它换成真值。两者长度不同会报错——那会让 header_len
426/// 与已写出的载荷错位。
427pub type FinalizeFolderIndex<'a> = &'a mut dyn FnMut() -> Result<Vec<u8>>;
428
429/// 流式加密的结果。
430#[derive(Debug, Clone)]
431pub struct StreamedFile {
432    /// 解析好的固定头。
433    pub header: FixedHeader,
434    /// 完整头部字节(含 MAC)。调用方据此做写盘后的自检(例如按密码重新打开),
435    /// 不必再从磁盘读回。
436    pub head: Vec<u8>,
437    /// 写出的总字节数(头部 + 载荷)。
438    pub len: u64,
439}
440
441/// 流式加密:从 `input` 读恰好 `plaintext_len` 字节明文,把完整的 omy 文件写进 `out`。
442///
443/// 与 [`encrypt_with_progress`] 产出**逐字节相同**的文件(给定相同 FEK 与随机材料
444/// 时),但内存占用与文件大小无关:原来要把整个明文读进内存、再在内存里拼出
445/// 整个密文,几 GB 的视频峰值是文件大小的两倍以上。
446///
447/// 做法:头部的长度只取决于各 TLV 的长度(与内容无关),所以先写同长度的
448/// 零占位,逐块加密写载荷,同时累计内容哈希与压缩索引;最后 seek 回开头写入
449/// 真正的头部。头部 MAC 覆盖的都是这时才确定的字段,占位不会进入 MAC。
450///
451/// `input` 实际长度与 `plaintext_len` 不符(文件在加密过程中被改动)时报错,
452/// 而不是写出一个与头部声明不一致的文件。
453///
454/// # Errors
455///
456/// 与 [`encrypt_with_progress`] 一致;另外读写失败返回 [`Error::Io`],
457/// 定稿的 FOLDER_INDEX 长度与占位不同返回 [`Error::MalformedHeader`]。
458#[allow(clippy::too_many_arguments)]
459pub fn encrypt_stream<R: std::io::Read, W: std::io::Write + std::io::Seek>(
460    input: &mut R,
461    plaintext_len: u64,
462    keks: &[Kek],
463    vault_salt: &[u8; 16],
464    opts: &EncryptOptions,
465    rnd: &RandomMaterial,
466    out: &mut W,
467    progress: Option<payload::ProgressFn<'_>>,
468    finalize_folder_index: Option<FinalizeFolderIndex<'_>>,
469) -> Result<StreamedFile> {
470    if keks.is_empty() {
471        return Err(Error::TooManySlots { got: 0, max: SLOT_COUNT });
472    }
473    validate_chunk_size(opts.chunk_size)?;
474    let fek = Fek::random();
475    encrypt_stream_with_fek(
476        input,
477        plaintext_len,
478        keks,
479        vault_salt,
480        opts,
481        rnd,
482        &fek,
483        out,
484        progress,
485        finalize_folder_index,
486    )
487}
488
489/// 与 [`encrypt_stream`] 相同,但用指定 FEK(测试与复现用)。
490///
491/// # Errors
492///
493/// 与 [`encrypt_stream`] 一致。
494#[allow(clippy::too_many_arguments)]
495pub fn encrypt_stream_with_fek<R: std::io::Read, W: std::io::Write + std::io::Seek>(
496    input: &mut R,
497    plaintext_len: u64,
498    keks: &[Kek],
499    vault_salt: &[u8; 16],
500    opts: &EncryptOptions,
501    rnd: &RandomMaterial,
502    fek: &Fek,
503    out: &mut W,
504    progress: Option<payload::ProgressFn<'_>>,
505    finalize_folder_index: Option<FinalizeFolderIndex<'_>>,
506) -> Result<StreamedFile> {
507    use std::io::SeekFrom;
508    if opts.chunk_size == 0 {
509        return Err(Error::MalformedHeader { reason: "chunk_size must not be zero" });
510    }
511    let mut header = new_header(opts, rnd, vault_salt, plaintext_len);
512    let n_chunks = usize::try_from(header.n_chunks())
513        .map_err(|_| Error::MalformedHeader { reason: "chunk count exceeds usize" })?;
514
515    // 占位头部:各 TLV 用与真值同长度的零内容,只为算出头部长度。
516    // 占位的密文只在内存里,写进文件的是全零
517    let zero_hash = [0u8; 32];
518    let placeholder_index = vec![
519        CompressionIndexEntry { ct_offset: 0, ct_len: 0, plain_len: 0 };
520        if opts.compress { n_chunks } else { 0 }
521    ];
522    let placeholder = build_tlvs(
523        opts,
524        fek,
525        opts.write_content_hash.then_some(&zero_hash),
526        &placeholder_index,
527        opts.folder_index.as_deref(),
528    )?;
529    let reserved = TLV_AREA_OFFSET
530        .saturating_add(placeholder.to_bytes().len())
531        .saturating_add(HEADER_MAC_LEN);
532    let base = out.stream_position().map_err(Error::Io)?;
533    out.write_all(&vec![0u8; reserved]).map_err(Error::Io)?;
534
535    let payload_key = fek.derive_payload_key(&header.file_uuid);
536    let (hash, index) = payload::encrypt_payload_stream(
537        &header,
538        &payload_key,
539        input,
540        opts.zstd_level,
541        &mut |ct| out.write_all(ct).map_err(Error::Io),
542        progress,
543    )?;
544
545    let folder_index = match (finalize_folder_index, opts.folder_index.as_deref()) {
546        (Some(f), Some(placeholder_idx)) => {
547            let real = f()?;
548            if real.len() != placeholder_idx.len() {
549                return Err(Error::MalformedHeader {
550                    reason: "finalized folder index length differs from placeholder",
551                });
552            }
553            Some(real)
554        }
555        (_, other) => other.map(<[u8]>::to_vec),
556    };
557    let tlvs = build_tlvs(
558        opts,
559        fek,
560        opts.write_content_hash.then_some(&hash),
561        &index,
562        folder_index.as_deref(),
563    )?;
564    let head = seal_header(&mut header, &tlvs, keks, fek, rnd, opts.cipher)?;
565    if head.len() != reserved {
566        return Err(Error::MalformedHeader { reason: "streamed header length changed" });
567    }
568    let end = out.stream_position().map_err(Error::Io)?;
569    out.seek(SeekFrom::Start(base)).map_err(Error::Io)?;
570    out.write_all(&head).map_err(Error::Io)?;
571    out.seek(SeekFrom::Start(end)).map_err(Error::Io)?;
572    out.flush().map_err(Error::Io)?;
573    Ok(StreamedFile { header, head, len: end.saturating_sub(base) })
574}
575
576/// 用指定的填充字节构建 slot 区,供确定性复现使用。
577fn build_slot_area_with_padding(
578    keks: &[Kek],
579    fek: &Fek,
580    file_uuid: &[u8; 16],
581    cipher: CipherId,
582    padding: &[u8],
583) -> Result<Vec<u8>> {
584    use crate::crypto::ZERO_NONCE;
585    use crate::header::SLOT_LEN;
586
587    if keks.len() > SLOT_COUNT {
588        return Err(Error::TooManySlots { got: keks.len(), max: SLOT_COUNT });
589    }
590    let mut w = Writer::with_capacity(SLOT_AREA_LEN);
591    for (i, kek) in keks.iter().enumerate() {
592        let idx = u16::try_from(i)
593            .map_err(|_| Error::TooManySlots { got: keks.len(), max: SLOT_COUNT })?;
594        let wk = kek.derive_slot_key(file_uuid, idx);
595        w.bytes(&cipher.encrypt(&wk, &ZERO_NONCE, fek.as_key().as_bytes(), &[])?);
596    }
597    let need = SLOT_AREA_LEN.saturating_sub(keks.len().saturating_mul(SLOT_LEN));
598    let pad = padding.get(..need).ok_or(Error::MalformedHeader {
599        reason: "provided slot padding shorter than required",
600    })?;
601    w.bytes(pad);
602    Ok(w.into_vec())
603}
604
605/// 已打开的加密文件:头部已验证,FEK 已解包。
606///
607/// 持有 FEK 与载荷密钥,可按需解密 TLV 与载荷块。析构时密钥自动擦除。
608#[derive(Debug)]
609pub struct OpenedFile {
610    /// 固定头。
611    pub header: FixedHeader,
612    /// TLV 集合(value 仍为原始形态,加密条目未解密)。
613    pub tlvs: TlvSet,
614    /// 命中的 KEK 在候选列表中的下标。
615    pub kek_index: usize,
616    /// 命中的 slot 序号。
617    pub slot_index: u16,
618    fek: Fek,
619    payload_key: SecretKey,
620}
621
622impl OpenedFile {
623    /// 载荷密钥。
624    #[must_use]
625    pub const fn payload_key(&self) -> &SecretKey {
626        &self.payload_key
627    }
628
629    /// 文件加密密钥。
630    #[must_use]
631    pub const fn fek(&self) -> &Fek {
632        &self.fek
633    }
634
635    /// 解密并还原原始文件名。
636    ///
637    /// # Errors
638    ///
639    /// - [`Error::MissingTlv`]:文件未存储文件名
640    /// - [`Error::MalformedTlv`]:解密失败或内容非法
641    pub fn filename(&self) -> Result<String> {
642        let padded = self.tlvs.decrypt_value(types::FILENAME, &self.fek, self.header.cipher_id)?;
643        unpad_filename(&padded)
644    }
645
646    /// 明文后缀(若存在)。
647    #[must_use]
648    pub fn plain_extension(&self) -> Option<String> {
649        self.tlvs
650            .find(types::PLAIN_EXT)
651            .map(|e| String::from_utf8_lossy(&e.value).into_owned())
652    }
653
654    /// 解密缩略图。
655    ///
656    /// # Errors
657    ///
658    /// 条目不存在或解密失败时返回错误。
659    pub fn thumbnail(&self) -> Result<Vec<u8>> {
660        self.tlvs.decrypt_value(types::THUMBNAIL, &self.fek, self.header.cipher_id)
661    }
662
663    /// 解密媒体元信息。
664    ///
665    /// # Errors
666    ///
667    /// 条目不存在或解密失败时返回错误。
668    pub fn media_meta(&self) -> Result<Vec<u8>> {
669        self.tlvs.decrypt_value(types::MEDIA_META, &self.fek, self.header.cipher_id)
670    }
671
672    /// 解密 MP4 的 `moov` box 副本。
673    ///
674    /// 起播时优先用它,可省掉「seek 到文件尾部读 moov」的额外往返。
675    ///
676    /// # Errors
677    ///
678    /// 条目不存在或解密失败时返回错误。缺失是**正常情况**(非 MP4 文件、
679    /// 或加密时未启用该优化),调用方应把错误当作「无缓存」而非故障。
680    pub fn moov_cache(&self) -> Result<Vec<u8>> {
681        self.tlvs.decrypt_value(types::MOOV_CACHE, &self.fek, self.header.cipher_id)
682    }
683
684    /// 是否带有 `moov` 缓存。
685    ///
686    /// 与 [`Self::moov_cache`] 不同,这里只看条目存在性,不做解密,
687    /// 因此可用于列表页的快速判断。
688    #[must_use]
689    pub fn has_moov_cache(&self) -> bool {
690        self.tlvs.find(types::MOOV_CACHE).is_some()
691    }
692
693    /// 是否带有媒体元信息。
694    #[must_use]
695    pub fn has_media_meta(&self) -> bool {
696        self.tlvs.find(types::MEDIA_META).is_some()
697    }
698
699    /// 是否带有缩略图。
700    ///
701    /// 只看条目存在性,不做解密。判断「有没有」不能靠解密是否成功:
702    /// 解密失败也可能是文件损坏,那样会把损坏静默当成「没有缩略图」。
703    #[must_use]
704    pub fn has_thumbnail(&self) -> bool {
705        self.tlvs.find(types::THUMBNAIL).is_some()
706    }
707
708    /// 记录在 TLV 中的原始明文哈希。
709    ///
710    /// # Errors
711    ///
712    /// 条目不存在、解密失败或长度不是 32 字节时返回错误。
713    pub fn stored_content_hash(&self) -> Result<[u8; 32]> {
714        let v = self.tlvs.decrypt_value(types::CONTENT_HASH, &self.fek, self.header.cipher_id)?;
715        let s = v.get(..32).ok_or(Error::MalformedTlv {
716            tlv_type: types::CONTENT_HASH,
717            reason: "content hash must be 32 bytes",
718        })?;
719        let mut out = [0u8; 32];
720        out.copy_from_slice(s);
721        Ok(out)
722    }
723
724    /// 压缩索引表(仅压缩文件存在)。
725    ///
726    /// # Errors
727    ///
728    /// 压缩文件缺少索引,或索引内容非法时返回错误。
729    pub fn compression_index(&self) -> Result<Vec<CompressionIndexEntry>> {
730        if !self.header.has_flag(flags::COMPRESSED) {
731            return Ok(Vec::new());
732        }
733        let raw =
734            self.tlvs.decrypt_value(types::COMPRESSION_INDEX, &self.fek, self.header.cipher_id)?;
735        decode_compression_index(&raw)
736    }
737
738    /// 目录容器索引(仅容器文件存在)。
739    ///
740    /// 解析时会校验路径安全与载荷区间自洽——见
741    /// [`ContainerIndex::parse`](crate::container::ContainerIndex::parse)。
742    ///
743    /// # Errors
744    ///
745    /// 非容器文件返回 [`Error::MissingTlv`];索引内容非法时返回解析错误。
746    pub fn folder_index(&self) -> Result<crate::container::ContainerIndex> {
747        if !self.header.has_flag(flags::CONTAINER) {
748            return Err(Error::MissingTlv { tlv_type: types::FOLDER_INDEX });
749        }
750        let raw = self
751            .tlvs
752            .decrypt_value(types::FOLDER_INDEX, &self.fek, self.header.cipher_id)?;
753        crate::container::ContainerIndex::parse(&raw)
754    }
755
756    /// 是否为目录容器。
757    #[must_use]
758    pub const fn is_container(&self) -> bool {
759        self.header.has_flag(flags::CONTAINER)
760    }
761
762    /// 是否为槽位可管理模式(规范 §3.5)。
763    #[must_use]
764    pub const fn is_slot_managed(&self) -> bool {
765        self.header.has_flag(flags::SLOT_DIRECTORY)
766    }
767
768    /// 解出槽位目录。
769    ///
770    /// 只有可管理模式的文件有。可否认模式下槽位**设计上**就不可枚举,
771    /// 那不是缺功能——见 [`crate::slotdir`] 模块文档。
772    ///
773    /// # Errors
774    ///
775    /// 可否认模式的文件返回 [`Error::MissingTlv`];目录长度不对时返回
776    /// [`Error::MalformedTlv`]。
777    pub fn slot_directory(&self) -> Result<crate::slotdir::SlotDirectory> {
778        if !self.is_slot_managed() {
779            return Err(Error::MissingTlv { tlv_type: types::SLOT_DIRECTORY });
780        }
781        let raw = self
782            .tlvs
783            .decrypt_value(types::SLOT_DIRECTORY, &self.fek, self.header.cipher_id)?;
784        crate::slotdir::SlotDirectory::decode(&raw)
785    }
786
787    /// 槽位目录的**原始字节**,用于原样搬运。
788    ///
789    /// 与 [`Self::slot_directory`] 的区别同 `raw_folder_index`:改写 slot 区
790    /// 时要把目录带过去,走 `decode` → `encode` 往返只会引入编码差异的风险。
791    ///
792    /// # Errors
793    ///
794    /// 可否认模式的文件返回 [`Error::MissingTlv`]。
795    pub fn raw_slot_directory(&self) -> Result<Vec<u8>> {
796        if !self.is_slot_managed() {
797            return Err(Error::MissingTlv { tlv_type: types::SLOT_DIRECTORY });
798        }
799        self.tlvs
800            .decrypt_value(types::SLOT_DIRECTORY, &self.fek, self.header.cipher_id)
801    }
802
803    /// 解密后的容器索引**原始字节**。
804    ///
805    /// 与 [`Self::folder_index`] 的区别:那个返回解析后的结构,用于读取内容;
806    /// 这个返回未解析的字节,用于原样搬运(重新加密时)。走
807    /// `parse` → `encode` 往返会把索引重新序列化一遍,任何编码差异都会变成
808    /// 「轮换后容器打不开」——而这种缺陷只在带容器的文件上出现,极难定位。
809    ///
810    /// # Errors
811    ///
812    /// 非容器文件返回 [`Error::MissingTlv`];解密失败时返回相应错误。
813    pub fn raw_folder_index(&self) -> Result<Vec<u8>> {
814        if !self.header.has_flag(flags::CONTAINER) {
815            return Err(Error::MissingTlv { tlv_type: types::FOLDER_INDEX });
816        }
817        self.tlvs.decrypt_value(types::FOLDER_INDEX, &self.fek, self.header.cipher_id)
818    }
819
820    /// 解密全部载荷。
821    ///
822    /// 若文件写有内容哈希,会自动校验;不匹配返回 [`Error::ContentHashMismatch`]。
823    ///
824    /// # Errors
825    ///
826    /// - [`Error::ChunkAuthFailed`]:某块认证失败
827    /// - [`Error::ContentHashMismatch`]:内容哈希校验失败
828    pub fn decrypt_all(&self, file_bytes: &[u8]) -> Result<Vec<u8>> {
829        self.decrypt_all_with_progress(file_bytes, None)
830    }
831
832    /// 与 [`Self::decrypt_all`] 相同,但分块过程中回报进度。
833    ///
834    /// # Errors
835    ///
836    /// 与 [`Self::decrypt_all`] 一致。
837    pub fn decrypt_all_with_progress(
838        &self,
839        file_bytes: &[u8],
840        progress: Option<payload::ProgressFn<'_>>,
841    ) -> Result<Vec<u8>> {
842        let start = self.header.header_len as usize;
843        let payload = file_bytes.get(start..).ok_or(Error::Truncated {
844            context: "payload",
845            need: start,
846            got: file_bytes.len(),
847        })?;
848
849        let idx = self.compression_index()?;
850        let idx_ref = if idx.is_empty() { None } else { Some(idx.as_slice()) };
851        let plain = payload::decrypt_payload_with_progress(
852            &self.header,
853            &self.payload_key,
854            payload,
855            idx_ref,
856            progress,
857        )?;
858
859        // 有哈希就校验——这是可还原性承诺的自检手段
860        if let Ok(expected) = self.stored_content_hash() {
861            use subtle::ConstantTimeEq;
862            let actual = content_hash(&plain);
863            if !bool::from(actual.ct_eq(&expected)) {
864                return Err(Error::ContentHashMismatch);
865            }
866        }
867        Ok(plain)
868    }
869}
870
871/// 仅解析头部而不尝试解密,用于快速识别文件。
872///
873/// 扫描目录时先用它判断「这是不是 omy 文件」,避免对每个文件都做密钥尝试。
874///
875/// # Errors
876///
877/// - [`Error::BadMagic`]:不是 omy 文件
878/// - [`Error::UnsupportedVersion`]:版本过高
879/// - [`Error::Truncated`]:数据不足
880pub fn peek_header(data: &[u8]) -> Result<FixedHeader> {
881    FixedHeader::parse(data)
882}
883
884/// 判断字节流是否以 omy 主文件 magic 开头。
885///
886/// 只看前 8 字节,是扫描时最廉价的过滤手段。
887#[must_use]
888pub fn is_omy_file(data: &[u8]) -> bool {
889    data.get(..8).is_some_and(|m| m == crate::header::MAGIC_FILE)
890}
891
892/// 打开加密文件:解包 FEK 并验证头部完整性。
893///
894/// # 顺序不可调换
895///
896/// 严格按规范 §9:解包 FEK → **立即验证 MAC** → 再检查 CRITICAL TLV。
897/// 若在 MAC 验证前就依据头部字段解读 TLV 或载荷,攻击者可篡改字段诱导错误解读。
898///
899/// # Errors
900///
901/// - [`Error::NoMatchingSlot`]:没有 KEK 能解开任何 slot。**这不代表文件损坏**,
902///   也可能只是该文件不属于当前解锁的密码
903/// - [`Error::HeaderMacMismatch`]:文件被篡改
904/// - [`Error::UnknownCriticalTlv`]:存在无法理解的必需字段
905pub fn open(data: &[u8], keks: &[Kek]) -> Result<OpenedFile> {
906    let header = FixedHeader::parse(data)?;
907
908    let hlen = header.header_len as usize;
909    if data.len() < hlen {
910        return Err(Error::Truncated { context: "header", need: hlen, got: data.len() });
911    }
912
913    let slot_end = SLOT_AREA_OFFSET.saturating_add(SLOT_AREA_LEN);
914    let slot_area = data.get(SLOT_AREA_OFFSET..slot_end).ok_or(Error::Truncated {
915        context: "key slot area",
916        need: slot_end,
917        got: data.len(),
918    })?;
919
920    let tlv_end = TLV_AREA_OFFSET.saturating_add(header.tlv_len as usize);
921    let tlv_blob = data.get(TLV_AREA_OFFSET..tlv_end).ok_or(Error::Truncated {
922        context: "tlv area",
923        need: tlv_end,
924        got: data.len(),
925    })?;
926
927    let mac_end = tlv_end.saturating_add(HEADER_MAC_LEN);
928    let stored_mac = data.get(tlv_end..mac_end).ok_or(Error::Truncated {
929        context: "header mac",
930        need: mac_end,
931        got: data.len(),
932    })?;
933
934    // 步骤 5:解包 FEK
935    let (fek, kek_index, slot_index) =
936        unwrap_fek(slot_area, keks, &header.file_uuid, header.cipher_id)?;
937
938    // 步骤 6:立即验证 MAC,失败必须终止
939    let covered = data.get(..tlv_end).ok_or(Error::Truncated {
940        context: "mac-covered region",
941        need: tlv_end,
942        got: data.len(),
943    })?;
944    let mac_key = fek.derive_header_mac_key(&header.file_uuid);
945    let mut expected = [0u8; HEADER_MAC_LEN];
946    let src = stored_mac.get(..HEADER_MAC_LEN).ok_or(Error::Truncated {
947        context: "header mac",
948        need: HEADER_MAC_LEN,
949        got: stored_mac.len(),
950    })?;
951    expected.copy_from_slice(src);
952    if !verify_header_mac(&mac_key, covered, &expected) {
953        return Err(Error::HeaderMacMismatch);
954    }
955
956    // 步骤 7:MAC 已确认可信,现在可以安全解读 TLV
957    let tlvs = TlvSet::parse(tlv_blob)?;
958    tlvs.check_critical()?;
959
960    // 压缩文件必须带索引,否则无法定位块
961    if header.has_flag(flags::COMPRESSED) && tlvs.find(types::COMPRESSION_INDEX).is_none() {
962        return Err(Error::MissingTlv { tlv_type: types::COMPRESSION_INDEX });
963    }
964
965    let payload_key = fek.derive_payload_key(&header.file_uuid);
966    Ok(OpenedFile { header, tlvs, kek_index, slot_index, fek, payload_key })
967}
968
969/// 便捷函数:用单个密码打开文件。
970///
971/// # 性能警告
972///
973/// 每次调用都会跑一次 Argon2id(默认参数约 180 ms)。**批量扫描时不要用它**——
974/// 应先用 [`Kek::from_password`] 派生一次并缓存,再对每个文件调用 [`open`]。
975///
976/// # Errors
977///
978/// 同 [`open`],另加 Argon2 派生失败。
979pub fn open_with_password(data: &[u8], password: &[u8]) -> Result<OpenedFile> {
980    let header = FixedHeader::parse(data)?;
981    let params = Argon2Params {
982        m_kib: header.argon2_m_kib,
983        t: header.argon2_t,
984        p: header.argon2_p,
985    };
986    let kek = Kek::from_password(password, &header.vault_salt, params)?;
987    open(data, &[kek])
988}
989
990/// 固定头长度,便于调用方按需分批读取。
991pub const PEEK_SIZE: usize = FIXED_HEADER_LEN;