From 320b4b2825c9f46ad0adc872c97aab69f341439b Mon Sep 17 00:00:00 2001 From: n0tst3v3 Date: Wed, 19 Aug 2026 17:16:08 -0600 Subject: [PATCH] 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 --- .../com/wytehat/btlogger/DeviceIdentity.java | 148 +---------- .../wytehat/btlogger/DeviceRegistration.java | 230 ++++++++++++++++++ .../com/wytehat/btlogger/SecureStore.java | 183 ++++++++++++++ .../com/wytehat/btlogger/ServerUploader.java | 57 ++++- 4 files changed, 472 insertions(+), 146 deletions(-) create mode 100644 app/src/main/java/com/wytehat/btlogger/DeviceRegistration.java create mode 100644 app/src/main/java/com/wytehat/btlogger/SecureStore.java diff --git a/app/src/main/java/com/wytehat/btlogger/DeviceIdentity.java b/app/src/main/java/com/wytehat/btlogger/DeviceIdentity.java index 71406ed..c4489e2 100644 --- a/app/src/main/java/com/wytehat/btlogger/DeviceIdentity.java +++ b/app/src/main/java/com/wytehat/btlogger/DeviceIdentity.java @@ -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(); - } } diff --git a/app/src/main/java/com/wytehat/btlogger/DeviceRegistration.java b/app/src/main/java/com/wytehat/btlogger/DeviceRegistration.java new file mode 100644 index 0000000..e2d0875 --- /dev/null +++ b/app/src/main/java/com/wytehat/btlogger/DeviceRegistration.java @@ -0,0 +1,230 @@ +package com.wytehat.btlogger; + +import android.content.Context; + +import org.json.JSONObject; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; + +/** + * Trades the bootstrap token for this device's own upload key. + * + * On first run the app presents the shared bootstrap token together with its + * device identifier to /api/v1/register-device, and the server returns a key + * issued to that device alone. The key is kept in {@link SecureStore} and used + * for every upload afterwards; the bootstrap token is never sent again. + * + * The point of the exchange is that the only secret shipped inside the APK + * grants registration and nothing else. Pulling it out of the package lets + * someone enrol a device; it does not let them read points, and it does not + * let them write as any phone that is already enrolled. A device that starts + * misbehaving is revoked on the server without touching the rest of the fleet. + * + * All methods here perform network I/O and must be called off the main thread. + */ +public final class DeviceRegistration { + + /** Name the issued key is filed under in {@link SecureStore}. */ + private static final String KEY_DEVICE_API_KEY = "device_api_key"; + + /** + * Registration secret shipped with the client. Public by construction - + * anything in an APK can be extracted - so the server treats it as a + * registration grant only, and revocation rather than secrecy is what + * limits a leaked copy. + */ + public static final String BOOTSTRAP_TOKEN = + "android-app-key-12345-change-in-production"; + + private static final int CONNECT_TIMEOUT_MS = 20000; + private static final int READ_TIMEOUT_MS = 30000; + + /** Cached so a batch of uploads does not re-read the Keystore each time. */ + private static volatile String cached; + + private DeviceRegistration() { + } + + /** + * This device's upload key, registering with the server if it has none. + * + * Returns null when registration could not be completed - offline, or the + * server refused the bootstrap token. Callers should surface that rather + * than falling back to the shared key, which the server no longer accepts + * for uploads. + */ + public static String key(Context context) { + + String local = cached; + + if (local != null) return local; + + synchronized (DeviceRegistration.class) { + + if (cached != null) return cached; + + Context application = context.getApplicationContext(); + + String stored = SecureStore.get(application, KEY_DEVICE_API_KEY); + + if (stored != null && stored.length() > 0) { + cached = stored; + return cached; + } + + cached = register(application); + + return cached; + } + } + + /** + * Discards the stored key, so the next upload registers again. + * + * Called when the server rejects the key: it was revoked, or rotated by a + * registration from elsewhere. Re-registering is the documented recovery, + * and it is why the server allows a device id to be re-enrolled. + */ + public static void forget(Context context) { + + synchronized (DeviceRegistration.class) { + cached = null; + SecureStore.remove(context.getApplicationContext(), + KEY_DEVICE_API_KEY); + } + } + + /** True when a key is already held, without going to the network. */ + public static boolean isRegistered(Context context) { + + if (cached != null) return true; + + String stored = SecureStore.get( + context.getApplicationContext(), KEY_DEVICE_API_KEY); + + return stored != null && stored.length() > 0; + } + + // ------------------------------------------------------------------ + // Enrolment + // ------------------------------------------------------------------ + + /** Performs the exchange. Returns the issued key, or null on failure. */ + private static String register(Context context) { + + HttpURLConnection connection = null; + + try { + + connection = (HttpURLConnection) + new URL(registrationUrl(context)).openConnection(); + + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(READ_TIMEOUT_MS); + + connection.setRequestProperty( + "X-Bootstrap-Token", BOOTSTRAP_TOKEN); + connection.setRequestProperty( + "X-Device-ID", DeviceIdentity.get(context)); + connection.setRequestProperty("Accept", "application/json"); + connection.setRequestProperty( + "Content-Type", "application/json; charset=utf-8"); + + JSONObject body = new JSONObject(); + body.put("label", android.os.Build.MODEL); + + OutputStream out = connection.getOutputStream(); + out.write(body.toString().getBytes("UTF-8")); + out.flush(); + out.close(); + + int status = connection.getResponseCode(); + + String response = readAll(status >= 400 + ? connection.getErrorStream() + : connection.getInputStream()); + + if (status != 200 && status != 201) { + return null; + } + + String issued = new JSONObject(response).optString("api_key", ""); + + if (issued.length() == 0) { + return null; + } + + // A device that cannot encrypt still gets to upload; the key just + // lives in memory and is re-issued on the next launch. + SecureStore.put(context, KEY_DEVICE_API_KEY, issued); + + return issued; + + } catch (Exception error) { + return null; + + } finally { + if (connection != null) connection.disconnect(); + } + } + + /** + * The registration endpoint, derived from the configured upload URL so + * that pointing the app at another server moves both together. + */ + static String registrationUrl(Context context) { + + String upload = ServerUploader.serverUrl(context); + + int api = upload.indexOf("/api/"); + + if (api > 0) { + return upload.substring(0, api) + "/api/v1/register-device"; + } + + // The upload URL is not shaped as expected; fall back to the host root. + try { + URL parsed = new URL(upload); + return parsed.getProtocol() + "://" + parsed.getAuthority() + + "/api/v1/register-device"; + } catch (Exception error) { + return upload; + } + } + + private static String readAll(InputStream stream) { + + if (stream == null) return ""; + + StringBuilder text = new StringBuilder(); + BufferedReader reader = null; + + try { + + reader = new BufferedReader(new InputStreamReader(stream, "UTF-8")); + + String line; + + while ((line = reader.readLine()) != null) { + text.append(line); + } + + } catch (Exception ignored) { + // A truncated body is handled by the caller's JSON parse failing. + } finally { + try { + if (reader != null) reader.close(); + } catch (Exception ignored) { + } + } + + return text.toString(); + } +} diff --git a/app/src/main/java/com/wytehat/btlogger/SecureStore.java b/app/src/main/java/com/wytehat/btlogger/SecureStore.java new file mode 100644 index 0000000..8594b00 --- /dev/null +++ b/app/src/main/java/com/wytehat/btlogger/SecureStore.java @@ -0,0 +1,183 @@ +package com.wytehat.btlogger; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.util.Base64; + +import java.security.KeyStore; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; + +/** + * Small encrypted key/value store for the few secrets this app has to keep: + * its fallback device identifier and the API key the server issues it. + * + * Values are sealed with AES-GCM under a key generated inside the + * AndroidKeyStore, so the key material never enters this process and does not + * travel in a backup. That is the same construction androidx.security's + * EncryptedSharedPreferences uses; it is done directly against the platform + * here because this project builds in AIDE with no dependency resolution and + * a minSdk of 14, so the AndroidX artifact is not available. + * + * Below API 23 there is no Keystore AES support. Rather than silently writing + * secrets in the clear, {@link #put} refuses and {@link #isAvailable} reports + * false, leaving the caller to decide - both callers here keep the value in + * memory for the life of the process instead. + */ +public final class SecureStore { + + private static final String PREFS = "secure_store"; + + private static final String KEYSTORE = "AndroidKeyStore"; + private static final String KEY_ALIAS = "btlogger_secure_store_v1"; + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + + /** 96 bits is the IV size GCM is specified around. */ + private static final int GCM_IV_BYTES = 12; + private static final int GCM_TAG_BITS = 128; + + private SecureStore() { + } + + /** True when this device can actually encrypt at rest. */ + public static boolean isAvailable() { + return Build.VERSION.SDK_INT >= 23; + } + + /** + * The stored plaintext for a name, or null when absent or unreadable. + * + * Unreadable covers a cleared Keystore and a restore onto another device; + * both mean the value is genuinely unrecoverable, so callers treat null as + * "not stored yet" rather than as an error. + */ + public static String get(Context context, String name) { + + if (!isAvailable()) return null; + + String sealed = prefs(context).getString(name, null); + + if (sealed == null) return null; + + return decrypt(sealed); + } + + /** Stores a value encrypted. Returns false if it could not be sealed. */ + public static boolean put(Context context, String name, String value) { + + if (!isAvailable()) return false; + + String sealed = encrypt(value); + + if (sealed == null) return false; + + return prefs(context).edit().putString(name, sealed).commit(); + } + + /** Forgets a value, e.g. after the server rejects the key it holds. */ + public static void remove(Context context, String name) { + prefs(context).edit().remove(name).commit(); + } + + private static SharedPreferences prefs(Context context) { + return context.getApplicationContext() + .getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + // ------------------------------------------------------------------ + // Keystore-backed AES-GCM + // + // Only reached when isAvailable() is true, so the API 23 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; + } + } + + private static String decrypt(String sealed) { + + try { + + int separator = sealed.indexOf(':'); + + if (separator <= 0 || separator == sealed.length() - 1) { + return null; + } + + byte[] iv = Base64.decode( + sealed.substring(0, separator), Base64.NO_WRAP); + byte[] ciphertext = Base64.decode( + sealed.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) { + // AEADBadTagException if tampered with, + // 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 this + * process 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 be 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 fbe7e45..90341ba 100644 --- a/app/src/main/java/com/wytehat/btlogger/ServerUploader.java +++ b/app/src/main/java/com/wytehat/btlogger/ServerUploader.java @@ -39,8 +39,12 @@ public final class ServerUploader { public static final String KEY_API_KEY = "server_api_key"; public static final String KEY_LAST_UPLOAD = "last_upload_at"; + // bt.justbug.me resolves elsewhere and has no certificate on the proxy, so + // the handshake there fails with TLSV1_ALERT_UNRECOGNIZED_NAME before any + // request is sent. com.org.bz is the host the ingestion server is actually + // published under. public static final String DEFAULT_URL = - "https://bt.justbug.me/api/v1/bt/upload"; + "https://com.org.bz/api/v1/bt/upload"; public static final String DEFAULT_API_KEY = "android-app-key-12345-change-in-production"; @@ -93,8 +97,25 @@ public final class ServerUploader { // Settings // ------------------------------------------------------------------ + /** Host the old default pointed at, kept only to migrate off it. */ + private static final String RETIRED_HOST = "bt.justbug.me"; + public static String serverUrl(Context context) { - return prefs(context).getString(KEY_URL, DEFAULT_URL); + + String stored = prefs(context).getString(KEY_URL, DEFAULT_URL); + + // Anyone who uploaded before this change has the old default saved, and + // it can only ever fail the TLS handshake. Move them across once rather + // than making every existing install edit the field by hand; a URL the + // user actually chose is left alone. + if (stored.contains(RETIRED_HOST)) { + + prefs(context).edit().putString(KEY_URL, DEFAULT_URL).apply(); + + return DEFAULT_URL; + } + + return stored; } public static String apiKey(Context context) { @@ -267,7 +288,22 @@ public final class ServerUploader { return result; } - return post(context, csv, result); + Result first = post(context, csv, result); + + // A 401 means the key this device holds is no longer good: revoked, or + // rotated by a registration from somewhere else. Enrolling again is the + // documented recovery, so do it once and retry rather than making the + // user discover the Server dialog. + if (!first.ok && first.httpStatus == 401) { + + DeviceRegistration.forget(context); + + if (DeviceRegistration.key(context) != null) { + return post(context, csv, new Result()); + } + } + + return first; } private static Result post(Context context, String csv, Result result) { @@ -287,11 +323,18 @@ public final class ServerUploader { connection.setConnectTimeout(CONNECT_TIMEOUT_MS); connection.setReadTimeout(READ_TIMEOUT_MS); - connection.setRequestProperty("X-API-Key", apiKey(context)); + // This device's own key, obtained by trading the bootstrap token + // on first run. The shared key is no longer accepted for uploads, + // so a null here means enrolment never completed and the request + // would be refused anyway. + String key = DeviceRegistration.key(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-API-Key", key != null ? key : apiKey(context)); + + // Redundant alongside a device key - the server takes the identity + // from the key it verified - but sent so a mismatch is caught + // loudly rather than silently attributing to the wrong phone. connection.setRequestProperty( "X-Device-ID", DeviceIdentity.get(context));