e8eabfde0283

feat: add rotating relay rendezvous state (v2.3.72)

time
2026-07-26 03:01 UTC · 3 months ago
machine
koh2
agent
codex
session
peach relay sync implementation
parent
78bdfd653a67
11 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/ChirpIdentity.kt+2 −0app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+6 −1app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+4 −0app/src/main/java/com/peachpasswords/sync/PairedDevice.kt+2 −0app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+7 −1app/src/main/java/com/peachpasswords/sync/RelaySettingsStore.kt+25 −1app/src/main/java/com/peachpasswords/sync/Rendezvous.kt+102 −0app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+50 −1app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+13 −0app/src/test/java/com/peachpasswords/sync/RendezvousTest.kt+86 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23710020-        versionName "2.3.71"19+        versionCode 23720020+        versionName "2.3.72"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/ChirpIdentity.kt+2 −0
@@ -14,6 +14,7 @@1414     val longTermPublicKeyHex: String,1515     val signingPublicKeyHex: String,1616     val wsPort: Int? = null,17+    val relayUrl: String = Rendezvous.DEFAULT_RELAY_URL,1718     /**1819      * This device's signed Pro license blob (`PPK-…`), set only when Pro. Sent1920      * in the outbound PAIR_IDENTITY so the peer can activate it after SAS — one@@ -61,6 +62,7 @@6162         // pairing window uses an ephemeral port and then closes, so a peer that6263         // cached that one would never reach us again.6364         wsPort = SyncReconnectListener.SYNC_PORT,65+        relayUrl = RelaySettingsStore.readRelayUrl(context),6466     )6567 }6668 

view file

app/src/main/java/com/peachpasswords/sync/CodePairingServer.kt+6 −1
@@ -212,7 +212,12 @@212212             ws.send(session.encrypt(encodePairIdentityFrame(localIdentity)))213213 214214             val sas = SASDerivation.derive(session.handshakeHash)215-            val device = pairIdentityToPairedDevice(theirIdentity)215+            val device = Rendezvous.bindPairState(216+                device = pairIdentityToPairedDevice(theirIdentity),217+                handshakeHash = session.handshakeHash,218+                // The device displaying the code chooses the mesh relay.219+                relayUrl = localIdentity.relayUrl,220+            )216221             val pending = PairingSession()217222             val name = device.deviceName?.takeIf { it.isNotBlank() } ?: "Unknown device"218223 

view file

app/src/main/java/com/peachpasswords/sync/MdnsPairingServer.kt+4 −0
@@ -214,6 +214,7 @@214214     val longTermPublicKeyHex: String,215215     val signingPublicKeyHex: String?,216216     val wsPort: Int?,217+    val relayUrl: String?,217218 )218219 219220 internal fun encodePairIdentityFrame(id: LocalChirpIdentity): ByteArray {@@ -226,6 +227,7 @@226227     sb.append(",\"long_term_public_key_hex\":\"").append(id.longTermPublicKeyHex).append('"')227228     sb.append(",\"signing_public_key_hex\":\"").append(id.signingPublicKeyHex).append('"')228229     id.wsPort?.let { sb.append(",\"ws_port\":").append(it) }230+    sb.append(",\"relay_url\":\"").append(jsonEscape(id.relayUrl)).append('"')229231     sb.append('}')230232     return sb.toString().toByteArray(StandardCharsets.UTF_8)231233 }@@ -248,6 +250,7 @@248250         longTermPublicKeyHex = ltpk,249251         signingPublicKeyHex = map["signing_public_key_hex"]?.takeIf { it.isNotBlank() },250252         wsPort = map["ws_port"]?.toIntOrNull(),253+        relayUrl = map["relay_url"]?.let(Rendezvous::normalizeRelayUrl),251254     )252255 }253256 @@ -259,6 +262,7 @@259262     signingPublicKeyHex = id.signingPublicKeyHex,260263     trustSource = "direct",261264     wsPort = id.wsPort,265+    relayUrl = id.relayUrl,262266     pairedAt = System.currentTimeMillis(),263267     lastSyncedAt = System.currentTimeMillis(),264268 )

view file

app/src/main/java/com/peachpasswords/sync/PairedDevice.kt+2 −0
@@ -11,6 +11,8 @@1111     val signingPublicKeyHex: String? = null,1212     val trustSource: String? = null,1313     val wsPort: Int? = null,14+    val rendezvousSecretHex: String? = null,15+    val relayUrl: String? = null,1416     val pairedAt: Long,1517     val lastSyncedAt: Long? = null,1618     val lastKnownIp: String? = null,

view file

app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+7 −1
@@ -125,7 +125,13 @@125125             val theirIdentity = parsePairIdentityFrame(session.decrypt(transport.receive()))126126 127127             val sas = SASDerivation.derive(session.handshakeHash)128-            val device = pairIdentityToPairedDevice(theirIdentity)128+            val incoming = pairIdentityToPairedDevice(theirIdentity)129+            val existing = deviceStore.getByPubkey(incoming.longTermPublicKeyHex.orEmpty())130+                ?: deviceStore.get(incoming.deviceId)131+            val device = incoming.copy(132+                rendezvousSecretHex = existing?.rendezvousSecretHex,133+                relayUrl = existing?.relayUrl ?: incoming.relayUrl,134+            )129135             deviceStore.upsert(device)130136 131137             // Encrypted vault sync — real initiator round.

view file

app/src/main/java/com/peachpasswords/sync/RelaySettingsStore.kt+25 −1
@@ -29,8 +29,32 @@2929         prefs.edit().putBoolean(KEY_ENABLED, enabled).apply()3030     }3131 32-    private companion object {32+    fun relayUrl(): String = prefs.getString(KEY_URL, null)33+        ?.let { runCatching { Rendezvous.normalizeRelayUrl(it) }.getOrNull() }34+        ?: Rendezvous.DEFAULT_RELAY_URL35+36+    fun setRelayUrl(input: String): String {37+        val normalized = Rendezvous.normalizeRelayUrl(input)38+        prefs.edit().putString(KEY_URL, normalized).apply()39+        return normalized40+    }41+42+    fun isLanOnly(): Boolean = prefs.getBoolean(KEY_LAN_ONLY, false)43+44+    fun setLanOnly(lanOnly: Boolean) {45+        prefs.edit().putBoolean(KEY_LAN_ONLY, lanOnly).apply()46+    }47+48+    companion object {3349         const val PREFS_FILE = "peach_relay_settings"3450         const val KEY_ENABLED = "relay_enabled"51+        const val KEY_URL = "relay_url"52+        const val KEY_LAN_ONLY = "lan_only"53+54+        fun readRelayUrl(context: Context): String =55+            context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE)56+                .getString(KEY_URL, null)57+                ?.let { runCatching { Rendezvous.normalizeRelayUrl(it) }.getOrNull() }58+                ?: Rendezvous.DEFAULT_RELAY_URL3559     }3660 }

view file

app/src/main/java/com/peachpasswords/sync/Rendezvous.kt+102 −0
@@ -0,0 +1,102 @@1+package com.peachpasswords.sync2+3+import org.bouncycastle.crypto.digests.SHA256Digest4+import org.bouncycastle.crypto.generators.HKDFBytesGenerator5+import org.bouncycastle.crypto.params.HKDFParameters6+import java.net.URI7+import java.nio.ByteBuffer8+import java.security.SecureRandom9+import java.util.Base6410+11+object Rendezvous {12+    const val DEFAULT_RELAY_URL = "wss://relay.peachpasswords.com"13+    const val WINDOW_SECONDS = 3600L14+    const val PAIRING_TOKEN_BYTES = 1615+16+    private val secretInfo = "peach-rendezvous-secret-v1".encodeToByteArray()17+    private val windowInfo = "peach-rendezvous-v1".encodeToByteArray()18+19+    fun deriveSecret(handshakeHash: ByteArray): ByteArray {20+        require(handshakeHash.size == 32) { "handshake hash must be exactly 32 bytes" }21+        return hkdf(handshakeHash, secretInfo)22+    }23+24+    /**25+     * Cross-client encoding contract:26+     * info = UTF8("peach-rendezvous-v1") || uint64be(windowIndex)27+     * id = base64url-no-padding(HKDF-SHA256(secret, salt="", info, 32))28+     */29+    fun deriveId(secret: ByteArray, windowIndex: Long): String {30+        require(secret.size == 32) { "rendezvous secret must be exactly 32 bytes" }31+        require(windowIndex >= 0) { "window index must be non-negative" }32+        val info = windowInfo + ByteBuffer.allocate(Long.SIZE_BYTES).putLong(windowIndex).array()33+        return Base64.getUrlEncoder().withoutPadding().encodeToString(hkdf(secret, info))34+    }35+36+    fun windowIndex(unixSeconds: Long, windowSeconds: Long = WINDOW_SECONDS): Long {37+        require(unixSeconds >= 0) { "unixSeconds must be non-negative" }38+        require(windowSeconds > 0) { "windowSeconds must be positive" }39+        return unixSeconds / windowSeconds40+    }41+42+    fun waitingIds(secret: ByteArray, unixSeconds: Long): List<String> {43+        val current = windowIndex(unixSeconds)44+        return listOf(current, (current - 1).coerceAtLeast(0)).map { deriveId(secret, it) }45+    }46+47+    fun dialingIds(secret: ByteArray, unixSeconds: Long): List<String> {48+        val current = windowIndex(unixSeconds)49+        return listOf(current, (current - 1).coerceAtLeast(0), current + 1).map {50+            deriveId(secret, it)51+        }52+    }53+54+    fun newPairingToken(random: SecureRandom = SecureRandom()): String {55+        val bytes = ByteArray(PAIRING_TOKEN_BYTES)56+        random.nextBytes(bytes)57+        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)58+    }59+60+    fun normalizeRelayUrl(input: String): String {61+        val uri = URI(input.trim())62+        require(uri.scheme == "wss") { "relay URL must use wss://" }63+        require(!uri.host.isNullOrBlank()) { "relay URL host missing" }64+        require(uri.userInfo == null && uri.rawQuery == null && uri.rawFragment == null) {65+            "relay URL must not contain credentials, query, or fragment"66+        }67+        val path = uri.path.orEmpty().trimEnd('/')68+        return URI("wss", null, uri.host, uri.port, path, null, null).toString().trimEnd('/')69+    }70+71+    fun buildUrl(relayUrl: String, rendezvousId: String): String {72+        require(rendezvousId.matches(Regex("^[A-Za-z0-9_-]{22,128}$"))) {73+            "invalid rendezvous id"74+        }75+        return "${normalizeRelayUrl(relayUrl)}/r/$rendezvousId"76+    }77+78+    fun bytesToHex(bytes: ByteArray): String =79+        bytes.joinToString("") { "%02x".format(it) }80+81+    fun secretFromHex(hex: String): ByteArray {82+        require(hex.matches(Regex("^[0-9a-f]{64}$"))) {83+            "rendezvous secret must be 32-byte lowercase hex"84+        }85+        return ByteArray(32) { index -> hex.substring(index * 2, index * 2 + 2).toInt(16).toByte() }86+    }87+88+    fun bindPairState(89+        device: PairedDevice,90+        handshakeHash: ByteArray,91+        relayUrl: String,92+    ): PairedDevice = device.copy(93+        rendezvousSecretHex = bytesToHex(deriveSecret(handshakeHash)),94+        relayUrl = normalizeRelayUrl(relayUrl),95+    )96+97+    private fun hkdf(ikm: ByteArray, info: ByteArray): ByteArray {98+        val generator = HKDFBytesGenerator(SHA256Digest())99+        generator.init(HKDFParameters(ikm, ByteArray(0), info))100+        return ByteArray(32).also { generator.generateBytes(it, 0, it.size) }101+    }102+}

