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]
错误原因分析
- Nonce生成完全错误:文档明确要求Nonce为HKDF生成的对称密钥最后4字节加上消息中的4字节计数器,但当前代码取了对称密钥的前4字节(
[...3]是前4个元素),直接导致Nonce不匹配。 - AES模式混淆:文档要求使用AES-256-CCM,但CryptoKit实现错误地使用了AES-GCM,两种模式的认证机制不同,无法兼容。
- 对称密钥拆分错误:HKDF生成的36字节数据中,前32字节是AES-256的密钥,后4字节是Nonce的固定前缀,当前代码将整个36字节作为AES密钥传入,导致密钥长度不符合AES-256要求(AES-256需要32字节密钥)。
- 填充方式错误: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
相关产品推荐
相关产品推荐

