1
0
mirror of https://codeberg.org/Freeyourgadget/Gadgetbridge synced 2024-09-01 03:55:47 +02:00

CMF Watch Pro 2: Negotiate authentication key

This commit is contained in:
José Rebelo 2024-08-19 10:15:13 +01:00
parent 7514b50d19
commit cc5eadbc62
4 changed files with 179 additions and 38 deletions

View File

@ -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;

View File

@ -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);

View File

@ -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;
}

View File

@ -1156,6 +1156,7 @@
<string name="authenticating">Authenticating</string>
<string name="authentication_required">Authentication required</string>
<string name="authentication_failed_check_key">Authentication failed, please check auth key</string>
<string name="authentication_failed_negotiation">Authentication key negotiation failed</string>
<string name="activity_prefs_sleep_duration">Preferred sleep duration in hours</string>
<string name="device_hw">Hardware revision: %1$s</string>
<string name="device_fw">Firmware version: %1$s</string>