From 6b3602268a42a130279a3c83c768ac2853dfcbf2 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:22:26 +0200 Subject: [PATCH] fix(sync): drop live messages over the size limit or naming a previous object --- .../2026-11-07-fix-sync2-live-limits.md | 2 + .../aedev/flow/sync2/live/LiveEnvelopes.kt | 7 ++- .../github/aedev/flow/sync2/LiveLimitsTest.kt | 59 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 .github/nightly-notes/2026-11-07-fix-sync2-live-limits.md create mode 100644 app/src/test/java/io/github/aedev/flow/sync2/LiveLimitsTest.kt diff --git a/.github/nightly-notes/2026-11-07-fix-sync2-live-limits.md b/.github/nightly-notes/2026-11-07-fix-sync2-live-limits.md new file mode 100644 index 000000000..0ba0ceb61 --- /dev/null +++ b/.github/nightly-notes/2026-11-07-fix-sync2-live-limits.md @@ -0,0 +1,2 @@ +## Fixed +- Linked devices: live messages that break the size or format limits are now dropped on the local network too diff --git a/app/src/main/java/io/github/aedev/flow/sync2/live/LiveEnvelopes.kt b/app/src/main/java/io/github/aedev/flow/sync2/live/LiveEnvelopes.kt index 503a00171..b26e60807 100644 --- a/app/src/main/java/io/github/aedev/flow/sync2/live/LiveEnvelopes.kt +++ b/app/src/main/java/io/github/aedev/flow/sync2/live/LiveEnvelopes.kt @@ -8,7 +8,9 @@ import io.github.aedev.flow.sync2.crypto.purposeKey import io.github.aedev.flow.sync2.engine.Session import io.github.aedev.flow.sync2.roster.Role import io.github.aedev.flow.sync2.store.Header +import io.github.aedev.flow.sync2.store.MAX_LIVE_MESSAGE import io.github.aedev.flow.sync2.store.ObjectType +import io.github.aedev.flow.sync2.store.ZERO_HASH import io.github.aedev.flow.sync2.store.parseObject import io.github.aedev.flow.sync2.store.sealObject import kotlinx.coroutines.CancellationException @@ -26,7 +28,7 @@ internal fun Session.sealLive( seq: Long, ): ByteArray { val epoch = state.epoch - val header = Header(ObjectType.LIVE, group, epoch, device.did, seq, ByteArray(32)) + val header = Header(ObjectType.LIVE, group, epoch, device.did, seq, ZERO_HASH) val key = purposeKey(currentKey(), group, epoch, Purpose.LIVE) return sealObject(header, key, encodePayload(LiveMessage.encode(message)), device.sigSeed) } @@ -34,11 +36,12 @@ internal fun Session.sealLive( /** Opens a valid live message signed by another current device of this group, or null for anything else. */ internal suspend fun Session.openLive(bytes: ByteArray): LiveReceived? = try { + if (bytes.size > MAX_LIVE_MESSAGE) return null val parsed = parseObject(bytes, group) val header = parsed.header val sender = SyncBytes.toHex(header.sender) val member = state.members[sender]?.takeIf { it.role == Role.DEVICE } - if (header.type != ObjectType.LIVE || sender == me || member == null) { + if (header.type != ObjectType.LIVE || !header.prev.contentEquals(ZERO_HASH) || sender == me || member == null) { null } else { parsed.verify(member.sig) diff --git a/app/src/test/java/io/github/aedev/flow/sync2/LiveLimitsTest.kt b/app/src/test/java/io/github/aedev/flow/sync2/LiveLimitsTest.kt new file mode 100644 index 000000000..197849767 --- /dev/null +++ b/app/src/test/java/io/github/aedev/flow/sync2/LiveLimitsTest.kt @@ -0,0 +1,59 @@ +package io.github.aedev.flow.sync2 + +import android.app.Application +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.github.aedev.flow.sync.crypto.SyncBytes +import io.github.aedev.flow.sync.crypto.SyncCrypto +import io.github.aedev.flow.sync2.crypto.Purpose +import io.github.aedev.flow.sync2.crypto.encodePayload +import io.github.aedev.flow.sync2.crypto.purposeKey +import io.github.aedev.flow.sync2.engine.Session +import io.github.aedev.flow.sync2.live.LiveMessage +import io.github.aedev.flow.sync2.live.openLive +import io.github.aedev.flow.sync2.live.sealLive +import io.github.aedev.flow.sync2.store.Header +import io.github.aedev.flow.sync2.store.MAX_LIVE_MESSAGE +import io.github.aedev.flow.sync2.store.ObjectType +import io.github.aedev.flow.sync2.store.sealObject +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +/** A live message is at most 64 KiB and has no `prev` (spec/store.md), on every transport. */ +@RunWith(AndroidJUnit4::class) +@Config(sdk = [34], application = Application::class) +class LiveLimitsTest { + @Test + fun a_live_message_over_the_limit_is_dropped() = + runTest { + val g = LinkedGroup(this).apply { create() } + val fromA = Session.open(g.deviceDb(), g.store, g.a) + val toB = Session.open(g.deviceDb(), g.store, g.b) + assertNotNull(toB.openLive(fromA.sealLive(LiveMessage.Now("v", title = "t"), 1))) + + val title = SyncBytes.toHex(SyncCrypto.randomBytes(100 * 1024)) + val big = fromA.sealLive(LiveMessage.Now("v", title = title), 2) + + assertTrue(big.size > MAX_LIVE_MESSAGE) + assertNull(toB.openLive(big)) + } + + @Test + fun a_live_message_naming_a_previous_object_is_dropped() = + runTest { + val g = LinkedGroup(this).apply { create() } + val toB = Session.open(g.deviceDb(), g.store, g.b) + val key = purposeKey(g.epochKey, g.group, 0, Purpose.LIVE) + val content = encodePayload(LiveMessage.encode(LiveMessage.Now("v"))) + + fun envelope(prev: ByteArray) = sealObject(Header(ObjectType.LIVE, g.group, 0, g.a.did, 1, prev), key, content, g.a.sigSeed) + + assertEquals(1L, toB.openLive(envelope(ByteArray(32)))?.seq) + assertNull(toB.openLive(envelope(ByteArray(32) { 1 }))) + } +}