NIP-44加密格式详解

nostr-protocol 发布于 2024-12-21 阅读 72

NIP-44是Nostr协议中一种新的加密负载格式,用于密钥对加密。它定义了版本化的加密算法,当前版本2使用secp256k1 ECDH、HKDF、ChaCha20和HMAC-SHA256。文章详细介绍了加密和解密流程,包括会话密钥生成、消息密钥派生、填充方案、MAC计算以及Base64编码。还讨论了协议的局限性和安全注意事项,并提供了实现伪代码和测试向量。该标准旨在为Nostr事件提供简单而安全的加密,但缺少前向保密和否认性。

加密负载(版本化)

可选

NIP 引入了一种新的基于密钥对的加密数据格式。此 NIP 支持版本化,允许同时存在多种算法选择。该格式可用于多种用途,但必须在 NIP-01 中定义的签名事件上下文中使用。

注意:此格式定义与新的直接消息标准相关的任何 kind,仅定义实现该标准所需的加密。它不应被用作 NIP-04 负载的直接替代品。

版本

当前定义的加密算法:

  • 0x00 - 保留
  • 0x01 - 已弃用且未定义
  • 0x02 - secp256k1 ECDH、HKDF、填充、ChaCha20、HMAC-SHA256、base64

局限性

每个 nostr 用户都有自己的公钥,这解决了其他解决方案中存在的密钥分发问题。然而,nostr 基于中继的架构使得实现更强大的私人消息协议变得困难,这类协议通常具备元数据隐藏、前向保密和泄露后安全等特性。

此 NIP 的目标是在签名事件上下文中提供一种简单的加密负载方法。在将此 NIP 应用于任何用例时,务必牢记用户的威胁模型和此 NIP 的局限性。对于高风险情况,用户应在专门的端到端加密消息软件中聊天,并限制将 nostr 仅用于交换联系方式。

就其本身而言,使用此方案发送的消息具有以下重要缺点:

  • 无可否认性:可以证明事件是由特定密钥签名的
  • 无前向保密性:当密钥泄露时,可以解密所有先前的对话
  • 无泄露后安全性:当密钥泄露时,可以解密所有未来的对话
  • 无后量子安全性:强大的量子计算机将能够解密消息
  • IP 地址泄露:用户的 IP 可能被中继以及用户与中继之间的所有中介看到
  • 时间戳泄露:created_at 是公开的,因为它是 NIP-01 事件的一部分
  • 有限的消息大小泄露:填充仅部分隐藏真实消息长度
  • 无附件:不支持附件

前向保密性的缺失可以通过仅将消息发送到受信任的中继,并要求它们在一定时间后删除存储的消息来部分缓解。

版本 2

NIP-44 版本 2 具有以下设计特点:

  • 负载在签名之前使用 MAC 进行身份验证,而不是之后,因为事件假定按照 NIP-01 进行签名。外部签名用于验证整个负载,必须在解密前验证。
  • 使用 ChaCha 而不是 AES,因为它更快,并且对多密钥攻击有更好的安全性
  • 使用 ChaCha 而不是 XChaCha,因为 XChaCha 尚未标准化。此外,xChaCha 改进的 nonce 抗碰撞性并非必需,因为每条消息都使用新的(密钥、nonce)对。
  • 使用 HMAC-SHA256 而不是 Poly1305,因为多项式 MAC 更容易伪造。
  • 使用 SHA256 而不是 SHA3 或 BLAKE,因为它已在 nostr 中使用。此外,在非并行环境中,BLAKE 的速度优势较小。
  • 使用自定义填充方案而不是 padmé,因为它能更好地减少小消息的泄漏。
  • 使用 Base64 编码而不是其他编码算法,因为它被广泛使用,并且已在 nostr 中使用。

