登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

crypto/mlkem 解封装失败时的错误处理边界

来源:17golang原创

时间:2026-10-10 22:32:19 122浏览 收藏

使用 Go 的 crypto/mlkem 做密钥封装时,最容易误判的一点是:Decapsulate 返回 nil 错误,并不代表收到的密文一定来自预期的发送方。官方 API 明确区分了两种情况:密文长度不正确时返回错误;长度正确但内容无效时不返回错误,而是产生一个与发送方不匹配的共享密钥。

官方文档:https://pkg.go.dev/crypto/mlkem

工程上应把“长度错误”当作输入或传输层失败,把“长度正确但协议确认失败”当作密码协议失败;两者都不能继续使用得到的共享密钥。

先分清两种失败

ML-KEM-768 和 ML-KEM-1024 都提供 Decapsulate,但它们的密文长度不同。以标准库导出的常量为准,768 参数集的密文长度是 1088 字节,1024 参数集是 1568 字节。长度检查可以快速发现截断、拼包错误或参数集配置不一致。

第二种情况更隐蔽:字节数刚好正确,但内容被篡改、来自错误的密钥或不属于当前会话。按照 FIPS 203 的设计,这时解封装不会直接返回错误,而是得到一个共享密钥。这个密钥与发送端的值不一致,必须交给后续协议确认来判定。

ML-KEM 解封装按密文长度和内容有效性分成两条错误处理分支的结构说明图
图1:ML-KEM 解封装两类输入边界的静态说明图,不是运行截图或运行证据。

最小配方:先做长度检查,再保留原始错误

长度检查不是替代库函数,而是把输入问题尽早映射成调用方能理解的错误。下面的包装函数只返回分类后的错误,不打印密文和共享密钥;上层可以据此决定丢弃请求、记录指标或返回通用提示。

package mlkemdemo

import (
    "crypto/mlkem"
    "errors"
    "fmt"
)

var (
    // ErrCiphertextLength 表示报文长度与当前参数集不匹配。
    ErrCiphertextLength = errors.New("mlkem ciphertext length mismatch")
    // ErrDecapsulation 表示库函数已经拒绝了解封装输入。
    ErrDecapsulation = errors.New("mlkem decapsulation failed")
)

func decapsulate768(dk *mlkem.DecapsulationKey768, ciphertext []byte) ([]byte, error) {
    // 先检查固定长度,便于区分截断、拼包和参数集不一致。
    if len(ciphertext) != mlkem.CiphertextSize768 {
        return nil, fmt.Errorf("%w: got=%d want=%d", ErrCiphertextLength, len(ciphertext), mlkem.CiphertextSize768)
    }

    // 只向上层传递错误状态,不把密文或共享密钥写进日志。
    sharedKey, err := dk.Decapsulate(ciphertext)
    if err != nil {
        return nil, fmt.Errorf("%w: %v", ErrDecapsulation, err)
    }
    return sharedKey, nil
}

这里的长度检查主要用于给出更稳定的错误分类。即使去掉它,标准库也会在长度不正确时返回错误;保留检查的价值是让协议入口能够在调用密码操作前统一处理输入边界。

为什么长度正确时不能只看 error

如果攻击者或中间链路把密文改成了另一串同长度字节,Decapsulate 仍可能返回 32 字节共享密钥。调用方若只判断 err == nil,就会把一把双方不同的“钥匙”交给后续加密流程,最终表现为解密失败、认证失败或业务消息无法打开。

正确做法是在 KEM 后面接一个已有的协议确认点,例如使用共享密钥派生 AEAD 密钥并验证密文标签,或对固定的会话上下文计算密钥确认值。确认数据应绑定会话标识、双方身份、算法参数和封装密文的摘要,避免把另一条会话的成功结果误当成当前会话的成功。

package mlkemdemo

import (
    "crypto/hmac"
    "crypto/sha256"
)

func keyConfirmation(sharedKey, transcript []byte) []byte {
    // 将会话上下文绑定到确认值,防止跨会话复用同一确认结果。
    mac := hmac.New(sha256.New, sharedKey)
    mac.Write([]byte("17golang/mlkem-confirm-v1|"))
    mac.Write(transcript)
    return mac.Sum(nil)
}

func sameConfirmation(sharedKey, transcript, expected []byte) bool {
    // 使用常量时间比较,避免把确认结果比较写成普通字节串比较。
    actual := keyConfirmation(sharedKey, transcript)
    return hmac.Equal(actual, expected)
}

确认失败时应立即清理或丢弃本次派生的密钥材料,并把请求标记为协议失败。不要尝试“再用这把密钥解一次”,也不要把共享密钥的十六进制内容写入日志来定位问题。

