51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

在 Rust 中构建加密文件系统的搭便车指南

这是关于如何在 Rust 中构建加密文件系统的系列文章中的第一篇。还有一个关于它的寻呼机。

如果你想看到更多有趣的项目并为开源做出贡献,请查看我的网站。

开端

这一切都始于我开始学习 Rust 并想要一个有趣的学习项目来保持动力。最初,我有一些想法并咨询了 ChatGPT ,它推荐了常见的应用程序,例如待办事项列表:)然而,我把它推向了更有趣和更具挑战性的领域,导致了像分布式文件系统、密码管理器、代理、网络流量监控器等建议......现在这些听起来都很有趣,但有些对于学习项目来说可能有点太复杂了,比如分布式文件系统

我的想法

我的项目想法源于有一个工作目录,其中包含项目信息,包括一些私人数据(不是我保存在KeePassXC中的凭据)。我在多个设备上将此目录与 Resilio 同步,但考虑使用 Google Drive 或 Dropbox,但是嘿,那里有私人信息,他们无法访问它。因此,像加密目录这样的解决方案,保持隐私,很有吸引力。所以我决定建造一个。毕竟,这将是一次很棒的学习经历。确实如此。

从一个学习项目,它演变成更多的东西,并很快准备好发布具有许多有趣功能的稳定版本。您可以查看该项目。

主要特点

  • 使用众所周知的经过审计的AEAD加密原语的安全性

  • 数据完整性,数据使用 WAL 写入,即使在崩溃或断电时也能确保完整性

  • 所有元数据和内容均已加密

  • 使用 mlock(2)、mprotect(2) 和 zeroize 安全管理内存中的凭证

  • 基于密码生成的加密密钥

  • 密码保存在操作系统的密钥环中

  • 更改密码而不重新加密所有数据

  • 读取和写入时的快速查找

  • 并行写入

  • 用保险丝裸露

  • 所有操作完全并发

概念和技术

将简要介绍所涉及的概念和一些技术细节,稍后将在单独的文章中详细说明它们。

FUSE(用户空间中的文件系统)

我以前使用过它,我可以使用它来将文件系统暴露给操作系统,以便从文件管理器和终端访问它。我在 Rust 中寻找 FUSE 实现并找到了 fuser,后来迁移到异步的 fuse3。我从它的示例开始,发现了一个有用的示例 ,称为一个基本实现,该实现将元数据保持在文件中,其中 inode 编号为名称,文件内容相同。文件夹被保存为 B 树,文件内容中包含子项。它还进行了一些权限检查。
这是一个很好的起点,但它不是多节点安全的,我们需要将其更改为多节点安全,因为文件夹在多个设备之间同步,我们的应用程序可以在多个设备上并行运行,甚至离线运行。我们需要为新文件/目录生成一个唯一的 inode,而文件中的子 B 树 显然不起作用。simple.rs

内存中 FS

我从使用 FUSE 的简单内存中 FS 开始,在那里我学到了更多关于 Box、Rc、RefCell、Arc 和 lifetime 等智能指针的知识。啊......生命周期 ,可以说是很多,是 Rust 中最复杂的概念之一,仅次于借用检查器。起初,它们很复杂,但是在你和它们战斗了一段时间后,然后你埋下了斧头,它们就更容易相处了。在你理解了编译器如何以及为什么让你 做事之后,你就明白这是正确的方法,它可以让你免于很多问题,你会很感激它。毕竟,这些都是 Rust 的承诺,内存安全,无数据竞争和竞争条件。事实上,它按照自己的前提生活。你需要来自其他语言,在这些语言中,你会遇到各种各样的问题,才能真正理解 Rust 为你提供的东西。您可以查看该项目。

多节点

我们需要在多节点中运行,因为文件夹将在多个设备上同步,应用程序可以并行 运行甚至离线 运行。我们需要为新文件生成唯一的 inode。解决方案是为每个设备分配一个随机 id(或通过命令 arg 设置),并生成为 $instance_id | $inode seq。
我以前在移动应用程序上使用过这种技术,该应用程序在多个设备上离线运行,收集数据,然后当我们有网络连接时,我们将这些数据推送到服务器。他们需要一种方法在离线模式下生成唯一 ID。但是不能使用 UUID(也不是 100% 唯一的),因为我们的 id 必须遵循预定义的模式,例如"00000"作为序列。我们所做的是为每个用户分配一个instance_id,并生成唯一的 ID,如 $instance_id-$seq,如 42--00037。我们没有遇到同一用户在多个设备上连接的情况,但在这种情况下,在登录时,您可以为用户分配一个序列,并将其与instance_id一起使用。

