Metrics: Add Bluetooth address obfuscator in Java
* Expose Bluetooth address obfuscator to Java layer as
AdapterService.obfuscateAddress(BluetoothDevice)
* Add unit tests to verify different usage scenarios
* Add unit tests that read metrics salt value from disk and compare
native layer obfuscation result with Java layer HMAC-SHA256
calculation result
Bug: 112969790
Test: make, BluetoothInstrumentationTests
Change-Id: Iac151616413073b3602fd61d9e620a932ae0340c
diff --git a/android/app/jni/com_android_bluetooth_btservice_AdapterService.cpp b/android/app/jni/com_android_bluetooth_btservice_AdapterService.cpp
index 2d0960c..c8a9d56 100644
--- a/android/app/jni/com_android_bluetooth_btservice_AdapterService.cpp
+++ b/android/app/jni/com_android_bluetooth_btservice_AdapterService.cpp
@@ -1217,6 +1217,25 @@
env->ReleaseByteArrayElements(address, addr, 0);
}
+static jbyteArray obfuscateAddressNative(JNIEnv* env, jobject obj,
+ jbyteArray address) {
+ ALOGV("%s", __func__);
+ if (!sBluetoothInterface) return env->NewByteArray(0);
+ jbyte* addr = env->GetByteArrayElements(address, nullptr);
+ if (addr == nullptr) {
+ jniThrowIOException(env, EINVAL);
+ return env->NewByteArray(0);
+ }
+ RawAddress addr_obj = {};
+ addr_obj.FromOctets((uint8_t*)addr);
+ std::string output = sBluetoothInterface->obfuscate_address(addr_obj);
+ jsize output_size = output.size() * sizeof(char);
+ jbyteArray output_bytes = env->NewByteArray(output_size);
+ env->SetByteArrayRegion(output_bytes, 0, output_size,
+ (const jbyte*)output.data());
+ return output_bytes;
+}
+
static JNINativeMethod sMethods[] = {
/* name, signature, funcPtr */
{"classInitNative", "()V", (void*)classInitNative},
@@ -1251,7 +1270,8 @@
{"dumpMetricsNative", "()[B", (void*)dumpMetricsNative},
{"factoryResetNative", "()Z", (void*)factoryResetNative},
{"interopDatabaseClearNative", "()V", (void*)interopDatabaseClearNative},
- {"interopDatabaseAddNative", "(I[BI)V", (void*)interopDatabaseAddNative}};
+ {"interopDatabaseAddNative", "(I[BI)V", (void*)interopDatabaseAddNative},
+ {"obfuscateAddressNative", "([B)[B", (void*)obfuscateAddressNative}};
int register_com_android_bluetooth_btservice_AdapterService(JNIEnv* env) {
return jniRegisterNativeMethods(
diff --git a/android/app/src/com/android/bluetooth/btservice/AdapterService.java b/android/app/src/com/android/bluetooth/btservice/AdapterService.java
index 7966da3..04eefa8 100644
--- a/android/app/src/com/android/bluetooth/btservice/AdapterService.java
+++ b/android/app/src/com/android/bluetooth/btservice/AdapterService.java
@@ -72,6 +72,7 @@
import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.app.IBatteryStats;
+import com.google.protobuf.ByteString;
import com.google.protobuf.InvalidProtocolBufferException;
import java.io.FileDescriptor;
@@ -2608,6 +2609,16 @@
}
}
+ /**
+ * Obfuscate Bluetooth MAC address into a PII free ID string
+ *
+ * @param device Bluetooth device whose MAC address will be obfuscated
+ * @return a {@link ByteString} that is unique to this MAC address on this device
+ */
+ public ByteString obfuscateAddress(BluetoothDevice device) {
+ return ByteString.copyFrom(obfuscateAddressNative(Utils.getByteAddress(device)));
+ }
+
static native void classInitNative();
native boolean initNative();
@@ -2691,6 +2702,8 @@
private native void interopDatabaseAddNative(int feature, byte[] address, int length);
+ private native byte[] obfuscateAddressNative(byte[] address);
+
// Returns if this is a mock object. This is currently used in testing so that we may not call
// System.exit() while finalizing the object. Otherwise GC of mock objects unfortunately ends up
// calling finalize() which in turn calls System.exit() and the process crashes.
diff --git a/android/app/tests/unit/src/com/android/bluetooth/TestUtils.java b/android/app/tests/unit/src/com/android/bluetooth/TestUtils.java
index d0e3324..4a9054b 100644
--- a/android/app/tests/unit/src/com/android/bluetooth/TestUtils.java
+++ b/android/app/tests/unit/src/com/android/bluetooth/TestUtils.java
@@ -33,9 +33,13 @@
import org.mockito.ArgumentCaptor;
import org.mockito.internal.util.MockUtil;
+import java.io.BufferedReader;
+import java.io.FileReader;
+import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
+import java.util.HashMap;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -271,6 +275,41 @@
}
/**
+ * Read Bluetooth adapter configuration from the filesystem
+ *
+ * @return A {@link HashMap} of Bluetooth configs in the format:
+ * section -> key1 -> value1
+ * -> key2 -> value2
+ * Assume no empty section name, no duplicate keys in the same section
+ */
+ public static HashMap<String, HashMap<String, String>> readAdapterConfig() {
+ HashMap<String, HashMap<String, String>> adapterConfig = new HashMap<>();
+ try (BufferedReader reader =
+ new BufferedReader(new FileReader("/data/misc/bluedroid/bt_config.conf"))) {
+ String section = "";
+ for (String line; (line = reader.readLine()) != null;) {
+ line = line.trim();
+ if (line.isEmpty() || line.startsWith("#")) {
+ continue;
+ }
+ if (line.startsWith("[")) {
+ if (line.charAt(line.length() - 1) != ']') {
+ return null;
+ }
+ section = line.substring(1, line.length() - 1);
+ adapterConfig.put(section, new HashMap<>());
+ } else {
+ String[] keyValue = line.split("=");
+ adapterConfig.get(section).put(keyValue[0].trim(), keyValue[1].trim());
+ }
+ }
+ } catch (IOException e) {
+ return null;
+ }
+ return adapterConfig;
+ }
+
+ /**
* Helper class used to run synchronously a runnable action on a looper.
*/
private static final class SyncRunnable implements Runnable {
diff --git a/android/app/tests/unit/src/com/android/bluetooth/btservice/AdapterServiceTest.java b/android/app/tests/unit/src/com/android/bluetooth/btservice/AdapterServiceTest.java
index 2b19a20..2b98a03 100644
--- a/android/app/tests/unit/src/com/android/bluetooth/btservice/AdapterServiceTest.java
+++ b/android/app/tests/unit/src/com/android/bluetooth/btservice/AdapterServiceTest.java
@@ -17,15 +17,11 @@
package com.android.bluetooth.btservice;
import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Matchers.anyInt;
-import static org.mockito.Mockito.doReturn;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.timeout;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.*;
import android.app.AlarmManager;
import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
import android.bluetooth.IBluetoothCallback;
import android.content.Context;
import android.content.pm.ApplicationInfo;
@@ -42,20 +38,38 @@
import android.support.test.filters.MediumTest;
import android.support.test.runner.AndroidJUnit4;
import android.test.mock.MockContentResolver;
+import android.util.ByteStringUtils;
+import android.util.Log;
import com.android.bluetooth.R;
+import com.android.bluetooth.TestUtils;
+import com.android.bluetooth.Utils;
+
+import com.google.protobuf.ByteString;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Ignore;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+import java.util.HashMap;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
@MediumTest
@RunWith(AndroidJUnit4.class)
public class AdapterServiceTest {
+ private static final String TAG = AdapterServiceTest.class.getSimpleName();
+
private AdapterService mAdapterService;
private @Mock Context mMockContext;
@@ -77,6 +91,23 @@
private PowerManager mPowerManager;
private PackageManager mMockPackageManager;
private MockContentResolver mMockContentResolver;
+ private HashMap<String, HashMap<String, String>> mAdapterConfig;
+
+ @BeforeClass
+ public static void setupClass() {
+ // Bring native layer up and down to make sure config files are properly loaded
+ if (Looper.myLooper() == null) {
+ Looper.prepare();
+ }
+ Assert.assertNotNull(Looper.myLooper());
+ AdapterService adapterService = new AdapterService();
+ adapterService.initNative();
+ adapterService.cleanupNative();
+ HashMap<String, HashMap<String, String>> adapterConfig = TestUtils.readAdapterConfig();
+ Assert.assertNotNull(adapterConfig);
+ Assert.assertNotNull("metrics salt is null: " + adapterConfig.toString(),
+ getMetricsSalt(adapterConfig));
+ }
@Before
public void setUp() throws PackageManager.NameNotFoundException {
@@ -85,12 +116,8 @@
}
Assert.assertNotNull(Looper.myLooper());
- InstrumentationRegistry.getInstrumentation().runOnMainSync(new Runnable() {
- @Override
- public void run() {
- mAdapterService = new AdapterService();
- }
- });
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(
+ () -> mAdapterService = new AdapterService());
mMockPackageManager = mock(PackageManager.class);
mMockContentResolver = new MockContentResolver(mMockContext);
MockitoAnnotations.initMocks(this);
@@ -128,6 +155,9 @@
mAdapterService.registerCallback(mIBluetoothCallback);
Config.init(mMockContext);
+
+ mAdapterConfig = TestUtils.readAdapterConfig();
+ Assert.assertNotNull(mAdapterConfig);
}
@After
@@ -463,4 +493,209 @@
// Restore earlier setting
SystemProperties.set(AdapterService.BLUETOOTH_BTSNOOP_ENABLE_PROPERTY, snoopSetting);
}
+
+ /**
+ * Test: Obfuscate Bluetooth address when Bluetooth is disabled
+ * Check whether the returned value meets expectation
+ */
+ @Test
+ public void testObfuscateBluetoothAddress_BluetoothDisabled() {
+ Assert.assertFalse(mAdapterService.isEnabled());
+ byte[] metricsSalt = getMetricsSalt(mAdapterConfig);
+ Assert.assertNotNull(metricsSalt);
+ BluetoothDevice device = TestUtils.getTestDevice(BluetoothAdapter.getDefaultAdapter(), 0);
+ ByteString obfuscatedAddress = mAdapterService.obfuscateAddress(device);
+ Assert.assertFalse(obfuscatedAddress.isEmpty());
+ Assert.assertFalse(isByteArrayAllZero(obfuscatedAddress.toByteArray()));
+ Assert.assertArrayEquals(obfuscateInJava(metricsSalt, device),
+ obfuscatedAddress.toByteArray());
+ }
+
+ /**
+ * Test: Obfuscate Bluetooth address when Bluetooth is enabled
+ * Check whether the returned value meets expectation
+ */
+ @Test
+ public void testObfuscateBluetoothAddress_BluetoothEnabled() {
+ Assert.assertFalse(mAdapterService.isEnabled());
+ doEnable(0, false);
+ Assert.assertTrue(mAdapterService.isEnabled());
+ byte[] metricsSalt = getMetricsSalt(mAdapterConfig);
+ Assert.assertNotNull(metricsSalt);
+ BluetoothDevice device = TestUtils.getTestDevice(BluetoothAdapter.getDefaultAdapter(), 0);
+ ByteString obfuscatedAddress = mAdapterService.obfuscateAddress(device);
+ Assert.assertFalse(obfuscatedAddress.isEmpty());
+ Assert.assertFalse(isByteArrayAllZero(obfuscatedAddress.toByteArray()));
+ Assert.assertArrayEquals(obfuscateInJava(metricsSalt, device),
+ obfuscatedAddress.toByteArray());
+ }
+
+ /**
+ * Test: Check if obfuscated Bluetooth address stays the same after toggling Bluetooth
+ */
+ @Test
+ public void testObfuscateBluetoothAddress_PersistentBetweenToggle() {
+ Assert.assertFalse(mAdapterService.isEnabled());
+ byte[] metricsSalt = getMetricsSalt(mAdapterConfig);
+ Assert.assertNotNull(metricsSalt);
+ BluetoothDevice device = TestUtils.getTestDevice(BluetoothAdapter.getDefaultAdapter(), 0);
+ ByteString obfuscatedAddress1 = mAdapterService.obfuscateAddress(device);
+ Assert.assertFalse(obfuscatedAddress1.isEmpty());
+ Assert.assertFalse(isByteArrayAllZero(obfuscatedAddress1.toByteArray()));
+ Assert.assertArrayEquals(obfuscateInJava(metricsSalt, device),
+ obfuscatedAddress1.toByteArray());
+ // Enable
+ doEnable(0, false);
+ Assert.assertTrue(mAdapterService.isEnabled());
+ ByteString obfuscatedAddress3 = mAdapterService.obfuscateAddress(device);
+ Assert.assertFalse(obfuscatedAddress3.isEmpty());
+ Assert.assertFalse(isByteArrayAllZero(obfuscatedAddress3.toByteArray()));
+ Assert.assertArrayEquals(obfuscatedAddress3.toByteArray(),
+ obfuscatedAddress1.toByteArray());
+ // Disable
+ doDisable(0, false);
+ Assert.assertFalse(mAdapterService.isEnabled());
+ ByteString obfuscatedAddress4 = mAdapterService.obfuscateAddress(device);
+ Assert.assertFalse(obfuscatedAddress4.isEmpty());
+ Assert.assertFalse(isByteArrayAllZero(obfuscatedAddress4.toByteArray()));
+ Assert.assertArrayEquals(obfuscatedAddress4.toByteArray(),
+ obfuscatedAddress1.toByteArray());
+ }
+
+ /**
+ * Test: Check if obfuscated Bluetooth address stays the same after re-initializing
+ * {@link AdapterService}
+ */
+ @Test
+ public void testObfuscateBluetoothAddress_PersistentBetweenAdapterServiceInitialization() throws
+ PackageManager.NameNotFoundException {
+ byte[] metricsSalt = getMetricsSalt(mAdapterConfig);
+ Assert.assertNotNull(metricsSalt);
+ Assert.assertFalse(mAdapterService.isEnabled());
+ BluetoothDevice device = TestUtils.getTestDevice(BluetoothAdapter.getDefaultAdapter(), 0);
+ ByteString obfuscatedAddress1 = mAdapterService.obfuscateAddress(device);
+ Assert.assertFalse(obfuscatedAddress1.isEmpty());
+ Assert.assertFalse(isByteArrayAllZero(obfuscatedAddress1.toByteArray()));
+ Assert.assertArrayEquals(obfuscateInJava(metricsSalt, device),
+ obfuscatedAddress1.toByteArray());
+ tearDown();
+ setUp();
+ Assert.assertFalse(mAdapterService.isEnabled());
+ ByteString obfuscatedAddress2 = mAdapterService.obfuscateAddress(device);
+ Assert.assertFalse(obfuscatedAddress2.isEmpty());
+ Assert.assertFalse(isByteArrayAllZero(obfuscatedAddress2.toByteArray()));
+ Assert.assertArrayEquals(obfuscatedAddress2.toByteArray(),
+ obfuscatedAddress1.toByteArray());
+ }
+
+ /**
+ * Test: Verify that obfuscated Bluetooth address changes after factory reset
+ *
+ * There are 4 types of factory reset that we are talking about:
+ * 1. Factory reset all user data from Settings -> Will restart phone
+ * 2. Factory reset WiFi and Bluetooth from Settings -> Will only restart WiFi and BT
+ * 3. Call BluetoothAdapter.factoryReset() -> Will disable Bluetooth and reset config in
+ * memory and disk
+ * 4. Call AdapterService.factoryReset() -> Will only reset config in memory
+ *
+ * We can only use No. 4 here
+ */
+ @Ignore("AdapterService.factoryReset() does not reload config into memory and hence old salt"
+ + " is still used until next time Bluetooth library is initialized. However Bluetooth"
+ + " cannot be used until Bluetooth process restart any way. Thus it is almost"
+ + " guaranteed that user has to re-enable Bluetooth and hence re-generate new salt"
+ + " after factory reset")
+ @Test
+ public void testObfuscateBluetoothAddress_FactoryReset() {
+ Assert.assertFalse(mAdapterService.isEnabled());
+ BluetoothDevice device = TestUtils.getTestDevice(BluetoothAdapter.getDefaultAdapter(), 0);
+ ByteString obfuscatedAddress1 = mAdapterService.obfuscateAddress(device);
+ Assert.assertFalse(obfuscatedAddress1.isEmpty());
+ Assert.assertFalse(isByteArrayAllZero(obfuscatedAddress1.toByteArray()));
+ mAdapterService.factoryReset();
+ ByteString obfuscatedAddress2 = mAdapterService.obfuscateAddress(device);
+ Assert.assertFalse(obfuscatedAddress2.isEmpty());
+ Assert.assertFalse(isByteArrayAllZero(obfuscatedAddress2.toByteArray()));
+ Assert.assertFalse(Arrays.equals(obfuscatedAddress2.toByteArray(),
+ obfuscatedAddress1.toByteArray()));
+ doEnable(0, false);
+ ByteString obfuscatedAddress3 = mAdapterService.obfuscateAddress(device);
+ Assert.assertFalse(obfuscatedAddress3.isEmpty());
+ Assert.assertFalse(isByteArrayAllZero(obfuscatedAddress3.toByteArray()));
+ Assert.assertArrayEquals(obfuscatedAddress3.toByteArray(),
+ obfuscatedAddress2.toByteArray());
+ mAdapterService.factoryReset();
+ ByteString obfuscatedAddress4 = mAdapterService.obfuscateAddress(device);
+ Assert.assertFalse(obfuscatedAddress4.isEmpty());
+ Assert.assertFalse(isByteArrayAllZero(obfuscatedAddress4.toByteArray()));
+ Assert.assertFalse(Arrays.equals(obfuscatedAddress4.toByteArray(),
+ obfuscatedAddress3.toByteArray()));
+ }
+
+ /**
+ * Test: Verify that obfuscated Bluetooth address changes after factory reset and reloading
+ * native layer
+ */
+ @Test
+ public void testObfuscateBluetoothAddress_FactoryResetAndReloadNativeLayer() throws
+ PackageManager.NameNotFoundException {
+ byte[] metricsSalt1 = getMetricsSalt(mAdapterConfig);
+ Assert.assertNotNull(metricsSalt1);
+ Assert.assertFalse(mAdapterService.isEnabled());
+ BluetoothDevice device = TestUtils.getTestDevice(BluetoothAdapter.getDefaultAdapter(), 0);
+ ByteString obfuscatedAddress1 = mAdapterService.obfuscateAddress(device);
+ Assert.assertFalse(obfuscatedAddress1.isEmpty());
+ Assert.assertFalse(isByteArrayAllZero(obfuscatedAddress1.toByteArray()));
+ Assert.assertArrayEquals(obfuscateInJava(metricsSalt1, device),
+ obfuscatedAddress1.toByteArray());
+ mAdapterService.factoryReset();
+ tearDown();
+ setUp();
+ // Cannot verify metrics salt since it is not written to disk until native cleanup
+ ByteString obfuscatedAddress2 = mAdapterService.obfuscateAddress(device);
+ Assert.assertFalse(obfuscatedAddress2.isEmpty());
+ Assert.assertFalse(isByteArrayAllZero(obfuscatedAddress2.toByteArray()));
+ Assert.assertFalse(Arrays.equals(obfuscatedAddress2.toByteArray(),
+ obfuscatedAddress1.toByteArray()));
+ }
+
+ private static byte[] getMetricsSalt(HashMap<String, HashMap<String, String>> adapterConfig) {
+ HashMap<String, String> metricsSection = adapterConfig.get("Metrics");
+ if (metricsSection == null) {
+ Log.e(TAG, "Metrics section is null: " + adapterConfig.toString());
+ return null;
+ }
+ String saltString = metricsSection.get("Salt256Bit");
+ if (saltString == null) {
+ Log.e(TAG, "Salt256Bit is null: " + metricsSection.toString());
+ return null;
+ }
+ byte[] metricsSalt = ByteStringUtils.fromHexToByteArray(saltString);
+ if (metricsSalt.length != 32) {
+ Log.e(TAG, "Salt length is not 32 bit, but is " + metricsSalt.length);
+ return null;
+ }
+ return metricsSalt;
+ }
+
+ private static byte[] obfuscateInJava(byte[] key, BluetoothDevice device) {
+ String algorithm = "HmacSHA256";
+ try {
+ Mac hmac256 = Mac.getInstance(algorithm);
+ hmac256.init(new SecretKeySpec(key, algorithm));
+ return hmac256.doFinal(Utils.getByteAddress(device));
+ } catch (NoSuchAlgorithmException | IllegalStateException | InvalidKeyException exp) {
+ exp.printStackTrace();
+ return null;
+ }
+ }
+
+ private static boolean isByteArrayAllZero(byte[] byteArray) {
+ for (byte i : byteArray) {
+ if (i != 0) {
+ return false;
+ }
+ }
+ return true;
+ }
}