This commit is contained in:
Jonas Bark
2025-10-28 16:08:59 +01:00
parent 74280eda34
commit 7e18a169d4
7 changed files with 2 additions and 280 deletions

View File

@@ -1,19 +1,13 @@
import 'package:flutter/foundation.dart';
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart';
import 'package:swift_control/bluetooth/devices/zwift/zwift_device.dart';
import 'package:swift_control/utils/crypto/local_key_provider.dart';
import 'package:swift_control/utils/crypto/zap_crypto.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:universal_ble/universal_ble.dart';
import '../../messages/notification.dart';
import 'constants.dart';
class ZwiftClick extends ZwiftDevice {
ZwiftClick(super.scanResult) : super(availableButtons: [ZwiftButtons.shiftUpRight, ZwiftButtons.shiftDownLeft]);
final zapEncryption = ZapCrypto(LocalKeyProvider());
@override
List<ControllerButton> processClickNotification(Uint8List message) {
final status = ClickKeyPadStatus.fromBuffer(message);
@@ -26,64 +20,4 @@ class ZwiftClick extends ZwiftDevice {
@override
String get latestFirmwareVersion => '1.1.0';
@override
Future<void> setupHandshake() async {
await UniversalBle.write(
device.deviceId,
customServiceId,
syncRxCharacteristic!.uuid,
Uint8List.fromList([
...ZwiftConstants.RIDE_ON,
...ZwiftConstants.REQUEST_START,
...zapEncryption.localKeyProvider.getPublicKeyBytes(),
]),
withoutResponse: true,
);
}
@override
void processDevicePublicKeyResponse(Uint8List bytes) {
final devicePublicKeyBytes = bytes.sublist(
ZwiftConstants.RIDE_ON.length + ZwiftConstants.RESPONSE_START_CLICK.length,
);
if (kDebugMode) {
print("Device Public Key - ${devicePublicKeyBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}");
}
zapEncryption.initialise(devicePublicKeyBytes);
}
@override
Future<void> processData(Uint8List bytes) async {
int type;
Uint8List message;
print('Processing encrypted data: ${bytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}');
final counter = bytes.sublist(0, 4); // Int.SIZE_BYTES is 4
final payload = bytes.sublist(4);
print(
'Counter: ${counter.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}',
);
print(
'Payload: ${payload.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}',
);
if (zapEncryption.encryptionKeyBytes == null) {
actionStreamInternal.add(
LogNotification(
'Encryption not initialized, yet. You may need to update the firmware of your device with the Zwift Companion app.',
),
);
}
final data = zapEncryption.decrypt(counter, payload);
type = data[0];
message = data.sublist(1);
print(
'Decrypted Data: ${data.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}',
);
}
}

View File

@@ -1,53 +0,0 @@
import 'dart:typed_data';
import 'package:pointycastle/digests/sha256.dart';
import 'package:pointycastle/ecc/api.dart';
import 'package:pointycastle/key_derivators/api.dart';
import 'package:pointycastle/key_derivators/hkdf.dart';
class EncryptionUtils {
static const int KEY_LENGTH = 32;
static const int HKDF_LENGTH = 36;
static const int MAC_LENGTH = 4;
static Uint8List publicKeyToByteArray(ECPublicKey ecPublicKey) {
final affineX = ecPublicKey.Q!.x!.toBigInteger();
final affineY = ecPublicKey.Q!.y!.toBigInteger();
final affineXUnsigned = _bigIntToUnsignedBytes(affineX!);
final affineYUnsigned = _bigIntToUnsignedBytes(affineY!);
return Uint8List.fromList([...affineXUnsigned, ...affineYUnsigned]);
}
static ECPublicKey generatePublicKey(Uint8List publicKeyBytes, ECDomainParameters params) {
final bitLength = params.n.bitLength ~/ 8;
final xBytes = publicKeyBytes.sublist(0, bitLength);
final yBytes = publicKeyBytes.sublist(bitLength, 2 * bitLength);
final x = BigInt.parse(xBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(), radix: 16);
final y = BigInt.parse(yBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(), radix: 16);
final ecPoint = params.curve.createPoint(x, y);
return ECPublicKey(ecPoint, params);
}
static Uint8List generateSharedSecretBytes(ECPrivateKey privateKey, ECPublicKey serverPublicKey) {
final ecdh = ECDHBasicAgreement();
ecdh.init(privateKey);
final sharedSecret = ecdh.calculateAgreement(serverPublicKey);
return _bigIntToUnsignedBytes(sharedSecret);
}
static Uint8List generateHKDFBytes(Uint8List secretKey, Uint8List salt) {
final hkdf = HKDFKeyDerivator(SHA256Digest());
final params = HkdfParameters(secretKey, HKDF_LENGTH, salt);
hkdf.init(params);
final result = Uint8List(HKDF_LENGTH);
hkdf.deriveKey(null, 0, result, 0);
return result;
}
static Uint8List _bigIntToUnsignedBytes(BigInt number) {
final bytes = number.toRadixString(16).padLeft((number.bitLength + 7) >> 3 << 1, '0');
return Uint8List.fromList(
List<int>.generate(bytes.length ~/ 2, (i) => int.parse(bytes.substring(i * 2, i * 2 + 2), radix: 16)),
);
}
}

View File

@@ -1,37 +0,0 @@
import 'dart:math';
import 'dart:typed_data';
import 'package:pointycastle/export.dart';
import 'package:swift_control/utils/crypto/encryption_utils.dart';
class LocalKeyProvider {
late AsymmetricKeyPair<PublicKey, PrivateKey> pair;
LocalKeyProvider() {
generateKeyPair();
}
Uint8List getPublicKeyBytes() {
return EncryptionUtils.publicKeyToByteArray(pair.publicKey as ECPublicKey);
}
ECPublicKey getPublicKey() {
return pair.publicKey as ECPublicKey;
}
ECPrivateKey getPrivateKey() {
return pair.privateKey as ECPrivateKey;
}
void generateKeyPair() {
final keyParams = ECKeyGeneratorParameters(ECCurve_secp256r1());
final secureRandom = FortunaRandom();
final random = Random.secure();
final seeds = List<int>.generate(32, (_) => random.nextInt(256));
secureRandom.seed(KeyParameter(Uint8List.fromList(seeds)));
final keyGenerator = ECKeyGenerator();
keyGenerator.init(ParametersWithRandom(keyParams, secureRandom));
pair = keyGenerator.generateKeyPair();
}
}

View File

@@ -1,81 +0,0 @@
import 'dart:typed_data';
import 'package:pointycastle/export.dart';
import 'package:swift_control/utils/crypto/encryption_utils.dart';
import 'local_key_provider.dart';
class ZapCrypto {
final LocalKeyProvider localKeyProvider;
final AESEngine aesEngine = AESEngine();
Uint8List? encryptionKeyBytes;
Uint8List? ivBytes;
int counter = 0;
ZapCrypto(this.localKeyProvider);
void initialise(Uint8List devicePublicKeyBytes) {
final hkdfBytes = generateHmacKeyDerivationFunctionBytes(devicePublicKeyBytes);
encryptionKeyBytes = hkdfBytes.sublist(0, EncryptionUtils.KEY_LENGTH);
ivBytes = hkdfBytes.sublist(32, EncryptionUtils.HKDF_LENGTH);
}
Uint8List encrypt(Uint8List data) {
assert(encryptionKeyBytes != null && ivBytes != null, "Not initialised");
final counterValue = counter;
counter++;
final nonceBytes = createNonce(ivBytes!, counterValue);
final encrypted = encryptDecrypt(true, nonceBytes, data);
return Uint8List.fromList(createCounterBytes(counterValue) + encrypted);
}
Uint8List decrypt(Uint8List counterArray, Uint8List payload) {
assert(encryptionKeyBytes != null && ivBytes != null, "Not initialised");
final nonceBytes = Uint8List.fromList(ivBytes! + counterArray);
return encryptDecrypt(false, nonceBytes, payload);
}
Uint8List encryptDecrypt(bool encrypt, Uint8List nonceBytes, Uint8List data) {
final aeadParameters = AEADParameters(
KeyParameter(encryptionKeyBytes!),
EncryptionUtils.MAC_LENGTH * 8,
nonceBytes,
Uint8List(0),
);
final ccmBlockCipher = CCMBlockCipher(aesEngine);
ccmBlockCipher.init(encrypt, aeadParameters);
final processed = Uint8List(ccmBlockCipher.getOutputSize(data.length));
ccmBlockCipher.processBytes(data, 0, data.length, processed, 0);
ccmBlockCipher.doFinal(processed, 0);
return processed;
}
Uint8List generateHmacKeyDerivationFunctionBytes(Uint8List devicePublicKeyBytes) {
final serverPublicKey = EncryptionUtils.generatePublicKey(
devicePublicKeyBytes,
localKeyProvider.getPublicKey().parameters!,
);
final sharedSecretBytes = EncryptionUtils.generateSharedSecretBytes(
localKeyProvider.getPrivateKey(),
serverPublicKey,
);
final salt = Uint8List.fromList(
EncryptionUtils.publicKeyToByteArray(serverPublicKey) + localKeyProvider.getPublicKeyBytes(),
);
return EncryptionUtils.generateHKDFBytes(sharedSecretBytes, salt);
}
Uint8List createNonce(Uint8List iv, int messageCounter) {
return Uint8List.fromList(iv + createCounterBytes(messageCounter));
}
Uint8List createCounterBytes(int messageCounter) {
final buffer = ByteData(4);
buffer.setInt32(0, messageCounter, Endian.little);
return buffer.buffer.asUint8List();
}
}

View File

@@ -11,8 +11,6 @@ import 'package:swift_control/bluetooth/devices/zwift/protocol/zp.pb.dart';
import 'package:swift_control/bluetooth/devices/zwift/protocol/zwift.pb.dart' hide RideButtonMask;
import 'package:swift_control/main.dart';
import 'package:swift_control/utils/actions/remote.dart';
import 'package:swift_control/utils/crypto/local_key_provider.dart';
import 'package:swift_control/utils/crypto/zap_crypto.dart';
import 'package:swift_control/utils/keymap/apps/my_whoosh.dart';
import 'package:swift_control/utils/requirements/multi.dart';
import 'package:swift_control/utils/requirements/platform.dart';
@@ -26,7 +24,6 @@ bool _isAdvertising = false;
bool _isLoading = false;
bool _isServiceAdded = false;
bool _isSubscribedToEvents = false;
final _zapEncryption = ZapCrypto(LocalKeyProvider());
Central? _central;
GATTCharacteristic? _asyncCharacteristic;
@@ -183,22 +180,6 @@ class ZwiftRequirement extends PlatformRequirement {
syncTxCharacteristic,
value: ZwiftConstants.RIDE_ON,
);
} else if (value.startsWith(handshake)) {
final devicePublicKeyBytes = value.sublist(
ZwiftConstants.RIDE_ON.length + ZwiftConstants.RESPONSE_START_CLICK_V2.length,
);
if (kDebugMode) {
print(
"Device Public Key - ${devicePublicKeyBytes.map((e) => e.toRadixString(16).padLeft(2, '0')).join(' ')}",
);
}
_zapEncryption.initialise(devicePublicKeyBytes);
// respond with our public key
await peripheralManager.notifyCharacteristic(
_central!,
syncTxCharacteristic,
value: Uint8List.fromList(ZwiftConstants.RIDE_ON),
);
}
break;
default:
@@ -316,7 +297,7 @@ class ZwiftRequirement extends PlatformRequirement {
manufacturerSpecificData: [
ManufacturerSpecificData(
id: 0x094A,
data: Uint8List.fromList([ZwiftConstants.CLICK_V2_LEFT_SIDE, 0x43, 0x63]),
data: Uint8List.fromList([ZwiftConstants.CLICK_V2_LEFT_SIDE, 0x13, 0x37]),
),
],
);
@@ -342,7 +323,7 @@ class ZwiftRequirement extends PlatformRequirement {
void writeCommand() {
final status = RideKeyPadStatus()
//..buttonMap = (~RideButtonMask.SHFT_UP_R_BTN.mask) & 0xFFFFFFFF
//..buttonMap = (~RideButtonMask.SHFT_UP_L_BTN.mask) & 0xFFFFFFFF
..buttonMap = (~RideButtonMask.SHFT_UP_L_BTN.mask) & 0xFFFFFFFF
..buttonMap = (~RideButtonMask.LEFT_BTN.mask) & 0xFFFFFFFF
..analogPaddles.clear();
@@ -362,11 +343,6 @@ class ZwiftRequirement extends PlatformRequirement {
final zero = Uint8List.fromList([0x23, 0x08, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F]);
peripheralManager.notifyCharacteristic(_central!, _asyncCharacteristic!, value: zero);
}
int encodeActiveLowMap(RideButtonMask button, PlayButtonStatus status) {
// 32-bit mask: pressed bit = 0, others = 1
return (0xFFFFFFFF ^ button.mask) & 0xFFFFFFFF;
}
}
class _PairWidget extends StatefulWidget {

View File

@@ -136,14 +136,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.0"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
cross_file:
dependency: transitive
description:
@@ -804,14 +796,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pointycastle:
dependency: "direct main"
description:
name: pointycastle
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
posix:
dependency: transitive
description:

View File

@@ -19,7 +19,6 @@ dependencies:
url: https://github.com/jonasbark/universal_ble.git
gamepads: ^0.1.8+2
pointycastle: any
path_provider: ^2.1.5
intl: any
version: ^3.0.0