Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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())
)
},
Expand Down
3 changes: 1 addition & 2 deletions app/src/main/java/com/bitchat/android/mesh/MeshCore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@ class MeshCore(
withAuthenticatedSession = encryptionService::withAuthenticatedSession,
store = authenticatedPeerStateStore,
localStateProvider = {
AuthenticatedPeerState(
PeerCapabilities.LOCAL_SUPPORTED,
AuthenticatedPeerState.local(
requireNotNull(encryptionService.getSigningPublicKey())
)
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,34 +1,64 @@
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update the peer-state wire spec with TLV 0x03

Adding TLV 0x03 changes the authenticated peer-state wire contract, but the published interop spec in docs/PRIVATE_MEDIA_V1.md still defines 0x21 as only the capabilities and Ed25519-key TLVs and says unknown TLVs are skipped. Since this field is meant for cross-client negotiation with other senders, leaving the spec stale makes the new canonical bytes and rejection rules ambiguous for clients implementing the same contract; please update the relevant spec alongside this wire-format change.

AGENTS.md reference: AGENTS.md:L90-L91

Useful? React with 👍 / 👎.

*
* 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())
addAll(capabilityBytes.toList())
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()
}

Expand All @@ -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
Expand All @@ -66,21 +114,43 @@ 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
)
}
}

override fun equals(other: Any?): Boolean =
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
}
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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.
}
}
}
}