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.MessageDigest 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 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) } /** * 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) } } /** * Resolve an opaque rotating capability to paired local state without * exposing public keys in a pre-handshake selector. Invalid/missing legacy * secrets are ignored; callers must fail closed rather than retry NK. */ fun resolvePeer( devices: List, rendezvousId: String, unixSeconds: Long = System.currentTimeMillis() / 1000L, ): PairedDevice? { if (!rendezvousId.matches(Regex("^[A-Za-z0-9_-]{22,128}$"))) return null val presented = rendezvousId.toByteArray(Charsets.US_ASCII) var match: PairedDevice? = null for (device in devices) { val secret = device.rendezvousSecretHex ?.takeIf { it.matches(Regex("^[0-9a-f]{64}$")) } ?.let(::secretFromHex) ?: continue for (candidate in waitingIds(secret, unixSeconds)) { if (MessageDigest.isEqual(candidate.toByteArray(Charsets.US_ASCII), presented)) { match = device } } } return match } 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) } } } /** * 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