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 private const val MIN_TOKEN_BYTES = 43031 /**32 * 4 bytes, which is what a four-group code actually costs.33 *34 * Base32 packs 5 bits per character, so the code length is fixed by the35 * payload: IP(4) + port(2) + token(n) bytes → ceil((6+n)*8/5) characters.36 * A 4-byte token is 10 bytes → exactly 16 characters → four clean groups,37 * and is the only size that lands on a group boundary. 8 bytes was 2338 * characters and six groups, which is the code June was looking at.39 *40 * The brief asks for both "8–10 byte token" and "~4 groups"; those are not41 * the same code. Its own worked example — A74H-48J3-KI32-OEP4, 1642 * characters — is a 4-byte token, so the example is what this follows.43 *44 * 32 bits is sound here because the token is not the security boundary:45 * it is single-use, lives for one short pairing window, is only reachable46 * from the same LAN, and a peer that guesses it still cannot pair without47 * the user confirming a matching SAS. Its whole job is to stop an48 * unrelated host from consuming the window. The browser independently sets49 * its floor at 4 bytes ([MIN_TOKEN_BYTES] there), so this is the shortest50 * code both sides accept rather than a number picked to be short.51 */52 const val TOKEN_BYTES = 45354 private val rng = SecureRandom()5556 data class Parsed(val ip: String, val port: Int, val tokenHex: String)5758 fun newToken(): ByteArray = ByteArray(TOKEN_BYTES).also(rng::nextBytes)5960 fun encode(ip: String, port: Int, token: ByteArray): String {61 val octets = ip.split(".")62 require(octets.size == IP_BYTES) { "ip must be dotted-quad IPv4" }63 require(port in 1..65535) { "invalid port: $port" }64 require(token.size >= MIN_TOKEN_BYTES) { "token must be at least $MIN_TOKEN_BYTES bytes" }6566 val bytes = ByteArray(IP_BYTES + PORT_BYTES + token.size)67 for (i in 0 until IP_BYTES) {68 val n = octets[i].toIntOrNull()69 require(n != null && n in 0..255) { "invalid IPv4 octet: ${octets[i]}" }70 bytes[i] = n.toByte()71 }72 bytes[4] = ((port shr 8) and 0xff).toByte()73 bytes[5] = (port and 0xff).toByte()74 token.copyInto(bytes, IP_BYTES + PORT_BYTES)75 return group(encodeCrockford(bytes))76 }7778 /** Null for anything that is not a well-formed code, never an exception. */79 fun parse(input: String): Parsed? {80 val bytes = decodeCrockford(input) ?: return null81 if (bytes.size < IP_BYTES + PORT_BYTES + MIN_TOKEN_BYTES) return null82 val port = ((bytes[4].toInt() and 0xff) shl 8) or (bytes[5].toInt() and 0xff)83 if (port == 0) return null84 return Parsed(85 ip = (0 until IP_BYTES).joinToString(".") { (bytes[it].toInt() and 0xff).toString() },86 port = port,87 tokenHex = bytes.copyOfRange(IP_BYTES + PORT_BYTES, bytes.size).toHex(),88 )89 }9091 fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }9293 private fun group(chars: String): String =94 chars.chunked(4).joinToString("-")9596 private fun encodeCrockford(bytes: ByteArray): String {97 val s = StringBuilder()98 var acc = 099 var bits = 0100 for (b in bytes) {101 acc = (acc shl 8) or (b.toInt() and 0xff)102 bits += 8103 while (bits >= 5) {104 bits -= 5105 s.append(ALPHABET[(acc shr bits) and 0x1f])106 }107 }108 if (bits > 0) s.append(ALPHABET[(acc shl (5 - bits)) and 0x1f])109 return s.toString()110 }111112 private fun decodeCrockford(input: String): ByteArray? {113 // Crockford's alphabet omits I, L, O and U; decoding folds I/L -> 1 and114 // O -> 0 so a code misread off a phone screen still resolves.115 val clean = buildString {116 for (c in input.uppercase()) when (c) {117 ' ', '-', '\t', '\n' -> Unit118 'I', 'L' -> append('1')119 'O' -> append('0')120 else -> append(c)121 }122 }123 if (clean.isEmpty()) return null124125 // Trailing bits that don't complete a byte are padding and are dropped.126 val out = ByteArray(clean.length * 5 / 8)127 var acc = 0128 var bits = 0129 var o = 0130 for (ch in clean) {131 val v = ALPHABET.indexOf(ch)132 if (v < 0) return null133 acc = (acc shl 5) or v134 bits += 5135 if (bits >= 8) {136 bits -= 8137 out[o++] = ((acc shr bits) and 0xff).toByte()138 }139 }140 return out141 }142}