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
↓ peach-android-65ac906c.tar.gzatlas at this save
files changed4 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 −0app/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)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) returnapp/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) {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 }