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
↓ peach-android-e8eabfde.tar.gzatlas at this save
files changed11 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 −0app/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 {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 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 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 )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,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.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 }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+}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)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). */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+}