如何设计一种更敏捷的文件加密格式

起因

每个人都有自己的小秘密,所以每个人都会有加密文件的需求,以前我会用压缩包的形式来加密文件,但这种方式使用起来非常痛苦:

  • 1.压缩包只能作为一个整体传输和复制,没传完就没法使用,对于网络传输很不友好。
  • 2.如果我想寻找某个文件,必须解压出来一个个寻找。
  • 3.体积大且集中。

我的需求是:

  • 1.每个文件独立加密,方便转移。
  • 2.可以通过密码直接打开文件,而不是先解密,用完再加密。
  • 3.我需要一张模糊的缩略图,来大致预览文件的内容。
  • 4.需要适合网络传输,例如网盘。
  • 5.极致安全,抗暴力破解、抗字典和彩虹表攻击。

由于需求比较独特,最终我打算自己设计一种文件格式。


浅谈密码学

最早接触到的加密解密,是小时候非常喜欢的《冒险小虎队》,在书中谜题的答案通常是被打乱的文字矩阵,读者需要用特定的解密卡遮挡,才能看懂被打乱的文字。

可以说密码学是伴随着计算机一路发展的,现代计算机之父图灵提出的图灵机,最早的应用就是密码破译。

1.现代密码

加密解密是一个自古就有的话题,古典密码学中,最简单且最广为人知的加密技术当属“恺撒密码”,它通过简单的字母移位替换进行加密,解密时再通过反向移位即可还原出原文。 恺撒密码是一种经典的对称加密,因为加密方式简单,因此破解也非常简单: 在英文词句中,不同的字母出现频率是不同的,只需要分析密文中的字母出现频率,就能推断出它的偏移量,从而解密。

现代密码学中,我们有了更强大的武器——数学。 我们使用秘钥对原文进行一系列数学计算后得出密文。解密时只需要按照规则再次进行计算,即可推出明文。 这种方式的高明之处在于,即使完全公开了加密方式,但没有密码也无法计算出明文。

现代密码能暴力破解吗?

举个例子:AES-256 的密钥可能性为 2^256 种,这完全是一个天文数字。 通过穷举法找到秘钥需要多久呢?我让 Grok 算了一下:

假设一次 AES-256 解密操作需要约 1000 次基本运算(实际复杂得多,但里简化估算),如果我们将超级计算机的计算能力完全用于暴力破解 AES-256:截至 2025 年,全球最快的超级计算机(如 Frontier,2022 年达到 1.1 exaFLOPS,即每秒 1.1×10^18 次浮点运算),每秒可执行 10^18 次操作(1 exaFLOPS),也就是可尝试 10^15(千万亿)个密钥。找到正确秘钥平均需要约 1.835×10^54 年,比宇宙生命还长。

比起加密算法,系统、硬件,和人性才是整个加密中最薄弱的漏洞,只要你不主动泄露、不被木马软件监听,不被钓鱼软件偷走密码,可以说没人能解开你的秘密。

2.两种方向的加密思路

对称加密

对称加密,指的是密文和原文通过某种关系一一对应的加密方式。通常用于大文件加密,保护数据的机密性。 其特点是加密和解密使用相同的密钥。

以下是几种广泛使用的对称加密算法: AESDESBlowfishTwofishChaCha

这其中我选择的是ChaCha:它由Daniel J. Bernstein设计,是一种流密码,密钥长度128位或256位。特点是高性能:无需硬件加速,仅软件运算速度也能接近AES速度;安全性强;灵活性高:支持多种轮次,流密码特性使其适合处理任意长度的数据,无需填充;抗时序攻击、简单易实现。

非对称加密

非对称加密(也称公钥加密),是密文和原文不一一对应的加密方式,使用一对密钥(公钥和私钥)进行加密和解密,公钥可公开用于加密,私钥则用于解密或签名。通常用于:密钥交换、小量数据加密、数字签名、身份认证。

