Migrate private media without silent downgrades

This commit is contained in:
jack
2026-07-12 10:21:07 -04:00
parent b7f0b33d3a
commit 55f85d920c
36 changed files with 2430 additions and 303 deletions
@@ -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)