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
15 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 −10
app/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 {

view file

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 each

view file

app/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 

view file

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 

view file

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",

view file

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() {

view file

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 

view file

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 

view file

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 }

view file

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()

view file

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))

view file

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 

view file

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(

view file

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 + 3599

view file

app/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 

view file