package com.wytehat.btlogger; import android.content.Context; import android.os.Build; import android.provider.Settings; import java.util.UUID; /** * The identifier this phone reports itself as, sent in X-Device-ID on every * upload so the server can attribute sightings to the device that saw them. * * Settings.Secure.ANDROID_ID is preferred: it survives app reinstalls, costs * nothing to read, and needs no permission. It is not always usable, though - * it is null before the device finishes first boot, and a well-known family of * buggy builds all report the same constant. When it cannot be trusted, a * random UUID is generated once and kept. * * That fallback is stored encrypted. The plain identifier would otherwise sit * in a readable preferences file, and on a rooted or backed-up device that is * a stable handle on the user that anything able to read the file could lift. * Encryption uses an AES-GCM key held in the AndroidKeyStore, so the key * material itself never enters the app process and cannot be pulled out of a * backup. * * androidx.security's EncryptedSharedPreferences does exactly this, but it * needs the AndroidX support libraries and API 23; this project is built in * AIDE with no dependency resolution and minSdk 14, so the same construction * is done directly against the platform Keystore. Below API 23 there is no * Keystore AES support and the value is stored in the clear - see * {@link #isFallbackEncrypted}. * * Values produced here match what the server accepts: 16 lower-case hex digits * (ANDROID_ID) or a canonical UUID (the fallback). */ public final class DeviceIdentity { /** Name the fallback identifier is filed under in {@link SecureStore}. */ private static final String KEY_DEVICE_UUID = "device_uuid"; /** * Shipped in a number of early builds, where every unit returned it. It is * a real ANDROID_ID value, so it passes a format check, but it identifies a * model rather than a phone and must not be used. */ private static final String KNOWN_BAD_ANDROID_ID = "9774d56d682e549c"; /** Resolved once per process; the value cannot change while we are running. */ private static volatile String cached; private DeviceIdentity() { } /** * The identifier to send in X-Device-ID. Never null, never empty. * * Safe to call from any thread, including the upload worker. */ public static String get(Context context) { String local = cached; if (local != null) { return local; } synchronized (DeviceIdentity.class) { if (cached == null) { cached = resolve(context.getApplicationContext()); } return cached; } } /** True when the stored fallback is actually encrypted at rest. */ public static boolean isFallbackEncrypted() { return SecureStore.isAvailable(); } // ------------------------------------------------------------------ // Resolution // ------------------------------------------------------------------ private static String resolve(Context context) { String androidId = readAndroidId(context); if (androidId != null) { return androidId; } return storedUuid(context); } /** * ANDROID_ID, lower-cased, or null when it cannot be trusted. * * Rejects the value outright rather than trying to repair it: a short or * non-hex reading means the platform gave us something we do not * understand, and guessing at it would produce an identifier that changes * between reads. */ private static String readAndroidId(Context context) { String value; try { value = Settings.Secure.getString( context.getContentResolver(), Settings.Secure.ANDROID_ID); } catch (Exception error) { // Some heavily modified builds throw rather than returning null. return null; } if (value == null) { return null; } value = value.trim().toLowerCase(); if (value.length() != 16 || !isHex(value)) { return null; } if (KNOWN_BAD_ANDROID_ID.equals(value)) { return null; } // All-zero shows up on emulators and on devices read before the ID has // been generated. It is not unique to anything. if (value.equals("0000000000000000")) { return null; } return value; } /** * The persisted fallback UUID, generating and storing one on first use. * * A stored value that cannot be read back - because the Keystore key was * cleared, or the app was restored onto a different device where the key * did not come with it - is replaced rather than treated as fatal. The * device gets a new identity in that case, which is the honest outcome: * the old one is genuinely unrecoverable. */ private static String storedUuid(Context context) { String stored = SecureStore.get(context, KEY_DEVICE_UUID); if (isUuid(stored)) { return stored; } String fresh = UUID.randomUUID().toString(); // A false return means this device cannot encrypt at rest. Writing the // identifier in the clear is worse than not persisting it, so it lives // for the life of the process only and the next launch makes another. SecureStore.put(context, KEY_DEVICE_UUID, fresh); return fresh; } // ------------------------------------------------------------------ // Validation // ------------------------------------------------------------------ private static boolean isHex(String value) { for (int index = 0; index < value.length(); index++) { char character = value.charAt(index); boolean digit = character >= '0' && character <= '9'; boolean letter = character >= 'a' && character <= 'f'; if (!digit && !letter) { return false; } } return true; } /** Canonical 8-4-4-4-12 lower-case UUID, the shape the server accepts. */ private static boolean isUuid(String value) { if (value == null || value.length() != 36) { return false; } for (int index = 0; index < 36; index++) { char character = value.charAt(index); if (index == 8 || index == 13 || index == 18 || index == 23) { if (character != '-') { return false; } continue; } boolean digit = character >= '0' && character <= '9'; boolean letter = character >= 'a' && character <= 'f'; if (!digit && !letter) { return false; } } return true; } }