mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge 724b3ca986f2e781ba219ea18f418061b7a99534 into c127eb83ab94c069c32d37530d2faecd381cd2a8
This commit is contained in:
commit
fc2fc72750
@ -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())
|
||||
)
|
||||
},
|
||||
|
||||
@ -65,8 +65,7 @@ class MeshCore(
|
||||
withAuthenticatedSession = encryptionService::withAuthenticatedSession,
|
||||
store = authenticatedPeerStateStore,
|
||||
localStateProvider = {
|
||||
AuthenticatedPeerState(
|
||||
PeerCapabilities.LOCAL_SUPPORTED,
|
||||
AuthenticatedPeerState.local(
|
||||
requireNotNull(encryptionService.getSigningPublicKey())
|
||||
)
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user