Sealer.kt
| 1 | package net.lexcom.opentracker.crypto |
| 2 | |
| 3 | import net.lexcom.opentracker.wire.HEADER_LEN |
| 4 | import net.lexcom.opentracker.wire.Header |
| 5 | import net.lexcom.opentracker.wire.MAX_DATAGRAM |
| 6 | import net.lexcom.opentracker.wire.Message |
| 7 | import net.lexcom.opentracker.wire.NONCE_LEN |
| 8 | import net.lexcom.opentracker.wire.TAG_LEN |
| 9 | import net.lexcom.opentracker.wire.WireFormatException |
| 10 | import net.lexcom.opentracker.wire.datagramLen |
| 11 | import java.security.GeneralSecurityException |
| 12 | import java.security.SecureRandom |
| 13 | import javax.crypto.Cipher |
| 14 | import javax.crypto.Mac |
| 15 | import javax.crypto.spec.IvParameterSpec |
| 16 | import 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 | */ |
| 34 | object 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 |