Rendezvous.kt

4.1 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.SecureRandom9import java.util.Base641011object Rendezvous {12    const val DEFAULT_RELAY_URL = "wss://relay.peachpasswords.com"13    const val WINDOW_SECONDS = 3600L14    const val PAIRING_TOKEN_BYTES = 161516    private val secretInfo = "peach-rendezvous-secret-v1".encodeToByteArray()17    private val windowInfo = "peach-rendezvous-v1".encodeToByteArray()1819    fun deriveSecret(handshakeHash: ByteArray): ByteArray {20        require(handshakeHash.size == 32) { "handshake hash must be exactly 32 bytes" }21        return hkdf(handshakeHash, secretInfo)22    }2324    /**25     * Cross-client encoding contract:26     * info = UTF8("peach-rendezvous-v1") || uint64be(windowIndex)27     * id = base64url-no-padding(HKDF-SHA256(secret, salt="", info, 32))28     */29    fun deriveId(secret: ByteArray, windowIndex: Long): String {30        require(secret.size == 32) { "rendezvous secret must be exactly 32 bytes" }31        require(windowIndex >= 0) { "window index must be non-negative" }32        val info = windowInfo + ByteBuffer.allocate(Long.SIZE_BYTES).putLong(windowIndex).array()33        return Base64.getUrlEncoder().withoutPadding().encodeToString(hkdf(secret, info))34    }3536    fun windowIndex(unixSeconds: Long, windowSeconds: Long = WINDOW_SECONDS): Long {37        require(unixSeconds >= 0) { "unixSeconds must be non-negative" }38        require(windowSeconds > 0) { "windowSeconds must be positive" }39        return unixSeconds / windowSeconds40    }4142    fun waitingIds(secret: ByteArray, unixSeconds: Long): List<String> {43        val current = windowIndex(unixSeconds)44        return listOf(current, (current - 1).coerceAtLeast(0)).map { deriveId(secret, it) }45    }4647    fun dialingIds(secret: ByteArray, unixSeconds: Long): List<String> {48        val current = windowIndex(unixSeconds)49        return listOf(current, (current - 1).coerceAtLeast(0), current + 1).map {50            deriveId(secret, it)51        }52    }5354    fun newPairingToken(random: SecureRandom = SecureRandom()): String {55        val bytes = ByteArray(PAIRING_TOKEN_BYTES)56        random.nextBytes(bytes)57        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)58    }5960    fun normalizeRelayUrl(input: String): String {61        val uri = URI(input.trim())62        require(uri.scheme == "wss") { "relay URL must use wss://" }63        require(!uri.host.isNullOrBlank()) { "relay URL host missing" }64        require(uri.userInfo == null && uri.rawQuery == null && uri.rawFragment == null) {65            "relay URL must not contain credentials, query, or fragment"66        }67        val path = uri.path.orEmpty().trimEnd('/')68        return URI("wss", null, uri.host, uri.port, path, null, null).toString().trimEnd('/')69    }7071    fun buildUrl(relayUrl: String, rendezvousId: String): String {72        require(rendezvousId.matches(Regex("^[A-Za-z0-9_-]{22,128}$"))) {73            "invalid rendezvous id"74        }75        return "${normalizeRelayUrl(relayUrl)}/r/$rendezvousId"76    }7778    fun bytesToHex(bytes: ByteArray): String =79        bytes.joinToString("") { "%02x".format(it) }8081    fun secretFromHex(hex: String): ByteArray {82        require(hex.matches(Regex("^[0-9a-f]{64}$"))) {83            "rendezvous secret must be 32-byte lowercase hex"84        }85        return ByteArray(32) { index -> hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() }86    }8788    fun bindPairState(89        device: PairedDevice,90        handshakeHash: ByteArray,91        relayUrl: String,92    ): PairedDevice = device.copy(93        rendezvousSecretHex = bytesToHex(deriveSecret(handshakeHash)),94        relayUrl = normalizeRelayUrl(relayUrl),95    )9697    private fun hkdf(ikm: ByteArray, info: ByteArray): ByteArray {98        val generator = HKDFBytesGenerator(SHA256Digest())99        generator.init(HKDFParameters(ikm, ByteArray(0), info))100        return ByteArray(32).also { generator.generateBytes(it, 0, it.size) }101    }102}