由于非对称加密计算开销大,且容量有限,通常不适合大文件加密,常用于加密小量数据,或需要在公共场景传输的数据。

以下是现代常用的非对称加密算法:加密:RSA(传统场景)、ECC(高性能场景)、ECDSA、EdDSA、ECDH、DH。

想了解原理可以看这个视频:李永乐老师11分钟讲加密算法

3.派生秘钥

常见的加密算法都需要固定位数的秘钥(例如 AES-256 秘钥必须是32字节),而用户输入的密码却长短不、一千奇百怪,所以我们需要一个算法,将任意密码转为与之唯一对应的 32字 节秘钥,再使用这个派生出来的秘钥进行加密。

你最先想到的可能是常用与文件校验的哈希算法,例如 MD5、SHA-256,他们确实满足条件,但他们并没有那么安全。

由于 SHA-256 的计算速度极快,且哈希后的秘钥和密码是一一对应的,假如用户的密码是常见的密码(如 ABC12345678),攻击者只需要不断跑字典或彩虹表,最终就有可能找到密码,一旦拿到了密码,任何加密算法都形同虚设了。

所以我们需要一种更安全的派生算法,我推荐的是 Argon2,它是一种非常慢,且占内存的算法。它一次计算需要上百毫秒、并且吃掉16-64MB的内存。这对于用户来说几乎没有感知,但对于攻击者来说,却指数级增加了攻击的难度,计算耗时限制了跑字典的频率,内存占用限制了并行计算的上限。

方案推荐

最终我推荐的加密方案: Argon2id + ChaCha20



文件加密实操

下面就来实操一下如何加密解密文件,语言方面,我试过 Go、Dart 的实现只能算差强人意,还是用 Rust 吧。

需求分析

  • 1.能加密、解密。
  • 2.文件校验,确保解密后和源文件一致。
  • 3.多密码,并且能用任意密码完整解密。
  • 4.修改密码。
  • 5.缩略图,存储一张模糊的缩略图,方便快速预览。

思路

为了支持多密码,同时高效的修改密码,我们可以采用信封模式: 使用一个秘钥 (CEK) 加密文件,再派生多个 KEK 秘钥 加密 CEK 秘钥。 解密时只需要使用密码求出一个 KEK,解出 CEK,再使用 CEK 解密文件。 修改密码也只需解出 CEK 再使用新密码派生 KEK,无需重新加密整个文件。 然后存储一个缩略图作为元数据即可。

让我们来设计文件格式:
长度(字节)取值名称说明
4WCRYMagic固定的文件标识
2 (大端序)NumberPassword Count密码数量
72 * N密码块Entries多个密码块,数量 N 取决于 Passwrod Count
128(加密)随机字节Encrypted Verify Block验证块
32哈希值Verify Block HMAC验证块的哈希值,用于判断解密是否成功
32原文件的哈希值File HMAC用于验证文件完整性
4 (大端序)NumberMetadata Length元数据的长度
NByte[]Metadata元数据
NByte[]Encrypted Content原始文件加密后的密文

每个密码块又分为:
长度(字节)取值名称说明
16随机字节Salt用于密码派生的随机盐
12随机字节KEK NonceKEK 对应的 Nonce
32(加密) CEK秘钥Encrypted CEK用于加密文件的CEK
12(加密) 随机字节Encrypted CEK NonceCEK 对应的 Nonce

步骤拆分

加密

  • 0.计算原文件哈希值备用。
  • 1.生成 CEK、CEK Nonce,并用它加密文件。
  • 2.使用用户密码加盐,派生 KEK + KEK Nonce。
  • 3.使用 KEK + KEK Nonce 加密 CEK + CEK Nonce(多密码重复2、3步骤)。
  • 4.生成随机字节的验证块,计算它的哈希备用,再用 CEK + CEK Nonce 加密验证块。
  • 5.传入元数据,计算长度备用。
  • 6.按照规则写出到文件。

注意:KEK 是根据密码派生而来,千万不能写入文件中!


