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 2526272829 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}