一、设备侧在四步协商中的角色

在OpenHarmony统一互联的EC-SPEKE协议中,CH585瘦设备作为Server(SPEKE_TYPE_SERVER)响应协商,手机App(运行OpenHarmony/HarmonyOS的富设备)作为Client(SPEKE_TYPE_CLIENT)发起。四步协商的报文流转为 spekeMsg1 → spekeMsg2 → spekeMsg3 → spekeMsg4,设备侧在其中执行了全部核心密码学运算——口令到曲线映射、密钥对生成、共享密钥计算、会话密钥派生、HMAC双向验证、数据加密密钥派生。

这些运算的源码位于 foundation/communication/iot_connect/core/infrastructure/security/speke/ 目录,封装在5个源文件中,通过阅读源代码可以还原出每个步骤的函数调用链和加密原语使用方式。本文从 SpekeInitSessionNegoContextGenDataEncKey,逐函数剖析设备侧在每个协商步骤中执行的加密计算。

二、Step 0:会话初始化(SpekeInitSession)

SpekeInitSession 是协商的入口函数(定义于 security_speke_session.c)。其函数签名为 SpekeInitSession(spekeType, SpekeCallback, user),其中 SpekeCallback 包含 getPinCode 回调。根据 spekeType 区分Client与Server两条初始化路径:

SpekeInitSession(SPEKE_TYPE_SERVER, callback, user)
│
├── IotcMalloc → 分配 SpekeSession 结构体 (sizeof(SpekeSession))
├── memset_s(session, 0, sizeof(SpekeSession)) → 清零
├── 保存 spekeType、callback(含 getPinCode 回调)、user
│
├── [回调] callback.getPinCode() → 获取 PINCODE
│   └── 对应: IotcOhSetOption(IOTC_OH_OPTION_DEVICE_GET_PINCODE_CALLBACK, GetPincode)
│       PIN_CODE = "01234567" (Demo值)
│
├── [Server端独有] SecurityRandom(session->salt, 16)
│   └── 生成16字节随机 salt
│       └── 内部调用: SecurityTrng()DevTrng(buf, len)
│           └── 移植层提供的TRNG回调
│
└── [Server端独有] NegoContextInit(salt, pinCode, ...)
    └── 预初始化协商上下文 (详见第三节)

与之对比,Client端(App侧)在 SpekeInitSession 中仅获取pinCode,不初始化negoContext——其协商上下文要等到收到Msg2后,用Msg2携带的salt和iterations才进行初始化。

设备端在 SpekeInitSession 阶段就完成salt生成和negoContext预初始化(包含PBKDF2派生、Elligator2映射、密钥对生成),这样后续收到Msg1时只需验证版本号即可直接组装Msg2响应,缩短了设备侧的响应时延。

SecurityRandom 的调用链为 SecurityRandom → SecurityTrng → g_trngCallback → DevTrng。TRNG回调的质量直接决定salt和私钥的不可预测性,而当前Demo使用LCG伪随机(seed = seed * 1103515245 + 12345),这是移植方案中最大的安全短板。

三、Step 0续:协商上下文初始化(NegoContextInit)

NegoContextInit(定义于 security_speke_nego_ctx.c)是整个SPEKE协议中代码量最大的函数,完成了从口令到椭圆曲线基点的完整映射链和密钥对生成。

NegoContextInit(context, salt, pinCode, ...)
│
├── IotcMalloc(sizeof(NegoContext)) → 分配协商上下文
├── SecurityRandom(context->localChallenge, 16) → 生成16字节本地挑战值
├── memcpy_s(context->salt, salt, 16) → 复制16字节salt
│
├── InitBasePoint(context)
│   ├── NegoGenPbkdf2SecretV2 (PBKDF2派生, 详见3.1)
│   ├── IotcEcpElligator2Curve25519 × 2 (Elligator2映射, 详见3.2)
│   └── IotcEcpPointAddClearCofactor → base(32B)
│
└── GenNegoKeyPair(context) (详见3.3)

NegoContext 结构体(定义于 security_speke_nego_ctx.h)包含协商全过程所需的所有中间状态:

