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 26272829 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 4950515253 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}136137138139140141142143const val PAIRING_WINDOW_MS = 5L * 60 * 1000