PairingCode.kt

5.6 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    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}