#define MAX_SALT_LEN 16
#define CHALLENGE_LEN 16
#define SESSION_KEY_LEN 16
#define HMAC_LEN 32

typedef struct {
    uint8_t salt[16];
    uint32_t saltLen;
    uint8_t *pubKey;      // 32B
    uint32_t pubKeyLen;
    uint8_t *localSk;     // 32B
    uint8_t *base;        // 32B
    uint8_t localChallenge[16];
    uint8_t remoteChallenge[16];
    uint8_t identityEncKey[16];
    uint8_t hmacKey[16];
    uint32_t iterations;
} NegoContext;

3.1 口令密钥派生(PBKDF2)

NegoGenPbkdf2SecretV2 使用SHA-512作为底层哈希算法进行PBKDF2派生:

NegoGenPbkdf2SecretV2(pinCode, salt, iterations)
└── IotcPkcs5Pbkdf2Hmac(param, output, outLen)
    ├── param.password = pinCode
    ├── param.salt = context->salt(16B) ‖ SPEKE_BASE_INFO_V2
    │   └── SPEKE_BASE_INFO_V2 = "ohos_connect_speke_base_info_curve25519_XMD:SHA-512_ELL2_RO_"
    ├── param.iterations = iterations
    ├── param.mdType = IOTC_MD_SHA512
    └── 输出: 96字节 (SPEKE_PBKDF2_OUTPUT_LEN = 96)
        ├── secret[0..47] → u[0] (48字节)
        └── secret[48..95] → u[1] (48字节)

PBKDF2通过多次HMAC-SHA512迭代增加口令暴力破解的计算成本。输出96字节并拆分为两个48字节的半 u[0]u[1],供后续Elligator2映射使用。salt由设备端随机生成的16字节 context->salt 与固定标识字符串 SPEKE_BASE_INFO_V2 拼接而成,该字符串直接编码了OpenHarmony统一互联对SPEKE协议的定制参数:曲线类型(curve25519)、哈希算法(SHA-512)、映射方法(ELL2_RO_,即Elligator2 Random Oracle模式)。这使得协议参数自描述,不同版本的SDK可以通过标识字符串区分。

3.2 口令到曲线映射(Elligator2 hash-to-curve)

这是SPEKE协议区别于普通DH的核心步骤。直接将口令哈希到曲线点会泄露口令信息——攻击者可以离线尝试不同口令计算对应的点。Elligator2算法使得映射后的输出在曲线点上均匀分布,无法区分随机点和口令映射点,抵抗离线字典攻击。

InitBasePoint 对PBKDF2输出的两个半 u[0]u[1] 分别执行Elligator2映射:

InitBasePoint(context)
│
├── IotcEcpElligator2Curve25519(u[0]) → q0 (64B: x[32] ‖ y[32])
│   └── 源码日志: "SPEKE3.0 hashToCurve u[0]"
│
├── IotcEcpElligator2Curve25519(u[1]) → q1 (64B: x[32] ‖ y[32])
│   └── 源码日志: "SPEKE3.0 hashToCurve u[1]"
│
└── IotcEcpPointAddClearCofactor(q0, q1) → base (32B)
    ├── 椭圆曲线点加法: q0 + q1
    ├── 清除余因子,确保结果在素数阶子群中
    └── 源码日志: "SPEKE3.0 hashToCurve Q[0].x"
        → 输出: 基点 G (Curve25519上的点, 32字节)

每个Elligator2映射输出64字节(x坐标32字节 ‖ y坐标32字节),两个候选点经点加法并清除余因子后得到32字节的基点G。底层椭圆曲线运算依赖mbedTLS的MPI(大数运算)模块:

MPI函数 用途 对应错误码
mbedtls_mpi_exp_mod 模幂运算(DH核心) IOTC_ADAPTER_CRYPTO_ERR_MPI_EXP_MOD
mbedtls_mpi_read_binary 字节串转大数 IOTC_ADAPTER_CRYPTO_ERR_MPI_READ_BINARY
mbedtls_mpi_write_binary 大数转字节串 IOTC_ADAPTER_CRYPTO_ERR_MPI_WRITE_BINARY

