PairingCode.kt

6.2 KB · raw

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}