view file

app/src/main/java/com/peachpasswords/ui/settings/SettingsCardBodies.kt+50 −1
@@ -661,11 +661,15 @@661661     val pendingVouched by syncVm.pendingVouched.collectAsState()662662     val syncStatus by syncVm.status.collectAsState()663663     val relayEnabled by syncVm.relayEnabled.collectAsState()664+    val configuredRelayUrl by syncVm.relayUrl.collectAsState()665+    val lanOnly by syncVm.lanOnly.collectAsState()664666     val scope = rememberCoroutineScope()665667     var confirmUnpair by remember { mutableStateOf<String?>(null) }666668     var rotateConfirmOpen by remember { mutableStateOf(false) }667669     var rotating by remember { mutableStateOf(false) }668670     var rotateError by remember { mutableStateOf<String?>(null) }671+    var relayUrlDraft by remember(configuredRelayUrl) { mutableStateOf(configuredRelayUrl) }672+    var relayUrlStatus by remember { mutableStateOf<String?>(null) }669673 670674     SubLabel("PAIRING", fg, top = 0.dp)671675     RowLine(fg) {@@ -734,7 +738,52 @@734738         }735739     }736740 737-    SubLabel("RELAYING", fg)741+    SubLabel("CONNECTION", fg)742+    RowLine(fg) {743+        Column(Modifier.weight(1f)) {744+            Text("LAN only", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)745+            Text(746+                "Disable internet relay fallback. Nearby-device sync still works.",747+                color = fg.copy(alpha = 0.7f), fontFamily = PeachType.Body, fontSize = 11.sp, lineHeight = 16.sp,748+                modifier = Modifier.padding(top = 2.dp),749+            )750+        }751+        Toggle(lanOnly, fg) { syncVm.setLanOnly(it) }752+    }753+    Text(754+        "RELAY URL",755+        color = fg.copy(alpha = 0.6f),756+        fontFamily = PeachType.Mono,757+        fontSize = 8.sp,758+        letterSpacing = 0.22.em,759+    )760+    Box(761+        Modifier.fillMaxWidth().padding(top = 6.dp)762+            .border(1.dp, fg.copy(alpha = 0.3f), RoundedCornerShape(8.dp))763+            .padding(horizontal = 12.dp, vertical = 10.dp),764+    ) {765+        BasicTextField(766+            value = relayUrlDraft,767+            onValueChange = { relayUrlDraft = it; relayUrlStatus = null },768+            singleLine = true,769+            textStyle = TextStyle(fontFamily = PeachType.Mono, fontSize = 11.sp, color = fg),770+            cursorBrush = SolidColor(fg),771+            modifier = Modifier.fillMaxWidth(),772+        )773+    }774+    RowLine(fg) {775+        ValueText(relayUrlStatus ?: "Only secure wss:// relays are accepted.", fg)776+        SubtleButton("save", fg) {777+            relayUrlStatus = if (syncVm.setRelayUrl(relayUrlDraft)) {778+                relayUrlDraft = syncVm.relayUrl.value779+                "Saved"780+            } else {781+                "Use a valid wss:// relay URL"782+            }783+        }784+    }785+786+    SubLabel("RELAYING FOR PEERS", fg)738787     RowLine(fg) {739788         Column(Modifier.weight(1f)) {740789             Text("Relay for other devices", color = fg, fontFamily = PeachType.Body, fontSize = 13.sp)

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+13 −0
@@ -59,10 +59,23 @@5959     /** Whether this phone forwards sync traffic between other paired devices. */6060     private val _relayEnabled = MutableStateFlow(relaySettings.isRelayEnabled())6161     val relayEnabled: StateFlow<Boolean> = _relayEnabled.asStateFlow()62+    private val _relayUrl = MutableStateFlow(relaySettings.relayUrl())63+    val relayUrl: StateFlow<String> = _relayUrl.asStateFlow()64+    private val _lanOnly = MutableStateFlow(relaySettings.isLanOnly())65+    val lanOnly: StateFlow<Boolean> = _lanOnly.asStateFlow()6266 6367     fun setRelayEnabled(on: Boolean) {6468         relaySettings.setRelayEnabled(on)6569         _relayEnabled.value = on70+    }71+72+    fun setRelayUrl(input: String): Boolean = runCatching {73+        _relayUrl.value = relaySettings.setRelayUrl(input)74+    }.isSuccess75+76+    fun setLanOnly(on: Boolean) {77+        relaySettings.setLanOnly(on)78+        _lanOnly.value = on6679     }6780 6881     /** Live list of trusted peers, backing the Sync settings card device list (C7). */

view file

app/src/test/java/com/peachpasswords/sync/RendezvousTest.kt+86 −0
@@ -0,0 +1,86 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertThrows5+import org.junit.Test6+import java.security.SecureRandom7+8+class RendezvousTest {9+    private val handshakeHash = Rendezvous.secretFromHex(10+        "0fbab8cd1782bf4b17d69beb69f5b80b0bf24d93da4924959c76ee701c32e655",11+    )12+13+    @Test fun `matches browser rendezvous KATs`() {14+        val secret = Rendezvous.deriveSecret(handshakeHash)15+        assertEquals(16+            "0803691c8ac7330883c0987e33cea4a171992366ce4b06b88691e84ca689773d",17+            Rendezvous.bytesToHex(secret),18+        )19+        assertEquals(20+            "9UY3j5AHnwYSApvymtY965su9n60rQOv8QZ2PZzHuKI",21+            Rendezvous.deriveId(secret, 500_000),22+        )23+    }24+25+    @Test fun `uses required waiting and dialing window order`() {26+        val secret = Rendezvous.deriveSecret(handshakeHash)27+        val now = 500_000L * 3600 + 359928+        assertEquals(29+            listOf(30+                Rendezvous.deriveId(secret, 500_000),31+                Rendezvous.deriveId(secret, 499_999),32+            ),33+            Rendezvous.waitingIds(secret, now),34+        )35+        assertEquals(36+            listOf(37+                Rendezvous.deriveId(secret, 500_000),38+                Rendezvous.deriveId(secret, 499_999),39+                Rendezvous.deriveId(secret, 500_001),40+            ),41+            Rendezvous.dialingIds(secret, now),42+        )43+    }44+45+    @Test fun `first-pair token is 128-bit base64url`() {46+        val deterministic = object : SecureRandom() {47+            override fun nextBytes(bytes: ByteArray) {48+                bytes.indices.forEach { bytes[it] = it.toByte() }49+            }50+        }51+        assertEquals("AAECAwQFBgcICQoLDA0ODw", Rendezvous.newPairingToken(deterministic))52+    }53+54+    @Test fun `relay URL requires credential-free wss`() {55+        assertEquals(56+            "wss://relay.example.test/base",57+            Rendezvous.normalizeRelayUrl("wss://relay.example.test/base/"),58+        )59+        assertThrows(IllegalArgumentException::class.java) {60+            Rendezvous.normalizeRelayUrl("ws://relay.example.test")61+        }62+        assertThrows(IllegalArgumentException::class.java) {63+            Rendezvous.normalizeRelayUrl("wss://user:[email protected]")64+        }65+    }66+67+    @Test fun `completed pairing persists secret and the listener relay`() {68+        val device = PairedDevice(69+            deviceId = "browser-1",70+            deviceName = "Firefox",71+            deviceType = "extension",72+            longTermPublicKeyHex = "ab".repeat(32),73+            pairedAt = 1L,74+        )75+        val bound = Rendezvous.bindPairState(76+            device,77+            handshakeHash,78+            "wss://relay.example.test/",79+        )80+        assertEquals(81+            "0803691c8ac7330883c0987e33cea4a171992366ce4b06b88691e84ca689773d",82+            bound.rendezvousSecretHex,83+        )84+        assertEquals("wss://relay.example.test", bound.relayUrl)85+    }86+}

view file