解密

  • 1.读取文件头,判断文件格式。
  • 2.读取密码数量(多密码重复3、4、5步骤)。
  • 3.读取密码块,取出盐,结合用户输入的密码算出 KEK。
  • 4.使用KEK + KEK Nonce 解密出 CEK 和 CEK Nonce。
  • 5.使用 CEK + CEK Nonce 解密验证块,计算哈希值并比对,判断密码是否正确。
  • 6.比对成功,使用 CEK + CEK Nonce 解密文件
  • 7.计算解密后的文件哈希值并比对,判断是否解密成功。


上代码

创建一个 Rust 项目,在 Cargo.toml 中导入依赖:

rand = "0.8"                    # 随机数
chacha20 = "0.9"                # chaCha20 stream cipher
cipher = "0.4"                  # 用这个替代旧的 stream-cipher
argon2 = "0.5"                  # 秘钥派生
hmac = "0.12"
sha2 = "0.10"                   # 哈希
byteorder = "1.4"
anyhow = "1.0"
subtle = "2.6.1"                # 简化错误处理

加密函数

// 加密文件的函数,接受输入文件路径、输出文件路径、密码列表和可选的元数据
pub fn encrypt_file(input_path: &str, output_path: &str, passwords: &[String], metadata: Option<&[u8]>) -> Result<()> {
    // 检查密码列表是否为空
    if passwords.is_empty() {
        bail!("至少需要一个密码");
    }
    // 检查密码数量是否超过最大限制(65535)
    if passwords.len() > 65535 {
        bail!("密码数量过多,最多支持 65535 个");
    }

    // 打开输入文件,创建 BufReader 以便高效读取
    let mut input_file = File::open(input_path).context("打开输入文件失败")?;
    // 创建输出文件,创建 BufWriter 以便高效写入
    let mut output_file = File::create(output_path).context("创建输出文件失败")?;

    // 生成内容加密密钥(CEK)和对应的随机数(nonce)
    let (cek, cek_nonce) = gen_cek();

    // 创建验证块并用随机数据填充,用于后续验证加密完整性
    let mut verify_block = vec![0u8; VERIFY_BLOCK_SIZE];
    rand::thread_rng().fill_bytes(&mut verify_block);

    // 使用 CEK 和 nonce 加密验证块
    let encrypted_verify_block = chacha_xor(&cek, &cek_nonce, &verify_block)?;

    // 初始化 HMAC-SHA256 用于验证块的完整性校验
    let mut hmac_verify = HmacSha256::new_from_slice(&cek).expect("HMAC 初始化失败");
    // 更新 HMAC 以包含验证块数据
    hmac_verify.update(&verify_block);
    // 计算验证块的 HMAC 值
    let verify_block_hmac = hmac_verify.finalize().into_bytes();

    // 初始化 HMAC-SHA256 用于计算整个文件的 HMAC
    let mut file_hmac_hasher = HmacSha256::new_from_slice(&cek).expect("HMAC 初始化失败");
    {
        // 创建输入文件的缓冲读取器
        let mut rdr = BufReader::new(&mut input_file);
        let mut buf = [0u8; 8192];
        // 循环读取文件内容,计算文件的 HMAC
        loop {
            let n = rdr.read(&mut buf)?;
            if n == 0 {
                break;
            }
            file_hmac_hasher.update(&buf[..n]);
        }
    }
    // 获取文件的 HMAC 值
    let file_hmac = file_hmac_hasher.finalize().into_bytes();

    // 重置输入文件指针到文件开头,以便后续加密
    input_file.seek(SeekFrom::Start(0)).context("重置输入文件指针失败")?;

    // 计算元数据长度(若无元数据则为 0)
    let metadata_len = metadata.map_or(0, |m| m.len());
    // 初始化头部缓冲区,预分配足够空间存储头部数据
    let mut header = Vec::with_capacity(6 + passwords.len() * ENTRY_SIZE + VERIFY_BLOCK_SIZE + 32 + 32 + 4 + metadata_len);

    // 写入文件头部魔数(HEADER_MAGIC)
    header.extend_from_slice(HEADER_MAGIC);
    // 写入密码数量(以大端字节序存储)
    header.write_u16::<BigEndian>(passwords.len() as u16)?;

    // 为每个密码生成加密密钥(KEK)并加密 CEK 和 nonce
    for pw in passwords {
        // 生成随机盐值
        let mut salt = [0u8; ENTRY_SALT_LEN];
        rand::thread_rng().fill_bytes(&mut salt);
        // 生成 KEK 的随机数(nonce)
        let kek_nonce = gen_nonce();
        // 使用密码和盐值派生 KEK
        let kek = derive_kek(pw.as_bytes(), &salt)?;
        // 使用 KEK 和 nonce 加密 CEK
        let encrypted_cek = chacha_xor(&kek, &kek_nonce, &cek)?;
        // 使用 KEK 和 nonce 加密 CEK 的 nonce
        let encrypted_cek_nonce = chacha_xor(&kek, &kek_nonce, &cek_nonce)?;
        // 将盐值、KEK nonce、加密的 CEK 和加密的 CEK nonce 写入头部
        header.extend_from_slice(&salt);
        header.extend_from_slice(&kek_nonce);
        header.extend_from_slice(&encrypted_cek);
        header.extend_from_slice(&encrypted_cek_nonce);
    }

    // 写入加密的验证块
    header.extend_from_slice(&encrypted_verify_block);
    // 写入验证块的 HMAC
    header.extend_from_slice(&verify_block_hmac);
    // 写入文件的 HMAC
    header.extend_from_slice(&file_hmac);
    // 写入元数据长度(以大端字节序存储)
    header.write_u32::<BigEndian>(metadata_len as u32)?;
    // 如果存在元数据,则写入元数据
    if let Some(metadata) = metadata {
        header.extend_from_slice(metadata);
    }

    // 将头部数据写入输出文件
    output_file.write_all(&header).context("写入头部失败")?;

    // 初始化 ChaCha20 流加密器,使用 CEK 和 nonce
    let mut cipher = ChaCha20::new(&cek.into(), &cek_nonce.into());
    // 创建输入文件的缓冲读取器和输出文件的缓冲写入器
    let mut reader = BufReader::new(input_file);
    let mut writer = BufWriter::new(output_file);
    let mut buf = [0u8; 8192];

    // 循环读取文件内容,加密并写入输出文件
    loop {
        let n = reader.read(&mut buf)?;
        if n == 0 {
            break;
        }
        // 复制缓冲区内容并对其应用流加密
        let mut chunk = buf[..n].to_vec();
        cipher.apply_keystream(&mut chunk);
        // 将加密后的数据写入输出文件
        writer.write_all(&chunk)?;
    }

    // 刷新输出缓冲区,确保所有数据写入文件
    writer.flush()?;
    // 返回成功结果
    Ok(())
}

