Frame.kt
⎇
Raw
1package net.lexcom.opentracker.wire
2
3import java.nio.ByteBuffer
4import java.nio.ByteOrder
5
6/**
7 * The OTP/1 codec — the Kotlin half of the wire contract with `crates/otproto`.
8 *
9 * Deliberately pure: no Android types, no I/O, no clock, no RNG. Nonces are
10 * passed in. That is what lets `VectorsTest` check it against the Rust golden
11 * vectors on the JVM without an emulator.
12 *
13 * Layout is documented once, in `crates/otproto/src/{frame,msg,point}.rs`. This
14 * file must stay byte-identical to it, and `vectors.json` is what proves it does.
15 *
16 * There is no clock anywhere in this protocol except [Point.ts]. Nothing carries
17 * the server's time, nothing measures clock skew, nothing corrects a timestamp.
18 */
19
20const val VERSION = 1
21const val HEADER_LEN = 21
22const val TAG_LEN = 16
23const val NONCE_LEN = 12
24const val POINT_LEN = 24
25const val MIN_DATAGRAM = HEADER_LEN + TAG_LEN
26const val MAX_DATAGRAM = 1200
27const val MAX_POINTS = 40
28
29private const val ACC_UNKNOWN = 0xFFFF
30private const val ALT_UNKNOWN = -0x8000
31private const val SPD_UNKNOWN = 0xFFFF
32private const val BRG_UNKNOWN = 0xFFFF
33private const val BAT_UNKNOWN = 0xFF
34
35private const val ACC_MAX = ACC_UNKNOWN - 1
36private const val SPD_MAX = SPD_UNKNOWN - 1
37private const val BRG_MAX = 35_999
38private const val ALT_MIN = ALT_UNKNOWN + 1
39
40/** Thrown for structurally impossible bytes. Never for merely odd values. */
41class WireFormatException(message: String) : Exception(message)
42
43enum class MsgType(val code: Int) {
44 LOC(0x1),
45 ACK(0x2),
46 NACK(0x3),
47 HELLO(0x4),
48 CONFIG(0x5),
49 CONFIG_GET(0x6),
50 PING(0x7),
51 PONG(0x8),
52
53 /** Sealed under `K_rev`, not `K_down`. See [Message.Revoked]. */
54 REVOKED(0x9),
55 ;
56
57 /** Uplink messages are sealed under `K_up`, downlink under `K_down`. */
58 val isUplink: Boolean get() = this == LOC || this == HELLO || this == CONFIG_GET || this == PING
59
60 companion object {
61 fun fromCode(code: Int): MsgType =
62 entries.firstOrNull { it.code == code }
63 ?: throw WireFormatException("unknown message type 0x${code.toString(16)}")
64 }
65}
66
67object PointFlags {
68 const val NONE = 0
69 const val CHARGING = 1 shl 0
70 const val NETWORK_FIX = 1 shl 1
71 const val LOW_ACCURACY = 1 shl 2
72 const val MOCK = 1 shl 3
73}
74
75object AckFlags {
76 const val NONE = 0
77 const val CONFIG_PENDING = 1 shl 0
78 const val THROTTLE = 1 shl 1
79}
80
81object HelloFlags {
82 const val NONE = 0
83 const val FIRST_LAUNCH = 1 shl 0
84}
85
86object ConfigFlags {
87 const val NONE = 0
88 const val TRACKING_ENABLED = 1 shl 0
89 const val REQUEST_HELLO = 1 shl 1
90}
91
92enum class Profile(val code: Int) {
93 BATTERY_SAVER(0),
94 BALANCED(1),
95 HIGH_ACCURACY(2),
96 ;
97
98 companion object {
99 fun fromCode(code: Int): Profile =
100 entries.firstOrNull { it.code == code }
101 ?: throw WireFormatException("unknown profile $code")
102 }
103}
104
105enum class RevokeReason(val code: Int) {
106 /** Explicitly revoked: "log out all other devices", or a password change. */
107 REVOKED(1),
108
109 /** Deleted by the server's staleness sweep after a long silence. */
110 EXPIRED(2),
111
112 /** The server has no record of this token: a restored backup, or a rotated
113 * server key. */
114 UNKNOWN(3),
115 ;
116
117 companion object {
118 fun fromCode(code: Int): RevokeReason =
119 entries.firstOrNull { it.code == code }
120 ?: throw WireFormatException("unknown revoke reason $code")
121 }
122}
123
124enum class NackReason(val code: Int) {
125 /** Token unknown, revoked or expired: clear local state, show login. */
126 UNKNOWN_TOKEN(1),
127 MALFORMED(2),
128 RATE_LIMITED(3),
129 STORAGE_FULL(4),
130 ;
131
132 companion object {
133 fun fromCode(code: Int): NackReason =
134 entries.firstOrNull { it.code == code }
135 ?: throw WireFormatException("unknown NACK reason $code")
136 }
137}
138
139/**
140 * One location report, 24 bytes on the wire.
141 *
142 * `null` means the device could not measure that field and is carried as the
143 * field's sentinel. [ts] is unsigned 32-bit, hence [Long].
144 */
145data class Point(
146 val ts: Long,
147 val latE7: Int,
148 val lonE7: Int,
149 val accDm: Int? = null,
150 val altM: Int? = null,
151 val spdCms: Int? = null,
152 val brgCdeg: Int? = null,
153 val batPct: Int? = null,
154 val flags: Int = PointFlags.NONE,
155) {
156 fun writeTo(buf: ByteBuffer) {
157 buf.putInt(ts.toInt())
158 buf.putInt(latE7)
159 buf.putInt(lonE7)
160 buf.putShort((accDm?.coerceAtMost(ACC_MAX) ?: ACC_UNKNOWN).toShort())
161 buf.putShort((altM?.coerceAtLeast(ALT_MIN) ?: ALT_UNKNOWN).toShort())
162 buf.putShort((spdCms?.coerceAtMost(SPD_MAX) ?: SPD_UNKNOWN).toShort())
163 buf.putShort((brgCdeg?.coerceAtMost(BRG_MAX) ?: BRG_UNKNOWN).toShort())
164 buf.put((batPct?.coerceAtMost(100) ?: BAT_UNKNOWN).toByte())
165 buf.put(flags.toByte())
166 buf.putShort(0) // reserved
167 }
168
169 fun toBytes(): ByteArray = ByteArray(POINT_LEN).also { writeTo(bufferOf(it)) }
170
171 companion object {
172 fun readFrom(buf: ByteBuffer): Point {
173 val ts = buf.int.toLong() and 0xFFFFFFFFL
174 val lat = buf.int
175 val lon = buf.int
176 val acc = buf.short.toInt() and 0xFFFF
177 val alt = buf.short.toInt() // signed
178 val spd = buf.short.toInt() and 0xFFFF
179 val brg = buf.short.toInt() and 0xFFFF
180 val bat = buf.get().toInt() and 0xFF
181 val flags = buf.get().toInt() and 0xFF
182 buf.short // reserved: ignored, not rejected, so a later version can use it
183 return Point(
184 ts = ts,
185 latE7 = lat,
186 lonE7 = lon,
187 accDm = if (acc == ACC_UNKNOWN) null else acc,
188 altM = if (alt == ALT_UNKNOWN) null else alt,
189 spdCms = if (spd == SPD_UNKNOWN) null else spd,
190 brgCdeg = if (brg == BRG_UNKNOWN) null else brg,
191 batPct = if (bat == BAT_UNKNOWN) null else bat,
192 flags = flags,
193 )
194 }
195
196 fun fromBytes(b: ByteArray): Point {
197 require(b.size == POINT_LEN) { "point record must be $POINT_LEN bytes, got ${b.size}" }
198 return readFrom(bufferOf(b))
199 }
200 }
201}
202
203sealed interface Message {
204 val type: MsgType
205 val payloadLen: Int
206
207 fun writePayload(buf: ByteBuffer)
208
209 fun encodePayload(): ByteArray = ByteArray(payloadLen).also { writePayload(bufferOf(it)) }
210
211 /** One or more fully independent points. */
212 data class Loc(val points: List<Point>) : Message {
213 init {
214 require(points.size in 1..MAX_POINTS) {
215 "LOC must carry 1..$MAX_POINTS points, got ${points.size}"
216 }
217 }
218
219 override val type get() = MsgType.LOC
220 override val payloadLen get() = 1 + points.size * POINT_LEN
221
222 override fun writePayload(buf: ByteBuffer) {
223 buf.put(points.size.toByte())
224 points.forEach { it.writeTo(buf) }
225 }
226 }
227
228 data class Ack(
229 val nonces: List<ByteArray>,
230 val flags: Int,
231 ) : Message {
232 init {
233 require(nonces.size in 1..MAX_POINTS) { "ACK must carry 1..$MAX_POINTS nonces" }
234 require(nonces.all { it.size == NONCE_LEN }) { "every nonce must be $NONCE_LEN bytes" }
235 }
236
237 override val type get() = MsgType.ACK
238 override val payloadLen get() = 1 + nonces.size * NONCE_LEN + 1
239
240 override fun writePayload(buf: ByteBuffer) {
241 buf.put(nonces.size.toByte())
242 nonces.forEach { buf.put(it) }
243 buf.put(flags.toByte())
244 }
245
246 // ByteArray identity would make data-class equality useless here.
247 override fun equals(other: Any?): Boolean =
248 other is Ack &&
249 flags == other.flags &&
250 nonces.size == other.nonces.size &&
251 nonces.indices.all { nonces[it].contentEquals(other.nonces[it]) }
252
253 override fun hashCode(): Int {
254 var h = flags
255 nonces.forEach { h = h * 31 + it.contentHashCode() }
256 return h
257 }
258 }
259
260 data class Nack(
261 val nonce: ByteArray,
262 val reason: NackReason,
263 val retryAfterS: Int,
264 ) : Message {
265 init {
266 require(nonce.size == NONCE_LEN) { "nonce must be $NONCE_LEN bytes" }
267 }
268
269 override val type get() = MsgType.NACK
270 override val payloadLen get() = NONCE_LEN + 2
271
272 override fun writePayload(buf: ByteBuffer) {
273 buf.put(nonce)
274 buf.put(reason.code.toByte())
275 buf.put(retryAfterS.toByte())
276 }
277
278 override fun equals(other: Any?): Boolean =
279 other is Nack &&
280 nonce.contentEquals(other.nonce) &&
281 reason == other.reason &&
282 retryAfterS == other.retryAfterS
283
284 override fun hashCode(): Int =
285 (nonce.contentHashCode() * 31 + reason.hashCode()) * 31 + retryAfterS
286 }
287
288 data class Hello(
289 val appVersionCode: Int,
290 val osApiLevel: Int,
291 val flags: Int,
292 val configVersion: Int,
293 ) : Message {
294 override val type get() = MsgType.HELLO
295 override val payloadLen get() = 6
296
297 override fun writePayload(buf: ByteBuffer) {
298 buf.putShort(appVersionCode.toShort())
299 buf.put(osApiLevel.toByte())
300 buf.put(flags.toByte())
301 buf.putShort(configVersion.toShort())
302 }
303 }
304
305 data class Config(
306 val configVersion: Int,
307 val profile: Profile,
308 val flags: Int,
309 val heartbeatS: Int,
310 val intervalScalePct: Int,
311 val minDistanceM: Int,
312 val maxPointsPerLoc: Int,
313 ) : Message {
314 override val type get() = MsgType.CONFIG
315 override val payloadLen get() = 12
316
317 override fun writePayload(buf: ByteBuffer) {
318 buf.putShort(configVersion.toShort())
319 buf.put(profile.code.toByte())
320 buf.put(flags.toByte())
321 buf.putShort(heartbeatS.toShort())
322 buf.putShort(intervalScalePct.toShort())
323 buf.putShort(minDistanceM.toShort())
324 buf.put(maxPointsPerLoc.toByte())
325 buf.put(0) // reserved
326 }
327 }
328
329 data class ConfigGet(val haveVersion: Int) : Message {
330 override val type get() = MsgType.CONFIG_GET
331 override val payloadLen get() = 2
332
333 override fun writePayload(buf: ByteBuffer) {
334 buf.putShort(haveVersion.toShort())
335 }
336 }
337
338 /**
339 * [echo] is opaque to the server and comes back verbatim in the [Pong], so
340 * this side can match a reply and measure a round trip. Put a monotonic
341 * reading in it — deliberately not a wall-clock time, which the server has no
342 * business interpreting.
343 */
344 data class Ping(val echo: Long, val seq: Int) : Message {
345 override val type get() = MsgType.PING
346 override val payloadLen get() = 6
347
348 override fun writePayload(buf: ByteBuffer) {
349 buf.putInt(echo.toInt())
350 buf.putShort(seq.toShort())
351 }
352 }
353
354 data class Pong(val echo: Long, val seq: Int) : Message {
355 override val type get() = MsgType.PONG
356 override val payloadLen get() = 6
357
358 override fun writePayload(buf: ByteBuffer) {
359 buf.putInt(echo.toInt())
360 buf.putShort(seq.toShort())
361 }
362 }
363
364 /**
365 * "This token is dead; log in again."
366 *
367 * The one message opened with `K_rev` rather than `K_down`. `K_down` derives
368 * from the token key, so it dies with the token's row on the server — and the
369 * moment the server most needs to speak is exactly when that row is gone.
370 * `K_rev` is issued at login and derives from a server master plus this
371 * device's `tokenId`, so no third party and no other device can produce one.
372 *
373 * Acting on it requires no clock and no counting. Two rules and nothing else:
374 * the datagram must open under this device's `K_rev`, and its header
375 * `tokenId` must match the token currently held. A captured notice for an old
376 * token therefore does nothing after the next login.
377 */
378 data class Revoked(val reason: RevokeReason) : Message {
379 override val type get() = MsgType.REVOKED
380 override val payloadLen get() = 1
381
382 override fun writePayload(buf: ByteBuffer) {
383 buf.put(reason.code.toByte())
384 }
385 }
386
387 companion object {
388 fun decodePayload(type: MsgType, payload: ByteArray): Message {
389 fun exact(expected: Int) {
390 if (payload.size != expected) {
391 throw WireFormatException(
392 "$type: expected $expected payload bytes, got ${payload.size}",
393 )
394 }
395 }
396
397 val buf = bufferOf(payload)
398 return when (type) {
399 MsgType.LOC -> {
400 if (payload.isEmpty()) throw WireFormatException("LOC: empty payload")
401 val count = buf.get().toInt() and 0xFF
402 if (count !in 1..MAX_POINTS) {
403 throw WireFormatException("LOC point count $count out of range 1..$MAX_POINTS")
404 }
405 exact(1 + count * POINT_LEN)
406 Loc(List(count) { Point.readFrom(buf) })
407 }
408
409 MsgType.ACK -> {
410 if (payload.isEmpty()) throw WireFormatException("ACK: empty payload")
411 val count = buf.get().toInt() and 0xFF
412 if (count !in 1..MAX_POINTS) {
413 throw WireFormatException("ACK nonce count $count out of range 1..$MAX_POINTS")
414 }
415 exact(1 + count * NONCE_LEN + 1)
416 val nonces = List(count) { ByteArray(NONCE_LEN).also(buf::get) }
417 Ack(nonces = nonces, flags = buf.get().toInt() and 0xFF)
418 }
419
420 MsgType.NACK -> {
421 exact(NONCE_LEN + 2)
422 Nack(
423 nonce = ByteArray(NONCE_LEN).also(buf::get),
424 reason = NackReason.fromCode(buf.get().toInt() and 0xFF),
425 retryAfterS = buf.get().toInt() and 0xFF,
426 )
427 }
428
429 MsgType.HELLO -> {
430 exact(6)
431 Hello(
432 appVersionCode = buf.short.toInt() and 0xFFFF,
433 osApiLevel = buf.get().toInt() and 0xFF,
434 flags = buf.get().toInt() and 0xFF,
435 configVersion = buf.short.toInt() and 0xFFFF,
436 )
437 }
438
439 MsgType.CONFIG -> {
440 exact(12)
441 val configVersion = buf.short.toInt() and 0xFFFF
442 val profile = Profile.fromCode(buf.get().toInt() and 0xFF)
443 val flags = buf.get().toInt() and 0xFF
444 val heartbeatS = buf.short.toInt() and 0xFFFF
445 val intervalScalePct = buf.short.toInt() and 0xFFFF
446 val minDistanceM = buf.short.toInt() and 0xFFFF
447 val maxPointsPerLoc = buf.get().toInt() and 0xFF
448 buf.get() // reserved
449 Config(
450 configVersion = configVersion,
451 profile = profile,
452 flags = flags,
453 heartbeatS = heartbeatS,
454 intervalScalePct = intervalScalePct,
455 minDistanceM = minDistanceM,
456 maxPointsPerLoc = maxPointsPerLoc,
457 )
458 }
459
460 MsgType.CONFIG_GET -> {
461 exact(2)
462 ConfigGet(haveVersion = buf.short.toInt() and 0xFFFF)
463 }
464
465 MsgType.PING -> {
466 exact(6)
467 Ping(
468 echo = buf.int.toLong() and 0xFFFFFFFFL,
469 seq = buf.short.toInt() and 0xFFFF,
470 )
471 }
472
473 MsgType.PONG -> {
474 exact(6)
475 Pong(
476 echo = buf.int.toLong() and 0xFFFFFFFFL,
477 seq = buf.short.toInt() and 0xFFFF,
478 )
479 }
480
481 MsgType.REVOKED -> {
482 exact(1)
483 Revoked(RevokeReason.fromCode(buf.get().toInt() and 0xFF))
484 }
485 }
486 }
487 }
488}
489
490/**
491 * The 21 cleartext header bytes, which are also the AEAD's additional data.
492 *
493 * Cleartext because the server must read [tokenId] to pick a key before it can
494 * decrypt; authenticated as AAD so a ciphertext cannot be retargeted to another
495 * token or another message type.
496 */
497data class Header(
498 val type: MsgType,
499 val tokenId: Long,
500 val nonce: ByteArray,
501) {
502 init {
503 require(nonce.size == NONCE_LEN) { "nonce must be $NONCE_LEN bytes" }
504 }
505
506 fun toBytes(): ByteArray {
507 val b = ByteArray(HEADER_LEN)
508 val buf = bufferOf(b)
509 buf.put(((VERSION shl 4) or type.code).toByte())
510 buf.putLong(tokenId)
511 buf.put(nonce)
512 return b
513 }
514
515 override fun equals(other: Any?): Boolean =
516 other is Header && type == other.type && tokenId == other.tokenId &&
517 nonce.contentEquals(other.nonce)
518
519 override fun hashCode(): Int =
520 (type.hashCode() * 31 + tokenId.hashCode()) * 31 + nonce.contentHashCode()
521
522 companion object {
523 /**
524 * Parse a datagram's header without decrypting it. Length, version and
525 * type filtering all happen here, before any crypto is spent.
526 */
527 fun peek(datagram: ByteArray): Header {
528 if (datagram.size < MIN_DATAGRAM) {
529 throw WireFormatException(
530 "datagram too short: ${datagram.size} bytes, minimum is $MIN_DATAGRAM",
531 )
532 }
533 if (datagram.size > MAX_DATAGRAM) {
534 throw WireFormatException(
535 "datagram too long: ${datagram.size} bytes, maximum is $MAX_DATAGRAM",
536 )
537 }
538 val verType = datagram[0].toInt() and 0xFF
539 val version = verType shr 4
540 if (version != VERSION) {
541 throw WireFormatException("unsupported protocol version $version")
542 }
543 val buf = bufferOf(datagram)
544 buf.get()
545 return Header(
546 type = MsgType.fromCode(verType and 0x0F),
547 tokenId = buf.long,
548 nonce = ByteArray(NONCE_LEN).also(buf::get),
549 )
550 }
551 }
552}
553
554/** Total datagram size for a payload of [payloadLen] bytes. */
555fun datagramLen(payloadLen: Int): Int = HEADER_LEN + payloadLen + TAG_LEN
556
557/**
558 * Ceiling on any datagram the server sends in reply to one it received.
559 *
560 * This is the anti-amplification control. It replaces an earlier rule that
561 * `response <= request` for every message type, which was achievable only by
562 * padding requests with reserved bytes, and which guarded against a threat
563 * authentication already removes: a reply is only ever sent to a datagram that
564 * passed AEAD verification, so a reflection attacker must already hold a live
565 * token key, and the leverage on offer is a ratio near 1.
566 */
567const val MAX_REPLY = 64
568
569fun fitsReplyBudget(responseLen: Int): Boolean = responseLen <= MAX_REPLY
570
571private fun bufferOf(b: ByteArray): ByteBuffer = ByteBuffer.wrap(b).order(ByteOrder.BIG_ENDIAN)
572