#archive-format #file-format #layer #key #block-size #encryption #data

mla

多层归档 - 一个纯Rust加密和压缩的归档文件格式

7个稳定版本

1.4.0 2023年10月2日
1.3.0 2022年9月1日
1.2.0 2021年9月28日
1.1.1 2021年5月31日
1.0.0 2020年7月24日

#166 in 加密学

Download history 185/week @ 2024-04-20 15/week @ 2024-04-27 7/week @ 2024-05-04 377/week @ 2024-05-11 231/week @ 2024-05-18 150/week @ 2024-05-25 171/week @ 2024-06-01 87/week @ 2024-06-08 149/week @ 2024-06-15 170/week @ 2024-06-22 236/week @ 2024-06-29 165/week @ 2024-07-06 199/week @ 2024-07-13 116/week @ 2024-07-20 157/week @ 2024-07-27 98/week @ 2024-08-03

603次每月下载
用于 2 crates

LGPL-3.0-only

250KB
4.5K SLoC

Build & test Cargo MLA Documentation MLA Cargo Curve25519-Parser Documentation Curve25519-Parser Cargo MLAR

多层归档 (MLA)

MLA是一个具有以下特性的归档文件格式

  • 支持压缩(基于 rust-brotli
  • 支持使用非对称密钥进行认证加密(基于Rust-Crypto的AES256-GCM和Curve25519上的ECIES方案,基于Rust-Crypto aes-ctrDalekCryptography x25519-dalek
  • 高效、架构无关且可移植(完全使用Rust编写)
  • 归档创建过程中的内存占用小
  • 可流式创建归档
    • 即使在数据二极管上也可以构建归档
    • 可以通过数据块添加文件,而不必最初知道最终大小
    • 文件块可以交错(可以添加文件的开始部分,开始第二个文件,然后继续添加第一个文件的各部分)
  • 归档文件可寻址,即使在压缩或加密的情况下也是如此。可以在归档的中间访问文件,而无需从开头读取
  • 如果被截断,归档可以修复。仍然在归档中的文件以及丢失末尾的文件的开始部分将被恢复
  • 相对较少出现错误,特别是在解析不受信任的归档时(Rust安全性)

仓库

此仓库包含

  • mla:实现MLA读取器和编写器的Rust库
  • mlar:用于常见操作的Rust实用工具(创建、列表、提取等)
  • curve25519-parser:一个用于解析DER/PEM公钥和私钥以及X25519密钥的Rust库(由openssl生成)
  • mla-fuzz-afl:用于模糊测试mla的Rust实用工具
  • bindings:其他语言的绑定
  • .github:持续集成需求

快速命令行使用

以下是一些使用mlar处理MLA格式归档的命令。

# Generate an X25519 key pair {key, key.pub} (OpenSSL could also be used)
mlar keygen key

# Create an archive with some files, using the public key
mlar create -p key.pub -o my_archive.mla /etc/os-release /etc/issue

# List the content of the archive, using the private key
mlar list -k key -i my_archive.mla

# Extract the content of the archive into a new directory
# In this example, this creates two files:
# extracted_content/etc/issue and extracted_content/etc/os-release
mlar extract -k key -i my_archive.mla -o extracted_content

# Display the content of a file in the archive
mlar cat -k key -i my_archive.mla /etc/os-release

# Convert the archive to a long-term one, removing encryption and using the best
# and slower compression level
mlar convert -k key -i my_archive.mla -o longterm.mla -l compress -q 11

# Create an archive with multiple recipient
mlar create -p archive.pub -p client1.pub -o my_archive.mla ...

mlar可以通过以下方式获取

  • 通过Cargo: cargo install mlar
  • 使用支持操作系统的最新版本

快速API使用

  • 创建一个具有压缩和加密的存档
use curve25519_parser::parse_openssl_25519_pubkey;
use mla::config::ArchiveWriterConfig;
use mla::ArchiveWriter;

const PUB_KEY: &[u8] = include_bytes!("samples/test_x25519_pub.pem");

fn main() {
    // Load the needed public key
    let public_key = parse_openssl_25519_pubkey(PUB_KEY).unwrap();

    // Create an MLA Archive - Output only needs the Write trait
    let mut buf = Vec::new();
    // Default is Compression + Encryption, to avoid mistakes
    let mut config = ArchiveWriterConfig::default();
    // The use of multiple public keys is supported
    config.add_public_keys(&vec![public_key]);
    // Create the Writer
    let mut mla = ArchiveWriter::from_config(&mut buf, config).unwrap();

    // Add a file
    mla.add_file("filename", 4, &[0, 1, 2, 3][..]).unwrap();

    // Complete the archive
    mla.finalize().unwrap();
}
  • 部分添加文件,以“并发”的方式
...
// A file is tracked by an id, and follows this API's call order:
// 1. id = start_file(filename);
// 2. append_file_content(id, content length, content (impl Read))
// 2-bis. repeat 2.
// 3. end_file(id)

// Start a file and add content
let id_file1 = mla.start_file("fname1").unwrap();
mla.append_file_content(id_file1, file1_part1.len() as u64, file1_part1.as_slice()).unwrap();
// Start a second file and add content
let id_file2 = mla.start_file("fname2").unwrap();
mla.append_file_content(id_file2, file2_part1.len() as u64, file2_part1.as_slice()).unwrap();
// Add a file as a whole
mla.add_file("fname3", file3.len() as u64, file3.as_slice()).unwrap();
// Add new content to the first file
mla.append_file_content(id_file1, file1_part2.len() as u64, file1_part2.as_slice()).unwrap();
// Mark still opened files as finished
mla.end_file(id_file1).unwrap();
mla.end_file(id_file2).unwrap();
  • 从存档中读取文件
use curve25519_parser::parse_openssl_25519_privkey;
use mla::config::ArchiveReaderConfig;
use mla::ArchiveReader;
use std::io;

const PRIV_KEY: &[u8] = include_bytes!("samples/test_x25519_archive_v1.pem");
const DATA: &[u8] = include_bytes!("samples/archive_v1.mla");

fn main() {
    // Get the private key
    let private_key = parse_openssl_25519_privkey(PRIV_KEY).unwrap();

    // Specify the key for the Reader
    let mut config = ArchiveReaderConfig::new();
    config.add_private_keys(&[private_key]);

    // Read from buf, which needs Read + Seek
    let buf = io::Cursor::new(DATA);
    let mut mla_read = ArchiveReader::from_config(buf, config).unwrap();

    // Get a file
    let mut file = mla_read
        .get_file("simple".to_string())
        .unwrap() // An error can be raised (I/O, decryption, etc.)
        .unwrap(); // Option(file), as the file might not exist in the archive

    // Get back its filename, size, and data
    println!("{} ({} bytes)", file.filename, file.size);
    let mut output = Vec::new();
    std::io::copy(&mut file.data, &mut output).unwrap();

    // Get back the list of files in the archive:
    for fname in mla_read.list_files().unwrap() {
        println!("{}", fname);
    }
}

⚠️ 文件名是String,可能包含路径分隔符(/\..等)。请在使用API时考虑这一点,以避免路径遍历问题。

用其他语言使用MLA

以下语言有绑定可用

设计

正如其名所示,MLA存档由多个独立层组成。以下部分介绍了MLA背后的设计理念。请参阅FORMAT.md以获取更正式的描述。

每一层都充当一个Unix PIPE,从输入接收字节,并在下一层输出。层由以下部分组成

  • Writer,实现Write特质。它负责在创建新存档时发出字节
  • Reader,实现ReadSeek特质。它负责在读取存档时读取字节
  • FailSafeReader,仅实现Read特质。它负责在修复存档时读取字节

层考虑了可修复性属性。读取它们时永远不需要从页脚获取信息,但可以使用页脚来优化读取。例如,可以使用页脚定位存档内的文件以进行优化,但仍可以通过读取整个存档直到找到文件来获取信息。

层是可选的,但它们的顺序是强制性的。用户可以选择启用或禁用它们。当前顺序如下

  1. 文件存储抽象(不是层)
  2. 原始层(强制)
  3. 压缩层
  4. 加密层
  5. 位置层(强制)
  6. 存储的字节

概述

+----------------+-------------------------------------------------------------------------------------------------------------+
| Archive Header |                                                                                                             | => Final container (File / Buffer / etc.)
+------------------------------------------------------------------------------------------------------------------------------+
                 +-------------------------------------------------------------------------------------------------------------+
                 |                                                                                                             | => Raw layer
                 +-------------------------------------------------------------------------------------------------------------+
                 +-----------+---------+------+---------+------+---------------------------------------------------------------+
                 | E. header | Block 1 | TAG1 | Block 2 | TAG2 | Block 3 | TAG3 | ...                                          | => Encryption layer
                 +-----------+---------+------+---------+------+---------------------------------------------------------------+
                             |         |      |         |      |         |      |                                              |
                             +-------+--      --+-------       -----------      ----+---------+------+---------+ +-------------+
                             | Blk 1 |          | Blk 2                             | Block 3 | ...  | Block n | |    Footer   | => Compression Layer
                             +-------+--      --+-------       -----------      ----+---------+------+---------+ +-------------+
                            /         \                                                             /           \
                           /           \                                                           /             \
                          /             \                                                         /               \
                         +-----------------------------------------------------------------------------------------+
                         |                                                                                         |             => Position layer
                         +-----------------------------------------------------------------------------------------+
                         +-------------+-------------+-------------+-------------+-----------+-------+-------------+
                         | File1 start | File1 data1 | File2 start | File1 data2 | File1 end |  ...  | Files index |             => Files information and content
                         +-------------+-------------+-------------+-------------+-----------+-------+-------------+

层描述

原始层

RawLayer*中实现(即RawLayerWriterRawLayerReaderRawLayerFailSafeReader)。

这是最简单的层。它需要提供层和最终输出世界之间的API。它还用于保持数据的起始位置。

位置层

PositionLayer*中实现。

类似于RawLayer,这是一个非常简单、实用的层。它跟踪已写入子层的字节数。

例如,它需要由文件存储层来跟踪文件流中的位置,用于索引。

加密层

EncryptionLayer*中实现。

此层使用对称认证加密与关联数据(AEAD)算法AES-GCM 256加密数据,并使用基于Curve25519的ECIES方案加密对称密钥。

ECIES方案扩展以支持多个公钥:首先生成一个公钥,然后使用该公钥与n个用户的公钥进行n次Diffie-Hellman交换。生成的公钥也记录在头信息中(以便用户重放DH交换)。根据ECIES推导后,我们得到n个密钥。然后使用这些密钥来加密一个公共密钥k,并将生成的n个密文存储在层头信息中。这个密钥k将用于归档的对称加密。

除了密钥外,每个归档还会生成一个nonce(8字节)。使用固定的关联数据。

生成过程使用来自crate randOsRng,它使用crate getrandom中的getrandom函数。 getrandom为许多系统提供实现,列表在此。在Linux上,它使用getrandom系统调用,并在/dev/urandom上回退。在Windows上,它使用RtlGenRandom API(自Windows XP/Windows Server 2003以来可用)。

为了“宁可信其有,不可信其无”,使用从OsRng生成的字节对ChaChaRng进行初始化,以构建一个CSPRNG(密码学安全的伪随机数生成器)。这个ChaChaRng提供了用于密钥和nonce生成的实际字节。

层数据由多个加密块组成,每个块的大小固定,除了最后一个块。每个块使用包括基本nonce和计数器的IV进行加密。这种结构与STREAM类似,除了last_block位。选择不使用它,因为

  • 在编写本文时,归档编写器不知道当前块是最后一个块。因此,它无法使用特定的IV。为了解决这个问题,必须在末尾添加一个虚拟的尾部块,导致最后一块检测的额外复杂性
  • 在STREAM中,使用last_block位来防止未检测到的截断。在MLA中,EndOfArchiveData标签在文件层级别已经承担了这个角色

因此,为了在给定位置进行搜索和读取,层将解密包含该位置的块,并在返回解密数据之前验证该标签。

作者决定使用椭圆曲线加密RSA,因为

  • 在编写本文时,尚未找到可投入生产的基于Rust的库
  • 存在一个经过安全审计的Curve25519 Rust库
  • Curve25519被广泛使用,并符合多个标准
  • 常见的论点,例如Trail of bits的论点

AES-GCM被使用,因为它是最常用的AEAD算法之一,使用它避免了整个类别的攻击。此外,它使我们能够依赖硬件加速(如AES-NI)以保持合理的性能。

已审查外部加密库

压缩层

CompressionLayer中实现。

该层基于Brotli压缩算法(RFC 7932)。每4MB的明文数据存储在一个单独压缩的数据块中。

该算法与大小为1的窗口一起使用,能够读取每个数据块,并在获取4MB的明文后停止。然后它被重置,并开始解压下一个数据块。

为了加快解压速度,并使层可搜索,使用了页脚。它保存压缩大小。知道解压大小后,可以通过跳转到正确的压缩块开头来在明文位置进行搜索,然后解压前几个字节,直到达到所需位置。

页脚还用于允许更宽的窗口,从而加快解压速度。最后,它还记录了最后一个数据块的大小,以计算压缩数据与页脚之间的边界。

4MB大小是在更好的压缩(较高值)和更快的搜索(较小值)之间进行权衡的结果。它是在对代表数据进行基准测试的基础上选择的。通过将压缩质量参数设置为较高的值(导致速度较慢的过程)也可以实现更好的压缩。

文件存储

文件以一系列归档文件块的形式保存。第一种特殊类型的块指示文件的开始,包括其文件名和文件ID。第二种特殊类型的块指示当前文件的结束。

块包含文件数据,前缀为当前块大小和相应的文件ID。即使格式可以处理流式文件,在写入之前也必须知道文件块的大小。文件ID允许不同文件的块交错。

文件结束块标记了给定文件的结束,并包含其完整内容的SHA256。因此,即使在修复操作中,也可以检查文件的完整性。

层页脚包含每个文件的大小、其结束块偏移量和其块位置的索引。块位置索引允许直接访问。结束块偏移量允许快速获取哈希,而文件大小有助于将数据转换为需要在数据之前包含文件大小的格式,如Tar。

如果此页脚不可用,则从开头读取归档以恢复文件信息。

API指南

归档格式为每个文件提供

  • 一个文件名,它是一个Unicode字符串
  • 数据,它是一系列字节

还计算了一些元数据,例如

  • 文件大小
  • 内容的SHA256哈希

没有额外的元数据(权限、所有权等),并且除非给出强有力的论据,否则可能不会添加。目标是保持文件格式尽可能简单,并将复杂性留给使用它的代码。例如,权限、所有权等在多个操作系统和文件系统上难以保证;并导致更高的复杂性,例如在tar中。出于同样的原因,/\在文件名中没有任何意义;用户自己决定如何处理它们(是否有命名空间?Windows风格的目录等)。

如果仍然想为其用例具有关联的元数据,推荐的方法是在归档中嵌入包含所需元数据的额外文件。

此外,预计文件格式在未来会略有变化,以保持更简单的向后兼容性,或者至少版本转换和简单支持。

库提供的API非常简单

  • 添加文件
  • 开始/添加文件块/结束
  • 列出归档中的文件(无序)
  • 获取文件
  • 获取文件哈希值

由于可能需要更具体的API,因此提供了辅助函数,位于mla::helpers中,例如

  • StreamWriter:在ArchiveWriter文件上提供Write接口(在不知道文件块大小时可以使用,例如与io::copy一起使用)
  • linear_extract:线性提取归档。提取整个归档的更快方式,通过减少昂贵的seek操作数量

是否真的需要新的格式?

由于现有的归档格式众多,可能并不需要。

但据作者所知,其中没有一个支持上述功能(但当然,更适合其他目的)。

例如(根据作者的见解)

  • tar格式在添加文件之前需要知道文件大小,并且不可随机访问
  • zip格式如果移除尾部可能会丢失关于文件的信息
  • 7zip格式在向其中添加文件时需要重新构建整个归档(不可流式传输)。它也很复杂,因此在解包未知归档时难以审计/信任
  • journald格式不可流式传输。此外,这里不需要一个写者/多个读者,从而释放了journald格式的一些限制
  • 任何归档 + age:可以将age与归档格式一起使用以提供加密,但可能缺乏与内部归档格式的集成
  • 备份格式通常写入以避免重复等问题,因此它们需要保留更大的结构在内存中,或者它们不是流式传输的

调整这些格式可能会产生类似的功能。选择保持对格式能力的更好控制,并尝试遵循KISS原则。

测试

仓库包含

  • 单元测试(针对mlacurve25519-parser),分别测试预期行为
  • 集成测试(针对mlar),测试常见场景,例如create->list->to-tarcreate->truncate->repair
  • 基准测试场景(针对mla
  • AFL场景(针对mla
  • 一个已提交的格式为v1的归档,以确保随时间推移的向后可读性

性能

可以通过嵌入式基准测试来评估性能,基于Criterion

已嵌入几个场景,例如

  • 文件添加,具有不同的大小和层配置
  • 文件添加,压缩质量不同
  • 文件读取,具有不同的大小和层配置
  • 随机文件读取,具有不同的大小和层配置
  • 线性归档提取,具有不同的大小和层配置

在"Intel(R) Core(TM) i5-7200U CPU @ 2.50GHz"上

$ cd mla/
$ cargo bench
...
multiple_layers_multiple_block_size/Layers ENCRYPT | COMPRESS | DEFAULT/1048576                                                                           
                        time:   [28.091 ms 28.259 ms 28.434 ms]
                        thrpt:  [35.170 MiB/s 35.388 MiB/s 35.598 MiB/s]
...
chunk_size_decompress_mutilfiles_random/Layers ENCRYPT | COMPRESS | DEFAULT/4194304                                                                          
                        time:   [126.46 ms 129.54 ms 133.42 ms]
                        thrpt:  [29.980 MiB/s 30.878 MiB/s 31.630 MiB/s]
...
linear_vs_normal_extract/LINEAR / Layers DEBUG | EMPTY/2097152                        
                        time:   [145.19 us 150.13 us 153.69 us]
                        thrpt:  [12.708 GiB/s 13.010 GiB/s 13.453 GiB/s]
...

Criterion.rs文档解释了如何获取HTML报告、比较结果等。

AES-NI扩展在支持的架构的编译工具链中已启用,这导致了加密层性能的大幅提升,尤其是在读取操作中。由于crate aesni静态地启用了它,如果用户的架构不支持它,可能会导致错误。它可以在编译时禁用,或者在.cargo/config中注释相关部分。

模糊测试

使用afl.rs制作的模糊测试场景在mla-fuzz-afl中可用。该场景能够

  • 创建带有交错文件和不同层启用的存档
  • 读取它们以检查其内容
  • 无截断地修复存档,并验证它
  • 修改存档的原始数据,并确保读取它不会引发恐慌(只会失败)
  • 修复修改后的存档,并确保恢复不会失败(只报告检测到的错误)

要启动它

  1. mla-fuzz-afl/src/main.rs中取消注释produce_samples()以生成初始样本
cd mla-fuzz-afl
# ... uncomment `produces_samples()` ...
mkdir in
mkdir out
cargo run
  1. 构建和启动AFL
cargo afl build
cargo afl run -i in -o out ../target/debug/mla-fuzz-afl

如果您发现了崩溃,尝试使用以下方法重放它们

  • AFL的秘鲁兔模式:cargo afl run -i - -o out -C ../target/debug/mla-fuzz-afl
  • 直接重放:../target/debug/mla-fuzz-afl < out/crashes/crash_id
  • 调试:在mla-fuzz-afl/src/main.rs中取消注释“重放样本”部分,并在需要时添加dbg!()

⚠️ 稳定性相当低,可能是由于场景使用的进程(从AFL提供的数据反序列化)以及内部算法(如brotli)的变异性。如果有的话,崩溃可能无法重现或由于mla-fuzz-afl内部工作较为复杂(因此可能存在bug)。可以通过在mla-fuzz-afl/src/main.rs中注释不相关部分来确保更好的体验。

常见问题解答(FAQ)

是否有MLAArchiveWriterSend类型?

默认情况下,MLAArchiveWriter不是Send类型。如果内部可写类型也是Send,则可以在Cargo.toml中启用mlasend功能,如下所示:

[dependencies]
mla = { version = "...", default-features = false, features = ["send"]}

如何确定性地生成一个密钥对?

可以使用mlar keygen--seed选项来确定性地生成一个密钥对。例如,它可以用于可重复测试或安全地存档密钥。

⚠️ 不建议使用种子,除非知道为什么这么做。结果私钥的安全性取决于种子的安全性。特别是

  • 如果攻击者知道seed,那么他就知道了私钥
  • 结果私钥的熵最多与seed的熵相同

用于生成的算法如下

  1. 给定一个seed,将其编码为UTF8字节序列bytes
  2. prng_seed= SHA512(字节)[0..32]
  3. 密钥=ChaCha-20轮(prng_seed)
  4. 密钥在根据Curve-25519参考规范进行钳位后,用作私钥

如何设置“分层密钥基础设施”?

mlar提供了一个子命令keyderive,用于从给定密钥沿派生路径确定性地派生子密钥(类似于BIP-32,只是无法从父密钥派生子公钥)。

例如,如果想要派生以下方案

root_key
    ├──["App X"]── key_app_x
    │   └──["v1.2.3"]── key_app_x_v1.2.3
    └──["App Y"]── key_app_y

可以使用以下命令

# Create the root key (--seed can be used if this key must be created deterministically, see above)
mlar keygen root_key
# Create App keys
mlar keyderive root_key key_app_x --path "App X"
mlar keyderive root_key key_app_y --path "App Y"
# Create the v1.2.3 key of App X
mlar keyderive key_app_x key_app_x_v1.2.3 --path "v1.2.3"

在此阶段,让我们考虑发生了中断并且密钥已经丢失的情况。

可以从root_key私钥恢复所有密钥。例如,要恢复key_app_v1.2.3

mlar keyderive root_key recovered_key --path "App X" --path "v1.2.3"

因此,如果App X的所有者只知道key_app_x,他可以恢复其所有子密钥,包括key_app_v1.2.3,但不能恢复key_app_y

⚠️ 此方案不提供任何撤销机制。如果父密钥被泄露,其子树中的所有密钥都必须被视为已泄露(即从它可以获得的过去和未来的所有密钥)。反之则不然:如果其任何子密钥被泄露,父密钥仍然安全。

用于生成的算法如下

  1. 给定一个私钥,将其密钥作为32字节值提取出来(Curve 25519的钳位私钥)

  2. 对于每个以UTF8编码的路径

    1. 使用HKDF-SHA512函数(RFC5869)派生种子:HKDF-SHA512(salt="PATH DERIVATION" ASCII-encoded, ikm=从父密钥提取的密钥, info=派生路径)
    2. 将前32字节作为ChaCha-20轮PRNG的种子
    3. ChaCha的第一个32字节输出,在根据Curve-25519参考规范进行钳位后,用作新的私钥
  3. 使用最后一个计算的私钥作为结果密钥

依赖项

~12MB
~376K SLoC