Rendezvous.kt

6.8 KB · raw

1package com.peachpasswords.sync23import org.bouncycastle.crypto.digests.SHA256Digest4import org.bouncycastle.crypto.generators.HKDFBytesGenerator5import org.bouncycastle.crypto.params.HKDFParameters6import java.net.URI7import java.nio.ByteBuffer8import java.security.MessageDigest9import java.security.SecureRandom10import java.util.Base641112object Rendezvous {13    const val DEFAULT_RELAY_URL = "wss://relay.peachpasswords.com"14    const val WINDOW_SECONDS = 3600L15    const val PAIRING_TOKEN_BYTES = 161617    private val secretInfo = "peach-rendezvous-secret-v1".encodeToByteArray()18    private val meshSecretInfo = "peach-mesh-rendezvous-v1".encodeToByteArray()19    private val windowInfo = "peach-rendezvous-v1".encodeToByteArray()2021    fun deriveSecret(handshakeHash: ByteArray): ByteArray {22        require(handshakeHash.size == 32) { "handshake hash must be exactly 32 bytes" }23        return hkdf(handshakeHash, secretInfo)24    }2526    /**27     * Cross-client manifest-introduced mesh capability:28     * info = UTF8("peach-mesh-rendezvous-v1") || lowPublicKey || highPublicKey29     * secret = HKDF-SHA256(vaultRoot, salt="", info, 32)30     */31    fun deriveMeshSecret(32        vaultRoot: ByteArray,33        ourPublicKey: ByteArray,34        peerPublicKey: ByteArray,35    ): ByteArray {36        require(vaultRoot.size == 32) { "vault root must be exactly 32 bytes" }37        require(ourPublicKey.size == 32 && peerPublicKey.size == 32) {38            "mesh public keys must be exactly 32 bytes"39        }40        val order = compareUnsigned(ourPublicKey, peerPublicKey)41        require(order != 0) { "mesh peers must have distinct public keys" }42        val low = if (order < 0) ourPublicKey else peerPublicKey43        val high = if (order < 0) peerPublicKey else ourPublicKey44        return hkdf(vaultRoot, meshSecretInfo + low + high)45    }4647    /**48     * Cross-client encoding contract:49     * info = UTF8("peach-rendezvous-v1") || uint64be(windowIndex)50     * id = base64url-no-padding(HKDF-SHA256(secret, salt="", info, 32))51     */52    fun deriveId(secret: ByteArray, windowIndex: Long): String {53        require(secret.size == 32) { "rendezvous secret must be exactly 32 bytes" }54        require(windowIndex >= 0) { "window index must be non-negative" }55        val info = windowInfo + ByteBuffer.allocate(Long.SIZE_BYTES).putLong(windowIndex).array()56        return Base64.getUrlEncoder().withoutPadding().encodeToString(hkdf(secret, info))57    }5859    fun windowIndex(unixSeconds: Long, windowSeconds: Long = WINDOW_SECONDS): Long {60        require(unixSeconds >= 0) { "unixSeconds must be non-negative" }61        require(windowSeconds > 0) { "windowSeconds must be positive" }62        return unixSeconds / windowSeconds63    }6465    fun waitingIds(secret: ByteArray, unixSeconds: Long): List<String> {66        val current = windowIndex(unixSeconds)67        return listOf(current, (current - 1).coerceAtLeast(0)).map { deriveId(secret, it) }68    }6970    fun dialingIds(secret: ByteArray, unixSeconds: Long): List<String> {71        val current = windowIndex(unixSeconds)72        return listOf(current, (current - 1).coerceAtLeast(0), current + 1).map {73            deriveId(secret, it)74        }75    }7677    /**78     * Resolve an opaque rotating capability to paired local state without79     * exposing public keys in a pre-handshake selector. Invalid/missing legacy80     * secrets are ignored; callers must fail closed rather than retry NK.81     */82    fun resolvePeer(83        devices: List<PairedDevice>,84        rendezvousId: String,85        unixSeconds: Long = System.currentTimeMillis() / 1000L,86    ): PairedDevice? {87        if (!rendezvousId.matches(Regex("^[A-Za-z0-9_-]{22,128}$"))) return null88        val presented = rendezvousId.toByteArray(Charsets.US_ASCII)89        var match: PairedDevice? = null90        for (device in devices) {91            val secret = device.rendezvousSecretHex92                ?.takeIf { it.matches(Regex("^[0-9a-f]{64}$")) }93                ?.let(::secretFromHex)94                ?: continue95            for (candidate in waitingIds(secret, unixSeconds)) {96                if (MessageDigest.isEqual(candidate.toByteArray(Charsets.US_ASCII), presented)) {97                    match = device98                }99            }100        }101        return match102    }103104    fun newPairingToken(random: SecureRandom = SecureRandom()): String {105        val bytes = ByteArray(PAIRING_TOKEN_BYTES)106        random.nextBytes(bytes)107        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)108    }109110    fun normalizeRelayUrl(input: String): String {111        val uri = URI(input.trim())112        require(uri.scheme == "wss") { "relay URL must use wss://" }113        require(!uri.host.isNullOrBlank()) { "relay URL host missing" }114        require(uri.userInfo == null && uri.rawQuery == null && uri.rawFragment == null) {115            "relay URL must not contain credentials, query, or fragment"116        }117        val path = uri.path.orEmpty().trimEnd('/')118        return URI("wss", null, uri.host, uri.port, path, null, null).toString().trimEnd('/')119    }120121    fun buildUrl(relayUrl: String, rendezvousId: String): String {122        require(rendezvousId.matches(Regex("^[A-Za-z0-9_-]{22,128}$"))) {123            "invalid rendezvous id"124        }125        return "${normalizeRelayUrl(relayUrl)}/r/$rendezvousId"126    }127128    fun bytesToHex(bytes: ByteArray): String =129        bytes.joinToString("") { "%02x".format(it) }130131    fun secretFromHex(hex: String): ByteArray {132        require(hex.matches(Regex("^[0-9a-f]{64}$"))) {133            "rendezvous secret must be 32-byte lowercase hex"134        }135        return ByteArray(32) { index -> hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() }136    }137138    fun bindPairState(139        device: PairedDevice,140        handshakeHash: ByteArray,141        relayUrl: String,142    ): PairedDevice = device.copy(143        rendezvousSecretHex = bytesToHex(deriveSecret(handshakeHash)),144        relayUrl = normalizeRelayUrl(relayUrl),145    )146147    private fun hkdf(ikm: ByteArray, info: ByteArray): ByteArray {148        val generator = HKDFBytesGenerator(SHA256Digest())149        generator.init(HKDFParameters(ikm, ByteArray(0), info))150        return ByteArray(32).also { generator.generateBytes(it, 0, it.size) }151    }152153    private fun compareUnsigned(a: ByteArray, b: ByteArray): Int {154        for (index in a.indices) {155            val left = a[index].toInt() and 0xff156            val right = b[index].toInt() and 0xff157            if (left != right) return left.compareTo(right)158        }159        return 0160    }161}162163/**164 * How long a first-pair invitation stays live. Five minutes: long enough to165 * walk to the other device, short enough that a code does not sit valid in a166 * pocket. Lived on the retired LAN `CodePairingServer` until relay-only167 * pairing (docs/sync-lan-retirement.md); it was never LAN-specific.168 */169const val PAIRING_WINDOW_MS = 5L * 60 * 1000