1b935547ec0d
feat: Add deterministic full-mesh sync (v2.8.0)
- time
- 2026-07-28 22:20 UTC · 2 months ago
- machine
- koh2
- agent
- gpt-5
- session
- deterministic full mesh
- parent
- a34938c7cc39
↓ peach-android-1b935547.tar.gzatlas at this save
files changed15 files
app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/CloudRelayDialer.kt+3 −4app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+18 −10app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+8 −4app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+6 −3app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+5 −13app/src/main/java/com/peachpasswords/sync/Rendezvous.kt+31 −0app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+1 −0app/src/main/java/com/peachpasswords/sync/SyncPeerRole.kt+4 −10app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+95 −62app/src/test/java/com/peachpasswords/CrossPlatformKATTest.kt+6 −4app/src/test/java/com/peachpasswords/sync/CloudRelayDialerTest.kt+2 −2app/src/test/java/com/peachpasswords/sync/ForegroundSyncCoordinatorTest.kt+2 −2app/src/test/java/com/peachpasswords/sync/RendezvousTest.kt+14 −0app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+21 −10app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616 applicationId "com.peachpasswords"1717 minSdk 261818 targetSdk 3519- versionCode 24014120- versionName "2.7.14"19+ versionCode 24014220+ versionName "2.8.0"2121 2222 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323 vectorDrawables {app/src/main/java/com/peachpasswords/sync/CloudRelayDialer.kt+3 −4
@@ -7,10 +7,9 @@77 * One relayed reconnect session where WE speak first — the mirror of88 * [runCloudRelayResponderSession], and the half Android never had.99 *10- * Until this existed the phone could only ever wait, which was fine against a11- * browser extension (it can only dial) and a deadlock against another phone:12- * two waiters in the same room, nobody opening. See [weDialPeer] for how the13- * role is assigned.10+ * All device types use the same [weDialPeer] public-key ordering, so exactly11+ * one endpoint speaks first even for extension-to-extension and phone-to-phone12+ * edges.1413 *1514 * The wire is the extension's, byte for byte: an unencrypted `PEACH_RECONNECT`1615 * selector naming the room, then Noise KK, then a padded PAIR_IDENTITY eachapp/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+18 −10
@@ -142,9 +142,10 @@142142 * Cloudflare relay is only a splice, so being in the room is all either side143143 * does at the transport level — what differs is who opens the conversation.144144 *145- * Toward a browser extension we wait, because an extension can only ever dial.146- * Toward another phone or tablet the role comes from [weDialPeer], because two147- * waiters in one room is a deadlock and was why phone-to-phone never synced.145+ * Every peer type uses [weDialPeer]: the lower long-term key dials and the146+ * higher key waits. Waiting peers hold current/previous rooms concurrently;147+ * diallers try current/previous/next sequentially so a clock-skew allowance148+ * does not triple every peer's simultaneous relay connections.148149 */149150 @Singleton150151 class CloudRelayReconnectListener @Inject constructor(@@ -304,8 +305,11 @@304305 val ids =305306 if (weDial) Rendezvous.dialingIds(secret, now)306307 else Rendezvous.waitingIds(secret, now)307- for (rendezvousId in ids) {308- val key = "${device.deviceId}|$relayUrl|$rendezvousId"308+ val targetIds = if (weDial) listOf(ids) else ids.map(::listOf)309+ for (rendezvousIds in targetIds) {310+ val key =311+ "${device.deviceId}|$relayUrl|${if (weDial) "dial" else "wait"}|" +312+ rendezvousIds.joinToString(",")309313 put(310314 key,311315 RelayWaitTarget(@@ -314,7 +318,7 @@314318 deviceType = device.deviceType,315319 trustSource = device.trustSource,316320 relayUrl = relayUrl,317- rendezvousId = rendezvousId,321+ rendezvousIds = rendezvousIds,318322 weDial = weDial,319323 ),320324 )@@ -333,7 +337,11 @@333337 watchers[key] = scope.launch {334338 var retryDelayMs =335339 if (target.weDial) DIAL_RETRY_DELAY_MS else RETRY_DELAY_MS340+ var rendezvousIndex = 0336341 while (isActive) {342+ val rendezvousId =343+ target.rendezvousIds[rendezvousIndex % target.rendezvousIds.size]344+ if (target.weDial) rendezvousIndex += 1337345 val result = runCatching {338346 val known = deviceStore.get(target.deviceId)339347 ?: throw IllegalStateException("relay peer was unpaired")@@ -345,7 +353,7 @@345353 )346354 val socket = try {347355 OkHttpRelaySocket.connect(348- Rendezvous.buildUrl(target.relayUrl, target.rendezvousId),356+ Rendezvous.buildUrl(target.relayUrl, rendezvousId),349357 )350358 } catch (error: Throwable) {351359 PeachSyncLog.w(@@ -368,7 +376,7 @@368376 runCloudRelayInitiatorSession(369377 socket = socket,370378 known = known,371- rendezvousId = target.rendezvousId,379+ rendezvousId = rendezvousId,372380 ourStaticPrivateKey = privateKey,373381 ourStaticPublicKey = publicKey,374382 localIdentity = localIdentity,@@ -388,7 +396,7 @@388396 runCloudRelayResponderSession(389397 socket = socket,390398 known = known,391- registeredRendezvousId = target.rendezvousId,399+ registeredRendezvousId = rendezvousId,392400 ourStaticPrivateKey = privateKey,393401 ourStaticPublicKey = publicKey,394402 localIdentity = localIdentity,@@ -434,7 +442,7 @@434442 val deviceType: String?,435443 val trustSource: String?,436444 val relayUrl: String,437- val rendezvousId: String,445+ val rendezvousIds: List<String>,438446 val weDial: Boolean,439447 )440448 app/src/main/java/com/peachpasswords/sync/DeviceManifestSupport.kt+8 −4
@@ -4,10 +4,8 @@44 import kotlinx.serialization.json.Json55 import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters66 7-/** 0x02 added the `revoked` array to the signed body. Coordinated wire change8- * with peach-browser — the canonical signing input differs, so a 0x01 peer and9- * a 0x02 peer cannot verify each other's manifests. */10-const val DEVICE_MANIFEST_VERSION = 0x027+/** 0x03 adds relay routing to each introduced peer edge. */8+const val DEVICE_MANIFEST_VERSION = 0x03119 private const val DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000L1210 internal const val NO_KEY_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000"1311 @@ -105,6 +103,7 @@105103 longTermPublicKeyHex = longTerm,106104 signingPublicKeyHex = signing,107105 wsPort = wsPort,106+ relayUrl = relayUrl,108107 pairedAt = pairedAt,109108 )110109 }@@ -114,6 +113,10 @@114113 if (entry.deviceType !in setOf("extension", "android", "ios")) return null115114 if (!isHex(entry.longTermPublicKeyHex, 64) || !isHex(entry.signingPublicKeyHex, 64)) return null116115 if (entry.wsPort != null && entry.wsPort !in 1..65_535) return null116+ if (117+ entry.relayUrl != null &&118+ runCatching { Rendezvous.normalizeRelayUrl(entry.relayUrl) }.isFailure119+ ) return null117120 return entry.copy(118121 longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),119122 signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),@@ -146,6 +149,7 @@146149 "signingPublicKeyHex" to normalised.signingPublicKeyHex.lowercase(),147150 )148151 normalised.wsPort?.let { map["wsPort"] = it }152+ normalised.relayUrl?.let { map["relayUrl"] = it }149153 return map150154 }151155 app/src/main/java/com/peachpasswords/sync/ForegroundSyncCoordinator.kt+6 −3
@@ -30,8 +30,8 @@3030 * Coming to the foreground means "make sure we are in our rooms", not "go3131 * and sync now" — the service holds a socket in each peer's rendezvous3232 * room and the role there decides who opens the conversation33- * ([weDialPeer]). Toward a browser extension we wait, because an extension34- * cannot hold a room open; toward another phone the lower key dials.33+ * ([weDialPeer]). The lower long-term public key dials and the higher key34+ * waits, for every supported device type.3535 * See docs/sync-lan-retirement.md.3636 */3737 fun triggerForegroundSync() {@@ -54,7 +54,10 @@5454 5555 internal fun foregroundSyncPeers(devices: List<PairedDevice>): List<PairedDevice> =5656 devices.filter { device ->57- val trusted = device.trustSource == null || device.trustSource == "direct"57+ val trusted =58+ device.trustSource == null ||59+ device.trustSource == "direct" ||60+ device.trustSource == "vouched"5861 val syncCapable = when (device.deviceType) {5962 null,6063 "android",app/src/main/java/com/peachpasswords/sync/PeachSyncService.kt+5 −13
@@ -26,21 +26,13 @@2626 import javax.inject.Inject2727 2828 /**29- * Keeps this device reachable on the relay so an already-paired peer can30- * reconnect. A browser extension can never listen, so if the phone is not31- * registered the two devices simply never sync and neither shows an error —32- * "no connection" and "nothing to sync" look identical.29+ * Keeps this device reachable on its deterministic relay waiter edges and30+ * drives its dialer edges. Every supported client now uses the same public-key31+ * role ordering, but Android still needs a foreground service: Doze and App32+ * Standby will take an ordinary background socket away.3333 *34- * A browser extension can never listen for a connection, so the phone has to be35- * the side that is dialable — and a phone that is only dialable while its app36- * is foregrounded is not dialable in any useful sense. Doze and App Standby37- * will take an ordinary background socket away. This is the same standard38- * pattern Syncthing and Tailscale use for exactly the same reason: a foreground39- * service with a visible, honest notification.40- *4134 * The notification is deliberately plain about what it is. A persistent42- * notification the user does not understand gets the app force-stopped, which43- * is the one outcome that breaks sync entirely.35+ * notification the user does not understand gets the app force-stopped.4436 */4537 @AndroidEntryPoint4638 class PeachSyncService : Service() {app/src/main/java/com/peachpasswords/sync/Rendezvous.kt+31 −0
@@ -15,6 +15,7 @@1515 const val PAIRING_TOKEN_BYTES = 161616 1717 private val secretInfo = "peach-rendezvous-secret-v1".encodeToByteArray()18+ private val meshSecretInfo = "peach-mesh-rendezvous-v1".encodeToByteArray()1819 private val windowInfo = "peach-rendezvous-v1".encodeToByteArray()1920 2021 fun deriveSecret(handshakeHash: ByteArray): ByteArray {@@ -23,6 +24,27 @@2324 }2425 2526 /**27+ * Cross-client manifest-introduced mesh capability:28+ * info = UTF8("peach-mesh-rendezvous-v1") || lowPublicKey || highPublicKey29+ * secret = HKDF-SHA256(vaultRoot, salt="", info, 32)30+ */31+ fun deriveMeshSecret(32+ vaultRoot: ByteArray,33+ ourPublicKey: ByteArray,34+ peerPublicKey: ByteArray,35+ ): ByteArray {36+ require(vaultRoot.size == 32) { "vault root must be exactly 32 bytes" }37+ require(ourPublicKey.size == 32 && peerPublicKey.size == 32) {38+ "mesh public keys must be exactly 32 bytes"39+ }40+ val order = compareUnsigned(ourPublicKey, peerPublicKey)41+ require(order != 0) { "mesh peers must have distinct public keys" }42+ val low = if (order < 0) ourPublicKey else peerPublicKey43+ val high = if (order < 0) peerPublicKey else ourPublicKey44+ return hkdf(vaultRoot, meshSecretInfo + low + high)45+ }46+47+ /**2648 * Cross-client encoding contract:2749 * info = UTF8("peach-rendezvous-v1") || uint64be(windowIndex)2850 * id = base64url-no-padding(HKDF-SHA256(secret, salt="", info, 32))@@ -126,6 +148,15 @@126148 val generator = HKDFBytesGenerator(SHA256Digest())127149 generator.init(HKDFParameters(ikm, ByteArray(0), info))128150 return ByteArray(32).also { generator.generateBytes(it, 0, it.size) }151+ }152+153+ private fun compareUnsigned(a: ByteArray, b: ByteArray): Int {154+ for (index in a.indices) {155+ val left = a[index].toInt() and 0xff156+ val right = b[index].toInt() and 0xff157+ if (left != right) return left.compareTo(right)158+ }159+ return 0129160 }130161 }131162 app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+1 −0
@@ -64,6 +64,7 @@6464 val longTermPublicKeyHex: String,6565 val signingPublicKeyHex: String,6666 val wsPort: Int? = null,67+ val relayUrl: String? = null,6768 val pairedAt: Long,6869 )6970 app/src/main/java/com/peachpasswords/sync/SyncPeerRole.kt+4 −10
@@ -9,22 +9,16 @@99 * secret, so the role has to be derivable from state both already hold —1010 * there is nothing on the wire to negotiate it with.1111 *12- * Two rules, in order:12+ * The lower long-term public key dials and the higher key waits, independent13+ * of device type. Browser service workers keep their waiter WebSocket alive14+ * with application liveness frames, so extension-to-extension edges no longer15+ * need a native device to act as the permanent listener.1316 *14- * 1. A browser extension can never hold a room open, so it always dials.15- * Toward an extension peer we therefore always wait.16- * 2. Otherwise — phone to phone, phone to tablet — the lower long-term public17- * key dials. Comparing the keys is symmetric by construction: each side18- * compares the same two values and reaches the opposite conclusion, so the19- * roles can never collide (both dialling, or both waiting, which is what20- * left phone-to-phone with nobody dialling).21- *2217 * The keys are hex of the same length, so an ordinary lexicographic compare is2318 * a total order over them. Ties are impossible: two devices sharing a static2419 * key are the same device.2520 */2621 internal fun weDialPeer(ourLongTermPublicKeyHex: String, peer: PairedDevice): Boolean {27- if (peer.deviceType == "extension") return false2822 val theirs = peer.longTermPublicKeyHex ?: return false2923 return ourLongTermPublicKeyHex.compareTo(theirs) < 03024 }app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+95 −62
@@ -132,80 +132,113 @@132132 // wrong: it ignored manifests from other phones/tablets, so mesh trust133133 // could never propagate between native devices (the whole point of the134134 // mesh now that phones are the listening side), and it never checked135- // trustSource at all — so a merely VOUCHED device could introduce136- // further devices, chaining trust transitively in a way the browser137- // deliberately refuses.135+ // trustSource at all — so a VOUCHED device could introduce further136+ // devices, chaining trust transitively. A direct issuer's valid v3137+ // routes are auto-admitted; vouched peers remain unable to vouch onward.138138 if (issuer.trustSource != "direct") return139139 val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return140140 val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return141141 if (body.issuerDeviceId != issuer.deviceId) return142+ val master = requireMasterKey()143+ val localKeys = deriveSyncIdentityKeypair(master, ourDeviceId)142144 143- // Revocations are absorbed BEFORE any vouching, so a manifest that both144- // revokes and re-vouches the same device cannot race itself. A peer may145- // not revoke US — that would let one compromised device sever the whole146- // mesh — so our own id is filtered out first.147- val newlyRevoked = registry.mergeRevocations(148- body.revoked.filterNot { it.deviceId == ourDeviceId },149- )150- for (deviceId in newlyRevoked) {151- registry.remove(deviceId)152- pending.remove(deviceId)153- syncAlerts?.record(154- listOf(155- SyncAlert(156- type = SyncAlertType.DEVICE_REVOKED,157- deviceName = issuer.deviceName ?: "paired device",158- entryTitle = deviceId,159- timestamp = System.currentTimeMillis(),160- ),161- ),145+ try {146+ // Revocations are absorbed BEFORE any vouching, so a manifest that both147+ // revokes and re-vouches the same device cannot race itself. A peer may148+ // not revoke US — that would let one compromised device sever the whole149+ // mesh — so our own id is filtered out first.150+ val newlyRevoked = registry.mergeRevocations(151+ body.revoked.filterNot { it.deviceId == ourDeviceId },162152 )163- }164- for (entry in body.devices) {165- if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue166- // Terminal: no manifest re-admits a revoked device. Matched on the167- // key as well as the id, because the id is the issuer's to choose —168- // checking only the id let a revoked key walk back in under a fresh169- // one. The check sits ahead of the existing-device lookup so it also170- // refuses rotating a device we still trust onto a revoked key.171- if (registry.isRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue172- val existing = registry.get(entry.deviceId)173- if (existing == null) {174- pending.upsert(175- PendingVouchedDevice(176- deviceId = entry.deviceId,177- deviceName = entry.deviceName,178- deviceType = entry.deviceType,179- longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),180- signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),181- wsPort = entry.wsPort,182- pairedAt = entry.pairedAt,183- receivedAt = System.currentTimeMillis(),184- vouchedByDeviceId = issuer.deviceId,153+ for (deviceId in newlyRevoked) {154+ registry.remove(deviceId)155+ pending.remove(deviceId)156+ syncAlerts?.record(157+ listOf(158+ SyncAlert(159+ type = SyncAlertType.DEVICE_REVOKED,160+ deviceName = issuer.deviceName ?: "paired device",161+ entryTitle = deviceId,162+ timestamp = System.currentTimeMillis(),163+ ),185164 ),186165 )187- } else {188- deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let { alert ->189- syncAlerts?.record(listOf(alert))166+ }167+ for (entry in body.devices) {168+ if (entry.deviceId == ourDeviceId || entry.deviceId == issuer.deviceId) continue169+ if (registry.isRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue170+ val existing = registry.get(entry.deviceId)171+ val meshRoute = deriveManifestMeshRoute(master, localKeys.publicKey, entry)172+ if (existing == null) {173+ if (meshRoute == null) continue174+ registry.upsert(175+ PairedDevice(176+ deviceId = entry.deviceId,177+ deviceName = entry.deviceName,178+ deviceType = entry.deviceType,179+ longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),180+ signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),181+ trustSource = "vouched",182+ wsPort = entry.wsPort,183+ rendezvousSecretHex = meshRoute.first,184+ relayUrl = meshRoute.second,185+ pairedAt = entry.pairedAt,186+ ),187+ )188+ pending.remove(entry.deviceId)189+ } else {190+ deviceKeyRotationAlert(existing, entry, System.currentTimeMillis())?.let { alert ->191+ syncAlerts?.record(listOf(alert))192+ }193+ registry.upsert(194+ existing.copy(195+ deviceName = existing.deviceName?.takeIf { it.isNotBlank() }196+ ?: entry.deviceName,197+ deviceType = entry.deviceType,198+ longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),199+ signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),200+ wsPort = existing.wsPort ?: entry.wsPort,201+ rendezvousSecretHex =202+ if (existing.trustSource == "vouched" && meshRoute != null) {203+ meshRoute.first204+ } else {205+ existing.rendezvousSecretHex206+ },207+ relayUrl =208+ if (existing.trustSource == "vouched" && meshRoute != null) {209+ meshRoute.second210+ } else {211+ existing.relayUrl212+ },213+ pairedAt = existing.pairedAt,214+ trustSource = existing.trustSource ?: "direct",215+ ),216+ )190217 }191- // S5: keys and ports propagate through the mesh, the name does192- // not. A device names itself at pairing; letting a third-party193- // issuer rewrite that is the rename-then-impersonate vector.194- registry.upsert(195- existing.copy(196- deviceName = existing.deviceName?.takeIf { it.isNotBlank() }197- ?: entry.deviceName,198- deviceType = entry.deviceType,199- longTermPublicKeyHex = entry.longTermPublicKeyHex.lowercase(),200- signingPublicKeyHex = entry.signingPublicKeyHex.lowercase(),201- wsPort = existing.wsPort ?: entry.wsPort,202- pairedAt = existing.pairedAt,203- trustSource = existing.trustSource ?: "direct",204- ),205- )206218 }219+ } finally {220+ master.fill(0)221+ localKeys.secretKey.fill(0)207222 }208223 }224+225+ private fun deriveManifestMeshRoute(226+ masterKey: ByteArray,227+ localPublicKey: ByteArray,228+ entry: ManifestDeviceEntry,229+ ): Pair<String, String>? = runCatching {230+ val relayUrl = Rendezvous.normalizeRelayUrl(entry.relayUrl ?: return null)231+ val secret = Rendezvous.deriveMeshSecret(232+ masterKey,233+ localPublicKey,234+ hexToPubkey(entry.longTermPublicKeyHex),235+ )236+ try {237+ Rendezvous.bytesToHex(secret) to relayUrl238+ } finally {239+ secret.fill(0)240+ }241+ }.getOrNull()209242 210243 private fun requireMasterKey(): ByteArray =211244 repo.syncMasterKey() ?: throw VaultLockedException()app/src/test/java/com/peachpasswords/CrossPlatformKATTest.kt+6 −4
@@ -62,13 +62,13 @@6262 assertEquals("ae0ec54041c9ba3e20c0c2d33bdeb2552f3b9a3fb4be3286ec0ef0a61be2fc9c", computeVaultId(masterKey))6363 }6464 65- @Test fun device_manifest_v2_canonical_form_vector() {65+ @Test fun device_manifest_v3_canonical_form_vector() {6666 // Pinned byte-for-byte against peach-browser's canonicalise() in6767 // device-manifest.test.ts. If either side reorders a key, renames a6868 // field, or changes number formatting, this string moves and the two6969 // stop verifying each other's signatures.7070 val body = DeviceManifestBody(71- manifestVersion = 2,71+ manifestVersion = 3,7272 issuerDeviceId = "issuer-1",7373 issuedAt = 1_700_000_000_000L,7474 devices = listOf(@@ -79,6 +79,7 @@7979 longTermPublicKeyHex = "aa".repeat(32),8080 signingPublicKeyHex = "bb".repeat(32),8181 wsPort = 49142,82+ relayUrl = "wss://relay.peachpasswords.com",8283 pairedAt = 1_700_000_000_001L,8384 ),8485 ),@@ -93,9 +94,10 @@9394 )9495 val expected = "{\"devices\":[{\"deviceId\":\"peer-1\",\"deviceName\":\"Peer One\"," +9596 "\"deviceType\":\"android\",\"longTermPublicKeyHex\":\"" + "aa".repeat(32) + "\"," +96- "\"pairedAt\":1700000000001,\"signingPublicKeyHex\":\"" + "bb".repeat(32) + "\"," +97+ "\"pairedAt\":1700000000001,\"relayUrl\":\"wss://relay.peachpasswords.com\"," +98+ "\"signingPublicKeyHex\":\"" + "bb".repeat(32) + "\"," +9799 "\"wsPort\":49142}],\"issuedAt\":1700000000000,\"issuerDeviceId\":\"issuer-1\"," +98- "\"manifestVersion\":2,\"revoked\":[{\"deviceId\":\"gone-1\"," +100+ "\"manifestVersion\":3,\"revoked\":[{\"deviceId\":\"gone-1\"," +99101 "\"longTermPublicKeyHex\":\"" + "cc".repeat(32) + "\",\"revokedAt\":1700000000002," +100102 "\"revokedByDeviceId\":\"issuer-1\"}]}"101103 assertEquals(expected, canonicalDeviceManifestBody(body))app/src/test/java/com/peachpasswords/sync/CloudRelayDialerTest.kt+2 −2
@@ -246,10 +246,10 @@246246 )247247 248248 @Test249- fun `we never dial a browser extension, whichever key is lower`() {249+ fun `extension peers use the same deterministic key ordering`() {250250 val low = "aa".repeat(32)251251 val high = "bb".repeat(32)252- assertFalse(weDialPeer(low, peer("extension", high)))252+ assertTrue(weDialPeer(low, peer("extension", high)))253253 assertFalse(weDialPeer(high, peer("extension", low)))254254 }255255 app/src/test/java/com/peachpasswords/sync/ForegroundSyncCoordinatorTest.kt+2 −2
@@ -4,7 +4,7 @@44 import org.junit.Test55 66 class ForegroundSyncCoordinatorTest {7- @Test fun foreground_sync_targets_direct_sync_capable_devices_only() {7+ @Test fun foreground_sync_targets_direct_and_vouched_sync_capable_devices() {88 val devices = listOf(99 peer("direct-android", type = "android", trust = "direct"),1010 peer("legacy-null-trust", type = "extension", trust = null),@@ -14,7 +14,7 @@1414 1515 val eligible = foregroundSyncPeers(devices).map { it.deviceId }1616 17- assertEquals(listOf("direct-android", "legacy-null-trust"), eligible)17+ assertEquals(listOf("direct-android", "legacy-null-trust", "vouched"), eligible)1818 }1919 2020 private fun peer(id: String, type: String?, trust: String?) = PairedDevice(app/src/test/java/com/peachpasswords/sync/RendezvousTest.kt+14 −0
@@ -24,6 +24,20 @@2424 )2525 }2626 27+ @Test fun `matches browser symmetric mesh capability KAT`() {28+ val root = ByteArray(32) { it.toByte() }29+ val low = ByteArray(32) { (it + 32).toByte() }30+ val high = ByteArray(32) { (it + 64).toByte() }31+ val forward = Rendezvous.deriveMeshSecret(root, low, high)32+ val reverse = Rendezvous.deriveMeshSecret(root, high, low)33+34+ assertEquals(35+ "ad0359be7dc8fbb7df19ed027354b5029133a23a6023f43c2b3500b43addae7b",36+ Rendezvous.bytesToHex(forward),37+ )38+ assertEquals(Rendezvous.bytesToHex(forward), Rendezvous.bytesToHex(reverse))39+ }40+2741 @Test fun `uses required waiting and dialing window order`() {2842 val secret = Rendezvous.deriveSecret(handshakeHash)2943 val now = 500_000L * 3600 + 3599app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+21 −10
@@ -34,6 +34,7 @@3434 }3535 3636 class VaultSyncAdapterTest {37+ private val meshRelay = "wss://relay.peachpasswords.com"3738 private class FakeDeviceRegistry(3839 devices: List<PairedDevice> = emptyList(),3940 ) : PairedDeviceRegistry {@@ -274,7 +275,7 @@274275 master.fill(0)275276 }276277 277- @Test fun incoming_extension_manifest_queues_unknown_vouched_devices_until_confirmed() = runBlocking {278+ @Test fun incoming_extension_manifest_auto_admits_routable_same_vault_peer() = runBlocking {278279 val repo = VaultRepository(FakeStorage())279280 repo.createVault("correct horse")280281 val master = repo.syncMasterKey()!!@@ -303,6 +304,7 @@303304 deviceType = "android",304305 longTermPublicKeyHex = "aa".repeat(32),305306 signingPublicKeyHex = "bb".repeat(32),307+ relayUrl = meshRelay,306308 pairedAt = 44L,307309 ),308310 ),@@ -311,10 +313,11 @@311313 312314 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })313315 314- assertNull(paired.get("new-phone"))315- assertEquals(1, pending.devices.size)316- assertEquals("new-phone", pending.devices.single().deviceId)317- assertEquals("vouched target stays sync-inert until UI confirm", emptyList<PairedDevice>(), paired.getAll().filter { it.deviceId == "new-phone" })316+ val admitted = paired.get("new-phone")317+ assertEquals("vouched", admitted?.trustSource)318+ assertEquals(meshRelay, admitted?.relayUrl)319+ assertTrue(admitted?.rendezvousSecretHex?.matches(Regex("[0-9a-f]{64}")) == true)320+ assertTrue(pending.devices.isEmpty())318321 master.fill(0)319322 }320323 /**@@ -353,6 +356,7 @@353356 deviceType = "extension",354357 longTermPublicKeyHex = "aa".repeat(32),355358 signingPublicKeyHex = "bb".repeat(32),359+ relayUrl = meshRelay,356360 pairedAt = 44L,357361 ),358362 ),@@ -361,8 +365,9 @@361365 362366 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })363367 364- assertEquals(1, pending.devices.size)365- assertEquals("laptop-b", pending.devices.single().deviceId)368+ assertEquals("vouched", paired.get("laptop-b")?.trustSource)369+ assertEquals(meshRelay, paired.get("laptop-b")?.relayUrl)370+ assertTrue(pending.devices.isEmpty())366371 master.fill(0)367372 }368373 @@ -400,6 +405,7 @@400405 deviceType = "extension",401406 longTermPublicKeyHex = "aa".repeat(32),402407 signingPublicKeyHex = "bb".repeat(32),408+ relayUrl = meshRelay,403409 pairedAt = 44L,404410 ),405411 ),@@ -455,6 +461,7 @@455461 deviceType = "extension",456462 longTermPublicKeyHex = "aa".repeat(32),457463 signingPublicKeyHex = "bb".repeat(32),464+ relayUrl = meshRelay,458465 pairedAt = 44L,459466 ),460467 ),@@ -466,10 +473,11 @@466473 assertEquals(0, pending.devices.size)467474 468475 // Control: the same manifest over the claimed issuer's OWN session does469- // queue the device. Without it the assertion above could pass for any476+ // admit the device. Without it the assertion above could pass for any470477 // reason — a malformed body, a bad signature, a dead code path.471478 adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })472- assertEquals(listOf("smuggled-device"), pending.devices.map { it.deviceId })479+ assertEquals("vouched", paired.get("smuggled-device")?.trustSource)480+ assertTrue(pending.devices.isEmpty())473481 master.fill(0)474482 }475483 @@ -534,6 +542,7 @@534542 deviceType = "extension",535543 longTermPublicKeyHex = stolenKey,536544 signingPublicKeyHex = "ee".repeat(32),545+ relayUrl = meshRelay,537546 pairedAt = System.currentTimeMillis(),538547 ),539548 ),@@ -556,6 +565,7 @@556565 deviceType = "extension",557566 longTermPublicKeyHex = "99".repeat(32),558567 signingPublicKeyHex = "ee".repeat(32),568+ relayUrl = meshRelay,559569 pairedAt = System.currentTimeMillis(),560570 ),561571 ),@@ -563,7 +573,8 @@563573 ),564574 ByteArray(32) { 0x11 },565575 )566- assertEquals(listOf("stolen-laptop-mk2"), pending.devices.map { it.deviceId })576+ assertEquals("vouched", paired.get("stolen-laptop-mk2")?.trustSource)577+ assertTrue(pending.devices.isEmpty())567578 master.fill(0)568579 }569580