Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Crypto Zwift Click iOS #2099

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@
path = zwiftplay
url = https://github.com/cagnulein/zwiftplay.git
branch = lib
[submodule "src/ios/CryptoSwift"]
path = src/ios/CryptoSwift
url = https://github.com/krzyzanowskim/CryptoSwift.git

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/ios/CryptoSwift
Submodule CryptoSwift added at 7892a1
4 changes: 4 additions & 0 deletions src/ios/lockscreen.h
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ class lockscreen {
int zwift_api_getdistance();
float zwift_api_getlatitude();
float zwift_api_getlongitude();

// Zwift ZAP Device
const char* zapDevice_buildHandshakeStart();
int zapDevice_processCharacteristic(const char* data, int len);
};

#endif // LOCKSCREEN_H
22 changes: 22 additions & 0 deletions src/ios/lockscreen.mm
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@

static zwift_protobuf_layer* zwiftProtobufLayer = nil;

static ZwiftPlayDevice *zapDevice = nil;

void lockscreen::setTimerDisabled() {
[[UIApplication sharedApplication] setIdleTimerDisabled: YES];
}
Expand All @@ -47,6 +49,11 @@
if (@available(iOS 17, *)) {
_adb = [[AdbClient alloc] initWithVerbose:YES];
}

if (@available(iOS 14, *)) {
zapDevice = [[ZwiftPlayDevice alloc] init];
}

}

long lockscreen::heartRate()
Expand Down Expand Up @@ -322,4 +329,19 @@
float lockscreen::zwift_api_getlongitude() {
return [zwiftProtobufLayer getLongitude];
}

const char* lockscreen::zapDevice_buildHandshakeStart() {
if(zapDevice) {
return (const char*)[[zapDevice buildHandshakeStart] bytes];
}
return nil;
}

int lockscreen::zapDevice_processCharacteristic(const char* data, int len) {
if(zapDevice) {
NSData *d = [NSData dataWithBytes:data length:len];
return [zapDevice processEncryptedDataWithBytes:d];
}
return 0;
}
#endif
63 changes: 63 additions & 0 deletions src/ios/zwift_play/abstractZapDevice.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Foundation
import os.log

// Assuming Logger and extensions for Data to convert to hex string and to check prefix are defined elsewhere in your Swift project.
extension Data {
func toHexString() -> String {
self.map { String(format: "%02x", $0) }.joined()
}

func starts(with prefix: Data) -> Bool {
self.prefix(prefix.count) == prefix
}
}

@available(iOS 14.0, *)
@objc class AbstractZapDevice: NSObject {
private static let logRaw = true

private var devicePublicKeyBytes: Data?
private let localKeyProvider = LocalKeyProvider()
open var zapEncryption: ZapCrypto

override init() {
self.zapEncryption = ZapCrypto(localKeyProvider: localKeyProvider)
}

@objc func processCharacteristic(characteristicName: String, bytes: Data?) -> Int {
guard let bytes = bytes else { return 0 }

if Self.logRaw {
os_log("%{public}@ %{public}@", log: OSLog.default, type: .debug, characteristicName, bytes.toHexString())
}

if bytes.starts(with: ZapConstants.rideOn + ZapConstants.responseStart) {
processDevicePublicKeyResponse(bytes: bytes)
} else if bytes.count > MemoryLayout<Int>.size + EncryptionUtils.macLength {
return processEncryptedData(bytes: bytes)
} else {
// Logger equivalent in Swift
os_log("Unprocessed - Data Type: %{public}@", log: OSLog.default, type: .error, bytes.toHexString())
}
return 0
}

@objc func buildHandshakeStart() -> Data {
return ZapConstants.rideOn + ZapConstants.requestStart + localKeyProvider.getPublicKeyBytes()
}

private func processDevicePublicKeyResponse(bytes: Data) {
let startIndex = ZapConstants.rideOn.count + ZapConstants.responseStart.count
devicePublicKeyBytes = bytes.subdata(in: startIndex..<bytes.count)
zapEncryption.initialise(devicePublicKeyBytes: devicePublicKeyBytes!)
if Self.logRaw {
// Logger equivalent in Swift
os_log("Device Public Key - %{public}@", log: OSLog.default, type: .debug, devicePublicKeyBytes!.toHexString())
}
}

// Abstract method placeholder - Swift doesn't support abstract classes/methods directly, so this method should be overridden in subclass.
func processEncryptedData(bytes: Data) -> Int {
fatalError("This method should be overridden by subclasses")
}
}
42 changes: 42 additions & 0 deletions src/ios/zwift_play/encryptionUtils.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Foundation
import CryptoSwift
import CryptoKit

