Rendezvous.kt

5.2 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 windowInfo = "peach-rendezvous-v1".encodeToByteArray()1920    fun deriveSecret(handshakeHash: ByteArray): ByteArray {21        require(handshakeHash.size == 32) { "handshake hash must be exactly 32 bytes" }22        return hkdf(handshakeHash, secretInfo)23    }2425    /**26     * Cross-client encoding contract:27     * info = UTF8("peach-rendezvous-v1") || uint64be(windowIndex)28     * id = base64url-no-padding(HKDF-SHA256(secret, salt="", info, 32))29     */30    fun deriveId(secret: ByteArray, windowIndex: Long): String {31        require(secret.size == 32) { "rendezvous secret must be exactly 32 bytes" }32        require(windowIndex >= 0) { "window index must be non-negative" }33        val info = windowInfo + ByteBuffer.allocate(Long.SIZE_BYTES).putLong(windowIndex).array()34        return Base64.getUrlEncoder().withoutPadding().encodeToString(hkdf(secret, info))35    }3637    fun windowIndex(unixSeconds: Long, windowSeconds: Long = WINDOW_SECONDS): Long {38        require(unixSeconds >= 0) { "unixSeconds must be non-negative" }39        require(windowSeconds > 0) { "windowSeconds must be positive" }40        return unixSeconds / windowSeconds41    }4243    fun waitingIds(secret: ByteArray, unixSeconds: Long): List<String> {44        val current = windowIndex(unixSeconds)45        return listOf(current, (current - 1).coerceAtLeast(0)).map { deriveId(secret, it) }46    }4748    fun dialingIds(secret: ByteArray, unixSeconds: Long): List<String> {49        val current = windowIndex(unixSeconds)50        return listOf(current, (current - 1).coerceAtLeast(0), current + 1).map {51            deriveId(secret, it)52        }53    }5455    /**56     * Resolve an opaque rotating capability to paired local state without57     * exposing public keys in a pre-handshake selector. Invalid/missing legacy58     * secrets are ignored; callers must fail closed rather than retry NK.59     */60    fun resolvePeer(61        devices: List<PairedDevice>,62        rendezvousId: String,63        unixSeconds: Long = System.currentTimeMillis() / 1000L,64    ): PairedDevice? {65        if (!rendezvousId.matches(Regex("^[A-Za-z0-9_-]{22,128}$"))) return null66        val presented = rendezvousId.toByteArray(Charsets.US_ASCII)67        var match: PairedDevice? = null68        for (device in devices) {69            val secret = device.rendezvousSecretHex70                ?.takeIf { it.matches(Regex("^[0-9a-f]{64}$")) }71                ?.let(::secretFromHex)72                ?: continue73            for (candidate in waitingIds(secret, unixSeconds)) {74                if (MessageDigest.isEqual(candidate.toByteArray(Charsets.US_ASCII), presented)) {75                    match = device76                }77            }78        }79        return match80    }8182    fun newPairingToken(random: SecureRandom = SecureRandom()): String {83        val bytes = ByteArray(PAIRING_TOKEN_BYTES)84        random.nextBytes(bytes)85        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)86    }8788    fun normalizeRelayUrl(input: String): String {89        val uri = URI(input.trim())90        require(uri.scheme == "wss") { "relay URL must use wss://" }91        require(!uri.host.isNullOrBlank()) { "relay URL host missing" }92        require(uri.userInfo == null && uri.rawQuery == null && uri.rawFragment == null) {93            "relay URL must not contain credentials, query, or fragment"94        }95        val path = uri.path.orEmpty().trimEnd('/')96        return URI("wss", null, uri.host, uri.port, path, null, null).toString().trimEnd('/')97    }9899    fun buildUrl(relayUrl: String, rendezvousId: String): String {100        require(rendezvousId.matches(Regex("^[A-Za-z0-9_-]{22,128}$"))) {101            "invalid rendezvous id"102        }103        return "${normalizeRelayUrl(relayUrl)}/r/$rendezvousId"104    }105106    fun bytesToHex(bytes: ByteArray): String =107        bytes.joinToString("") { "%02x".format(it) }108109    fun secretFromHex(hex: String): ByteArray {110        require(hex.matches(Regex("^[0-9a-f]{64}$"))) {111            "rendezvous secret must be 32-byte lowercase hex"112        }113        return ByteArray(32) { index -> hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() }114    }115116    fun bindPairState(117        device: PairedDevice,118        handshakeHash: ByteArray,119        relayUrl: String,120    ): PairedDevice = device.copy(121        rendezvousSecretHex = bytesToHex(deriveSecret(handshakeHash)),122        relayUrl = normalizeRelayUrl(relayUrl),123    )124125    private fun hkdf(ikm: ByteArray, info: ByteArray): ByteArray {126        val generator = HKDFBytesGenerator(SHA256Digest())127        generator.init(HKDFParameters(ikm, ByteArray(0), info))128        return ByteArray(32).also { generator.generateBytes(it, 0, it.size) }129    }130}