第三章 密钥边界划分:握手密钥与应用密钥的隔离

做TLS实现的人都知道,密码学里最怕的不是算法弱,而是密钥乱用。我在早期参与一个国密改造项目时,遇到过这么个情况:对方把握手过程中算出来的临时密钥直接拿去加密业务数据,结果密钥复用导致安全审计不过。说白了,密钥边界没划清楚。

这一章我们就来聊聊,国密TLS握手过程中,密钥到底是怎么一步步生成、隔离、使用的。你理解了这个边界,实现时才能避免“密钥穿墙”的坑。

密钥从哪里来?又到哪里去?

国密TLS握手遵循GM/T 0024-2014标准,密钥的诞生路径非常清晰。先看整体流程:

核心原则: 每一层密钥只服务一个阶段,握手密钥绝不外溢到应用层。
国密TLS密钥派生边界流程 预主密钥 Pre-Master Secret (48B) 主密钥 Master Secret (48B) 密钥块 Key Block (可变长度) 握手密钥 Handshake Key 应用密钥 Application Key 密钥边界线 说明:握手阶段产生的密钥块被严格切分为两部分 左侧用于握手消息加密(握手密钥),右侧用于数据传输(应用密钥)

SM2密钥交换生成预主密钥

在整个TLS握手流程中,预主密钥是根基。它由客户端和服务端通过SM2密钥交换协议(SM2 Key Exchange,简称SM2KE)协商产生。

我记得第一次看GM/T 0024标准时,发现它要求预主密钥必须是48字节,和传统TLS一样。但生成方式完全不同——国密走的是SM2的KDF(密钥派生函数,基于SM3哈希)。

注意: 预主密钥的随机数必须由TRNG(真随机数发生器)生成,绝不能用伪随机数替代。我曾经在测试环境踩过这个坑,用rand()代替,结果握手成功率不稳定。

PRF将预主密钥扩展为主密钥

预主密钥不会直接使用。TLS的伪随机函数(PRF)基于SM3,把48字节的预主密钥扩展成同样48字节的主密钥。为什么长度不变?因为主密钥后续还要进行第二轮PRF扩展。

  • 输入: 预主密钥 + 客户端随机数 + 服务端随机数
  • 算法: 基于SM3的HMAC迭代
  • 输出: 48字节主密钥

这里有个小细节:国标里的PRF标签字符串必须严格按照GM/T 0024附录A来写,我之前因为写错了标签,导致和开源库无法互连。排查了两天,血泪教训。

主密钥到密钥块的“边界分水岭”

主密钥继续通过PRF扩展出密钥块(Key Block)。密钥块的长度取决于密码套件需要的密钥总数。举个例子,如果使用SM4-GCM + SHA256,需要:

密钥类型长度(字节)用途
客户端写密钥(SM4)16加密客户端发送的数据
服务端写密钥(SM4)16加密服务端发送的数据
客户端写IV12GCM模式初始向量
服务端写IV12GCM模式初始向量
客户端写MAC密钥32GMAC认证
服务端写MAC密钥32GMAC认证

这个密钥块被一条看不见的“边界线”一分为二:前一半用于握手阶段的消息加密,后一半用于应用数据的传输加密。

个人经验: 我在实现时习惯用一个结构体来明确区分 handshake_ctx 和 application_ctx,防止代码中误把握手密钥传给加解密函数。从架构上做隔离比靠人脑记靠谱。

握手密钥的应用范围

握手密钥只服务于ChangeCipherSpec之后、Finished消息之前的握手记录。在国密TLS中,一旦收到对方的Finished消息,握手密钥立刻销毁。

你可能会问:那握手中的早期数据(如CertificateVerify)呢?嗯,CertificateVerify也是用握手密钥加密的,因为它产生在握手结束之前。

  • 包含哪些消息: ServerHello、Certificate、ServerKeyExchange、ClientCertificate、ClientKeyExchange、CertificateVerify、Finished
  • 加密算法: SM4-GCM 或 SM4-CCM,取决于密码套件
  • 生命周期: 从ChangeCipherSpec消息发送起,到双方Finished验证通过止

应用密钥的严格隔离

应用密钥在握手完成之后才启用。注意,虽然它和握手密钥来自同一个Key Block,但代码层面必须互斥。标准规定:

GM/T 0024-2014 第7.3条: 握手阶段结束后,端点必须丢弃所有握手密钥,并切换到使用应用密钥。任何情况下不得将握手密钥用于应用数据加密。

在我做的一个物联网网关项目中,为了节省内存,工程师试图在握手完成后复用握手密钥的SM4上下文,结果被安全评审拦住了。说白了,密钥的生命周期和内存上下文必须解耦。

完整的密钥边界检查清单

每次实现国密TLS握手后,我都会按下面的清单做自检。你也试试:

  1. 预主密钥在生成后是否立即进行下一次PRF,不保留原始值?
  2. 主密钥是否只用于派生Key Block,不参与其他操作?
  3. Key Block中的握手部分是否在收到Finished后立即clear?
  4. 应用密钥是否在握手完成前被设置为NULL(不可用)?
  5. 密钥派生函数使用的HMAC标签是否与标准一致?

这一章内容比较密集,核心就是记住密钥边界存在于预主密钥 → 主密钥 → 密钥块 → 分离的两个分支。你只要在代码里用不同的结构体或者不同的内存区域来管理,基本就不会出大问题。嗯,我这里多提醒一句:千万别在日志里打印密钥信息,即使只是调试阶段。我在一个项目中见识过打印出预主密钥的日志,虽然只是开发环境,但规范意识要养成。