@available(iOS 14.0, *)
struct EncryptionUtils {
static let keyLength = 32
static let hkdfLength = 36
static let macLength = 4

// Converti una chiave pubblica EC in array di byte
static func publicKeyToByteArray(publicKey: P256.KeyAgreement.PublicKey) -> Data {
// Assumendo che `publicKey` sia una rappresentazione simile di CryptoKit's P256,
// la conversione diretta alla rappresentazione di byte è già disponibile
return publicKey.rawRepresentation
}

// Genera una chiave pubblica EC dai byte ricevuti
static func generatePublicKey(publicKeyBytes: Data) throws -> P256.KeyAgreement.PublicKey {
// Assumendo l'uso di CryptoKit per la generazione della chiave pubblica
return try P256.KeyAgreement.PublicKey(x963Representation: publicKeyBytes)
}

// Genera byte di segreto condiviso
static func generateSharedSecretBytes(privateKey: P256.KeyAgreement.PrivateKey, publicKey: P256.KeyAgreement.PublicKey) -> Data {
let sharedSecret = try! privateKey.sharedSecretFromKeyAgreement(with: publicKey)
// Direttamente in formato Data
return sharedSecret.withUnsafeBytes { Data($0) }
}

// Genera byte usando HKDF
static func generateHKDFBytes(secretKey: Data, salt: Data, info: Data = Data(), outputByteCount: Int) -> Data? {
do {
let hkdf = try HKDF(password: secretKey.bytes, salt: salt.bytes, info: info.bytes, keyLength: outputByteCount, variant: .sha256)
let derivedKey = try hkdf.calculate()
return Data(derivedKey)
} catch {
print("Errore durante la derivazione HKDF: \(error)")
return nil
}
}
}
36 changes: 36 additions & 0 deletions src/ios/zwift_play/localKeyProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation
import CryptoKit

@available(iOS 14.0, *)
class LocalKeyProvider {
private var keyPair: P256.KeyAgreement.PrivateKey?

init() {
generateKeyPair()
}

func getPublicKeyBytes() -> Data {
guard let publicKey = keyPair?.publicKey else {
fatalError("Key pair not initialized correctly.")
}
return publicKey.rawRepresentation
}

func getPublicKey() -> P256.KeyAgreement.PublicKey {
guard let publicKey = keyPair?.publicKey else {
fatalError("Key pair not initialized correctly.")
}
return publicKey
}

func getPrivateKey() -> P256.KeyAgreement.PrivateKey {
guard let keyPair = keyPair else {
fatalError("Key pair not initialized correctly.")
}
return keyPair
}

private func generateKeyPair() {
keyPair = P256.KeyAgreement.PrivateKey()
}
}
14 changes: 14 additions & 0 deletions src/ios/zwift_play/zapBleUuids.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Foundation
import CoreBluetooth

enum ZapBleUuids {
// ZAP Service - Zwift Accessory Protocol
static let zwiftCustomServiceUUID = CBUUID(string: "00000001-19CA-4651-86E5-FA29DCDD09D1")
static let zwiftAsyncCharacteristicUUID = CBUUID(string: "00000002-19CA-4651-86E5-FA29DCDD09D1")
static let zwiftSyncRxCharacteristicUUID = CBUUID(string: "00000003-19CA-4651-86E5-FA29DCDD09D1")
static let zwiftSyncTxCharacteristicUUID = CBUUID(string: "00000004-19CA-4651-86E5-FA29DCDD09D1")
// This doesn't appear in the real hardware but is found in the companion app code.
// static let zwiftDebugCharacteristicUUID = CBUUID(string: "00000005-19CA-4651-86E5-FA29DCDD09D1")
// I have not seen this characteristic used. Guess it could be for Device Firmware Update (DFU)? it is a chip from Nordic.
static let zwiftUnknown6CharacteristicUUID = CBUUID(string: "00000006-19CA-4651-86E5-FA29DCDD09D1")
}
20 changes: 20 additions & 0 deletions src/ios/zwift_play/zapConstants.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Foundation

