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:
jack
2026-07-12 11:18:17 -04:00
23 changed files with 1416 additions and 277 deletions
+6 -1
View File
@@ -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()
)
}
@@ -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()
}
@@ -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()
}
}
}
@@ -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)
}
}