package com.peachpasswords.sync import org.bouncycastle.crypto.digests.SHA256Digest import org.bouncycastle.crypto.generators.HKDFBytesGenerator import org.bouncycastle.crypto.params.HKDFParameters import java.net.URI import java.nio.ByteBuffer import java.security.SecureRandom import java.util.Base64 object Rendezvous { const val DEFAULT_RELAY_URL = "wss://relay.peachpasswords.com" const val WINDOW_SECONDS = 3600L const val PAIRING_TOKEN_BYTES = 16 private val secretInfo = "peach-rendezvous-secret-v1".encodeToByteArray() private val meshSecretInfo = "peach-mesh-rendezvous-v2".encodeToByteArray() private val windowInfo = "peach-rendezvous-v1".encodeToByteArray() fun deriveSecret(handshakeHash: ByteArray): ByteArray { require(handshakeHash.size == 32) { "handshake hash must be exactly 32 bytes" } return hkdf(handshakeHash, secretInfo) } /** * Mesh capability v2: X25519 with the local random private key, followed * by HKDF-SHA256 over the canonical unsigned public-key ordering. */ fun deriveMeshSecret( ourPrivateKey: ByteArray, ourPublicKey: ByteArray, peerPublicKey: ByteArray, ): ByteArray { require(ourPrivateKey.size == 32 && ourPublicKey.size == 32 && peerPublicKey.size == 32) require(compareUnsigned(ourPublicKey, peerPublicKey) != 0) val shared = ByteArray(32) try { org.bouncycastle.crypto.params.X25519PrivateKeyParameters(ourPrivateKey, 0) .generateSecret(org.bouncycastle.crypto.params.X25519PublicKeyParameters(peerPublicKey, 0), shared, 0) require(shared.any { it.toInt() != 0 }) { "mesh low-order point rejected" } val low = if (compareUnsigned(ourPublicKey, peerPublicKey) < 0) ourPublicKey else peerPublicKey val high = if (low === ourPublicKey) peerPublicKey else ourPublicKey return hkdf(shared, meshSecretInfo + low + high) } finally { shared.fill(0) } } /** * Cross-client encoding contract: * info = UTF8("peach-rendezvous-v1") || uint64be(windowIndex) * id = base64url-no-padding(HKDF-SHA256(secret, salt="", info, 32)) */ fun deriveId(secret: ByteArray, windowIndex: Long): String { require(secret.size == 32) { "rendezvous secret must be exactly 32 bytes" } require(windowIndex >= 0) { "window index must be non-negative" } val info = windowInfo + ByteBuffer.allocate(Long.SIZE_BYTES).putLong(windowIndex).array() return Base64.getUrlEncoder().withoutPadding().encodeToString(hkdf(secret, info)) } fun windowIndex(unixSeconds: Long, windowSeconds: Long = WINDOW_SECONDS): Long { require(unixSeconds >= 0) { "unixSeconds must be non-negative" } require(windowSeconds > 0) { "windowSeconds must be positive" } return unixSeconds / windowSeconds } fun waitingIds(secret: ByteArray, unixSeconds: Long): List { val current = windowIndex(unixSeconds) return listOf(current, (current - 1).coerceAtLeast(0)).map { deriveId(secret, it) } } fun dialingIds(secret: ByteArray, unixSeconds: Long): List { val current = windowIndex(unixSeconds) return listOf(current, (current - 1).coerceAtLeast(0), current + 1).map { deriveId(secret, it) } } fun newPairingToken(random: SecureRandom = SecureRandom()): String { val bytes = ByteArray(PAIRING_TOKEN_BYTES) random.nextBytes(bytes) return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes) } fun normalizeRelayUrl(input: String): String { val uri = URI(input.trim()) require(uri.scheme == "wss") { "relay URL must use wss://" } require(!uri.host.isNullOrBlank()) { "relay URL host missing" } require(uri.userInfo == null && uri.rawQuery == null && uri.rawFragment == null) { "relay URL must not contain credentials, query, or fragment" } val path = uri.path.orEmpty().trimEnd('/') return URI("wss", null, uri.host, uri.port, path, null, null).toString().trimEnd('/') } fun buildUrl(relayUrl: String, rendezvousId: String): String { require(rendezvousId.matches(Regex("^[A-Za-z0-9_-]{22,128}$"))) { "invalid rendezvous id" } return "${normalizeRelayUrl(relayUrl)}/r/$rendezvousId" } fun bytesToHex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) } fun secretFromHex(hex: String): ByteArray { require(hex.matches(Regex("^[0-9a-f]{64}$"))) { "rendezvous secret must be 32-byte lowercase hex" } return ByteArray(32) { index -> hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() } } fun bindPairState( device: PairedDevice, handshakeHash: ByteArray, relayUrl: String, ): PairedDevice = device.copy( rendezvousSecretHex = bytesToHex(deriveSecret(handshakeHash)), relayUrl = normalizeRelayUrl(relayUrl), ) private fun hkdf(ikm: ByteArray, info: ByteArray): ByteArray { val generator = HKDFBytesGenerator(SHA256Digest()) generator.init(HKDFParameters(ikm, ByteArray(0), info)) return ByteArray(32).also { generator.generateBytes(it, 0, it.size) } } private fun compareUnsigned(a: ByteArray, b: ByteArray): Int { for (index in a.indices) { val left = a[index].toInt() and 0xff val right = b[index].toInt() and 0xff if (left != right) return left.compareTo(right) } return 0 } } /** * How long a first-pair invitation stays live. Five minutes: long enough to * walk to the other device, short enough that a code does not sit valid in a * pocket. Lived on the retired LAN `CodePairingServer` until relay-only * pairing (docs/sync-lan-retirement.md); it was never LAN-specific. */ const val PAIRING_WINDOW_MS = 5L * 60 * 1000