feat(upload): identify this device to the ingestion server
Sends X-Device-ID on every upload so the server can attribute sightings to the phone that recorded them; it now rejects uploads without one. Prefers Settings.Secure.ANDROID_ID: it survives reinstalls, costs nothing to read and needs no permission. It is not always trustworthy though - null before first boot completes, and a known family of builds all report the same constant - so those readings are rejected outright rather than repaired, and a random UUID is generated and kept instead. Guessing at a malformed reading would produce an identifier that changes between reads, which is worse than falling back. The fallback UUID is stored encrypted under an AES-GCM key held in the AndroidKeyStore, so the key material never enters the app process and cannot be lifted out of a backup or off a rooted device. androidx.security's EncryptedSharedPreferences does exactly this, but it needs AndroidX and API 23; this project builds in AIDE with no dependency resolution and minSdk 14, so the same construction is done directly against the platform Keystore. The Keystore calls sit behind SDK_INT checks and are never resolved below 23, where the identifier is kept in memory for the process lifetime rather than written out in the clear. A stored value that will not decrypt - key cleared, or restored onto another device - yields a new identity rather than a crash. The old one is genuinely unrecoverable at that point. Upload errors now surface the server's "error" field instead of the raw JSON envelope, so a rejected device ID reads as a sentence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
308d63b8d8
commit
97c1e48e91
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 "";
|
||||
|
||||
Reference in New Issue
Block a user