SPEKE v2:口令认证与密钥协商技术详解

双方只掌握同一段口令时,如何在不发送口令明文的前提下确认彼此,并得到用于通信的会话密钥?SPEKE 将口令参与的密钥交换与双向密钥确认结合起来,解决这一问题。本文以 IoT Connect 的 SPEKE v2 实现为例,介绍四消息交互、曲线基点构造、X25519 共享秘密计算、HKDF 密钥派生和 AES-GCM 数据保护。

SPEKE 与 SPAKE2 是不同的 PAKE 协议。本文使用 spekeMsg1–4 和项目 speke_v2 的实现约定;RFC 9382 定义的 SPAKE2 不作为本文报文与计算公式的依据。

阅读范围本文约定
角色SPEKE 客户端与服务端,双方预先获得相同口令
实现版本IoT Connect speke_v2,协商 JSON 版本为 2.0
主要算法PBKDF2-HMAC-SHA512、Elligator2、X25519、HKDF-SHA256、HMAC-SHA256、AES-128-GCM
说明方式协议原理结合当前源码,示意公式用于解释数据关系
核实范围依据 2026-10-27 本地源码;不替代密码学安全证明或逐条规范符合性核验

一、SPEKE 解决什么问题

SPEKE 属于口令认证密钥交换(PAKE)技术。口令通常便于人工输入,但不应直接作为通信加密密钥,也不应通过发送明文来证明双方掌握它。

在本文实现中,双方从相同口令和盐构造口令相关基点,再分别生成临时私钥、公钥,计算共享秘密。随后通过 HMAC 完成双向确认,并为数据加密派生独立密钥。协商消息传输的是公开参数和确认值,不包含 PIN 明文字段。

概念作用与其他材料的区别
口令 / PIN参与基点构造与认证不是直接传给 AES 的密钥
盐 salt参与本轮基点构造和密钥派生公开协商参数,无需作为秘密传输
临时私钥参与本轮密钥交换由本端生成,不发送给对端
临时公钥供对端计算共享秘密随协商消息传输
HMAC 确认值检验双方派生结果是否一致按发送方向区分挑战顺序
数据密钥用于后续 AES-GCM 加密通过 KDF 派生,由对应会话持有

认证完成的判断需要双方确认:服务端验证客户端确认值,客户端验证服务端确认值。收到一个公钥或成功发送一条消息,都不足以宣告协议完成。

二、四条消息怎样完成双方确认

下文将发起协商的一方称为客户端,响应协商的一方称为服务端。消息里的 pk1 属于服务端,pk2 属于客户端;kcfData2 是客户端确认数据,kcfData1 是服务端确认数据。编号应按源码含义理解,不能仅凭数字猜测发送方向。

img

2.1 每条消息携带什么

四条消息均包含 sessionId 和 msgId,采用扁平 JSON。额外字段如下。

消息方向主要字段
spekeMsg1客户端 → 服务端version: "2.0"
spekeMsg2服务端 → 客户端challenge1、salt、pk1、iterations
spekeMsg3客户端 → 服务端challenge2、pk2、kcfData2
spekeMsg4服务端 → 客户端kcfData1

其中,盐和挑战各为 16 字节,当前 X25519 公钥为 32 字节,HMAC 确认值为 32 字节。二进制参数经过十六进制编码进入 JSON,因此编码后字符数是原始字节数的两倍;不能直接用字符串长度替代解码后的长度校验。

以下只是结构示例,sessionId 为虚构值,不含真实协商材料:

{
  "sessionId": "00000000000000000000000000000000",
  "msgId": "spekeMsg1",
  "version": "2.0"
}

消息组装见 security_speke_common.c,服务端与客户端阶段处理分别见 security_speke_server.c 和 security_speke_client.c。

2.2 为什么需要第三条和第四条消息

交换公钥后,双方可以计算各自的共享秘密,但还需要确认对端是否获得相同结果。项目使用带方向顺序的随机挑战生成 HMAC:

客户端确认:HMAC-SHA256(K_hmac, challenge2 || challenge1)
服务端确认:HMAC-SHA256(K_hmac, challenge1 || challenge2)

这里的 || 表示字节拼接。发送端按“本地挑战、对端挑战”计算,验证端按“对端挑战、本地挑战”重算,两边得到对应的同一输入。

确认值不一致时,NegoContextVerifyHmac() 返回 IOTC_CORE_COMM_SEC_ERR_SPEKE_VERIFY_HMAC。这个错误说明口令、派生结果或协商上下文存在不一致,单凭它不能断言一定是输入口令错误。

三、从短口令到会话密钥

当前实现先从口令与盐构造口令相关的曲线基点,再生成临时公钥、计算共享秘密,最后按用途派生不同密钥。下面是对源码计算过程的说明,不是可替代密码库的实现代码。

img

3.1 口令相关基点的构造

security_speke_nego_ctx.c 使用以下步骤:

