diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 22f178678..2a1599ef0 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -64,8 +64,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic withAuthenticatedSession = encryptionService::withAuthenticatedSession, store = authenticatedPeerStateStore, localStateProvider = { - AuthenticatedPeerState( - PeerCapabilities.LOCAL_SUPPORTED, + AuthenticatedPeerState.local( requireNotNull(encryptionService.getSigningPublicKey()) ) }, diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index 28cd3d021..46e53b0e0 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -65,8 +65,7 @@ class MeshCore( withAuthenticatedSession = encryptionService::withAuthenticatedSession, store = authenticatedPeerStateStore, localStateProvider = { - AuthenticatedPeerState( - PeerCapabilities.LOCAL_SUPPORTED, + AuthenticatedPeerState.local( requireNotNull(encryptionService.getSigningPublicKey()) ) }, diff --git a/app/src/main/java/com/bitchat/android/model/AuthenticatedPeerState.kt b/app/src/main/java/com/bitchat/android/model/AuthenticatedPeerState.kt index 0dcb23fcd..597cca214 100644 --- a/app/src/main/java/com/bitchat/android/model/AuthenticatedPeerState.kt +++ b/app/src/main/java/com/bitchat/android/model/AuthenticatedPeerState.kt @@ -1,27 +1,51 @@ package com.bitchat.android.model +import com.bitchat.android.util.AppConstants + /** * Canonical payload carried inside Noise payload type 0x21. * * Wire format: * `[version=0x01][type=0x01][len=1...8][minimal LE capabilities]` * `[type=0x02][len=32][Ed25519 public key]` + * `[type=0x03][len=2][reassembly fragment ceiling, big-endian]` (optional) * - * Unknown TLVs are skipped. Both known fields must occur exactly once. + * Unknown TLVs are skipped. Both required fields must occur exactly once. */ data class AuthenticatedPeerState( val capabilities: PeerCapabilities, - val signingPublicKey: ByteArray + val signingPublicKey: ByteArray, + /** + * How many BLE fragments this peer will reassemble for one packet. + * + * The private-media capability bit says a peer understands encrypted + * media; it says nothing about how much of it that peer can hold. A + * sender that has only the bit has to guess the ceiling from the packet + * type, and that guess is wrong for this client: `FragmentManager` + * rejects any stream above `MAX_FRAGMENTS_PER_ID` regardless of type, + * while a sender assuming the encrypted path implies a large reassembler + * will plan far more than that and see no failure. + * + * `null` means the peer did not advertise one, which is what every client + * released before this TLV sends. + */ + val maxReassemblyFragments: Int? = null ) { init { require(signingPublicKey.size == SIGNING_PUBLIC_KEY_SIZE) { "Ed25519 public key must be 32 bytes" } + require(maxReassemblyFragments == null || maxReassemblyFragments in 1..0xFFFF) { + "Reassembly ceiling must fit the 2-byte wire field and admit at least one fragment" + } } fun encode(): ByteArray { val capabilityBytes = capabilities.encoded() - return buildList(1 + 2 + capabilityBytes.size + 2 + signingPublicKey.size) { + val ceilingBytes = if (maxReassemblyFragments == null) 0 else 2 + CEILING_SIZE + return buildList( + 1 + 2 + capabilityBytes.size + 2 + signingPublicKey.size + ceilingBytes + ) { add(VERSION.toByte()) add(CAPABILITIES_TLV.toByte()) add(capabilityBytes.size.toByte()) @@ -29,6 +53,12 @@ data class AuthenticatedPeerState( add(SIGNING_PUBLIC_KEY_TLV.toByte()) add(SIGNING_PUBLIC_KEY_SIZE.toByte()) addAll(signingPublicKey.toList()) + if (maxReassemblyFragments != null) { + add(MAX_REASSEMBLY_FRAGMENTS_TLV.toByte()) + add(CEILING_SIZE.toByte()) + add(((maxReassemblyFragments shr 8) and 0xFF).toByte()) + add((maxReassemblyFragments and 0xFF).toByte()) + } }.toByteArray() } @@ -37,12 +67,30 @@ data class AuthenticatedPeerState( private const val CAPABILITIES_TLV = 0x01 private const val SIGNING_PUBLIC_KEY_TLV = 0x02 private const val SIGNING_PUBLIC_KEY_SIZE = 32 + private const val MAX_REASSEMBLY_FRAGMENTS_TLV = 0x03 + private const val CEILING_SIZE = 2 + + /** + * This client's own state, as advertised to an authenticated peer. + * + * Built here rather than at each call site so the mesh services + * cannot drift apart, and so the advertised ceiling is pinned to the + * constant the reassembler enforces instead of being restated by + * hand next to a capability set. + */ + fun local(signingPublicKey: ByteArray): AuthenticatedPeerState = + AuthenticatedPeerState( + PeerCapabilities.LOCAL_SUPPORTED, + signingPublicKey, + AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID + ) fun decode(data: ByteArray): AuthenticatedPeerState? { if (data.firstOrNull()?.toInt()?.and(0xFF) != VERSION) return null var offset = 1 var capabilities: PeerCapabilities? = null var signingPublicKey: ByteArray? = null + var maxReassemblyFragments: Int? = null while (offset < data.size) { if (offset + 2 > data.size) return null @@ -66,13 +114,30 @@ data class AuthenticatedPeerState( signingPublicKey = value } + MAX_REASSEMBLY_FRAGMENTS_TLV -> { + // Rejected rather than skipped like an unknown type: a + // peer that meant to constrain the sender but sent a + // field it cannot read must not be treated as having + // said nothing, because "said nothing" falls back to + // the permissive type guess. + if (maxReassemblyFragments != null || length != CEILING_SIZE) return null + val decoded = ((value[0].toInt() and 0xFF) shl 8) or + (value[1].toInt() and 0xFF) + if (decoded == 0) return null + maxReassemblyFragments = decoded + } + else -> Unit } } val decodedCapabilities = capabilities ?: return null val decodedSigningKey = signingPublicKey ?: return null - return AuthenticatedPeerState(decodedCapabilities, decodedSigningKey) + return AuthenticatedPeerState( + decodedCapabilities, + decodedSigningKey, + maxReassemblyFragments + ) } } @@ -80,7 +145,12 @@ data class AuthenticatedPeerState( this === other || (other is AuthenticatedPeerState && capabilities == other.capabilities && - signingPublicKey.contentEquals(other.signingPublicKey)) + signingPublicKey.contentEquals(other.signingPublicKey) && + maxReassemblyFragments == other.maxReassemblyFragments) - override fun hashCode(): Int = 31 * capabilities.hashCode() + signingPublicKey.contentHashCode() + override fun hashCode(): Int { + var result = 31 * capabilities.hashCode() + signingPublicKey.contentHashCode() + result = 31 * result + (maxReassemblyFragments ?: 0) + return result + } } diff --git a/app/src/test/kotlin/com/bitchat/android/model/AuthenticatedPeerStateTest.kt b/app/src/test/kotlin/com/bitchat/android/model/AuthenticatedPeerStateTest.kt index 056a55848..0dcd27faa 100644 --- a/app/src/test/kotlin/com/bitchat/android/model/AuthenticatedPeerStateTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/model/AuthenticatedPeerStateTest.kt @@ -1,8 +1,12 @@ package com.bitchat.android.model +import com.bitchat.android.util.AppConstants + import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Test class AuthenticatedPeerStateTest { @@ -65,4 +69,106 @@ class AuthenticatedPeerStateTest { assertEquals(0x21, encoded[0].toInt() and 0xFF) assertEquals(NoisePayloadType.PEER_STATE, NoisePayload.decode(encoded)?.type) } + + // reassembly ceiling (TLV 0x03) + + @Test + fun `ceiling round-trips as a two-byte big-endian TLV`() { + val state = AuthenticatedPeerState( + PeerCapabilities.PRIVATE_MEDIA, + signingKey, + AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID + ) + + val encoded = state.encode() + + assertArrayEquals( + byteArrayOf(0x01, 0x01, 0x02, 0x00, 0x01, 0x02, 0x20) + signingKey + + byteArrayOf(0x03, 0x02, 0x01, 0x00), + encoded + ) + assertEquals(state, AuthenticatedPeerState.decode(encoded)) + assertEquals(256, AuthenticatedPeerState.decode(encoded)?.maxReassemblyFragments) + } + + @Test + fun `a payload without the ceiling decodes as not advertised`() { + // Exactly what every client released before this TLV emits. It must + // read as "did not say", never as a ceiling of zero. + val legacy = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey).encode() + + assertEquals(1 + 4 + 34, legacy.size) + assertNull(AuthenticatedPeerState.decode(legacy)?.maxReassemblyFragments) + } + + @Test + fun `an unknown trailing TLV still leaves the ceiling readable`() { + // The compatibility direction that lets this ship independently: the + // decoder ignores types it does not know, so a peer that predates + // 0x03 still reads capabilities and key out of a payload carrying one. + val withFutureField = AuthenticatedPeerState( + PeerCapabilities.PRIVATE_MEDIA, + signingKey, + 512 + ).encode() + byteArrayOf(0x7E, 0x01, 0x09) + + val decoded = AuthenticatedPeerState.decode(withFutureField) + assertEquals(PeerCapabilities.PRIVATE_MEDIA, decoded?.capabilities) + assertEquals(512, decoded?.maxReassemblyFragments) + } + + @Test + fun `decoder rejects an unreadable ceiling rather than ignoring it`() { + val prefix = byteArrayOf(0x01, 0x01, 0x02, 0x00, 0x01, 0x02, 0x20) + signingKey + val ceiling = byteArrayOf(0x03, 0x02, 0x01, 0x00) + val invalid = listOf( + // Zero reassembles nothing — never a value to honour. + prefix + byteArrayOf(0x03, 0x02, 0x00, 0x00), + // Wrong width is a different encoding, not this one. + prefix + byteArrayOf(0x03, 0x01, 0x01), + prefix + byteArrayOf(0x03, 0x04, 0x00, 0x00, 0x01, 0x00), + // Two ceilings are ambiguous; picking either is a guess. + prefix + ceiling + ceiling + ) + + invalid.forEach { + assertNull("Expected rejection for ${it.joinToString()}", AuthenticatedPeerState.decode(it)) + } + } + + @Test + fun `the advertised ceiling is the bound the reassembler actually enforces`() { + // The whole point of the field: a sender must be told the number + // FragmentManager rejects above, not one chosen separately from it. + // Asserted against `local()`, which is what the mesh services send — + // so dropping the ceiling from the advertisement fails here. + val local = AuthenticatedPeerState.local(signingKey) + + assertEquals(PeerCapabilities.LOCAL_SUPPORTED, local.capabilities) + assertEquals( + AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID, + local.maxReassemblyFragments + ) + assertEquals( + local, + AuthenticatedPeerState.decode(local.encode()) + ) + assertTrue( + "The ceiling has to fit the two-byte wire field", + AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID in 1..0xFFFF + ) + } + + @Test + fun `a ceiling outside the wire field is refused at construction`() { + listOf(0, -1, 0x10000).forEach { invalid -> + try { + AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey, invalid) + fail("Expected rejection for ceiling $invalid") + } catch (expected: IllegalArgumentException) { + // Encoding it would truncate, or advertise a peer that + // reassembles nothing; both are worse than not advertising. + } + } + } }