/* Copyright (C) 2024 José Rebelo This file is part of Gadgetbridge. Gadgetbridge is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. Gadgetbridge is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package nodomain.freeyourgadget.gadgetbridge.service.devices.cmfwatchpro; import android.bluetooth.BluetoothGattCharacteristic; import androidx.annotation.Nullable; import org.apache.commons.lang3.ArrayUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.security.GeneralSecurityException; import java.util.HashMap; import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.zip.CRC32; import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions; import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; import nodomain.freeyourgadget.gadgetbridge.util.CryptoUtils; import nodomain.freeyourgadget.gadgetbridge.util.GB; public class CmfCharacteristic { private final Logger LOG = LoggerFactory.getLogger(CmfCharacteristic.class); private static final byte[] AES_IV = new byte[]{0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x5a}; private static final byte PAYLOAD_HEADER = (byte) 0xf5; private final BluetoothGattCharacteristic bluetoothGattCharacteristic; private final UUID characteristicUUID; private final Handler handler; private byte[] sessionKey; private int mtu = 247; private final Map chunkBuffers = new HashMap<>(); public CmfCharacteristic(final BluetoothGattCharacteristic bluetoothGattCharacteristic, final Handler handler) { this.bluetoothGattCharacteristic = bluetoothGattCharacteristic; this.characteristicUUID = bluetoothGattCharacteristic.getUuid(); this.handler = handler; } public UUID getCharacteristicUUID() { return characteristicUUID; } public void setSessionKey(final byte[] sessionKey) { this.sessionKey = sessionKey; } public void setMtu(final int mtu) { this.mtu = mtu; } public void sendCommand(final TransactionBuilder builder, final CmfCommand cmd, final byte[] payload) { final byte[][] chunks; final boolean encrypted = shouldEncrypt(cmd); if (encrypted) { chunks = makeChunksEncrypted(payload); } else { chunks = makeChunksPlaintext(payload); } if (chunks == null) { // Something went wrong chunking - error was already printed return; } LOG.debug( "Send command: cmd={}{}", cmd, payload.length > 0 ? " payload=" + GB.hexdump(payload) : "" ); for (int i = 0; i < chunks.length; i++) { final byte[] chunk = chunks[i]; final ByteBuffer buf = ByteBuffer.allocate(chunk.length + 11).order(ByteOrder.BIG_ENDIAN); buf.put(PAYLOAD_HEADER); buf.putShort((short) chunk.length); buf.putShort((short) cmd.getCmd1()); buf.putShort((short) chunks.length); buf.putShort((short) (i + 1)); buf.putShort((short) cmd.getCmd2()); buf.put(chunk); builder.write(bluetoothGattCharacteristic, buf.array()); } } private byte[][] makeChunksPlaintext(final byte[] payload) { final int chunkSize = mtu - 20; final int numChunks = (int) Math.ceil(payload.length / (float) chunkSize); final byte[][] chunks = new byte[numChunks][]; final CRC32 crc = new CRC32(); for (int i = 0; i < numChunks; i++) { final int startIdx = i * chunkSize; final int endIdx = Math.min(startIdx + chunkSize, payload.length); final byte[] chunk = ArrayUtils.subarray(payload, startIdx, endIdx); crc.reset(); crc.update(chunk, 0, chunk.length); chunks[i] = ArrayUtils.addAll( chunk, BLETypeConversions.fromUint32((int) crc.getValue()) ); } return chunks; } @Nullable private byte[][] makeChunksEncrypted(final byte[] payload) { if (payload.length == 0) { return new byte[1][0]; } // AES will output 16-byte blocks, exclude the protocol overhead (11 bytes) final int maxEncryptedPayloadSize = ((mtu - 11) / 16) * 16; final int maxPayloadSize = maxEncryptedPayloadSize - 4 - 1; // exclude 4 bytes for crc and 1 byte of aes padding final int numChunks = (int) Math.ceil(payload.length / (float) (maxPayloadSize)); final byte[][] chunks = new byte[numChunks][]; if (numChunks != 1) { LOG.debug("Splitting payload into {} chunks of {} bytes", numChunks, maxPayloadSize); } final CRC32 crc = new CRC32(); for (int i = 0; i < numChunks; i++) { final int startIdx = i * maxPayloadSize; final int endIdx = Math.min(startIdx + maxPayloadSize, payload.length); final byte[] chunk = ArrayUtils.subarray(payload, startIdx, endIdx); crc.reset(); crc.update(chunk, 0, chunk.length); final byte[] payloadToEncrypt = ArrayUtils.addAll( chunk, BLETypeConversions.fromUint32((int) crc.getValue()) ); try { chunks[i] = CryptoUtils.encryptAES_CBC_Pad(payloadToEncrypt, sessionKey, AES_IV); } catch (final GeneralSecurityException e) { LOG.error("Failed to encrypt chunk", e); return null; } } return chunks; } 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; } return true; } public void onCharacteristicChanged(final byte[] value) { final ByteBuffer buf = ByteBuffer.wrap(value).order(ByteOrder.BIG_ENDIAN); final byte header = buf.get(); if (header != PAYLOAD_HEADER) { LOG.error("Unexpected first byte {}", String.format("0x%02x", header)); return; } final int payloadLength = buf.getShort(); final int cmd1 = buf.getShort() & 0xFFFF; final int chunkCount = buf.getShort(); final int chunkIndex = buf.getShort(); final int cmd2 = buf.getShort() & 0xFFFF; final CmfCommand cmd = CmfCommand.fromCodes(cmd1, cmd2); final byte[] payload; if (payloadLength > 0) { try { 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; } } 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); if (cmd == CmfCommand.AUTH_FAILED) { handler.onCommand(cmd, new byte[0]); } if (chunkCount > 1) { chunkBuffers.remove(cmd); } return; } } else { payload = new byte[0]; } LOG.debug( "Got {}: {}{}", chunkCount > 1 ? String.format(Locale.ROOT, "chunk %d/%d", chunkIndex, chunkCount) : "command", cmd != null ? String.format("cmd=%s", cmd) : String.format("cmd1=0x%04x cmd2=0x%04x", cmd1, cmd2), payload.length > 0 ? " payload=" + GB.hexdump(payload) : "" ); if (cmd == null) { // Just ignore unknown commands LOG.warn("Unknown command cmd1={} cmd2={}", String.format("0x%04x", cmd1), String.format("0x%04x", cmd2)); return; } final byte[] fullPayload; if (chunkCount == 1) { // Single-chunk payload - just pass it through fullPayload = payload; } else { final ChunkBuffer buffer; if (chunkBuffers.containsKey(cmd)) { buffer = Objects.requireNonNull(chunkBuffers.get(cmd)); } else { buffer = new ChunkBuffer(); chunkBuffers.put(cmd, buffer); } if (chunkIndex != buffer.expectedChunk) { LOG.warn("Got unexpected chunk, expected {}", buffer.expectedChunk); if (chunkIndex != 1) { // This chunk is not the first one and we got out of sync - ignore it and do not proceed return; } // Just discard whatever we had and start over buffer.baos.reset(); } try { buffer.baos.write(payload); } catch (final IOException e) { LOG.error("Failed to write payload to chunk buffer", e); return; } buffer.expectedChunk = chunkIndex + 1; if (chunkIndex != chunkCount) { // Chunk buffer not full yet return; } LOG.debug("Got all {} chunks for {}", chunkCount, cmd); fullPayload = buffer.baos.toByteArray().clone(); chunkBuffers.remove(cmd); } if (handler == null) { LOG.error("Handler is null for {}", characteristicUUID); return; } try { handler.onCommand(cmd, fullPayload); } catch (final Exception e) { LOG.error("Exception while handling command", e); } } public interface Handler { void onCommand(CmfCommand cmd, byte[] payload); } private static class ChunkBuffer { private int expectedChunk = 1; private final ByteArrayOutputStream baos = new ByteArrayOutputStream(); } }