为了确保实例之间的 inode 唯一,我们将有以程:

  • 当应用程序启动时,它会生成一个唯一的随机数(unique in),将其用作u8``data_dir/seq``instance_id

  • 也有一个参数--instance-id

  • 保存该 ID 并下次使用。keyring

  • 如果下次启动时卸载了应用程序,我们将生成一个新的随机 ID

  • 生成随机数 likeinstance_id | inode_seq

  • 保持inode_seq``data_dir/seq/instance_id/inode

  • 生成 inode 时,首先递增并保存,然后在 inode 中使用它inode_seq

可能的问题:

考虑这个例子:一个人使用我们的程序,创建数据备份,继续使用该程序。如果他们恢复备份,那么他们将从早期的计数器重新启动,并重用相同的 inode。我们将使用 inode 作为随机数,当该文件夹中的文件名时,它最终将重用随机数(最终导致灾难性的失败)。

为了缓解这种情况,我们还可以保留 in keyring 并使用 。inode_seq``max(keyring, app_data)

限制:使用前 8 位进行instance_id可将 inode 数减少到 288,230,376,151,711,743,这绰绰有余。

安全

我们对 nonce、$instance id | $nonce seq 也是如此。我们在每个实例文件夹的数据目录中保存的序列。为了解决用户恢复备份并因此重用随机数和索引节点(最终导致灾难性故障)的问题,我们也在密钥环中保留序列并使用 max(keyring, data dir)。

限制:如果实例 ID 是 u8,并且我们以 256KB 的块进行加密,则我们可以 eccrypt 的最大
数据为 7.923E+16 PB (PB)。

使用 ring 进行加密,也将扩展到 RustCrypto,即纯粹的 Rust。第一次,我们生成一个随机加密密钥,并使用 argon2 从用户密码派生的另一个密钥对其进行加密。我们只使用AEAD密码,因为它们提供身份验证和完整性检查,ChaCha20Poly1305和Aes256Gcm。凭据在 mem 中保密,使用时 mlocked,未读取时 mprotected,丢弃时归零d。
哈希是使用 blake3 和 rand_chacha 对随机数进行的。

文件完整性

"有长城,然后有这个:一个好的WAL。
WAL(Write-ahead logging)是数据库用来
写入事务以确保完整性的一种非常常见的技术。我正在使用 okaywal 为此。
这个想法很简单,对于每个文件,我们都有一个 wal 文件夹,当我们需要向文件写入某些内容时,我们首先写入 wal,然后在一些 threastains 大小之后,我们检查并 flusing 到我们写入时删除的实际文件,当我们完成编写所有更改时,我们做同样的事情。同时,如果进程死亡或我们遇到断电,下次我们开始时,我们会看到 wal 中有变化,我们首先编写这些更改。同时,我们可以将文件标记为正在恢复,我们可以在文件前缀中使用像✚这样的Unicode图标来将文件标记为正在恢复。在此期间,我们将不允许读取和写入文件。

寻求

为了支持快速搜索,我们将文件加密为 256KB 的块。当我们需要在读取时查找时,我们从明文偏移转换为密文块索引,并解密该块。我们实际上在相同的 Read 结构上实现了 Seek。

impl<R: Read> Read for RingCryptoRead<R> {
    #[instrument(name = "RingCryptoReader:read", skip(self, buf))]
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        // first try to read remaining decrypted data
        let len = self.buf.read(buf)?;
        if len != 0 {
            return Ok(len);
        }
        // we read all the data from the buffer, so we need to read a new block and decrypt it
        decrypt_block!(
            self.block_index,
            self.buf,
            self.input.as_mut().unwrap(),
            self.last_nonce,
            self.opening_key
        );
        let len = self.buf.read(buf)?;
        Ok(len)
    }
}
impl<R: Read + Seek> Seek for RingCryptoRead<R> {
    #[allow(clippy::cast_possible_wrap)]
    #[allow(clippy::cast_sign_loss)]
    fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
        let plaintext_len = self.get_plaintext_len()?;
        let new_pos = match pos {
            SeekFrom::Start(pos) => pos as i64,
            SeekFrom::End(pos) => plaintext_len as i64 + pos,
            SeekFrom::Current(pos) => self.pos() as i64 + pos,
        };
        if new_pos < 0 {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "new position < 0",
            ));
        }
        // keep in bounds
        let mut new_pos = new_pos as u64;
        new_pos = new_pos.min(plaintext_len);
        if self.pos() == new_pos {
            return Ok(new_pos);
        }
        let block_index = self.pos() / self.plaintext_block_size as u64;
        let new_block_index = new_pos / self.plaintext_block_size as u64;
        if block_index == new_block_index {
            let at_full_block_end = self.pos() % self.plaintext_block_size as u64 == 0
                && self.buf.available_read() == 0;
            if self.buf.available() > 0
                // this make sure we are not at the end of the current block, which is the start boundary of next block
                // in that case we need to seek inside the next block
                && !at_full_block_end
            {
                // seek inside current block
                self.buf.seek_read(SeekFrom::Start(
                    NONCE_LEN as u64 + new_pos % self.plaintext_block_size as u64,
                ))?;
            } else {
                // we need to read a new block and seek inside that block
                let plaintext_block_size = self.plaintext_block_size;
                stream_util::seek_forward(self, new_pos % plaintext_block_size as u64, true)?;
            }
        } else {
            // change block
            self.input.as_mut().unwrap().seek(SeekFrom::Start(
                new_block_index * self.ciphertext_block_size as u64,
            ))?;
            self.buf.clear();
            self.block_index = new_block_index;
            if new_pos % self.plaintext_block_size as u64 == 0 {
                // in case we need to seek at the start of the new block, we need to decrypt here, because we altered
                // the block_index but the seek seek_forward from below will not decrypt anything
                // as the offset in new block is 0. In that case the po()
                // method is affected as it will use the wrong block_index value
                decrypt_block!(
                    self.block_index,
                    self.buf,
                    self.input.as_mut().unwrap(),
                    self.last_nonce,
                    self.opening_key
                );
            }
            // seek inside new block
            let plaintext_block_size = self.plaintext_block_size;
            stream_util::seek_forward(self, new_pos % plaintext_block_size as u64, true)?;
        }
        Ok(self.pos())
    }
}

