PairingCode.kt
1package com.peachpasswords.sync23import java.security.SecureRandom45/**6 * The compact join code this phone displays and the user types into the7 * browser. Exact port of the browser's `shared/pairing-code.ts`, which is the8 * authoritative definition of the wire format — the browser only ever decodes,9 * the phone is the only side that generates.10 *11 * Payload: IPv4 (4 bytes) | port (2 bytes, big-endian) | token (>= 4 bytes),12 * in Crockford's Base32, displayed in groups of four:13 *14 * A74H-48J3-KI32-OEP415 *16 * Token length is deliberately open — everything after the port is token — so17 * this side can pick anywhere in the 4–10 byte range with no coordinated change18 * on the browser.19 *20 * SECURITY: the token travels in the clear in the hello frame and is NOT the21 * security boundary; the user-compared SAS after the Noise NK handshake is. The22 * token only stops an unrelated host on the same LAN from consuming the pairing23 * window.24 */25object PairingCode {26 private const val ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"27 private const val IP_BYTES = 428 private const val PORT_BYTES = 229 // Decoding stays lenient about token length so a peer on an older build30 // (8-byte tokens, 23 characters) still pairs instead of hard-failing.31 // Encoding is strict — see TOKEN_BYTES.32 private const val MIN_TOKEN_BYTES = 433 private const val MAX_TOKEN_BYTES = 163435 /**36 * 4 bytes, which is what a four-group code actually costs.37 *38 * Base32 packs 5 bits per character, so the code length is fixed by the39 * payload: IP(4) + port(2) + token(n) bytes → ceil((6+n)*8/5) characters.40 * A 4-byte token is 10 bytes → exactly 16 characters → four clean groups,41 * and is the only size that lands on a group boundary. 8 bytes was 2342 * characters and six groups, which is the code June was looking at.43 *44 * The brief asks for both "8–10 byte token" and "~4 groups"; those are not45 * the same code. Its own worked example — A74H-48J3-KI32-OEP4, 1646 * characters — is a 4-byte token, so the example is what this follows.47 *48 * 32 bits is sound here because the token is not the security boundary:49 * it is single-use, lives for one short pairing window, is only reachable50 * from the same LAN, and a peer that guesses it still cannot pair without51 * the user confirming a matching SAS. Its whole job is to stop an52 * unrelated host from consuming the window. The browser independently sets53 * its floor at 4 bytes ([MIN_TOKEN_BYTES] there), so this is the shortest54 * code both sides accept rather than a number picked to be short.55 */56 const val TOKEN_BYTES = 45758 private val rng = SecureRandom()5960 data class Parsed(val ip: String, val port: Int, val tokenHex: String)6162 fun newToken(): ByteArray = ByteArray(TOKEN_BYTES).also(rng::nextBytes)6364 fun encode(ip: String, port: Int, token: ByteArray): String {65 val octets = ip.split(".")66 require(octets.size == IP_BYTES) { "ip must be dotted-quad IPv4" }67 require(port in 1..65535) { "invalid port: $port" }68 // Strict, mirroring the browser: a longer token silently produces a69 // longer code, which is exactly the bug this guard exists to prevent.70 require(token.size == TOKEN_BYTES) {71 "token must be exactly $TOKEN_BYTES bytes (got ${token.size}); " +72 "every extra byte adds 1.6 characters to the code"73 }7475 val bytes = ByteArray(IP_BYTES + PORT_BYTES + token.size)76 for (i in 0 until IP_BYTES) {77 val n = octets[i].toIntOrNull()78 require(n != null && n in 0..255) { "invalid IPv4 octet: ${octets[i]}" }79 bytes[i] = n.toByte()80 }81 bytes[4] = ((port shr 8) and 0xff).toByte()82 bytes[5] = (port and 0xff).toByte()83 token.copyInto(bytes, IP_BYTES + PORT_BYTES)84 return group(encodeCrockford(bytes))85 }8687 /** Null for anything that is not a well-formed code, never an exception. */88 fun parse(input: String): Parsed? {89 val bytes = decodeCrockford(input) ?: return null90 val tokenBytes = bytes.size - IP_BYTES - PORT_BYTES91 if (tokenBytes < MIN_TOKEN_BYTES || tokenBytes > MAX_TOKEN_BYTES) return null92 val port = ((bytes[4].toInt() and 0xff) shl 8) or (bytes[5].toInt() and 0xff)93 if (port == 0) return null94 return Parsed(95 ip = (0 until IP_BYTES).joinToString(".") { (bytes[it].toInt() and 0xff).toString() },96 port = port,97 tokenHex = bytes.copyOfRange(IP_BYTES + PORT_BYTES, bytes.size).toHex(),98 )99 }100101 fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }102103 private fun group(chars: String): String =104 chars.chunked(4).joinToString("-")105106 private fun encodeCrockford(bytes: ByteArray): String {107 val s = StringBuilder()108 var acc = 0109 var bits = 0110 for (b in bytes) {111 acc = (acc shl 8) or (b.toInt() and 0xff)112 bits += 8113 while (bits >= 5) {114 bits -= 5115 s.append(ALPHABET[(acc shr bits) and 0x1f])116 }117 }118 if (bits > 0) s.append(ALPHABET[(acc shl (5 - bits)) and 0x1f])119 return s.toString()120 }121122 private fun decodeCrockford(input: String): ByteArray? {123 // Crockford's alphabet omits I, L, O and U; decoding folds I/L -> 1 and124 // O -> 0 so a code misread off a phone screen still resolves.125 val clean = buildString {126 for (c in input.uppercase()) when (c) {127 ' ', '-', '\t', '\n' -> Unit128 'I', 'L' -> append('1')129 'O' -> append('0')130 else -> append(c)131 }132 }133 if (clean.isEmpty()) return null134135 // Trailing bits that don't complete a byte are padding and are dropped.136 val out = ByteArray(clean.length * 5 / 8)137 var acc = 0138 var bits = 0139 var o = 0140 for (ch in clean) {141 val v = ALPHABET.indexOf(ch)142 if (v < 0) return null143 acc = (acc shl 5) or v144 bits += 5145 if (bits >= 8) {146 bits -= 8147 out[o++] = ((acc shr bits) and 0xff).toByte()148 }149 }150 return out151 }152}