把异常挡在协议层

在真实服务中,可以把处理顺序固定为五层:先确认参数集和报文长度,再调用 Decapsulate,然后用协议上下文做认证确认,最后才把派生密钥交给业务加密层。这样做的重点不是增加一个重复的 if,而是避免把密码库的返回语义直接暴露成业务成功。

ML-KEM 从输入长度检查到协议确认和错误映射的分层处理结构图
图2:从输入长度到协议确认的错误处理分层结构图,不是运行截图或运行证据。
阶段应判断什么失败处理
参数选择发送方和接收方使用同一 ML-KEM 参数集拒绝请求并记录配置指标
长度入口密文长度是否匹配 768 或 1024 常量丢弃输入,不进入解封装
Decapsulate库函数是否返回 error包装为内部错误,不记录敏感字节
协议确认AEAD 标签或确认值是否匹配当前上下文丢弃共享密钥,返回通用协议失败
业务使用确认成功后才派生或使用业务密钥保留最小化审计信息

错误映射不要把密码细节泄露给客户端

服务端内部可以区分 ciphertext_length、decapsulation_error 和 key_confirmation_failed 三个指标,但对外响应不必逐字暴露差异。尤其是面向不可信请求时,返回“密文长度不对”“密钥确认不匹配”等过细信息,可能给对方提供协议探测线索。

比较稳妥的映射方式是:内部日志记录错误类别、参数集和会话追踪 ID,不记录密文、私钥种子、共享密钥或完整请求;客户端统一收到“安全上下文建立失败”,可重试的网络传输错误才进入有限次数的重试。重试必须重新生成或重新协商协议上下文,不能重复使用已经确认失败的共享密钥。

768 与 1024 的接入清单

Go 1.24 开始,crypto/mlkem 进入标准库,官方文档建议多数应用优先使用 ML-KEM-768;如果业务明确选择 1024,则应让参数集、密文长度和协议标签一起配置,避免只替换类型而遗漏入口约束。

官方发布说明:https://go.dev/doc/go1.24

  • 使用 mlkem.CiphertextSize768 或 mlkem.CiphertextSize1024,不要手写数字。
  • 把参数集写入会话上下文或协议版本,确认值必须绑定它。
  • 把 err == nil 只解释为“库函数完成了解封装”,不要解释为“密文已认证”。
  • 协议确认失败后丢弃共享密钥,日志只保留分类、计数和追踪信息。
  • 对外错误保持简洁,对内指标区分长度、库调用和确认阶段。

完整片段:在业务入口统一收口

func openSession768(dk *mlkem.DecapsulationKey768, ciphertext, transcript, expected []byte) ([]byte, error) {
    // 第一步只检查公开的报文长度,避免无效输入进入密码运算。
    if len(ciphertext) != mlkem.CiphertextSize768 {
        return nil, fmt.Errorf("%w", ErrCiphertextLength)
    }

    // 第二步调用标准库;返回的共享密钥在确认前只能留在当前函数内。
    sharedKey, err := dk.Decapsulate(ciphertext)
    if err != nil {
        return nil, fmt.Errorf("%w", ErrDecapsulation)
    }

    // 第三步用会话上下文确认双方确实得到同一把共享密钥。
    if !sameConfirmation(sharedKey, transcript, expected) {
        return nil, errors.New("mlkem key confirmation failed")
    }

    // 只有确认成功后,才把密钥交给后续 KDF 或 AEAD 层。
    return sharedKey, nil
}

这个片段没有把“内容无效”伪装成 Decapsulate 的错误,因为库函数本身不会替应用完成协议认证。把检查和错误映射集中在入口后,重试、监控和安全审计都更容易保持一致。

常见问题

长度正确但密文被篡改,能否靠 Decapsulate 返回错误发现?
不能。长度正确但内容无效时,API 可能返回不匹配的共享密钥,需要由 AEAD 完整性或密钥确认步骤识别。

可以把共享密钥打印出来比较双方结果吗?
不可以。应比较确认值或认证结果;调试时也只记录参数集、阶段和追踪 ID。

长度检查是不是重复实现了标准库逻辑?
它可以和标准库的检查同时存在。入口检查的主要价值是统一错误分类、提前拒绝明显的传输问题,并让协议层明确当前参数集。

什么时候用 ML-KEM-1024?
由业务的安全等级、性能和协议兼容约束决定。无论选择哪一套,都要让密钥类型、密文长度常量和协议上下文保持一致。

总结一下:Decapsulate 的 error 只覆盖明确的解封装输入错误,不能替代消息认证。把长度检查、库调用、协议确认和业务密钥使用分层处理,才能真正收住 crypto/mlkem 解封装失败时的错误边界。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>