加密

  1. 计算对话密钥
    • 执行 ECDH(标量乘法):公钥 B 乘以私钥 A 输出 shared_x 必须是共享点未哈希的 32 字节 x 坐标
    • 使用 HKDF-extract,参数为 sha256、IKM=shared_xsalt=utf8_encode('nip44-v2')
    • HKDF 输出将是两个用户之间的 conversation_key
    • 当密钥角色互换时,它始终相同:conv(a, B) == conv(b, A)
  2. 生成随机的 32 字节 nonce
    • 始终使用 CSPRNG
    • 不要从消息内容生成 nonce
    • 切勿在消息之间重复使用相同的 nonce:这样做会使消息可解密,但不会泄露长期密钥
  3. 计算消息密钥
    • 密钥由 conversation_keynonce 生成。验证两者均为 32 字节长
    • 使用 HKDF-expand,参数为 sha256、PRK=conversation_keyinfo=nonceL=76
    • 将 76 字节的 HKDF 输出切片为:chacha_key(字节 0..32)、chacha_nonce(字节 32..44)、hmac_key(字节 44..76)
  4. 添加填充
    • 内容必须从 UTF-8 编码为字节数组
    • 验证明文长度。最小为 1 字节,最大为 65535 字节
    • 填充格式为:[plaintext_length: u16][plaintext][zero_bytes]
    • 填充算法基于 2 的幂,最小填充消息大小为 32 字节
    • 明文长度以大端序编码为填充 blob 的前 2 个字节
  5. 加密填充后的内容
    • 使用 ChaCha20,密钥和 nonce 来自步骤 3
  6. 计算 MAC(消息认证码)
    • 使用 AAD(附加认证数据)——不是对密文计算 MAC,而是计算 nonceciphertext 拼接结果上的 MAC
    • 验证 AAD(即 nonce)为 32 字节
  7. 使用 concat(version, nonce, ciphertext, mac) 对参数进行 Base64 编码(带填充)

加密负载必须包含在事件负载中,进行哈希和签名,如 NIP-01 中定义,使用 secp256k1 上的 Schnorr 签名方案。

解密

在解密之前,必须按照 NIP-01 的定义验证事件的公钥和签名。公钥必须是有效的非零 secp256k1 曲线点,签名必须是有效的 secp256k1 Schnorr 签名。有关确切的验证规则,请参阅 BIP-340。

  1. 检查第一个负载字符是否为 #
    • # 是一个可选的未来兼容性标志,表示使用了非 base64 编码
    • # 不存在于 base64 字母表中,但实现必须指示加密版本尚不受支持,而不是抛出 base64 is invalid 错误
  2. 解码 Base64
    • Base64 解码为 version, nonce, ciphertext, mac
    • 如果版本未知,实现必须提示加密版本不受支持
    • 验证 base64 消息的长度,以防止对 base64 解码器的拒绝服务攻击:长度范围应在 132 到 87472 个字符之间
    • 验证解码后消息的长度,以检查解码器的输出:长度范围应在 99 到 65603 字节之间
  3. 计算对话密钥
  4. 计算消息密钥
  5. 使用 AAD 计算 MAC(消息认证码)并进行比对
    • 如果 MAC 与步骤 2 中解码的 MAC 不匹配,则停止并抛出错误
    • 使用常量时间比较算法
  6. 解密密文
    • 使用 ChaCha20,密钥和 nonce 来自步骤 3
  7. 移除填充
    • 读取明文的头两个大端序字节,它们对应明文长度
    • 验证切片明文的长度与这两个大端序字节的值匹配
    • 验证加密过程步骤 3 中计算的填充与实际填充一致

