From 01feaabffdfe45fc7f00662494fe9bea23564dea Mon Sep 17 00:00:00 2001 From: TaaviE Date: Fri, 2 Oct 2020 02:01:02 +0300 Subject: [PATCH] Added support for Nut devices --- README.md | 1 + .../devices/nut/NutConstants.java | 133 ++++ .../devices/nut/NutCoordinator.java | 164 +++++ .../gadgetbridge/devices/nut/NutKey.java | 266 ++++++++ .../service/devices/nut/NutSupport.java | 632 ++++++++++++++++++ .../main/res/xml/devicesettings_nutmini.xml | 23 + .../service/devices/nut/NutUtilsTest.java | 247 +++++++ 7 files changed, 1466 insertions(+) create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutConstants.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutCoordinator.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutKey.java create mode 100644 app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/nut/NutSupport.java create mode 100644 app/src/main/res/xml/devicesettings_nutmini.xml create mode 100644 app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/nut/NutUtilsTest.java diff --git a/README.md b/README.md index 71718708c..7befdcca1 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ vendor's servers. * HPlus Devices (e.g. ZeBand) [Wiki](https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/HPlus) * iTag [Wiki](https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/iTag) * ID115 +* Nut Mini 3, Nut 2 and possibly others [Wiki](https://codeberg.org/Freeyourgadget/Gadgetbridge/wiki/Nut) * JYou Y5 * Lefun * Lenovo Watch 9 diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutConstants.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutConstants.java new file mode 100644 index 000000000..4c75dea43 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutConstants.java @@ -0,0 +1,133 @@ +/* Copyright (C) 2020 Taavi Eomäe + + 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.devices.nut; + +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.service.btle.GattService; + +public class NutConstants { + /** + * Just battery info + */ + public static final UUID SERVICE_BATTERY = GattService.UUID_SERVICE_BATTERY_SERVICE; + public static final UUID CHARAC_BATTERY_INFO = UUID.fromString("00002a19-0000-1000-8000-00805f9b34fb"); + + /** + * Device info available. + **/ + public static final UUID SERVICE_DEVICE_INFO = GattService.UUID_SERVICE_DEVICE_INFORMATION; + /** + * Firmware version. + * Used with {@link NutConstants#SERVICE_DEVICE_INFO} + */ + public static final UUID CHARAC_FIRMWARE_VERSION = UUID.fromString("00002a26-0000-1000-8000-00805f9b34fb"); + /** + * System ID. + * Used with {@link NutConstants#SERVICE_DEVICE_INFO} + */ + public static final UUID CHARAC_SYSTEM_ID = UUID.fromString("00002a23-0000-1000-8000-00805f9b34fb"); + /** + * Hardware version. + * Used with {@link NutConstants#SERVICE_DEVICE_INFO} + */ + public static final UUID CHARAC_HARDWARE_VERSION = UUID.fromString("00002a27-0000-1000-8000-00805f9b34fb"); + /** + * Manufacturer name. + * Used with {@link NutConstants#SERVICE_DEVICE_INFO} + */ + public static final UUID CHARAC_MANUFACTURER_NAME = UUID.fromString("00002a29-0000-1000-8000-00805f9b34fb"); + + + /** + * Link loss alert service. + */ + public static final UUID SERVICE_LINK_LOSS = UUID.fromString("00001803-0000-1000-8000-00805f9b34fb"); + + + /** + * Immediate alert service. + */ + public static final UUID SERVICE_IMMEDIATE_ALERT = UUID.fromString("00001802-0000-1000-8000-00805f9b34fb"); + /** + * Immediate alert level + * Used with {@link NutConstants#SERVICE_IMMEDIATE_ALERT} + */ + public static final UUID CHARAC_LINK_LOSS_ALERT_LEVEL = UUID.fromString("00002a06-0000-1000-8000-00805f9b34fb"); + + + /** + * Proprietary command endpoint. + * TODO: Anything else in this service on "Focus"? + */ + public static final UUID SERVICE_PROPRIETARY_NUT = UUID.fromString("0000ff00-0000-1000-8000-00805f9b34fb"); + /** + * Shutdown or reset. + * Used with {@link NutConstants#SERVICE_PROPRIETARY_NUT} + */ + public static final UUID CHARAC_CHANGE_POWER = UUID.fromString("0000ff01-0000-1000-8000-00805f9b34fb"); + /** + * Commands for proprietary service. + * Used with {@link NutConstants#SERVICE_PROPRIETARY_NUT} + */ + public static final UUID CHARAC_DFU_PW = UUID.fromString("0000ff02-0000-1000-8000-00805f9b34fb"); + /** + * Authentication using 16-byte key? + * Used with {@link NutConstants#SERVICE_PROPRIETARY_NUT} + * TODO: Exists only on Nut Mini? + */ + public static final UUID CHARAC_AUTH_STATUS = UUID.fromString("0000ff05-0000-1000-8000-00805f9b34fb"); + + + /** + * Ringing configuration. + * TODO: Exact purpose? + */ + public static final UUID SERVICE_UNKNOWN_2 = UUID.fromString("0000ffe0-0000-1000-8000-00805f9b34fb"); + /** + * Ringing configuration. + * Used with {@link NutConstants#SERVICE_UNKNOWN_2} + * TODO: Something else on other devices? + */ + public static final UUID CHARAC_UNKNOWN_2 = UUID.fromString("0000ffe1-0000-1000-8000-00805f9b34fb"); + + + /** + * Very little mention online, specific to Nut devices? + */ + public static final UUID UNKNOWN_3 = UUID.fromString("00001530-0000-1000-8000-00805f9b34fb"); + + /** + * Concatenates two byte arrays + * In reverse + * Pads with zeros when too short + * Truncates when too long + */ + public static byte[] assemblePacket(byte[] arr1, byte[] arr2) { + byte[] result = new byte[16]; + for (int i = 0; i < Math.min(arr2.length, 8); i++) { + // Reverse the array - 0-indexed - start shorter arrays from "0", truncate longer ones - current index + // = source array, start from offset on bigger arrays + result[8 - 1 - (8 - Math.min(arr2.length, 8)) - i] = arr2[i + Math.max((arr2.length - 8), 0)]; + } + for (int i = 0; i < Math.min(arr1.length, 8); i++) { + result[16 - 1 - (8 - Math.min(arr1.length, 8)) - i] = arr1[i + Math.max((arr1.length - 8), 0)]; + } + return result; + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutCoordinator.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutCoordinator.java new file mode 100644 index 000000000..704ef2ed3 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutCoordinator.java @@ -0,0 +1,164 @@ +/* Copyright (C) 2016-2020 Andreas Shimokawa, Carsten Pfeiffer, Daniele Gobbetti, Taavi Eomäe + + 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.devices.nut; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.bluetooth.le.ScanFilter; +import android.content.Context; +import android.net.Uri; +import android.os.Build; + +import androidx.annotation.NonNull; + +import java.util.Collection; +import java.util.Collections; + +import nodomain.freeyourgadget.gadgetbridge.R; +import nodomain.freeyourgadget.gadgetbridge.devices.AbstractDeviceCoordinator; +import nodomain.freeyourgadget.gadgetbridge.devices.InstallHandler; +import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider; +import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession; +import nodomain.freeyourgadget.gadgetbridge.entities.Device; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDeviceCandidate; +import nodomain.freeyourgadget.gadgetbridge.model.ActivitySample; +import nodomain.freeyourgadget.gadgetbridge.model.DeviceType; + +public class NutCoordinator extends AbstractDeviceCoordinator { + @Override + @NonNull + public DeviceType getSupportedType(GBDeviceCandidate candidate) { + String name = candidate.getDevice().getName(); + if (name != null && name.toLowerCase().startsWith("nut")) { + return DeviceType.NUTMINI; + } + return DeviceType.UNKNOWN; + } + + @Override + public int getBondingStyle() { + return BONDING_STYLE_ASK; + } + + @NonNull + @Override + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public Collection createBLEScanFilters() { + ScanFilter filter = new ScanFilter.Builder() + .setDeviceName("nut") // Nut Mini + .build(); + return Collections.singletonList(filter); + } + + @Override + public DeviceType getDeviceType() { + return DeviceType.NUTMINI; + } + + @Override + public int[] getSupportedDeviceSpecificSettings(GBDevice device) { + return new int[]{ + R.xml.devicesettings_nutmini, + }; + } + + @Override + public Class getPairingActivity() { + return null; + } + + @Override + public InstallHandler findInstallHandler(Uri uri, Context context) { + return null; + } + + @Override + public boolean supportsActivityDataFetching() { + return false; + } + + @Override + public boolean supportsActivityTracking() { + return false; + } + + @Override + public SampleProvider getSampleProvider(GBDevice device, DaoSession session) { + return null; + } + + @Override + public boolean supportsScreenshots() { + return false; + } + + @Override + public int getAlarmSlotCount() { + return 0; + } + + @Override + public boolean supportsSmartWakeup(GBDevice device) { + return false; + } + + @Override + public boolean supportsHeartRateMeasurement(GBDevice device) { + return false; + } + + @Override + public String getManufacturer() { + return "Nut"; + } + + @Override + public boolean supportsAppsManagement() { + return false; + } + + @Override + public Class getAppsManagementActivity() { + return null; + } + + @Override + public boolean supportsCalendarEvents() { + return false; + } + + @Override + public boolean supportsRealtimeData() { + return false; //TODO: RRSI + } + + @Override + public boolean supportsWeather() { + return false; + } + + @Override + public boolean supportsFindDevice() { + return true; + } + + @Override + protected void deleteDevice(@NonNull GBDevice gbDevice, @NonNull Device device, @NonNull DaoSession session) { + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutKey.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutKey.java new file mode 100644 index 000000000..2b00d4270 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/devices/nut/NutKey.java @@ -0,0 +1,266 @@ +/* Copyright (C) 2020 Taavi Eomäe + + 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.devices.nut; + +import org.apache.commons.lang3.ArrayUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.math.BigInteger; +import java.util.AbstractMap; +import java.util.Map; + +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class NutKey { + private static final Logger LOG = LoggerFactory.getLogger(NutKey.class); + + /** + * Different from {@link GB#hexStringToByteArray} because + * it returns an array of zero bytes when it's given zero bytes + *

