858a3129886d
fix: Decentralize mesh graph convergence (v2.8.3)
- time
- 2026-07-29 02:11 UTC · 2 months ago
- machine
- koh2
- parent
- 19321e591d55
↓ peach-android-858a3129.tar.gzatlas at this save
files changed7 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 −11app/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 {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)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 @Serializableapp/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) returnapp/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 {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")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