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 22f17867..2a1599ef 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 28cd3d02..46e53b0e 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 0dcb23fc..597cca21 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 056a5584..0dcd27fa 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. + } + } + } } diff --git a/docs/PRIVATE_MEDIA_V1.md b/docs/PRIVATE_MEDIA_V1.md index 5fbe0b95..95a50c3f 100644 --- a/docs/PRIVATE_MEDIA_V1.md +++ b/docs/PRIVATE_MEDIA_V1.md @@ -55,13 +55,41 @@ type is `0x21`, followed by this canonical byte sequence: 01 version 01 capabilities, minimal little-endian 02 20 <32 bytes> Ed25519 signing public key +03 02 optional reassembly fragment ceiling, big-endian ``` -Both known TLVs are required exactly once. Decoders reject an unknown version, -truncated TLV, duplicate known TLV, a capability length outside `1...8`, a -non-minimal capability value, or an Ed25519 key whose length is not 32. Unknown -TLVs are skipped for forward compatibility, and the two known TLVs may arrive -in either order. +Capabilities (`0x01`) and the signing key (`0x02`) are required exactly once. +The ceiling (`0x03`) is optional and may occur at most once. Known TLVs may +arrive in any order; the encoder emits them in the order shown above. Android +rejects an unknown version, truncated TLV, duplicate known TLV, a capability +length outside `1...8`, a non-minimal capability value, or an Ed25519 key whose +length is not 32. Unknown TLVs are skipped for forward compatibility. + +The ceiling is an unsigned two-byte big-endian count in `1...65535`, unlike the +little-endian capabilities bitfield. Android advertises `03 02 01 00` (256), +matching `FragmentManager`'s `MAX_FRAGMENTS_PER_ID` for every packet type. +Absence decodes as not advertised, not as zero or unlimited capacity. Older +peer-state decoders can skip this extension while retaining the required fields. +Android rejects the entire peer-state payload if a present ceiling is zero, +not two bytes wide, or duplicated; it does not silently ignore a malformed +known ceiling. This is Android's rejection policy, not a claim that every +other client's malformed-input policy is identical. + +For a synthetic signing key containing bytes `00` through `1f`, private-media +capability bit 8 and a 256-fragment ceiling have this canonical payload +(excluding the outer Noise payload type `21`): + +```text +01 01 02 00 01 02 20 +00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f +10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f +03 02 01 00 +``` + +This ceiling is authenticated session metadata, not a persistent identity pin +or authorization to increase local limits. Android advertises its bound but +continues to cap its own sends locally; applying a remote ceiling to Android's +send admission is a separate change. Each endpoint sends `0x21` when the generation authenticates and echoes its local state at most once after accepting the peer's first valid proof for that @@ -138,7 +166,8 @@ must also fit their unsigned 16-bit wire fields without truncation. A rejected plan creates no local echo and sends no fragments. The 256-fragment limit is a transport/reassembly safety bound, not a capability -negotiated through bit 8. A future larger transfer protocol needs a separate +negotiated through bit 8. Peer-state TLV `0x03` advertises that existing bound; +it does not increase it. A future larger transfer protocol needs a separate capability and bounded streaming design. ## Compatibility summary