diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchPro2Coordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchPro2Coordinator.java index 383c61718..a8123c5da 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchPro2Coordinator.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/cmfwatchpro/CmfWatchPro2Coordinator.java @@ -41,6 +41,12 @@ public class CmfWatchPro2Coordinator extends CmfWatchProCoordinator { return R.drawable.ic_device_watchxplus_disabled; } + @Override + public int getBondingStyle() { + // We can negotiate auth key - #3982 + return BONDING_STYLE_BOND; + } + @Override public boolean supportsSunriseSunset() { return true; diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCharacteristic.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCharacteristic.java index 5e51b71ee..dbeeebe7b 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCharacteristic.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfCharacteristic.java @@ -182,6 +182,8 @@ public class CmfCharacteristic { private boolean shouldEncrypt(final CmfCommand cmd) { switch (cmd) { + case AUTH_PAIR_REQUEST: + case AUTH_PAIR_REPLY: case DATA_CHUNK_WRITE_AGPS: case DATA_CHUNK_WRITE_WATCHFACE: return false; @@ -199,7 +201,7 @@ public class CmfCharacteristic { return; } - final int encryptedPayloadLength = buf.getShort(); + final int payloadLength = buf.getShort(); final int cmd1 = buf.getShort() & 0xFFFF; final int chunkCount = buf.getShort(); final int chunkIndex = buf.getShort(); @@ -208,23 +210,29 @@ public class CmfCharacteristic { final CmfCommand cmd = CmfCommand.fromCodes(cmd1, cmd2); final byte[] payload; - if (encryptedPayloadLength > 0) { - final byte[] encryptedPayload = new byte[encryptedPayloadLength]; - buf.get(encryptedPayload); - + if (payloadLength > 0) { try { - final byte[] decryptedPayload = CryptoUtils.decryptAES_CBC_Pad(encryptedPayload, sessionKey, AES_IV); - payload = ArrayUtils.subarray(decryptedPayload, 0, decryptedPayload.length - 4); - final int expectedCrc = BLETypeConversions.toUint32(decryptedPayload, decryptedPayload.length - 4); - final CRC32 crc = new CRC32(); - crc.update(payload, 0, payload.length); - final int actualCrc = (int) crc.getValue(); - if (actualCrc != expectedCrc) { - LOG.error("Payload CRC mismatch for {}: got {}, expected {}", cmd, String.format("%08X", actualCrc), String.format("%08X", expectedCrc)); - if (chunkCount > 1) { - chunkBuffers.remove(cmd); + if (cmd == null || shouldEncrypt(cmd)) { + final byte[] encryptedPayload = new byte[payloadLength]; + buf.get(encryptedPayload); + + final byte[] decryptedPayload = CryptoUtils.decryptAES_CBC_Pad(encryptedPayload, sessionKey, AES_IV); + payload = ArrayUtils.subarray(decryptedPayload, 0, decryptedPayload.length - 4); + final int expectedCrc = BLETypeConversions.toUint32(decryptedPayload, decryptedPayload.length - 4); + final CRC32 crc = new CRC32(); + crc.update(payload, 0, payload.length); + final int actualCrc = (int) crc.getValue(); + if (actualCrc != expectedCrc) { + LOG.error("Payload CRC mismatch for {}: got {}, expected {}", cmd, String.format("%08X", actualCrc), String.format("%08X", expectedCrc)); + if (chunkCount > 1) { + chunkBuffers.remove(cmd); + } + return; } - return; + } else { + // Plaintext payload - it does not have the crc, but the length still includes it (?) + payload = new byte[buf.limit() - buf.position()]; + buf.get(payload); } } catch (final GeneralSecurityException e) { LOG.error("Failed to decrypt payload for {} ({}/{})", cmd, String.format("0x%04x", cmd1), String.format("0x%04x", cmd2), e); diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfWatchProSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfWatchProSupport.java index 920906e02..262c12e56 100644 --- a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfWatchProSupport.java +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/cmfwatchpro/CmfWatchProSupport.java @@ -27,6 +27,8 @@ import android.net.Uri; import android.os.Build; import android.widget.Toast; +import androidx.annotation.Nullable; + import net.e175.klaus.solarpositioning.DeltaT; import net.e175.klaus.solarpositioning.SPA; import net.e175.klaus.solarpositioning.SunriseTransitSet; @@ -39,11 +41,12 @@ import org.slf4j.LoggerFactory; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; -import java.security.GeneralSecurityException; import java.security.MessageDigest; import java.util.ArrayList; +import java.util.Arrays; import java.util.Calendar; import java.util.GregorianCalendar; +import java.util.Random; import java.util.TimeZone; import java.util.UUID; @@ -54,6 +57,7 @@ import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInf import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventFindPhone; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventMusicControl; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdateDeviceInfo; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventUpdatePreferences; import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; import nodomain.freeyourgadget.gadgetbridge.devices.cmfwatchpro.CmfWatchProCoordinator; import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; @@ -87,6 +91,10 @@ public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements Cmf public static final UUID UUID_CHARACTERISTIC_CMF_DATA_WRITE = UUID.fromString("02f00000-0000-0000-0000-00000000ffe1"); public static final UUID UUID_CHARACTERISTIC_CMF_DATA_READ = UUID.fromString("02f00000-0000-0000-0000-00000000ffe2"); + public static final UUID UUID_SERVICE_CMF_SHELL = UUID.fromString("77d4e67c-2fe2-2334-0d35-9ccd078f529c"); + public static final UUID UUID_CHARACTERISTIC_CMF_SHELL_WRITE = UUID.fromString("77d4ff01-2fe2-2334-0d35-9ccd078f529c"); + public static final UUID UUID_CHARACTERISTIC_CMF_SHELL_READ = UUID.fromString("77d4ff02-2fe2-2334-0d35-9ccd078f529c"); + // An a5 byte is used a lot in single payloads, probably as a "proof of encryption"? public static final byte A5 = (byte) 0xa5; @@ -95,6 +103,9 @@ public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements Cmf private CmfCharacteristic characteristicDataRead; private CmfCharacteristic characteristicDataWrite; + private final byte[] authRandom1 = new byte[16]; + private final byte[] authAppSecret = new byte[16]; + private final CmfActivitySync activitySync = new CmfActivitySync(this); private final CmfPreferences preferences = new CmfPreferences(this); private CmfDataUploader dataUploader; @@ -105,6 +116,7 @@ public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements Cmf super(LOG); addSupportedService(UUID_SERVICE_CMF_CMD); addSupportedService(UUID_SERVICE_CMF_DATA); + addSupportedService(UUID_SERVICE_CMF_SHELL); } @Override @@ -143,6 +155,16 @@ public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements Cmf return builder; } + final BluetoothGattCharacteristic btCharacteristicShellWrite = getCharacteristic(UUID_CHARACTERISTIC_CMF_SHELL_WRITE); + if (btCharacteristicShellWrite == null) { + LOG.warn("Characteristic shell write is null"); + } + + final BluetoothGattCharacteristic btCharacteristicShellRead = getCharacteristic(UUID_CHARACTERISTIC_CMF_SHELL_READ); + if (btCharacteristicShellRead == null) { + LOG.warn("Characteristic shell read is null"); + } + dataUploader = new CmfDataUploader(this); characteristicCommandRead = new CmfCharacteristic(btCharacteristicCommandRead, this); @@ -150,20 +172,29 @@ public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements Cmf characteristicDataRead = new CmfCharacteristic(btCharacteristicDataRead, dataUploader); characteristicDataWrite = new CmfCharacteristic(btCharacteristicDataWrite, null); - final byte[] secretKey = getSecretKey(getDevice()); - characteristicCommandRead.setSessionKey(secretKey); - characteristicCommandWrite.setSessionKey(secretKey); - characteristicDataRead.setSessionKey(secretKey); - characteristicDataWrite.setSessionKey(secretKey); - - builder.notify(btCharacteristicCommandWrite, true); builder.notify(btCharacteristicCommandRead, true); - builder.notify(btCharacteristicDataWrite, true); builder.notify(btCharacteristicDataRead, true); + if (btCharacteristicShellRead != null) { + builder.notify(btCharacteristicShellRead, true); + } builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.AUTHENTICATING, getContext())); - sendCommand(builder, CmfCommand.AUTH_PHONE_NAME, ArrayUtils.addAll(new byte[]{A5}, Build.MODEL.getBytes(StandardCharsets.UTF_8))); + final byte[] secretKey = getSecretKey(getDevice()); + + if (secretKey != null) { + characteristicCommandRead.setSessionKey(secretKey); + characteristicCommandWrite.setSessionKey(secretKey); + characteristicDataRead.setSessionKey(secretKey); + characteristicDataWrite.setSessionKey(secretKey); + + sendCommand(builder, CmfCommand.AUTH_PHONE_NAME, ArrayUtils.addAll(new byte[]{A5}, Build.MODEL.getBytes(StandardCharsets.UTF_8))); + } else if (btCharacteristicShellWrite != null) { + builder.write(getCharacteristic(UUID_CHARACTERISTIC_CMF_SHELL_WRITE), "AT GETSECRET".getBytes()); + } else { + GB.toast(getContext(), R.string.authentication_failed_check_key, Toast.LENGTH_LONG, GB.WARN); + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.NOT_CONNECTED, getContext())); + } return builder; } @@ -191,6 +222,9 @@ public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements Cmf } else if (characteristicUUID.equals(characteristicDataRead.getCharacteristicUUID())) { characteristicDataRead.onCharacteristicChanged(value); return true; + } else if (characteristicUUID.equals(UUID_CHARACTERISTIC_CMF_SHELL_READ)) { + handleShellCommand(value); + return true; } LOG.warn("Unhandled characteristic changed: {} {}", characteristicUUID, GB.hexdump(value)); @@ -218,6 +252,51 @@ public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements Cmf } switch (cmd) { + case AUTH_PAIR_REPLY: + final byte[] authRandom2 = ArrayUtils.subarray(payload, 0, 16); + final byte[] signedAuthRandom2 = ArrayUtils.subarray(payload, 16, 48); + + LOG.debug("authRandom2: {}", GB.hexdump(authRandom2)); + LOG.debug("signedAuthRandom2: {}", GB.hexdump(signedAuthRandom2)); + + try { + // Validate random2 signature + final MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + sha256.update(authRandom2); + sha256.update(authAppSecret); + byte[] random2verify = sha256.digest(); + + if (!Arrays.equals(signedAuthRandom2, random2verify)) { + LOG.error("random2 signature mismatch"); + authNegotiationFailed(); + return; + } + + // Compute K1 and update preferences + sha256.reset(); + sha256.update(authRandom1); + sha256.update(authRandom2); + sha256.update(authAppSecret); + final byte[] k1full = sha256.digest(); + final byte[] secretKey = ArrayUtils.subarray(k1full, 0, 16); + + LOG.debug("Negotiated K1: {}", k1full); + + evaluateGBDeviceEvent(new GBDeviceEventUpdatePreferences("authkey", GB.hexdump(secretKey))); + + characteristicCommandRead.setSessionKey(secretKey); + characteristicCommandWrite.setSessionKey(secretKey); + characteristicDataRead.setSessionKey(secretKey); + characteristicDataWrite.setSessionKey(secretKey); + + sendCommand("auth step 2", CmfCommand.AUTH_PHONE_NAME, ArrayUtils.addAll(new byte[]{A5}, Build.MODEL.getBytes(StandardCharsets.UTF_8))); + } catch (final Exception e) { + LOG.error("Failed to negotiate K1", e); + authNegotiationFailed(); + return; + } + + return; case AUTH_FAILED: LOG.error("Authentication failed, disconnecting"); GB.toast(getContext(), R.string.authentication_failed_check_key, Toast.LENGTH_LONG, GB.WARN); @@ -244,7 +323,7 @@ public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements Cmf characteristicCommandWrite.setSessionKey(sessionKey); characteristicDataRead.setSessionKey(sessionKey); characteristicDataWrite.setSessionKey(sessionKey); - } catch (final GeneralSecurityException e) { + } catch (final Exception e) { LOG.error("Failed to compute session key from auth nonce", e); return; } @@ -363,23 +442,70 @@ public class CmfWatchProSupport extends AbstractBTLEDeviceSupport implements Cmf builder.queue(getQueue()); } - private static byte[] getSecretKey(final GBDevice device) { - final byte[] authKeyBytes = new byte[16]; + private void handleShellCommand(final byte[] bytes) { + final String shellCommand = new String(bytes).strip(); + if (!shellCommand.startsWith("GETSECRET:")) { + LOG.error("Got unknown shell command: {}", GB.hexdump(bytes)); + return; + } + if (!shellCommand.endsWith(",OK")) { + LOG.error("Failed to get secret: {}", GB.hexdump(bytes)); + authNegotiationFailed(); + return; + } + + final byte[] signedRandom1; + try { + new Random().nextBytes(authRandom1); + final byte[] secretBytes = GB.hexStringToByteArray(shellCommand.substring(10, 10 + 32)); + System.arraycopy(secretBytes, 0, authAppSecret, 0, authAppSecret.length); + + final MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + sha256.update(authRandom1); + sha256.update(authAppSecret); + signedRandom1 = sha256.digest(); + + LOG.debug("authRandom1: {}", GB.hexdump(authRandom1)); + LOG.debug("authAppSecret: {}", GB.hexdump(authAppSecret)); + LOG.debug("signedRandom1: {}", GB.hexdump(signedRandom1)); + } catch (final Exception e) { + LOG.error("Failed to generate signed random1", e); + authNegotiationFailed(); + return; + } + + sendCommand("auth send signed random1", CmfCommand.AUTH_PAIR_REQUEST, ArrayUtils.addAll(authRandom1, signedRandom1)); + } + + private void authNegotiationFailed() { + GB.toast(getContext(), R.string.authentication_failed_negotiation, Toast.LENGTH_LONG, GB.WARN); + final GBDevice device = getDevice(); + if (device != null) { + GBApplication.deviceService(device).disconnect(); + } + } + + @Nullable + private static byte[] getSecretKey(final GBDevice device) { final SharedPreferences sharedPrefs = GBApplication.getDeviceSpecificSharedPrefs(device.getAddress()); final String authKey = sharedPrefs.getString("authkey", "").trim(); - if (StringUtils.isNotBlank(authKey)) { - final byte[] srcBytes; - // Allow both with and without 0x, to avoid user mistakes - if (authKey.length() == 34 && authKey.startsWith("0x")) { - srcBytes = GB.hexStringToByteArray(authKey.trim().substring(2)); - } else { - srcBytes = GB.hexStringToByteArray(authKey.trim()); - } - System.arraycopy(srcBytes, 0, authKeyBytes, 0, Math.min(srcBytes.length, 16)); + if (StringUtils.isBlank(authKey)) { + return null; } + final byte[] authKeyBytes = new byte[16]; + + final byte[] srcBytes; + // Allow both with and without 0x, to avoid user mistakes + if (authKey.length() == 34 && authKey.startsWith("0x")) { + srcBytes = GB.hexStringToByteArray(authKey.trim().substring(2)); + } else { + srcBytes = GB.hexStringToByteArray(authKey.trim()); + } + System.arraycopy(srcBytes, 0, authKeyBytes, 0, Math.min(srcBytes.length, 16)); + return authKeyBytes; } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f1ec4f4ec..de513ce79 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1156,6 +1156,7 @@ Authenticating Authentication required Authentication failed, please check auth key + Authentication key negotiation failed Preferred sleep duration in hours Hardware revision: %1$s Firmware version: %1$s