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 2627282930 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 5657585960 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}