Merge 23a06698b578c714c572f30110f5394415d4a6e1 into 6ade68256cf32ca327047ba7f618ac6514f205ff

This commit is contained in:
Taksh Kothari 2026-10-18 01:00:24 +05:30 committed by GitHub
commit 2845594618
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 219 additions and 16 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.
}
}
}
}

View File

@ -55,13 +55,41 @@ type is `0x21`, followed by this canonical byte sequence:
01 version
01 <len 1...8> <value> capabilities, minimal little-endian
02 20 <32 bytes> Ed25519 signing public key
03 02 <high> <low> 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