+ * https://stackoverflow.com/a/140430/4636860 + * + * @param encoded hexadecimal string like "0xcafebabe", "DEADBEEF" or "feeddead" + * @return resulting byte array + */ + public static byte[] hexStringToByteArrayNut(String encoded) { + if (encoded.startsWith("0x")) { + encoded = encoded.substring(2); + } + + if ((encoded.length() % 2) != 0) { + throw new IllegalArgumentException("Input string must contain an even number of characters"); + } + + final byte[] result = new byte[encoded.length() / 2]; + final char[] enc = encoded.toCharArray(); + for (int i = 0; i < enc.length; i += 2) { + result[i / 2] = (byte) Integer.parseInt(String.valueOf(enc[i]) + enc[i + 1], 16); + } + return result; + } + + /** + * Returns the array as hexadecimal string space delimited + * + * @param bytes bytes to return + * @return returns + */ + public static String bytesToHex2(byte[] bytes) { + char[] hexChars = new char[bytes.length * 3]; + for (int j = 0; j < bytes.length; j++) { + hexChars[j * 3] = GB.HEX_CHARS[(bytes[j] & 0xFF) >>> 4]; + hexChars[j * 3 + 1] = GB.HEX_CHARS[(bytes[j] & 0xFF) & 0x0F]; + hexChars[j * 3 + 2] = " ".toCharArray()[0]; + } + return new String(hexChars).toLowerCase(); + } + + /** + * Generates an assembled packet based on inputs + * + * @param mac the mac of the target device (AA:BB:CC:DD:EE:FF) + * @param challenge the received challenge, THE ENTIRE PAYLOAD WITH PREAMBLE! + * @param key1 first key + * @param key2 second key + * @return assembled packet (without the preamble!) + */ + public static byte[] passwordGeneration(String mac, byte[] challenge, BigInteger key1, BigInteger key2) { + if (challenge[0] != 0x01) { + throw new IllegalArgumentException("Challenge must be given with the preamble"); + } + + byte[] mac_as_bytes = macAsByteArray(mac); + byte[] correct_challenge = new byte[challenge.length - 1]; + System.arraycopy(challenge, 1, correct_challenge, 0, challenge.length - 1); + ArrayUtils.reverse(correct_challenge); + BigInteger c = new BigInteger(1, mac_as_bytes).add(new BigInteger(1, correct_challenge)); + + BigInteger max64 = BigInteger.ONE.add(BigInteger.ONE).pow(64).subtract(BigInteger.ONE); + BigInteger tmp1 = key2.xor(max64); + BigInteger result1; + if (c.compareTo(tmp1) > 0) { + result1 = c.add(key1).subtract(tmp1); + } else { + result1 = key1; + } + + BigInteger result2; + if (key2.remainder(BigInteger.ONE.add(BigInteger.ONE)).compareTo(BigInteger.ONE) == 0) { + result2 = key2.add(c); + } else { + result2 = key2.multiply(BigInteger.ONE.add(BigInteger.ONE)).add(c); + } + + return byteArraysConcatReverseWithPad(result1.toByteArray(), result2.toByteArray()); + } + + /** + * Reverses the password generation into keys + *