示例用法

encrypt_file("原文件路径", "加密文件输出路径", &[String::from("密码1")], Some(b"任意内容的元数据".as_ref())).unwrap();

解密函数

// 解密文件函数,使用提供的密码解密文件,返回 (HMAC 匹配结果, 元数据) 的元组。
// `input_path`: 加密输入文件的路径。
// `output_path`: 解密后文件输出的路径。
// `password`: 用于推导解密密钥的密码。
// 返回 `Result<(bool, Vec<u8>)>`: 布尔值表示计算的 HMAC 是否与存储的 HMAC 匹配,以及元数据字节。
pub fn decrypt_file(input_path: &str, output_path: &str, password: &str) -> Result<(bool, Vec<u8>)> {
    // 打开输入文件以进行读取。
    let mut input_file = File::open(input_path).context("打开输入文件失败")?;

    // 读取文件开头的固定 6 字节头部。
    let mut fixed_header = [0u8; 6];
    input_file.read_exact(&mut fixed_header).context("读取固定头部失败")?;

    // 验证头部的前 4 字节是否匹配预期的 HEADER_MAGIC(表明是 WCRY 格式)。
    if &fixed_header[..4] != HEADER_MAGIC {
        bail!("无效的文件头部,非 WCRY 格式");
    }

    // 读取密码条目数量(存储为 2 字节大端序无符号整数)。
    let num_passwords = (&fixed_header[4..6]).read_u16::<BigEndian>()? as usize;

    // 读取密码条目到向量中(每个条目大小为 ENTRY_SIZE 字节)。
    let mut entries = vec![0u8; num_passwords * ENTRY_SIZE];
    input_file.read_exact(&mut entries).context("读取密码条目失败")?;

    // 读取加密的验证块、其 HMAC 以及原始文件的 HMAC 到单一缓冲区。
    let mut vb_and_hmac = vec![0u8; VERIFY_BLOCK_SIZE + 32 + 32];
    input_file.read_exact(&mut vb_and_hmac).context("读取验证块和 HMAC 失败")?;

    // 将缓冲区分割为三部分:
    // - encrypted_verify_block: 加密的验证块。
    // - verify_block_hmac: 解密验证块的 HMAC。
    // - original_file_hmac: 原始文件内容的 HMAC。
    let encrypted_verify_block = &vb_and_hmac[..VERIFY_BLOCK_SIZE];
    let verify_block_hmac = &vb_and_hmac[VERIFY_BLOCK_SIZE..VERIFY_BLOCK_SIZE + 32];
    let original_file_hmac = &vb_and_hmac[VERIFY_BLOCK_SIZE + 32..];

    // 读取元数据长度(4 字节,大端序),若无则默认为 0(向后兼容旧格式)。
    let mut metadata_len_buf = [0u8; 4];
    let metadata_len = match input_file.read_exact(&mut metadata_len_buf) {
        Ok(()) => u32::from_be_bytes(metadata_len_buf) as usize,
        Err(_) => 0, // 向后兼容:旧格式可能没有元数据。
    };

    // 根据元数据长度读取元数据(如果存在)到向量中。
    let mut metadata = vec![0u8; metadata_len];
    if metadata_len > 0 {
        input_file.read_exact(&mut metadata).context("读取元数据失败")?;
    }

    // 标记是否找到有效密码。
    let mut found = false;
    // 用于存储内容加密密钥(CEK)和其 nonce 的缓冲区。
    let mut cek = [0u8; CEK_LEN];
    let mut cek_nonce = [0u8; CEK_NONCE_LEN];

    // 将输入密码转换为字节,用于密钥推导。
    let pw_bytes = password.as_bytes();

    // 遍历每个密码条目,寻找匹配的密钥。
    for i in 0..num_passwords {
        // 计算当前条目在 entries 向量中的偏移量。
        let off = i * ENTRY_SIZE;
        // 从条目中提取盐值、KEK nonce、加密的 CEK 和加密的 CEK nonce。
        let salt = &entries[off..off + ENTRY_SALT_LEN];
        let kek_nonce = &entries[off + ENTRY_SALT_LEN..off + ENTRY_SALT_LEN + ENTRY_KEK_NONCE_LEN];
        let encrypted_cek = &entries[off + ENTRY_SALT_LEN + ENTRY_KEK_NONCE_LEN ..off + ENTRY_SALT_LEN + ENTRY_KEK_NONCE_LEN + ENTRY_ENCRYPTED_CEK_LEN];
        let encrypted_cek_nonce = &entries[off + ENTRY_SALT_LEN + ENTRY_KEK_NONCE_LEN + ENTRY_ENCRYPTED_CEK_LEN ..off + ENTRY_SIZE];

        // 使用密码和盐值推导密钥加密密钥(KEK)。
        let kek = match derive_kek(pw_bytes, salt) {
            Ok(k) => k,
            Err(_) => continue, // 如果 KEK 推导失败,跳到下一个条目。
        };

        // 使用 KEK 和 KEK nonce 通过 ChaCha XOR 解密 CEK。
        let cek_candidate = match chacha_xor(&kek, kek_nonce, encrypted_cek) {
            Ok(v) => v,
            Err(_) => continue, // 如果解密失败,跳到下一个条目。
        };

        // 使用相同的 KEK 和 KEK nonce 解密 CEK nonce。
        let cek_nonce_candidate = match chacha_xor(&kek, kek_nonce, encrypted_cek_nonce) {
            Ok(v) => v,
            Err(_) => continue, // 如果解密失败,跳到下一个条目。
        };

        // 验证解密的 CEK 和 nonce 长度是否正确。
        if cek_candidate.len() != CEK_LEN || cek_nonce_candidate.len() != CEK_NONCE_LEN {
            continue; // 如果长度无效,跳到下一个条目。
        }

        // 使用候选 CEK 和 nonce 解密验证块。
        let verify_block_candidate = match chacha_xor(&cek_candidate, &cek_nonce_candidate, encrypted_verify_block) {
            Ok(v) => v,
            Err(_) => continue, // 如果解密失败,跳到下一个条目。
        };

        // 使用候选 CEK 计算解密验证块的 HMAC。
        let mut h = HmacSha256::new_from_slice(&cek_candidate).expect("HMAC 初始化失败");
        h.update(&verify_block_candidate);
        let sum = h.finalize().into_bytes();

        // 检查计算的 HMAC 是否与存储的验证块 HMAC 匹配(使用常量时间比较)。
        if sum.ct_eq(verify_block_hmac).unwrap_u8() == 1 {
            // 如果 HMAC 匹配,保存有效的 CEK 和 nonce,并标记密码已找到。
            cek.copy_from_slice(&cek_candidate[..CEK_LEN]);
            cek_nonce.copy_from_slice(&cek_nonce_candidate[..CEK_NONCE_LEN]);
            found = true;
            break; // 找到有效密钥,退出循环。
        }
    }

    // 如果没有找到有效密码,返回错误。
    if !found {
        bail!("提供的密码不匹配任何加密密钥");
    }

    // 创建输出文件以写入解密内容。
    let mut output_file = File::create(output_path).context("创建输出文件失败")?;

    // 使用 CEK 和 nonce 初始化 ChaCha20 流密码,用于解密文件内容。
    let mut cipher = ChaCha20::new(&cek.into(), &cek_nonce.into());

    // 使用 CEK 初始化 HMAC 计算器,用于计算解密文件内容的 HMAC。
    let mut hmac_hasher = HmacSha256::new_from_slice(&cek).expect("HMAC 初始化失败");

    // 用于读取和解密文件块的缓冲区(每次 8KB)。
    let mut buf = [0u8; 8192];

    // 分块读取加密文件内容,解密后写入输出文件。
    loop {
        // 从输入文件中读取一块数据到缓冲区。
        let n = input_file.read(&mut buf)?;
        if n == 0 {
            break; // 文件末尾,退出循环。
        }

        // 使用 ChaCha20 流密码解密该块数据。
        let mut chunk = buf[..n].to_vec();
        cipher.apply_keystream(&mut chunk);

        // 将解密后的数据块写入输出文件。
        output_file.write_all(&chunk)?;

        // 更新 HMAC 计算器,加入解密后的数据块。
        hmac_hasher.update(&chunk);
    }

    // 计算解密文件内容的最终 HMAC。
    let computed_hmac = hmac_hasher.finalize().into_bytes();

    // 使用常量时间比较,检查计算的 HMAC 是否与存储的原始文件 HMAC 匹配。
    let matched = computed_hmac.ct_eq(original_file_hmac).unwrap_u8() == 1;

    // 返回 HMAC 匹配结果和元数据。
    Ok((matched, metadata))
}

示例用法

decrypt_file("加密文件路径", "解密文件输出路径", "密码").unwrap();

修改密码等功能,可以来 GitHub 查看完整代码。