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>
This commit is contained in:
n0tst3v3
2026-08-19 17:16:08 -06:00
co-authored by Claude Opus 5
parent 97c1e48e91
commit 320b4b2825
4 changed files with 472 additions and 146 deletions
@@ -1,19 +1,10 @@
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.
@@ -43,19 +34,9 @@ import javax.crypto.spec.GCMParameterSpec;
*/
public final class DeviceIdentity {
private static final String PREFS = "device_identity";
/** Encrypted (or, below API 23, plain) fallback identifier. */
/** Name the fallback identifier is filed under in {@link SecureStore}. */
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
@@ -94,7 +75,7 @@ public final class DeviceIdentity {
/** True when the stored fallback is actually encrypted at rest. */
public static boolean isFallbackEncrypted() {
return Build.VERSION.SDK_INT >= 23;
return SecureStore.isAvailable();
}
// ------------------------------------------------------------------
@@ -166,32 +147,18 @@ public final class DeviceIdentity {
*/
private static String storedUuid(Context context) {
SharedPreferences prefs =
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
String stored = SecureStore.get(context, KEY_DEVICE_UUID);
String stored = prefs.getString(KEY_DEVICE_UUID, null);
if (stored != null) {
String plain = isFallbackEncrypted() ? decrypt(stored) : stored;
if (isUuid(plain)) {
return plain;
}
if (isUuid(stored)) {
return stored;
}
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();
// 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;
}
@@ -245,101 +212,4 @@ public final class DeviceIdentity {
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();
}
}