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