secret = PBKDF2-HMAC-SHA512(PIN, salt || DST, iterations, 96)
u0、u1 = 从两段 48 字节输入得到的域元素
Q0、Q1 = Elligator2 映射得到的曲线点
P = ClearCofactor(Q0 + Q1)
base = P 的 x 坐标

项目使用的域分离标签 DST 为:

ohos_connect_speke_base_info_curve25519_XMD:SHA-512_ELL2_RO_

标签必须逐字节一致。 大小写、下划线以及末尾字符的变化都会改变输入,进而改变基点和派生结果。

这里需要区分标准背景与项目实现:RFC 9380 定义了椭圆曲线映射、域分离及相关套件。当前项目使用 PBKDF2 生成映射输入,不能因为标签含有 XMD:SHA-512,就把实际执行的 PBKDF2 步骤替换成 RFC 的通用 expand_message_xmd。互通时应对照双方采用的具体协议版本和参数,本文不据标签名称宣称完整 RFC 套件符合性。

3.2 临时公钥与共享秘密

用符号说明当前计算关系:服务端临时私钥为 a,客户端临时私钥为 b,双方按各自输入构造基点 P。

pk1 = X25519(a, P)
pk2 = X25519(b, P)

服务端:Z_server = X25519(a, pk2)
客户端:Z_client = X25519(b, pk1)

当口令、盐和映射参数一致,且双方处理正确时,应得到相同共享秘密;后续 HMAC 确认会检验本轮派生结果。私钥由随机数接口生成并按 X25519 要求处理,不通过消息发送。

这一流程的目的不是把短口令直接当作 AES 密钥。口令参与认证和基点构造,临时密钥交换与 KDF 再产生本轮使用的密钥材料。

3.3 不同用途的密钥分别派生

源码先执行 HKDF-SHA256,输出 32 字节,再分为两个 16 字节密钥:

material = HKDF-SHA256(
    IKM  = Z,
    salt = 协商盐,
    info = "ohos_connect_speke_sessionkey_info",
    L    = 32
)

identityEncKey = material[0..15]
hmacKey        = material[16..31]

业务数据密钥另外派生:

dataEncKey = HKDF-SHA256(
    IKM  = identityEncKey,
    salt = 协商盐,
    info = "ohos_speke_session_key",
    L    = 16
)

实现对应 GenSessionKey()、NegoContextGenHmac()、NegoContextVerifyHmac() 和 NegoContextGenDataEncKey()。其中 identityEncKey 用作下一阶段数据密钥派生的输入,hmacKey 用于双向密钥确认。

3.4 迭代次数以实际参数为准

当前 GN 默认值是 1000,CMake 缓存默认值为 10000;实际构建可以覆盖。服务端通过 spekeMsg2.iterations 告知客户端参数。

客户端的 ParseIterations() 在字段缺失、类型不合法或超出 1000–100000 范围时,当前实现回退为 10000。因此,跨端对打要核对实际发送值和实际使用值,不能只比较源码注释中的默认数字。源码中写死“10000”的说明也不能覆盖函数实际传入的 iterations。

四、会话密钥怎样保护数据

4.1 AES-128-GCM 封装

SpekeEncryptData() 使用 16 字节 dataEncKey 和随机 IV 执行 AES-128-GCM。当前二进制格式为:

版本字段(1 字节) || IV(12 字节) || 密文(N 字节) || GCM Tag(16 字节)

因此,封装结果长度为 N + 29 字节;这是该二进制格式自身的开销,不包含传输协议头或 Base64 编码。当前写入的格式版本值为 0,它与协商 JSON 中的 version: "2.0" 是不同字段。

GCM 同时保护密文的机密性和完整性。接收方必须验证 Tag,只有解密验证成功后,才能把明文交给上层。当前调用未提供附加认证数据,即 add = NULL、addLen = 0;不能据此宣称传输层头字段已被该次 GCM 调用认证。

4.2 编码与密码算法分别处理

某些传输适配会把上述二进制结果编码为 Base64。Base64 的职责是表达二进制数据,不提供加密能力,也不参与 SPEKE 的口令认证。

发送:业务明文 → AES-GCM 加密 → 二进制封装 → 可选传输编码
接收:可选传输解码 → AES-GCM 验证与解密 → 业务明文

下面按阶段说明密钥确认与数据解密的关系,图中的状态属于技术说明,不表示源码中存在同名状态枚举。

img

二进制格式和加解密入口见 security_speke_session.c。这里的会话加密封装是项目实现约定,不应泛化为所有 SPEKE 协议实现通用的数据格式。

五、实现与互通中的关键细节

5.1 口令必须按相同字节解释

双方使用相同的显示文字,不代表输入密码算法的字节一定相同。大小写、尾部空格、换行、字符编码以及是否把字符串结束符计入长度,都可能改变 PBKDF2 输入。

应明确口令的编码和长度规则,按约定传入真实字节数。需要检查一致性时,在受控调试过程中比较输入结果,不在常规日志中记录口令、密钥或口令相关派生材料。

5.2 口令在会话创建时读取

