Rendezvous.kt

5.9 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 meshSecretInfo = "peach-mesh-rendezvous-v2".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     * Mesh capability v2: X25519 with the local random private key, followed27     * by HKDF-SHA256 over the canonical unsigned public-key ordering.28     */29    fun deriveMeshSecret(30        ourPrivateKey: ByteArray,31        ourPublicKey: ByteArray,32        peerPublicKey: ByteArray,33    ): ByteArray {34        require(ourPrivateKey.size == 32 && ourPublicKey.size == 32 && peerPublicKey.size == 32)35        require(compareUnsigned(ourPublicKey, peerPublicKey) != 0)36        val shared = ByteArray(32)37        try {38            org.bouncycastle.crypto.params.X25519PrivateKeyParameters(ourPrivateKey, 0)39                .generateSecret(org.bouncycastle.crypto.params.X25519PublicKeyParameters(peerPublicKey, 0), shared, 0)40            require(shared.any { it.toInt() != 0 }) { "mesh low-order point rejected" }41            val low = if (compareUnsigned(ourPublicKey, peerPublicKey) < 0) ourPublicKey else peerPublicKey42            val high = if (low === ourPublicKey) peerPublicKey else ourPublicKey43            return hkdf(shared, meshSecretInfo + low + high)44        } finally {45            shared.fill(0)46        }47    }48    /**49     * Cross-client encoding contract:50     * info = UTF8("peach-rendezvous-v1") || uint64be(windowIndex)51     * id = base64url-no-padding(HKDF-SHA256(secret, salt="", info, 32))52     */53    fun deriveId(secret: ByteArray, windowIndex: Long): String {54        require(secret.size == 32) { "rendezvous secret must be exactly 32 bytes" }55        require(windowIndex >= 0) { "window index must be non-negative" }56        val info = windowInfo + ByteBuffer.allocate(Long.SIZE_BYTES).putLong(windowIndex).array()57        return Base64.getUrlEncoder().withoutPadding().encodeToString(hkdf(secret, info))58    }5960    fun windowIndex(unixSeconds: Long, windowSeconds: Long = WINDOW_SECONDS): Long {61        require(unixSeconds >= 0) { "unixSeconds must be non-negative" }62        require(windowSeconds > 0) { "windowSeconds must be positive" }63        return unixSeconds / windowSeconds64    }6566    fun waitingIds(secret: ByteArray, unixSeconds: Long): List<String> {67        val current = windowIndex(unixSeconds)68        return listOf(current, (current - 1).coerceAtLeast(0)).map { deriveId(secret, it) }69    }7071    fun dialingIds(secret: ByteArray, unixSeconds: Long): List<String> {72        val current = windowIndex(unixSeconds)73        return listOf(current, (current - 1).coerceAtLeast(0), current + 1).map {74            deriveId(secret, it)75        }76    }7778    fun newPairingToken(random: SecureRandom = SecureRandom()): String {79        val bytes = ByteArray(PAIRING_TOKEN_BYTES)80        random.nextBytes(bytes)81        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)82    }8384    fun normalizeRelayUrl(input: String): String {85        val uri = URI(input.trim())86        require(uri.scheme == "wss") { "relay URL must use wss://" }87        require(!uri.host.isNullOrBlank()) { "relay URL host missing" }88        require(uri.userInfo == null && uri.rawQuery == null && uri.rawFragment == null) {89            "relay URL must not contain credentials, query, or fragment"90        }91        val path = uri.path.orEmpty().trimEnd('/')92        return URI("wss", null, uri.host, uri.port, path, null, null).toString().trimEnd('/')93    }9495    fun buildUrl(relayUrl: String, rendezvousId: String): String {96        require(rendezvousId.matches(Regex("^[A-Za-z0-9_-]{22,128}$"))) {97            "invalid rendezvous id"98        }99        return "${normalizeRelayUrl(relayUrl)}/r/$rendezvousId"100    }101102    fun bytesToHex(bytes: ByteArray): String =103        bytes.joinToString("") { "%02x".format(it) }104105    fun secretFromHex(hex: String): ByteArray {106        require(hex.matches(Regex("^[0-9a-f]{64}$"))) {107            "rendezvous secret must be 32-byte lowercase hex"108        }109        return ByteArray(32) { index -> hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() }110    }111112    fun bindPairState(113        device: PairedDevice,114        handshakeHash: ByteArray,115        relayUrl: String,116    ): PairedDevice = device.copy(117        rendezvousSecretHex = bytesToHex(deriveSecret(handshakeHash)),118        relayUrl = normalizeRelayUrl(relayUrl),119    )120121    private fun hkdf(ikm: ByteArray, info: ByteArray): ByteArray {122        val generator = HKDFBytesGenerator(SHA256Digest())123        generator.init(HKDFParameters(ikm, ByteArray(0), info))124        return ByteArray(32).also { generator.generateBytes(it, 0, it.size) }125    }126127    private fun compareUnsigned(a: ByteArray, b: ByteArray): Int {128        for (index in a.indices) {129            val left = a[index].toInt() and 0xff130            val right = b[index].toInt() and 0xff131            if (left != right) return left.compareTo(right)132        }133        return 0134    }135}136137/**138 * How long a first-pair invitation stays live. Five minutes: long enough to139 * walk to the other device, short enough that a code does not sit valid in a140 * pocket. Lived on the retired LAN `CodePairingServer` until relay-only141 * pairing (docs/sync-lan-retirement.md); it was never LAN-specific.142 */143const val PAIRING_WINDOW_MS = 5L * 60 * 1000