mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-27 15:25:25 +00:00
Migrate private media without silent downgrades
This commit is contained in:
@@ -5,6 +5,8 @@ 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.assertNull
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -180,6 +182,68 @@ class FragmentManagerTest {
|
||||
assertTrue("Payload content should match", originalPacket.payload.contentEquals(reassembledPacket.payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inbound fragment set above 256 is rejected`() {
|
||||
val payload = FragmentPayload(
|
||||
fragmentID = ByteArray(8) { 1 },
|
||||
index = 0,
|
||||
total = 257,
|
||||
originalType = MessageType.NOISE_ENCRYPTED.value,
|
||||
data = byteArrayOf(1)
|
||||
).encode()
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.FRAGMENT.value,
|
||||
senderID = hexStringToByteArray(senderID),
|
||||
recipientID = hexStringToByteArray(recipientID),
|
||||
timestamp = 1u,
|
||||
payload = payload,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
assertNull(fragmentManager.handleFragment(packet))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fragment payload refuses UInt16 truncation`() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
FragmentPayload(
|
||||
fragmentID = ByteArray(8) { 2 },
|
||||
index = 0,
|
||||
total = 65_536,
|
||||
originalType = MessageType.NOISE_ENCRYPTED.value,
|
||||
data = byteArrayOf(1)
|
||||
).encode()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generic public packet retains a 257 fragment outbound plan`() {
|
||||
val randomPayload = ByteArray(180 * 1024).also { Random(0xB17C4A7).nextBytes(it) }
|
||||
|
||||
fun plan(contentSize: Int): List<BitchatPacket> = fragmentManager.createFragments(
|
||||
BitchatPacket(
|
||||
version = 2u,
|
||||
type = MessageType.FILE_TRANSFER.value,
|
||||
senderID = hexStringToByteArray(senderID),
|
||||
recipientID = com.bitchat.android.protocol.SpecialRecipients.BROADCAST,
|
||||
timestamp = 1u,
|
||||
payload = randomPayload.copyOf(contentSize),
|
||||
signature = ByteArray(64) { 7 },
|
||||
ttl = 7u
|
||||
)
|
||||
)
|
||||
|
||||
var low = 1
|
||||
var high = randomPayload.size
|
||||
while (low < high) {
|
||||
val mid = low + (high - low) / 2
|
||||
if (plan(mid).size >= 257) high = mid else low = mid + 1
|
||||
}
|
||||
|
||||
assertEquals(257, plan(low).size)
|
||||
}
|
||||
|
||||
private fun hexStringToByteArray(hexString: String): ByteArray {
|
||||
val result = ByteArray(8)
|
||||
for (i in 0 until 8) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.bitchat
|
||||
|
||||
import com.bitchat.android.mesh.PeerManager
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@@ -31,6 +32,71 @@ class PeerManagerTest {
|
||||
|
||||
val emptyDeviceAddresses = emptyMap<String, String>()
|
||||
|
||||
@Test
|
||||
fun peer_capabilities_are_retained_with_verified_identity() {
|
||||
val capabilities = PeerCapabilities(
|
||||
PeerCapabilities.PRIVATE_MEDIA.rawValue or (1L shl 15)
|
||||
)
|
||||
|
||||
peerManager.updatePeerInfoFromVerifiedAnnouncement(
|
||||
peerID = "peer-capabilities",
|
||||
nickname = "alice",
|
||||
noisePublicKey = ByteArray(32) { 1 },
|
||||
signingPublicKey = ByteArray(32) { 2 },
|
||||
isVerified = true,
|
||||
capabilities = capabilities
|
||||
)
|
||||
|
||||
assertEquals(capabilities, peerManager.getPeerInfo("peer-capabilities")?.capabilities)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normal_peer_updates_preserve_signed_absent_and_empty_capabilities() {
|
||||
val peerID = "peer-capability-state"
|
||||
val noiseKey = ByteArray(32) { 3 }
|
||||
val signingKey = ByteArray(32) { 4 }
|
||||
|
||||
peerManager.updatePeerInfoFromVerifiedAnnouncement(
|
||||
peerID,
|
||||
"alice",
|
||||
noiseKey,
|
||||
signingKey,
|
||||
true,
|
||||
null
|
||||
)
|
||||
var info = peerManager.getPeerInfo(peerID)!!
|
||||
assertEquals(true, info.hasVerifiedAnnouncement)
|
||||
assertEquals(null, info.capabilities)
|
||||
assertEquals(true, info.verifiedAnnouncementNoisePublicKey!!.contentEquals(noiseKey))
|
||||
|
||||
peerManager.updatePeerInfo(peerID, "alice2", noiseKey, signingKey, true)
|
||||
info = peerManager.getPeerInfo(peerID)!!
|
||||
assertEquals(true, info.hasVerifiedAnnouncement)
|
||||
assertEquals(null, info.capabilities)
|
||||
|
||||
peerManager.updatePeerInfoFromVerifiedAnnouncement(
|
||||
peerID,
|
||||
"alice2",
|
||||
noiseKey,
|
||||
signingKey,
|
||||
true,
|
||||
PeerCapabilities.NONE
|
||||
)
|
||||
peerManager.updatePeerInfo(peerID, "alice3", noiseKey, signingKey, true)
|
||||
info = peerManager.getPeerInfo(peerID)!!
|
||||
assertEquals(true, info.hasVerifiedAnnouncement)
|
||||
assertEquals(PeerCapabilities.NONE, info.capabilities)
|
||||
|
||||
val changedNoiseKey = ByteArray(32) { 7 }
|
||||
peerManager.updatePeerInfo(peerID, "alice4", changedNoiseKey, signingKey, true)
|
||||
info = peerManager.getPeerInfo(peerID)!!
|
||||
assertEquals(PeerCapabilities.NONE, info.capabilities)
|
||||
assertEquals(
|
||||
true,
|
||||
info.verifiedAnnouncementNoisePublicKey!!.contentEquals(noiseKey)
|
||||
)
|
||||
}
|
||||
|
||||
val testRSSI = mapOf(
|
||||
"peer1" to 0,
|
||||
"peer2" to 10,
|
||||
@@ -257,4 +323,4 @@ class PeerManagerTest {
|
||||
|
||||
assertEquals(expectedLine2, actualLine2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.bitchat.android.identity
|
||||
|
||||
import android.content.Context
|
||||
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.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class PrivateMediaCapabilityPinPersistenceTest {
|
||||
private val fingerprint = "ab".repeat(32)
|
||||
private lateinit var manager: SecureIdentityStateManager
|
||||
private lateinit var prefs: android.content.SharedPreferences
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
prefs = RuntimeEnvironment.getApplication().getSharedPreferences(
|
||||
"private-media-pin-${UUID.randomUUID()}",
|
||||
Context.MODE_PRIVATE
|
||||
)
|
||||
manager = SecureIdentityStateManager(prefs, testOnly = true)
|
||||
manager.clearIdentityData()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
manager.clearIdentityData()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capability pin persists and panic identity wipe removes it`() {
|
||||
manager.markPrivateMediaCapable(fingerprint)
|
||||
|
||||
val reloaded = SecureIdentityStateManager(prefs, testOnly = true)
|
||||
assertTrue(reloaded.isPrivateMediaCapable(fingerprint))
|
||||
|
||||
reloaded.clearIdentityData()
|
||||
// Simulate an old BLE/Wi-Fi controller finishing a pre-panic callback
|
||||
// after another manager performed the wipe.
|
||||
manager.markPrivateMediaCapable(fingerprint)
|
||||
val afterPanic = SecureIdentityStateManager(prefs, testOnly = true)
|
||||
assertFalse(afterPanic.isPrivateMediaCapable(fingerprint))
|
||||
assertTrue(afterPanic.getPrivateMediaCapabilityPinsForTesting().isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class IdentityAnnouncementTest {
|
||||
private val nickname = "peer"
|
||||
private val noiseKey = ByteArray(32) { 0x11 }
|
||||
private val signingKey = ByteArray(32) { 0x22 }
|
||||
|
||||
@Test
|
||||
fun `private media capability uses iOS little-endian bytes`() {
|
||||
assertArrayEquals(byteArrayOf(0x00, 0x01), PeerCapabilities.PRIVATE_MEDIA.encoded())
|
||||
assertTrue(PeerCapabilities.decode(byteArrayOf(0x00, 0x01)).contains(PeerCapabilities.PRIVATE_MEDIA))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy announcement without capability TLV still decodes`() {
|
||||
val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!!
|
||||
|
||||
val decoded = IdentityAnnouncement.decode(legacy)!!
|
||||
|
||||
assertEquals(nickname, decoded.nickname)
|
||||
assertArrayEquals(noiseKey, decoded.noisePublicKey)
|
||||
assertArrayEquals(signingKey, decoded.signingPublicKey)
|
||||
assertNull(decoded.capabilities)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit empty capability TLV decodes as present but empty`() {
|
||||
val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!!
|
||||
|
||||
val decoded = IdentityAnnouncement.decode(legacy + byteArrayOf(0x05, 0x00))!!
|
||||
|
||||
assertEquals(PeerCapabilities.NONE, decoded.capabilities)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown capability bits and TLVs survive decode and re-encode`() {
|
||||
val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!!
|
||||
val wire = legacy + byteArrayOf(
|
||||
0x05, 0x02, 0x00, 0x81.toByte(), // privateMedia plus unknown bit 15
|
||||
0x7F, 0x03, 0x01, 0x02, 0x03
|
||||
)
|
||||
|
||||
val decoded = IdentityAnnouncement.decode(wire)!!
|
||||
|
||||
assertEquals(0x8100L, decoded.capabilities?.rawValue)
|
||||
assertEquals(1, decoded.unknownTLVs.size)
|
||||
assertEquals(0x7F, decoded.unknownTLVs.single().type)
|
||||
assertArrayEquals(byteArrayOf(0x01, 0x02, 0x03), decoded.unknownTLVs.single().value)
|
||||
|
||||
val roundTripped = IdentityAnnouncement.decode(decoded.encode()!!)!!
|
||||
assertEquals(decoded.capabilities, roundTripped.capabilities)
|
||||
assertEquals(decoded.unknownTLVs, roundTripped.unknownTLVs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local announcement send advertises private media`() {
|
||||
val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!!
|
||||
|
||||
assertArrayEquals(
|
||||
byteArrayOf(0x05, 0x02, 0x00, 0x01),
|
||||
encoded.takeLast(4).toByteArray()
|
||||
)
|
||||
assertTrue(
|
||||
IdentityAnnouncement.decode(encoded)!!
|
||||
.capabilities!!
|
||||
.contains(PeerCapabilities.PRIVATE_MEDIA)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.bitchat.android.service
|
||||
|
||||
import android.os.Build
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
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 org.robolectric.annotation.Config
|
||||
import java.util.UUID
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
|
||||
class TransportBridgeServiceTest {
|
||||
private val targetId = "test-${UUID.randomUUID()}"
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
TransportBridgeService.unregister(targetId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bridged prepared plan retains exact payloads with decremented TTL`() {
|
||||
var captured: RoutedPacket? = null
|
||||
TransportBridgeService.register(
|
||||
targetId,
|
||||
object : TransportBridgeService.TransportLayer {
|
||||
override fun send(packet: RoutedPacket) {
|
||||
captured = packet
|
||||
}
|
||||
}
|
||||
)
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = ByteArray(8) { 1 },
|
||||
recipientID = ByteArray(8) { 2 },
|
||||
timestamp = System.nanoTime().toULong(),
|
||||
payload = byteArrayOf(3, 4, 5),
|
||||
signature = ByteArray(64) { 6 },
|
||||
ttl = 7u
|
||||
)
|
||||
val prepared = listOf(
|
||||
packet.copy(type = MessageType.FRAGMENT.value, payload = byteArrayOf(10)),
|
||||
packet.copy(type = MessageType.FRAGMENT.value, payload = byteArrayOf(11))
|
||||
)
|
||||
|
||||
TransportBridgeService.broadcast(
|
||||
sourceId = "source-${UUID.randomUUID()}",
|
||||
packet = RoutedPacket(packet, preparedPackets = prepared)
|
||||
)
|
||||
|
||||
val forwarded = captured
|
||||
assertNotNull(forwarded)
|
||||
assertEquals(6u.toUByte(), forwarded!!.packet.ttl)
|
||||
assertEquals(2, forwarded.preparedPackets?.size)
|
||||
forwarded.preparedPackets!!.zip(prepared).forEach { (actual, original) ->
|
||||
assertEquals(6u.toUByte(), actual.ttl)
|
||||
assertTrue(actual.payload.contentEquals(original.payload))
|
||||
assertEquals(original.type, actual.type)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
import com.bitchat.android.mesh.MeshService
|
||||
import com.bitchat.android.mesh.PreparedPrivateMediaTransfer
|
||||
import com.bitchat.android.mesh.PrivateMediaPreparation
|
||||
import com.bitchat.android.mesh.PrivateMediaWireMode
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import org.junit.After
|
||||
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.mockito.kotlin.any
|
||||
import org.mockito.kotlin.eq
|
||||
import org.mockito.kotlin.mock
|
||||
import org.mockito.kotlin.never
|
||||
import org.mockito.kotlin.times
|
||||
import org.mockito.kotlin.verify
|
||||
import org.mockito.kotlin.whenever
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import java.io.File
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MediaSendingManagerMigrationTest {
|
||||
private val peerID = "8877665544332211"
|
||||
private lateinit var state: ChatState
|
||||
private lateinit var mesh: MeshService
|
||||
private lateinit var manager: MediaSendingManager
|
||||
private lateinit var file: File
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
state = ChatState(CoroutineScope(SupervisorJob() + Dispatchers.Unconfined))
|
||||
state.setNickname("me")
|
||||
mesh = mock()
|
||||
whenever(mesh.myPeerID).thenReturn("0011223344556677")
|
||||
whenever(mesh.getPeerNicknames()).thenReturn(mapOf(peerID to "old peer"))
|
||||
manager = MediaSendingManager(
|
||||
state,
|
||||
MessageManager(state),
|
||||
mock(),
|
||||
getMeshService = { mesh }
|
||||
)
|
||||
file = kotlin.io.path.createTempFile("private-media", ".jpg").toFile().apply {
|
||||
writeBytes(ByteArray(128) { it.toByte() })
|
||||
}
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preflight rejection creates no local echo mapping or send`() {
|
||||
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
|
||||
.thenReturn(PrivateMediaPreparation.Rejected("too many fragments"))
|
||||
|
||||
manager.sendImageNote(peerID, null, file.absolutePath)
|
||||
|
||||
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
|
||||
assertEquals(null, manager.legacyPrivateMediaConsent.value)
|
||||
verify(mesh, never()).sendFilePrivate(any(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing Noise session initiates one handshake without echo prompt or media send`() {
|
||||
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
|
||||
.thenReturn(PrivateMediaPreparation.NeedsHandshake)
|
||||
|
||||
manager.sendImageNote(peerID, null, file.absolutePath)
|
||||
|
||||
verify(mesh, times(1)).initiateNoiseHandshake(peerID)
|
||||
verify(mesh, never()).sendFilePrivate(any(), any())
|
||||
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
|
||||
assertEquals(null, manager.legacyPrivateMediaConsent.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy consent is one shot rechecks policy and echoes only after approval`() {
|
||||
val commits = AtomicInteger(0)
|
||||
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
|
||||
.thenReturn(PrivateMediaPreparation.RequiresLegacyConsent("relay-visible warning"))
|
||||
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(true)))
|
||||
.thenAnswer { invocation ->
|
||||
val transferId = invocation.getArgument<String>(2)
|
||||
PrivateMediaPreparation.Ready(
|
||||
PreparedPrivateMediaTransfer(
|
||||
transferId = transferId,
|
||||
// Simulate the capability becoming authenticated while
|
||||
// the consent dialog was open: recheck must upgrade.
|
||||
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
|
||||
) {
|
||||
commits.incrementAndGet()
|
||||
true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
manager.sendImageNote(peerID, null, file.absolutePath)
|
||||
|
||||
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
|
||||
val request = manager.legacyPrivateMediaConsent.value
|
||||
assertNotNull(request)
|
||||
|
||||
manager.approveLegacyPrivateMedia(request!!.requestId)
|
||||
manager.approveLegacyPrivateMedia(request.requestId)
|
||||
|
||||
assertEquals(1, commits.get())
|
||||
assertEquals(1, state.privateChats.value[peerID]?.size)
|
||||
assertEquals(null, manager.legacyPrivateMediaConsent.value)
|
||||
verify(mesh, times(1)).prepareFilePrivate(eq(peerID), any(), any(), eq(false))
|
||||
verify(mesh, times(1)).prepareFilePrivate(eq(peerID), any(), any(), eq(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prepared transfer ID mismatch aborts before local echo or commit`() {
|
||||
val commits = AtomicInteger(0)
|
||||
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
|
||||
.thenReturn(
|
||||
PrivateMediaPreparation.Ready(
|
||||
PreparedPrivateMediaTransfer(
|
||||
transferId = "wrong-transfer-id",
|
||||
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
|
||||
) {
|
||||
commits.incrementAndGet()
|
||||
true
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
manager.sendImageNote(peerID, null, file.absolutePath)
|
||||
|
||||
assertEquals(0, commits.get())
|
||||
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancelled consent cannot later send or echo`() {
|
||||
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
|
||||
.thenReturn(PrivateMediaPreparation.RequiresLegacyConsent("relay-visible warning"))
|
||||
|
||||
manager.sendImageNote(peerID, null, file.absolutePath)
|
||||
val request = manager.legacyPrivateMediaConsent.value!!
|
||||
manager.cancelLegacyPrivateMedia(request.requestId)
|
||||
manager.approveLegacyPrivateMedia(request.requestId)
|
||||
|
||||
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
|
||||
verify(mesh, never()).prepareFilePrivate(eq(peerID), any(), any(), eq(true))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user