4 个版本 (重大更改)
0.4.0 | 2023年9月29日 |
---|---|
0.3.0 | 2022年8月19日 |
0.2.0 | 2020年9月14日 |
0.1.0 | 2020年8月5日 |
#1385 在 密码学 中
每月463次下载
在 3 个crate 中使用
46KB
294 行
多层存档(MLA)
MLA 是一种存档文件格式,具有以下特性:
- 支持压缩(基于
rust-brotli
) - 支持使用非对称密钥进行认证加密(基于 Curve25519 的 ECIES 方案,AES256-GCM,基于 Rust-Crypto
aes-ctr
和 DalekCryptographyx25519-dalek
) - 高效、架构无关且可移植(完全使用 Rust 编写)
- 存档创建过程中的内存占用小
- 可流式存档创建
- 甚至可以通过数据隔离器构建存档
- 可以通过数据块添加文件,无需事先知道最终大小
- 文件块可以交错(可以在添加一个文件的开始后,开始第二个文件,然后继续添加第一个文件的部分)
- 存档文件是可定位的,即使已压缩或加密。可以在存档的中间访问文件,而无需从开头读取
- 如果被截断,存档可以修复。仍在存档中的文件以及缺少末尾的文件的开始将被恢复
- 更不容易出错,特别是在解析不受信任的存档时(Rust 安全性)
仓库
此仓库包含以下内容:
mla
:实现 MLA 读取器和写入器的 Rust 库mlar
:用于常见操作的 Rust 工具,包装mla
(创建、列出、提取等)curve25519-parser
:解析 DER/PEM 公钥和私钥 Ed25519 密钥以及 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
trait。它在创建新存档时负责发出字节 - 一个
Reader
,实现了Read
和Seek
trait。它在读取存档时负责读取字节 - 一个
FailSafeReader
,仅实现了Read
trait。它在修复存档时负责读取字节
层是考虑到可修复性而设计的。读取它们时决不能需要来自脚本的详细信息,但可以使用脚本来优化读取。例如,使用脚本来定位存档内的文件可以优化到文件的开头,但仍然可以通过读取整个存档直到找到文件来获取信息。
层是可选的,但它们的顺序是强制的。用户可以选择启用或禁用它们。当前顺序如下
- 文件存储抽象(不是层)
- 原始层(强制)
- 压缩层
- 加密层
- 位置层(强制)
- 存储的字节
概述
+----------------+-------------------------------------------------------------------------------------------------------------+
| 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
提供用于密钥和非ce生成中实际使用的字节。
层数据由几个加密块组成,除了最后一个块外,每个块的大小都是固定的。每个块使用包括基本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
中注释掉相关部分。
模糊测试
在mla-fuzz-afl
中提供了一个使用afl.rs制作的模糊测试场景。该场景能够
- 创建包含交错文件的存档,并启用不同层
- 读取它们以检查其内容
- 无截断地修复存档,并验证它
- 更改存档原始数据,并确保读取它不会引发恐慌(只会失败)
- 修复更改后的存档,并确保恢复不会失败(仅报告检测到的错误)
启动它
- 通过取消注释
produce_samples()
在mla-fuzz-afl/src/main.rs
中生成初始样本
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
中注释掉不相关的部分来确保更好的体验。
常见问题解答(FAQ)
MLAArchiveWriter
是否是 Send
类型?
默认情况下,MLAArchiveWriter
不是 Send
类型。如果内部可写类型也是 Send
,可以在 Cargo.toml
中为 mla
启用 send
功能,例如
[dependencies]
mla = { version = "...", default-features = false, features = ["send"]}
如何确定性地生成一个密钥对?
mlar keygen
的 --seed
选项可以用来确定性地生成一个密钥对。例如,它可以用于可重复测试或安全地存档密钥。
⚠️ 如果不知道为什么这么做,不推荐使用 seed
。结果的私钥安全性依赖于种子安全性。特别是
- 如果攻击者知道了
seed
,他就知道了私钥 - 结果的私钥熵最多与
seed
相同
用于生成的算法如下
- 给定一个
seed
,将其编码为UTF8字节的UTF8序列bytes
prng_seed= SHA512(bytes)[0..32]
secret=ChaCha-20rounds(prng_seed)
secret
在经过Curve-25519参考指定的clamp处理后,用作私钥
如何设置“分层密钥基础设施”?
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
。
⚠️ 此方案不提供任何撤销机制。如果父密钥被泄露,其子树中的所有密钥都必须被视为已泄露(即从它可以获得的过去和未来的所有密钥)。反之则不然:如果其任一子密钥被泄露,父密钥仍然安全。
用于生成的算法如下
-
给定一个
私钥
,将其密钥
提取为 32 字节值(Curve 25519 的钳位私钥) -
对于每个
路径
以 UTF8 编码- 使用以下 HKDF-SHA512 函数(RFC5869)从密钥中派生一个种子:
HKDF-SHA512(salt="PATH DERIVATION" ASCII-encoded, ikm=从父密钥提取的密钥, info=派生路径)
- 将前 32 字节作为 ChaCha-20 轮的 PRNG 的种子
- ChaCha 的第一个 32 字节输出(根据 Curve-25519 参考规范进行钳位)用作新的私钥
- 使用以下 HKDF-SHA512 函数(RFC5869)从密钥中派生一个种子:
-
使用最后计算的私钥作为结果密钥
依赖项
~4–5MB
~105K SLoC