4个稳定版本

1.3.0 2023年10月9日
1.2.0 2021年10月1日
1.1.1 2021年3月4日
1.1.0 2020年9月14日

#471 in 解析实现

LGPL-3.0-only

370KB
5.5K SLoC

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

多层归档(MLA)

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

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

仓库

此仓库包含

  • mla:实现MLA读取器和编写器的Rust库
  • mlar:一个Rust实用程序,用于包装mla以执行常用操作(创建、列出、提取等)
  • curve25519-parser:一个Rust库,用于解析DER/PEM公共和私有Ed25519密钥以及X25519密钥(由openssl创建)
  • mla-fuzz-afl:一个用于fuzzmla的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: 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 rand中的OsRng,它使用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-tar,或create->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 的内部工作方式(稍微复杂且可能存在错误),因此建议在 mla-fuzz-afl/src/main.rs 中注释掉不相关的部分以获得更好的体验。

常见问题解答

MLAArchiveWriter 是否是 Send

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

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

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

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

⚠️ 如果不知道为什么这样做,则不建议使用 seed。生成私钥的安全性取决于 seed 的安全性。特别是

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

用于生成的算法如下

  1. 给定一个 seed,将其编码为 UTF8 字节序列 bytes
  2. prng_seed= SHA512(bytes)[0..32]
  3. secret=ChaCha-20rounds(prng_seed)
  4. secret,经过曲线-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"

在这个阶段,让我们考虑发生了中断并且密钥已经丢失。

可以从根私钥中恢复所有密钥。例如,恢复 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. 给定一个 private key,将其 secret 提取为 32 字节值(Curve 25519 的夹紧私钥)

  2. 对于每个以 UTF8 编码的 path

    1. 使用 HKDF-SHA512 函数(RFC5869)从一个种子中提取:HKDF-SHA512(salt="PATH DERIVATION" ASCII 编码, ikm=从父密钥提取的密钥, info=提取路径)
    2. 使用前 32 字节作为 ChaCha-20 轮次 PRNG 的种子
    3. ChaCha 的第一个 32 字节输出,在按 Curve-25519 参考指定的方式夹紧后,用作新的私钥
  3. 使用最后计算的私钥作为结果密钥

依赖项

~17–27MB
~568K SLoC