PairingCode.kt

4.5 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    /** 8 bytes — comfortably inside the browser's 4–10 range, 23 characters. */32    const val TOKEN_BYTES = 83334    private val rng = SecureRandom()3536    data class Parsed(val ip: String, val port: Int, val tokenHex: String)3738    fun newToken(): ByteArray = ByteArray(TOKEN_BYTES).also(rng::nextBytes)3940    fun encode(ip: String, port: Int, token: ByteArray): String {41        val octets = ip.split(".")42        require(octets.size == IP_BYTES) { "ip must be dotted-quad IPv4" }43        require(port in 1..65535) { "invalid port: $port" }44        require(token.size >= MIN_TOKEN_BYTES) { "token must be at least $MIN_TOKEN_BYTES bytes" }4546        val bytes = ByteArray(IP_BYTES + PORT_BYTES + token.size)47        for (i in 0 until IP_BYTES) {48            val n = octets[i].toIntOrNull()49            require(n != null && n in 0..255) { "invalid IPv4 octet: ${octets[i]}" }50            bytes[i] = n.toByte()51        }52        bytes[4] = ((port shr 8) and 0xff).toByte()53        bytes[5] = (port and 0xff).toByte()54        token.copyInto(bytes, IP_BYTES + PORT_BYTES)55        return group(encodeCrockford(bytes))56    }5758    /** Null for anything that is not a well-formed code, never an exception. */59    fun parse(input: String): Parsed? {60        val bytes = decodeCrockford(input) ?: return null61        if (bytes.size < IP_BYTES + PORT_BYTES + MIN_TOKEN_BYTES) return null62        val port = ((bytes[4].toInt() and 0xff) shl 8) or (bytes[5].toInt() and 0xff)63        if (port == 0) return null64        return Parsed(65            ip = (0 until IP_BYTES).joinToString(".") { (bytes[it].toInt() and 0xff).toString() },66            port = port,67            tokenHex = bytes.copyOfRange(IP_BYTES + PORT_BYTES, bytes.size).toHex(),68        )69    }7071    fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }7273    private fun group(chars: String): String =74        chars.chunked(4).joinToString("-")7576    private fun encodeCrockford(bytes: ByteArray): String {77        val s = StringBuilder()78        var acc = 079        var bits = 080        for (b in bytes) {81            acc = (acc shl 8) or (b.toInt() and 0xff)82            bits += 883            while (bits >= 5) {84                bits -= 585                s.append(ALPHABET[(acc shr bits) and 0x1f])86            }87        }88        if (bits > 0) s.append(ALPHABET[(acc shl (5 - bits)) and 0x1f])89        return s.toString()90    }9192    private fun decodeCrockford(input: String): ByteArray? {93        // Crockford's alphabet omits I, L, O and U; decoding folds I/L -> 1 and94        // O -> 0 so a code misread off a phone screen still resolves.95        val clean = buildString {96            for (c in input.uppercase()) when (c) {97                ' ', '-', '\t', '\n' -> Unit98                'I', 'L' -> append('1')99                'O' -> append('0')100                else -> append(c)101            }102        }103        if (clean.isEmpty()) return null104105        // Trailing bits that don't complete a byte are padding and are dropped.106        val out = ByteArray(clean.length * 5 / 8)107        var acc = 0108        var bits = 0109        var o = 0110        for (ch in clean) {111            val v = ALPHABET.indexOf(ch)112            if (v < 0) return null113            acc = (acc shl 5) or v114            bits += 5115            if (bits >= 8) {116                bits -= 8117                out[o++] = ((acc shr bits) and 0xff).toByte()118            }119        }120        return out121    }122}