Files
BlueToothLogger/app/src/main/java/com/wytehat/btlogger/DeviceIdentity.java
T
n0tst3v3andClaude Opus 5 320b4b2825 feat(upload): register for a per-device key, and fix the upload host
Registration
------------
On first upload the app trades the bootstrap token and its device id at
/api/v1/register-device for a key belonging to this device alone, keeps it in
the Keystore-encrypted store, and uses it from then on. The bootstrap token is
never sent again.

The only secret shipped in the APK now grants enrolment and nothing else.
Extracting it lets someone register a device; it does not let them read points
or upload as a phone that is already enrolled.

A 401 on upload means the key was revoked or rotated elsewhere. The uploader
forgets it, registers again and retries once, rather than leaving the user to
find the Server dialog.

The registration URL is derived from the configured upload URL, so pointing
the app at another server moves both together.

Keystore storage
----------------
The AES-GCM/Keystore code moves out of DeviceIdentity into SecureStore, now
that there are two secrets to keep rather than one. Below API 23 there is no
Keystore AES: SecureStore refuses to write instead of silently storing
secrets in the clear, and both callers keep their value in memory for the
process lifetime.

Upload host
-----------
DEFAULT_URL moves to com.org.bz. bt.justbug.me resolves elsewhere and has no
certificate on the proxy, so every upload there died in the TLS handshake with
TLSV1_ALERT_UNRECOGNIZED_NAME before a request was sent. Installs that already
saved the old default are migrated across on read; a URL the user chose is
left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:16:08 -06:00

216 lines
6.8 KiB
Java

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;
}
}