3.3 密钥对生成(GenNegoKeyPair)

GenNegoKeyPair(context)
│
├── SecurityRandom(context->localSk, 32)
│   └── 生成32字节随机私钥
│
├── ClampPrivateKey(localSk)
│   ├── sk[0] &= 0xF8    (清除低3位)
│   ├── sk[31] &= 0x7F   (清除最高位)
│   └── sk[31] |= 0x40   (置次高位)
│
└── IotcEcpX25519Compute(localSk, base) → pubKey (32B)
    └── 标量乘法: pubKey = localSk · G
        → 输出32字节公钥 pubKey

X25519私钥的clamping操作(清除低3位、清除最高位、置次高位)是Curve25519的标准要求,确保私钥在正确的子群中。X25519标量乘法是整个协商中计算量最大的操作,涉及255次大数乘法。CH585 RISC-V RV32IMAC无硬件乘法加速器(仅M扩展提供基础32x32乘法),纯软件实现约需200ms。

四、Step 1→2:接收Msg1、发送Msg2

设备端收到App发来的Msg1(仅包含version)后,SpekeServerProcessReq(定义于 security_speke_session.c)负责处理。由于negoContext已在 SpekeInitSession 中预初始化完成,设备端只需验证Msg1的版本号,即可直接使用预初始化时生成的salt、challenge1、pk1组装Msg2发送给App端。

SpekeServerProcessReq(jsonMsg1)
│
├── SpekeCommonVerifyVersion(json, "2.0")
│   └── 验证协议版本号
│
├── (negoContext已在SpekeInitSession中预初始化, 无需重复执行PBKDF2/Elligator2)
│
└── 组装Msg2发送给App端
    └── Msg2包含: salt, iterations, challenge1, pk1

Msg2携带的 saltiterations 是设备端在 SpekeInitSession 阶段生成/确定的协商参数。App端(Client)收到Msg2后,才使用这些参数执行 NegoContextInit 初始化自己的协商上下文。由于双方使用相同的PINCODE和salt/iterations参数,生成的基点G必然相同——这是SPEKE协议正确性的基础。

五、Step 2→3:接收Msg3、计算共享密钥与HMAC验证

App端将 challenge2pk2kcfData2 组装为Msg3,经BLE GATT Write发送给设备端。设备端通过 SpekeServerProcessCfm(定义于 security_speke_session.c)处理Msg3,依次执行共享密钥计算、HMAC验证、HMAC生成,最后组装Msg4发送。

5.1 计算共享密钥(NegoContextGenSessionKey)

SpekeServerProcessCfm 解析出Msg3中的 challenge2pk2,保存远端挑战值后调用 NegoContextGenSessionKey

NegoContextGenSessionKey(context, remotePubKey)
│
├── NegoContextSetRemoteChallenge(context, challenge2)
│   └── 保存远端挑战值到协商上下文
│
├── IotcEcpX25519Compute(localSk, remotePubKey) → sharedSecret (32B)
│   └── 标量乘法: S = localSk · pk2
│   └── 数学等价: S = localSk · clientSk · G
│       (因为 pk2 = clientSk · G)
│   └── 双方计算出相同的共享密钥S
│
└── GenSessionKey(context, sharedSecret)
    └── IotcHkdf(hkdf_param, output, &keyLen)
        ├── param.ikm = sharedSecret (S, 32B)
        ├── param.salt = context->salt (16B)
        ├── param.info = "ohos_connect_speke_sessionkey_info" (SPEKE_SESSION_KEY_INFO_V2)
        ├── param.mdType = IOTC_MD_SHA256
        └── 输出32字节,拆分为:
            ├── identityEncKey (16B) — 身份加密密钥
            └── hmacKey (16B) — HMAC密钥

会话密钥经HKDF-SHA256从共享密钥S派生为32字节,再拆分为两个16字节的子密钥:identityEncKey 用于后续数据加密密钥的派生材料,hmacKey 用于HMAC计算与验证。这种密钥分离设计确保加密与认证使用不同的密钥,互不干扰。

