Prefs.kt
| 1 | package net.lexcom.opentracker.store |
| 2 | |
| 3 | import android.content.Context |
| 4 | import androidx.core.content.edit |
| 5 | import java.util.Base64 |
| 6 | |
| 7 | /** |
| 8 | * The one persistent record this app keeps: the device's [Credentials]. |
| 9 | * |
| 10 | * The server issues them exactly once, so losing this file costs the user a |
| 11 | * re-login. `TrackerService` can be restarted by the system with a null Intent, |
| 12 | * which is why the credentials have to come from here and not from an extra. |
| 13 | * |
| 14 | * Plain [android.content.SharedPreferences] in `MODE_PRIVATE`. The file lives in |
| 15 | * the app's private data directory, which only this uid can read. Backup and |
| 16 | * device-to-device transfer are shut off by `android:allowBackup="false"` in the |
| 17 | * manifest, for the reason stated there: restoring the token key onto a second |
| 18 | * device would silently clone a credential. That is also why `DataExtractionRules` |
| 19 | * is disabled in lint rather than answered with an XML file. |
| 20 | * |
| 21 | * Deliberately not `EncryptedSharedPreferences`: it is a new dependency |
| 22 | * (`androidx.security`), it is deprecated, and it protects against an attacker |
| 23 | * who has already read the private data directory, at which point the game is |
| 24 | * over anyway. |
| 25 | */ |
| 26 | class Prefs(context: Context) { |
| 27 | |
| 28 | private val sp = context.getSharedPreferences(FILE, Context.MODE_PRIVATE) |
| 29 | |
| 30 | /** |
| 31 | * Returns null unless every field reads back intact. |
| 32 | * |
| 33 | * A half-written or hand-edited record must not produce a [Credentials] with |
| 34 | * a 31-byte key. That would fail far away from here, as datagrams the server |
| 35 | * silently drops, instead of as a login prompt. |
| 36 | */ |
| 37 | fun load(): Credentials? { |
| 38 | if (!sp.contains(KEY_TOKEN_ID)) return null |
| 39 | val tokenKey = decodeStoredKey(sp.getString(KEY_TOKEN_KEY, null)) ?: return null |
| 40 | val kRev = decodeStoredKey(sp.getString(KEY_REVOKE_KEY, null)) ?: return null |
| 41 | val host = sp.getString(KEY_UDP_HOST, null) ?: return null |
| 42 | val port = sp.getInt(KEY_UDP_PORT, 0) |
| 43 | if (host.isEmpty() || port !in 1..65535) return null |
| 44 | return Credentials( |
| 45 | // A Long round-trips exactly here, unlike through JSON. |
| 46 | tokenId = sp.getLong(KEY_TOKEN_ID, 0), |
| 47 | tokenKey = tokenKey, |
| 48 | kRev = kRev, |
| 49 | udpHost = host, |
| 50 | udpPort = port, |
| 51 | tlsUrl = sp.getString(KEY_TLS_URL, null), |
| 52 | configVersion = sp.getInt(KEY_CONFIG_VERSION, 0), |
| 53 | ) |
| 54 | } |
| 55 | |
| 56 | /** `commit()`, not `apply()`: this runs once per login and must survive a kill. */ |
| 57 | fun save(c: Credentials) { |
| 58 | sp.edit(commit = true) { |
| 59 | putLong(KEY_TOKEN_ID, c.tokenId) |
| 60 | putString(KEY_TOKEN_KEY, encodeStoredKey(c.tokenKey)) |
| 61 | putString(KEY_REVOKE_KEY, encodeStoredKey(c.kRev)) |
| 62 | putString(KEY_UDP_HOST, c.udpHost) |
| 63 | putInt(KEY_UDP_PORT, c.udpPort) |
| 64 | putString(KEY_TLS_URL, c.tlsUrl) |
| 65 | putInt(KEY_CONFIG_VERSION, c.configVersion) |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Whether the user wants to be tracked, as opposed to whether the service |
| 71 | * happens to be running right now. |
| 72 | * |
| 73 | * Without this flag a receiver cannot tell "the user tapped Stop" from "the |
| 74 | * system killed us", and those two need opposite answers. BootReceiver and |
| 75 | * WatchdogReceiver read it to decide whether restarting is wanted at all. |
| 76 | * |
| 77 | * Default false, so a fresh install never starts tracking on its own. |
| 78 | */ |
| 79 | fun isTrackingEnabled(): Boolean = sp.getBoolean(KEY_TRACKING_ENABLED, false) |
| 80 | |
| 81 | /** `commit()` for the same reason as [save]: the next reader may be a |
| 82 | * receiver in a process the system is about to kill. */ |
| 83 | fun setTrackingEnabled(on: Boolean) { |
| 84 | sp.edit(commit = true) { putBoolean(KEY_TRACKING_ENABLED, on) } |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Removes the keys, not just a flag. |
| 89 | * |
| 90 | * A `REVOKED` notice calls this. Leaving the token key on disk after it would |
| 91 | * keep a dead credential around for anyone who later gets the file. |
| 92 | * |
| 93 | * This wipes [KEY_TRACKING_ENABLED] too, because it clears the whole file. |
| 94 | * That is wanted: without credentials there is nothing to restart. |
| 95 | */ |
| 96 | fun clear() { |
| 97 | sp.edit(commit = true) { clear() } |
| 98 | } |
| 99 | |
| 100 | private companion object { |
| 101 | const val FILE = "credentials" |
| 102 | const val KEY_TOKEN_ID = "token_id" |
| 103 | const val KEY_TOKEN_KEY = "token_key" |
| 104 | const val KEY_REVOKE_KEY = "revoke_key" |
| 105 | const val KEY_UDP_HOST = "udp_host" |
| 106 | const val KEY_UDP_PORT = "udp_port" |
| 107 | const val KEY_TLS_URL = "tls_url" |
| 108 | const val KEY_CONFIG_VERSION = "config_version" |
| 109 | const val KEY_TRACKING_ENABLED = "tracking_enabled" |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | internal fun encodeStoredKey(key: ByteArray): String = |
| 114 | Base64.getEncoder().encodeToString(key) |
| 115 | |
| 116 | /** Null for missing, unparseable, or wrong-length input. See [Prefs.load]. */ |
| 117 | internal fun decodeStoredKey(b64: String?): ByteArray? = |
| 118 | b64?.let { runCatching { decodeKey(it, "stored key") }.getOrNull() } |
| 119 |