mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-26 05:25:21 +00:00
Merge branch 'main' into wifi-aware-refactor-mesh-core
# Conflicts: # app/src/main/AndroidManifest.xml # app/src/main/java/com/bitchat/android/BitchatApplication.kt # app/src/main/java/com/bitchat/android/MainActivity.kt # app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionTracker.kt # app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt # app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt # app/src/main/java/com/bitchat/android/onboarding/PermissionManager.kt # app/src/main/java/com/bitchat/android/ui/SidebarComponents.kt
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package com.bitchat.android.crypto
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.bitchat.android.noise.NoiseEncryptionService
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.util.Arrays
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class EncryptionServiceTest {
|
||||
|
||||
private lateinit var context: Context
|
||||
private lateinit var encryptionService: EncryptionService
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
context = ApplicationProvider.getApplicationContext()
|
||||
encryptionService = EncryptionService(context)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test clearPersistentIdentity changes keys`() {
|
||||
// 1. Get initial keys
|
||||
val initialStaticKey = encryptionService.getStaticPublicKey()
|
||||
val initialSigningKey = encryptionService.getSigningPublicKey()
|
||||
val initialFingerprint = encryptionService.getIdentityFingerprint()
|
||||
|
||||
assertNotNull("Initial static key should not be null", initialStaticKey)
|
||||
assertNotNull("Initial signing key should not be null", initialSigningKey)
|
||||
|
||||
// 2. Call clearPersistentIdentity (Panic Mode)
|
||||
encryptionService.clearPersistentIdentity()
|
||||
|
||||
// 3. Get keys again.
|
||||
val afterStaticKey = encryptionService.getStaticPublicKey()
|
||||
val afterSigningKey = encryptionService.getSigningPublicKey()
|
||||
val afterFingerprint = encryptionService.getIdentityFingerprint()
|
||||
|
||||
// 4. Verify keys are different (Panic Mode should clear/rotate in-memory keys)
|
||||
// Note: We use string comparison for byte arrays to be safe in assertion messages
|
||||
assertNotEquals("Static key should change after panic",
|
||||
Arrays.toString(initialStaticKey), Arrays.toString(afterStaticKey))
|
||||
|
||||
assertNotEquals("Signing key should change after panic",
|
||||
Arrays.toString(initialSigningKey), Arrays.toString(afterSigningKey))
|
||||
|
||||
assertNotEquals("Fingerprint should change after panic",
|
||||
initialFingerprint, afterFingerprint)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.model.FragmentPayload
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.util.Random
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class FragmentManagerTest {
|
||||
|
||||
private lateinit var fragmentManager: FragmentManager
|
||||
private val senderID = "1122334455667788"
|
||||
private val recipientID = "8877665544332211"
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
fragmentManager = FragmentManager()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test fragmentation without route`() {
|
||||
// Create a large payload (e.g., 1000 bytes)
|
||||
val payload = ByteArray(1000)
|
||||
Random().nextBytes(payload)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = hexStringToByteArray(senderID),
|
||||
recipientID = hexStringToByteArray(recipientID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = 7u,
|
||||
route = null
|
||||
)
|
||||
|
||||
val fragments = fragmentManager.createFragments(packet)
|
||||
|
||||
assertTrue("Should create multiple fragments", fragments.size > 1)
|
||||
|
||||
// Verify each fragment fits in MTU (512)
|
||||
for (fragment in fragments) {
|
||||
val encodedSize = fragment.toBinaryData()?.size ?: 0
|
||||
assertTrue("Fragment encoded size should be <= 512, was $encodedSize", encodedSize <= 512)
|
||||
|
||||
// Inspect the payload data size
|
||||
val fragmentPayload = FragmentPayload.decode(fragment.payload)
|
||||
assertNotNull(fragmentPayload)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test fragmentation with route`() {
|
||||
// Create a large payload
|
||||
val payload = ByteArray(1000)
|
||||
Random().nextBytes(payload)
|
||||
|
||||
// Create a fake route (3 hops)
|
||||
val route = listOf(
|
||||
hexStringToByteArray("AABBCCDDEEFF0011"),
|
||||
hexStringToByteArray("1100FFEEDDCCBBAA"),
|
||||
hexStringToByteArray("1234567890ABCDEF")
|
||||
)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
version = 2u,
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = hexStringToByteArray(senderID),
|
||||
recipientID = hexStringToByteArray(recipientID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = 7u,
|
||||
route = route
|
||||
)
|
||||
|
||||
val fragments = fragmentManager.createFragments(packet)
|
||||
|
||||
assertTrue("Should create multiple fragments", fragments.size > 1)
|
||||
|
||||
// Verify fragments retain the route and version 2
|
||||
for (fragment in fragments) {
|
||||
assertEquals("Fragment version should be 2", 2u.toUByte(), fragment.version)
|
||||
assertEquals("Fragment should have the route", route.size, fragment.route?.size)
|
||||
|
||||
val encodedSize = fragment.toBinaryData()?.size ?: 0
|
||||
assertTrue("Fragment encoded size should be <= 512, was $encodedSize", encodedSize <= 512)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test fragmentation size difference with and without route`() {
|
||||
// This test specifically checks if the dynamic calculation logic works
|
||||
// by observing that fragments with routes carry less data payload per fragment
|
||||
|
||||
val payload = ByteArray(2000) // Large enough to ensure full fragments
|
||||
Random().nextBytes(payload)
|
||||
|
||||
// 1. Without route
|
||||
val packetNoRoute = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = hexStringToByteArray(senderID),
|
||||
recipientID = hexStringToByteArray(recipientID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = 7u,
|
||||
route = null
|
||||
)
|
||||
val fragmentsNoRoute = fragmentManager.createFragments(packetNoRoute)
|
||||
val firstFragPayloadNoRoute = FragmentPayload.decode(fragmentsNoRoute[0].payload)
|
||||
val dataSizeNoRoute = firstFragPayloadNoRoute?.data?.size ?: 0
|
||||
|
||||
// 2. With large route (e.g., 5 hops)
|
||||
val route = List(5) { hexStringToByteArray("000000000000000$it") }
|
||||
val packetWithRoute = BitchatPacket(
|
||||
version = 2u,
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = hexStringToByteArray(senderID),
|
||||
recipientID = hexStringToByteArray(recipientID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = 7u,
|
||||
route = route
|
||||
)
|
||||
val fragmentsWithRoute = fragmentManager.createFragments(packetWithRoute)
|
||||
val firstFragPayloadWithRoute = FragmentPayload.decode(fragmentsWithRoute[0].payload)
|
||||
val dataSizeWithRoute = firstFragPayloadWithRoute?.data?.size ?: 0
|
||||
|
||||
println("Data size without route: $dataSizeNoRoute")
|
||||
println("Data size with route: $dataSizeWithRoute")
|
||||
|
||||
assertTrue("Data payload should be smaller with route", dataSizeWithRoute < dataSizeNoRoute)
|
||||
|
||||
// Rough verification of the math:
|
||||
// 5 hops * 8 bytes = 40 bytes extra.
|
||||
// Plus v2 header overhead differences.
|
||||
// The difference should be roughly 40+ bytes.
|
||||
assertTrue("Difference should be significant", (dataSizeNoRoute - dataSizeWithRoute) >= 40)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test reassembly`() {
|
||||
val originalPayload = ByteArray(1500)
|
||||
Random().nextBytes(originalPayload)
|
||||
|
||||
val originalPacket = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.FILE_TRANSFER.value,
|
||||
senderID = hexStringToByteArray(senderID),
|
||||
recipientID = hexStringToByteArray(recipientID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = originalPayload,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
val fragments = fragmentManager.createFragments(originalPacket)
|
||||
|
||||
var reassembledPacket: BitchatPacket? = null
|
||||
|
||||
// Feed fragments back into FragmentManager
|
||||
// Note: FragmentManager stores state in incomingFragments
|
||||
|
||||
for (fragment in fragments) {
|
||||
val result = fragmentManager.handleFragment(fragment)
|
||||
if (result != null) {
|
||||
reassembledPacket = result
|
||||
}
|
||||
}
|
||||
|
||||
assertNotNull("Should have reassembled packet", reassembledPacket)
|
||||
assertEquals("Type should match", originalPacket.type, reassembledPacket!!.type)
|
||||
assertEquals("Payload size should match", originalPacket.payload.size, reassembledPacket.payload.size)
|
||||
assertTrue("Payload content should match", originalPacket.payload.contentEquals(reassembledPacket.payload))
|
||||
}
|
||||
|
||||
private fun hexStringToByteArray(hexString: String): ByteArray {
|
||||
val result = ByteArray(8)
|
||||
for (i in 0 until 8) {
|
||||
val byteStr = hexString.substring(i * 2, i * 2 + 2)
|
||||
result[i] = byteStr.toInt(16).toByte()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,14 @@ import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.ConscryptMode
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.Date
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@ConscryptMode(ConscryptMode.Mode.OFF) // Disable Conscrypt to avoid native library loading issues
|
||||
class FileTransferTest {
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.util.toHexString
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.never
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.whenever
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
class PacketRelayManagerTest {
|
||||
|
||||
private lateinit var packetRelayManager: PacketRelayManager
|
||||
private val delegate: PacketRelayManagerDelegate = mock()
|
||||
|
||||
private val myPeerID = "1111111111111111"
|
||||
private val otherPeerID = "2222222222222222"
|
||||
private val nextHopPeerID = "3333333333333333"
|
||||
private val finalRecipientID = "4444444444444444"
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
packetRelayManager = PacketRelayManager(myPeerID)
|
||||
packetRelayManager.delegate = delegate
|
||||
whenever(delegate.getNetworkSize()).thenReturn(10)
|
||||
whenever(delegate.getBroadcastRecipient()).thenReturn(byteArrayOf(0,0,0,0,0,0,0,0))
|
||||
}
|
||||
|
||||
private fun createPacket(route: List<ByteArray>?, recipient: String? = null): BitchatPacket {
|
||||
return BitchatPacket(
|
||||
type = MessageType.MESSAGE.value,
|
||||
senderID = hexStringToPeerBytes(otherPeerID),
|
||||
recipientID = recipient?.let { hexStringToPeerBytes(it) },
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = "hello".toByteArray(),
|
||||
ttl = 5u,
|
||||
route = route
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packet with duplicate hops is dropped`() = runTest {
|
||||
val route = listOf(
|
||||
hexStringToPeerBytes(nextHopPeerID),
|
||||
hexStringToPeerBytes(nextHopPeerID)
|
||||
)
|
||||
val packet = createPacket(route)
|
||||
val routedPacket = RoutedPacket(packet, otherPeerID)
|
||||
|
||||
packetRelayManager.handlePacketRelay(routedPacket)
|
||||
|
||||
verify(delegate, never()).sendToPeer(any(), any())
|
||||
verify(delegate, never()).broadcastPacket(any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid source-routed packet is relayed to next hop`() = runTest {
|
||||
val route = listOf(
|
||||
hexStringToPeerBytes(myPeerID),
|
||||
hexStringToPeerBytes(nextHopPeerID)
|
||||
)
|
||||
val packet = createPacket(route, finalRecipientID)
|
||||
val routedPacket = RoutedPacket(packet, otherPeerID)
|
||||
whenever(delegate.sendToPeer(any(), any())).thenReturn(true)
|
||||
|
||||
packetRelayManager.handlePacketRelay(routedPacket)
|
||||
|
||||
verify(delegate).sendToPeer(org.mockito.kotlin.eq(nextHopPeerID), any())
|
||||
verify(delegate, never()).broadcastPacket(any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `last hop does not relay further`() = runTest {
|
||||
val route = listOf(
|
||||
hexStringToPeerBytes(myPeerID)
|
||||
)
|
||||
val packet = createPacket(route, finalRecipientID)
|
||||
val routedPacket = RoutedPacket(packet, otherPeerID)
|
||||
whenever(delegate.sendToPeer(any(), any())).thenReturn(true)
|
||||
|
||||
packetRelayManager.handlePacketRelay(routedPacket)
|
||||
|
||||
verify(delegate).sendToPeer(org.mockito.kotlin.eq(finalRecipientID), any())
|
||||
verify(delegate, never()).broadcastPacket(any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packet with empty route is broadcast`() = runTest {
|
||||
val packet = createPacket(null)
|
||||
val routedPacket = RoutedPacket(packet, otherPeerID)
|
||||
|
||||
packetRelayManager.handlePacketRelay(routedPacket)
|
||||
|
||||
verify(delegate, never()).sendToPeer(any(), any())
|
||||
verify(delegate).broadcastPacket(any())
|
||||
}
|
||||
|
||||
private fun hexStringToPeerBytes(hex: String): ByteArray {
|
||||
val result = ByteArray(8)
|
||||
var idx = 0
|
||||
var out = 0
|
||||
while (idx + 1 < hex.length && out < 8) {
|
||||
val b = hex.substring(idx, idx + 2).toIntOrNull(16)?.toByte() ?: 0
|
||||
result[out++] = b
|
||||
idx += 2
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import android.os.Build
|
||||
import com.bitchat.android.crypto.EncryptionService
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.*
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
|
||||
class SecurityManagerTest {
|
||||
|
||||
private lateinit var securityManager: SecurityManager
|
||||
private lateinit var fakeEncryptionService: FakeEncryptionService
|
||||
private lateinit var mockDelegate: SecurityManagerDelegate
|
||||
|
||||
private val myPeerID = "1111222233334444"
|
||||
private val otherPeerID = "aaaabbbbccccdddd"
|
||||
private val unknownPeerID = "9999888877776666"
|
||||
|
||||
private val dummyPayload = "Hello World".toByteArray()
|
||||
private val validSignature = ByteArray(64) { 1 }
|
||||
private val invalidSignature = ByteArray(64) { 0 }
|
||||
|
||||
// Key pairs (using dummy bytes for mock verification)
|
||||
private val otherSigningKey = ByteArray(32) { 0xA }
|
||||
private val otherNoiseKey = ByteArray(32) { 0xB }
|
||||
|
||||
// Fake implementation to bypass initialization issues in tests
|
||||
open class FakeEncryptionService : EncryptionService(RuntimeEnvironment.getApplication()) {
|
||||
var shouldVerify: Boolean = true
|
||||
var lastVerifySignature: ByteArray? = null
|
||||
var lastVerifyKey: ByteArray? = null
|
||||
|
||||
override fun initialize() {
|
||||
// Do nothing to avoid KeyStore access in tests
|
||||
}
|
||||
|
||||
override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
|
||||
lastVerifySignature = signature
|
||||
lastVerifyKey = publicKeyBytes
|
||||
|
||||
// Simple logic: if configured to verify, check if signature matches validSignature
|
||||
// We use the signature bytes passed in setup()
|
||||
if (shouldVerify) {
|
||||
return signature.contentEquals(byteArrayOf(1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1))
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
fakeEncryptionService = FakeEncryptionService()
|
||||
mockDelegate = mock()
|
||||
|
||||
securityManager = SecurityManager(fakeEncryptionService, myPeerID)
|
||||
securityManager.delegate = mockDelegate
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
if (::securityManager.isInitialized) {
|
||||
securityManager.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket - rejects packet with missing signature`() {
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.MESSAGE.value,
|
||||
ttl = 10u,
|
||||
senderID = otherPeerID,
|
||||
payload = dummyPayload
|
||||
)
|
||||
packet.signature = null
|
||||
|
||||
val result = securityManager.validatePacket(packet, otherPeerID)
|
||||
|
||||
assertFalse("Packet without signature should be rejected", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket - rejects packet with invalid signature`() {
|
||||
setupKnownPeer(otherPeerID, otherSigningKey)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.MESSAGE.value,
|
||||
ttl = 10u,
|
||||
senderID = otherPeerID,
|
||||
payload = dummyPayload
|
||||
)
|
||||
packet.signature = invalidSignature
|
||||
|
||||
val result = securityManager.validatePacket(packet, otherPeerID)
|
||||
|
||||
assertFalse("Packet with invalid signature should be rejected", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket - rejects packet from unknown peer (no key)`() {
|
||||
whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.MESSAGE.value,
|
||||
ttl = 10u,
|
||||
senderID = unknownPeerID,
|
||||
payload = dummyPayload
|
||||
)
|
||||
packet.signature = validSignature
|
||||
|
||||
val result = securityManager.validatePacket(packet, unknownPeerID)
|
||||
|
||||
assertFalse("Packet from unknown peer should be rejected (cannot verify signature)", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket - accepts packet with valid signature from known peer`() {
|
||||
setupKnownPeer(otherPeerID, otherSigningKey)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.MESSAGE.value,
|
||||
ttl = 10u,
|
||||
senderID = otherPeerID,
|
||||
payload = dummyPayload
|
||||
)
|
||||
packet.signature = validSignature
|
||||
|
||||
val result = securityManager.validatePacket(packet, otherPeerID)
|
||||
|
||||
assertTrue("Valid signed packet from known peer should be accepted", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket - accepts ANNOUNCE packet from unknown peer (extracts key)`() {
|
||||
val announcement = IdentityAnnouncement(
|
||||
nickname = "New User",
|
||||
noisePublicKey = otherNoiseKey,
|
||||
signingPublicKey = otherSigningKey
|
||||
)
|
||||
val payload = announcement.encode()!!
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
ttl = 10u,
|
||||
senderID = unknownPeerID,
|
||||
payload = payload
|
||||
)
|
||||
packet.signature = validSignature
|
||||
|
||||
whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null)
|
||||
|
||||
val result = securityManager.validatePacket(packet, unknownPeerID)
|
||||
|
||||
assertTrue("ANNOUNCE from unknown peer should be accepted (key extracted from payload)", result)
|
||||
// Verify we used the correct key
|
||||
assertTrue("Should have used extracted key for verification",
|
||||
fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket - rejects ANNOUNCE packet with invalid signature`() {
|
||||
val announcement = IdentityAnnouncement(
|
||||
nickname = "New User",
|
||||
noisePublicKey = otherNoiseKey,
|
||||
signingPublicKey = otherSigningKey
|
||||
)
|
||||
val payload = announcement.encode()!!
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
ttl = 10u,
|
||||
senderID = unknownPeerID,
|
||||
payload = payload
|
||||
)
|
||||
packet.signature = invalidSignature
|
||||
|
||||
val result = securityManager.validatePacket(packet, unknownPeerID)
|
||||
|
||||
assertFalse("ANNOUNCE with invalid signature should be rejected", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket - rejects ANNOUNCE packet with malformed payload`() {
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
ttl = 10u,
|
||||
senderID = unknownPeerID,
|
||||
payload = byteArrayOf(0x00, 0x01, 0x02)
|
||||
)
|
||||
packet.signature = validSignature
|
||||
|
||||
val result = securityManager.validatePacket(packet, unknownPeerID)
|
||||
|
||||
assertFalse("ANNOUNCE with malformed payload should be rejected (cannot extract key)", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket - ignores own packets`() {
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.MESSAGE.value,
|
||||
ttl = 10u,
|
||||
senderID = myPeerID,
|
||||
payload = dummyPayload
|
||||
)
|
||||
packet.signature = null
|
||||
|
||||
val result = securityManager.validatePacket(packet, myPeerID)
|
||||
|
||||
assertFalse("Own packets should return false (skipped)", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket - detects duplicates`() {
|
||||
setupKnownPeer(otherPeerID, otherSigningKey)
|
||||
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.MESSAGE.value,
|
||||
ttl = 10u,
|
||||
senderID = otherPeerID,
|
||||
payload = dummyPayload
|
||||
)
|
||||
packet.signature = validSignature
|
||||
|
||||
val result1 = securityManager.validatePacket(packet, otherPeerID)
|
||||
assertTrue("First packet should be accepted", result1)
|
||||
|
||||
val result2 = securityManager.validatePacket(packet, otherPeerID)
|
||||
assertFalse("Duplicate packet should be rejected", result2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket - handles ANNOUNCE duplicates correctly`() {
|
||||
val announcement = IdentityAnnouncement(
|
||||
nickname = "New User",
|
||||
noisePublicKey = otherNoiseKey,
|
||||
signingPublicKey = otherSigningKey
|
||||
)
|
||||
val payload = announcement.encode()!!
|
||||
|
||||
// 1. Initial Announce (Fresh)
|
||||
val packet1 = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS, // 7u
|
||||
senderID = unknownPeerID,
|
||||
payload = payload
|
||||
)
|
||||
packet1.signature = validSignature
|
||||
|
||||
whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null)
|
||||
|
||||
assertTrue("First ANNOUNCE should be accepted", securityManager.validatePacket(packet1, unknownPeerID))
|
||||
|
||||
// 2. Relayed Duplicate (Lower TTL)
|
||||
val packet2 = packet1.copy(ttl = (com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS - 1u).toUByte())
|
||||
assertFalse("Relayed duplicate ANNOUNCE should be rejected", securityManager.validatePacket(packet2, unknownPeerID))
|
||||
|
||||
// 3. Direct Duplicate (Max TTL)
|
||||
val packet3 = packet1.copy(ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS)
|
||||
assertTrue("Fresh duplicate ANNOUNCE should be accepted", securityManager.validatePacket(packet3, unknownPeerID))
|
||||
}
|
||||
|
||||
private fun setupKnownPeer(peerID: String, signingKey: ByteArray) {
|
||||
val info = PeerInfo(
|
||||
id = peerID,
|
||||
nickname = "Test User",
|
||||
isConnected = true,
|
||||
isDirectConnection = true,
|
||||
noisePublicKey = ByteArray(32),
|
||||
signingPublicKey = signingKey,
|
||||
isVerifiedNickname = false,
|
||||
lastSeen = System.currentTimeMillis()
|
||||
)
|
||||
whenever(mockDelegate.getPeerInfo(peerID)).thenReturn(info)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.bitchat.android.services
|
||||
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Date
|
||||
|
||||
class AppStateStoreTest {
|
||||
@Before
|
||||
fun setUp() {
|
||||
AppStateStore.clear()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
AppStateStore.clear()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `public timeline collapses request sync replay even when android message ids differ`() {
|
||||
val timestamp = Date(1_700_000_000_000L)
|
||||
val originalDelivery = BitchatMessage(
|
||||
id = "random-id-from-first-delivery",
|
||||
sender = "alice",
|
||||
content = "hello from sync",
|
||||
timestamp = timestamp,
|
||||
senderPeerID = "1122334455667788"
|
||||
)
|
||||
val requestSyncReplay = originalDelivery.copy(id = "different-random-id-from-replay")
|
||||
|
||||
AppStateStore.addPublicMessage(originalDelivery)
|
||||
AppStateStore.addPublicMessage(requestSyncReplay)
|
||||
|
||||
assertEquals(listOf(originalDelivery), AppStateStore.publicMessages.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `public timeline still keeps same content sent at different packet timestamps`() {
|
||||
val first = BitchatMessage(
|
||||
id = "first-packet-id",
|
||||
sender = "alice",
|
||||
content = "same text",
|
||||
timestamp = Date(1_700_000_000_000L),
|
||||
senderPeerID = "1122334455667788"
|
||||
)
|
||||
val second = first.copy(
|
||||
id = "second-packet-id",
|
||||
timestamp = Date(first.timestamp.time + 1_000L)
|
||||
)
|
||||
|
||||
AppStateStore.addPublicMessage(first)
|
||||
AppStateStore.addPublicMessage(second)
|
||||
|
||||
assertEquals(listOf(first, second), AppStateStore.publicMessages.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.bitchat.android.services.meshgraph
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.Before
|
||||
|
||||
class MeshGraphServiceTest {
|
||||
|
||||
private lateinit var service: MeshGraphService
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
// Use the test-only API to reset the singleton state safely
|
||||
MeshGraphService.resetForTesting()
|
||||
service = MeshGraphService.getInstance()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateFromAnnouncement_AddsNeighbors() {
|
||||
val origin = "PeerA"
|
||||
val neighbors = listOf("PeerB", "PeerC")
|
||||
val timestamp = 100UL
|
||||
|
||||
service.updateFromAnnouncement(origin, "Alice", neighbors, timestamp)
|
||||
|
||||
val snapshot = service.graphState.value
|
||||
// Verify nodes
|
||||
assertTrue(snapshot.nodes.any { it.peerID == "PeerA" })
|
||||
assertTrue(snapshot.nodes.any { it.peerID == "PeerB" })
|
||||
assertTrue(snapshot.nodes.any { it.peerID == "PeerC" })
|
||||
|
||||
// Verify edges (unconfirmed because B and C haven't announced A)
|
||||
// A -> B
|
||||
val edgeAB = snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") }
|
||||
assertNotNull(edgeAB)
|
||||
assertFalse(edgeAB!!.isConfirmed)
|
||||
assertEquals("PeerA", edgeAB.confirmedBy)
|
||||
|
||||
// A -> C
|
||||
val edgeAC = snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerC") || (it.a == "PeerC" && it.b == "PeerA") }
|
||||
assertNotNull(edgeAC)
|
||||
assertFalse(edgeAC!!.isConfirmed)
|
||||
assertEquals("PeerA", edgeAC.confirmedBy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateFromAnnouncement_NewerTimestampReplacesNeighbors() {
|
||||
val origin = "PeerA"
|
||||
|
||||
// Initial state: A -> {B, C}
|
||||
service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 100UL)
|
||||
|
||||
// Update: A -> {B, D} (newer timestamp)
|
||||
service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerD"), 200UL)
|
||||
|
||||
val snapshot = service.graphState.value
|
||||
|
||||
// Verify Edge A-B exists
|
||||
assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") })
|
||||
|
||||
// Verify Edge A-D exists
|
||||
assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerD") || (it.a == "PeerD" && it.b == "PeerA") })
|
||||
|
||||
// Verify Edge A-C does NOT exist
|
||||
assertNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerC") || (it.a == "PeerC" && it.b == "PeerA") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateFromAnnouncement_OlderTimestampIsIgnored() {
|
||||
val origin = "PeerA"
|
||||
|
||||
// Initial state: A -> {B, C} at ts=200
|
||||
service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 200UL)
|
||||
|
||||
// Old Update: A -> {D} at ts=100
|
||||
service.updateFromAnnouncement(origin, "Alice", listOf("PeerD"), 100UL)
|
||||
|
||||
val snapshot = service.graphState.value
|
||||
|
||||
// Should still be {B, C}
|
||||
assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") })
|
||||
assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerC") || (it.a == "PeerC" && it.b == "PeerA") })
|
||||
assertNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerD") || (it.a == "PeerD" && it.b == "PeerA") })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateFromAnnouncement_NullNeighborsClearsList_TheFix() {
|
||||
val origin = "PeerA"
|
||||
|
||||
// Initial state: A -> {B, C} at ts=100
|
||||
service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 100UL)
|
||||
|
||||
// Update with NULL neighbors (omitted TLV) at ts=200
|
||||
service.updateFromAnnouncement(origin, "Alice", null, 200UL)
|
||||
|
||||
val snapshot = service.graphState.value
|
||||
|
||||
// All edges from A should be gone
|
||||
val edgesFromA = snapshot.edges.filter { it.a == "PeerA" || it.b == "PeerA" }
|
||||
assertTrue("Edges from PeerA should be empty after null update", edgesFromA.isEmpty())
|
||||
|
||||
// Nodes B and C might still exist if they were added to the node list, but connected edges are gone.
|
||||
// Actually, publishSnapshot collects nodes from nicknames and announcements.
|
||||
// Since we provided nicknames for PeerA, it should be there.
|
||||
// PeerB and PeerC were only in announcements. Since A's announcement is cleared, and B/C never announced,
|
||||
// they might disappear from the node list if 'nicknames' doesn't contain them.
|
||||
// Let's check edges primarily as that's what routing cares about.
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testUpdateFromAnnouncement_NullNeighborsWithOlderTimestampIsIgnored() {
|
||||
val origin = "PeerA"
|
||||
|
||||
// Initial state: A -> {B, C} at ts=200
|
||||
service.updateFromAnnouncement(origin, "Alice", listOf("PeerB", "PeerC"), 200UL)
|
||||
|
||||
// Old Update with NULL neighbors at ts=100
|
||||
service.updateFromAnnouncement(origin, "Alice", null, 100UL)
|
||||
|
||||
val snapshot = service.graphState.value
|
||||
|
||||
// Should still be {B, C} because the null update was older
|
||||
assertFalse(snapshot.edges.isEmpty())
|
||||
assertNotNull(snapshot.edges.find { (it.a == "PeerA" && it.b == "PeerB") || (it.a == "PeerB" && it.b == "PeerA") })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user