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
↓ peach-android-0fee2a17.tar.gzatlas at this save
files changed2 files
app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+20 −10app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+54 −0app/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. */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 /**