文章目录

引言
单个密码算法解决不了一个完整系统的安全问题。哈希函数能发现数据变化,却不能隐藏明文;加密能隐藏明文,却不必然防篡改;HMAC 能验证消息,却不能防止旧消息被重放;密钥派生能把共享秘密变成密钥,却不能替你判断这个秘密是否足够强;数字签名能提供第三方可验证的来源证明,却无法保证消息一定送达。
真实的密码学工程从来不是“选一个最强算法”这么简单,而是把机密性、完整性、身份认证、防重放、密钥管理、协议版本、错误处理和迁移能力组合成一个可运行的协议。
设想一个场景:发送方要给接收方传递一条敏感约会消息。攻击者可能偷看、篡改、替换、延迟、重放甚至阻断这条消息。双方可能没有在线通信能力,只能依赖不可信信道传递消息。怎样设计一个协议,让接收方能读到真正的内容,并尽量确认消息没有被动过手脚?
这个故事足够朴素,却覆盖了安全协议设计中最常见的问题。
一、先拆需求,而不是先选算法
很多系统的安全设计一开始就跑偏,是因为问题还没拆清楚,就开始争论“用 AES 还是 ChaCha20”“用 SHA-256 还是 SHA-512”。算法选择当然重要,但它只能服务于明确的安全目标。
一条安全消息至少涉及六类需求。
第一,身份识别。接收方需要知道消息声称来自谁。比如消息里写着 sender_id=niulang,这是身份识别,但还不是身份认证。
第二,身份认证。接收方需要验证消息确实由对应发送方生成,而不是攻击者冒名伪造。
第三,机密性。没有权限的人即使拿到消息,也不能读出明文。
第四,完整性。攻击者不能悄悄修改密文、公开头部、算法参数或业务字段,并让接收方无感接受。
第五,新鲜性。旧消息不能被拿来重复使用。一次合法的“同意支付 100 元”不能被重放一百次。
第六,可用性。协议应尽量提高正确消息被接收和处理的概率,但密码学不能保证消息一定送达。传输阻断、删除、网络中断、接收方离线,最终要靠通信系统、重试机制和业务流程处理。
另外还有一个经常被混淆的需求:不可抵赖。不可抵赖意味着事后能向第三方证明“这条消息就是某个发送方发的”。共享对称密钥通常做不到严格不可抵赖,因为双方都知道同一个密钥,任何一方理论上都能伪造消息。要做第三方可验证的不可抵赖,通常需要数字签名、可信时间戳、审计日志或第三方见证。
因此,一个实用协议的目标可以写得更具体:
发送方和接收方共享或协商出会话密钥;
消息明文只对接收方可见;
公开元数据和密文都受到完整性保护;
接收方能识别密钥版本、算法版本和消息用途;
攻击者不能重放旧消息触发新的业务效果;
系统未来能迁移算法和轮换密钥。
有了这些目标,再选算法才不会变成堆名词。
二、威胁模型:攻击者能做什么
设计协议时要默认信道不可信。攻击者至少具备这些能力:
- 窃听:读取所有经过信道的消息;
- 篡改:修改消息中的任意字节;
- 删除:让消息永远到不了接收方;
- 插入:伪造新消息;
- 调包:把 A 的消息替换成 B 的消息;
- 重放:把过去截获的合法消息再次发送;
- 延迟:在未来某个时间再投递旧消息;
- 选择输入:诱导发送方加密某些可控内容;
- 观察错误:通过服务端错误差异推断内部状态。
密码学能很好地处理窃听、篡改、伪造和重放;对删除和阻断只能降低动机或配合传输层重试,不能从根本上解决。攻击者把所有消息都丢掉,任何加密算法都无能为力。
这个边界必须提前说清楚,否则协议会背上不可能完成的任务。
三、密钥从哪里来
安全消息协议的核心不是“怎么加密”,而是“密钥从哪里来、如何使用、何时失效”。
常见密钥来源有三类。
1. 预共享高熵密钥
如果双方已经通过安全渠道共享了一个随机生成的高熵密钥,例如 256 位随机数,那么后续可以用 HKDF 派生不同用途的子密钥:
master_secret = random_256_bit_secret
enc_key = HKDF(master_secret, salt, info="msg/encrypt/v1")
ack_key = HKDF(master_secret, salt, info="msg/ack/v1")
这里的 info 很关键。它把同一个主秘密分隔到不同用途,避免“加密密钥、确认回执密钥、日志认证密钥混用”。这种做法叫做域分离。
HKDF 适合从高熵共享秘密或密钥交换结果中派生密钥。它不适合把人类能记住的短口令直接变成长期安全密钥,因为口令熵通常太低。
2. 低熵共享秘密
如果双方只有一个共同知道的秘密,比如一句暗号、一段口令、某个私人信息,就必须谨慎。人类秘密通常不够随机,容易被离线猜测。把它直接喂给 SHA-256 或 HKDF 得到 AES 密钥,是危险设计。
传统做法是使用基于口令的密钥派生函数,例如 PBKDF2,并加入随机盐和足够高的迭代成本:
key = PBKDF2-HMAC-SHA256(
password = shared_secret,
salt = random_salt,
iterations = high_cost,
dkLen = 32
)
演示代码里常把迭代次数写成 1,便于复现计算结果;生产系统不能这么做。NIST SP 800-63B 对密码存储的核心要求是:使用加盐且带成本因子的密码哈希方案,让攻击者每次猜测都付出足够代价,并且成本因子应随计算能力提升而调整。NIST SP 800-132 也明确把盐值、迭代次数、口令派生作为 PBKDF 的关键参数。
不过,PBKDF2 仍然无法消除一个问题:如果攻击者能拿到加密包并离线猜口令,就可以反复尝试。更现代的方向是使用 PAKE,也就是密码认证密钥交换。PAKE 的目标是让双方基于低熵口令协商强密钥,同时限制攻击者离线猜测。SPAKE2+ 已以 RFC 9383 发布;OPAQUE 作为增强型 PAKE,也已形成 RFC 9807。新系统如果真要基于口令建立会话密钥,应优先研究 PAKE,而不是自己拼 PBKDF2 流程。
3. 公钥密钥交换
如果双方没有预共享秘密,更常见的做法是使用公钥密码学建立共享密钥,例如 X25519/ECDH、TLS 1.3 握手或 HPKE。密钥交换得到共享秘密后,再用 HKDF 派生 AEAD 密钥。
简化结构如下:
shared_secret = ECDH(sender_private, receiver_public)
key_schedule = HKDF(shared_secret, salt, info)
enc_key = key_schedule.export("message encryption")
这类方案能获得更好的可扩展性,尤其适合多用户系统。发送方只需要接收方公钥,不必提前和每个接收方共享一个秘密。HPKE 就是为这种“用接收方公钥加密消息”场景设计的标准化框架。
四、为什么优先选择 AEAD
早期系统常见组合是:
ciphertext = AES-CBC(key1, plaintext)
tag = HMAC-SHA256(key2, ciphertext)
只要顺序、填充、错误处理、密钥分离全部做对,这种 Encrypt-then-MAC 可以安全。但现实中开发者经常犯错:先解密再验 MAC、把 IV 漏出认证范围、复用密钥、错误信息暴露 padding 细节、只认证密文不认证头部。
AEAD 把机密性和完整性封装到一个标准接口里,更不容易误用。RFC 5116 对 AEAD 的抽象很清晰:加密函数接收密钥、nonce、明文和关联数据,输出密文和认证标签;解密函数只有在认证通过后才返回明文。
典型接口:
ciphertext, tag = AEAD_Encrypt(key, nonce, plaintext, aad)
plaintext = AEAD_Decrypt(key, nonce, ciphertext, aad, tag)
plaintext 是需要保密的内容。aad 是不需要保密但必须防篡改的公开数据,例如协议版本、发送方、接收方、消息类型、序列号、密钥 ID、时间戳。AAD 不会被加密,但会被认证。攻击者修改 AAD 中任意一位,解密认证就会失败。
常用 AEAD 算法包括:
- AES-GCM:生态成熟,硬件加速广泛,NIST SP 800-38D 标准化;
- ChaCha20-Poly1305:软件性能好,在缺少 AES 硬件加速的平台上很有优势,RFC 8439 定义了 IETF 使用方式;
- AES-CCM:常见于部分受限设备和协议;
- XChaCha20-Poly1305:使用更长 nonce,工程上更抗随机 nonce 碰撞,但标准化生态与平台支持要逐项确认。
对大多数后端和移动场景,AES-GCM 与 ChaCha20-Poly1305 都是合理选择。已有成熟 TLS/系统库支持时,不要自己实现底层算法。
五、nonce:AEAD 最容易踩的坑
AEAD 的 nonce 常被误解成“随便生成一个随机数”。更准确地说,nonce 的核心要求是:在同一个密钥下不能重复。
ChaCha20-Poly1305 的 IETF 版本使用 96 位 nonce。AES-GCM 也通常推荐 96 位 nonce。只要同一把密钥下 nonce 重复,后果可能非常严重:不仅泄露明文关系,还可能破坏认证安全。
生成 nonce 有两种常见方式。
第一,随机生成。96 位随机 nonce 在低消息量场景下碰撞概率很低,但系统必须评估规模。如果同一密钥会加密海量消息,随机碰撞风险会累积。
第二,计数器生成。为每个密钥维护单调递增序列号,把序列号编码进 nonce。这样可以避免碰撞,但必须保证崩溃恢复、并发发送、多实例部署时不会回退或重复。
许多工程系统采用组合方式:
nonce = sender_random_prefix || message_sequence
每个会话生成一个随机前缀,消息序号递增。这样既减少状态冲突,也方便接收方做重放检测。
无论使用哪种方式,协议都应限制单个密钥可加密的消息数量。达到上限就轮换密钥,而不是无限期使用。
六、把公开字段放进 AAD
安全协议通常包含两类数据。
一类是私密明文,例如:
{
"content": "七月初七晚七点,鹊桥相会",
"location": "鹊桥",
"note": "不见不散"
}
另一类是公开元数据,例如:
{
"version": 1,
"alg": "CHACHA20-POLY1305",
"kdf": "HKDF-SHA256",
"key_id": "psk-2026-06",
"sender": "niulang",
"receiver": "zhinv",
"message_type": "date_invitation",
"sequence": 42,
"created_at": "2026-06-20T10:00:00Z"
}
公开不代表可以被修改。攻击者如果能把 receiver 改成另一个人,把 message_type 改成另一个业务动作,把 sequence 改成旧值,系统就可能误处理。因此这些字段应进入 AAD。
一个可靠的加密包可以长这样:
{
"version": 1,
"suite": "MSG-CHACHA20POLY1305-HKDFSHA256-v1",
"key_id": "psk-2026-06",
"salt": "base64...",
"nonce": "base64...",
"aad": {
"sender": "niulang",
"receiver": "zhinv",
"message_type": "date_invitation",
"sequence": 42,
"created_at": "2026-06-20T10:00:00Z"
},
"ciphertext": "base64...",
"tag": "base64..."
}
实际实现时,AAD 必须采用确定性编码。不要直接把普通 JSON 字符串拼进去,因为 JSON 字段顺序、空白、转义、数字格式都可能导致两端看到的字节不同。可以使用规范 JSON、CBOR 确定性编码、Protocol Buffers 确定性序列化,或者自定义严格的长度前缀二进制格式。
安全协议签的是字节,不是“看起来一样”的对象。
七、防篡改不等于防重放
AEAD 可以发现消息是否被修改,却不能判断消息是不是旧的。
攻击者截获一条合法消息:
sequence = 42
ciphertext = ...
tag = ...
未来再次发送同一条消息,AEAD 认证仍然会通过。因为消息确实没被改,tag 也确实正确。这就是重放攻击。
防重放需要额外状态。常见做法有三种。
第一,单调序列号。每个发送方维护递增 sequence,接收方记录已经处理到的位置。低于或等于已处理序号的消息拒绝。适合严格有序信道。
第二,滑动窗口。接收方允许一定乱序范围,记录窗口内已见序号。适合网络包可能乱序的协议。
第三,nonce 去重表。每条消息带随机 nonce 或 message_id,接收方在时间窗口内记录已处理值。适合异步业务消息,但需要存储和过期清理。
时间戳只能辅助,不能单独防重放。攻击者可以在有效时间窗口内重放消息。正确做法通常是:
timestamp + sequence/message_id + 接收方去重状态
对业务操作还要设计幂等键。即使协议层阻止了大多数重放,业务层仍应避免“同一个付款请求执行两次”。
八、身份认证:共享密钥能证明什么,不能证明什么
如果只有发送方和接收方知道同一个共享秘密,接收方能通过 AEAD tag 或 HMAC 判断:生成这条消息的人知道这份秘密。由于攻击者不知道秘密,因此伪造消息很难。
这提供的是双方之间的消息认证。
但它不提供严格不可抵赖。因为接收方也知道同一份秘密。如果发生争议,第三方无法区分这条消息是发送方生成的,还是接收方自己伪造后声称来自发送方。
要解决“翻脸不认账”,需要更强的证据结构:
- 数字签名:发送方用私钥签名,任何人可用公钥验证;
- 可信时间戳:证明某条消息在某时刻已经存在;
- 透明日志或审计日志:把消息哈希提交到只能追加的系统;
- 回执签名:接收方收到消息后,用自己的私钥签署确认;
- 第三方转发或见证:由可信服务记录发送、投递和确认事件。
在只使用对称密码的体系里,可以做“接收确认”,但这个确认仍然只在共享密钥参与方之间有证明力。要面对第三方争议,最终还是要引入非对称签名或可信第三方。
九、确认回执:怎样证明“对方收到了”
消息送达确认不是加密本身能解决的问题。一个合理设计是让接收方在成功解密并验证后返回回执:
{
"type": "ack",
"for_message_id": "msg-2026-000042",
"receiver": "zhinv",
"status": "decrypted",
"received_at": "2026-06-20T10:03:00Z"
}
如果双方只有共享密钥,回执可以用独立派生的 ack_key 做 HMAC 或 AEAD 认证:
ack_key = HKDF(master_secret, salt, info="msg/ack/v1")
ack_tag = HMAC-SHA256(ack_key, canonical_ack)
这能让发送方相信“知道共享秘密的一方确认收到”。但仍然无法向第三方证明接收方不能抵赖,因为发送方也能计算同样的 HMAC。
如果要不可抵赖的回执,接收方应使用私钥签名:
receipt_sig = Sign(receiver_private_key, hash(canonical_ack))
发送方保存原消息、消息哈希、投递时间、接收方签名回执和证书链。这样争议发生时,第三方可以验证接收方确实签过这个回执。
工程上还要注意:回执只证明接收方系统处理过消息,不一定证明人类读过内容。要证明“人已阅读”,还需要明确的人机交互确认和审计策略。
十、一个现代安全消息协议草案
下面给出一个更工程化的协议骨架。它不试图替代 TLS、Signal、MLS 或 HPKE 这类成熟协议,而是帮助理解各字段为什么存在。
1. 协议套件
suite = MSG-v1-CHACHA20POLY1305-HKDFSHA256
套件固定以下选择:
- KDF:HKDF-SHA256;
- AEAD:ChaCha20-Poly1305;
- nonce:96 位;
- tag:128 位;
- AAD 编码:确定性 CBOR;
- 明文编码:UTF-8 JSON 或业务二进制格式。
如果密钥来自口令,不直接进入该套件,而是先通过 PAKE 或合规的口令派生流程建立高熵 master_secret。
2. 密钥派生
prk = HKDF-Extract(salt, master_secret)
enc_key = HKDF-Expand(prk, "MSG-v1 encryption", 32)
nonce_key = HKDF-Expand(prk, "MSG-v1 nonce", 32)
ack_key = HKDF-Expand(prk, "MSG-v1 ack", 32)
不同用途独立派生,避免一个密钥承担所有职责。
3. 构造 AAD
{
"version": 1,
"suite": "MSG-v1-CHACHA20POLY1305-HKDFSHA256",
"key_id": "psk-2026-06",
"sender": "niulang",
"receiver": "zhinv",
"message_id": "msg-2026-000042",
"sequence": 42,
"created_at": "2026-06-20T10:00:00Z",
"expires_at": "2026-06-20T10:10:00Z",
"purpose": "date_invitation"
}
这些字段不保密,但必须防篡改。
4. 加密
aad_bytes = deterministic_encode(aad)
plaintext_bytes = encode(plaintext)
nonce = make_unique_nonce(sender_id, key_id, sequence)
ciphertext, tag = AEAD_Encrypt(enc_key, nonce, plaintext_bytes, aad_bytes)
输出包:
{
"aad": { ... },
"salt": "base64...",
"nonce": "base64...",
"ciphertext": "base64...",
"tag": "base64..."
}
5. 解密验证
接收方处理顺序非常重要:
- 解析外层格式,但不信任任何字段;
- 检查
version和suite是否支持; - 根据
key_id找到候选密钥; - 重新确定性编码 AAD;
- 检查
receiver是否是自己; - 检查
expires_at是否过期; - 检查
sequence/message_id是否已处理; - 调用 AEAD 解密;
- 认证失败返回统一错误;
- 认证成功后再解析明文并执行业务;
- 记录 message_id,防止重放;
- 如需要,生成回执。
不要在认证失败时返回“密钥错误”“tag 错误”“nonce 重复”“JSON 明文错误”等细节。错误差异会成为攻击面。
十一、算法公开,秘密只应存在于密钥
安全协议不应依赖“攻击者不知道我用了什么算法”。公开字段里写明算法、KDF、盐值、nonce、版本号,并不会降低安全性。现代密码学默认算法公开,安全性来自密钥和协议性质。
需要保密的是:
- 预共享主密钥;
- 口令或 PAKE 输入秘密;
- 私钥;
- 派生出的会话密钥;
- 服务器端 pepper 或 HSM 内部密钥。
不需要保密的是:
- 算法名称;
- 版本号;
- KDF 盐值;
- AEAD nonce;
- key_id;
- AAD;
- 密文和 tag。
但“不保密”不等于“不认证”。nonce、key_id、算法版本、发送方和接收方一旦被恶意修改,可能导致解密失败、降级攻击、调包攻击或业务误处理。因此这些公开字段应被纳入 AAD 或签名范围。
十二、降级攻击与版本迁移
协议一旦上线,就会面对算法老化。今天的推荐算法,未来可能进入遗留状态。安全协议必须从第一天支持版本迁移。
常见做法是:
version = 1
suite = MSG-v1-CHACHA20POLY1305-HKDFSHA256
key_id = psk-2026-06
服务端或接收方根据 suite 选择算法,根据 key_id 选择密钥。迁移时:
- 新消息只用新套件和新密钥;
- 旧消息保留只读解密能力;
- 禁止攻击者把新套件降级为旧套件;
- 套件字段必须被认证;
- 弱算法到期后彻底拒绝新消息生成。
降级攻击的典型形式是:双方都支持强算法和弱算法,攻击者把协商过程改成弱算法。如果算法协商结果没有被认证,协议就可能被迫退回旧套件。TLS 1.3 的设计里,握手消息会被完整性绑定,就是为了避免这类问题。
自定义消息协议即使不做复杂协商,也要保证“使用了哪个版本、哪个算法、哪个 key_id”被认证。
十三、密钥轮换与撤销
密钥不应无限期使用。轮换策略至少要回答四个问题:
- 新消息什么时候开始使用新密钥;
- 旧消息需要保留多久解密能力;
- 旧密钥泄露后如何撤销;
- 已发送但未确认的消息如何处理。
一种简单方案:
key_id = psk-2026-06 active for encryption
key_id = psk-2026-05 decrypt-only
key_id = psk-2026-04 disabled
发送方只使用 active 密钥;接收方可以在短期内保留 decrypt-only 密钥处理历史消息;超过期限后禁用。发生泄露时,立即吊销对应 key_id,并拒绝相关消息。
如果系统需要长期归档密文,要额外考虑密钥托管、再加密、访问审计和到期销毁。不要把运行时通信密钥和长期归档密钥混用。
十四、实现细节比算法名更容易出错
下面这些问题在真实系统中非常常见。
1. 自己拼接字段
危险写法:
aad = sender + receiver + sequence
如果没有长度分隔,ab + c 和 a + bc 可能得到相同字节串。正确做法是确定性编码或长度前缀。
2. nonce 随机但没有规模评估
“96 位随机数够大”通常没错,但前提是同一密钥下消息量有限。高吞吐系统应考虑计数器 nonce 或定期换密钥。
3. 认证失败后继续处理
AEAD 解密失败时,明文不存在。不要尝试解析部分明文,不要根据明文内容返回错误。
4. 把 key_id 当安全边界
key_id 只是索引,不是权限证明。攻击者可以改 key_id,所以它必须被 AAD 认证。接收方还要检查这个 key_id 是否允许用于当前 sender、receiver 和 purpose。
5. 复用同一密钥做所有事
加密、回执、日志、导出文件、token 签名都应使用不同派生密钥。一个密钥泄露不应拖垮全系统。
6. 日志泄露敏感数据
调试日志常把 plaintext、derived key、完整 token、解密错误上下文打出来。生产日志只应记录 message_id、key_id、失败类别和追踪 ID,且避免泄露可重放材料。
7. 使用非标准库或自己实现算法
ChaCha20、Poly1305、AES-GCM、HKDF、PBKDF2 都有成熟实现。业务代码应调用经过维护的密码库,而不是复制网上代码。
十五、2026 年的工程建议
如果要从零设计一个安全消息系统,优先考虑成熟协议,而不是自定义协议:
- 在线传输用 TLS 1.3;
- 接收方公钥加密用 HPKE;
- 端到端消息参考 Signal Protocol、MLS 等成熟设计;
- 口令认证密钥交换研究 SPAKE2+、OPAQUE;
- 本地数据加密使用成熟库提供的 AEAD 封装。
确实需要自定义消息格式时,可以采用以下基线:
- AEAD:AES-256-GCM 或 ChaCha20-Poly1305;
- KDF:高熵秘密使用 HKDF-SHA256/SHA384;
- 口令来源:优先 PAKE;存储场景使用 Argon2id、scrypt、bcrypt 或 PBKDF2 高成本参数;
- nonce:同一密钥下全局唯一;
- AAD:覆盖版本、算法、key_id、sender、receiver、purpose、sequence、timestamp;
- 防重放:sequence/message_id + 接收方去重状态;
- 不可抵赖:使用数字签名和签名回执;
- 迁移:所有密文携带 version、suite、key_id;
- 错误处理:认证失败统一返回;
- 日志:不记录密钥、明文和可重放凭据。
结语:安全来自组合,不来自堆算法
一个完整的安全消息协议,本质上是在回答一组具体问题:
- 谁能读;
- 谁发的;
- 有没有被改;
- 是不是旧消息;
- 用的是哪把密钥;
- 将来怎么换算法;
- 出错时泄露什么;
- 争议发生时谁能证明什么。
AEAD 解决的是机密性和完整性,HKDF 解决的是密钥派生,nonce 和 sequence 解决的是唯一性与新鲜性,AAD 绑定的是公开上下文,签名解决的是第三方可验证身份,回执解决的是送达确认,版本字段解决的是未来迁移。
把这些部件放在正确的位置,协议才真正安全。密码学工程最忌讳的是把 AES、SHA-256、HMAC、随机数随手堆在一起,然后相信“看起来很强”。真正可靠的设计,往往更克制:少用自定义结构,多用标准接口;少依赖秘密算法,多认证公开上下文;少追求一次到位,多预留迁移路径。
参考资料
- RFC 5116: An Interface and Algorithms for Authenticated Encryption
- RFC 8439: ChaCha20 and Poly1305 for IETF Protocols
- NIST SP 800-38D: Galois/Counter Mode (GCM) and GMAC
- RFC 5869: HMAC-based Extract-and-Expand Key Derivation Function (HKDF)
- NIST SP 800-132: Recommendation for Password-Based Key Derivation
- NIST SP 800-63B: Authentication and Lifecycle Management
- RFC 8446: The Transport Layer Security (TLS) Protocol Version 1.3
- RFC 9180: Hybrid Public Key Encryption
- RFC 9383: SPAKE2+, an Augmented PAKE Protocol
- RFC 9807: The OPAQUE Augmented Password-Authenticated Key Exchange Protocol

490

被折叠的 条评论
为什么被折叠?



