mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-27 09:45:25 +00:00
Merge branch 'codex/bind-noise-peer-identities' into codex/advertise-private-media-capability
# Conflicts: # app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt # app/src/main/java/com/bitchat/android/mesh/MeshCore.kt # app/src/main/java/com/bitchat/android/mesh/MessageHandler.kt # app/src/main/java/com/bitchat/android/model/RoutedPacket.kt # app/src/test/kotlin/com/bitchat/android/mesh/MessageHandlerTest.kt
This commit is contained in:
@@ -12,6 +12,11 @@ fun e(tag: String, msg: String): Int {
|
||||
return 0;
|
||||
}
|
||||
|
||||
fun e(tag: String, msg: String, throwable: Throwable): Int {
|
||||
println("ERROR: $tag: $msg (${throwable.message})")
|
||||
return 0;
|
||||
}
|
||||
|
||||
fun w(tag: String, msg: String): Int {
|
||||
println("WARN: $tag: $msg")
|
||||
return 0;
|
||||
@@ -25,4 +30,4 @@ fun v(tag: String, msg: String): Int {
|
||||
fun i(tag: String, msg: String): Int {
|
||||
println("INFO: $tag: $msg")
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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.noise.NoisePeerIdentity
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
@@ -20,7 +21,6 @@ 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
|
||||
import org.mockito.kotlin.never
|
||||
import org.mockito.kotlin.verify
|
||||
@@ -36,9 +36,9 @@ class MessageHandlerTest {
|
||||
private lateinit var delegate: MessageHandlerDelegate
|
||||
|
||||
private val myPeerID = "1111222233334444"
|
||||
private val peerID = "aaaabbbbccccdddd"
|
||||
private val nickname = "peer"
|
||||
private val noiseKey = ByteArray(32) { 0x0B }
|
||||
private val peerID = NoisePeerIdentity.derivePeerID(noiseKey)!!
|
||||
private val nickname = "peer"
|
||||
private val signingKey = ByteArray(32) { 0x0A }
|
||||
private val signature = ByteArray(64) { 1 }
|
||||
private val announceClockSkewToleranceMs = 10 * 60 * 1000L
|
||||
@@ -73,10 +73,8 @@ class MessageHandlerTest {
|
||||
|
||||
assertTrue("Announce within clock skew tolerance should still store peer identity", result)
|
||||
verify(delegate).updatePeerInfoFromVerifiedAnnouncement(
|
||||
eq(peerID), eq(nickname), any(), any(), eq(true), isNull()
|
||||
eq(peerID), eq(nickname), any(), any(), eq(true), anyOrNull()
|
||||
)
|
||||
verify(delegate).onVerifiedAnnouncementProcessed(peerID)
|
||||
verify(delegate).updatePeerIDBinding(eq(peerID), eq(nickname), any(), isNull())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +87,7 @@ class MessageHandlerTest {
|
||||
|
||||
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()
|
||||
eq(peerID), eq(nickname), any(), any(), eq(true), anyOrNull()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -127,8 +125,6 @@ class MessageHandlerTest {
|
||||
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
|
||||
any(), any(), any(), any(), any(), anyOrNull()
|
||||
)
|
||||
verify(delegate, never()).onVerifiedAnnouncementProcessed(any())
|
||||
verify(delegate, never()).updatePeerIDBinding(any(), any(), any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +140,6 @@ class MessageHandlerTest {
|
||||
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
|
||||
any(), any(), any(), any(), any(), anyOrNull()
|
||||
)
|
||||
verify(delegate, never()).onVerifiedAnnouncementProcessed(any())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,14 +239,90 @@ class MessageHandlerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first self-signed announce cannot claim an ID derived from another Noise key`() = runBlocking {
|
||||
val attackerNoiseKey = ByteArray(32) { 0x6B }
|
||||
val packet = announcePacket(ageMs = 0, noisePublicKey = attackerNoiseKey)
|
||||
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
|
||||
|
||||
assertFalse("A valid self-signature cannot bind an attacker key to a victim ID", result)
|
||||
verify(delegate, never()).verifyEd25519Signature(any(), any(), any())
|
||||
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
|
||||
any(), any(), any(), any(), any(), anyOrNull()
|
||||
)
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `announce requires a 32-byte Noise static key before peer update`() = runBlocking {
|
||||
val malformedNoiseKey = ByteArray(31) { 0x0B }
|
||||
val packet = announcePacket(ageMs = 0, noisePublicKey = malformedNoiseKey)
|
||||
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
|
||||
|
||||
assertFalse(result)
|
||||
verify(delegate, never()).verifyEd25519Signature(any(), any(), any())
|
||||
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
|
||||
any(), any(), any(), any(), any(), anyOrNull()
|
||||
)
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `announce packet sender cannot be processed under a different routed peer ID`() = runBlocking {
|
||||
val otherPeerID = NoisePeerIdentity.derivePeerID(ByteArray(32) { 0x21 })!!
|
||||
val packet = announcePacket(ageMs = 0)
|
||||
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, otherPeerID, "relay-link"))
|
||||
|
||||
assertFalse(result)
|
||||
verify(delegate, never()).verifyEd25519Signature(any(), any(), any())
|
||||
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
|
||||
any(), any(), any(), any(), any(), anyOrNull()
|
||||
)
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `known peer signing key cannot be replaced without bound Noise session`() = runBlocking {
|
||||
whenever(delegate.getPeerInfo(peerID)).thenReturn(peerInfo(signingPublicKey = ByteArray(32) { 0x44 }))
|
||||
whenever(delegate.hasNoiseSession(peerID)).thenReturn(false)
|
||||
val packet = announcePacket(ageMs = 0)
|
||||
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
|
||||
|
||||
assertFalse(result)
|
||||
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
|
||||
any(), any(), any(), any(), any(), anyOrNull()
|
||||
)
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ambient bound Noise session does not authorize signing key replacement`() = runBlocking {
|
||||
whenever(delegate.getPeerInfo(peerID)).thenReturn(peerInfo(signingPublicKey = ByteArray(32) { 0x44 }))
|
||||
whenever(delegate.hasNoiseSession(peerID)).thenReturn(true)
|
||||
val packet = announcePacket(ageMs = 0)
|
||||
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
|
||||
|
||||
assertFalse(result)
|
||||
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
|
||||
any(), any(), any(), any(), any(), anyOrNull()
|
||||
)
|
||||
Unit
|
||||
}
|
||||
|
||||
private fun announcePacket(
|
||||
ageMs: Long,
|
||||
ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(),
|
||||
capabilities: PeerCapabilities? = null
|
||||
capabilities: PeerCapabilities? = null,
|
||||
noisePublicKey: ByteArray = noiseKey
|
||||
): BitchatPacket {
|
||||
val announcement = IdentityAnnouncement(
|
||||
nickname = nickname,
|
||||
noisePublicKey = noiseKey,
|
||||
noisePublicKey = noisePublicKey,
|
||||
signingPublicKey = signingKey,
|
||||
capabilities = capabilities
|
||||
)
|
||||
@@ -270,4 +341,15 @@ class MessageHandlerTest {
|
||||
private fun String.hexToBytes(): ByteArray {
|
||||
return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
|
||||
private fun peerInfo(signingPublicKey: ByteArray) = PeerInfo(
|
||||
id = peerID,
|
||||
nickname = nickname,
|
||||
isConnected = true,
|
||||
isDirectConnection = true,
|
||||
noisePublicKey = noiseKey,
|
||||
signingPublicKey = signingPublicKey,
|
||||
isVerifiedNickname = true,
|
||||
lastSeen = System.currentTimeMillis()
|
||||
)
|
||||
}
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class PacketProcessorAnnounceSideEffectTest {
|
||||
private val processors = mutableListOf<PacketProcessor>()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
processors.forEach(PacketProcessor::shutdown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejected announce does not update last seen or relay`() = runBlocking {
|
||||
val delegate = RecordingDelegate(acceptAnnounce = false)
|
||||
val processor = processor(delegate)
|
||||
|
||||
processor.processPacket(announce())
|
||||
withTimeout(1_000) { delegate.handled.await() }
|
||||
|
||||
assertNull(withTimeoutOrNull(250) { delegate.lastSeen.await() })
|
||||
assertEquals(0, delegate.relayCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `accepted announce unlocks post-handler side effects`() = runBlocking {
|
||||
val delegate = RecordingDelegate(acceptAnnounce = true)
|
||||
val processor = processor(delegate)
|
||||
|
||||
processor.processPacket(announce())
|
||||
|
||||
assertEquals(PEER_ID, withTimeout(1_000) { delegate.lastSeen.await() })
|
||||
}
|
||||
|
||||
private fun processor(delegate: RecordingDelegate): PacketProcessor =
|
||||
PacketProcessor(MY_PEER_ID).also {
|
||||
it.delegate = delegate
|
||||
processors += it
|
||||
}
|
||||
|
||||
private fun announce(): RoutedPacket {
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
senderID = PEER_ID.hexToBytes(),
|
||||
recipientID = SpecialRecipients.BROADCAST,
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = byteArrayOf(0x01),
|
||||
ttl = 7u
|
||||
)
|
||||
return RoutedPacket(packet, PEER_ID, "direct-link")
|
||||
}
|
||||
|
||||
private class RecordingDelegate(
|
||||
private val acceptAnnounce: Boolean
|
||||
) : PacketProcessorDelegate {
|
||||
val handled = CompletableDeferred<Unit>()
|
||||
val lastSeen = CompletableDeferred<String>()
|
||||
@Volatile var relayCount = 0
|
||||
|
||||
override fun validatePacketSecurity(packet: BitchatPacket, peerID: String) = true
|
||||
override fun updatePeerLastSeen(peerID: String) {
|
||||
lastSeen.complete(peerID)
|
||||
}
|
||||
override fun getPeerNickname(peerID: String): String? = null
|
||||
override fun getNetworkSize() = 1
|
||||
override fun getBroadcastRecipient(): ByteArray = SpecialRecipients.BROADCAST
|
||||
override fun handleNoiseHandshake(routed: RoutedPacket) = false
|
||||
override fun handleNoiseEncrypted(routed: RoutedPacket) = Unit
|
||||
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
|
||||
handled.complete(Unit)
|
||||
return acceptAnnounce
|
||||
}
|
||||
override fun handleMessage(routed: RoutedPacket) = Unit
|
||||
override fun handleLeave(routed: RoutedPacket) = Unit
|
||||
override fun handleFragment(packet: BitchatPacket): BitchatPacket? = null
|
||||
override fun handleRequestSync(routed: RoutedPacket) = Unit
|
||||
override fun sendAnnouncementToPeer(peerID: String) = Unit
|
||||
override fun sendCachedMessages(peerID: String) = Unit
|
||||
override fun relayPacket(routed: RoutedPacket) {
|
||||
relayCount += 1
|
||||
}
|
||||
override fun sendToPeer(peerID: String, routed: RoutedPacket) = false
|
||||
}
|
||||
|
||||
private fun String.hexToBytes(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
|
||||
private companion object {
|
||||
const val MY_PEER_ID = "1111222233334444"
|
||||
const val PEER_ID = "aaaabbbbccccdddd"
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,13 @@ package com.bitchat.android.mesh
|
||||
import android.os.Build
|
||||
import com.bitchat.android.crypto.EncryptionService
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.noise.NoiseHandshakeProcessingResult
|
||||
import com.bitchat.android.noise.NoisePeerIdentity
|
||||
import com.bitchat.android.noise.NoiseSessionError
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -26,15 +31,14 @@ class SecurityManagerTest {
|
||||
|
||||
private val myPeerID = "1111222233334444"
|
||||
private val otherPeerID = "aaaabbbbccccdddd"
|
||||
private val unknownPeerID = "9999888877776666"
|
||||
// Key pairs (using dummy bytes for mock verification)
|
||||
private val otherSigningKey = ByteArray(32) { 0xA }
|
||||
private val otherNoiseKey = ByteArray(32) { 0xB }
|
||||
private val unknownPeerID = NoisePeerIdentity.derivePeerID(otherNoiseKey)!!
|
||||
|
||||
private val dummyPayload = "Hello World".toByteArray()
|
||||
private val validSignature = ByteArray(64) { 1 }
|
||||
private val invalidSignature = ByteArray(64) { 0 }
|
||||
|
||||
// Key pairs (using dummy bytes for mock verification)
|
||||
private val otherSigningKey = ByteArray(32) { 0xA }
|
||||
private val otherNoiseKey = ByteArray(32) { 0xB }
|
||||
|
||||
// Fake implementation to bypass initialization issues in tests
|
||||
open class FakeEncryptionService : EncryptionService(RuntimeEnvironment.getApplication()) {
|
||||
@@ -42,6 +46,10 @@ class SecurityManagerTest {
|
||||
var lastVerifySignature: ByteArray? = null
|
||||
var lastVerifyData: ByteArray? = null
|
||||
var lastVerifyKey: ByteArray? = null
|
||||
var handshakeResult = NoiseHandshakeProcessingResult(null, false)
|
||||
var handshakeError: Exception? = null
|
||||
var handshakeCalls = 0
|
||||
var removePeerCalls = 0
|
||||
|
||||
override fun initialize() {
|
||||
// Do nothing to avoid KeyStore access in tests
|
||||
@@ -59,6 +67,19 @@ class SecurityManagerTest {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
override fun processHandshakeMessageWithResult(
|
||||
data: ByteArray,
|
||||
peerID: String
|
||||
): NoiseHandshakeProcessingResult {
|
||||
handshakeCalls += 1
|
||||
handshakeError?.let { throw it }
|
||||
return handshakeResult
|
||||
}
|
||||
|
||||
override fun removePeer(peerID: String) {
|
||||
removePeerCalls += 1
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
@@ -197,6 +218,41 @@ class SecurityManagerTest {
|
||||
assertTrue("Valid signed packet from known peer should be accepted", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket rejects unsigned and invalidly signed LEAVE packets`() {
|
||||
setupKnownPeer(otherPeerID, otherSigningKey)
|
||||
|
||||
val unsigned = BitchatPacket(
|
||||
type = MessageType.LEAVE.value,
|
||||
ttl = 7u,
|
||||
senderID = otherPeerID,
|
||||
payload = byteArrayOf()
|
||||
)
|
||||
assertFalse("Unsigned LEAVE must not evict or relay the claimed peer", securityManager.validatePacket(unsigned, otherPeerID))
|
||||
|
||||
val invalid = BitchatPacket(
|
||||
type = MessageType.LEAVE.value,
|
||||
ttl = 7u,
|
||||
senderID = otherPeerID,
|
||||
payload = "forged".toByteArray()
|
||||
).also { it.signature = invalidSignature }
|
||||
assertFalse("Bad LEAVE signature must be rejected", securityManager.validatePacket(invalid, otherPeerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket accepts signed LEAVE from known peer`() {
|
||||
setupKnownPeer(otherPeerID, otherSigningKey)
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.LEAVE.value,
|
||||
ttl = 7u,
|
||||
senderID = otherPeerID,
|
||||
payload = byteArrayOf()
|
||||
).also { it.signature = validSignature }
|
||||
|
||||
assertTrue("A valid signed LEAVE remains wire-compatible", securityManager.validatePacket(packet, otherPeerID))
|
||||
assertTrue(fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket - accepts ANNOUNCE packet from unknown peer (extracts key)`() {
|
||||
val announcement = IdentityAnnouncement(
|
||||
@@ -261,6 +317,33 @@ class SecurityManagerTest {
|
||||
assertFalse("ANNOUNCE with malformed payload should be rejected (cannot extract key)", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket rejects self-signed announce whose Noise key derives another sender ID`() {
|
||||
val attackerNoiseKey = ByteArray(32) { 0x6B }
|
||||
val announcement = IdentityAnnouncement("Attacker", attackerNoiseKey, otherSigningKey)
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
ttl = 7u,
|
||||
senderID = unknownPeerID,
|
||||
payload = announcement.encode()!!
|
||||
).also { it.signature = validSignature }
|
||||
|
||||
assertFalse(securityManager.validatePacket(packet, unknownPeerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket rejects announce packet under a different routed sender`() {
|
||||
val announcement = IdentityAnnouncement("Peer", otherNoiseKey, otherSigningKey)
|
||||
val packet = BitchatPacket(
|
||||
type = MessageType.ANNOUNCE.value,
|
||||
ttl = 7u,
|
||||
senderID = unknownPeerID,
|
||||
payload = announcement.encode()!!
|
||||
).also { it.signature = validSignature }
|
||||
|
||||
assertFalse(securityManager.validatePacket(packet, otherPeerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket - ignores own packets`() {
|
||||
val packet = BitchatPacket(
|
||||
@@ -326,6 +409,86 @@ class SecurityManagerTest {
|
||||
assertTrue("Fresh duplicate ANNOUNCE should be accepted", securityManager.validatePacket(packet3, unknownPeerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replacement message one sends response without evicting or falsely completing`() = runBlocking {
|
||||
val response = byteArrayOf(0x31, 0x32)
|
||||
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(response, false)
|
||||
val routed = handshakePacket(byteArrayOf(0x01, 0x02, 0x03))
|
||||
|
||||
val accepted = securityManager.handleNoiseHandshake(routed)
|
||||
|
||||
assertTrue(accepted)
|
||||
assertTrue(fakeEncryptionService.removePeerCalls == 0)
|
||||
verify(mockDelegate).sendHandshakeResponse(otherPeerID, response)
|
||||
verify(mockDelegate, never()).onKeyExchangeCompleted(any(), any(), anyOrNull(), anyOrNull())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `identity mismatch preserves peer and does not poison retry or completion`() = runBlocking {
|
||||
val routed = handshakePacket(byteArrayOf(0x41, 0x42, 0x43))
|
||||
fakeEncryptionService.handshakeError = NoiseSessionError.PeerIdentityMismatch(
|
||||
otherPeerID,
|
||||
"0000000000000000"
|
||||
)
|
||||
|
||||
assertFalse(securityManager.handleNoiseHandshake(routed))
|
||||
assertTrue(fakeEncryptionService.removePeerCalls == 0)
|
||||
verify(mockDelegate, never()).sendHandshakeResponse(any(), any())
|
||||
verify(mockDelegate, never()).onKeyExchangeCompleted(any(), any(), anyOrNull(), anyOrNull())
|
||||
|
||||
fakeEncryptionService.handshakeError = null
|
||||
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
|
||||
response = null,
|
||||
establishedNow = true,
|
||||
authenticatedRemoteStaticKey = otherNoiseKey
|
||||
)
|
||||
assertTrue("Failed frames must not poison the processed-exchange cache", securityManager.handleNoiseHandshake(routed))
|
||||
assertTrue(fakeEncryptionService.handshakeCalls == 2)
|
||||
verify(mockDelegate).onKeyExchangeCompleted(
|
||||
otherPeerID,
|
||||
otherNoiseKey,
|
||||
"direct-link",
|
||||
"direct-link-token"
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `completion callback fires only for the frame that establishes a bound session`() = runBlocking {
|
||||
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
|
||||
response = null,
|
||||
establishedNow = true,
|
||||
authenticatedRemoteStaticKey = otherNoiseKey
|
||||
)
|
||||
val routed = handshakePacket(byteArrayOf(0x51, 0x52, 0x53))
|
||||
|
||||
assertTrue(securityManager.handleNoiseHandshake(routed))
|
||||
|
||||
verify(mockDelegate, times(1)).onKeyExchangeCompleted(
|
||||
otherPeerID,
|
||||
otherNoiseKey,
|
||||
"direct-link",
|
||||
"direct-link-token"
|
||||
)
|
||||
verify(mockDelegate, never()).sendHandshakeResponse(any(), any())
|
||||
assertTrue(fakeEncryptionService.removePeerCalls == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relayed completion does not authenticate the relay as the peer link`() = runBlocking {
|
||||
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
|
||||
response = null,
|
||||
establishedNow = true,
|
||||
authenticatedRemoteStaticKey = otherNoiseKey
|
||||
)
|
||||
val routed = handshakePacket(
|
||||
payload = byteArrayOf(0x61, 0x62, 0x63),
|
||||
ttl = 6u
|
||||
)
|
||||
|
||||
assertTrue(securityManager.handleNoiseHandshake(routed))
|
||||
verify(mockDelegate).onKeyExchangeCompleted(otherPeerID, otherNoiseKey, null, null)
|
||||
}
|
||||
|
||||
private fun setupKnownPeer(peerID: String, signingKey: ByteArray) {
|
||||
val info = PeerInfo(
|
||||
id = peerID,
|
||||
@@ -339,4 +502,25 @@ class SecurityManagerTest {
|
||||
)
|
||||
whenever(mockDelegate.getPeerInfo(peerID)).thenReturn(info)
|
||||
}
|
||||
|
||||
private fun handshakePacket(payload: ByteArray, ttl: UByte = 7u): RoutedPacket {
|
||||
val packet = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_HANDSHAKE.value,
|
||||
senderID = otherPeerID.hexToBytes(),
|
||||
recipientID = myPeerID.hexToBytes(),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = payload,
|
||||
ttl = ttl
|
||||
)
|
||||
return RoutedPacket(
|
||||
packet = packet,
|
||||
peerID = otherPeerID,
|
||||
relayAddress = "direct-link",
|
||||
ingressLinkID = "direct-link-token"
|
||||
)
|
||||
}
|
||||
|
||||
private fun String.hexToBytes(): ByteArray =
|
||||
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||
}
|
||||
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
package com.bitchat.android.noise
|
||||
|
||||
import com.bitchat.android.noise.southernstorm.protocol.Noise
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class NoiseSessionManagerIdentityBindingTest {
|
||||
private data class TestIdentity(
|
||||
val privateKey: ByteArray,
|
||||
val publicKey: ByteArray,
|
||||
val peerID: String
|
||||
)
|
||||
|
||||
private val managers = mutableListOf<NoiseSessionManager>()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
managers.forEach(NoiseSessionManager::shutdown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid derived identities establish in initiator and responder roles`() {
|
||||
val alice = identity()
|
||||
val bob = identity()
|
||||
val aliceManager = manager(alice)
|
||||
val bobManager = manager(bob)
|
||||
|
||||
completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID)
|
||||
|
||||
assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
|
||||
assertTrue(bobManager.hasEstablishedSession(alice.peerID))
|
||||
assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID))
|
||||
assertArrayEquals(alice.publicKey, bobManager.getRemoteStaticKey(alice.peerID))
|
||||
|
||||
val plaintext = "bound transport".toByteArray()
|
||||
val ciphertext = aliceManager.encrypt(plaintext, bob.peerID)
|
||||
assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initiator rejects remote static key before returning message three`() {
|
||||
val alice = identity()
|
||||
val bob = identity()
|
||||
val victim = identity()
|
||||
val aliceManager = manager(alice)
|
||||
val bobManager = manager(bob)
|
||||
var authenticatedCallbacks = 0
|
||||
aliceManager.onSessionEstablished = { _, _ -> authenticatedCallbacks += 1 }
|
||||
|
||||
val message1 = aliceManager.initiateHandshake(victim.peerID)!!
|
||||
val message2 = bobManager.processHandshakeMessage(alice.peerID, message1)!!
|
||||
|
||||
expectIdentityMismatch {
|
||||
aliceManager.processHandshakeMessage(victim.peerID, message2)
|
||||
}
|
||||
|
||||
assertFalse(aliceManager.hasEstablishedSession(victim.peerID))
|
||||
assertNull(aliceManager.getSession(victim.peerID))
|
||||
assertTrue("No authenticated callback may escape a mismatched initiator", authenticatedCallbacks == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `responder rejects authenticated initiator key under a different claimed ID`() {
|
||||
val alice = identity()
|
||||
val bob = identity()
|
||||
val victim = identity()
|
||||
val aliceManager = manager(alice)
|
||||
val bobManager = manager(bob)
|
||||
var authenticatedCallbacks = 0
|
||||
bobManager.onSessionEstablished = { _, _ -> authenticatedCallbacks += 1 }
|
||||
|
||||
val message1 = aliceManager.initiateHandshake(bob.peerID)!!
|
||||
val message2 = bobManager.processHandshakeMessage(victim.peerID, message1)!!
|
||||
val message3 = aliceManager.processHandshakeMessage(bob.peerID, message2)!!
|
||||
|
||||
expectIdentityMismatch {
|
||||
bobManager.processHandshakeMessage(victim.peerID, message3)
|
||||
}
|
||||
|
||||
assertFalse(bobManager.hasEstablishedSession(victim.peerID))
|
||||
assertNull(bobManager.getSession(victim.peerID))
|
||||
assertTrue("No authenticated callback may escape a mismatched responder", authenticatedCallbacks == 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mismatched responder replacement preserves established session and transport keys`() {
|
||||
val alice = identity()
|
||||
val bob = identity()
|
||||
val attacker = identity()
|
||||
val aliceManager = manager(alice)
|
||||
val bobManager = manager(bob)
|
||||
val attackerManager = manager(attacker)
|
||||
var aliceAuthenticatedCallbacks = 0
|
||||
aliceManager.onSessionEstablished = { _, _ -> aliceAuthenticatedCallbacks += 1 }
|
||||
|
||||
completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID)
|
||||
val originalSession = aliceManager.getSession(bob.peerID)
|
||||
assertTrue(aliceAuthenticatedCallbacks == 1)
|
||||
|
||||
val attackerMessage1 = attackerManager.initiateHandshake(alice.peerID)!!
|
||||
val aliceMessage2 = aliceManager.processHandshakeMessage(bob.peerID, attackerMessage1)!!
|
||||
val attackerMessage3 = attackerManager.processHandshakeMessage(alice.peerID, aliceMessage2)!!
|
||||
|
||||
expectIdentityMismatch {
|
||||
aliceManager.processHandshakeMessage(bob.peerID, attackerMessage3)
|
||||
}
|
||||
|
||||
assertSame("Rejected candidate must not replace the working session", originalSession, aliceManager.getSession(bob.peerID))
|
||||
assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
|
||||
assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID))
|
||||
assertTrue("Rejected replacement must not fire authentication callback", aliceAuthenticatedCallbacks == 1)
|
||||
|
||||
val plaintext = "original session survives".toByteArray()
|
||||
val ciphertext = aliceManager.encrypt(plaintext, bob.peerID)
|
||||
assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID))
|
||||
|
||||
// The failed candidate must be fully removed so a later valid restart can replace cleanly.
|
||||
val restartedBobManager = manager(bob)
|
||||
val validMessage1 = restartedBobManager.initiateHandshake(alice.peerID)!!
|
||||
val validMessage2 = aliceManager.processHandshakeMessage(bob.peerID, validMessage1)!!
|
||||
val validMessage3 = restartedBobManager.processHandshakeMessage(alice.peerID, validMessage2)!!
|
||||
assertNull(aliceManager.processHandshakeMessage(bob.peerID, validMessage3))
|
||||
assertTrue(aliceAuthenticatedCallbacks == 2)
|
||||
|
||||
val retriedPlaintext = "valid retry promoted".toByteArray()
|
||||
val retriedCiphertext = restartedBobManager.encrypt(retriedPlaintext, alice.peerID)
|
||||
assertArrayEquals(retriedPlaintext, aliceManager.decrypt(retriedCiphertext, bob.peerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid responder replacement promotes only after bound handshake completes`() {
|
||||
val alice = identity()
|
||||
val bob = identity()
|
||||
val aliceManager = manager(alice)
|
||||
val originalBobManager = manager(bob)
|
||||
var aliceAuthenticatedCallbacks = 0
|
||||
aliceManager.onSessionEstablished = { _, _ -> aliceAuthenticatedCallbacks += 1 }
|
||||
|
||||
completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID)
|
||||
val originalSession = aliceManager.getSession(bob.peerID)
|
||||
|
||||
// Simulate Bob restarting with the same persistent static identity and no session state.
|
||||
val restartedBobManager = manager(bob)
|
||||
val message1 = restartedBobManager.initiateHandshake(alice.peerID)!!
|
||||
val message2 = aliceManager.processHandshakeMessage(bob.peerID, message1)!!
|
||||
val message3 = restartedBobManager.processHandshakeMessage(alice.peerID, message2)!!
|
||||
assertNull(aliceManager.processHandshakeMessage(bob.peerID, message3))
|
||||
|
||||
assertNotSame(originalSession, aliceManager.getSession(bob.peerID))
|
||||
assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
|
||||
assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID))
|
||||
assertTrue(aliceAuthenticatedCallbacks == 2)
|
||||
|
||||
val plaintext = "replacement transport".toByteArray()
|
||||
val ciphertext = restartedBobManager.encrypt(plaintext, alice.peerID)
|
||||
assertArrayEquals(plaintext, aliceManager.decrypt(ciphertext, bob.peerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `peer ID derivation rejects malformed keys and non-wire claims`() {
|
||||
val peer = identity()
|
||||
|
||||
assertTrue(NoisePeerIdentity.matchesClaimedPeerID(peer.peerID, peer.publicKey))
|
||||
assertFalse(NoisePeerIdentity.matchesClaimedPeerID(peer.peerID.uppercase(), peer.publicKey))
|
||||
assertFalse(NoisePeerIdentity.matchesClaimedPeerID("not-a-wire-id", peer.publicKey))
|
||||
assertFalse(NoisePeerIdentity.matchesClaimedPeerID(peer.peerID, ByteArray(31)))
|
||||
assertNull(NoisePeerIdentity.derivePeerID(ByteArray(31)))
|
||||
}
|
||||
|
||||
private fun completeHandshake(
|
||||
initiator: NoiseSessionManager,
|
||||
initiatorPeerID: String,
|
||||
responder: NoiseSessionManager,
|
||||
responderPeerID: String
|
||||
) {
|
||||
val message1 = initiator.initiateHandshake(responderPeerID)!!
|
||||
val message2 = responder.processHandshakeMessage(initiatorPeerID, message1)!!
|
||||
val message3 = initiator.processHandshakeMessage(responderPeerID, message2)!!
|
||||
assertNull(responder.processHandshakeMessage(initiatorPeerID, message3))
|
||||
}
|
||||
|
||||
private fun expectIdentityMismatch(block: () -> Unit) {
|
||||
try {
|
||||
block()
|
||||
fail("Expected authenticated Noise key to be rejected for the claimed peer ID")
|
||||
} catch (_: NoiseSessionError.PeerIdentityMismatch) {
|
||||
// Expected.
|
||||
}
|
||||
}
|
||||
|
||||
private fun manager(identity: TestIdentity): NoiseSessionManager = NoiseSessionManager(
|
||||
localStaticPrivateKey = identity.privateKey,
|
||||
localStaticPublicKey = identity.publicKey,
|
||||
localPeerID = identity.peerID
|
||||
).also { managers += it }
|
||||
|
||||
private fun identity(): TestIdentity {
|
||||
val dh = Noise.createDH("25519")
|
||||
return try {
|
||||
dh.generateKeyPair()
|
||||
val privateKey = ByteArray(32)
|
||||
val publicKey = ByteArray(32)
|
||||
dh.getPrivateKey(privateKey, 0)
|
||||
dh.getPublicKey(publicKey, 0)
|
||||
TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!)
|
||||
} finally {
|
||||
dh.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.bitchat.android.wifiaware
|
||||
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
class AuthenticatedIngressLinkPolicyTest {
|
||||
@Test
|
||||
fun `authentication promotes only the exact ingress link`() {
|
||||
val attackerSocket = Any()
|
||||
val victimSocket = Any()
|
||||
val links = mapOf(
|
||||
"attacker-link" to AuthenticatedIngressLinkPolicy.Link("provisional-attacker", attackerSocket),
|
||||
"victim-link" to AuthenticatedIngressLinkPolicy.Link("provisional-victim", victimSocket)
|
||||
)
|
||||
val current = mapOf(
|
||||
"provisional-attacker" to attackerSocket,
|
||||
"provisional-victim" to victimSocket
|
||||
)
|
||||
|
||||
val resolved = AuthenticatedIngressLinkPolicy.resolve(
|
||||
authenticatedLinkID = "victim-link",
|
||||
authenticatedRelayAddress = "provisional-victim",
|
||||
links = links,
|
||||
currentTransportForRelay = current::get
|
||||
)
|
||||
|
||||
assertSame(victimSocket, resolved?.transport)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale replaced or mismatched ingress links cannot be promoted`() {
|
||||
val completedSocket = Any()
|
||||
val replacementSocket = Any()
|
||||
val links = mapOf(
|
||||
"completed-link" to AuthenticatedIngressLinkPolicy.Link("provisional", completedSocket)
|
||||
)
|
||||
|
||||
assertNull(
|
||||
AuthenticatedIngressLinkPolicy.resolve(
|
||||
authenticatedLinkID = "missing-link",
|
||||
authenticatedRelayAddress = "provisional",
|
||||
links = links,
|
||||
currentTransportForRelay = { completedSocket }
|
||||
)
|
||||
)
|
||||
assertNull(
|
||||
AuthenticatedIngressLinkPolicy.resolve(
|
||||
authenticatedLinkID = "completed-link",
|
||||
authenticatedRelayAddress = "different-provisional",
|
||||
links = links,
|
||||
currentTransportForRelay = { completedSocket }
|
||||
)
|
||||
)
|
||||
assertNull(
|
||||
AuthenticatedIngressLinkPolicy.resolve(
|
||||
authenticatedLinkID = "completed-link",
|
||||
authenticatedRelayAddress = "provisional",
|
||||
links = links,
|
||||
currentTransportForRelay = { replacementSocket }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.bitchat.android.wifiaware
|
||||
|
||||
import android.net.ConnectivityManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.mockito.kotlin.doReturn
|
||||
import org.mockito.kotlin.mock
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.Socket
|
||||
|
||||
class WifiAwareConnectionTrackerTest {
|
||||
@Test
|
||||
fun `compare and rebind rejects stale authenticated socket after replacement`() {
|
||||
val tracker = WifiAwareConnectionTracker(
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
|
||||
mock<ConnectivityManager>()
|
||||
)
|
||||
val authenticatedSocket = syncedSocket()
|
||||
val replacementSocket = syncedSocket()
|
||||
tracker.onClientConnected("provisional", authenticatedSocket)
|
||||
tracker.onClientConnected("provisional", replacementSocket)
|
||||
|
||||
assertFalse(
|
||||
tracker.rebindPeerIdIfCurrent(
|
||||
previousPeerId = "provisional",
|
||||
resolvedPeerId = "canonical",
|
||||
expectedSocket = authenticatedSocket
|
||||
)
|
||||
)
|
||||
assertSame(replacementSocket, tracker.getSocketForPeer("provisional"))
|
||||
assertNull(tracker.getSocketForPeer("canonical"))
|
||||
|
||||
assertTrue(
|
||||
tracker.rebindPeerIdIfCurrent(
|
||||
previousPeerId = "provisional",
|
||||
resolvedPeerId = "canonical",
|
||||
expectedSocket = replacementSocket
|
||||
)
|
||||
)
|
||||
assertSame(replacementSocket, tracker.getSocketForPeer("canonical"))
|
||||
assertSame(replacementSocket, tracker.getSocketForPeer("provisional"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `authenticated provisional socket cannot displace existing canonical socket`() {
|
||||
val tracker = WifiAwareConnectionTracker(
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
|
||||
mock<ConnectivityManager>()
|
||||
)
|
||||
val provisionalSocket = syncedSocket()
|
||||
val canonicalSocket = syncedSocket()
|
||||
tracker.onClientConnected("provisional", provisionalSocket)
|
||||
tracker.onClientConnected("canonical", canonicalSocket)
|
||||
|
||||
assertFalse(
|
||||
tracker.rebindPeerIdIfCurrent(
|
||||
previousPeerId = "provisional",
|
||||
resolvedPeerId = "canonical",
|
||||
expectedSocket = provisionalSocket
|
||||
)
|
||||
)
|
||||
assertSame(provisionalSocket, tracker.getSocketForPeer("provisional"))
|
||||
assertSame(canonicalSocket, tracker.getSocketForPeer("canonical"))
|
||||
assertTrue("Rejected promotion must not alias the provisional ID", tracker.canonicalPeerId("provisional") == "provisional")
|
||||
}
|
||||
|
||||
private fun syncedSocket(): SyncedSocket {
|
||||
val raw = mock<Socket> {
|
||||
on { getInputStream() } doReturn ByteArrayInputStream(byteArrayOf())
|
||||
on { getOutputStream() } doReturn ByteArrayOutputStream()
|
||||
}
|
||||
return SyncedSocket(raw)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user