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 meshSecretInfo = "peach-mesh-rendezvous-v1".encodeToByteArray()19 private val windowInfo = "peach-rendezvous-v1".encodeToByteArray()2021 fun deriveSecret(handshakeHash: ByteArray): ByteArray {22 require(handshakeHash.size == 32) { "handshake hash must be exactly 32 bytes" }23 return hkdf(handshakeHash, secretInfo)24 }2526 2728293031 fun deriveMeshSecret(32 vaultRoot: ByteArray,33 ourPublicKey: ByteArray,34 peerPublicKey: ByteArray,35 ): ByteArray {36 require(vaultRoot.size == 32) { "vault root must be exactly 32 bytes" }37 require(ourPublicKey.size == 32 && peerPublicKey.size == 32) {38 "mesh public keys must be exactly 32 bytes"39 }40 val order = compareUnsigned(ourPublicKey, peerPublicKey)41 require(order != 0) { "mesh peers must have distinct public keys" }42 val low = if (order < 0) ourPublicKey else peerPublicKey43 val high = if (order < 0) peerPublicKey else ourPublicKey44 return hkdf(vaultRoot, meshSecretInfo + low + high)45 }4647 4849505152 fun deriveId(secret: ByteArray, windowIndex: Long): String {53 require(secret.size == 32) { "rendezvous secret must be exactly 32 bytes" }54 require(windowIndex >= 0) { "window index must be non-negative" }55 val info = windowInfo + ByteBuffer.allocate(Long.SIZE_BYTES).putLong(windowIndex).array()56 return Base64.getUrlEncoder().withoutPadding().encodeToString(hkdf(secret, info))57 }5859 fun windowIndex(unixSeconds: Long, windowSeconds: Long = WINDOW_SECONDS): Long {60 require(unixSeconds >= 0) { "unixSeconds must be non-negative" }61 require(windowSeconds > 0) { "windowSeconds must be positive" }62 return unixSeconds / windowSeconds63 }6465 fun waitingIds(secret: ByteArray, unixSeconds: Long): List<String> {66 val current = windowIndex(unixSeconds)67 return listOf(current, (current - 1).coerceAtLeast(0)).map { deriveId(secret, it) }68 }6970 fun dialingIds(secret: ByteArray, unixSeconds: Long): List<String> {71 val current = windowIndex(unixSeconds)72 return listOf(current, (current - 1).coerceAtLeast(0), current + 1).map {73 deriveId(secret, it)74 }75 }7677 7879808182 fun resolvePeer(83 devices: List<PairedDevice>,84 rendezvousId: String,85 unixSeconds: Long = System.currentTimeMillis() / 1000L,86 ): PairedDevice? {87 if (!rendezvousId.matches(Regex("^[A-Za-z0-9_-]{22,128}$"))) return null88 val presented = rendezvousId.toByteArray(Charsets.US_ASCII)89 var match: PairedDevice? = null90 for (device in devices) {91 val secret = device.rendezvousSecretHex92 ?.takeIf { it.matches(Regex("^[0-9a-f]{64}$")) }93 ?.let(::secretFromHex)94 ?: continue95 for (candidate in waitingIds(secret, unixSeconds)) {96 if (MessageDigest.isEqual(candidate.toByteArray(Charsets.US_ASCII), presented)) {97 match = device98 }99 }100 }101 return match102 }103104 fun newPairingToken(random: SecureRandom = SecureRandom()): String {105 val bytes = ByteArray(PAIRING_TOKEN_BYTES)106 random.nextBytes(bytes)107 return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)108 }109110 fun normalizeRelayUrl(input: String): String {111 val uri = URI(input.trim())112 require(uri.scheme == "wss") { "relay URL must use wss://" }113 require(!uri.host.isNullOrBlank()) { "relay URL host missing" }114 require(uri.userInfo == null && uri.rawQuery == null && uri.rawFragment == null) {115 "relay URL must not contain credentials, query, or fragment"116 }117 val path = uri.path.orEmpty().trimEnd('/')118 return URI("wss", null, uri.host, uri.port, path, null, null).toString().trimEnd('/')119 }120121 fun buildUrl(relayUrl: String, rendezvousId: String): String {122 require(rendezvousId.matches(Regex("^[A-Za-z0-9_-]{22,128}$"))) {123 "invalid rendezvous id"124 }125 return "${normalizeRelayUrl(relayUrl)}/r/$rendezvousId"126 }127128 fun bytesToHex(bytes: ByteArray): String =129 bytes.joinToString("") { "%02x".format(it) }130131 fun secretFromHex(hex: String): ByteArray {132 require(hex.matches(Regex("^[0-9a-f]{64}$"))) {133 "rendezvous secret must be 32-byte lowercase hex"134 }135 return ByteArray(32) { index -> hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() }136 }137138 fun bindPairState(139 device: PairedDevice,140 handshakeHash: ByteArray,141 relayUrl: String,142 ): PairedDevice = device.copy(143 rendezvousSecretHex = bytesToHex(deriveSecret(handshakeHash)),144 relayUrl = normalizeRelayUrl(relayUrl),145 )146147 private fun hkdf(ikm: ByteArray, info: ByteArray): ByteArray {148 val generator = HKDFBytesGenerator(SHA256Digest())149 generator.init(HKDFParameters(ikm, ByteArray(0), info))150 return ByteArray(32).also { generator.generateBytes(it, 0, it.size) }151 }152153 private fun compareUnsigned(a: ByteArray, b: ByteArray): Int {154 for (index in a.indices) {155 val left = a[index].toInt() and 0xff156 val right = b[index].toInt() and 0xff157 if (left != right) return left.compareTo(right)158 }159 return 0160 }161}162163164165166167168169const val PAIRING_WINDOW_MS = 5L * 60 * 1000