5.2 验证kcfData2(NegoContextVerifyHmac)

设备端收到Msg3中的 kcfData2 后,调用 NegoContextVerifyHmac 进行验证。验证时HMAC的输入数据顺序与生成时相反——使用 remoteChallenge ‖ localChallenge

NegoContextVerifyHmac(context, kcfData2)
│
├── GenNegoHmac(context, computed_hmac)
│   └── IotcHmacCalc(hmac_param, output, &outLen)
│       ├── param.key = hmacKey (16B)
│       ├── param.data = remoteChallenge(16B) ‖ localChallenge(16B)
│       ├── param.mdType = IOTC_MD_SHA256
│       └── 底层: HMAC-SHA256
│           → 重新计算HMAC (输出32字节, HMAC_LEN)
│
└── memcmp(computed_hmac, kcfData2, HMAC_LEN)
    ├── 匹配 → 验证通过
    └── 不匹配 → 返回 IOTC_CORE_COMM_SEC_ERR_SPEKE_VERIFY_HMAC

注意HMAC验证的输入顺序为 remoteChallenge ‖ localChallenge,这与生成kcfData1时的 localChallenge ‖ remoteChallenge 顺序相反。这种方向性设计确保HMAC具有方向性:设备端生成的HMAC与App端生成的HMAC使用不同的挑战值顺序,防止反射攻击。

5.3 生成kcfData1(NegoContextGenHmac)

验证通过后,设备端调用 NegoContextGenHmac 生成自己的 kcfData1

NegoContextGenHmac(context, kcfData1)
│
└── GenNegoHmac(context, hmac_output)
    └── IotcHmacCalc(hmac_param, output, &outLen)
        ├── param.key = hmacKey (16B)
        ├── param.data = localChallenge(16B) ‖ remoteChallenge(16B)
        ├── param.mdType = IOTC_MD_SHA256
        └── 底层: HMAC-SHA256
            → 输出: kcfData1 (32字节, HMAC_LEN)

└── kcfData1 将包含在Msg4中发送给App端

HMAC生成与验证均使用SHA-256算法,密钥为会话密钥派生出的 hmacKey(16B)。两者唯一的区别在于挑战值的拼接顺序:生成kcfData1时为 localChallenge ‖ remoteChallenge,验证kcfData2时为 remoteChallenge ‖ localChallenge

六、Step 3→4:发送Msg4及数据加密密钥派生

设备端组装Msg4(包含kcfData1)发送给App端后,调用 NegoContextGenDataEncKey 派生独立的数据加密密钥,完成最后的密钥派生。

SpekeCommonCreateNegoMsg(msgType=4, sessionId, jsonPayload)
│
├── IotcJsonCreate() → 创建JSON对象
├── SpekeCommonAddDataToJson(json, "kcfData1", kcfData1)
├── 添加 sessionId, msgId="spekeMsg4"
└── 序列化为JSON字符串
    → 通过 BleGattsSendIndication → RPC → GATT_Indication 发送

源码日志确认此步骤:"SPEKE SEND Msg4[SERVER_CFM, sessionId=%s, len=%u]: %s"

6.1 派生数据加密密钥(NegoContextGenDataEncKey)

NegoContextGenDataEncKey(context)
│
└── IotcHkdf(hkdf_param, dataEncKey, &keyLen)
    ├── param.ikm = identityEncKey (16B)
    ├── param.salt = context->salt (16B)
    ├── param.info = "ohos_speke_session_key" (SPEKE_DATA_KEY_INFO_V2)
    ├── param.mdType = IOTC_MD_SHA256
    └── 输出: dataEncKey (16字节)

数据加密密钥的派生材料是会话密钥中的 identityEncKey(16B),而非整个sessionKey。HKDF使用SHA-256,info参数为 "ohos_speke_session_key",salt为 context->salt

密钥分离的设计意图:identityEncKeyhmacKey 从共享密钥S经不同的HKDF参数派生,dataEncKey 又从 identityEncKey 再次派生,形成多层密钥层次。即使 dataEncKey 被提取,攻击者也无法还原 identityEncKeyhmacKey,反之亦然。

