Sealer.kt
⎇
Raw
1package net.lexcom.opentracker.crypto
2
3import net.lexcom.opentracker.wire.HEADER_LEN
4import net.lexcom.opentracker.wire.Header
5import net.lexcom.opentracker.wire.MAX_DATAGRAM
6import net.lexcom.opentracker.wire.Message
7import net.lexcom.opentracker.wire.NONCE_LEN
8import net.lexcom.opentracker.wire.TAG_LEN
9import net.lexcom.opentracker.wire.WireFormatException
10import net.lexcom.opentracker.wire.datagramLen
11import java.security.GeneralSecurityException
12import java.security.SecureRandom
13import javax.crypto.Cipher
14import javax.crypto.Mac
15import javax.crypto.spec.IvParameterSpec
16import javax.crypto.spec.SecretKeySpec
17
18/**
19 * ChaCha20-Poly1305 sealing and the HKDF key schedule.
20 *
21 * The point of this file is that it has **no dependencies**. Android's platform
22 * Conscrypt exposes `ChaCha20/Poly1305/NoPadding`, and Conscrypt is a Mainline
23 * module on Android 10+, so there is no Tink, no libsodium, no BouncyCastle, no
24 * JNI and no NDK anywhere in this build — which also means no per-ABI `.so`
25 * files and roughly 1.5 MB less APK.
26 *
27 * Two provider quirks are worth knowing:
28 * - The transform is spelled `ChaCha20/Poly1305/NoPadding` by Conscrypt but
29 * `ChaCha20-Poly1305` by the JDK's SunJCE (JEP 329), so JVM unit tests and
30 * the phone need different names. [transform] resolves whichever is present.
31 * - The nonce goes in an [IvParameterSpec], **not** a `GCMParameterSpec`.
32 * Passing the latter throws, and the error message does not say why.
33 */
34object Sealer {
35
36 const val KEY_LEN = 32
37
38 private val TRANSFORMS = listOf("ChaCha20/Poly1305/NoPadding", "ChaCha20-Poly1305")
39
40 /** Resolved once per process. */
41 val transform: String by lazy {
42 TRANSFORMS.firstOrNull { name ->
43 runCatching { Cipher.getInstance(name) }.isSuccess
44 } ?: throw GeneralSecurityException(
45 "no ChaCha20-Poly1305 provider; tried ${TRANSFORMS.joinToString()}",
46 )
47 }
48
49 val providerName: String by lazy { Cipher.getInstance(transform).provider.name }
50
51 private val rng = SecureRandom()
52
53 /** 12 fresh random bytes: an OTP/1 nonce, which is also a message id. */
54 fun newNonce(): ByteArray = ByteArray(NONCE_LEN).also(rng::nextBytes)
55
56 // -- key schedule --------------------------------------------------------
57
58 /**
59 * HKDF-Expand with SHA-256. Expand only, no extract: the token key is
60 * already 32 uniformly random bytes from the server's CSPRNG, so there is no
61 * entropy to condition.
62 */
63 fun hkdfExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
64 require(length in 1..255 * 32) { "HKDF output length $length out of range" }
65 val mac = Mac.getInstance("HmacSHA256")
66 val out = ByteArray(length)
67 var previous = ByteArray(0)
68 var offset = 0
69 var counter = 1
70 while (offset < length) {
71 mac.init(SecretKeySpec(prk, "HmacSHA256"))
72 mac.update(previous)
73 mac.update(info)
74 mac.update(counter.toByte())
75 previous = mac.doFinal()
76 val take = minOf(previous.size, length - offset)
77 previous.copyInto(out, offset, 0, take)
78 offset += take
79 counter++
80 }
81 return out
82 }
83
84 /** `K_up = HKDF-Expand(token_key, "otp/1/up", 32)` — device to server. */
85 fun deriveUp(tokenKey: ByteArray): ByteArray =
86 hkdfExpand(tokenKey, "otp/1/up".toByteArray(), KEY_LEN)
87
88 /** `K_down = HKDF-Expand(token_key, "otp/1/down", 32)` — server to device. */
89 fun deriveDown(tokenKey: ByteArray): ByteArray =
90 hkdfExpand(tokenKey, "otp/1/down".toByteArray(), KEY_LEN)
91
92 /**
93 * `K_rev = HKDF-Expand(master, "otp/1/revoke" || token_id, 32)`.
94 *
95 * The app never derives this in production — the server issues it at login
96 * and it is stored alongside the token key. This exists so the golden vectors
97 * can prove the derivation matches, and so a test can construct a notice for
98 * a *different* token id and confirm it is rejected.
99 */
100 fun deriveRevocation(master: ByteArray, tokenId: Long): ByteArray {
101 val info = ByteArray(12 + 8)
102 "otp/1/revoke".toByteArray().copyInto(info)
103 for (i in 0 until 8) {
104 info[12 + i] = (tokenId ushr (56 - 8 * i)).toByte()
105 }
106 return hkdfExpand(master, info, KEY_LEN)
107 }
108
109 // -- seal / open ---------------------------------------------------------
110
111 /**
112 * `header || ChaCha20Poly1305(key, nonce, payload, aad = header)`.
113 *
114 * The nonce comes from [header], so callers must have obtained it from
115 * [newNonce]. Reusing one under the same key is catastrophic for
116 * ChaCha20-Poly1305, which is why nothing here silently invents one.
117 */
118 fun seal(key: ByteArray, header: Header, payload: ByteArray): ByteArray {
119 require(key.size == KEY_LEN) { "key must be $KEY_LEN bytes" }
120 val total = datagramLen(payload.size)
121 require(total <= MAX_DATAGRAM) { "datagram would be $total bytes, over the budget" }
122
123 val aad = header.toBytes()
124 val cipher = Cipher.getInstance(transform)
125 cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(key, "ChaCha20"), IvParameterSpec(header.nonce))
126 cipher.updateAAD(aad)
127 val sealed = cipher.doFinal(payload) // ciphertext || tag
128
129 val out = ByteArray(total)
130 aad.copyInto(out, 0)
131 sealed.copyInto(out, HEADER_LEN)
132 return out
133 }
134
135 fun sealMessage(key: ByteArray, tokenId: Long, nonce: ByteArray, msg: Message): ByteArray =
136 seal(key, Header(msg.type, tokenId, nonce), msg.encodePayload())
137
138 /**
139 * Verify and decrypt. Throws [GeneralSecurityException] on a bad tag.
140 *
141 * A device that cannot open a datagram simply drops it. Never answer one —
142 * a reply would turn the socket into a forgery oracle.
143 */
144 fun open(key: ByteArray, datagram: ByteArray): Pair<Header, ByteArray> {
145 require(key.size == KEY_LEN) { "key must be $KEY_LEN bytes" }
146 val header = Header.peek(datagram)
147 if (datagram.size < HEADER_LEN + TAG_LEN) {
148 throw WireFormatException("datagram too short to hold a tag")
149 }
150 val cipher = Cipher.getInstance(transform)
151 cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "ChaCha20"), IvParameterSpec(header.nonce))
152 cipher.updateAAD(datagram, 0, HEADER_LEN)
153 val payload = cipher.doFinal(datagram, HEADER_LEN, datagram.size - HEADER_LEN)
154 return header to payload
155 }
156
157 fun openMessage(key: ByteArray, datagram: ByteArray): Pair<Header, Message> {
158 val (header, payload) = open(key, datagram)
159 return header to Message.decodePayload(header.type, payload)
160 }
161
162 // -- self test -----------------------------------------------------------
163
164 data class SelfTestReport(
165 val ok: Boolean,
166 val transform: String?,
167 val provider: String?,
168 val detail: String,
169 ) {
170 val summary: String
171 get() = buildString {
172 append(if (ok) "OK" else "FAILED")
173 if (transform != null) append("\ntransform: $transform")
174 if (provider != null) append("\nprovider: $provider")
175 append("\n")
176 append(detail)
177 }
178 }
179
180 /**
181 * Seals a known vector and checks it byte-for-byte, then opens it again.
182 *
183 * Cheap insurance against an OEM shipping a mangled provider set, and it
184 * turns "does API 29 really have ChaCha20-Poly1305?" into a fact visible in
185 * the log pane rather than a claim in a design document. The vector is the
186 * `loc_single` case from `crates/otproto/tests/vectors.json`; the full set is
187 * checked on the JVM by `VectorsTest`.
188 */
189 fun selfTest(): SelfTestReport {
190 val tokenKey = hexToBytes("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
191 val expectedUp = "52c8535360382dd1d2b9d4b5d605f7c46f8a69fd4b5d62dfd900ca10b8ac6196"
192 val expectedDatagram = "110123456789abcdef000102030405060708090a0bee7fe4db683bd4169d85" +
193 "b517d4e14fceed13336f3437fe90f2c162c7b9a8073cfefc686999c6fa2a83"
194
195 val t = runCatching { transform }.getOrElse { e ->
196 return SelfTestReport(false, null, null, "no provider: ${e.message}")
197 }
198 val p = runCatching { providerName }.getOrNull()
199
200 return try {
201 val kUp = deriveUp(tokenKey)
202 if (bytesToHex(kUp) != expectedUp) {
203 return SelfTestReport(false, t, p, "HKDF mismatch: got ${bytesToHex(kUp)}")
204 }
205
206 val msg = Message.Loc(
207 listOf(
208 net.lexcom.opentracker.wire.Point(
209 ts = 1_785_000_042L,
210 latE7 = 525_200_080,
211 lonE7 = 134_050_000,
212 accDm = 80,
213 altM = 34,
214 spdCms = 450,
215 brgCdeg = 21_400,
216 batPct = 76,
217 flags = net.lexcom.opentracker.wire.PointFlags.NETWORK_FIX,
218 ),
219 ),
220 )
221 val nonce = hexToBytes("000102030405060708090a0b")
222 val sealed = sealMessage(kUp, 0x0123456789abcdefL, nonce, msg)
223 if (bytesToHex(sealed) != expectedDatagram) {
224 return SelfTestReport(
225 false, t, p,
226 "datagram mismatch\n got ${bytesToHex(sealed)}\nwant $expectedDatagram",
227 )
228 }
229
230 val (header, decoded) = openMessage(kUp, sealed)
231 if (decoded != msg) {
232 return SelfTestReport(false, t, p, "round trip changed the message")
233 }
234
235 // A tampered tag must be rejected.
236 val tampered = sealed.copyOf().also { it[it.size - 1] = (it[it.size - 1].toInt() xor 1).toByte() }
237 val rejected = runCatching { open(kUp, tampered) }.isFailure
238 if (!rejected) {
239 return SelfTestReport(false, t, p, "tampered datagram was accepted")
240 }
241
242 SelfTestReport(
243 true, t, p,
244 "HKDF, seal, open and tamper detection all match the Rust vectors " +
245 "(token 0x${header.tokenId.toString(16)}, ${sealed.size} B datagram).",
246 )
247 } catch (e: GeneralSecurityException) {
248 SelfTestReport(false, t, p, "crypto error: $e")
249 }
250 }
251
252 internal fun hexToBytes(s: String): ByteArray {
253 require(s.length % 2 == 0) { "odd-length hex string" }
254 return ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
255 }
256
257 internal fun bytesToHex(b: ByteArray): String =
258 StringBuilder(b.size * 2).apply {
259 b.forEach { append("%02x".format(it)) }
260 }.toString()
261}
262