对于在写时搜索,它有点复杂,我们也需要充当读者。首先,我们需要解密块,然后写入它,最后加密并将其写入磁盘。因为 Rust 没有方法,所以覆盖代码不如 reader 干净,我们只扩展。

并行写入

使用 RwLock,我们允许并行读取和写入,并解决与 WAL 的冲突。对于并行写入不同块的 torrent 应用程序特别有用,但对于数据库也特别有用。

用法

您可以将其用作 CLI 或库来构建自己的加密解决方案。

命令行界面

rencfs mount --mount-point MOUNT_POINT --data-dir DATA_DIR

前途

  • 计划也在 macOS 和 Windows 上实施它

  • systemd 服务正在 rencfs-daemon 上工作

  • GUI 正在 rencfs-desktop 和 rencfs-kotlin 上开发 Android 和 iOS 的移动应用程序正在 rencfs-kotlin 上开发

性能

Aes256Gcm 比 ChaCha20Poly1305 平均快 1.28 倍。这是因为大多数 CPU 上通过 AES-NI 对 AES 进行硬件加速。但是在硬件加速不可用的情况下,ChaCha20Poly1305 更快。此外,ChaChaPoly1305 在 SIMD 方面更好。

密码比较

AES-GCM 与 ChaCha20-Poly1305

  • 如果您有硬件加速(例如 AES-NI),则 AES-GCM 可提供更好的性能。在我的基准测试中,它平均快了 1.28 倍。如果您没有硬件加速,AES-GCM 要么比 ChaCha20-Poly1305 慢,要么在缓存计时中泄露您的加密密钥。

  • AES-GCM 可以针对多个安全级别(128 位、192 位、256 位),而 ChaCha20-Poly1305 仅在 256 位安全级别定义。

  • 随机数大小:
    --- AES-GCM:各不相同,但标准是 96 位(12 字节)。如果提供更长的随机数,则会将其散列为 16 个字节。
    --- ChaCha20-Poly1305:标准化版本使用 96 位随机数(12 字节),但原始版本使用 64 位随机数(8 字节)。

  • 单个(键、随机数)对的磨损:
    --- AES-GCM:消息必须小于 2³²--- 2 个块(也称为 2³⁶--- 32 字节,又名 2³⁹--- 256 位),大约为 64GB。这也使得具有长随机数的 AES-GCM 的安全分析变得复杂,因为哈希随机数不会以设置为 00 00 00 02 的下 4 个字节开始。
    --- ChaCha20-Poly1305:ChaCha 有一个内部计数器(标准化 IETF 变体为 32 位,原始设计为 64 位)。最大消息长度为 2³⁹--- 256 位,约 256GB。

  • 这两种算法都不能防止随机数误用。

  • ChaChaPoly1305 更擅长 SIMD。

结论

两者都是不错的选择。AES-GCM 在硬件支持下可以更快,但 ChaCha20-Poly1305 的纯软件实现几乎总是快速且时间恒定。

  • 它是完全异步构建的,基于 Tokio 和 Fuse3

  • ring 用于加密,argon2 用于密钥派生功能(创建用于从密码加密主加密密钥的密钥))

  • 用于加密安全随机数生成器的rand_chacha

  • 用于在内存中保持传递和加密密钥安全的秘密 mlock ed 在使用时 mlocked,在未读取时 mprotected 和 drop 时将 d 归零。它仅在使用时将加密密钥保存在内存中,当不激活时,它将在内存中释放并将它们归零

  • 用于哈希的 blake3

  • 使用密钥环在操作系统密钥环中保存的密码

  • 跟踪和日志的跟踪

赞(2)
未经允许不得转载:工具盒子 » 在 Rust 中构建加密文件系统的搭便车指南