安全攻防 - 安全消息协议设计实战:从一个约会难题看懂密码学组合

在这里插入图片描述

引言

单个密码算法解决不了一个完整系统的安全问题。哈希函数能发现数据变化,却不能隐藏明文;加密能隐藏明文,却不必然防篡改;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. 解密验证

接收方处理顺序非常重要:

  1. 解析外层格式,但不信任任何字段;
  2. 检查 versionsuite 是否支持;
  3. 根据 key_id 找到候选密钥;
  4. 重新确定性编码 AAD;
  5. 检查 receiver 是否是自己;
  6. 检查 expires_at 是否过期;
  7. 检查 sequence/message_id 是否已处理;
  8. 调用 AEAD 解密;
  9. 认证失败返回统一错误;
  10. 认证成功后再解析明文并执行业务;
  11. 记录 message_id,防止重放;
  12. 如需要,生成回执。

不要在认证失败时返回“密钥错误”“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”被认证。

十三、密钥轮换与撤销

密钥不应无限期使用。轮换策略至少要回答四个问题:

  1. 新消息什么时候开始使用新密钥;
  2. 旧消息需要保留多久解密能力;
  3. 旧密钥泄露后如何撤销;
  4. 已发送但未确认的消息如何处理。

一种简单方案:

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 + ca + 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、随机数随手堆在一起,然后相信“看起来很强”。真正可靠的设计,往往更克制:少用自定义结构,多用标准接口;少依赖秘密算法,多认证公开上下文;少追求一次到位,多预留迁移路径。

参考资料

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小小工匠

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值