Skip to main content

omy_core/
header.rs

1//! 格式常量与 Fixed Header 编解码。
2//!
3//! 全部定义来自 `docs/research/02-file-format-spec.md` §2。
4//! **所有多字节整数为小端序**,唯一例外是载荷 nonce/AAD 中的块序号(大端序,见
5//! [`crate::crypto::chunk_nonce`])。
6
7use crate::crypto::CipherId;
8use crate::error::{Error, Result};
9use crate::util::{Cursor, Writer};
10
11/// 主文件 magic:`"OMYFILE"` + 世代号 `0x01`。
12pub const MAGIC_FILE: [u8; 8] = *b"OMYFILE\x01";
13/// 分片文件 magic:`"OMYSHRD"` + 世代号 `0x01`。
14pub const MAGIC_SHARD: [u8; 8] = *b"OMYSHRD\x01";
15
16/// 本实现支持的格式主版本上限。文件声明更高主版本时必须拒绝打开。
17pub const VERSION_MAJOR: u16 = 1;
18/// 本实现写出的次版本号。
19pub const VERSION_MINOR: u16 = 0;
20
21/// Fixed Header 长度。
22pub const FIXED_HEADER_LEN: usize = 96;
23/// 单个 key slot 长度(32 字节密文 + 16 字节 tag)。
24pub const SLOT_LEN: usize = 48;
25/// key slot 数量,固定为 8(可否认性依赖固定槽数)。
26pub const SLOT_COUNT: usize = 8;
27/// [`SLOT_COUNT`] 的 `u8` 形式,用于写入 header 的 `slot_count` 字段。
28pub const SLOT_COUNT_U8: u8 = 8;
29
30// 两者必须一致,否则写出的 header 与实际槽数不符
31const _: () = assert!(SLOT_COUNT == SLOT_COUNT_U8 as usize, "SLOT_COUNT 与其 u8 形式不一致");
32/// Key Slot Area 总长度。
33pub const SLOT_AREA_LEN: usize = SLOT_LEN * SLOT_COUNT;
34/// Key Slot Area 起始偏移。
35pub const SLOT_AREA_OFFSET: usize = FIXED_HEADER_LEN;
36/// TLV 区起始偏移。
37pub const TLV_AREA_OFFSET: usize = SLOT_AREA_OFFSET + SLOT_AREA_LEN;
38/// Header MAC 长度。
39pub const HEADER_MAC_LEN: usize = 32;
40
41/// 分片头长度。
42pub const SHARD_HEADER_LEN: usize = 56;
43
44/// 允许的最小分块大小(64 KiB)。
45pub const MIN_CHUNK_SIZE: u32 = 64 * 1024;
46/// 允许的最大分块大小(16 MiB)。
47pub const MAX_CHUNK_SIZE: u32 = 16 * 1024 * 1024;
48/// 默认分块大小(256 KiB),兼顾 seek 粒度与吞吐。
49pub const DEFAULT_CHUNK_SIZE: u32 = 256 * 1024;
50/// 移动端建议的分块大小上限(1 MiB)。
51pub const MOBILE_MAX_CHUNK_SIZE: u32 = 1024 * 1024;
52
53/// 文件名 padding 的桶大小。
54pub const FILENAME_BUCKET: usize = 64;
55
56/// 固定头中 `header_len` 字段的字节偏移。
57///
58/// 布局:`magic(8) + version_major(2) + version_minor(2)` = 12。
59///
60/// 扫描器需要在**不做完整解析**的前提下先取出 `header_len`,以便决定还要再读
61/// 多少字节(完整解析需要 slot 区之后的数据,此时还没读到)。因此这个偏移必须
62/// 作为公开常量暴露,而不是散落的魔数。
63pub const HEADER_LEN_FIELD_OFFSET: usize = 12;
64
65// 若固定头布局调整而此常量未同步,扫描器会读到错误的长度且不报错——用静态断言锁住
66const _: () = assert!(
67    HEADER_LEN_FIELD_OFFSET == MAGIC_FILE.len() + 2 + 2,
68    "HEADER_LEN_FIELD_OFFSET 必须等于 magic + version_major + version_minor 的长度"
69);
70
71/// 解析不可信输入时的健壮性上界(见 `docs/research/07-platform-and-sidechannels.md` §8.3)。
72///
73/// # 为什么这些上界必须存在
74///
75/// 头部里的长度字段来自**不可信的文件内容**。若不设上界,一个 12 字节的恶意头部
76/// 声明 `tlv_len = 0xFFFFFFFF` 就能让实现尝试分配 4 GiB 内存——无需任何有效密钥,
77/// 纯粹的拒绝服务。
78///
79/// # 这一组与 [`MIN_CHUNK_SIZE`] 那一组的区别
80///
81/// 本模块存在**两套**边界,用途完全不同,不可混用:
82///
83/// | 用途 | 常量 | 作用位置 |
84/// |---|---|---|
85/// | 解析上界(防 DoS) | `parse_limits::*` | 读取任何文件时,**必须**校验 |
86/// | 新建策略范围 | [`MIN_CHUNK_SIZE`]/[`MAX_CHUNK_SIZE`] | 仅创建文件时校验 |
87///
88/// 解析上界比策略范围宽松得多,这是刻意的:格式允许的取值范围应大于本实现推荐用户
89/// 选择的范围,否则会拒绝其它实现产出的合法文件(测试向量 v3/v4 即用 4096/2048
90/// 字节的块)。
91pub mod parse_limits {
92    /// `header_len` 上限:64 MiB。含缩略图的头部也远小于此值。
93    pub const MAX_HEADER_LEN: u32 = 64 * 1024 * 1024;
94    /// `tlv_len` 上限:32 MiB。
95    pub const MAX_TLV_LEN: u32 = 32 * 1024 * 1024;
96    /// TLV 条目数上限,防止大量零长度条目耗尽内存。
97    pub const MAX_TLV_ENTRIES: usize = 4096;
98    /// 压缩索引条目数上限。
99    pub const MAX_INDEX_ENTRIES: u32 = 16 * 1024 * 1024;
100    /// 解析时接受的最大分块大小:64 MiB。
101    ///
102    /// 这是**唯一**需要卡住的分块边界:过大的 `chunk_size` 会让单块解密
103    /// 一次性分配巨额内存。
104    pub const MAX_PARSE_CHUNK_SIZE: u32 = 64 * 1024 * 1024;
105    /// 容器模式目录条目数上限。
106    pub const MAX_FOLDER_ENTRIES: usize = 10_000_000;
107}
108
109/// KDF 标识:Argon2id。
110pub const KDF_ARGON2ID: u8 = 1;
111/// 压缩标识:不压缩。
112pub const COMPRESS_NONE: u8 = 0;
113/// 压缩标识:zstd。
114pub const COMPRESS_ZSTD: u8 = 1;
115
116/// `flags` 位定义(规范 §2.1)。
117pub mod flags {
118    /// TLV 中存有加密的原文件名。
119    pub const FILENAME_ENCRYPTED: u32 = 1 << 0;
120    /// 后缀以明文单独存放。
121    pub const EXT_PRESERVED: u32 = 1 << 1;
122    /// 载荷分块经 zstd 压缩,必须存在压缩索引表。
123    pub const COMPRESSED: u32 = 1 << 2;
124    /// 本文件是分片集合的一部分。
125    pub const SHARDED: u32 = 1 << 3;
126    /// TLV 中存有加密缩略图。
127    pub const HAS_THUMBNAIL: u32 = 1 << 4;
128    /// 容器模式(整个文件夹打包)。
129    pub const CONTAINER: u32 = 1 << 5;
130    /// 伪装模式,真实 header 不在文件起始处。
131    pub const DISGUISED: u32 = 1 << 6;
132    /// 载荷非原始字节(转码过),无法 bit-for-bit 还原。
133    pub const TRANSCODED: u32 = 1 << 7;
134    /// 时间戳等元数据已被抹平。
135    pub const META_NORMALIZED: u32 = 1 << 8;
136    /// 载荷尾部有随机填充以隐藏真实大小。
137    pub const SIZE_PADDED: u32 = 1 << 9;
138    /// 槽位可管理模式:TLV 中存有加密的槽位目录(规范 §3.5)。
139    ///
140    /// 用 flag 位而不抬 `version_major`:flags 被头部 MAC 覆盖,篡改必被
141    /// 检出,防降级效果与版本号相同;而抬主版本会让既有实现直接拒绝打开
142    /// 新文件,5 组测试向量也要全部重做,换不来额外安全性。
143    ///
144    /// 老实现读到本位会忽略它,但 `TLV_SLOT_DIRECTORY` 标了 CRITICAL,
145    /// 所以它仍会如实拒绝,不会把可管理模式的文件当普通文件误改。
146    pub const SLOT_DIRECTORY: u32 = 1 << 10;
147
148    /// 本实现已知的全部位。其余位保留,写入时必须为 0。
149    pub const KNOWN_MASK: u32 = FILENAME_ENCRYPTED
150        | EXT_PRESERVED
151        | COMPRESSED
152        | SHARDED
153        | HAS_THUMBNAIL
154        | CONTAINER
155        | DISGUISED
156        | TRANSCODED
157        | META_NORMALIZED
158        | SIZE_PADDED
159        | SLOT_DIRECTORY;
160}
161
162/// Fixed Header(96 字节)。
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct FixedHeader {
165    /// 格式主版本。
166    pub version_major: u16,
167    /// 格式次版本。
168    pub version_minor: u16,
169    /// 从文件起始到载荷起始的总字节数。
170    pub header_len: u32,
171    /// 文件唯一标识,用于 nonce 派生、AAD 绑定、分片归属。
172    pub file_uuid: [u8; 16],
173    /// Argon2id 盐,同一 vault 内所有文件相同。
174    pub vault_salt: [u8; 16],
175    /// 位标志,见 [`flags`]。
176    pub flags: u32,
177    /// AEAD 算法。
178    pub cipher_id: CipherId,
179    /// KDF 算法,当前仅 Argon2id。
180    pub kdf_id: u8,
181    /// 压缩算法。
182    pub compress_id: u8,
183    /// key slot 数量,固定为 8。
184    pub slot_count: u8,
185    /// Argon2 内存成本(KiB)。
186    pub argon2_m_kib: u32,
187    /// Argon2 迭代次数。
188    pub argon2_t: u32,
189    /// Argon2 并行度。
190    pub argon2_p: u32,
191    /// 明文分块大小。
192    pub chunk_size: u32,
193    /// 原始明文总长度。
194    pub plaintext_size: u64,
195    /// 随机 nonce 前缀,与块序号组合成完整 nonce。
196    pub base_nonce: [u8; 7],
197    /// 保留给未来的就地编辑功能,当前必须为 0。
198    pub chunk_version: u8,
199    /// TLV 区总字节数。
200    pub tlv_len: u32,
201}
202
203impl FixedHeader {
204    /// 解析 Fixed Header。
205    ///
206    /// 按规范 §9 步骤 1–3 的顺序校验:先 magic,再版本,最后字段一致性。
207    /// 版本检查必须在字段解析**之前**完成——否则可能按错误的布局解读未来版本的头部。
208    ///
209    /// # Errors
210    ///
211    /// - [`Error::Truncated`]:输入不足 96 字节
212    /// - [`Error::BadMagic`]:magic 不匹配
213    /// - [`Error::UnsupportedVersion`]:主版本高于本实现
214    /// - [`Error::MalformedHeader`]:字段取值非法
215    pub fn parse(data: &[u8]) -> Result<Self> {
216        if data.len() < FIXED_HEADER_LEN {
217            return Err(Error::Truncated {
218                context: "fixed header",
219                need: FIXED_HEADER_LEN,
220                got: data.len(),
221            });
222        }
223        let mut c = Cursor::new(data, "fixed header");
224
225        let magic: [u8; 8] = c.take_array()?;
226        if magic != MAGIC_FILE {
227            return Err(Error::BadMagic { got: magic });
228        }
229
230        let version_major = c.u16le()?;
231        let version_minor = c.u16le()?;
232        if version_major > VERSION_MAJOR {
233            return Err(Error::UnsupportedVersion {
234                found_major: version_major,
235                found_minor: version_minor,
236                supported_major: VERSION_MAJOR,
237            });
238        }
239
240        let header_len = c.u32le()?;
241        let file_uuid: [u8; 16] = c.take_array()?;
242        let vault_salt: [u8; 16] = c.take_array()?;
243        let flags = c.u32le()?;
244        let cipher_id = CipherId::from_u8(c.u8()?)?;
245        let kdf_id = c.u8()?;
246        let compress_id = c.u8()?;
247        let slot_count = c.u8()?;
248        let argon2_m_kib = c.u32le()?;
249        let argon2_t = c.u32le()?;
250        let argon2_p = c.u32le()?;
251        let chunk_size = c.u32le()?;
252        let plaintext_size = c.u64le()?;
253        let base_nonce: [u8; 7] = c.take_array()?;
254        let chunk_version = c.u8()?;
255        let tlv_len = c.u32le()?;
256        let _reserved = c.u32le()?; // 规范要求读取时忽略
257
258        debug_assert_eq!(c.position(), FIXED_HEADER_LEN, "fixed header 布局与规范不符");
259
260        let h = Self {
261            version_major,
262            version_minor,
263            header_len,
264            file_uuid,
265            vault_salt,
266            flags,
267            cipher_id,
268            kdf_id,
269            compress_id,
270            slot_count,
271            argon2_m_kib,
272            argon2_t,
273            argon2_p,
274            chunk_size,
275            plaintext_size,
276            base_nonce,
277            chunk_version,
278            tlv_len,
279        };
280        h.validate()?;
281        Ok(h)
282    }
283
284    /// 校验字段自洽性与解析上界。
285    ///
286    /// 上界校验在**分配任何内存之前**完成,这样恶意头部无法通过声明巨大长度
287    /// 触发大额分配(见 `docs/research/07-platform-and-sidechannels.md` §8.3)。
288    ///
289    /// # Errors
290    ///
291    /// 任一字段非法、超出解析上界,或字段间矛盾时返回 [`Error::MalformedHeader`]。
292    pub fn validate(&self) -> Result<()> {
293        if self.slot_count as usize != SLOT_COUNT {
294            return Err(Error::MalformedHeader { reason: "slot_count must be 8" });
295        }
296        if self.kdf_id != KDF_ARGON2ID {
297            return Err(Error::MalformedHeader { reason: "unknown kdf_id" });
298        }
299        if self.compress_id != COMPRESS_NONE && self.compress_id != COMPRESS_ZSTD {
300            return Err(Error::MalformedHeader { reason: "unknown compress_id" });
301        }
302        if self.chunk_version != 0 {
303            return Err(Error::MalformedHeader { reason: "chunk_version must be 0 in v1" });
304        }
305
306        // ---- 解析上界:必须在任何内存分配之前拒绝 ----
307        if self.header_len > parse_limits::MAX_HEADER_LEN {
308            return Err(Error::MalformedHeader {
309                reason: "header_len exceeds the 64 MiB parse limit",
310            });
311        }
312        if self.tlv_len > parse_limits::MAX_TLV_LEN {
313            return Err(Error::MalformedHeader {
314                reason: "tlv_len exceeds the 32 MiB parse limit",
315            });
316        }
317        // chunk_size 只卡上限,不卡下限。
318        //
319        // 设计文档 §8.3 曾建议 MIN_CHUNK_SIZE = 4096,但测试向量 v4 使用 2048 字节块,
320        // v3 使用 4096。向量由参考实现产出且已验证,是可执行的事实;而小块**不构成
321        // DoS 风险**(只会让文件略大),真正危险的是过大的 chunk_size 导致单块解密
322        // 一次性分配巨额内存。因此这里只拒绝过大值和零值。
323        if self.chunk_size == 0 {
324            return Err(Error::MalformedHeader { reason: "chunk_size must not be zero" });
325        }
326        if self.chunk_size > parse_limits::MAX_PARSE_CHUNK_SIZE {
327            return Err(Error::MalformedHeader {
328                reason: "chunk_size exceeds the 64 MiB parse limit",
329            });
330        }
331
332        // header_len 必须严格等于 96 + 384 + tlv_len + 32
333        let expect_len = TLV_AREA_OFFSET
334            .checked_add(self.tlv_len as usize)
335            .and_then(|v| v.checked_add(HEADER_MAC_LEN))
336            .ok_or(Error::MalformedHeader { reason: "tlv_len overflows header_len" })?;
337        if (self.header_len as usize) != expect_len {
338            return Err(Error::MalformedHeader {
339                reason: "header_len does not match 96 + 384 + tlv_len + 32",
340            });
341        }
342
343        // plaintext_size 与 chunk_size 组合出的块数不得超过 u32
344        // (nonce 中的块序号是 u32be,越界会导致 nonce 复用)
345        let n = self.n_chunks();
346        if n > u64::from(u32::MAX) {
347            return Err(Error::MalformedHeader {
348                reason: "chunk count exceeds u32; nonce space would be exhausted",
349            });
350        }
351
352        // COMPRESSED 与 compress_id 必须一致,否则会按错误方式解读载荷
353        let compressed_flag = self.flags & flags::COMPRESSED != 0;
354        if compressed_flag != (self.compress_id == COMPRESS_ZSTD) {
355            return Err(Error::MalformedHeader {
356                reason: "COMPRESSED flag contradicts compress_id",
357            });
358        }
359        Ok(())
360    }
361
362    /// 序列化为 96 字节。
363    ///
364    /// # Errors
365    ///
366    /// 字段非法时返回 [`Error::MalformedHeader`]。
367    pub fn to_bytes(&self) -> Result<[u8; FIXED_HEADER_LEN]> {
368        self.validate()?;
369        let mut w = Writer::with_capacity(FIXED_HEADER_LEN);
370        w.bytes(&MAGIC_FILE)
371            .u16le(self.version_major)
372            .u16le(self.version_minor)
373            .u32le(self.header_len)
374            .bytes(&self.file_uuid)
375            .bytes(&self.vault_salt)
376            .u32le(self.flags)
377            .u8(self.cipher_id as u8)
378            .u8(self.kdf_id)
379            .u8(self.compress_id)
380            .u8(self.slot_count)
381            .u32le(self.argon2_m_kib)
382            .u32le(self.argon2_t)
383            .u32le(self.argon2_p)
384            .u32le(self.chunk_size)
385            .u64le(self.plaintext_size)
386            .bytes(&self.base_nonce)
387            .u8(self.chunk_version)
388            .u32le(self.tlv_len)
389            .u32le(0); // reserved
390
391        let v = w.into_vec();
392        let mut out = [0u8; FIXED_HEADER_LEN];
393        let src = v.get(..FIXED_HEADER_LEN).ok_or(Error::MalformedHeader {
394            reason: "serialized fixed header shorter than 96 bytes",
395        })?;
396        out.copy_from_slice(src);
397        Ok(out)
398    }
399
400    /// 载荷块总数。空文件也有 1 块。
401    #[must_use]
402    pub const fn n_chunks(&self) -> u64 {
403        let cs = self.chunk_size as u64;
404        if cs == 0 {
405            return 1;
406        }
407        let n = self.plaintext_size.div_ceil(cs);
408        if n == 0 { 1 } else { n }
409    }
410
411    /// 是否置位了指定 flag。
412    #[must_use]
413    pub const fn has_flag(&self, flag: u32) -> bool {
414        self.flags & flag != 0
415    }
416
417    /// 头部记录的 Argon2 参数。
418    ///
419    /// 解密时必须用**文件里记录的**参数派生 KEK,不能用当前的默认档位——
420    /// 文件可能是用别的档位加密的,用错参数会得到完全不同的 KEK。
421    #[must_use]
422    pub const fn argon2_params(&self) -> crate::crypto::Argon2Params {
423        crate::crypto::Argon2Params {
424            m_kib: self.argon2_m_kib,
425            t: self.argon2_t,
426            p: self.argon2_p,
427        }
428    }
429
430    /// 未压缩载荷中第 `index` 块密文的 `(偏移, 长度)`。
431    ///
432    /// 这是视频任意 seek 的数学基础:无需索引表,O(1) 定位。
433    ///
434    /// ```text
435    /// ct_offset(i) = header_len + i × (chunk_size + 16)
436    /// ct_len(i)    = min(chunk_size, plaintext_size - i × chunk_size) + 16
437    /// ```
438    ///
439    /// # Errors
440    ///
441    /// - [`Error::MalformedHeader`]:文件是压缩的(此时公式不适用,须查索引表)
442    /// - [`Error::ChunkOutOfRange`]:`index` 超出总块数
443    pub fn chunk_ct_range(&self, index: u64) -> Result<(u64, u64)> {
444        if self.has_flag(flags::COMPRESSED) {
445            return Err(Error::MalformedHeader {
446                reason: "chunk_ct_range is invalid for compressed payloads; use the index table",
447            });
448        }
449        let total = self.n_chunks();
450        if index >= total {
451            return Err(Error::ChunkOutOfRange { index, total });
452        }
453        let cs = u64::from(self.chunk_size);
454        let tag = crate::crypto::TAG_LEN as u64;
455
456        let stride = cs
457            .checked_add(tag)
458            .ok_or(Error::MalformedHeader { reason: "chunk stride overflow" })?;
459        let ct_offset = u64::from(self.header_len)
460            .checked_add(index.checked_mul(stride).ok_or(Error::MalformedHeader {
461                reason: "chunk offset overflow",
462            })?)
463            .ok_or(Error::MalformedHeader { reason: "chunk offset overflow" })?;
464
465        let consumed = index
466            .checked_mul(cs)
467            .ok_or(Error::MalformedHeader { reason: "chunk offset overflow" })?;
468        let plain_len = self.plaintext_size.saturating_sub(consumed).min(cs);
469        let ct_len = plain_len
470            .checked_add(tag)
471            .ok_or(Error::MalformedHeader { reason: "chunk length overflow" })?;
472
473        Ok((ct_offset, ct_len))
474    }
475
476    /// 明文偏移所属的块序号。
477    ///
478    /// `chunk_size` 为 0 时返回 0。正常文件不会出现该值([`Self::validate`] 已拒绝),
479    /// 这里只是避免除零 panic。
480    #[must_use]
481    pub const fn chunk_index_of(&self, plain_offset: u64) -> u64 {
482        match plain_offset.checked_div(self.chunk_size as u64) {
483            Some(v) => v,
484            None => 0,
485        }
486    }
487}
488
489/// 校验分块大小落在**新建文件**的推荐范围内。
490///
491/// # 这是策略校验,不是格式校验
492///
493/// 只应在**创建新文件**时调用,用于阻止用户选择不合理的块大小。
494///
495/// **读取路径绝不能调用它**:格式本身只要求 `chunk_size` 非零,
496/// 小于 [`MIN_CHUNK_SIZE`] 的文件在格式上完全合法(测试向量 v3/v4 就用了
497/// 4096 与 2048 字节的块)。若在解析时强加此范围,实现将无法读取这些合法文件。
498/// 格式层的自洽性校验在 [`FixedHeader::validate`] 中完成。
499///
500/// # Errors
501///
502/// 超出 [`MIN_CHUNK_SIZE`]–[`MAX_CHUNK_SIZE`] 时返回 [`Error::InvalidChunkSize`]。
503pub const fn validate_chunk_size(chunk_size: u32) -> Result<()> {
504    if chunk_size < MIN_CHUNK_SIZE || chunk_size > MAX_CHUNK_SIZE {
505        return Err(Error::InvalidChunkSize {
506            got: chunk_size,
507            min: MIN_CHUNK_SIZE,
508            max: MAX_CHUNK_SIZE,
509        });
510    }
511    Ok(())
512}