Merge 724b3ca986f2e781ba219ea18f418061b7a99534 into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
Taksh Kothari 2026-09-14 09:43:25 +05:30 committed by GitHub
commit fc2fc72750
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 184 additions and 10 deletions

View File

@ -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())
)
},

View File

@ -65,8 +65,7 @@ class MeshCore(
withAuthenticatedSession = encryptionService::withAuthenticatedSession,
store = authenticatedPeerStateStore,
localStateProvider = {
AuthenticatedPeerState(
PeerCapabilities.LOCAL_SUPPORTED,
AuthenticatedPeerState.local(
requireNotNull(encryptionService.getSigningPublicKey())
)
},

View File

@ -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<Byte>(1 + 2 + capabilityBytes.size + 2 + signingPublicKey.size) {
val ceilingBytes = if (maxReassemblyFragments == null) 0 else 2 + CEILING_SIZE
return buildList<Byte>(
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
}
}

View File

@ -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.
}
}
}
}