65ac906cdbe1

trust device manifests from any directly paired issuer and try a peer's cached address before mDNS

time
2026-07-25 08:06 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
mesh trust and reconnect cache
parent
9970711d78f6
4 files app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+19 −1app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+10 −1app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+21 −1app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+94 −0
app/src/main/java/com/peachpasswords/sync/PairingDiscoveryClient.kt+19 −1
@@ -149,13 +149,31 @@149149         }150150     }151151 152-    /** Discover then dial the first peer whose deviceId matches [deviceId]. */152+    /**153+     * Dial a known peer: the cached address first, discovery only as a fallback.154+     *155+     * [cached] is the peer's last-known endpoint, refreshed by the listener on156+     * every inbound reconnect. Trying it first is the "silent reconnect" the157+     * sync design asks for (§6) — this used to go straight to mDNS and report158+     * NOT_FOUND after a 5s timeout, so a peer that was perfectly reachable at159+     * its cached address failed to sync whenever discovery was unavailable,160+     * which on a home network is often.161+     *162+     * The cached attempt gets a short timeout: a stale address is the normal163+     * case after a network change, and it must not delay the fallback.164+     */153165     suspend fun syncWithKnownDevice(154166         deviceId: String,155167         localIdentity: LocalChirpIdentity,156168         vaultFactory: () -> ChirpSyncVaultAdapter?,157169         discoveryTimeoutMs: Long = 5_000L,170+        cached: DiscoveredPeer? = null,171+        cachedTimeoutMs: Int = 2_000,158172     ): PairingResult {173+        if (cached != null) {174+            val viaCache = connectAndSync(cached, localIdentity, vaultFactory, connectTimeoutMs = cachedTimeoutMs)175+            if (viaCache is PairingResult.Success) return viaCache176+        }159177         val peer = withTimeoutOrNull(discoveryTimeoutMs) {160178             discoverPeers().first { it.deviceId == deviceId }161179         } ?: return PairingResult.Error(PairingError.NOT_FOUND)

view file

app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+10 −1
@@ -130,7 +130,16 @@130130         val pending = pendingVouched ?: return131131         val issuerId = manifest.body.issuerDeviceId132132         val issuer = registry.get(issuerId) ?: return133-        if (issuer.deviceType != "extension") return133+        // Match the browser (`applyVerifiedPeerManifest` in sync-vault-adapter.ts):134+        // trust a manifest only from a DIRECTLY paired issuer. This previously135+        // keyed on deviceType == "extension" instead, which got both halves136+        // wrong: it ignored manifests from other phones/tablets, so mesh trust137+        // could never propagate between native devices (the whole point of the138+        // mesh now that phones are the listening side), and it never checked139+        // trustSource at all — so a merely VOUCHED device could introduce140+        // further devices, chaining trust transitively in a way the browser141+        // deliberately refuses.142+        if (issuer.trustSource != "direct") return134143         val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return135144         val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return136145         if (body.issuerDeviceId != issuer.deviceId) return

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+21 −1
@@ -8,6 +8,9 @@88 import com.peachpasswords.sync.PairedDeviceStore99 import com.peachpasswords.sync.PendingVouchedDevice1010 import com.peachpasswords.sync.PendingVouchedDeviceStore11+import com.peachpasswords.sync.DiscoveredPeer12+import com.peachpasswords.sync.SyncReconnectListener13+import com.peachpasswords.sync.hexToPubkey1114 import com.peachpasswords.sync.PairingDiscoveryClient1215 import com.peachpasswords.sync.RelaySettingsStore1316 import com.peachpasswords.sync.SyncEndpoint@@ -144,7 +147,24 @@144147             try {145148                 val identity = buildLocalIdentity(context, master)146149                 peers.forEach { dev ->147-                    runCatching { discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter) }150+                    // The listener refreshes lastKnownIp on every inbound151+                    // reconnect, so this is usually still valid and saves a152+                    // discovery round — and works when mDNS does not.153+                    val cached = dev.lastKnownIp?.let { ip ->154+                        DiscoveredPeer(155+                            deviceId = dev.deviceId,156+                            deviceName = dev.deviceName ?: dev.deviceId,157+                            host = ip,158+                            port = dev.wsPort ?: SyncReconnectListener.SYNC_PORT,159+                            remoteStaticPublicKey = dev.longTermPublicKeyHex160+                                ?.takeIf { it.length == 64 }161+                                ?.let(::hexToPubkey)162+                                ?: return@forEach,163+                        )164+                    }165+                    runCatching {166+                        discoveryClient.syncWithKnownDevice(dev.deviceId, identity, ::buildAdapter, cached = cached)167+                    }148168                 }149169                 _status.value = SyncStatus.LastSynced(System.currentTimeMillis())150170             } catch (e: Exception) {

view file

app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+94 −0
@@ -194,4 +194,98 @@194194         assertEquals("vouched target stays sync-inert until UI confirm", emptyList<PairedDevice>(), paired.getAll().filter { it.deviceId == "new-phone" })195195         master.fill(0)196196     }197+    /**198+     * Browser parity (`applyVerifiedPeerManifest`): trust is keyed on the issuer199+     * being DIRECTLY paired, not on it being a browser. Android used to require200+     * deviceType == "extension", which silently killed mesh propagation between201+     * native devices — the case the mesh exists for now that phones are the202+     * listening side.203+     */204+    @Test fun `a manifest from a directly paired phone is accepted`() = runBlocking {205+        val repo = VaultRepository(FakeStorage())206+        repo.createVault("correct horse")207+        val master = repo.syncMasterKey()!!208+        val issuerSigning = deriveSyncSigningKeypair(master, "phone-issuer")209+        val paired = FakeDeviceRegistry(210+            listOf(211+                PairedDevice(212+                    deviceId = "phone-issuer",213+                    deviceName = "Other Phone",214+                    deviceType = "android",215+                    longTermPublicKeyHex = "11".repeat(32),216+                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),217+                    trustSource = "direct",218+                    pairedAt = 1L,219+                ),220+            ),221+        )222+        val pending = FakePendingRegistry()223+        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)224+        val manifest = buildSignedDeviceManifest(225+            issuerDeviceId = "phone-issuer",226+            devices = listOf(227+                ManifestDeviceEntry(228+                    deviceId = "laptop-b",229+                    deviceName = "Laptop B",230+                    deviceType = "extension",231+                    longTermPublicKeyHex = "aa".repeat(32),232+                    signingPublicKeyHex = "bb".repeat(32),233+                    pairedAt = 44L,234+                ),235+            ),236+            signingPrivateKey = issuerSigning.privateKey,237+        )238+239+        adapter.applyIncomingDeviceManifest(manifest)240+241+        assertEquals(1, pending.devices.size)242+        assertEquals("laptop-b", pending.devices.single().deviceId)243+        master.fill(0)244+    }245+246+    /**247+     * The other half: a device that was itself only vouched for must not be able248+     * to vouch for anyone else. Without this, trust chains transitively from a249+     * single direct pairing — which the browser refuses outright.250+     */251+    @Test fun `a vouched issuer cannot introduce further devices`() = runBlocking {252+        val repo = VaultRepository(FakeStorage())253+        repo.createVault("correct horse")254+        val master = repo.syncMasterKey()!!255+        val issuerSigning = deriveSyncSigningKeypair(master, "vouched-issuer")256+        val paired = FakeDeviceRegistry(257+            listOf(258+                PairedDevice(259+                    deviceId = "vouched-issuer",260+                    deviceName = "Vouched Device",261+                    deviceType = "extension",262+                    longTermPublicKeyHex = "11".repeat(32),263+                    signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),264+                    trustSource = "vouched",265+                    pairedAt = 1L,266+                ),267+            ),268+        )269+        val pending = FakePendingRegistry()270+        val adapter = VaultSyncAdapter(repo, ourDeviceId = "android-self", pairedDevices = paired, pendingVouched = pending)271+        val manifest = buildSignedDeviceManifest(272+            issuerDeviceId = "vouched-issuer",273+            devices = listOf(274+                ManifestDeviceEntry(275+                    deviceId = "smuggled-device",276+                    deviceName = "Smuggled",277+                    deviceType = "extension",278+                    longTermPublicKeyHex = "aa".repeat(32),279+                    signingPublicKeyHex = "bb".repeat(32),280+                    pairedAt = 44L,281+                ),282+            ),283+            signingPrivateKey = issuerSigning.privateKey,284+        )285+286+        adapter.applyIncomingDeviceManifest(manifest)287+288+        assertEquals("a vouched device must not chain trust onward", 0, pending.devices.size)289+        master.fill(0)290+    }197291 }

view file