diff --git a/app/src/main/java/com/wytehat/btlogger/DeviceIdentity.java b/app/src/main/java/com/wytehat/btlogger/DeviceIdentity.java new file mode 100644 index 0000000..71406ed --- /dev/null +++ b/app/src/main/java/com/wytehat/btlogger/DeviceIdentity.java @@ -0,0 +1,345 @@ +package com.wytehat.btlogger; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.provider.Settings; +import android.util.Base64; + +import java.security.KeyStore; +import java.util.UUID; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; + +/** + * 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 { + + private static final String PREFS = "device_identity"; + + /** Encrypted (or, below API 23, plain) fallback identifier. */ + private static final String KEY_DEVICE_UUID = "device_uuid"; + + private static final String KEYSTORE = "AndroidKeyStore"; + private static final String KEY_ALIAS = "btlogger_device_id_v1"; + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + + /** 96 bits is the IV size GCM is specified around; anything else is slower. */ + private static final int GCM_IV_BYTES = 12; + private static final int GCM_TAG_BITS = 128; + + /** + * 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 Build.VERSION.SDK_INT >= 23; + } + + // ------------------------------------------------------------------ + // 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) { + + SharedPreferences prefs = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + + String stored = prefs.getString(KEY_DEVICE_UUID, null); + + if (stored != null) { + + String plain = isFallbackEncrypted() ? decrypt(stored) : stored; + + if (isUuid(plain)) { + return plain; + } + } + + String fresh = UUID.randomUUID().toString(); + + String toStore = isFallbackEncrypted() ? encrypt(fresh) : fresh; + + if (toStore == null) { + // Encryption is unavailable on this device. Storing the identifier + // in the clear is worse than not persisting it, so keep it for the + // life of the process only; the next launch generates another. + return fresh; + } + + prefs.edit().putString(KEY_DEVICE_UUID, toStore).commit(); + + 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; + } + + // ------------------------------------------------------------------ + // Keystore-backed encryption + // + // Isolated behind SDK_INT checks and only touched on API 23+, so the + // android.security.keystore classes are never resolved on older devices. + // ------------------------------------------------------------------ + + /** "ivBase64:ciphertextBase64", or null if the device cannot encrypt. */ + private static String encrypt(String plain) { + + try { + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, secretKey()); + + byte[] iv = cipher.getIV(); + byte[] ciphertext = cipher.doFinal(plain.getBytes("UTF-8")); + + return Base64.encodeToString(iv, Base64.NO_WRAP) + + ":" + + Base64.encodeToString(ciphertext, Base64.NO_WRAP); + + } catch (Exception error) { + return null; + } + } + + /** Plaintext, or null when the value cannot be recovered. */ + private static String decrypt(String stored) { + + try { + + int separator = stored.indexOf(':'); + + if (separator <= 0 || separator == stored.length() - 1) { + return null; + } + + byte[] iv = Base64.decode( + stored.substring(0, separator), Base64.NO_WRAP); + byte[] ciphertext = Base64.decode( + stored.substring(separator + 1), Base64.NO_WRAP); + + if (iv.length != GCM_IV_BYTES) { + return null; + } + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, secretKey(), + new GCMParameterSpec(GCM_TAG_BITS, iv)); + + return new String(cipher.doFinal(ciphertext), "UTF-8"); + + } catch (Exception error) { + // Includes AEADBadTagException when the ciphertext was tampered + // with, and KeyPermanentlyInvalidatedException after a restore. + return null; + } + } + + /** + * The AES key for this install, created on first use. + * + * Generated inside the Keystore, so the raw key never exists in the app's + * memory and does not leave the device in a backup. + */ + private static SecretKey secretKey() throws Exception { + + KeyStore keyStore = KeyStore.getInstance(KEYSTORE); + keyStore.load(null); + + KeyStore.Entry existing = keyStore.getEntry(KEY_ALIAS, null); + + if (existing instanceof KeyStore.SecretKeyEntry) { + return ((KeyStore.SecretKeyEntry) existing).getSecretKey(); + } + + KeyGenerator generator = + KeyGenerator.getInstance(android.security.keystore.KeyProperties.KEY_ALGORITHM_AES, + KEYSTORE); + + generator.init(new android.security.keystore.KeyGenParameterSpec.Builder( + KEY_ALIAS, + android.security.keystore.KeyProperties.PURPOSE_ENCRYPT + | android.security.keystore.KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(android.security.keystore.KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings( + android.security.keystore.KeyProperties.ENCRYPTION_PADDING_NONE) + // No user-authentication requirement: uploads run from a background + // service while the phone is locked, and a key that needed an + // unlock would make the identifier unreadable exactly then. + .setRandomizedEncryptionRequired(true) + .build()); + + return generator.generateKey(); + } +} diff --git a/app/src/main/java/com/wytehat/btlogger/ServerUploader.java b/app/src/main/java/com/wytehat/btlogger/ServerUploader.java index 5f28750..fbe7e45 100644 --- a/app/src/main/java/com/wytehat/btlogger/ServerUploader.java +++ b/app/src/main/java/com/wytehat/btlogger/ServerUploader.java @@ -66,6 +66,9 @@ public final class ServerUploader { public int rowsSent; public String message = ""; + /** Identifier the server read from X-Device-ID, echoed back. */ + public String deviceId = ""; + /** One line suitable for a toast or a status label. */ public String summary() { @@ -285,6 +288,13 @@ public final class ServerUploader { connection.setReadTimeout(READ_TIMEOUT_MS); connection.setRequestProperty("X-API-Key", apiKey(context)); + + // Identifies which phone recorded these sightings. The server + // stores it against every point and refuses the upload with a 400 + // if it is missing or malformed, so it goes on every request. + connection.setRequestProperty( + "X-Device-ID", DeviceIdentity.get(context)); + connection.setRequestProperty("Accept", "application/json"); connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); @@ -320,8 +330,14 @@ public final class ServerUploader { } if (result.httpStatus >= 400) { + + // The server explains itself in an "error" field; showing that + // beats showing the raw JSON envelope around it. + String detail = errorMessage(response); + result.message = "HTTP " + result.httpStatus - + (response.length() > 0 ? ": " + trim(response) : ""); + + (detail.length() > 0 ? ": " + detail : ""); + return result; } @@ -358,12 +374,30 @@ public final class ServerUploader { result.newPointsAdded = json.optInt("new_points_added", 0); result.duplicatesSkipped = json.optInt("duplicates_skipped", 0); result.invalidRows = json.optInt("invalid_rows", 0); + result.deviceId = json.optString("device_id", ""); } catch (Exception ignored) { // A 2xx with an unreadable body still means the server took it. } } + /** + * The server's "error" field, or the trimmed body when it is not JSON. + */ + private static String errorMessage(String response) { + + if (response == null || response.length() == 0) return ""; + + try { + String error = new JSONObject(response).optString("error", ""); + if (error.length() > 0) return error; + } catch (Exception ignored) { + // Not JSON - a proxy error page, most likely. + } + + return trim(response); + } + private static String readAll(InputStream stream) { if (stream == null) return "";