package com.peachpasswords.sync import java.security.SecureRandom /** * The compact join code this phone displays and the user types into the * browser. Exact port of the browser's `shared/pairing-code.ts`, which is the * authoritative definition of the wire format — the browser only ever decodes, * the phone is the only side that generates. * * Payload: IPv4 (4 bytes) | port (2 bytes, big-endian) | token (>= 4 bytes), * in Crockford's Base32, displayed in groups of four: * * A74H-48J3-KI32-OEP4 * * Token length is deliberately open — everything after the port is token — so * this side can pick anywhere in the 4–10 byte range with no coordinated change * on the browser. * * SECURITY: the token travels in the clear in the hello frame and is NOT the * security boundary; the user-compared SAS after the Noise NK handshake is. The * token only stops an unrelated host on the same LAN from consuming the pairing * window. */ object PairingCode { private const val ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" private const val IP_BYTES = 4 private const val PORT_BYTES = 2 // Decoding stays lenient about token length so a peer on an older build // (8-byte tokens, 23 characters) still pairs instead of hard-failing. // Encoding is strict — see TOKEN_BYTES. private const val MIN_TOKEN_BYTES = 4 private const val MAX_TOKEN_BYTES = 16 /** * 4 bytes, which is what a four-group code actually costs. * * Base32 packs 5 bits per character, so the code length is fixed by the * payload: IP(4) + port(2) + token(n) bytes → ceil((6+n)*8/5) characters. * A 4-byte token is 10 bytes → exactly 16 characters → four clean groups, * and is the only size that lands on a group boundary. 8 bytes was 23 * characters and six groups, which is the code June was looking at. * * The brief asks for both "8–10 byte token" and "~4 groups"; those are not * the same code. Its own worked example — A74H-48J3-KI32-OEP4, 16 * characters — is a 4-byte token, so the example is what this follows. * * 32 bits is sound here because the token is not the security boundary: * it is single-use, lives for one short pairing window, is only reachable * from the same LAN, and a peer that guesses it still cannot pair without * the user confirming a matching SAS. Its whole job is to stop an * unrelated host from consuming the window. The browser independently sets * its floor at 4 bytes ([MIN_TOKEN_BYTES] there), so this is the shortest * code both sides accept rather than a number picked to be short. */ const val TOKEN_BYTES = 4 private val rng = SecureRandom() data class Parsed(val ip: String, val port: Int, val tokenHex: String) fun newToken(): ByteArray = ByteArray(TOKEN_BYTES).also(rng::nextBytes) fun encode(ip: String, port: Int, token: ByteArray): String { val octets = ip.split(".") require(octets.size == IP_BYTES) { "ip must be dotted-quad IPv4" } require(port in 1..65535) { "invalid port: $port" } // Strict, mirroring the browser: a longer token silently produces a // longer code, which is exactly the bug this guard exists to prevent. require(token.size == TOKEN_BYTES) { "token must be exactly $TOKEN_BYTES bytes (got ${token.size}); " + "every extra byte adds 1.6 characters to the code" } val bytes = ByteArray(IP_BYTES + PORT_BYTES + token.size) for (i in 0 until IP_BYTES) { val n = octets[i].toIntOrNull() require(n != null && n in 0..255) { "invalid IPv4 octet: ${octets[i]}" } bytes[i] = n.toByte() } bytes[4] = ((port shr 8) and 0xff).toByte() bytes[5] = (port and 0xff).toByte() token.copyInto(bytes, IP_BYTES + PORT_BYTES) return group(encodeCrockford(bytes)) } /** Null for anything that is not a well-formed code, never an exception. */ fun parse(input: String): Parsed? { val bytes = decodeCrockford(input) ?: return null val tokenBytes = bytes.size - IP_BYTES - PORT_BYTES if (tokenBytes < MIN_TOKEN_BYTES || tokenBytes > MAX_TOKEN_BYTES) return null val port = ((bytes[4].toInt() and 0xff) shl 8) or (bytes[5].toInt() and 0xff) if (port == 0) return null return Parsed( ip = (0 until IP_BYTES).joinToString(".") { (bytes[it].toInt() and 0xff).toString() }, port = port, tokenHex = bytes.copyOfRange(IP_BYTES + PORT_BYTES, bytes.size).toHex(), ) } fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } private fun group(chars: String): String = chars.chunked(4).joinToString("-") private fun encodeCrockford(bytes: ByteArray): String { val s = StringBuilder() var acc = 0 var bits = 0 for (b in bytes) { acc = (acc shl 8) or (b.toInt() and 0xff) bits += 8 while (bits >= 5) { bits -= 5 s.append(ALPHABET[(acc shr bits) and 0x1f]) } } if (bits > 0) s.append(ALPHABET[(acc shl (5 - bits)) and 0x1f]) return s.toString() } private fun decodeCrockford(input: String): ByteArray? { // Crockford's alphabet omits I, L, O and U; decoding folds I/L -> 1 and // O -> 0 so a code misread off a phone screen still resolves. val clean = buildString { for (c in input.uppercase()) when (c) { ' ', '-', '\t', '\n' -> Unit 'I', 'L' -> append('1') 'O' -> append('0') else -> append(c) } } if (clean.isEmpty()) return null // Trailing bits that don't complete a byte are padding and are dropped. val out = ByteArray(clean.length * 5 / 8) var acc = 0 var bits = 0 var o = 0 for (ch in clean) { val v = ALPHABET.indexOf(ch) if (v < 0) return null acc = (acc shl 5) or v bits += 5 if (bits >= 8) { bits -= 8 out[o++] = ((acc shr bits) and 0xff).toByte() } } return out } }