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