+ * This assumes you have: + * The MAC of the device, the challenge and response payload + *

+ * See also {@link NutKey#passwordGeneration} + * + * @param challenge the RECEIVED and COMPLETE payload (truncated accordingly) + * @param response the SENT and COMPLETE payload + * @param deviceMac colon-separated MAC address of the Nut as a string + */ + public static Map.Entry reversePasswordGeneration(byte[] challenge, + byte[] response, + String deviceMac) { + // The two arrays that were concat. with byteArraysConcatReverseWithPad(orig1, orig2) + byte[] original1 = new byte[8]; + byte[] original2 = new byte[8]; + + // The response without preamble + if (response[0] != 0x02) { + throw new IllegalArgumentException("Response always begins with 0x02"); + } + byte[] cleanResponse = new byte[16]; + System.arraycopy(response, 1, cleanResponse, 0, cleanResponse.length); + + // The challenge without preamble + byte[] cleanChall = new byte[4]; + System.arraycopy(challenge, 1, cleanChall, 0, cleanChall.length); + + // Reverse the two arrays sent as a response + byteArraysDeConcatReverseWithPad(cleanResponse, original1, original2); + + // Two common components in the equation + BigInteger a = new BigInteger(1, macAsByteArray(deviceMac)); + byte[] cleanChallTmp = cleanChall.clone(); + ArrayUtils.reverse(cleanChallTmp); + BigInteger b = new BigInteger(1, cleanChallTmp); + BigInteger c = a.add(b); + BigInteger max64 = BigInteger.ONE.add(BigInteger.ONE).pow(64).subtract(BigInteger.ONE); + + // We don't know actual keys used yet + // There's two possibilities, + // either it's directly what's in the packet + // orig1 = key1 + BigInteger key1a = new BigInteger(1, original1); + // Or it's derived from key2 using this formula :S + // orig1 = c + key1 - (key2 XOR (2^64 - 1) + // see below when it might be needed + + // It's either just + // orig2 = key2 + c + BigInteger key2a = new BigInteger(1, original2).subtract(c); + // alternatively + // orig2 = 2 * key2 + c + BigInteger key2b = new BigInteger(1, original2).multiply(BigInteger.ONE.add(BigInteger.ONE)).subtract(c); + + // Now we have key2a, key2b, key1a, + // trying to determine if we can do with just those, + // or need to continue + byte[] key1a2aresult = passwordGeneration(deviceMac, challenge, key1a, key2a); + LOG.debug("Result1a2a:02 %s\n", bytesToHex2(key1a2aresult)); + + if (java.util.Arrays.equals(key1a2aresult, cleanResponse)) { + LOG.debug("Found key1a & key2a are correct, DONE!"); + return new AbstractMap.SimpleEntry<>(key1a, key2a); + } + // Unsuccessful, let's try key1a with key2b + + byte[] key1a2bresult = passwordGeneration(deviceMac, challenge, key1a, key2b); + LOG.debug("Result1a2b:02 %s\n", bytesToHex2(key1a2bresult)); + + if (java.util.Arrays.equals(key1a2bresult, cleanResponse)) { + LOG.debug("Found key1a & key2b are correct, DONE!"); + return new AbstractMap.SimpleEntry<>(key1a, key2b); + } + + // If we're still not done, we have to calculate two possible key1b-s + // one for key2a, other for key2b + // key1 = c + (key2 XOR (2^64 - 1) + orig1 + BigInteger key1b2a = c.add(key2a.xor(max64)).add(new BigInteger(1, original1)); + byte[] key1b2aresult = passwordGeneration(deviceMac, challenge, key1b2a, key2a); + LOG.debug("Result1b2a:02 %s\n", bytesToHex2(key1b2aresult)); + + if (java.util.Arrays.equals(key1b2aresult, cleanResponse)) { + LOG.debug("Found key1b2a & key2b are correct, DONE!"); + return new AbstractMap.SimpleEntry<>(key1b2a, key2b); + } + + BigInteger key1b2b = c.add(key2b.xor(max64)).add(new BigInteger(1, original1)); + byte[] key1b2bresult = passwordGeneration(deviceMac, challenge, key1b2b, key2b); + LOG.debug("Result1b2b:02 %s\n", bytesToHex2(key1b2bresult)); + + if (java.util.Arrays.equals(key1b2bresult, cleanResponse)) { + LOG.debug("Found key1b2b & key2b are correct, DONE!"); + return new AbstractMap.SimpleEntry<>(key1b2b, key2b); + } + + LOG.warn("Input might be incorrect, a correct key was not found"); + return null; + } + + /** + * Turns the MAC address into an array of bytes + * + * @param address MAC address + * @return byte[] containing the MAC address bytes + */ + public static byte[] macAsByteArray(String address) { + //noinspection DynamicRegexReplaceableByCompiledPattern + return hexStringToByteArrayNut(address.replace(":", "")); + } + + /** + * Concatenates two byte arrays in reverse and pads with zeros + * + * @param arr1 first array to concatenate + * @param arr2 second array to concatenate + * @return 16 bytes that contain the array in reverse, zeros if any parameter is empty + */ + public static byte[] byteArraysConcatReverseWithPad(byte[] arr1, byte[] arr2) { + byte[] result = new byte[16]; + for (int i = 0; i < Math.min(arr2.length, 8); i++) { + // Reverse the array - 0-indexed - start shorter arrays from "0" - byte index to handle + result[8 - 1 - (8 - Math.min(arr2.length, 8)) - i] = arr2[i + Math.max((arr2.length - 8), 0)]; + } + for (int i = 0; i < Math.min(arr1.length, 8); i++) { + result[16 - 1 - (8 - Math.min(arr1.length, 8)) - i] = arr1[i + Math.max((arr1.length - 8), 0)]; + } + return result; + } + + /** + * De-concatenates two byte arrays in reverse, + * places them in specified destinations, + *

+ * 16 bytes that contain the array in reverse, + * zeros if any parameter is empty + * + * @param input array to de-concatenate, 16 bytes + */ + public static void byteArraysDeConcatReverseWithPad(byte[] input, byte[] dest1, byte[] dest2) { + if (input.length != 16) { + throw new IllegalArgumentException("Input must be 16 bytes!"); + } + for (int i = 0; i < 8; i++) { + dest2[8 - 1 - i] = input[i]; + } + for (int i = 8; i < 16; i++) { + dest1[16 - 1 - i] = input[i]; + } + } +} diff --git a/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/nut/NutSupport.java b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/nut/NutSupport.java new file mode 100644 index 000000000..8ebdc8aa5 --- /dev/null +++ b/app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/nut/NutSupport.java @@ -0,0 +1,632 @@ +/* Copyright (C) 2016-2020 Andreas Shimokawa, Carsten Pfeiffer, Taavi Eomäe + + 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.nut; + +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCharacteristic; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.widget.Toast; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Map; +import java.util.UUID; + +import nodomain.freeyourgadget.gadgetbridge.GBApplication; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo; +import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo; +import nodomain.freeyourgadget.gadgetbridge.devices.nut.NutConstants; +import nodomain.freeyourgadget.gadgetbridge.devices.nut.NutKey; +import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice; +import nodomain.freeyourgadget.gadgetbridge.model.Alarm; +import nodomain.freeyourgadget.gadgetbridge.model.CalendarEventSpec; +import nodomain.freeyourgadget.gadgetbridge.model.CallSpec; +import nodomain.freeyourgadget.gadgetbridge.model.CannedMessagesSpec; +import nodomain.freeyourgadget.gadgetbridge.model.MusicSpec; +import nodomain.freeyourgadget.gadgetbridge.model.MusicStateSpec; +import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec; +import nodomain.freeyourgadget.gadgetbridge.model.WeatherSpec; +import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLEDeviceSupport; +import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder; +import nodomain.freeyourgadget.gadgetbridge.service.btle.actions.SetDeviceStateAction; +import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.IntentListener; +import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.battery.BatteryInfo; +import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.battery.BatteryInfoProfile; +import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfo; +import nodomain.freeyourgadget.gadgetbridge.service.btle.profiles.deviceinfo.DeviceInfoProfile; +import nodomain.freeyourgadget.gadgetbridge.util.GB; + +public class NutSupport extends AbstractBTLEDeviceSupport { + private static final Logger LOG = LoggerFactory.getLogger(NutSupport.class); + + private final GBDeviceEventBatteryInfo batteryCmd = new GBDeviceEventBatteryInfo(); + + private final DeviceInfoProfile deviceInfoProfile; + private final BatteryInfoProfile batteryInfoProfile; + private final IntentListener listener = new IntentListener() { + @Override + public void notify(Intent intent) { + String action = intent.getAction(); + if (action.equals(DeviceInfoProfile.ACTION_DEVICE_INFO)) { + handleDeviceInfo((DeviceInfo) intent.getParcelableExtra(DeviceInfoProfile.EXTRA_DEVICE_INFO)); + } else if (action.equals(BatteryInfoProfile.ACTION_BATTERY_INFO)) { + handleBatteryInfo((BatteryInfo) intent.getParcelableExtra(BatteryInfoProfile.EXTRA_BATTERY_INFO)); + } else { + LOG.warn("Unhandled intent given to listener"); + } + } + }; + private SharedPreferences prefs = null; + /** + * It uses the proprietary Nut interface. + */ + private boolean proprietary = false; + /** + * Proprietary Nut interface needs authentication. + *

+ * Don't write characteristics until authenticated. + *

+ * Will disconnect in a minute if you don't authenticate. + */ + private boolean authenticated = true; + /** + * The two keys used for authentication + */ + private BigInteger key1; + private BigInteger key2; + + + public NutSupport() { + super(LOG); + addSupportedService(NutConstants.SERVICE_BATTERY); + addSupportedService(NutConstants.SERVICE_DEVICE_INFO); + addSupportedService(NutConstants.SERVICE_IMMEDIATE_ALERT); + addSupportedService(NutConstants.SERVICE_LINK_LOSS); + addSupportedService(NutConstants.SERVICE_PROPRIETARY_NUT); + addSupportedService(NutConstants.SERVICE_UNKNOWN_2); + + deviceInfoProfile = new DeviceInfoProfile<>(this); + deviceInfoProfile.addListener(listener); + addSupportedProfile(deviceInfoProfile); + + batteryInfoProfile = new BatteryInfoProfile<>(this); + batteryInfoProfile.addListener(listener); + addSupportedProfile(batteryInfoProfile); + } + + private void handleBatteryInfo(BatteryInfo info) { + LOG.info("Received Nut battery info"); + batteryCmd.level = (short) info.getPercentCharged(); + handleGBDeviceEvent(batteryCmd); + } + + private void handleDeviceInfo(DeviceInfo info) { + LOG.info("Received Nut device info"); + LOG.info(String.valueOf(info)); + GBDeviceEventVersionInfo versionInfo = new GBDeviceEventVersionInfo(); + if (info.getHardwareRevision() != null) { + versionInfo.hwVersion = info.getHardwareRevision(); + } + if (info.getFirmwareRevision() != null) { + versionInfo.fwVersion = info.getFirmwareRevision(); + } + + handleGBDeviceEvent(versionInfo); + } + + @Override + protected TransactionBuilder initializeDevice(TransactionBuilder builder) { + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZING, getContext())); + builder.add(new SetDeviceStateAction(getDevice(), GBDevice.State.INITIALIZED, getContext())); + + // Init prefs + prefs = GBApplication.getDeviceSpecificSharedPrefs(getDevice().getAddress()); + loadKeysFromPrefs(); + + LOG.debug("Requesting device info!"); + deviceInfoProfile.requestDeviceInfo(builder); + batteryInfoProfile.requestBatteryInfo(builder); + + // If this characteristic exists, it has proprietary Nut interface + this.proprietary = (getCharacteristic(NutConstants.CHARAC_AUTH_STATUS) != null); + + if (proprietary) { + this.authenticated = false; + /** + * Part of {@link NutConstants.SERVICE_PROPRIETARY_NUT} + * Enables proprietary notification + */ + builder.notify(getCharacteristic(NutConstants.CHARAC_AUTH_STATUS), true); + LOG.info("Enabled authentication status notify"); + + /** + * Part of {@link NutConstants.SERVICE_UNKNOWN_2} + * Enables button-press notify + */ + builder.notify(getCharacteristic(NutConstants.CHARAC_UNKNOWN_2), true); + } else { + /** + * Part of {@link NutConstants.SERVICE_UNKNOWN_1_WEIRDNESS} + * Enables button-press notify + */ + builder.notify(getCharacteristic(NutConstants.CHARAC_CHANGE_POWER), true); + } + + readDeviceInfo(); + return builder; + } + + @Override + public boolean useAutoConnect() { + return true; + } + + @Override + public void onNotification(NotificationSpec notificationSpec) { + + } + + @Override + public void onDeleteNotification(int id) { + + } + + @Override + public void onSetTime() { + + } + + @Override + public void onSetAlarms(ArrayList alarms) { + + } + + @Override + public void onSetCallState(CallSpec callSpec) { + + } + + @Override + public void onSetCannedMessages(CannedMessagesSpec cannedMessagesSpec) { + + } + + @Override + public void onSetMusicState(MusicStateSpec stateSpec) { + + } + + @Override + public void onSetMusicInfo(MusicSpec musicSpec) { + + } + + @Override + public void onEnableRealtimeSteps(boolean enable) { + + } + + @Override + public void onInstallApp(Uri uri) { + + } + + @Override + public void onAppInfoReq() { + + } + + @Override + public void onAppStart(UUID uuid, boolean start) { + + } + + @Override + public void onAppDelete(UUID uuid) { + + } + + @Override + public void onAppConfiguration(UUID appUuid, String config, Integer id) { + + } + + @Override + public void onAppReorder(UUID[] uuids) { + + } + + @Override + public void onFetchRecordedData(int dataTypes) { + + } + + @Override + public void onReset(int flags) { + + } + + @Override + public void onHeartRateTest() { + + } + + @Override + public void onEnableRealtimeHeartRateMeasurement(boolean enable) { + + } + + @Override + public void onFindDevice(boolean enable) { + deviceImmediateAlert(enable); + } + + @Override + public void onSetConstantVibration(int intensity) { + } + + @Override + public void onScreenshotReq() { + + } + + @Override + public void onEnableHeartRateSleepSupport(boolean enable) { + + } + + @Override + public void onSetHeartRateMeasurementInterval(int seconds) { + + } + + @Override + public void onAddCalendarEvent(CalendarEventSpec calendarEventSpec) { + + } + + @Override + public void onDeleteCalendarEvent(byte type, long id) { + + } + + @Override + public boolean onCharacteristicChanged(BluetoothGatt gatt, + BluetoothGattCharacteristic characteristic) { + if (super.onCharacteristicChanged(gatt, characteristic)) { + return true; + } + + UUID characteristicUUID = characteristic.getUuid(); + if (characteristicUUID.equals(NutConstants.CHARAC_AUTH_STATUS)) { + handleAuthResult(characteristic.getValue()); + return true; + } + LOG.info("Unhandled characteristic changed: " + characteristicUUID); + return false; + } + + @Override + public boolean onCharacteristicRead(BluetoothGatt gatt, + BluetoothGattCharacteristic characteristic, + int status) { + if (super.onCharacteristicRead(gatt, characteristic, status)) { + return true; + } + UUID characteristicUUID = characteristic.getUuid(); + + if (characteristicUUID.equals(NutConstants.CHARAC_SYSTEM_ID)) { + // TODO: Handle System ID read + return true; + } + LOG.info("Unhandled characteristic read: " + characteristicUUID); + return false; + } + + @Override + public void onSendConfiguration(String config) { + + } + + @Override + public void onReadConfiguration(String config) { + + } + + @Override + public void onTestNewFunction() { + + } + + @Override + public void onSendWeather(WeatherSpec weatherSpec) { + + } + + /** + * Enables or disables link loss alert + */ + private void deviceLinkLossAlert(boolean enable) { + UUID charac; + if (this.proprietary) { + /** Part of {@link NutConstants.SERVICE_PROPRIETARY_NUT} */ + charac = NutConstants.CHARAC_CHANGE_POWER; + } else { + /** Part of {@link NutConstants.SERVICE_IMMEDIATE_ALERT} */ + charac = NutConstants.CHARAC_LINK_LOSS_ALERT_LEVEL; + } + + byte[] payload = new byte[]{(byte) (enable ? 0x00 : 0x01)}; + if (enable) { + writeCharacteristic("Enable link loss alert", charac, payload); + } else { + writeCharacteristic("Disable link loss alert", charac, payload); + } + } + + /** + * Should trigger an immediate alert + * + * @param enable turn on or not + */ + private void deviceImmediateAlert(boolean enable) { + UUID charac; + if (this.proprietary) { + /** Part of {@link NutConstants.SERVICE_IMMEDIATE_ALERT} */ + charac = NutConstants.CHARAC_LINK_LOSS_ALERT_LEVEL; + if (!authenticated) { + LOG.warn("Not authenticated, can't alert"); + return; + } + } else { + /** Part of {@link NutConstants.SERVICE_PROPRIETARY_NUT} */ + charac = NutConstants.CHARAC_CHANGE_POWER; + } + + if (enable) { + writeCharacteristic("Start alert", charac, new byte[]{(byte) 0x04}); + } else { + writeCharacteristic("Stop alert", charac, new byte[]{(byte) 0x03}); + } + } + + /** + * This will write a new key to the device + *

