You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Swift实现蓝牙设备AES-CCM消息解密失败问题排查

解密Zwift Play控制器蓝牙消息失败:Nonce与AES-CCM实现问题

在Swift中对接Zwift Play控制器的蓝牙消息,已完成P256密钥协商生成对称密钥,但使用AES-256-CCM解密接收消息时持续失败,怀疑Nonce生成及AES模式使用存在问题。

密钥协商代码

生成并发送己方公钥

let privateKey = P256.KeyAgreement.PrivateKey()
var message = "RideOn".data(using: .utf8)!
message.append(Data([0x01, 0x09]))
let publicKey = privateKey.publicKey
message.append(publicKey.rawRepresentation)
/// 发送给外设

接收对方公钥并生成共享密钥

let rideOn = "RideOn".data(using: .utf8)!

guard value.starts(with: rideOn) else {
    logger.error("no ride on?")
    return
}

let key = value.dropFirst(8)

guard let publicKey = try? P256.KeyAgreement.PublicKey(rawRepresentation: key) else {
    logger.debug("wrong key?")
    return
}

let salt = key + privateKey.publicKey.rawRepresentation

guard let sharedSecret = try? privateKey.sharedSecretFromKeyAgreement(with: publicKey) else {
    logger.debug("no shared secret")
    return
}
let symmetricKey = sharedSecret.hkdfDerivedSymmetricKey(using: SHA256.self, salt: salt, sharedInfo: Data(), outputByteCount: 36)

当前解密实现

CryptoSwift版本

let nonceData = symmetricKey.withUnsafeBytes { Data(Array($0)) }[...3] + value[...3]
let tag = value.suffix(4)
var value = value
value = value.dropFirst(4)
value.removeLast(4)

do {
    let aes = try AES(key: symmetricKey.withUnsafeBytes { Array($0) }, blockMode: CCM(iv: [UInt8](nonceData), tagLength: 4, messageLength: value.count), padding: .zeroPadding)
    let result = try aes.decrypt([UInt8](value))

    print("Result: \(result)")
} catch {
    logger.debug("Error: \(error.localizedDescription)")
}

CryptoKit版本

do {
    let nonce = try AES.GCM.Nonce(data: nonceData)

    let sealedBox = try AES.GCM.SealedBox(nonce: nonce, ciphertext: value, tag: tag)
    let result = try AES.GCM.open(sealedBox, using: symmetricKey)

} catch {
    logger.debug("\(error.localizedDescription)")
}

示例数据

Own private key: [36, 147, 90, 234, 66, 250, 58, 153, 187, 163, 237, 184, 100, 91, 99, 35, 139, 69, 181, 239, 250, 214, 164, 116, 118, 58, 95, 218, 28, 25, 111, 105]
Own public key: [34, 76, 142, 17, 129, 101, 252, 53, 92, 200, 144, 34, 20, 43, 115, 100, 181, 27, 19, 176, 145, 253, 34, 241, 74, 107, 125, 254, 130, 146, 169, 65, 118, 6, 199, 146, 89, 20, 47, 233, 101, 134, 76, 192, 61, 167, 135, 89, 100, 140, 56, 63, 3, 117, 57, 52, 136, 220, 38, 44, 119, 8, 110, 201]
Click key: [247, 134, 157, 189, 154, 129, 53, 47, 254, 165, 164, 49, 150, 55, 151, 75, 7, 182, 85, 194, 28, 198, 177, 153, 128, 75, 32, 50, 155, 204, 225, 201, 215, 241, 81, 246, 9, 222, 89, 81, 11, 190, 74, 214, 117, 41, 155, 227, 87, 41, 120, 34, 141, 227, 83, 85, 22, 246, 115, 0, 65, 174, 194, 48]
Salt: [247, 134, 157, 189, 154, 129, 53, 47, 254, 165, 164, 49, 150, 55, 151, 75, 7, 182, 85, 194, 28, 198, 177, 153, 128, 75, 32, 50, 155, 204, 225, 201, 215, 241, 81, 246, 9, 222, 89, 81, 11, 190, 74, 214, 117, 41, 155, 227, 87, 41, 120, 34, 141, 227, 83, 85, 22, 246, 115, 0, 65, 174, 194, 48]

点击控制器后接收的消息:
[21, 0, 0, 0, 248, 68, 228, 177, 135, 249, 158, 236, 165]
[22, 0, 0, 0, 78, 244, 116, 18, 109, 111, 238, 175, 86]
[23, 0, 0, 0, 122, 90, 33, 237, 14, 237, 234]
[24, 0, 0, 0, 139, 66, 8, 195, 217, 18, 234]
[25, 0, 0, 0, 3, 247, 14, 46, 58, 88, 71]

错误原因分析

  1. Nonce生成完全错误:文档明确要求Nonce为HKDF生成的对称密钥最后4字节加上消息中的4字节计数器,但当前代码取了对称密钥的前4字节([...3]是前4个元素),直接导致Nonce不匹配。
  2. AES模式混淆:文档要求使用AES-256-CCM,但CryptoKit实现错误地使用了AES-GCM,两种模式的认证机制不同,无法兼容。
  3. 对称密钥拆分错误:HKDF生成的36字节数据中,前32字节是AES-256的密钥,后4字节是Nonce的固定前缀,当前代码将整个36字节作为AES密钥传入,导致密钥长度不符合AES-256要求(AES-256需要32字节密钥)。
  4. 填充方式错误:CCM模式属于流密码类模式,不需要额外填充,当前代码使用.zeroPadding会引入不必要的填充字节,导致解密失败。

修正后的解决方案

关键修正点

  • 拆分36字节对称密钥:前32字节为AES密钥,后4字节为Nonce固定前缀
  • 按文档要求生成Nonce:前缀(密钥最后4字节) + 消息前4字节计数器
  • 使用CryptoSwift的CCM实现(CryptoKit无CCM支持)
  • 移除不必要的填充,使用.noPadding

修正后的CryptoSwift解密代码

// 拆分对称密钥:前32字节是AES-256密钥,后4字节是Nonce固定前缀
let keyData = symmetricKey.withUnsafeBytes { Data($0) }
let aesKeyBytes = Array(keyData.prefix(32))
let noncePrefix = keyData.suffix(4)

// 解析消息结构:[4字节计数器][密文][4字节Tag]
guard value.count >= 8 else {
    logger.debug("消息长度不足")
    return
}
let counter = value.prefix(4)
let tag = Array(value.suffix(4))
let ciphertext = Array(value.dropFirst(4).dropLast(4))

// 生成正确的Nonce
let nonceData = noncePrefix + counter

do {
    let aes = try AES(
        key: aesKeyBytes,
        blockMode: CCM(
            iv: Array(nonceData),
            tagLength: 4,
            messageLength: ciphertext.count
        ),
        padding: .noPadding
    )
    let plaintext = try aes.decrypt(ciphertext)
    print("解密结果: \(plaintext)")
    // 可进一步将plaintext转换为对应的数据结构
} catch {
    logger.debug("解密失败: \(error.localizedDescription)")
}

额外验证建议

  • 确认消息计数器的字节序:示例消息中的计数器[21,0,0,0]是小端格式,Nonce直接按字节拼接即可,无需转换。
  • 检查共享密钥生成流程:确保salt是对方公钥拼接己方公钥,与文档描述一致。
  • 验证Tag长度:文档中Tag为4字节,确保截取的Tag长度正确。

内容的提问来源于stack exchange,提问作者Bob Voorneveld

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.29 00:05:56