enum ZapConstants {
static let zwiftManufacturerId = 2378 // Zwift, Inc
static let rc1LeftSide: UInt8 = 3
static let rc1RightSide: UInt8 = 2
static let zwiftClick: UInt8 = 9

static let rideOn = Data([82, 105, 100, 101, 79, 110])

// these don't actually seem to matter, its just the header has to be 7 bytes RIDEON + 2
static let requestStart = Data([0, 9]) //Data([1, 2])
static let responseStart = Data([1, 3]) // from device

// Message types received from device
static let controllerNotificationMessageType: UInt8 = 7
static let emptyMessageType: UInt8 = 21
static let batteryLevelType: UInt8 = 25
static let clickType: UInt8 = 55
}
91 changes: 91 additions & 0 deletions src/ios/zwift_play/zapCrypto.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import Foundation
import CryptoKit

@available(iOS 14.0, *)
class ZapCrypto {
private let localKeyProvider: LocalKeyProvider
private var encryptionKeyBytes: Data?
private var ivBytes: Data?
private var counter: Int = 0

init(localKeyProvider: LocalKeyProvider) {
self.localKeyProvider = localKeyProvider
}

func initialise(devicePublicKeyBytes: Data) {
let hkdfBytes: Data = generateHmacKeyDerivationFunctionBytes(devicePublicKeyBytes: devicePublicKeyBytes)
self.encryptionKeyBytes = hkdfBytes.subdata(in: 0..<EncryptionUtils.keyLength)
self.ivBytes = hkdfBytes.subdata(in: 32..<EncryptionUtils.hkdfLength)
}

func encrypt(data: Data) -> Data? {
guard let encryptionKeyBytes = encryptionKeyBytes, let ivBytes = ivBytes else {
assertionFailure("Not initialised")
return nil
}

let counterValue = counter
self.counter += 1

let nonceBytes: Data = createNonce(iv: ivBytes, messageCounter: counterValue)
return encryptDecrypt(encrypt: true, nonceBytes: nonceBytes, data: data)?.prependCounter(counterValue)
}

func decrypt(counterArray: Data, payload: Data) -> Data? {
guard let ivBytes = ivBytes else {
assertionFailure("Not initialised")
return nil
}

let nonceBytes: Data = ivBytes + counterArray
return encryptDecrypt(encrypt: false, nonceBytes: nonceBytes, data: payload)
}

private func encryptDecrypt(encrypt: Bool, nonceBytes: Data, data: Data) -> Data? {
let encryptionKey = SymmetricKey(data: encryptionKeyBytes!)

let sealedBox: AES.GCM.SealedBox
do {
if encrypt {
sealedBox = try AES.GCM.seal(data, using: encryptionKey, nonce: AES.GCM.Nonce(data: nonceBytes))
} else {
sealedBox = try AES.GCM.SealedBox(nonce: AES.GCM.Nonce(data: nonceBytes), ciphertext: data, tag: Data())
return try AES.GCM.open(sealedBox, using: encryptionKey)
}
return sealedBox.ciphertext + sealedBox.tag
} catch {
print(error)
return nil
}
}

private func generateHmacKeyDerivationFunctionBytes(devicePublicKeyBytes: Data) -> Data {
do {
let serverPublicKey = try EncryptionUtils.generatePublicKey(publicKeyBytes: devicePublicKeyBytes)
let sharedSecretBytes = EncryptionUtils.generateSharedSecretBytes(privateKey: localKeyProvider.getPrivateKey(), publicKey: serverPublicKey)
let salt = EncryptionUtils.publicKeyToByteArray(publicKey: serverPublicKey) + localKeyProvider.getPublicKeyBytes()
return EncryptionUtils.generateHKDFBytes(secretKey: sharedSecretBytes, salt: salt, outputByteCount: EncryptionUtils.hkdfLength) ?? Data()
} catch {
print(error)
return Data()
}
}

private func createNonce(iv: Data, messageCounter: Int) -> Data {
return iv + createCounterBytes(messageCounter: messageCounter)
}

private func createCounterBytes(messageCounter: Int) -> Data {
var counterValue = messageCounter.bigEndian
return Data(bytes: &counterValue, count: MemoryLayout.size(ofValue: counterValue))
}
}

extension Data {
func prependCounter(_ counter: Int) -> Data {
var counterValue = counter.bigEndian
var data = Data(bytes: &counterValue, count: MemoryLayout.size(ofValue: counterValue))
data.append(contentsOf: self)
return data
}
}
Loading