源码日志确认此步骤:"SpekeServerProcessCfm: gen dataEncKey err:%d"(错误路径),"SPEKE ServerProcessCfm : negotiation finished, sessionKey derived"

6.2 通知协商完成

NotifyNegoFinish(session)
│
├── 触发事件: IOTC_BLE_SPEKE_NEGOTIATION_SUCCESS
│   (定义在 iotc_event.h)
│
└── 后续动作:
    ├── LinkLayerSetEncryptType(LINKLAYER_ENCRYPT_TYPE_SPEKE)
    │   → 设置 g_encryptType = SPEKE
    ├── LinkLayerRegisterSpekeSessionGetCb(callback)
    │   → 注册 g_spekeSessionGetCb
    └── 此后所有BLE数据均经过 SpekeEncryptData/DecryptData 加解密

七、协商后数据加解密

协商完成后,SpekeEncryptDataSpekeDecryptData(定义于 security_speke_session.c)使用 dataEncKey 对配网数据进行AES-GCM加解密:

数据发送:
  LinkLayerEncryptData(plaintext)
    → LinkLayerSpekeEncrypt(plaintext)
      → g_spekeSessionGetCb() → 获取 SpekeSession
      → SpekeEncryptData(session, plaintext)
        → IotcAesGcmEncrypt (key = dataEncKey)
        → 输出格式: version(1B) + IV(12B) + ciphertext + tag(16B)

数据接收:
  LinkLayerDecryptData(ciphertext)
    → LinkLayerSpekeDecrypt(ciphertext)
      → g_spekeSessionGetCb() → 获取 SpekeSession
      → SpekeDecryptData(session, ciphertext)
        → IotcAesGcmDecrypt + 验证tag (key = dataEncKey)
        → memcpy_s(plaintext, ...)
        → 输出: plaintext

AES-GCM相关常量定义:GCM_IV_LEN = 12(初始向量长度)、GCM_TAG_LEN = 16(认证标签长度)、GCM_VER_LEN = 1(版本字段长度)。加密输出格式为 version(1B) + IV(12B) + ciphertext + tag(16B)。GCM(Galois/Counter Mode)模式同时提供加密和认证,适合BLE链路上的短数据包传输。

八、设备侧加密计算完整调用层级

SpekeInitSession (security_speke_session.c)
├── [回调] getPinCode → 获取 PINCODE
├── [Server端] SecurityRandom → 生成 salt(16B)
└── [Server端] NegoContextInit (预初始化)
    ├── SecurityRandom → localChallenge(16B)
    ├── InitBasePoint
    │   ├── NegoGenPbkdf2SecretV2 → PBKDF2-SHA512 (输出96B)
    │   ├── IotcEcpElligator2Curve25519 × 2
    │   └── IotcEcpPointAddClearCofactor → base(32B)
    └── GenNegoKeyPair
        ├── SecurityRandom → localSk(32B)
        ├── ClampPrivateKey
        └── IotcEcpX25519Compute → pubKey(32B)

SpekeServerProcessReq (收Msg1, 发Msg2)
├── SpekeCommonVerifyVersion
└── 组装Msg2 (salt, iterations, challenge1, pk1)

SpekeServerProcessCfm (收Msg3, 发Msg4)
├── NegoContextGenSessionKey
│   ├── IotcEcpX25519Compute → sharedSecret(32B)
│   └── GenSessionKey → HKDF-SHA256
│       → identityEncKey(16B) + hmacKey(16B)
├── NegoContextVerifyHmac(kcfData2)
│   └── GenNegoHmac → HMAC-SHA256
│       (data = remoteChallenge ‖ localChallenge)
├── NegoContextGenHmac → kcfData1
│   └── GenNegoHmac → HMAC-SHA256
│       (data = localChallenge ‖ remoteChallenge)
├── 组装Msg4发送
├── NegoContextGenDataEncKey
│   └── IotcHkdf → HKDF-SHA256
│       (ikm = identityEncKey, info = "ohos_speke_session_key")
└── NotifyNegoFinish

