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 解析实现
370KB
5.5K SLoC
多层归档(MLA)
MLA是一种具有以下特性的归档文件格式:
- 支持压缩(基于
rust-brotli
) - 支持使用非对称密钥进行认证加密(基于Curve25519的ECIES方案,AES256-GCM,基于 Rust-Crypto
aes-ctr
和 DalekCryptographyx25519-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
,实现了Read
和Seek
特性。它负责在读取存档时读取字节 - 一个
FailSafeReader
,仅实现了Read
特性。它负责在修复存档时读取字节
层考虑了可修复性。读取它们时,永远不需要从页脚获取信息,但可以使用页脚来优化读取。例如,可以通过页脚定位到文件开始来优化存档中文件的访问,但仍然可以通过读取整个存档直到找到文件来获取信息。
层是可选的,但它们的顺序是强制的。用户可以选择启用或禁用它们。当前顺序如下:
- 文件存储抽象(不是一个层)
- 原始层(必需)
- 压缩层
- 加密层
- 位置层(必需)
- 存储的字节
概述
+----------------+-------------------------------------------------------------------------------------------------------------+
| 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*
中实现(即RawLayerWriter
、RawLayerReader
和RawLayerFailSafeReader
)。
这是最简单的层。它需要提供一个在层和最终输出世界之间的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。
测试
仓库包含
- 单元测试(用于
mla
和curve25519-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
中。该场景可以
- 创建包含交错文件和不同层启用的存档
- 读取它们以检查其内容
- 无截断地修复存档,并验证它
- 修改存档的原始数据,并确保读取它不会崩溃(只会失败)
- 修复被修改的存档,并确保恢复不会失败(只报告检测到的错误)
要启动它
- 在
mla-fuzz-afl/src/main.rs
中取消注释produce_samples()
以生成初始样本
cd mla-fuzz-afl
# ... uncomment `produces_samples()` ...
mkdir in
mkdir out
cargo run
- 构建和启动 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
中启用 mla
的 send
功能,例如
[dependencies]
mla = { version = "...", default-features = false, features = ["send"]}
如何确定性地生成密钥对?
mlar keygen
的 --seed
选项可用于确定性地生成密钥对。例如,它可以用于可重复测试或将密钥存档在安全的地方。
⚠️ 如果不知道为什么这样做,则不建议使用 seed
。生成私钥的安全性取决于 seed
的安全性。特别是
- 如果攻击者知道
seed
,那么他就知道私钥 - 生成的私钥的熵最多与
seed
的熵相同
用于生成的算法如下
- 给定一个
seed
,将其编码为 UTF8 字节序列bytes
prng_seed= SHA512(bytes)[0..32]
secret=ChaCha-20rounds(prng_seed)
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
。
⚠️ 此方案不提供任何撤销机制。如果父密钥遭到破坏,其子树中的所有密钥都必须被视为遭到破坏(即从中可以获得的所有过去和未来的密钥)。反之则不成立:如果任何子密钥遭到破坏,父密钥仍然安全。
用于生成的算法如下
-
给定一个
private key
,将其secret
提取为 32 字节值(Curve 25519 的夹紧私钥) -
对于每个以 UTF8 编码的
path
- 使用 HKDF-SHA512 函数(RFC5869)从一个种子中提取:
HKDF-SHA512(salt="PATH DERIVATION" ASCII 编码, ikm=从父密钥提取的密钥, info=提取路径)
- 使用前 32 字节作为 ChaCha-20 轮次 PRNG 的种子
- ChaCha 的第一个 32 字节输出,在按 Curve-25519 参考指定的方式夹紧后,用作新的私钥
- 使用 HKDF-SHA512 函数(RFC5869)从一个种子中提取:
-
使用最后计算的私钥作为结果密钥
依赖项
~17–27MB
~568K SLoC