step 10: login, credential storage, UDP transport and uplink
Aandroid/app/src/main/java/net/lexcom/opentracker/net/NetWatcher.kt
@@ -0,0 +1,41 @@
package net.lexcom.opentracker.net
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
/**
* Says when connectivity comes back, so the uplink retries at once.
*
* Without this, a queue that filled during a tunnel waits out [Uplink]'s
* backoff before it tries again, which can be a minute of stale positions after
* the signal is already fine.
*
* Framework binding only. No logic, so no test.
*/
class NetWatcher(context: Context) {
private val manager = context.getSystemService(ConnectivityManager::class.java)
private var callback: ConnectivityManager.NetworkCallback? = null
/** [onAvailable] runs on a framework thread, not the caller's. */
fun start(onAvailable: () -> Unit) {
stop()
val cb = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) = onAvailable()
}
manager?.registerDefaultNetworkCallback(cb)
callback = cb
}
/**
* Must be called from the service's `onDestroy`.
*
* A callback left registered outlives the service, keeps a reference to it,
* and goes on waking the process on every network change.
*/
fun stop() {
callback?.let { manager?.unregisterNetworkCallback(it) }
callback = null
}
}
Aandroid/app/src/main/java/net/lexcom/opentracker/net/UdpTransport.kt
@@ -0,0 +1,64 @@
package net.lexcom.opentracker.net
import net.lexcom.opentracker.wire.MAX_DATAGRAM
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
import java.net.SocketTimeoutException
/**
* The only file in the uplink that touches a socket.
*
* [Transport] exists for exactly one reason: it lets [Uplink] run against a fake
* on the JVM, with no device and no network. Nothing else belongs in it. There
* is no TLS fallback here; that is step 15 and it implements this same interface.
*/
interface Transport {
/** Throws [java.io.IOException] when the network is down. The caller decides. */
fun send(datagram: ByteArray)
/** The next datagram, or null if [timeoutMs] passed. A timeout is normal. */
fun receive(timeoutMs: Int): ByteArray?
fun close()
}
/** One connectionless UDP socket pointed at the server. Not thread-safe. */
class UdpTransport(private val host: String, private val port: Int) : Transport {
private val socket = DatagramSocket()
/**
* Resolved per send, never in the constructor.
*
* A phone loses and regains DNS constantly. Resolving once at construction
* would make one failed lookup permanent for the life of the service, and
* the platform resolver already caches, so the repeated call is cheap.
*
* ponytail: a cache miss blocks this thread for as long as the resolver
* takes, which on a dying cell can be seconds. Acceptable because the caller
* is already a background thread that blocks on `receive` anyway. Cache the
* InetAddress and invalidate it on send failure if that ever shows up.
*/
override fun send(datagram: ByteArray) {
socket.send(DatagramPacket(datagram, datagram.size, InetAddress.getByName(host), port))
}
override fun receive(timeoutMs: Int): ByteArray? {
socket.soTimeout = timeoutMs
// Exactly MAX_DATAGRAM bytes. A longer datagram is truncated by the OS,
// which then fails AEAD and is dropped. That is the correct outcome: no
// datagram this protocol defines is longer, so anything that is, is not
// ours.
val buf = ByteArray(MAX_DATAGRAM)
val packet = DatagramPacket(buf, buf.size)
return try {
socket.receive(packet)
buf.copyOf(packet.length)
} catch (_: SocketTimeoutException) {
null
}
}
override fun close() = socket.close()
}
Aandroid/app/src/main/java/net/lexcom/opentracker/net/Uplink.kt
@@ -0,0 +1,306 @@
package net.lexcom.opentracker.net
import net.lexcom.opentracker.crypto.Sealer
import net.lexcom.opentracker.queue.PointQueue
import net.lexcom.opentracker.store.Credentials
import net.lexcom.opentracker.wire.AckFlags
import net.lexcom.opentracker.wire.HelloFlags
import net.lexcom.opentracker.wire.Header
import net.lexcom.opentracker.wire.MAX_DATAGRAM
import net.lexcom.opentracker.wire.MAX_POINTS
import net.lexcom.opentracker.wire.Message
import net.lexcom.opentracker.wire.MsgType
import net.lexcom.opentracker.wire.NackReason
import net.lexcom.opentracker.wire.POINT_LEN
import net.lexcom.opentracker.wire.RevokeReason
import net.lexcom.opentracker.wire.WireFormatException
import net.lexcom.opentracker.wire.datagramLen
import java.io.IOException
import java.security.GeneralSecurityException
/**
* Drains [PointQueue] into sealed `LOC` datagrams and acts on the replies.
*
* Pure logic over an injected [Transport], [PointQueue] and clock. No Android
* types, no socket, no `Thread.sleep`, no coroutine. That is what lets
* `UplinkTest` drive a whole conversation on the JVM against a fake server.
*
* This class decides *what* to send and *whether* it may send now. It never
* decides *when*: the service's loop calls [sendRound] and this returns
* [Round.BackOff] if it is too early. Nothing here blocks except the one reply
* wait, which is bounded by [REPLY_TIMEOUT_MS].
*
* Nothing thrown from a received datagram escapes. The socket is an open port
* and garbage arrives on it; a `WireFormatException` from `MsgType.fromCode` or
* from `decodePayload` that reached the service would kill tracking outright.
*
* Not thread-safe, and it must run on the thread that owns the queue. The
* peek/ack pairing depends on it: [sendRound] calls `peek` once, and the
* matching `ack` is the next queue call it makes, so no other `peek` can slip
* between them and void the ack.
*/
class Uplink(
private val transport: Transport,
private val queue: PointQueue,
private val credentials: Credentials,
private val nowMs: () -> Long,
) {
private val kUp = Sealer.deriveUp(credentials.tokenKey)
private val kDown = Sealer.deriveDown(credentials.tokenKey)
/** Earliest time a round may touch the socket again. See [backOff]. */
private var notBeforeMs = 0L
/** Current silence penalty, doubled per failure and reset by any reply. */
private var silenceBackoffMs = MIN_BACKOFF_MS
/**
* The largest batch that fits both ceilings.
*
* [MAX_POINTS] binds today; the datagram budget would allow 48. Computed
* rather than written down so a change to either constant stays correct.
*/
private val batchSize: Int = run {
var n = MAX_POINTS
while (n > 1 && datagramLen(1 + n * POINT_LEN) > MAX_DATAGRAM) n--
n
}
/**
* Send one batch of queued points and handle the reply.
*
* Points are removed from the queue only when the server confirms them.
* Every other outcome leaves them queued for the next round.
*/
fun sendRound(): Round {
val early = tooEarly()
if (early != null) return early
val points = queue.peek(batchSize)
if (points.isEmpty()) return Round.Idle
val sent = send(Message.Loc(points)) ?: return backOff("send failed")
return awaitReply(sent, ackCount = points.size)
}
/**
* Announce this device: after a login and after the network changes.
*
* The server records the app and OS version, and answers with an `ACK` whose
* `CONFIG_PENDING` flag says whether [Credentials.configVersion] is stale.
* That flag is the whole reason to carry the version here.
*/
fun hello(appVersionCode: Int, osApiLevel: Int, firstLaunch: Boolean = false): Round {
val early = tooEarly()
if (early != null) return early
val msg = Message.Hello(
appVersionCode = appVersionCode,
osApiLevel = osApiLevel,
flags = if (firstLaunch) HelloFlags.FIRST_LAUNCH else HelloFlags.NONE,
configVersion = credentials.configVersion,
)
val sent = send(msg) ?: return backOff("send failed")
return awaitReply(sent, ackCount = 0)
}
/** Tells the next round it may go immediately. Call this when the network returns. */
fun clearBackOff() {
notBeforeMs = 0L
silenceBackoffMs = MIN_BACKOFF_MS
}
// -- sending -------------------------------------------------------------
/** Seals [msg] under `K_up` and sends it. Returns the nonce used, or null. */
private fun send(msg: Message): ByteArray? {
val nonce = Sealer.newNonce()
val datagram = Sealer.sealMessage(kUp, credentials.tokenId, nonce, msg)
return try {
transport.send(datagram)
nonce
} catch (_: IOException) {
// No route, no DNS, airplane mode. Indistinguishable from silence
// and handled the same way: keep the points, wait, try again.
null
}
}
// -- receiving -----------------------------------------------------------
/**
* Read replies until one settles this round, or until the socket goes quiet.
*
* More than one datagram can be waiting: a late reply to an earlier round,
* or junk aimed at the port. Each is judged on its own and the undecidable
* ones are dropped, so a single stale `ACK` cannot mask the real one.
* [MAX_REPLIES_PER_ROUND] bounds the work a flood can cause.
*/
private fun awaitReply(sentNonce: ByteArray, ackCount: Int): Round {
repeat(MAX_REPLIES_PER_ROUND) {
val datagram = try {
transport.receive(REPLY_TIMEOUT_MS)
} catch (_: IOException) {
null
} ?: return backOff("no reply")
val round = interpret(datagram, sentNonce, ackCount)
if (round != null) {
// Any answer at all proves the path works, so the silence
// penalty starts over from the floor.
silenceBackoffMs = MIN_BACKOFF_MS
return round
}
}
return backOff("no usable reply")
}
/** One received datagram. Null means "not for this round; keep reading". */
private fun interpret(datagram: ByteArray, sentNonce: ByteArray, ackCount: Int): Round? = try {
val header = Header.peek(datagram)
when {
// An uplink type arriving here is our own traffic replayed back, or
// someone else's. It can never be a legitimate reply.
header.type.isUplink -> null
// Both rules from the KDoc on Message.Revoked, and both are needed.
// A notice captured before the last login still opens under the old
// K_rev, so the token id is what makes it harmless afterwards.
header.type == MsgType.REVOKED ->
if (header.tokenId != credentials.tokenId) {
null
} else {
val (_, msg) = Sealer.openMessage(credentials.kRev, datagram)
(msg as? Message.Revoked)?.let { Round.TokenDead(it.reason) }
}
header.tokenId != credentials.tokenId -> null
else -> downlink(datagram, sentNonce, ackCount)
}
} catch (_: WireFormatException) {
// Structurally impossible bytes: a bad message code, a payload of the
// wrong length. Dropped, never answered, never rethrown.
null
} catch (_: GeneralSecurityException) {
// Failed the tag. Forged, corrupted, or sealed under a key we retired.
null
}
private fun downlink(datagram: ByteArray, sentNonce: ByteArray, ackCount: Int): Round? {
val (_, msg) = Sealer.openMessage(kDown, datagram)
return when (msg) {
is Message.Ack -> {
// The nonce match is the whole point. An ACK confirms one LOC
// datagram, named by that datagram's nonce, and it is the only
// evidence the points reached storage. Acking the queue on an
// ACK for some earlier round would delete points this round sent
// but the server never stored.
if (msg.nonces.none { it.contentEquals(sentNonce) }) {
null
} else {
if (ackCount > 0) queue.ack(ackCount)
Round.Acked(ackCount, msg.flags and AckFlags.CONFIG_PENDING != 0)
}
}
is Message.Nack -> if (!msg.nonce.contentEquals(sentNonce)) null else nack(msg, ackCount)
// CONFIG and PONG are valid downlink messages this step does not use.
// Dropping them costs nothing; steps 11 and 12 add the handling.
else -> null
}
}
private fun nack(msg: Message.Nack, ackCount: Int): Round = when (msg.reason) {
// The server has no live row for this token, or it is revoked. Retrying
// can only ever produce the same answer, so stop and say so.
NackReason.UNKNOWN_TOKEN -> Round.TokenDead(null)
// The server refused the payload and will refuse it again. Retrying
// wedges the queue head forever, so these points are dropped. The usual
// cause is a fix whose timestamp falls outside the server's window,
// which no amount of resending will fix.
NackReason.MALFORMED -> {
if (ackCount > 0) queue.ack(ackCount)
Round.Dropped(ackCount)
}
// Backpressure. Honouring retryAfterS is not optional: a fleet that
// ignores it is how a saturated server stays saturated. Zero is not
// taken literally, or the client would answer throttling with a flood.
NackReason.RATE_LIMITED, NackReason.STORAGE_FULL -> {
val waitMs = maxOf(msg.retryAfterS * 1000L, MIN_BACKOFF_MS)
notBeforeMs = nowMs() + waitMs
Round.Throttled(msg.reason, notBeforeMs)
}
}
// -- pacing --------------------------------------------------------------
private fun tooEarly(): Round.BackOff? =
if (nowMs() < notBeforeMs) Round.BackOff(notBeforeMs) else null
/**
* Nothing came back. Keep the points and wait longer than last time.
*
* 5 s, doubling to a 60 s ceiling. The floor stops a caller that polls
* hard from turning one outage into a send per tick; the ceiling keeps a
* long tunnel from pushing the next attempt an hour out. `NetWatcher`
* cancels the wait outright when connectivity returns, which is why these
* numbers can stay this coarse.
*/
private fun backOff(reason: String): Round.NoReply {
notBeforeMs = nowMs() + silenceBackoffMs
silenceBackoffMs = (silenceBackoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
return Round.NoReply(reason, notBeforeMs)
}
companion object {
/** One RTT plus slack. A phone on a bad cell needs the slack. */
const val REPLY_TIMEOUT_MS = 2_000
const val MIN_BACKOFF_MS = 5_000L
const val MAX_BACKOFF_MS = 60_000L
/** A flood cannot make one round read forever. */
const val MAX_REPLIES_PER_ROUND = 8
}
}
/**
* What one round did. Returned rather than signalled, so the caller decides.
*
* [Round.TokenDead] in particular is a value and not an exception or a callback:
* step 9 owns clearing `Prefs` and telling the user to log in again, and this
* class has no business reaching into either.
*/
sealed interface Round {
/** The queue was empty. Nothing was sent. */
data object Idle : Round
/** The server stored [count] points and they are gone from the queue. */
data class Acked(val count: Int, val configPending: Boolean) : Round
/** [count] points the server will never accept were discarded. */
data class Dropped(val count: Int) : Round
/** Sent, nothing usable came back. The points are still queued. */
data class NoReply(val reason: String, val retryAtMs: Long) : Round
/** The server asked for a pause. Nothing will be sent before [retryAtMs]. */
data class Throttled(val reason: NackReason, val retryAtMs: Long) : Round
/** Called too early. Nothing touched the socket. */
data class BackOff(val retryAtMs: Long) : Round
/**
* This token is finished. Log in again.
*
* [reason] is null when a `NACK` said so rather than a `REVOKED` notice,
* because that message carries no reason code.
*/
data class TokenDead(val reason: RevokeReason?) : Round
}
Aandroid/app/src/main/java/net/lexcom/opentracker/store/Credentials.kt
@@ -0,0 +1,50 @@
package net.lexcom.opentracker.store
/**
* Everything the device needs to speak OTP/1, issued once by a device login.
*
* The server returns this exactly once and never again. Losing it means logging
* in again, so [Prefs] is what stands between the user and a re-login.
*
* [kRev] is separate from [tokenKey] on purpose. `K_up` and `K_down` derive from
* the token key and die with the token's row on the server. `K_rev` derives from
* a server master and the [tokenId], so the server can still send a message this
* device can verify after that row is gone. See `Message.Revoked`.
*/
data class Credentials(
val tokenId: Long,
/** 32 bytes. `K_up` and `K_down` derive from it. */
val tokenKey: ByteArray,
/** 32 bytes. Opens a `REVOKED` notice and nothing else. */
val kRev: ByteArray,
val udpHost: String,
val udpPort: Int,
val tlsUrl: String?,
val configVersion: Int,
) {
// ByteArray identity would make the generated equality useless, and these
// are compared in tests.
override fun equals(other: Any?): Boolean =
other is Credentials &&
tokenId == other.tokenId &&
tokenKey.contentEquals(other.tokenKey) &&
kRev.contentEquals(other.kRev) &&
udpHost == other.udpHost &&
udpPort == other.udpPort &&
tlsUrl == other.tlsUrl &&
configVersion == other.configVersion
override fun hashCode(): Int {
var h = tokenId.hashCode()
h = h * 31 + tokenKey.contentHashCode()
h = h * 31 + kRev.contentHashCode()
h = h * 31 + udpHost.hashCode()
h = h * 31 + udpPort
h = h * 31 + (tlsUrl?.hashCode() ?: 0)
return h * 31 + configVersion
}
/** Keys must never reach a log line or a crash report. */
override fun toString(): String =
"Credentials(tokenId=$tokenId, udp=$udpHost:$udpPort, configVersion=$configVersion)"
}
Aandroid/app/src/main/java/net/lexcom/opentracker/store/LoginClient.kt
@@ -0,0 +1,199 @@
package net.lexcom.opentracker.store
import net.lexcom.opentracker.crypto.Sealer
import org.json.JSONObject
import java.io.IOException
import java.io.InputStream
import java.net.HttpURLConnection
import java.net.URL
import java.util.Base64
/**
* The only HTTP request this app ever makes: `POST /api/login` with
* `purpose: "device"`, which mints an OTP/1 token and returns it exactly once.
*
* Everything after this runs over UDP, so there is no OkHttp and no Retrofit
* here. One request does not justify a client library, and
* [java.net.HttpURLConnection] resolves to `HttpsURLConnection` for an `https`
* URL, so TLS and certificate validation come from the platform.
*
* The I/O and the parsing are deliberately separate. [parseLoginResponse] is a
* pure function over the response body, so the part that can silently corrupt a
* key is testable on the JVM without a socket.
*/
/** Required on every state-changing `/api` request. See `require_csrf` in `api.rs`. */
private const val CSRF_HEADER = "X-OT-CSRF"
private const val USER_AGENT = "opentracker-android/0.1.0"
/** A tracker that hangs on a dead server stops tracking, so both are bounded. */
private const val CONNECT_TIMEOUT_MS = 15_000
private const val READ_TIMEOUT_MS = 20_000
/**
* The outcome of a login, modelled so the UI can say which one happened.
*
* "Wrong password" and "no network" need different words on screen, and a
* nullable return would collapse them into one.
*/
sealed interface LoginResult {
data class Ok(val credentials: Credentials) : LoginResult
/** HTTP 401. The username or the password is wrong. */
data object BadCredentials : LoginResult
/** HTTP 429. [retryAfterSeconds] is null when the server's hint was unreadable. */
data class RateLimited(val retryAfterSeconds: Long?) : LoginResult
/** Anything else: no network, TLS failure, 5xx, a malformed reply. */
data class Failed(val message: String) : LoginResult
}
/**
* Logs in and returns device credentials.
*
* Blocking. Call it off the main thread.
*
* [baseUrl] is the server root, for example `https://track.example.net`.
*/
fun login(
baseUrl: String,
username: String,
password: String,
deviceName: String,
): LoginResult {
val body = JSONObject()
.put("username", username)
// JSONObject escapes; a password with a quote in it must not break the body.
.put("password", password)
.put("purpose", "device")
.put("device_name", deviceName)
.put("platform", "android")
.toString()
val conn = try {
URL(baseUrl.trimEnd('/') + "/api/login").openConnection() as HttpURLConnection
} catch (e: Exception) {
return LoginResult.Failed("bad server address: ${e.message ?: e.javaClass.simpleName}")
}
return try {
conn.requestMethod = "POST"
conn.connectTimeout = CONNECT_TIMEOUT_MS
conn.readTimeout = READ_TIMEOUT_MS
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Accept", "application/json")
conn.setRequestProperty("User-Agent", USER_AGENT)
// Any value works. The defence is that a browser cannot set a custom
// header cross-origin without a preflight this server never grants.
conn.setRequestProperty(CSRF_HEADER, "1")
conn.outputStream.use { it.write(body.toByteArray()) }
val code = conn.responseCode
val text = (if (code in 200..299) conn.inputStream else conn.errorStream).readTextOrEmpty()
when {
code == 401 -> LoginResult.BadCredentials
code == 429 -> LoginResult.RateLimited(retryAfterSeconds(text))
code in 200..299 -> try {
LoginResult.Ok(parseLoginResponse(text))
} catch (e: Exception) {
LoginResult.Failed("server sent an unusable reply: ${e.message}")
}
// Keep the server's own message. Swallowing it turns every failure
// into "something went wrong", which nobody can act on.
else -> LoginResult.Failed("HTTP $code: ${errorMessage(text) ?: "no detail"}")
}
} catch (e: IOException) {
LoginResult.Failed("${e.javaClass.simpleName}: ${e.message ?: "network error"}")
} finally {
conn.disconnect()
}
}
private fun InputStream?.readTextOrEmpty(): String =
this?.use { it.bufferedReader().readText() } ?: ""
/** `ApiError` serialises as `{"error": "..."}`. */
private fun errorMessage(body: String): String? =
runCatching { JSONObject(body).getString("error") }.getOrNull()
/**
* `ApiError::TooManyRequests` renders as "too many attempts; try again in 42s".
*
* There is no `Retry-After` header, so the number comes out of that sentence.
*/
private val RETRY_HINT = Regex("""in\s+(\d+)s""")
private fun retryAfterSeconds(body: String): Long? =
errorMessage(body)?.let { RETRY_HINT.find(it)?.groupValues?.get(1)?.toLongOrNull() }
/**
* `"token_id": 12345678901234567890` — a `u64` written as a JSON number.
*
* Token ids are full 64-bit random values, so most of them are above 2^53 and
* many are above 2^63. Android's `org.json` parses a number that does not fit a
* `Long` as a `Double`, and `getLong` then clamps it, which silently returns the
* wrong token id. So the digits are read from the raw body and converted as
* unsigned, giving the exact bit pattern the wire header carries.
*
* `TokenInfo.token_id` in the same server is a *string*, for the browser's sake.
* `DeviceCredentials.token_id` is not, and it is the only `token_id` in this
* response, so a single match is unambiguous.
*/
private val TOKEN_ID = Regex(""""token_id"\s*:\s*(\d+)""")
internal fun parseTokenId(body: String): Long {
val digits = requireNotNull(TOKEN_ID.find(body)?.groupValues?.get(1)) {
"response carries no numeric token_id"
}
// Throws NumberFormatException (an IllegalArgumentException) above 2^64-1.
return java.lang.Long.parseUnsignedLong(digits)
}
/**
* Maps a `LoginResponse` body to [Credentials].
*
* Throws [IllegalArgumentException] or [org.json.JSONException] if the reply is
* not a usable device login. A short key is a corrupt login, not something to
* carry forward and fail on mysteriously at the first datagram.
*/
fun parseLoginResponse(body: String): Credentials {
val device = requireNotNull(JSONObject(body).optJSONObject("device")) {
"reply has no device credentials; was purpose=device sent?"
}
val port = device.getInt("udp_port")
require(port in 1..65535) { "udp_port $port is out of range" }
// Prefs.load rejects an empty host too. Catching it here turns a bad server
// config into a login error instead of a mystery re-login later.
val host = device.getString("udp_host")
require(host.isNotEmpty()) { "udp_host is empty" }
return Credentials(
tokenId = parseTokenId(body),
tokenKey = decodeKey(device.getString("token_key"), "token_key"),
kRev = decodeKey(device.getString("revoke_key"), "revoke_key"),
udpHost = host,
udpPort = port,
// Omitted entirely when the server has no TLS relay configured.
tlsUrl = if (device.isNull("tls_url")) null else device.getString("tls_url"),
configVersion = device.getJSONObject("config").getInt("config_version"),
)
}
/**
* `java.util.Base64` rather than `android.util.Base64`: it exists from API 26,
* below this app's minSdk 29, and unlike the Android class it also works in a
* JVM unit test, where `android.jar` stubs return defaults.
*/
internal fun decodeKey(b64: String, field: String): ByteArray {
val raw = try {
Base64.getDecoder().decode(b64)
} catch (e: IllegalArgumentException) {
throw IllegalArgumentException("$field is not valid base64", e)
}
require(raw.size == Sealer.KEY_LEN) {
"$field decoded to ${raw.size} bytes, expected ${Sealer.KEY_LEN}"
}
return raw
}
Aandroid/app/src/main/java/net/lexcom/opentracker/store/Prefs.kt
@@ -0,0 +1,96 @@
package net.lexcom.opentracker.store
import android.content.Context
import androidx.core.content.edit
import java.util.Base64
/**
* The one persistent record this app keeps: the device's [Credentials].
*
* The server issues them exactly once, so losing this file costs the user a
* re-login. `TrackerService` can be restarted by the system with a null Intent,
* which is why the credentials have to come from here and not from an extra.
*
* Plain [android.content.SharedPreferences] in `MODE_PRIVATE`. The file lives in
* the app's private data directory, which only this uid can read. Backup and
* device-to-device transfer are shut off by `android:allowBackup="false"` in the
* manifest, for the reason stated there: restoring the token key onto a second
* device would silently clone a credential. That is also why `DataExtractionRules`
* is disabled in lint rather than answered with an XML file.
*
* Deliberately not `EncryptedSharedPreferences`: it is a new dependency
* (`androidx.security`), it is deprecated, and it protects against an attacker
* who has already read the private data directory, at which point the game is
* over anyway.
*/
class Prefs(context: Context) {
private val sp = context.getSharedPreferences(FILE, Context.MODE_PRIVATE)
/**
* Returns null unless every field reads back intact.
*
* A half-written or hand-edited record must not produce a [Credentials] with
* a 31-byte key. That would fail far away from here, as datagrams the server
* silently drops, instead of as a login prompt.
*/
fun load(): Credentials? {
if (!sp.contains(KEY_TOKEN_ID)) return null
val tokenKey = decodeStoredKey(sp.getString(KEY_TOKEN_KEY, null)) ?: return null
val kRev = decodeStoredKey(sp.getString(KEY_REVOKE_KEY, null)) ?: return null
val host = sp.getString(KEY_UDP_HOST, null) ?: return null
val port = sp.getInt(KEY_UDP_PORT, 0)
if (host.isEmpty() || port !in 1..65535) return null
return Credentials(
// A Long round-trips exactly here, unlike through JSON.
tokenId = sp.getLong(KEY_TOKEN_ID, 0),
tokenKey = tokenKey,
kRev = kRev,
udpHost = host,
udpPort = port,
tlsUrl = sp.getString(KEY_TLS_URL, null),
configVersion = sp.getInt(KEY_CONFIG_VERSION, 0),
)
}
/** `commit()`, not `apply()`: this runs once per login and must survive a kill. */
fun save(c: Credentials) {
sp.edit(commit = true) {
putLong(KEY_TOKEN_ID, c.tokenId)
putString(KEY_TOKEN_KEY, encodeStoredKey(c.tokenKey))
putString(KEY_REVOKE_KEY, encodeStoredKey(c.kRev))
putString(KEY_UDP_HOST, c.udpHost)
putInt(KEY_UDP_PORT, c.udpPort)
putString(KEY_TLS_URL, c.tlsUrl)
putInt(KEY_CONFIG_VERSION, c.configVersion)
}
}
/**
* Removes the keys, not just a flag.
*
* A `REVOKED` notice calls this. Leaving the token key on disk after it would
* keep a dead credential around for anyone who later gets the file.
*/
fun clear() {
sp.edit(commit = true) { clear() }
}
private companion object {
const val FILE = "credentials"
const val KEY_TOKEN_ID = "token_id"
const val KEY_TOKEN_KEY = "token_key"
const val KEY_REVOKE_KEY = "revoke_key"
const val KEY_UDP_HOST = "udp_host"
const val KEY_UDP_PORT = "udp_port"
const val KEY_TLS_URL = "tls_url"
const val KEY_CONFIG_VERSION = "config_version"
}
}
internal fun encodeStoredKey(key: ByteArray): String =
Base64.getEncoder().encodeToString(key)
/** Null for missing, unparseable, or wrong-length input. See [Prefs.load]. */
internal fun decodeStoredKey(b64: String?): ByteArray? =
b64?.let { runCatching { decodeKey(it, "stored key") }.getOrNull() }
Aandroid/app/src/test/java/net/lexcom/opentracker/LoginClientTest.kt
@@ -0,0 +1,139 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.store.parseLoginResponse
import net.lexcom.opentracker.store.parseTokenId
import java.util.Base64
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Guards the login reply parser, which is the only place a wrong token id or a
* short key can enter this app.
*
* Both failures are invisible at the point they happen: the device would just
* send datagrams the server drops. So they are caught here, at parse time.
*
* No socket is involved. `parseLoginResponse` is pure, which is the whole reason
* it is separate from the HTTP call.
*/
class LoginClientTest {
private val tokenKey = ByteArray(32) { it.toByte() }
private val revokeKey = ByteArray(32) { (0xFF - it).toByte() }
private val tokenKeyB64 = Base64.getEncoder().encodeToString(tokenKey)
private val revokeKeyB64 = Base64.getEncoder().encodeToString(revokeKey)
private fun body(
tokenId: String = "81985529216486895",
tokenKey: String = tokenKeyB64,
revokeKey: String = revokeKeyB64,
tlsUrl: String? = "\"https://track.example.net\"",
udpPort: String = "5353",
) = """
{
"user": {"id": 1, "username": "ada", "display_name": "Ada",
"is_admin": false, "server_time": 1785000042},
"device": {
"token_id": $tokenId,
"token_key": "$tokenKey",
"revoke_key": "$revokeKey",
"udp_host": "track.example.net",
"udp_port": $udpPort,
${if (tlsUrl != null) "\"tls_url\": $tlsUrl," else ""}
"config": {"config_version": 3, "profile": "balanced"}
}
}
""".trimIndent()
@Test
fun `a device login maps to credentials`() {
val c = parseLoginResponse(body())
assertEquals(81_985_529_216_486_895L, c.tokenId)
assertContentEquals(tokenKey, c.tokenKey)
assertContentEquals(revokeKey, c.kRev)
assertEquals("track.example.net", c.udpHost)
assertEquals(5353, c.udpPort)
assertEquals("https://track.example.net", c.tlsUrl)
assertEquals(3, c.configVersion)
}
@Test
fun `a token id above 2 to the 63 keeps its exact bit pattern`() {
// 0xFEDCBA9876543210 as an unsigned decimal. It is above Long.MAX_VALUE,
// so it only survives as a negative Long with the same 64 bits. Android's
// org.json turns such a literal into a Double and getLong then clamps it,
// which is why the digits are read from the raw body instead.
val c = parseLoginResponse(body(tokenId = "18364758544493064720"))
assertEquals(-0x0123456789abcdf0L, c.tokenId)
assertEquals("fedcba9876543210", java.lang.Long.toHexString(c.tokenId))
}
@Test
fun `the largest token id round trips`() {
assertEquals(-1L, parseTokenId("""{"token_id": 18446744073709551615}"""))
}
@Test
fun `a token id above 2 to the 53 is not rounded`() {
// The first integer a Double cannot represent. A parser that goes through
// a Double answers 9007199254740992 here.
assertEquals(9_007_199_254_740_993L, parseTokenId("""{"token_id": 9007199254740993}"""))
}
@Test
fun `a token id wider than 64 bits is rejected`() {
assertFailsWith<NumberFormatException> {
parseTokenId("""{"token_id": 18446744073709551616}""")
}
}
@Test
fun `a short token key is a corrupt login`() {
val short = Base64.getEncoder().encodeToString(ByteArray(31))
val e = assertFailsWith<IllegalArgumentException> {
parseLoginResponse(body(tokenKey = short))
}
assertTrue(e.message!!.contains("31 bytes"))
}
@Test
fun `a short revoke key is a corrupt login`() {
val short = Base64.getEncoder().encodeToString(ByteArray(16))
assertFailsWith<IllegalArgumentException> {
parseLoginResponse(body(revokeKey = short))
}
}
@Test
fun `a key that is not base64 is rejected`() {
assertFailsWith<IllegalArgumentException> {
parseLoginResponse(body(tokenKey = "not base64!!"))
}
}
@Test
fun `an absent tls_url reads as null`() {
assertNull(parseLoginResponse(body(tlsUrl = null)).tlsUrl)
}
@Test
fun `an explicit null tls_url reads as null`() {
assertNull(parseLoginResponse(body(tlsUrl = "null")).tlsUrl)
}
@Test
fun `a browser login carries no device credentials`() {
val browser = """{"user": {"id": 1, "username": "ada", "display_name": "Ada",
"is_admin": false, "server_time": 1785000042}}"""
assertFailsWith<IllegalArgumentException> { parseLoginResponse(browser) }
}
@Test
fun `a nonsense udp port is rejected`() {
assertFailsWith<IllegalArgumentException> { parseLoginResponse(body(udpPort = "0")) }
}
}
Aandroid/app/src/test/java/net/lexcom/opentracker/PrefsTest.kt
@@ -0,0 +1,45 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.store.decodeStoredKey
import net.lexcom.opentracker.store.encodeStoredKey
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertNull
/**
* Covers only the encode/decode pair `Prefs` stores its two keys with.
*
* `SharedPreferences` itself cannot be exercised here: `isReturnDefaultValues`
* makes every framework method a no-op, so a test around `Prefs.load()` would
* assert nothing. What matters and is testable is the rule that a damaged stored
* key reads back as null, never as a wrong-length key.
*/
class PrefsTest {
@Test
fun `a stored key round trips`() {
val key = ByteArray(32) { (it * 7).toByte() }
assertContentEquals(key, decodeStoredKey(encodeStoredKey(key)))
}
@Test
fun `a missing key reads as null`() {
assertNull(decodeStoredKey(null))
}
@Test
fun `a truncated key reads as null rather than as a short key`() {
val full = encodeStoredKey(ByteArray(32))
assertNull(decodeStoredKey(full.substring(0, 20)))
}
@Test
fun `a key of the wrong length reads as null`() {
assertNull(decodeStoredKey(encodeStoredKey(ByteArray(16))))
}
@Test
fun `a non-base64 value reads as null`() {
assertNull(decodeStoredKey("not base64!!"))
}
}
Aandroid/app/src/test/java/net/lexcom/opentracker/UplinkTest.kt
@@ -0,0 +1,347 @@
package net.lexcom.opentracker
import net.lexcom.opentracker.crypto.Sealer
import net.lexcom.opentracker.net.Round
import net.lexcom.opentracker.net.Transport
import net.lexcom.opentracker.net.Uplink
import net.lexcom.opentracker.queue.PointQueue
import net.lexcom.opentracker.store.Credentials
import net.lexcom.opentracker.wire.AckFlags
import net.lexcom.opentracker.wire.Header
import net.lexcom.opentracker.wire.Message
import net.lexcom.opentracker.wire.MsgType
import net.lexcom.opentracker.wire.NackReason
import net.lexcom.opentracker.wire.Point
import net.lexcom.opentracker.wire.RevokeReason
import java.io.File
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
/**
* The uplink against a fake server that speaks the real protocol.
*
* The fake opens every datagram with the real [Sealer] and answers the real
* nonce, so a passing test proves the bytes a server would actually receive.
*
* What is defended here: points leave the queue only once the server confirms
* them, a revocation is acted on under exactly the two rules `Message.Revoked`
* states, backpressure is obeyed, and nothing arriving on an open UDP port can
* throw out of the loop.
*/
class UplinkTest {
private val file = File.createTempFile("uplink", ".bin").also { it.delete() }
private val tokenId = 0x0123456789abcdefL
private val otherTokenId = 0x00000000deadbeefL
private val tokenKey = ByteArray(32) { it.toByte() }
private val kRev = ByteArray(32) { (0xA0 + it).toByte() }
private val kUp = Sealer.deriveUp(tokenKey)
private val kDown = Sealer.deriveDown(tokenKey)
private val credentials = Credentials(
tokenId = tokenId,
tokenKey = tokenKey,
kRev = kRev,
udpHost = "127.0.0.1",
udpPort = 7373,
tlsUrl = null,
configVersion = 3,
)
private val queue = PointQueue(file, 64)
private var clockMs = 1_785_000_000_000L
@AfterTest
fun cleanUp() {
queue.close()
file.delete()
}
/**
* A server on a wire with no latency.
*
* [respond] is handed the request it just received, so a reply can name the
* nonce the uplink actually chose. That is the one thing a canned reply
* queue could not do, and nonce matching is most of what these tests check.
*/
private inner class FakeServer : Transport {
val sent = ArrayDeque<ByteArray>()
val replies = ArrayDeque<ByteArray>()
var respond: (Header, Message) -> List<ByteArray> = { _, _ -> emptyList() }
override fun send(datagram: ByteArray) {
sent.addLast(datagram)
val (header, msg) = Sealer.openMessage(kUp, datagram)
replies.addAll(respond(header, msg))
}
/** Null is a timeout, which is what an empty reply queue means. */
override fun receive(timeoutMs: Int): ByteArray? = replies.removeFirstOrNull()
override fun close() = Unit
/** The last request, as the server saw it. */
fun lastRequest(): Pair<Header, Message> = Sealer.openMessage(kUp, sent.last())
}
private val server = FakeServer()
private fun uplink() = Uplink(server, queue, credentials) { clockMs }
private fun point(i: Int) =
Point(ts = 1_785_000_000L + i, latE7 = 525_200_000 + i, lonE7 = 134_050_000 + i, batPct = 70)
private fun sealDown(msg: Message) = Sealer.sealMessage(kDown, tokenId, Sealer.newNonce(), msg)
private fun ack(nonce: ByteArray, flags: Int = AckFlags.NONE) =
sealDown(Message.Ack(listOf(nonce), flags))
private fun nack(nonce: ByteArray, reason: NackReason, retryAfterS: Int = 0) =
sealDown(Message.Nack(nonce, reason, retryAfterS))
/** Answers every request with a plain ACK for its own nonce. */
private fun acksEverything() {
server.respond = { header, _ -> listOf(ack(header.nonce)) }
}
@Test
fun `a queued point goes out as a LOC the server can open and decode`() {
queue.append(point(1))
acksEverything()
uplink().sendRound()
val (header, msg) = server.lastRequest()
assertEquals(MsgType.LOC, header.type)
assertEquals(tokenId, header.tokenId)
assertEquals(Message.Loc(listOf(point(1))), msg)
}
@Test
fun `an ACK carrying the sent nonce clears the queue`() {
queue.append(point(1))
queue.append(point(2))
acksEverything()
val result = assertIs<Round.Acked>(uplink().sendRound())
assertEquals(2, result.count)
assertEquals(0, queue.size)
}
@Test
fun `an ACK carrying a different nonce clears nothing`() {
queue.append(point(1))
// An ACK retires the LOC datagram it names. One for another datagram is
// no evidence at all that these points were stored.
server.respond = { _, _ -> listOf(ack(ByteArray(12) { 0x77 })) }
assertIs<Round.NoReply>(uplink().sendRound())
assertEquals(1, queue.size)
}
@Test
fun `a stale ACK does not hide the real one behind it`() {
queue.append(point(1))
server.respond = { header, _ -> listOf(ack(ByteArray(12) { 0x77 }), ack(header.nonce)) }
assertEquals(1, assertIs<Round.Acked>(uplink().sendRound()).count)
assertEquals(0, queue.size)
}
@Test
fun `no reply at all leaves the points queued for the next round`() {
queue.append(point(1))
queue.append(point(2))
assertIs<Round.NoReply>(uplink().sendRound())
assertEquals(2, queue.size)
assertEquals(1, server.sent.size)
// The retry sends the same two points again.
val u = uplink()
acksEverything()
assertEquals(2, assertIs<Round.Acked>(u.sendRound()).count)
assertEquals(0, queue.size)
}
@Test
fun `a silent round backs off before it touches the socket again`() {
queue.append(point(1))
val u = uplink()
assertIs<Round.NoReply>(u.sendRound())
// Same millisecond, so the wait has not expired and nothing is sent.
assertIs<Round.BackOff>(u.sendRound())
assertEquals(1, server.sent.size)
// Connectivity returning cancels the wait outright.
u.clearBackOff()
u.sendRound()
assertEquals(2, server.sent.size)
}
@Test
fun `a REVOKED under K_rev naming this token reports the token as dead`() {
queue.append(point(1))
server.respond = { _, _ ->
listOf(
Sealer.sealMessage(
kRev,
tokenId,
Sealer.newNonce(),
Message.Revoked(RevokeReason.REVOKED),
),
)
}
val result = assertIs<Round.TokenDead>(uplink().sendRound())
assertEquals(RevokeReason.REVOKED, result.reason)
// A dead token is not a delivery. The points stay.
assertEquals(1, queue.size)
}
@Test
fun `a REVOKED naming a different token is ignored`() {
queue.append(point(1))
// Correctly sealed for that other token, then captured off the wire. The
// header token id is the only thing that makes it inert here.
val otherKRev = Sealer.deriveRevocation(ByteArray(32) { 9 }, otherTokenId)
server.respond = { _, _ ->
listOf(
Sealer.sealMessage(
otherKRev,
otherTokenId,
Sealer.newNonce(),
Message.Revoked(RevokeReason.EXPIRED),
),
)
}
assertIs<Round.NoReply>(uplink().sendRound())
assertEquals(1, queue.size)
}
@Test
fun `a REVOKED sealed under the wrong key is ignored`() {
queue.append(point(1))
// Right token id, right message, wrong key. K_down must not be enough to
// declare this device finished.
server.respond = { _, _ -> listOf(sealDown(Message.Revoked(RevokeReason.REVOKED))) }
assertIs<Round.NoReply>(uplink().sendRound())
assertEquals(1, queue.size)
}
@Test
fun `a NACK with UNKNOWN_TOKEN reports the token as dead`() {
queue.append(point(1))
server.respond = { header, _ -> listOf(nack(header.nonce, NackReason.UNKNOWN_TOKEN)) }
// Null reason: a NACK carries no revoke reason code.
assertNull(assertIs<Round.TokenDead>(uplink().sendRound()).reason)
assertEquals(1, queue.size)
}
@Test
fun `a NACK with retryAfterS is honoured rather than ignored`() {
queue.append(point(1))
server.respond = { header, _ -> listOf(nack(header.nonce, NackReason.RATE_LIMITED, 30)) }
val u = uplink()
val throttled = assertIs<Round.Throttled>(u.sendRound())
assertEquals(NackReason.RATE_LIMITED, throttled.reason)
assertEquals(clockMs + 30_000L, throttled.retryAtMs)
assertEquals(1, queue.size)
val sentSoFar = server.sent.size
clockMs += 29_000
assertIs<Round.BackOff>(u.sendRound())
assertEquals(sentSoFar, server.sent.size)
clockMs += 2_000
u.sendRound()
assertEquals(sentSoFar + 1, server.sent.size)
}
@Test
fun `a throttle with no retry hint still waits`() {
queue.append(point(1))
server.respond = { header, _ -> listOf(nack(header.nonce, NackReason.STORAGE_FULL, 0)) }
val u = uplink()
val throttled = assertIs<Round.Throttled>(u.sendRound())
assertEquals(clockMs + Uplink.MIN_BACKOFF_MS, throttled.retryAtMs)
assertIs<Round.BackOff>(u.sendRound())
}
@Test
fun `a NACK with MALFORMED drops points the server will never accept`() {
queue.append(point(1))
// Retrying a rejected payload forever would wedge the queue head.
server.respond = { header, _ -> listOf(nack(header.nonce, NackReason.MALFORMED)) }
assertEquals(1, assertIs<Round.Dropped>(uplink().sendRound()).count)
assertEquals(0, queue.size)
}
@Test
fun `garbage a truncated datagram and an unknown type code are all dropped`() {
queue.append(point(1))
server.respond = { header, _ ->
val good = ack(header.nonce)
listOf(
ByteArray(0),
ByteArray(4) { 0xFF.toByte() }, // shorter than any header
ByteArray(80) { 0x5A }, // right length, wrong everything
good.copyOf(good.size / 2), // truncated mid-ciphertext
// Version 1, message type 0xF. MsgType.fromCode throws on that
// code, and the throw must not reach the service.
good.copyOf().also { it[0] = 0x1F },
)
}
assertIs<Round.NoReply>(uplink().sendRound())
assertEquals(1, queue.size)
}
@Test
fun `an uplink message arriving at the device is dropped`() {
queue.append(point(1))
// Our own LOC replayed back at us. It opens under no downlink key, and
// the type check rejects it before any of that.
server.respond = { _, _ -> listOf(server.sent.last()) }
assertIs<Round.NoReply>(uplink().sendRound())
assertEquals(1, queue.size)
}
@Test
fun `HELLO carries the config version and its ACK reports a pending config`() {
server.respond = { header, _ -> listOf(ack(header.nonce, AckFlags.CONFIG_PENDING)) }
val result = assertIs<Round.Acked>(uplink().hello(appVersionCode = 7, osApiLevel = 29))
val (header, msg) = server.lastRequest()
assertEquals(MsgType.HELLO, header.type)
val hello = assertIs<Message.Hello>(msg)
assertEquals(3, hello.configVersion)
assertEquals(7, hello.appVersionCode)
assertEquals(29, hello.osApiLevel)
// A HELLO retires no points, so the queue is never touched.
assertEquals(0, result.count)
assertEquals(true, result.configPending)
}
@Test
fun `an empty queue sends nothing`() {
assertEquals(Round.Idle, uplink().sendRound())
assertEquals(0, server.sent.size)
}
}