+ * However, it is irreversible, + * if you can't generate the right packets, + * the device is basically bricked! + *

+ * If you can generate the correct packets, + * it can be reset... somehow + * + * @param key key + */ + private void deviceWriteNewKey(byte[] key) { + // TODO: Determine each nuance of how this + // works before using it! + byte[] result_payload = new byte[key.length + 1]; + result_payload[0] = (byte) 0x04; + System.arraycopy(key, 0, result_payload, 1, key.length); + + writeCharacteristic("Write new key", + NutConstants.CHARAC_DFU_PW, + result_payload); + } + + /** + * Turns the device off + */ + private void deviceShutdown() { + writeCharacteristic("Shutdown", NutConstants.CHARAC_CHANGE_POWER, new byte[]{0x06}); + } + + /** + * Switches the device to Nordic's DFU mode + */ + private void deviceDFU() { + writeCharacteristic("Enable DFU mode", NutConstants.CHARAC_DFU_PW, new byte[]{0x14}); + } + + /** + * Specifies how long the alert lasts + * + * @param duration in seconds (I think) + */ + private void deviceWriteAlertDuration(int duration) { + if (duration == 0) { + duration = 15; + } + + UUID charac; + if (this.proprietary) { + charac = NutConstants.CHARAC_DFU_PW; + } else { + charac = NutConstants.CHARAC_CHANGE_POWER; + } + + writeCharacteristic("Write alert duration", + charac, + new byte[]{37, (byte) duration}); + } + + private void readHardwareInfo() { + BluetoothGattCharacteristic characteristic = getCharacteristic(NutConstants.CHARAC_HARDWARE_VERSION); + if (characteristic != null && + ((characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_READ) > 0)) { + readCharacteristic("Device read hardware", + NutConstants.CHARAC_HARDWARE_VERSION); + } + + BluetoothGattCharacteristic characteristic1 = getCharacteristic(NutConstants.CHARAC_MANUFACTURER_NAME); + if (characteristic1 != null && + (characteristic1.getProperties() & BluetoothGattCharacteristic.PROPERTY_READ) > 0) { + readCharacteristic("Read manufacturer", + NutConstants.CHARAC_MANUFACTURER_NAME); + } + } + + private void readFirmwareInfo() { + BluetoothGattCharacteristic characteristic = getCharacteristic(NutConstants.CHARAC_FIRMWARE_VERSION); + if (characteristic != null && + (characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_READ) > 0) { + readCharacteristic("Read firmware version", + NutConstants.CHARAC_FIRMWARE_VERSION); + } + } + + private void readBatteryInfo() { + BluetoothGattCharacteristic characteristic = getCharacteristic(NutConstants.CHARAC_BATTERY_INFO); + if (characteristic != null && + (characteristic.getProperties() & BluetoothGattCharacteristic.PROPERTY_READ) > 0) { + readCharacteristic("Read battery info", + NutConstants.CHARAC_BATTERY_INFO); + } + } + + /** + * Loads the three keys from device-specific shared preferences + */ + private void loadKeysFromPrefs() { + if (prefs != null) { + LOG.info("Reading keys"); + key1 = new BigInteger(prefs.getString("nut_packet_key_1", "0")); + key2 = new BigInteger(prefs.getString("nut_packet_key_2", "0")); + if (key1.equals(BigInteger.ZERO) || key2.equals(BigInteger.ZERO)) { + byte[] challenge = NutKey.hexStringToByteArrayNut(prefs.getString("nut_packet_challenge", "00")); + byte[] response = NutKey.hexStringToByteArrayNut(prefs.getString("nut_response_challenge", "00")); + if (Arrays.equals(challenge, new byte[]{0x00}) || + Arrays.equals(response, new byte[]{0x00})) { + GB.toast("No key available for the device", Toast.LENGTH_LONG, GB.ERROR); + return; + } + Map.Entry key = NutKey.reversePasswordGeneration( + challenge, + response, + gbDevice.getAddress() + ); + if (key == null) { + GB.toast("No correct key available for the device", Toast.LENGTH_LONG, GB.ERROR); + return; + } + key1 = key.getKey(); + key2 = key.getValue(); + LOG.debug("Key was extracted from challenge-response packets"); + } else { + LOG.debug("Key was preset"); + } + } + } + + /** + * Processes the authentication flow of the proprietary Nut protocol + * See more: {@link NutConstants#SERVICE_PROPRIETARY_NUT} + * + * @param received the notify characteristic's content + */ + public final void handleAuthResult(byte[] received) { + if (received != null && received.length != 0) { + if (received[0] == 0x01) { + // Password is needed + // Preamble, counter, rotating key, static key + byte[] payload = new byte[1 + 1 + 3 + 12]; + + // This is a response to the challenge + payload[0] = 0x02; + + // Modify the challenge + byte[] response = NutKey.passwordGeneration(gbDevice.getAddress(), received, key1, key2); + System.arraycopy(response, 0, payload, 1, response.length); + + writeCharacteristic("Authentication", + NutConstants.CHARAC_DFU_PW, + payload + ); + LOG.debug("Successfully sent auth"); + } else if (received[0] == 0x03) { + if (received[1] == 0x55) { + LOG.debug("Successful password attempt or uninitialized"); + authenticated = true; + initChara(); + } else { + LOG.debug("Error authenticating"); + // TODO: Disconnect + } + } else if (received[0] == 0x05) { + LOG.debug("Password has been set"); + } else { + LOG.debug("Invalid packet"); + // TODO: Disconnect + } + } + } + + /** + * Initializes required characteristics + */ + private void initChara() { + if (proprietary) { + writeCharacteristic("Init alert 1", NutConstants.CHARAC_LINK_LOSS_ALERT_LEVEL, new byte[]{(byte) 0x00}); + writeCharacteristic("Init alert 2", NutConstants.CHARAC_LINK_LOSS_ALERT_LEVEL, new byte[]{(byte) 0x00}); + writeCharacteristic("Init alert 3", NutConstants.CHARAC_LINK_LOSS_ALERT_LEVEL, new byte[]{(byte) 0x00}); + } + } + + /** + * Initiates a read of all the device information characteristics + */ + private void readDeviceInfo() { + readBatteryInfo(); + readHardwareInfo(); + readFirmwareInfo(); + } + + /** + * Just wraps writing into a neat little function + * + * @param taskName something that describes the task a bit + * @param charac the characteristic to write + * @param data the data to write + */ + private void writeCharacteristic(String taskName, UUID charac, byte[] data) { + BluetoothGattCharacteristic characteristic = getCharacteristic(charac); + + TransactionBuilder builder = new TransactionBuilder(taskName); + builder.write(characteristic, data); + builder.queue(getQueue()); + } + + /** + * Just wraps reading into a neat little function + * + * @param taskName something that describes the task a bit + * @param charac the characteristic to read + */ + private void readCharacteristic(String taskName, UUID charac) { + BluetoothGattCharacteristic characteristic = getCharacteristic(charac); + + TransactionBuilder builder = new TransactionBuilder(taskName); + builder.read(characteristic); + builder.queue(getQueue()); + } +} diff --git a/app/src/main/res/xml/devicesettings_nutmini.xml b/app/src/main/res/xml/devicesettings_nutmini.xml new file mode 100644 index 000000000..323dfcd73 --- /dev/null +++ b/app/src/main/res/xml/devicesettings_nutmini.xml @@ -0,0 +1,23 @@ + + + + + + + diff --git a/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/nut/NutUtilsTest.java b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/nut/NutUtilsTest.java new file mode 100644 index 000000000..0df021164 --- /dev/null +++ b/app/src/test/java/nodomain/freeyourgadget/gadgetbridge/service/devices/nut/NutUtilsTest.java @@ -0,0 +1,247 @@ +/* Copyright (C) 2020 Taavi Eomäe + + 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.nut; + +import org.junit.Test; + +import java.math.BigInteger; +import java.util.AbstractMap; +import java.util.Map; + +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertNotNull; +import static junit.framework.TestCase.fail; +import static nodomain.freeyourgadget.gadgetbridge.devices.nut.NutKey.byteArraysConcatReverseWithPad; +import static nodomain.freeyourgadget.gadgetbridge.devices.nut.NutKey.byteArraysDeConcatReverseWithPad; +import static nodomain.freeyourgadget.gadgetbridge.devices.nut.NutKey.bytesToHex2; +import static nodomain.freeyourgadget.gadgetbridge.devices.nut.NutKey.hexStringToByteArrayNut; +import static nodomain.freeyourgadget.gadgetbridge.devices.nut.NutKey.macAsByteArray; +import static nodomain.freeyourgadget.gadgetbridge.devices.nut.NutKey.passwordGeneration; +import static nodomain.freeyourgadget.gadgetbridge.devices.nut.NutKey.reversePasswordGeneration; + +public class NutUtilsTest { + @Test + public void testPasswordGen() { + String result = bytesToHex2(passwordGeneration( + "00:00:00:00:00:00", + new byte[]{1, 0, 0, 0, 0, 0, 0}, + BigInteger.ZERO, + BigInteger.ZERO) + ); + String expected = bytesToHex2(new byte[]{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}); + assertEquals(expected, result); + } + + @Test + public void testPasswordGen2() { + String result = bytesToHex2(passwordGeneration( + "00:00:00:00:00:00", + new byte[]{1, 0, 0, 0, 0, 0, 0}, + BigInteger.ZERO, + BigInteger.ZERO) + ); + String expected = bytesToHex2(new byte[]{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}); + assertEquals(expected, result); + } + + @Test + public void testPasswordGen3() { + String result = bytesToHex2(passwordGeneration( + "00:00:00:00:00:00", + new byte[]{1, 1, 0, 0, 0, 0, 0}, + BigInteger.ZERO, + BigInteger.ZERO) + ); + String expected = bytesToHex2(new byte[]{0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}); + assertEquals(expected, result); + } + + @Test + public void testPasswordGen4() { + String result = bytesToHex2(passwordGeneration( + "00:00:00:00:00:00", + new byte[]{1, 0, 0, 0, 0, 0, 0}, + new BigInteger(1, new byte[]{1, 0, 0, 0, 0, 0}), + BigInteger.ZERO) + ); + String expected = bytesToHex2(new byte[]{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00}); + assertEquals(expected, result); + } + + @Test + public void testPasswordGen5() { + String result = bytesToHex2(passwordGeneration( + "00:00:00:00:00:00", + new byte[]{1, 0, 0, 0, 0, 0, 0, 0, 0}, + BigInteger.ZERO, + new BigInteger(1, new byte[]{1, 1, 1, 1, 1, 1, 0, 0})) + ); + String expected = bytesToHex2(new byte[]{0x00, 0x00, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}); + assertEquals(expected, result); + } + + @Test + public void testPasswordGen6() { + String result = bytesToHex2(passwordGeneration( + "01:02:03:04:05:06", + new byte[]{1, 1, 2, 3, 4, 5, 6, 7, 8}, + BigInteger.ZERO, + BigInteger.ZERO) + ); + String expected = bytesToHex2(new byte[]{0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x07, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}); + assertEquals(expected, result); + } + + @Test + public void testPasswordGen7() { + String result = bytesToHex2(passwordGeneration( + "01:02:03:04:05:06", + new byte[]{1, 1, 2, 3, 4, 5, 6, 7, 8}, + new BigInteger(1, new byte[]{1, 2, 3, 4, 5, 6, 7, 8}), + new BigInteger(1, new byte[]{1, 2, 3, 4, 5, 6, 7, 8})) + ); + String expected = bytesToHex2(new byte[]{0x17, 0x15, 0x13, 0x11, 0x0f, 0x0d, 0x0b, 0x0a, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01}); + assertEquals(expected, result); + } + + @Test + public void testReversePassword() { + String challenge = "01e8f0340d000000000000000000000000"; + String response = "029bbd0fa25aed0000dcfd0c0000000000"; + String device_mac = "ED:5A:94:CB:98:E4"; + + Map.Entry key = reversePasswordGeneration( + hexStringToByteArrayNut(challenge), + hexStringToByteArrayNut(response), + device_mac + ); + + assertNotNull(key); + assertEquals(new AbstractMap.SimpleEntry<>(new BigInteger("851420"), new BigInteger("996303")), key); + } + + @Test + public void testPassword() { + String challenge = "011a9b826c000000000000000000000000"; + String response = "02cd675d015bed0000dcfd0c0000000000"; + String device_mac = "ED:5A:94:CB:98:E4"; + + Map.Entry key = new AbstractMap.SimpleEntry<>(new BigInteger("851420"), new BigInteger("996303")); + + byte[] result = new byte[17]; + result[0] = 0x02; + System.arraycopy(passwordGeneration(device_mac, hexStringToByteArrayNut(challenge), key.getKey(), key.getValue()), 0, result, 1, 16); + + assertEquals(bytesToHex2(hexStringToByteArrayNut(response)), bytesToHex2(result)); + } + + @Test + public void testInvalidResponse() { + String challenge = "00"; + String response = "00"; + String device_mac = "0:00:00:00:00:00"; + try { + reversePasswordGeneration( + hexStringToByteArrayNut(challenge), + hexStringToByteArrayNut(response), + device_mac + ); + } catch (IllegalArgumentException e) { + // This is intended behaviour + assertNotNull(e); + return; + } + fail(); + } + + @Test + public void testHexToByteArray() { + byte[] arr = hexStringToByteArrayNut("0x0000000000"); + assertEquals(bytesToHex2(new byte[]{0x00, 0x00, 0x00, 0x00, 0x00}), bytesToHex2(arr)); + } + + @Test + public void testHexToByteArray2() { + byte[] arr = hexStringToByteArrayNut("cafebabe"); + assertEquals(bytesToHex2(new byte[]{(byte) 0xca, (byte) 0xfe, (byte) 0xba, (byte) 0xbe}), bytesToHex2(arr)); + } + + @Test + public void testMACToByteArray() { + byte[] arr = macAsByteArray("AA:BB:CC:DD:EE:FF"); + assertEquals(bytesToHex2( + new byte[]{ + (byte) 0xaa, + (byte) 0xbb, + (byte) 0xcc, + (byte) 0xdd, + (byte) 0xee, + (byte) 0xff + } + ), bytesToHex2(arr)); + } + + @Test + public void testConcat() { + byte[] src1 = new byte[]{1, 2, 3, 4, 5, 6, 7, 8}; + byte[] src2 = new byte[]{9, 10, 11, 12, 13, 14, 15, 16}; + assertEquals( + bytesToHex2(new byte[]{0x10, 0x0f, 0x0e, 0x0d, 0x0c, 0x0b, 0x0a, 0x09, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01}), + bytesToHex2(byteArraysConcatReverseWithPad( + src1, + src2) + ) + ); + } + + @Test + public void testConcatDeConcat() { + byte[] src1 = new byte[]{0, 0, 3, 4, 5, 6, 0, 0}; + byte[] src2 = new byte[]{0, 0, 7, 8, 9, 1, 0, 0}; + byte[] dst1 = new byte[8]; + byte[] dst2 = new byte[8]; + byteArraysDeConcatReverseWithPad( + byteArraysConcatReverseWithPad( + src1, + src2 + ), + dst1, + dst2 + ); + assertEquals(bytesToHex2(src1), bytesToHex2(dst1)); + assertEquals(bytesToHex2(src2), bytesToHex2(dst2)); + } + + @Test + public void testConcatDeConcat2() { + byte[] src1 = new byte[]{1, 2, 3, 4, 5, 6, 7, 8}; + byte[] src2 = new byte[]{9, 10, 11, 12, 13, 14, 15, 16}; + byte[] dst1 = new byte[8]; + byte[] dst2 = new byte[8]; + byteArraysDeConcatReverseWithPad( + byteArraysConcatReverseWithPad( + src1, + src2 + ), + dst1, + dst2 + ); + assertEquals(bytesToHex2(src1), bytesToHex2(dst1)); + assertEquals(bytesToHex2(src2), bytesToHex2(dst2)); + } +}