858a3129886d

fix: Decentralize mesh graph convergence (v2.8.3)

- Advertise and exchange device-manifest v3 at equal entry versions - Let direct and vouched Noise peers propagate the signed mesh pool - Wake relay sync immediately after a local revocation - Add coordinator and adapter regression coverage

time
2026-07-29 02:11 UTC · 2 months ago
machine
koh2
parent
19321e591d55
7 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+57 −4app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+5 −0app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+6 −9app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+4 −0app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+37 −0app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+10 −11
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 24014420-        versionName "2.8.2"19+        versionCode 24014520+        versionName "2.8.3"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+57 −4
@@ -12,14 +12,14 @@1212  * After reconnect Noise KK authenticates both locally-paired static keys, the1313  * two sides drive:1414  *15- *   ->  SYNC_HELLO { vaultId, deviceId, version, deviceTime }15+ *   ->  SYNC_HELLO { vaultId, deviceId, version, deviceTime, deviceManifestVersion? }1616  *   <-  SYNC_HELLO                                  (mismatch → vault_mismatch)1717  *   <-> SYNC_MANIFEST { entries, tombstones }       (each side sends one)1818  *   ->  SYNC_REQUEST { entryIds }                   (entries newer on the peer)1919  *   <-  SYNC_REQUEST2020  *   <-> SYNC_ENTRIES   (sender unconditional; receiver reads iff it requested)2121  *   <-> SYNC_TOMBSTONES22- *   <-> SYNC_DEVICE_MANIFEST                         (optional, both opt in)22+ *   <-> SYNC_DEVICE_MANIFEST                         (also runs on equal versions)2323  *   <-> SYNC_DONE2424  *2525  * Entries are opaque base64 ciphertext — the coordinator never decrypts.@@ -103,7 +103,14 @@103103             val localVersion = vault.getLocalVersion()104104 105105             session.sendMsg(106-                SyncMessage.Hello(ourVaultId, vault.getOurDeviceId(), localVersion, nowMs),106+                SyncMessage.Hello(107+                    ourVaultId,108+                    vault.getOurDeviceId(),109+                    localVersion,110+                    nowMs,111+                    deviceManifestVersion =112+                        if (vault.supportsDeviceManifest) DEVICE_MANIFEST_VERSION else null,113+                ),107114             )108115             val hello = session.recvAs<SyncMessage.Hello>("initiator", "SYNC_HELLO")109116             if (hello.vaultId != ourVaultId) {@@ -113,6 +120,27 @@113120             warnOnSkew(nowMs, hello.deviceTime)114121 115122             if (hello.version == localVersion) {123+                // Entry versions do not cover paired-device introductions or124+                // revocations. Exchange that signed graph when both peers125+                // advertise support; old peers keep the legacy DONE fast path.126+                if (127+                    vault.supportsDeviceManifest &&128+                    hello.deviceManifestVersion == DEVICE_MANIFEST_VERSION129+                ) {130+                    session.sendMsg(131+                        SyncMessage.DeviceManifest(vault.buildOutgoingDeviceManifest()),132+                    )133+                    val theirs = session.recvAs<SyncMessage.DeviceManifest>(134+                        "initiator",135+                        "SYNC_DEVICE_MANIFEST",136+                    )137+                    if (theirs.manifest != null) {138+                        vault.applyIncomingDeviceManifest(139+                            theirs.manifest,140+                            session.remoteStaticPublicKey,141+                        )142+                    }143+                }116144                 session.sendMsg(SyncMessage.Done(0, 0))117145                 session.awaitClosingDone("initiator", "SYNC_DONE")118146                 return@withTimeout SyncOutcome(SyncOutcomeStatus.COMPLETED)@@ -202,7 +230,14 @@202230             // Always answer HELLO so the initiator's recv completes, even on a203231             // mismatch where we bail immediately after.204232             session.sendMsg(205-                SyncMessage.Hello(ourVaultId, vault.getOurDeviceId(), localVersion, nowMs),233+                SyncMessage.Hello(234+                    ourVaultId,235+                    vault.getOurDeviceId(),236+                    localVersion,237+                    nowMs,238+                    deviceManifestVersion =239+                        if (vault.supportsDeviceManifest) DEVICE_MANIFEST_VERSION else null,240+                ),206241             )207242             if (hello.vaultId != ourVaultId) {208243                 return@withTimeout SyncOutcome(SyncOutcomeStatus.VAULT_MISMATCH)@@ -210,6 +245,24 @@210245             warnOnSkew(nowMs, hello.deviceTime)211246 212247             if (hello.version == localVersion) {248+                if (249+                    vault.supportsDeviceManifest &&250+                    hello.deviceManifestVersion == DEVICE_MANIFEST_VERSION251+                ) {252+                    val theirs = session.recvAs<SyncMessage.DeviceManifest>(253+                        "responder",254+                        "SYNC_DEVICE_MANIFEST",255+                    )256+                    session.sendMsg(257+                        SyncMessage.DeviceManifest(vault.buildOutgoingDeviceManifest()),258+                    )259+                    if (theirs.manifest != null) {260+                        vault.applyIncomingDeviceManifest(261+                            theirs.manifest,262+                            session.remoteStaticPublicKey,263+                        )264+                    }265+                }213266                 session.recvAs<SyncMessage.Done>("responder", "SYNC_DONE")214267                 session.sendMsg(SyncMessage.Done(0, 0))215268                 return@withTimeout SyncOutcome(SyncOutcomeStatus.COMPLETED)

view file

app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+5 −0
@@ -19,6 +19,11 @@1919         val deviceId: String,2020         val version: Long,2121         val deviceTime: Long,22+        /**23+         * Advertises equal-version device-graph exchange support. Older peers24+         * omit this field and retain the legacy SYNC_DONE fast path.25+         */26+        val deviceManifestVersion: Int? = null,2227     ) : SyncMessage()2328 2429     @Serializable

view file

app/src/main/java/com/peachpasswords/sync/VaultSyncAdapter.kt+6 −9
@@ -127,15 +127,12 @@127127         if (authenticatedRemoteStaticPublicKey.size != 32) return128128         val issuer = registry.getByPubkey(pubkeyToHex(authenticatedRemoteStaticPublicKey)) ?: return129129         // Match the browser (`applyVerifiedPeerManifest` in sync-vault-adapter.ts):130-        // trust a manifest only from a DIRECTLY paired issuer. This previously131-        // keyed on deviceType == "extension" instead, which got both halves132-        // wrong: it ignored manifests from other phones/tablets, so mesh trust133-        // could never propagate between native devices (the whole point of the134-        // mesh now that phones are the listening side), and it never checked135-        // 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.138-        if (issuer.trustSource != "direct") return130+        // the issuer must already be admitted locally, but it need not be the131+        // device that performed the original pairing. Direct and vouched peers132+        // are both Noise-authenticated same-vault members, so either may carry133+        // the signed pool onward. Unknown and legacy-unclassified rows remain134+        // fail-closed.135+        if (issuer.trustSource != "direct" && issuer.trustSource != "vouched") return139136         val signingKey = issuer.signingPublicKeyHex?.lowercase()?.takeIf { it.length == 64 } ?: return140137         val body = verifyDeviceManifest(manifest, hexToPubkey(signingKey)) ?: return141138         if (body.issuerDeviceId != issuer.deviceId) return

view file

app/src/main/java/com/peachpasswords/ui/sync/SyncViewModel.kt+4 −0
@@ -130,6 +130,10 @@130130      */131131     fun unpairDevice(id: String) = viewModelScope.launch {132132         deviceStore.revoke(id, getOrCreateDeviceId(context))133+        // Revocation is mesh state, not a vault-entry mutation. Wake the relay134+        // service immediately so the signed revocation reaches every remaining135+        // peer instead of waiting for a later foreground or periodic trigger.136+        PeachSyncService.start(context)133137     }134138 135139     fun confirmVouchedDevice(id: String) = viewModelScope.launch {

view file

app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+37 −0
@@ -110,6 +110,43 @@110110         assertFalse(b.blobs.containsKey("x"))111111     }112112 113+    @Test fun equal_versions_still_exchange_device_graphs_when_both_peers_support_it() =114+        runBlocking {115+            val (sa, sb) = duplex()116+            val manifestA = SignedDeviceManifest(117+                body = DeviceManifestBody(118+                    DEVICE_MANIFEST_VERSION,119+                    "A",120+                    1_000L,121+                    emptyList(),122+                ),123+                signatureHex = "11".repeat(64),124+            )125+            val manifestB = SignedDeviceManifest(126+                body = DeviceManifestBody(127+                    DEVICE_MANIFEST_VERSION,128+                    "B",129+                    1_000L,130+                    emptyList(),131+                ),132+                signatureHex = "22".repeat(64),133+            )134+            val a = FakeAdapter("A", version = 7, supportsDeviceManifest = true).apply {135+                outgoingDeviceManifest = manifestA136+            }137+            val b = FakeAdapter("B", version = 7, supportsDeviceManifest = true).apply {138+                outgoingDeviceManifest = manifestB139+            }140+141+            val ra = async { runSyncInitiator(sa, a, nowMs = 1_000L) }142+            val rb = async { runSyncResponder(sb, b, nowMs = 1_000L) }143+144+            assertEquals(SyncOutcomeStatus.COMPLETED, ra.await().status)145+            assertEquals(SyncOutcomeStatus.COMPLETED, rb.await().status)146+            assertEquals(manifestB, a.incomingDeviceManifest)147+            assertEquals(manifestA, b.incomingDeviceManifest)148+        }149+113150     @Test fun vault_id_mismatch_aborts_both_sides() = runBlocking {114151         val (sa, sb) = duplex()115152         val a = FakeAdapter("A", version = 1, vaultId = "V1")

view file

app/src/test/java/com/peachpasswords/sync/VaultSyncAdapterTest.kt+10 −11
@@ -165,7 +165,7 @@165165         assertTrue(verifyDeviceManifest(manifest.copy(signatureHex = "00".repeat(64)), signing.publicKeyRaw, nowMs = 1_000L) == null)166166     }167167 168-    @Test fun `revocation evicts the device and permanently blocks re-vouching`() = runBlocking {168+    @Test fun `vouched peer revocation evicts the device and permanently blocks re-vouching`() = runBlocking {169169         val repo = VaultRepository(FakeStorage())170170         repo.createVault("correct horse")171171         val master = repo.syncMasterKey()!!@@ -178,7 +178,7 @@178178                     deviceType = "extension",179179                     longTermPublicKeyHex = "11".repeat(32),180180                     signingPublicKeyHex = pubkeyToHex(issuerSigning.publicKeyRaw),181-                    trustSource = "direct",181+                    trustSource = "vouched",182182                     pairedAt = 1L,183183                 ),184184                 PairedDevice(@@ -371,12 +371,8 @@371371         master.fill(0)372372     }373373 374-    /**375-     * The other half: a device that was itself only vouched for must not be able376-     * to vouch for anyone else. Without this, trust chains transitively from a377-     * single direct pairing — which the browser refuses outright.378-     */379-    @Test fun `a vouched issuer cannot introduce further devices`() = runBlocking {374+    /** An admitted peer can propagate the pool without the original hub online. */375+    @Test fun `a vouched issuer introduces further mesh devices`() = runBlocking {380376         val repo = VaultRepository(FakeStorage())381377         repo.createVault("correct horse")382378         val master = repo.syncMasterKey()!!@@ -400,8 +396,8 @@400396             issuerDeviceId = "vouched-issuer",401397             devices = listOf(402398                 ManifestDeviceEntry(403-                    deviceId = "smuggled-device",404-                    deviceName = "Smuggled",399+                    deviceId = "onward-mesh-device",400+                    deviceName = "Onward mesh device",405401                     deviceType = "extension",406402                     longTermPublicKeyHex = "aa".repeat(32),407403                     signingPublicKeyHex = "bb".repeat(32),@@ -414,7 +410,10 @@414410 415411         adapter.applyIncomingDeviceManifest(manifest, ByteArray(32) { 0x11 })416412 417-        assertEquals("a vouched device must not chain trust onward", 0, pending.devices.size)413+        assertEquals("vouched", paired.get("onward-mesh-device")?.trustSource)414+        assertEquals(meshRelay, paired.get("onward-mesh-device")?.relayUrl)415+        assertTrue(paired.get("onward-mesh-device")?.rendezvousSecretHex != null)416+        assertEquals(0, pending.devices.size)418417         master.fill(0)419418     }420419 

view file