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:
n0tst3v3
2026-08-19 16:25:56 -06:00
co-authored by Claude Opus 5
parent 308d63b8d8
commit 97c1e48e91
2 changed files with 380 additions and 1 deletions
@@ -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();
}
}