细节

  • 加密方法
    • secure_random_bytes(length) 从 CSPRNG 获取随机数
    • hkdf(IKM, salt, info, L) 表示 HKDF(RFC 5869),使用 SHA256 哈希函数,由方法 hkdf_extract(IKM, salt)hkdf_expand(OKM, info, L) 组成
    • chacha20(key, nonce, data) 是 ChaCha20(RFC 8439),起始计数器设置为 0
    • hmac_sha256(key, message) 是 HMAC(RFC 2104
    • secp256k1_ecdh(priv_a, pub_b) 是点 B 与标量 a 的乘法(a ⋅ B),定义于 BIP340。该操作产生一个共享点,我们使用 BIP340 中的方法 bytes(P) 编码共享点的 32 字节 x 坐标。私钥和公钥必须按照 BIP340 进行验证:公钥必须是有效的、在曲线上的点,私钥必须是范围 [1, secp256k1_order - 1] 内的标量。NIP-44 不对输出进行哈希处理,请记住这一点,因为某些库使用 sha256 对其进行哈希处理。例如,在 libsecp256k1 中,未哈希的版本可通过 secp256k1_ec_pubkey_tweak_mul 获得
  • 运算符
    • x[i:j],其中 x 是字节数组,i, j <= 0 返回一个 (j - i) 字节的数组,包含 x 中从第 i 字节(含)到第 j 字节(不含)的副本
  • 常量 c
    • min_plaintext_size 为 1。1 字节消息填充到 32 字节
    • max_plaintext_size 为 65535(64kB - 1),填充到 65536 字节
  • 函数
    • base64_encode(string)base64_decode(bytes) 是 Base64(RFC 4648,带填充)
    • concat 指字节数组拼接
    • is_equal_ct(a, b) 是两个字节数组的常量时间相等性检查
    • utf8_encode(string)utf8_decode(bytes) 在字符串与字节数组之间相互转换
    • write_u8(number) 将数字限制在 0..255 范围内,并编码为大端序 uint8 字节数组
    • write_u16_be(number) 将数字限制在 0..65535 范围内,并编码为大端序 uint16 字节数组
    • zeros(length) 创建长度为 length >= 0 的字节数组,填充为零
    • floor(number)log2(number) 是众所周知的数学方法

实现伪代码

以下是类似 Python 的伪代码函数集合,实现了上述原语,旨在指导实现者。不同语言的实现集合可在 https://github.com/paulmillr/nip44 获取。

## 计算填充后字节数组的长度。
def calc_padded_len(unpadded_len):
  next_power = 1 << (floor(log2(unpadded_len - 1))) + 1
  if next_power <= 256:
    chunk = 32
  else:
    chunk = next_power / 8
  if unpadded_len <= 32:
    return 32
  else:
    return chunk * (floor((len - 1) / chunk) + 1)

## 将未填充的明文转换为填充后的字节数组
def pad(plaintext):
  unpadded = utf8_encode(plaintext)
  unpadded_len = len(plaintext)
  if (unpadded_len < c.min_plaintext_size or
      unpadded_len > c.max_plaintext_size): raise Exception('invalid plaintext length')
  prefix = write_u16_be(unpadded_len)
  suffix = zeros(calc_padded_len(unpadded_len) - unpadded_len)
  return concat(prefix, unpadded, suffix)

## 将填充后的字节数组转换为未填充的明文
def unpad(padded):
  unpadded_len = read_uint16_be(padded[0:2])
  unpadded = padded[2:2+unpadded_len]
  if (unpadded_len == 0 or
      len(unpadded) != unpadded_len or
      len(padded) != 2 + calc_padded_len(unpadded_len)): raise Exception('invalid padding')
  return utf8_decode(unpadded)

## 元数据:始终 65b(版本:1b,nonce:32b,最大:32b)
## 明文:1b 到 0xffff
## 填充后的明文:32b 到 0xffff
## 密文:32b+2 到 0xffff+2
## 原始负载:99(65+32+2)到 65603(65+0xffff+2)
## 压缩负载(base64):132b 到 87472b
def decode_payload(payload):
  plen = len(payload)
  if plen == 0 or payload[0] == '#': raise Exception('unknown version')
  if plen < 132 or plen > 87472: raise Exception('invalid payload size')
  data = base64_decode(payload)
  dlen = len(d)
  if dlen < 99 or dlen > 65603: raise Exception('invalid data size');
  vers = data[0]
  if vers != 2: raise Exception('unknown version ' + vers)
  nonce = data[1:33]
  ciphertext = data[33:dlen - 32]
  mac = data[dlen - 32:dlen]
  return (nonce, ciphertext, mac)

def hmac_aad(key, message, aad):
  if len(aad) != 32: raise Exception('AAD associated data must be 32 bytes');
  return hmac(sha256, key, concat(aad, message));

## 计算用户 A 和 B 之间的长期密钥:`get_key(Apriv, Bpub) == get_key(Bpriv, Apub)`
def get_conversation_key(private_key_a, public_key_b):
  shared_x = secp256k1_ecdh(private_key_a, public_key_b)
  return hkdf_extract(IKM=shared_x, salt=utf8_encode('nip44-v2'))

## 计算唯一的每消息密钥
def get_message_keys(conversation_key, nonce):
  if len(conversation_key) != 32: raise Exception('invalid conversation_key length')
  if len(nonce) != 32: raise Exception('invalid nonce length')
  keys = hkdf_expand(OKM=conversation_key, info=nonce, L=76)
  chacha_key = keys[0:32]
  chacha_nonce = keys[32:44]
  hmac_key = keys[44:76]
  return (chacha_key, chacha_nonce, hmac_key)

def encrypt(plaintext, conversation_key, nonce):
  (chacha_key, chacha_nonce, hmac_key) = get_message_keys(conversation_key, nonce)
  padded = pad(plaintext)
  ciphertext = chacha20(key=chacha_key, nonce=chacha_nonce, data=padded)
  mac = hmac_aad(key=hmac_key, message=ciphertext, aad=nonce)
  return base64_encode(concat(write_u8(2), nonce, ciphertext, mac))

def decrypt(payload, conversation_key):
  (nonce, ciphertext, mac) = decode_payload(payload)
  (chacha_key, chacha_nonce, hmac_key) = get_message_keys(conversation_key, nonce)
  calculated_mac = hmac_aad(key=hmac_key, message=ciphertext, aad=nonce)
  if not is_equal_ct(calculated_mac, mac): raise Exception('invalid MAC')
  padded_plaintext = chacha20(key=chacha_key, nonce=chacha_nonce, data=ciphertext)
  return unpad(padded_plaintext)

## 用法:
##   conversation_key = get_conversation_key(sender_privkey, recipient_pubkey)
##   nonce = secure_random_bytes(32)
##   payload = encrypt('hello world', conversation_key, nonce)
##   'hello world' == decrypt(payload, conversation_key)

审计

该标准 v2 版本于 2023 年 12 月由 Cure53 进行审计。请查看 audit-2023.12.pdf审计员网站

测试和代码

不同语言的实现集合可在 https://github.com/paulmillr/nip44 获取。

我们发布了详尽的测试向量。这些向量并未直接放在文档中,而是提供了向量的 sha256 校验和:

269ed0f69e4c192512cc779e78c555090cebc7c785b609e338a62afc3ce25040  nip44.vectors.json

文件中测试向量的示例:

{
  "sec1": "0000000000000000000000000000000000000000000000000000000000000001",
  "sec2": "0000000000000000000000000000000000000000000000000000000000000002",
  "conversation_key": "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d",
  "nonce": "0000000000000000000000000000000000000000000000000000000000000001",
  "plaintext": "a",
  "payload": "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
}

该文件还包含中间值。以下是关于其用法的快速指南:

  • valid.get_conversation_key:从密钥 sec1 和公钥 pub2 计算 conversation_key
  • valid.get_message_keys:从 conversation_key 和 nonce 计算 chacha_key、chacha_nonce、hmac_key
  • valid.calc_padded_len:获取未填充的长度(第一个值),计算填充后的长度(第二个值)
  • valid.encrypt_decrypt:模拟真实对话。从 sec2 计算 pub2,验证来自 (sec1, pub2) 的 conversation_key,加密,验证负载;然后从 sec1 计算 pub1,验证来自 (sec2, pub1) 的 conversation_key,解密,验证明文
  • valid.encrypt_decrypt_long_msg:与上一步相同,但提供完整明文和负载的校验和
  • invalid.encrypt_msg_lengths
  • invalid.get_conversation_key:计算 conversation_key 必须抛出错误
  • invalid.decrypt:解密消息内容必须抛出错误
  • 原文链接: github.com/nostr-protoco...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~

相关文章

0 条评论