1个不稳定版本

0.1.0 2023年6月28日

#2203密码学

CC0-1.0 OR Apache-2.0

16KB
131

BLAKE3-AEAD

一个基于BLAKE3哈希函数构建的**实验性**认证加密算法

主要目标

  • 作为TLS-like用例中AES-GCM的替代品
  • 对短消息(约1 KiB)的高性能
  • 在标准的BLAKE3 API的术语下定义

一些有用的特性

  • nonce可以达到64字节。
  • 最大消息和AAD长度相对较大,分别为262和262-1字节
  • 消息和相关数据可以并行处理,每个处理一次,无需事先知道它们的长度。
  • 将认证标签截断为N位,保留预期的O(2N)位安全性。 (正确?)
  • 紧凑的实现可以直接与BLAKE3压缩函数一起工作,并省略树哈希部分。

尖锐的边缘

  • 唯一的nonces是调用者的责任。
  • nonce的重用会对安全性造成灾难性影响。
  • 解密必须要么缓冲整个消息,要么处理未认证的明文。
  • 没有密钥承诺

此外:所有这些缺点与AES-GCM和ChaCha20-Poly1305相同。这些以TLS为导向的加密算法将字节在线和短消息性能置于首位,在我看来,这使得它们成为“危险”的构建块,仅供专家使用。BLAKE3-AEAD旨在实现相同的使用案例并做出相同的权衡。对于具有较少尖锐边缘的更通用设计,请参阅Bessie

设计

一些BLAKE3背景

此设计依赖于BLAKE3的两个特别功能。首先,BLAKE3具有内置的密钥模式。它通过用调用者的密钥替换标准IV来实现,且不需要任何额外的压缩。

其次,BLAKE3支持可扩展输出。扩展输出块是通过递增压缩函数内部t参数产生的,这允许并行计算块或定位到输出流的任何点。这使得扩展输出成为一个自然的流加密算法,同时也允许我们使用定位来实现域分离,类似于可调整块加密中的调整。

通用哈希

本文件中的Python代码示例摘自blake3_aead.py

TAG_LEN = 16
BLOCK_LEN = 64

def universal_hash(key, message, initial_seek):
    output = bytes(TAG_LEN)
    for block_start in range(0, len(message), BLOCK_LEN):
        block = message[block_start : block_start + BLOCK_LEN]
        seek = initial_seek + block_start
        block_output = blake3(block, key=key).digest(length=TAG_LEN, seek=seek)
        output = xor(output, block_output)
    return output

换句话说

  • 将消息分割成64字节块,最后一个块可能较短。
  • 分别计算每个块的密钥BLAKE3哈希。
  • 对于每个块输出,在输出流中定位到等于initial_seek加上消息位置的位置。取16字节。
  • 将所有16字节的标签进行XOR运算以形成输出。

XOR结构使得可以并行计算所有块。常规BLAKE3树结构也可以并行化,但只能在1 KiB块粒度上并行化,而universal_hash可以在64字节块上并行化。这对于短消息性能很重要。

此函数的安全属性旨在与AES-GCM的GHASH或ChaCha20-Poly1305的(未掩码)Poly1305类似。"通用哈希"是一个有些学术性的术语,但可以这样说,这些比常规的密钥哈希如BLAKE3或HMAC要弱得多。它们就像危险品一样危险。

initial_seek参数用于下面的域分离。

加密

MSG_SEEK = 2**63
AAD_SEEK = 2**63 + 2**62

def encrypt(key, nonce, aad, plaintext):
    stream = blake3(nonce, key=key).digest(length=len(plaintext) + TAG_LEN)
    ciphertext_msg = xor(plaintext, stream[: len(plaintext)])
    msg_tag = universal_hash(key, ciphertext_msg, MSG_SEEK)
    aad_tag = universal_hash(key, aad, AAD_SEEK)
    tag = xor(stream[len(plaintext) :], xor(msg_tag, aad_tag))
    return ciphertext_msg + tag

BLAKE3 XOF支持最多264-1个输出字节。输出空间分为三个部分,密钥流从偏移量0开始,消息验证器从偏移量263开始,相关数据验证器从偏移量263+262开始。流密码产生比消息长度多16字节的输出,这些额外的字节用于掩码组合认证标签。

解密

def decrypt(key, nonce, aad, ciphertext):
    plaintext_len = len(ciphertext) - TAG_LEN
    ciphertext_msg = ciphertext[:plaintext_len]
    stream = blake3(nonce, key=key).digest(length=len(ciphertext))
    msg_tag = universal_hash(key, ciphertext_msg, MSG_SEEK)
    aad_tag = universal_hash(key, aad, AAD_SEEK)
    expected_tag = xor(stream[plaintext_len:], xor(msg_tag, aad_tag))
    if not compare_digest(expected_tag, ciphertext[plaintext_len:]):
        raise ValueError("invalid ciphertext")
    return xor(ciphertext_msg, stream[:plaintext_len])

理由

验证器结构

从广义上讲,构建验证器有两种选择

  • 使用长期密钥计算它并用密钥流掩码(本设计所做)。
  • 使用nonce派生的密钥并发布未掩码的密钥。

派生子密钥可能会始终涉及对压缩函数的调用,而掩码有时是免费的,如果消息的最后一个块恰好是48字节或更少。请注意,如果您使用标签掩码,您不需要它直到加密过程的末尾,因此从流尾获取它很自然。但如果你使用派生的子密钥,你需要它们在开始时,使用流尾会显得笨拙。

"在流末尾添加16字节"对于实现来说也比"在流前预留一个块"更容易并行化。理想情况下,您可以从一个函数调用中获取整个流,但那时该调用需要输出空间来写入流字节。最自然的地方是调用者的输出缓冲区,特别是如果您可以直接将流XOR到明文上。该缓冲区已经在末尾预留了16字节用于标签,因此使用这些字节作为临时空间是免费的。但要求调用者提供前端的临时空间会显得笨拙

请注意,直接使用未掩码的universal_hash是不安全的,因为当输入为空时,无论密钥如何,其输出都是全零。我们可以通过调整定义来改变这一点,使空输入仍然被视为一个块。另一方面,当AAD为空时,不调用压缩函数是件好事。

滥用抵抗性

将明文的MAC纳入密钥流,以提供对nonce重用的某些抵抗性可能会很棒。但这有一些性能上的缺点:加密需要输入两次遍历,接收方必须在拒绝一个坏数据包之前完成所有解密工作。

搜索常数

我们可以将XOF输出空间分成三个大约相等的部分,而不是当前分配一半给密钥流的布局。然而,将最大消息大小从262字节增加到~262.4字节几乎没有任何实际价值,并且使MSG_SEEKAAD_SEEK常数保持简单更好。

Nonce长度

支持大于64字节的nonce对任何使用BLAKE3库构建的实现来说都是微不足道的,实际上上面的代码示例也已经做到了(因为省略了一些断言)。然而,并非所有实现都希望携带完整的BLAKE3哈希函数。紧凑的实现可能更愿意直接使用压缩函数,并省略树哈希部分。将nonce限制为64字节允许这些紧凑的实现,而64字节已经相当慷慨了。相比之下,XSalsa和XChaCha的扩展nonce仅为24字节。

依赖项

~1.5MB
~39K SLoC