NIP-44加密格式详解
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 中使用。
加密
- 计算对话密钥
- 执行 ECDH(标量乘法):公钥 B 乘以私钥 A
输出
shared_x必须是共享点未哈希的 32 字节 x 坐标 - 使用 HKDF-extract,参数为 sha256、
IKM=shared_x和salt=utf8_encode('nip44-v2') - HKDF 输出将是两个用户之间的
conversation_key - 当密钥角色互换时,它始终相同:
conv(a, B) == conv(b, A)
- 执行 ECDH(标量乘法):公钥 B 乘以私钥 A
输出
- 生成随机的 32 字节 nonce
- 始终使用 CSPRNG
- 不要从消息内容生成 nonce
- 切勿在消息之间重复使用相同的 nonce:这样做会使消息可解密,但不会泄露长期密钥
- 计算消息密钥
- 密钥由
conversation_key和nonce生成。验证两者均为 32 字节长 - 使用 HKDF-expand,参数为 sha256、
PRK=conversation_key、info=nonce和L=76 - 将 76 字节的 HKDF 输出切片为:
chacha_key(字节 0..32)、chacha_nonce(字节 32..44)、hmac_key(字节 44..76)
- 密钥由
- 添加填充
- 内容必须从 UTF-8 编码为字节数组
- 验证明文长度。最小为 1 字节,最大为 65535 字节
- 填充格式为:
[plaintext_length: u16][plaintext][zero_bytes] - 填充算法基于 2 的幂,最小填充消息大小为 32 字节
- 明文长度以大端序编码为填充 blob 的前 2 个字节
- 加密填充后的内容
- 使用 ChaCha20,密钥和 nonce 来自步骤 3
- 计算 MAC(消息认证码)
- 使用 AAD(附加认证数据)——不是对密文计算 MAC,而是计算
nonce和ciphertext拼接结果上的 MAC - 验证 AAD(即 nonce)为 32 字节
- 使用 AAD(附加认证数据)——不是对密文计算 MAC,而是计算
- 使用
concat(version, nonce, ciphertext, mac)对参数进行 Base64 编码(带填充)
加密负载必须包含在事件负载中,进行哈希和签名,如 NIP-01 中定义,使用 secp256k1 上的 Schnorr 签名方案。
解密
在解密之前,必须按照 NIP-01 的定义验证事件的公钥和签名。公钥必须是有效的非零 secp256k1 曲线点,签名必须是有效的 secp256k1 Schnorr 签名。有关确切的验证规则,请参阅 BIP-340。
- 检查第一个负载字符是否为
##是一个可选的未来兼容性标志,表示使用了非 base64 编码#不存在于 base64 字母表中,但实现必须指示加密版本尚不受支持,而不是抛出base64 is invalid错误
- 解码 Base64
- Base64 解码为
version, nonce, ciphertext, mac - 如果版本未知,实现必须提示加密版本不受支持
- 验证 base64 消息的长度,以防止对 base64 解码器的拒绝服务攻击:长度范围应在 132 到 87472 个字符之间
- 验证解码后消息的长度,以检查解码器的输出:长度范围应在 99 到 65603 字节之间
- Base64 解码为
- 计算对话密钥
- 参见加密的步骤 1
- 计算消息密钥
- 参见加密的步骤 3
- 使用 AAD 计算 MAC(消息认证码)并进行比对
- 如果 MAC 与步骤 2 中解码的 MAC 不匹配,则停止并抛出错误
- 使用常量时间比较算法
- 解密密文
- 使用 ChaCha20,密钥和 nonce 来自步骤 3
- 移除填充
- 读取明文的头两个大端序字节,它们对应明文长度
- 验证切片明文的长度与这两个大端序字节的值匹配
- 验证加密过程步骤 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),起始计数器设置为 0hmac_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_keyvalid.get_message_keys:从 conversation_key 和 nonce 计算 chacha_key、chacha_nonce、hmac_keyvalid.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_lengthsinvalid.get_conversation_key:计算 conversation_key 必须抛出错误invalid.decrypt:解密消息内容必须抛出错误
- 原文链接: github.com/nostr-protoco...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~