mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 05:45:19 +00:00
Migrate private media without silent downgrades
This commit is contained in:
@@ -2,6 +2,8 @@ package com.bitchat.android.mesh
|
||||
|
||||
import android.os.Build
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
@@ -16,6 +18,7 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mockito.kotlin.any
|
||||
import org.mockito.kotlin.anyOrNull
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.isNull
|
||||
import org.mockito.kotlin.mock
|
||||
@@ -49,7 +52,11 @@ class MessageHandlerTest {
|
||||
|
||||
whenever(delegate.getPeerInfo(peerID)).thenReturn(null)
|
||||
whenever(delegate.verifyEd25519Signature(any(), any(), any())).thenReturn(true)
|
||||
whenever(delegate.updatePeerInfo(any(), any(), any(), any(), any())).thenReturn(true)
|
||||
whenever(
|
||||
delegate.updatePeerInfoFromVerifiedAnnouncement(
|
||||
any(), any(), any(), any(), any(), anyOrNull()
|
||||
)
|
||||
).thenReturn(true)
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -58,46 +65,161 @@ class MessageHandlerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleAnnounce accepts announce within clock skew tolerance for identity binding`() = runBlocking {
|
||||
val packet = announcePacket(ageMs = AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000)
|
||||
fun `handleAnnounce accepts announce within clock skew tolerance for identity binding`() {
|
||||
runBlocking {
|
||||
val packet = announcePacket(ageMs = AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000)
|
||||
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
|
||||
|
||||
assertTrue("Announce within clock skew tolerance should still store peer identity", result)
|
||||
verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true))
|
||||
verify(delegate).updatePeerIDBinding(eq(peerID), eq(nickname), any(), isNull())
|
||||
assertTrue("Announce within clock skew tolerance should still store peer identity", result)
|
||||
verify(delegate).updatePeerInfoFromVerifiedAnnouncement(
|
||||
eq(peerID), eq(nickname), any(), any(), eq(true), isNull()
|
||||
)
|
||||
verify(delegate).onVerifiedAnnouncementProcessed(peerID)
|
||||
verify(delegate).updatePeerIDBinding(eq(peerID), eq(nickname), any(), isNull())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleAnnounce accepts future announce within clock skew tolerance`() = runBlocking {
|
||||
val packet = announcePacket(ageMs = -(AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000))
|
||||
fun `handleAnnounce accepts future announce within clock skew tolerance`() {
|
||||
runBlocking {
|
||||
val packet = announcePacket(ageMs = -(AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000))
|
||||
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
|
||||
|
||||
assertTrue("Future announce within clock skew tolerance should still store peer identity", result)
|
||||
verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true))
|
||||
Unit
|
||||
assertTrue("Future announce within clock skew tolerance should still store peer identity", result)
|
||||
verify(delegate).updatePeerInfoFromVerifiedAnnouncement(
|
||||
eq(peerID), eq(nickname), any(), any(), eq(true), isNull()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleAnnounce rejects announce older than clock skew tolerance`() = runBlocking {
|
||||
val packet = announcePacket(ageMs = announceClockSkewToleranceMs + 1_000)
|
||||
fun `handleAnnounce stores advertised capabilities including unknown bits`() {
|
||||
runBlocking {
|
||||
val capabilities = PeerCapabilities(
|
||||
PeerCapabilities.PRIVATE_MEDIA.rawValue or (1L shl 15)
|
||||
)
|
||||
val packet = announcePacket(ageMs = 0, capabilities = capabilities)
|
||||
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "relay-link"))
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
|
||||
|
||||
assertFalse("Announce older than clock skew tolerance should not store peer identity", result)
|
||||
verify(delegate, never()).updatePeerInfo(any(), any(), any(), any(), any())
|
||||
verify(delegate, never()).updatePeerIDBinding(any(), any(), any(), any())
|
||||
assertTrue(result)
|
||||
verify(delegate).updatePeerInfoFromVerifiedAnnouncement(
|
||||
eq(peerID),
|
||||
eq(nickname),
|
||||
any(),
|
||||
any(),
|
||||
eq(true),
|
||||
eq(capabilities)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleAnnounce rejects announce older than clock skew tolerance`() {
|
||||
runBlocking {
|
||||
val packet = announcePacket(ageMs = announceClockSkewToleranceMs + 1_000)
|
||||
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "relay-link"))
|
||||
|
||||
assertFalse("Announce older than clock skew tolerance should not store peer identity", result)
|
||||
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
|
||||
any(), any(), any(), any(), any(), anyOrNull()
|
||||
)
|
||||
verify(delegate, never()).onVerifiedAnnouncementProcessed(any())
|
||||
verify(delegate, never()).updatePeerIDBinding(any(), any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleAnnounce never promotes capability after invalid signature`() {
|
||||
runBlocking {
|
||||
whenever(delegate.verifyEd25519Signature(any(), any(), any())).thenReturn(false)
|
||||
val packet = announcePacket(ageMs = 0, capabilities = PeerCapabilities.PRIVATE_MEDIA)
|
||||
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
|
||||
|
||||
assertFalse(result)
|
||||
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
|
||||
any(), any(), any(), any(), any(), anyOrNull()
|
||||
)
|
||||
verify(delegate, never()).onVerifiedAnnouncementProcessed(any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `directed raw private media requires a valid signature`() {
|
||||
runBlocking {
|
||||
whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST)
|
||||
whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname)
|
||||
whenever(delegate.verifySignature(any(), eq(peerID))).thenReturn(false)
|
||||
val file = BitchatFilePacket(
|
||||
fileName = "legacy.jpg",
|
||||
fileSize = 3,
|
||||
mimeType = "image/jpeg",
|
||||
content = byteArrayOf(1, 2, 3)
|
||||
)
|
||||
val unsigned = BitchatPacket(
|
||||
version = 2u,
|
||||
type = MessageType.FILE_TRANSFER.value,
|
||||
senderID = peerID.hexToBytes(),
|
||||
recipientID = myPeerID.hexToBytes(),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = file.encode()!!,
|
||||
signature = null,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
handler.handleMessage(RoutedPacket(unsigned, peerID, "direct-link"))
|
||||
handler.handleMessage(
|
||||
RoutedPacket(unsigned.copy(signature = ByteArray(64) { 1 }), peerID, "direct-link")
|
||||
)
|
||||
|
||||
verify(delegate, never()).onMessageReceived(any())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid signed directed raw private media remains interoperable`() {
|
||||
runBlocking {
|
||||
whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST)
|
||||
whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname)
|
||||
whenever(delegate.verifySignature(any(), eq(peerID))).thenReturn(true)
|
||||
val file = BitchatFilePacket(
|
||||
fileName = "legacy-valid.jpg",
|
||||
fileSize = 3,
|
||||
mimeType = "image/jpeg",
|
||||
content = byteArrayOf(1, 2, 3)
|
||||
)
|
||||
val packet = BitchatPacket(
|
||||
version = 2u,
|
||||
type = MessageType.FILE_TRANSFER.value,
|
||||
senderID = peerID.hexToBytes(),
|
||||
recipientID = myPeerID.hexToBytes(),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = file.encode()!!,
|
||||
signature = signature,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
handler.handleMessage(RoutedPacket(packet, peerID, "direct-link"))
|
||||
|
||||
verify(delegate).verifySignature(packet, peerID)
|
||||
verify(delegate).onMessageReceived(any())
|
||||
}
|
||||
}
|
||||
|
||||
private fun announcePacket(
|
||||
ageMs: Long,
|
||||
ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte()
|
||||
ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(),
|
||||
capabilities: PeerCapabilities? = null
|
||||
): BitchatPacket {
|
||||
val announcement = IdentityAnnouncement(
|
||||
nickname = nickname,
|
||||
noisePublicKey = noiseKey,
|
||||
signingPublicKey = signingKey
|
||||
signingPublicKey = signingKey,
|
||||
capabilities = capabilities
|
||||
)
|
||||
return BitchatPacket(
|
||||
version = 1u,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PrivateMediaSecurityTest {
|
||||
private class MemoryPins : PrivateMediaCapabilityPinStore {
|
||||
val pins = mutableSetOf<String>()
|
||||
override fun contains(fingerprint: String): Boolean = fingerprint in pins
|
||||
override fun insert(fingerprint: String) {
|
||||
pins += fingerprint
|
||||
}
|
||||
}
|
||||
|
||||
private val peerID = "0011223344556677"
|
||||
private val remoteStatic = ByteArray(32) { (it + 1).toByte() }
|
||||
private var peerInfo: PeerInfo? = null
|
||||
private var authenticatedRemoteStatic: ByteArray? = null
|
||||
private val pins = MemoryPins()
|
||||
private val controller = PrivateMediaSecurityController(
|
||||
peerInfoProvider = { peerInfo },
|
||||
authenticatedRemoteStaticProvider = { authenticatedRemoteStatic?.copyOf() },
|
||||
pinStore = pins
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `announce before handshake promotes only after authenticated key arrives`() {
|
||||
peerInfo = peer(capabilities = PeerCapabilities.PRIVATE_MEDIA)
|
||||
|
||||
assertFalse(controller.refreshAuthenticatedCapability(peerID))
|
||||
assertTrue(pins.pins.isEmpty())
|
||||
|
||||
authenticatedRemoteStatic = remoteStatic
|
||||
assertTrue(controller.refreshAuthenticatedCapability(peerID))
|
||||
assertEquals(PrivateMediaPolicyDecision.Encrypted, controller.sendPolicy(peerID))
|
||||
assertEquals(1, pins.pins.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handshake before announce promotes only after verified announce arrives`() {
|
||||
authenticatedRemoteStatic = remoteStatic
|
||||
|
||||
assertFalse(controller.refreshAuthenticatedCapability(peerID))
|
||||
assertTrue(pins.pins.isEmpty())
|
||||
|
||||
peerInfo = peer(capabilities = PeerCapabilities.PRIVATE_MEDIA)
|
||||
assertTrue(controller.refreshAuthenticatedCapability(peerID))
|
||||
assertEquals(PrivateMediaPolicyDecision.Encrypted, controller.sendPolicy(peerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `self certified or mismatched announcement never creates pin`() {
|
||||
authenticatedRemoteStatic = remoteStatic
|
||||
peerInfo = peer(
|
||||
capabilities = PeerCapabilities.PRIVATE_MEDIA,
|
||||
noiseKey = ByteArray(32) { 0x55 }
|
||||
)
|
||||
|
||||
assertFalse(controller.refreshAuthenticatedCapability(peerID))
|
||||
assertTrue(pins.pins.isEmpty())
|
||||
assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Blocked)
|
||||
|
||||
// A normal peer refresh may carry a new current key, but it cannot
|
||||
// transfer capability trust away from the key in the signed announce.
|
||||
peerInfo = peer(
|
||||
capabilities = PeerCapabilities.PRIVATE_MEDIA,
|
||||
noiseKey = remoteStatic,
|
||||
verifiedAnnouncementNoiseKey = ByteArray(32) { 0x55 }
|
||||
)
|
||||
assertFalse(controller.refreshAuthenticatedCapability(peerID))
|
||||
assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Blocked)
|
||||
|
||||
peerInfo = peer(
|
||||
capabilities = PeerCapabilities.PRIVATE_MEDIA,
|
||||
hasVerifiedAnnouncement = false
|
||||
)
|
||||
assertFalse(controller.refreshAuthenticatedCapability(peerID))
|
||||
assertTrue(pins.pins.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `signed absent and explicit empty capabilities both require one shot consent`() {
|
||||
authenticatedRemoteStatic = remoteStatic
|
||||
|
||||
peerInfo = peer(capabilities = null)
|
||||
assertEquals(
|
||||
PrivateMediaPolicyDecision.RequiresLegacyConsent,
|
||||
controller.sendPolicy(peerID)
|
||||
)
|
||||
|
||||
peerInfo = peer(capabilities = PeerCapabilities.NONE)
|
||||
assertEquals(
|
||||
PrivateMediaPolicyDecision.RequiresLegacyConsent,
|
||||
controller.sendPolicy(peerID)
|
||||
)
|
||||
assertTrue(pins.pins.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pin is HSTS and prevents later capability downgrade`() {
|
||||
authenticatedRemoteStatic = remoteStatic
|
||||
peerInfo = peer(capabilities = PeerCapabilities.PRIVATE_MEDIA)
|
||||
assertEquals(PrivateMediaPolicyDecision.Encrypted, controller.sendPolicy(peerID))
|
||||
|
||||
peerInfo = peer(capabilities = null)
|
||||
assertEquals(PrivateMediaPolicyDecision.Encrypted, controller.sendPolicy(peerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pin never bypasses requirement for a live authenticated static key`() {
|
||||
authenticatedRemoteStatic = remoteStatic
|
||||
peerInfo = peer(capabilities = PeerCapabilities.PRIVATE_MEDIA)
|
||||
assertEquals(PrivateMediaPolicyDecision.Encrypted, controller.sendPolicy(peerID))
|
||||
|
||||
authenticatedRemoteStatic = null
|
||||
assertEquals(PrivateMediaPolicyDecision.NeedsHandshake, controller.sendPolicy(peerID))
|
||||
}
|
||||
|
||||
private fun peer(
|
||||
capabilities: PeerCapabilities?,
|
||||
noiseKey: ByteArray = remoteStatic,
|
||||
hasVerifiedAnnouncement: Boolean = true,
|
||||
verifiedAnnouncementNoiseKey: ByteArray = noiseKey
|
||||
) = PeerInfo(
|
||||
id = peerID,
|
||||
nickname = "peer",
|
||||
isConnected = true,
|
||||
isDirectConnection = true,
|
||||
noisePublicKey = noiseKey,
|
||||
signingPublicKey = ByteArray(32) { 9 },
|
||||
isVerifiedNickname = true,
|
||||
lastSeen = 1,
|
||||
capabilities = capabilities,
|
||||
hasVerifiedAnnouncement = hasVerifiedAnnouncement,
|
||||
verifiedAnnouncementNoisePublicKey = if (hasVerifiedAnnouncement) {
|
||||
verifiedAnnouncementNoiseKey
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.util.Random
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class PrivateMediaTransferPreparerTest {
|
||||
private val senderID = hex("0011223344556677")
|
||||
private val recipientID = hex("8877665544332211")
|
||||
private val fragmentManagers = mutableListOf<FragmentManager>()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
fragmentManagers.forEach(FragmentManager::shutdown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `encrypted mode emits deployed Noise 0x20 and signs before fragmentation`() {
|
||||
var encryptedPlaintext: ByteArray? = null
|
||||
val preparer = preparer(
|
||||
policy = PrivateMediaPolicyDecision.Encrypted,
|
||||
encrypt = { bytes, _ ->
|
||||
encryptedPlaintext = bytes
|
||||
byteArrayOf(0x41) + bytes
|
||||
}
|
||||
)
|
||||
|
||||
val outcome = preparer.prepare("peer", recipientID, file(64), false)
|
||||
|
||||
val ready = outcome as PrivateMediaBuildOutcome.Ready
|
||||
assertEquals(MessageType.NOISE_ENCRYPTED.value, ready.built.packet.type)
|
||||
assertNotNull(ready.built.packet.signature)
|
||||
assertEquals(PrivateMediaWireMode.ENCRYPTED_NOISE_0X20, ready.built.wireMode)
|
||||
val decoded = NoisePayload.decode(encryptedPlaintext!!)
|
||||
assertEquals(NoisePayloadType.FILE_TRANSFER, decoded?.type)
|
||||
assertEquals(0x20u.toUByte(), encryptedPlaintext!![0].toUByte())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy mode requires consent and signing failure aborts`() {
|
||||
val noConsent = preparer(policy = PrivateMediaPolicyDecision.RequiresLegacyConsent)
|
||||
.prepare("peer", recipientID, file(64), false)
|
||||
assertTrue(noConsent is PrivateMediaBuildOutcome.RequiresLegacyConsent)
|
||||
|
||||
val signingFailure = preparer(
|
||||
policy = PrivateMediaPolicyDecision.RequiresLegacyConsent,
|
||||
finalizer = { null }
|
||||
).prepare("peer", recipientID, file(64), true)
|
||||
assertTrue(signingFailure is PrivateMediaBuildOutcome.Rejected)
|
||||
assertTrue((signingFailure as PrivateMediaBuildOutcome.Rejected).reason.contains("nothing was sent"))
|
||||
|
||||
val malformedSignature = preparer(
|
||||
policy = PrivateMediaPolicyDecision.RequiresLegacyConsent,
|
||||
finalizer = { packet -> packet.copy(signature = ByteArray(0)) }
|
||||
).prepare("peer", recipientID, file(64), true)
|
||||
assertTrue(malformedSignature is PrivateMediaBuildOutcome.Rejected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `consented legacy mode is signed directed raw 0x22`() {
|
||||
val outcome = preparer(policy = PrivateMediaPolicyDecision.RequiresLegacyConsent)
|
||||
.prepare("peer", recipientID, file(64), true)
|
||||
|
||||
val ready = outcome as PrivateMediaBuildOutcome.Ready
|
||||
assertEquals(MessageType.FILE_TRANSFER.value, ready.built.packet.type)
|
||||
assertTrue(ready.built.packet.recipientID!!.contentEquals(recipientID))
|
||||
assertNotNull(ready.built.packet.signature)
|
||||
assertEquals(PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22, ready.built.wireMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handshake requirement returns before encoding signing encryption or fragmentation`() {
|
||||
var encrypted = false
|
||||
var finalized = false
|
||||
var fragmented = false
|
||||
val fragmentManager = FragmentManager().also { fragmentManagers += it }
|
||||
val preparer = PrivateMediaTransferPreparer(
|
||||
senderID = senderID,
|
||||
ttl = 7u,
|
||||
policyProvider = { PrivateMediaPolicyDecision.NeedsHandshake },
|
||||
encrypt = { _, _ ->
|
||||
encrypted = true
|
||||
byteArrayOf(1)
|
||||
},
|
||||
finalizeRoutedAndSigned = {
|
||||
finalized = true
|
||||
it
|
||||
},
|
||||
fragment = { packet, maxFragments ->
|
||||
fragmented = true
|
||||
fragmentManager.createFragments(packet, maxFragments)
|
||||
}
|
||||
)
|
||||
|
||||
val outcome = preparer.prepare("peer", recipientID, file(64), false)
|
||||
|
||||
assertEquals(PrivateMediaBuildOutcome.NeedsHandshake, outcome)
|
||||
assertTrue(!encrypted)
|
||||
assertTrue(!finalized)
|
||||
assertTrue(!fragmented)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no route accepts 256 final fragments and rejects 257`() {
|
||||
assertExactBoundary(route = null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `source route accepts 256 final fragments and rejects 257`() {
|
||||
assertExactBoundary(
|
||||
route = listOf(
|
||||
hex("1021324354657687"),
|
||||
hex("2031425364758697"),
|
||||
hex("30415263748596a7")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertExactBoundary(route: List<ByteArray>?) {
|
||||
val randomContent = ByteArray(180 * 1024).also { Random(0xB17C4A7).nextBytes(it) }
|
||||
val preparer = preparer(
|
||||
policy = PrivateMediaPolicyDecision.Encrypted,
|
||||
finalizer = { packet ->
|
||||
packet.copy(
|
||||
version = if (route == null) packet.version else 2u,
|
||||
route = route,
|
||||
signature = ByteArray(64) { 0x5A }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
fun outcome(contentSize: Int): PrivateMediaBuildOutcome = preparer.prepare(
|
||||
"peer",
|
||||
recipientID,
|
||||
BitchatFilePacket(
|
||||
fileName = "boundary.bin",
|
||||
fileSize = contentSize.toLong(),
|
||||
mimeType = "application/octet-stream",
|
||||
content = randomContent.copyOf(contentSize)
|
||||
),
|
||||
false
|
||||
)
|
||||
|
||||
var low = 1
|
||||
var high = randomContent.size
|
||||
while (low < high) {
|
||||
val mid = low + (high - low) / 2
|
||||
if (outcome(mid) is PrivateMediaBuildOutcome.Rejected) high = mid else low = mid + 1
|
||||
}
|
||||
|
||||
val accepted = outcome(low - 1) as PrivateMediaBuildOutcome.Ready
|
||||
val rejected = outcome(low)
|
||||
assertEquals(256, accepted.built.fragments.size)
|
||||
assertTrue(rejected is PrivateMediaBuildOutcome.Rejected)
|
||||
assertTrue((rejected as PrivateMediaBuildOutcome.Rejected).reason.contains("256"))
|
||||
}
|
||||
|
||||
private fun preparer(
|
||||
policy: PrivateMediaPolicyDecision,
|
||||
encrypt: (ByteArray, String) -> ByteArray? = { bytes, _ -> byteArrayOf(0x01) + bytes },
|
||||
finalizer: (BitchatPacket) -> BitchatPacket? = { packet ->
|
||||
packet.copy(signature = ByteArray(64) { 0x33 })
|
||||
}
|
||||
): PrivateMediaTransferPreparer {
|
||||
val fragmentManager = FragmentManager().also { fragmentManagers += it }
|
||||
return PrivateMediaTransferPreparer(
|
||||
senderID = senderID,
|
||||
ttl = 7u,
|
||||
policyProvider = { policy },
|
||||
encrypt = encrypt,
|
||||
finalizeRoutedAndSigned = finalizer,
|
||||
fragment = fragmentManager::createFragments,
|
||||
now = { 1_700_000_000_000uL }
|
||||
)
|
||||
}
|
||||
|
||||
private fun file(size: Int) = BitchatFilePacket(
|
||||
fileName = "test.bin",
|
||||
fileSize = size.toLong(),
|
||||
mimeType = "application/octet-stream",
|
||||
content = ByteArray(size) { it.toByte() }
|
||||
)
|
||||
|
||||
private fun hex(value: String): ByteArray =
|
||||
value.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
@@ -40,6 +40,7 @@ class SecurityManagerTest {
|
||||
open class FakeEncryptionService : EncryptionService(RuntimeEnvironment.getApplication()) {
|
||||
var shouldVerify: Boolean = true
|
||||
var lastVerifySignature: ByteArray? = null
|
||||
var lastVerifyData: ByteArray? = null
|
||||
var lastVerifyKey: ByteArray? = null
|
||||
|
||||
override fun initialize() {
|
||||
@@ -48,6 +49,7 @@ class SecurityManagerTest {
|
||||
|
||||
override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
|
||||
lastVerifySignature = signature
|
||||
lastVerifyData = data
|
||||
lastVerifyKey = publicKeyBytes
|
||||
|
||||
// Simple logic: if configured to verify, check if signature matches validSignature
|
||||
@@ -90,6 +92,44 @@ class SecurityManagerTest {
|
||||
assertFalse("Packet without signature should be rejected", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verifySignature - verifies canonical packet with announced signing key`() {
|
||||
setupKnownPeer(otherPeerID, otherSigningKey)
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.FILE_TRANSFER.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = dummyPayload,
|
||||
signature = validSignature,
|
||||
ttl = 10u
|
||||
)
|
||||
|
||||
assertTrue(securityManager.verifySignature(packet, otherPeerID))
|
||||
assertTrue(fakeEncryptionService.lastVerifySignature.contentEquals(validSignature))
|
||||
assertTrue(fakeEncryptionService.lastVerifyData.contentEquals(packet.toBinaryDataForSigning()))
|
||||
assertTrue(fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verifySignature - rejects missing signature and unknown signing key`() {
|
||||
val unsigned = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.FILE_TRANSFER.value,
|
||||
senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID),
|
||||
recipientID = MeshPacketUtils.hexStringToByteArray(myPeerID),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = dummyPayload,
|
||||
ttl = 10u
|
||||
)
|
||||
assertFalse(securityManager.verifySignature(unsigned, otherPeerID))
|
||||
|
||||
unsigned.signature = validSignature
|
||||
whenever(mockDelegate.getPeerInfo(otherPeerID)).thenReturn(null)
|
||||
assertFalse(securityManager.verifySignature(unsigned, otherPeerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket - rejects packet with invalid signature`() {
|
||||
setupKnownPeer(otherPeerID, otherSigningKey)
|
||||
@@ -107,6 +147,22 @@ class SecurityManagerTest {
|
||||
assertFalse("Packet with invalid signature should be rejected", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid packet does not poison duplicate detection for later valid packet`() {
|
||||
setupKnownPeer(otherPeerID, otherSigningKey)
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.MESSAGE.value,
|
||||
ttl = 10u,
|
||||
senderID = otherPeerID,
|
||||
payload = dummyPayload
|
||||
)
|
||||
packet.signature = invalidSignature
|
||||
assertFalse(securityManager.validatePacket(packet, otherPeerID))
|
||||
|
||||
packet.signature = validSignature
|
||||
assertTrue(securityManager.validatePacket(packet, otherPeerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket - rejects packet from unknown peer (no key)`() {
|
||||
whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null)
|
||||
|
||||
Reference in New Issue
Block a user