0fee2a175dd2

H-10: negotiated device-manifest capability computed once from both HELLOs

time
2026-07-29 14:31 UTC · 2 months ago
machine
koh2
agent
claude-opus-4
session
audit-remediation-2026-07-29
parent
30cd436080de
2 files app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+20 −10app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+54 −0
app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+20 −10
@@ -118,15 +118,13 @@118118                 return@withTimeout SyncOutcome(SyncOutcomeStatus.VAULT_MISMATCH)119119             }120120             warnOnSkew(nowMs, hello.deviceTime)121+            val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)121122 122123             if (hello.version == localVersion) {123124                 // Entry versions do not cover paired-device introductions or124125                 // revocations. Exchange that signed graph when both peers125126                 // advertise support; old peers keep the legacy DONE fast path.126-                if (127-                    vault.supportsDeviceManifest &&128-                    hello.deviceManifestVersion == DEVICE_MANIFEST_VERSION129-                ) {127+                if (exchangeDeviceManifest) {130128                     session.sendMsg(131129                         SyncMessage.DeviceManifest(vault.buildOutgoingDeviceManifest()),132130                     )@@ -176,7 +174,7 @@176174             vault.applyTombstones(peerTombstones.tombstones)177175             tombstonesReceived = peerTombstones.tombstones.size178176 179-            if (vault.supportsDeviceManifest) {177+            if (exchangeDeviceManifest) {180178                 session.sendMsg(SyncMessage.DeviceManifest(vault.buildOutgoingDeviceManifest()))181179                 val theirs = session.recvAs<SyncMessage.DeviceManifest>(182180                     "initiator",@@ -243,12 +241,10 @@243241                 return@withTimeout SyncOutcome(SyncOutcomeStatus.VAULT_MISMATCH)244242             }245243             warnOnSkew(nowMs, hello.deviceTime)244+            val exchangeDeviceManifest = vault.negotiatesDeviceManifest(hello)246245 247246             if (hello.version == localVersion) {248-                if (249-                    vault.supportsDeviceManifest &&250-                    hello.deviceManifestVersion == DEVICE_MANIFEST_VERSION251-                ) {247+                if (exchangeDeviceManifest) {252248                     val theirs = session.recvAs<SyncMessage.DeviceManifest>(253249                         "responder",254250                         "SYNC_DEVICE_MANIFEST",@@ -302,7 +298,7 @@302298             session.sendMsg(SyncMessage.Tombstones(newerForPeer))303299             tombstonesSent = newerForPeer.size304300 305-            if (vault.supportsDeviceManifest) {301+            if (exchangeDeviceManifest) {306302                 val theirs = session.recvAs<SyncMessage.DeviceManifest>(307303                     "responder",308304                     "SYNC_DEVICE_MANIFEST",@@ -328,6 +324,20 @@328324     } catch (e: Exception) {329325         SyncOutcome(SyncOutcomeStatus.ERRORED, error = e.message ?: "sync_failed")330326     }327+}328+329+/**330+ * Whether this session speaks the device-manifest sequence, decided once from331+ * both HELLOs. Gating a later step on local support alone deadlocks a new peer332+ * against a legacy one: we would send/await SYNC_DEVICE_MANIFEST while the peer333+ * follows the legacy sequence, and the session hangs until the 30s timeout.334+ */335+private fun SyncVaultAdapter.negotiatesDeviceManifest(hello: SyncMessage.Hello): Boolean {336+    if (!supportsDeviceManifest) return false337+    val theirs = hello.deviceManifestVersion ?: return false338+    // The negotiated version is the minimum of both advertisements, and we only339+    // implement one — a peer advertising older falls back to the legacy path.340+    return minOf(DEVICE_MANIFEST_VERSION, theirs) == DEVICE_MANIFEST_VERSION331341 }332342 333343 /** Entry ids the peer holds at a strictly newer mtime than we do. */

view file

app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+54 −0
@@ -62,6 +62,11 @@6262     }6363 }6464 65+private fun manifestFor(deviceId: String) = SignedDeviceManifest(66+    body = DeviceManifestBody(DEVICE_MANIFEST_VERSION, deviceId, 1_000L, emptyList()),67+    signatureHex = deviceId.first().code.toString(16).padStart(2, '0').repeat(64),68+)69+6570 class SyncCoordinatorTest {6671 6772     @Test fun full_round_trip_exchanges_entries_both_ways() = runBlocking {@@ -193,6 +198,55 @@193198         assertArrayEquals(ByteArray(32), b.incomingAuthenticatedKey!!)194199         val wire = decodeSyncMessage(SyncMessage.DeviceManifest(null).encodeToBytes())195200         assertEquals(SyncMessage.DeviceManifest(null), wire)201+    }202+203+    /**204+     * H-10: the device-manifest step must be negotiated from both HELLOs, not205+     * from local support alone. A manifest-aware peer talking to a legacy one206+     * previously sent/awaited SYNC_DEVICE_MANIFEST on the differing-version207+     * path while the peer followed the legacy sequence — the session then hung208+     * until the 30s timeout. Every combination must complete, and the manifest209+     * must cross exactly when both sides advertise it.210+     */211+    @Test fun device_manifest_step_is_negotiated_from_both_hellos() = runBlocking {212+        for (aSupports in listOf(true, false)) {213+            for (bSupports in listOf(true, false)) {214+                for (equalVersions in listOf(true, false)) {215+                    for (aInitiates in listOf(true, false)) {216+                        val label =217+                            "a=$aSupports b=$bSupports equal=$equalVersions aInitiates=$aInitiates"218+                        val (sa, sb) = duplex()219+                        val a = FakeAdapter("A", version = 7, supportsDeviceManifest = aSupports)220+                            .apply { outgoingDeviceManifest = manifestFor("A") }221+                        val b = FakeAdapter(222+                            "B",223+                            version = if (equalVersions) 7 else 9,224+                            supportsDeviceManifest = bSupports,225+                        ).apply { outgoingDeviceManifest = manifestFor("B") }226+227+                        val (initiator, responder) = if (aInitiates) a to b else b to a228+                        val (initSession, respSession) = if (aInitiates) sa to sb else sb to sa229+                        val ri = async { runSyncInitiator(initSession, initiator, nowMs = 1_000L) }230+                        val rr = async { runSyncResponder(respSession, responder, nowMs = 1_000L) }231+232+                        assertEquals(label, SyncOutcomeStatus.COMPLETED, ri.await().status)233+                        assertEquals(label, SyncOutcomeStatus.COMPLETED, rr.await().status)234+235+                        val bothSupport = aSupports && bSupports236+                        assertEquals(237+                            label,238+                            if (bothSupport) manifestFor("B") else null,239+                            a.incomingDeviceManifest,240+                        )241+                        assertEquals(242+                            label,243+                            if (bothSupport) manifestFor("A") else null,244+                            b.incomingDeviceManifest,245+                        )246+                    }247+                }248+            }249+        }196250     }197251 198252     /**

view file