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