当前 SpekeInitSession() 创建对象时调用 getPinCode 回调,把口令与长度缓存在会话对象中。收到后续消息并不等于重新读取口令。

因此,当认证输入或对端关联条件变化时,调用方需要明确旧会话的失效和新建规则。复用同一上下文时,重复首条消息可能得到相同的第二条消息;这既可能是正常重传行为,也可能暴露调用方错误复用了旧上下文,必须结合会话归属判断。

5.3 算法版本与传输入口分别确认

SPEKE 消息可以承载在不同通信方式上。入口路径、服务名和底层链路,只负责将消息送到正确处理函数,不能替代算法版本检查。

本实现由构建配置选择 speke_v2 源目录,首条消息使用 JSON version: "2.0"。改变传输入口的名称不会自动改变密码算法。核对互通问题时,需要同时确认实际编译实现、消息版本和参数值。

5.4 长度、字节序和编码不能混用

检查项当前实现的关键值核对方式
盐16 字节客户端解码后检查长度
挑战每端生成 16 字节按协议约定验证,不把字符数当字节数
X25519 公钥32 字节对端公钥传入计算接口前验证
HMAC 确认值32 字节验证函数检查实际长度
业务密钥16 字节由派生流程产生,不接受显示口令替代

十六进制大小写属于编码形式,解码后应得到同一组字节;接收端是否允许不同编码形式,应按双方协议约定处理。曲线映射中的域元素解释和适配接口字节序也必须一致,不能只凭输入长度正确就判断计算结果相同。

5.5 完成回调与对端完成是两个观察点

服务端处理第三条消息成功后会通知本端协商结果,再由调用方发送第四条消息。客户端必须收到第四条消息并验证后,才完成自己的确认。

因此,“服务端回调成功”只能说明该端已经通过确认。若第四条消息丢失,客户端仍可能等待或重试。调用方需要按传输特性处理重传、超时与释放,不能把一次底层函数返回成功解释为对端已经收到消息。

5.6 输出缓冲区与秘密材料的生命周期

SpekeStartSession()、SpekeProcessPacket()、SpekeEncryptData() 和 SpekeDecryptData() 可能返回分配的输出缓冲区,调用方按接口契约使用并释放。异步发送时,还需要保证发送完成前数据仍然有效,或由发送层复制所需内容。

会话释放应清理口令、临时私钥和派生密钥等秘密材料。实现审查还要核对失败分支是否留下部分对象,以及调试日志是否输出秘密值。本文只介绍机制,不据正常握手成功推导出所有资源和安全边界均已审计完成。

六、按协议阶段排查常见错误

先找到最后一个成功阶段,再向下一步检查,比直接围绕“协商失败”这个总结果定位更有效。

现象已经推进到哪里重点检查
发出第一条消息,没有第二条消息首轮请求已交给传输网络或链路可达性、消息分发、版本校验、服务端初始化
收到第二条消息,未生成第三条消息服务端参数已经返回盐长度、字段解码、迭代次数、基点构造和共享秘密计算
第三条消息 HMAC 不一致已进入客户端密钥确认两端口令、盐、KDF 参数、映射结果和当前会话归属
服务端成功,客户端等待第四条消息服务端确认已经完成第四条消息发送、丢失、重传以及客户端确认校验
双方确认成功,数据解密失败已进入加密通信使用的会话对象、密钥、IV、Tag、长度和传输编码

6.1 HMAC 不一致表示什么

当前实现会记录:

SPEKE verify hmac mismatch

并返回 IOTC_CORE_COMM_SEC_ERR_SPEKE_VERIFY_HMAC。该错误的直接含义是:收到的确认值,与本端根据当前密钥和挑战重算的结果不同。

排查可以按以下顺序进行:

  1. 核对双方使用的口令字节和长度是否一致。
  2. 核对本轮盐、iterations 与域分离标签。
  3. 核对消息是否属于当前会话,是否错误复用了旧上下文。
  4. 核对曲线映射、共享秘密计算、字节序与 KDF 输入顺序。
  5. 核对收到的确认字段是否完整,并区分编码问题与计算问题。

不能仅凭这一行日志断言“口令一定错误”,也不能通过持续重发同一错误确认值修复派生结果差异。

6.2 GCM 验证失败表示什么

GCM Tag 验证失败可能来自密钥不匹配、错误会话、IV 或 Tag 解析偏移错误、密文变化等。此时应返回失败,不能把未通过认证的输出当作正常明文处理。

如果还有 Base64 或其他外层编码,应先确认解码后的二进制长度和布局,再检查 GCM 输入。多端日志需要统一时间基准,才能关联同一轮协商及后续数据,不能直接用未校准时钟计算耗时。

七、参考资料

外部参考

配套图源:图 1 四消息时序、图 2 密钥派生、图 3 会话处理。图源供 Mermaid 编辑器编辑;本次文档更新未导出发布图片。

Logo

社区规范:仅讨论OpenHarmony相关问题。

更多推荐