协商后:
├── SpekeEncryptData → IotcAesGcmEncrypt (AES-GCM)
└── SpekeDecryptData → IotcAesGcmDecrypt (AES-GCM)

九、CH585上的性能分析

CH585运行在RISC-V RV32IMAC架构上,无硬件加密加速器。所有密码学运算通过mbedTLS 3.x纯软件实现:

运算 底层操作 预估耗时 出现次数 总耗时
PBKDF2 HMAC-SHA512 × N ~80ms 1次 ~80ms
Elligator2 hash-to-curve 映射 + 点运算 ~100ms 1次 ~100ms
X25519(基点×私钥) 255次MPI乘法 ~200ms 1次 ~200ms
X25519(共享密钥) 255次MPI乘法 ~200ms 1次 ~200ms
HKDF(会话密钥) HMAC-SHA256 ~5ms 1次 ~5ms
HMAC验证(kcfData2) HMAC-SHA256 ~5ms 1次 ~5ms
HMAC生成(kcfData1) HMAC-SHA256 ~5ms 1次 ~5ms
HKDF(数据密钥) HMAC-SHA256 ~5ms 1次 ~5ms
AES-GCM加/解密 AES-128 ~1ms/包 持续

单次完整协商总耗时约600ms,远在10秒超时(IOTC_CONF_API_WAIT_MAX_TIME)之内。协商仅需执行一次,后续数据加解密每次约1ms,对配网体验无影响。

十、安全输入的质量要求

设备侧的四步协商加密计算依赖三个外部输入,每个输入的质量都直接影响协议安全性:

输入 来源 Demo值 安全要求 风险
PINCODE getPinCode回调 "01234567" 8字节随机,不可预测 硬编码易被读取
AC_KEY GetAcKey回调 48字节固定值 安全存储区或OTP 硬编码可从Flash提取
TRNG DevTrng回调 LCG伪随机 CSPRNG,密码学安全 LCG可预测,威胁私钥

TRNG是最关键的风险点。LCG的静态种子意味着每次复位后生成的随机数序列完全相同,攻击者可以预测私钥 localSk 和salt,从而在密钥协商中冒充设备。OpenHarmony安全规范要求安全敏感场景必须使用CSPRNG,推荐使用CH585硬件RNG(若可用)或ADC噪声+DRBG方案。

十一、总结

EC-SPEKE四步协商中,设备侧执行的加密计算构成一条从口令到数据加密密钥的完整派生链:

PINCODE (8字节)
    │ PBKDF2-SHA512 + Elligator2
    ▼
基点 G (Curve25519点, 32B)
    │ X25519(localSk, G)
    ▼
设备公钥 pk1 ───交换─── App公钥 pk2
    │                     │
    └──── X25519(localSk, pk2) ────┘
                   │
                   ▼
            共享密钥 S (32B)
                   │ HKDF-SHA256 (info="ohos_connect_speke_sessionkey_info")
                   ▼
    identityEncKey(16B) + hmacKey(16B)
      │                        │
      │                  HMAC-SHA256
      │                  ├── 验证 kcfData2 (Msg3, remote‖local)
      │                  └── 生成 kcfData1 (Msg4, local‖remote)
      │
      └── HKDF-SHA256 (info="ohos_speke_session_key")
                │
                ▼
          数据加密密钥 dataEncKey (16B)
                │
                ▼
          AES-GCM 加解密

这条派生链的每个环节都由mbedTLS 3.x提供底层支撑,移植层的职责是确保三个安全输入(PINCODE、AC_KEY、TRNG)的质量和mbedTLS适配的正确性。PBKDF2的salt拼接字符串 "ohos_connect_speke_base_info_curve25519_XMD:SHA-512_ELL2_RO_"、会话密钥HKDF的info "ohos_connect_speke_sessionkey_info"、数据密钥HKDF的info "ohos_speke_session_key" 共同体现了OpenHarmony统一互联对SPEKE协议的定制化定义,使协议参数自描述且版本可追溯。


Logo

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

更多推荐