mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-27 16:25:24 +00:00
Authenticate private media capability per Noise session
This commit is contained in:
+29
-6
@@ -1,6 +1,10 @@
|
||||
package com.bitchat.android.identity
|
||||
|
||||
import android.content.Context
|
||||
import com.bitchat.android.model.AuthenticatedPeerState
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -33,18 +37,37 @@ class PrivateMediaCapabilityPinPersistenceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `capability pin persists and panic identity wipe removes it`() {
|
||||
manager.markPrivateMediaCapable(fingerprint)
|
||||
fun `authenticated Ed key and capabilities persist rotate and clear atomically`() {
|
||||
val firstKey = ByteArray(32) { 0x11 }
|
||||
val rotatedKey = ByteArray(32) { 0x22 }
|
||||
assertTrue(manager.storeAuthenticatedPeerState(
|
||||
fingerprint,
|
||||
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, firstKey)
|
||||
))
|
||||
|
||||
val reloaded = SecureIdentityStateManager(prefs, testOnly = true)
|
||||
assertArrayEquals(firstKey, reloaded.getAuthenticatedSigningKey(fingerprint))
|
||||
assertEquals(
|
||||
PeerCapabilities.PRIVATE_MEDIA,
|
||||
reloaded.getAuthenticatedPeerState(fingerprint)?.capabilities
|
||||
)
|
||||
assertTrue(reloaded.isPrivateMediaCapable(fingerprint))
|
||||
|
||||
assertTrue(reloaded.storeAuthenticatedPeerState(
|
||||
fingerprint,
|
||||
AuthenticatedPeerState(PeerCapabilities.NONE, rotatedKey)
|
||||
))
|
||||
assertArrayEquals(rotatedKey, reloaded.getAuthenticatedSigningKey(fingerprint))
|
||||
// HSTS-style private-media history is not erased by a no-bit proof.
|
||||
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)
|
||||
assertFalse(manager.storeAuthenticatedPeerState(
|
||||
fingerprint,
|
||||
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, firstKey)
|
||||
))
|
||||
val afterPanic = SecureIdentityStateManager(prefs, testOnly = true)
|
||||
assertEquals(null, afterPanic.getAuthenticatedPeerState(fingerprint))
|
||||
assertFalse(afterPanic.isPrivateMediaCapable(fingerprint))
|
||||
assertTrue(afterPanic.getPrivateMediaCapabilityPinsForTesting().isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.AuthenticatedPeerState
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.noise.AuthenticatedNoiseSession
|
||||
import com.bitchat.android.noise.NoisePeerIdentity
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AuthenticatedPeerStateCoordinatorTest {
|
||||
private class MemoryStore : AuthenticatedPeerStateStore {
|
||||
val states = mutableMapOf<String, AuthenticatedPeerState>()
|
||||
val pins = mutableSetOf<String>()
|
||||
|
||||
override fun load(fingerprint: String): AuthenticatedPeerState? = states[fingerprint]
|
||||
override fun persist(
|
||||
fingerprint: String,
|
||||
state: AuthenticatedPeerState,
|
||||
onCommitted: () -> Unit
|
||||
): Boolean {
|
||||
states[fingerprint] = state
|
||||
if (state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) pins += fingerprint
|
||||
onCommitted()
|
||||
return true
|
||||
}
|
||||
override fun isPrivateMediaPinned(fingerprint: String): Boolean = fingerprint in pins
|
||||
}
|
||||
|
||||
private val remoteStatic = ByteArray(32) { 0x33 }
|
||||
private val peerID = NoisePeerIdentity.derivePeerID(remoteStatic)!!
|
||||
private val firstSession = AuthenticatedNoiseSession(
|
||||
remoteStatic,
|
||||
ByteArray(32) { 0x71 }
|
||||
)
|
||||
private val secondSession = AuthenticatedNoiseSession(
|
||||
remoteStatic,
|
||||
ByteArray(32) { 0x72 }
|
||||
)
|
||||
private val localState = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 0x44 })
|
||||
private val remoteState = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 0x55 })
|
||||
|
||||
@Test
|
||||
fun `every generation emits and first valid proof echoes exactly once`() {
|
||||
val store = MemoryStore()
|
||||
val sent = CopyOnWriteArrayList<AuthenticatedPeerState>()
|
||||
val applied = CopyOnWriteArrayList<AuthenticatedPeerState>()
|
||||
var activeSession = firstSession
|
||||
val coordinator = AuthenticatedPeerStateCoordinator(
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
authenticatedSessionProvider = { activeSession },
|
||||
withAuthenticatedSession = { _, expected, action ->
|
||||
if (expected == activeSession) action() else false
|
||||
},
|
||||
store = store,
|
||||
localStateProvider = { localState },
|
||||
applyAuthenticatedState = { _, key, state ->
|
||||
assertArrayEquals(remoteStatic, key)
|
||||
applied += state
|
||||
},
|
||||
sendState = { _, state, _ -> sent.add(state) },
|
||||
onResolution = {},
|
||||
proofTimeoutMs = 5_000
|
||||
)
|
||||
|
||||
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
|
||||
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession))
|
||||
assertEquals(1, sent.size)
|
||||
assertTrue(coordinator.receive(peerID, remoteState, firstSession))
|
||||
assertEquals(2, sent.size)
|
||||
assertTrue(coordinator.receive(peerID, remoteState, firstSession))
|
||||
assertEquals("Repeated proof must not echo again", 2, sent.size)
|
||||
assertEquals(1, applied.size)
|
||||
|
||||
val different = AuthenticatedPeerState(PeerCapabilities.NONE, ByteArray(32) { 0x66 })
|
||||
assertFalse(
|
||||
"A generation cannot replace its first proof",
|
||||
coordinator.receive(peerID, different, firstSession)
|
||||
)
|
||||
|
||||
activeSession = secondSession
|
||||
// Policy can observe the crypto swap before its completion callback. It must adopt the
|
||||
// new token as Awaiting instead of reusing gen-N Proven state.
|
||||
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, secondSession))
|
||||
assertEquals(3, sent.size)
|
||||
coordinator.onSessionAuthenticated(peerID, remoteStatic, secondSession.sessionToken)
|
||||
assertEquals(3, sent.size)
|
||||
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, secondSession))
|
||||
assertFalse(coordinator.receive(peerID, remoteState, firstSession))
|
||||
assertTrue(coordinator.receive(peerID, different, secondSession))
|
||||
assertEquals(4, sent.size)
|
||||
assertEquals(2, applied.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `live generation is adopted once and watchdog is never reset by policy reads`() = runBlocking {
|
||||
val sent = CopyOnWriteArrayList<AuthenticatedPeerState>()
|
||||
val resolutions = CopyOnWriteArrayList<String>()
|
||||
val coordinator = AuthenticatedPeerStateCoordinator(
|
||||
scope = this,
|
||||
authenticatedSessionProvider = { firstSession },
|
||||
withAuthenticatedSession = { _, expected, action ->
|
||||
if (expected == firstSession) action() else false
|
||||
},
|
||||
store = MemoryStore(),
|
||||
localStateProvider = { localState },
|
||||
applyAuthenticatedState = { _, _, _ -> },
|
||||
sendState = { _, state, _ -> sent += state; true },
|
||||
onResolution = resolutions::add,
|
||||
proofTimeoutMs = 20
|
||||
)
|
||||
|
||||
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession))
|
||||
delay(10)
|
||||
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession))
|
||||
delay(25)
|
||||
|
||||
assertEquals(AuthenticatedPeerStateStatus.TimedOut, coordinator.status(peerID, firstSession))
|
||||
assertEquals(1, sent.size)
|
||||
assertEquals(listOf(peerID), resolutions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delayed old completion cannot replace a newer tracked generation`() {
|
||||
val sentTokens = CopyOnWriteArrayList<ByteArray>()
|
||||
var activeSession = secondSession
|
||||
val coordinator = AuthenticatedPeerStateCoordinator(
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
authenticatedSessionProvider = { activeSession },
|
||||
withAuthenticatedSession = { _, expected, action ->
|
||||
if (expected == activeSession) action() else false
|
||||
},
|
||||
store = MemoryStore(),
|
||||
localStateProvider = { localState },
|
||||
applyAuthenticatedState = { _, _, _ -> },
|
||||
sendState = { _, _, session -> sentTokens += session.sessionToken; true },
|
||||
onResolution = {},
|
||||
proofTimeoutMs = 5_000
|
||||
)
|
||||
|
||||
coordinator.onSessionAuthenticated(peerID, remoteStatic, secondSession.sessionToken)
|
||||
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
|
||||
|
||||
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, secondSession))
|
||||
assertEquals(1, sentTokens.size)
|
||||
assertArrayEquals(secondSession.sessionToken, sentTokens.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `watchdog resolves no-proof generation without trusting persisted prior state`() = runBlocking {
|
||||
val store = MemoryStore()
|
||||
store.states[fingerprint(remoteStatic)] = remoteState
|
||||
val resolutions = CopyOnWriteArrayList<String>()
|
||||
val coordinator = AuthenticatedPeerStateCoordinator(
|
||||
scope = this,
|
||||
authenticatedSessionProvider = { firstSession },
|
||||
withAuthenticatedSession = { _, expected, action ->
|
||||
if (expected == firstSession) action() else false
|
||||
},
|
||||
store = store,
|
||||
localStateProvider = { localState },
|
||||
applyAuthenticatedState = { _, _, _ -> },
|
||||
sendState = { _, _, _ -> true },
|
||||
onResolution = resolutions::add,
|
||||
proofTimeoutMs = 20
|
||||
)
|
||||
|
||||
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
|
||||
delay(50)
|
||||
|
||||
assertEquals(AuthenticatedPeerStateStatus.TimedOut, coordinator.status(peerID, firstSession))
|
||||
assertEquals(listOf(peerID), resolutions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copied-static preannounce Ed key is replaced by authenticated proof`() {
|
||||
val peerManager = PeerManager()
|
||||
val attackerEd = ByteArray(32) { 0x11 }
|
||||
val victimEd = ByteArray(32) { 0x22 }
|
||||
peerManager.updatePeerInfoFromVerifiedAnnouncement(
|
||||
peerID,
|
||||
"attacker-name",
|
||||
remoteStatic,
|
||||
attackerEd,
|
||||
true,
|
||||
PeerCapabilities.PRIVATE_MEDIA
|
||||
)
|
||||
val coordinator = AuthenticatedPeerStateCoordinator(
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
authenticatedSessionProvider = { firstSession },
|
||||
withAuthenticatedSession = { _, expected, action ->
|
||||
if (expected == firstSession) action() else false
|
||||
},
|
||||
store = MemoryStore(),
|
||||
localStateProvider = { localState },
|
||||
applyAuthenticatedState = peerManager::applyAuthenticatedPeerState,
|
||||
sendState = { _, _, _ -> true },
|
||||
onResolution = {},
|
||||
proofTimeoutMs = 5_000
|
||||
)
|
||||
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
|
||||
|
||||
assertTrue(
|
||||
coordinator.receive(
|
||||
peerID,
|
||||
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, victimEd),
|
||||
firstSession
|
||||
)
|
||||
)
|
||||
val peer = peerManager.getPeerInfo(peerID)!!
|
||||
assertArrayEquals(victimEd, peer.signingPublicKey)
|
||||
assertEquals(peerID, peer.nickname)
|
||||
assertFalse("Copied self-signed nickname must lose verified status", peer.isVerifiedNickname)
|
||||
assertFalse(peer.hasVerifiedAnnouncement)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed durable persistence cannot publish peer state in memory`() {
|
||||
val applied = CopyOnWriteArrayList<AuthenticatedPeerState>()
|
||||
val store = object : AuthenticatedPeerStateStore {
|
||||
override fun load(fingerprint: String): AuthenticatedPeerState? = null
|
||||
override fun persist(
|
||||
fingerprint: String,
|
||||
state: AuthenticatedPeerState,
|
||||
onCommitted: () -> Unit
|
||||
): Boolean = false
|
||||
override fun isPrivateMediaPinned(fingerprint: String): Boolean = false
|
||||
}
|
||||
val coordinator = AuthenticatedPeerStateCoordinator(
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
authenticatedSessionProvider = { firstSession },
|
||||
withAuthenticatedSession = { _, expected, action ->
|
||||
if (expected == firstSession) action() else false
|
||||
},
|
||||
store = store,
|
||||
localStateProvider = { localState },
|
||||
applyAuthenticatedState = { _, _, state -> applied += state },
|
||||
sendState = { _, _, _ -> true },
|
||||
onResolution = {},
|
||||
proofTimeoutMs = 5_000
|
||||
)
|
||||
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
|
||||
|
||||
assertFalse(coordinator.receive(peerID, remoteState, firstSession))
|
||||
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession))
|
||||
assertTrue(applied.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale decryption lease cannot persist or apply after generation replacement`() {
|
||||
var activeSession = firstSession
|
||||
var persistCalls = 0
|
||||
var applyCalls = 0
|
||||
val store = object : AuthenticatedPeerStateStore {
|
||||
override fun load(fingerprint: String): AuthenticatedPeerState? = null
|
||||
override fun persist(
|
||||
fingerprint: String,
|
||||
state: AuthenticatedPeerState,
|
||||
onCommitted: () -> Unit
|
||||
): Boolean {
|
||||
persistCalls += 1
|
||||
onCommitted()
|
||||
return true
|
||||
}
|
||||
override fun isPrivateMediaPinned(fingerprint: String): Boolean = false
|
||||
}
|
||||
val coordinator = AuthenticatedPeerStateCoordinator(
|
||||
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
authenticatedSessionProvider = { activeSession },
|
||||
withAuthenticatedSession = { _, expected, action ->
|
||||
if (expected == activeSession) action() else false
|
||||
},
|
||||
store = store,
|
||||
localStateProvider = { localState },
|
||||
applyAuthenticatedState = { _, _, _ -> applyCalls += 1 },
|
||||
sendState = { _, _, _ -> true },
|
||||
onResolution = {},
|
||||
proofTimeoutMs = 5_000
|
||||
)
|
||||
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
|
||||
activeSession = secondSession
|
||||
|
||||
assertFalse(coordinator.receive(peerID, remoteState, firstSession))
|
||||
assertEquals(0, persistCalls)
|
||||
assertEquals(0, applyCalls)
|
||||
}
|
||||
|
||||
private fun fingerprint(key: ByteArray): String =
|
||||
MessageDigest.getInstance("SHA-256").digest(key).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
@@ -2,10 +2,15 @@ package com.bitchat.android.mesh
|
||||
|
||||
import android.os.Build
|
||||
import com.bitchat.android.model.IdentityAnnouncement
|
||||
import com.bitchat.android.model.AuthenticatedPeerState
|
||||
import com.bitchat.android.model.BitchatFilePacket
|
||||
import com.bitchat.android.model.NoisePayload
|
||||
import com.bitchat.android.model.NoisePayloadType
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.noise.NoisePeerIdentity
|
||||
import com.bitchat.android.noise.AuthenticatedNoiseSession
|
||||
import com.bitchat.android.noise.NoiseDecryptionResult
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
import com.bitchat.android.protocol.SpecialRecipients
|
||||
@@ -38,6 +43,10 @@ class MessageHandlerTest {
|
||||
private val myPeerID = "1111222233334444"
|
||||
private val noiseKey = ByteArray(32) { 0x0B }
|
||||
private val peerID = NoisePeerIdentity.derivePeerID(noiseKey)!!
|
||||
private val authenticatedSession = AuthenticatedNoiseSession(
|
||||
noiseKey,
|
||||
ByteArray(32) { 0x5D }
|
||||
)
|
||||
private val nickname = "peer"
|
||||
private val signingKey = ByteArray(32) { 0x0A }
|
||||
private val signature = ByteArray(64) { 1 }
|
||||
@@ -216,7 +225,9 @@ class MessageHandlerTest {
|
||||
)
|
||||
val prereleasePlaintext = byteArrayOf(0x09) + file.encode()!!
|
||||
val ciphertext = byteArrayOf(0x41, 0x42, 0x43)
|
||||
whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(prereleasePlaintext)
|
||||
whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(
|
||||
NoiseDecryptionResult(prereleasePlaintext, authenticatedSession)
|
||||
)
|
||||
whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname)
|
||||
whenever(delegate.getMyNickname()).thenReturn("me")
|
||||
whenever(delegate.encryptForPeer(any(), eq(peerID))).thenReturn(byteArrayOf(0x55))
|
||||
@@ -239,6 +250,41 @@ class MessageHandlerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid encrypted peer state is delivered and malformed state is ignored`() = runBlocking {
|
||||
val state = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey)
|
||||
val validCiphertext = byteArrayOf(0x31)
|
||||
val malformedCiphertext = byteArrayOf(0x32)
|
||||
whenever(delegate.decryptFromPeer(validCiphertext, peerID)).thenReturn(
|
||||
NoiseDecryptionResult(
|
||||
NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode(),
|
||||
authenticatedSession
|
||||
)
|
||||
)
|
||||
whenever(delegate.decryptFromPeer(malformedCiphertext, peerID)).thenReturn(
|
||||
NoiseDecryptionResult(
|
||||
NoisePayload(NoisePayloadType.PEER_STATE, byteArrayOf(0x01, 0x01)).encode(),
|
||||
authenticatedSession
|
||||
)
|
||||
)
|
||||
val base = BitchatPacket(
|
||||
version = 1u,
|
||||
type = MessageType.NOISE_ENCRYPTED.value,
|
||||
senderID = peerID.hexToBytes(),
|
||||
recipientID = myPeerID.hexToBytes(),
|
||||
timestamp = System.currentTimeMillis().toULong(),
|
||||
payload = validCiphertext,
|
||||
ttl = 7u
|
||||
)
|
||||
|
||||
handler.handleNoiseEncrypted(RoutedPacket(base, peerID, "direct-link"))
|
||||
handler.handleNoiseEncrypted(
|
||||
RoutedPacket(base.copy(payload = malformedCiphertext), peerID, "direct-link")
|
||||
)
|
||||
|
||||
verify(delegate).onAuthenticatedPeerStateReceived(peerID, state, authenticatedSession)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first self-signed announce cannot claim an ID derived from another Noise key`() = runBlocking {
|
||||
val attackerNoiseKey = ByteArray(32) { 0x6B }
|
||||
@@ -314,6 +360,20 @@ class MessageHandlerTest {
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `persisted authenticated Ed key rejects copied-static preannounce after restart`() = runBlocking {
|
||||
whenever(delegate.getAuthenticatedSigningKey(any())).thenReturn(ByteArray(32) { 0x44 })
|
||||
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(),
|
||||
|
||||
@@ -1,144 +1,69 @@
|
||||
package com.bitchat.android.mesh
|
||||
|
||||
import com.bitchat.android.model.AuthenticatedPeerState
|
||||
import com.bitchat.android.model.PeerCapabilities
|
||||
import com.bitchat.android.noise.AuthenticatedNoiseSession
|
||||
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 var authenticatedSession: AuthenticatedNoiseSession? = AuthenticatedNoiseSession(
|
||||
ByteArray(32) { (it + 1).toByte() },
|
||||
ByteArray(32) { 0x51 }
|
||||
)
|
||||
private var status: AuthenticatedPeerStateStatus = AuthenticatedPeerStateStatus.Awaiting
|
||||
private var pinned = false
|
||||
private val controller = PrivateMediaSecurityController(
|
||||
peerInfoProvider = { peerInfo },
|
||||
authenticatedRemoteStaticProvider = { authenticatedRemoteStatic?.copyOf() },
|
||||
pinStore = pins
|
||||
authenticatedSessionProvider = { authenticatedSession },
|
||||
peerStateStatusProvider = { _, _ -> status },
|
||||
isPrivateMediaPinned = { pinned }
|
||||
)
|
||||
|
||||
@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)
|
||||
fun `live session waits for fresh generation proof`() {
|
||||
assertEquals(PrivateMediaPolicyDecision.AwaitingPeerState, controller.sendPolicy(peerID))
|
||||
}
|
||||
|
||||
@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 }
|
||||
fun `valid private-media proof enables encrypted wire`() {
|
||||
status = AuthenticatedPeerStateStatus.Proven(
|
||||
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 1 })
|
||||
)
|
||||
assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Encrypted)
|
||||
}
|
||||
|
||||
assertFalse(controller.refreshAuthenticatedCapability(peerID))
|
||||
assertTrue(pins.pins.isEmpty())
|
||||
@Test
|
||||
fun `no-bit proof permits consent only when capability was never pinned`() {
|
||||
status = AuthenticatedPeerStateStatus.Proven(
|
||||
AuthenticatedPeerState(PeerCapabilities.NONE, ByteArray(32) { 1 })
|
||||
)
|
||||
assertEquals(PrivateMediaPolicyDecision.RequiresLegacyConsent, controller.sendPolicy(peerID))
|
||||
|
||||
pinned = true
|
||||
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))
|
||||
@Test
|
||||
fun `no-proof timeout permits old-client consent but blocks pinned downgrade`() {
|
||||
status = AuthenticatedPeerStateStatus.TimedOut
|
||||
assertEquals(PrivateMediaPolicyDecision.RequiresLegacyConsent, controller.sendPolicy(peerID))
|
||||
|
||||
pinned = true
|
||||
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())
|
||||
fun `live session missing coordinator generation waits and never unlocks legacy`() {
|
||||
status = AuthenticatedPeerStateStatus.Missing
|
||||
assertEquals(PrivateMediaPolicyDecision.AwaitingPeerState, controller.sendPolicy(peerID))
|
||||
}
|
||||
|
||||
@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
|
||||
fun `pin never bypasses requirement for live authenticated session`() {
|
||||
pinned = true
|
||||
authenticatedSession = 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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 com.bitchat.android.noise.AuthenticatedNoiseSession
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
@@ -18,6 +19,10 @@ import java.util.Random
|
||||
class PrivateMediaTransferPreparerTest {
|
||||
private val senderID = hex("0011223344556677")
|
||||
private val recipientID = hex("8877665544332211")
|
||||
private val authenticatedSession = AuthenticatedNoiseSession(
|
||||
ByteArray(32) { 0x21 },
|
||||
ByteArray(32) { 0x22 }
|
||||
)
|
||||
private val fragmentManagers = mutableListOf<FragmentManager>()
|
||||
|
||||
@After
|
||||
@@ -29,10 +34,10 @@ class PrivateMediaTransferPreparerTest {
|
||||
fun `encrypted mode emits deployed Noise 0x20 and signs before fragmentation`() {
|
||||
var encryptedPlaintext: ByteArray? = null
|
||||
val preparer = preparer(
|
||||
policy = PrivateMediaPolicyDecision.Encrypted,
|
||||
encrypt = { bytes, _ ->
|
||||
policy = PrivateMediaPolicyDecision.Encrypted(authenticatedSession),
|
||||
encrypt = { bytes, _, _ ->
|
||||
encryptedPlaintext = bytes
|
||||
byteArrayOf(0x41) + bytes
|
||||
PrivateMediaEncryptionResult.Success(byteArrayOf(0x41) + bytes)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -101,9 +106,9 @@ class PrivateMediaTransferPreparerTest {
|
||||
senderID = senderID,
|
||||
ttl = 7u,
|
||||
policyProvider = { PrivateMediaPolicyDecision.NeedsHandshake },
|
||||
encrypt = { _, _ ->
|
||||
encrypt = { _, _, _ ->
|
||||
encrypted = true
|
||||
byteArrayOf(1)
|
||||
PrivateMediaEncryptionResult.Success(byteArrayOf(1))
|
||||
},
|
||||
finalizeRoutedAndSigned = {
|
||||
finalized = true
|
||||
@@ -123,6 +128,38 @@ class PrivateMediaTransferPreparerTest {
|
||||
assertTrue(!fragmented)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `peer-state wait 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.AwaitingPeerState },
|
||||
encrypt = { _, _, _ ->
|
||||
encrypted = true
|
||||
PrivateMediaEncryptionResult.Success(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.AwaitingPeerState, outcome)
|
||||
assertTrue(!encrypted)
|
||||
assertTrue(!finalized)
|
||||
assertTrue(!fragmented)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no route accepts 256 final fragments and rejects 257`() {
|
||||
assertExactBoundary(route = null)
|
||||
@@ -139,10 +176,62 @@ class PrivateMediaTransferPreparerTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generation churn re-runs policy once instead of losing the send intent`() {
|
||||
val replacementSession = AuthenticatedNoiseSession(
|
||||
authenticatedSession.remoteStaticKey,
|
||||
ByteArray(32) { 0x23 }
|
||||
)
|
||||
var policyCalls = 0
|
||||
val fragmentManager = FragmentManager().also { fragmentManagers += it }
|
||||
val preparer = PrivateMediaTransferPreparer(
|
||||
senderID = senderID,
|
||||
ttl = 7u,
|
||||
policyProvider = {
|
||||
policyCalls += 1
|
||||
PrivateMediaPolicyDecision.Encrypted(
|
||||
if (policyCalls == 1) authenticatedSession else replacementSession
|
||||
)
|
||||
},
|
||||
encrypt = { bytes, _, session ->
|
||||
if (session == authenticatedSession) {
|
||||
PrivateMediaEncryptionResult.GenerationChanged
|
||||
} else {
|
||||
PrivateMediaEncryptionResult.Success(byteArrayOf(0x41) + bytes)
|
||||
}
|
||||
},
|
||||
finalizeRoutedAndSigned = { it.copy(signature = ByteArray(64) { 0x33 }) },
|
||||
fragment = fragmentManager::createFragments
|
||||
)
|
||||
|
||||
val outcome = preparer.prepare("peer", recipientID, file(64), false)
|
||||
|
||||
assertTrue(outcome is PrivateMediaBuildOutcome.Ready)
|
||||
assertEquals(2, policyCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repeated generation churn remains retryable`() {
|
||||
val fragmentManager = FragmentManager().also { fragmentManagers += it }
|
||||
val preparer = PrivateMediaTransferPreparer(
|
||||
senderID = senderID,
|
||||
ttl = 7u,
|
||||
policyProvider = { PrivateMediaPolicyDecision.Encrypted(authenticatedSession) },
|
||||
encrypt = { _, _, _ -> PrivateMediaEncryptionResult.GenerationChanged },
|
||||
finalizeRoutedAndSigned = { it.copy(signature = ByteArray(64) { 0x33 }) },
|
||||
fragment = fragmentManager::createFragments
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
PrivateMediaBuildOutcome.AwaitingPeerState,
|
||||
preparer.prepare("peer", recipientID, file(64), false)
|
||||
)
|
||||
}
|
||||
|
||||
private fun assertExactBoundary(route: List<ByteArray>?) {
|
||||
val randomContent = ByteArray(180 * 1024).also { Random(0xB17C4A7).nextBytes(it) }
|
||||
val preparer = preparer(
|
||||
policy = PrivateMediaPolicyDecision.Encrypted,
|
||||
policy = PrivateMediaPolicyDecision.Encrypted(authenticatedSession),
|
||||
finalizer = { packet ->
|
||||
packet.copy(
|
||||
version = if (route == null) packet.version else 2u,
|
||||
@@ -180,7 +269,13 @@ class PrivateMediaTransferPreparerTest {
|
||||
|
||||
private fun preparer(
|
||||
policy: PrivateMediaPolicyDecision,
|
||||
encrypt: (ByteArray, String) -> ByteArray? = { bytes, _ -> byteArrayOf(0x01) + bytes },
|
||||
encrypt: (
|
||||
ByteArray,
|
||||
String,
|
||||
AuthenticatedNoiseSession
|
||||
) -> PrivateMediaEncryptionResult = { bytes, _, _ ->
|
||||
PrivateMediaEncryptionResult.Success(byteArrayOf(0x01) + bytes)
|
||||
},
|
||||
finalizer: (BitchatPacket) -> BitchatPacket? = { packet ->
|
||||
packet.copy(signature = ByteArray(64) { 0x33 })
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ class SecurityManagerTest {
|
||||
// Key pairs (using dummy bytes for mock verification)
|
||||
private val otherSigningKey = ByteArray(32) { 0xA }
|
||||
private val otherNoiseKey = ByteArray(32) { 0xB }
|
||||
private val sessionToken = ByteArray(32) { 0x5C }
|
||||
private val unknownPeerID = NoisePeerIdentity.derivePeerID(otherNoiseKey)!!
|
||||
|
||||
private val dummyPayload = "Hello World".toByteArray()
|
||||
@@ -344,6 +345,21 @@ class SecurityManagerTest {
|
||||
assertFalse(securityManager.validatePacket(packet, otherPeerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validatePacket rejects announce conflicting with persisted authenticated Ed key`() {
|
||||
whenever(mockDelegate.getAuthenticatedSigningKey(otherNoiseKey))
|
||||
.thenReturn(ByteArray(32) { 0x44 })
|
||||
val announcement = IdentityAnnouncement("Copied", otherNoiseKey, 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 - ignores own packets`() {
|
||||
val packet = BitchatPacket(
|
||||
@@ -420,7 +436,9 @@ class SecurityManagerTest {
|
||||
assertTrue(accepted)
|
||||
assertTrue(fakeEncryptionService.removePeerCalls == 0)
|
||||
verify(mockDelegate).sendHandshakeResponse(otherPeerID, response)
|
||||
verify(mockDelegate, never()).onKeyExchangeCompleted(any(), any(), anyOrNull(), anyOrNull())
|
||||
verify(mockDelegate, never()).onKeyExchangeCompleted(
|
||||
any(), any(), any(), anyOrNull(), anyOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -434,19 +452,23 @@ class SecurityManagerTest {
|
||||
assertFalse(securityManager.handleNoiseHandshake(routed))
|
||||
assertTrue(fakeEncryptionService.removePeerCalls == 0)
|
||||
verify(mockDelegate, never()).sendHandshakeResponse(any(), any())
|
||||
verify(mockDelegate, never()).onKeyExchangeCompleted(any(), any(), anyOrNull(), anyOrNull())
|
||||
verify(mockDelegate, never()).onKeyExchangeCompleted(
|
||||
any(), any(), any(), anyOrNull(), anyOrNull()
|
||||
)
|
||||
|
||||
fakeEncryptionService.handshakeError = null
|
||||
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
|
||||
response = null,
|
||||
establishedNow = true,
|
||||
authenticatedRemoteStaticKey = otherNoiseKey
|
||||
authenticatedRemoteStaticKey = otherNoiseKey,
|
||||
authenticatedSessionToken = sessionToken
|
||||
)
|
||||
assertTrue("Failed frames must not poison the processed-exchange cache", securityManager.handleNoiseHandshake(routed))
|
||||
assertTrue(fakeEncryptionService.handshakeCalls == 2)
|
||||
verify(mockDelegate).onKeyExchangeCompleted(
|
||||
otherPeerID,
|
||||
otherNoiseKey,
|
||||
sessionToken,
|
||||
"direct-link",
|
||||
"direct-link-token"
|
||||
)
|
||||
@@ -457,7 +479,8 @@ class SecurityManagerTest {
|
||||
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
|
||||
response = null,
|
||||
establishedNow = true,
|
||||
authenticatedRemoteStaticKey = otherNoiseKey
|
||||
authenticatedRemoteStaticKey = otherNoiseKey,
|
||||
authenticatedSessionToken = sessionToken
|
||||
)
|
||||
val routed = handshakePacket(byteArrayOf(0x51, 0x52, 0x53))
|
||||
|
||||
@@ -466,6 +489,7 @@ class SecurityManagerTest {
|
||||
verify(mockDelegate, times(1)).onKeyExchangeCompleted(
|
||||
otherPeerID,
|
||||
otherNoiseKey,
|
||||
sessionToken,
|
||||
"direct-link",
|
||||
"direct-link-token"
|
||||
)
|
||||
@@ -478,7 +502,8 @@ class SecurityManagerTest {
|
||||
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
|
||||
response = null,
|
||||
establishedNow = true,
|
||||
authenticatedRemoteStaticKey = otherNoiseKey
|
||||
authenticatedRemoteStaticKey = otherNoiseKey,
|
||||
authenticatedSessionToken = sessionToken
|
||||
)
|
||||
val routed = handshakePacket(
|
||||
payload = byteArrayOf(0x61, 0x62, 0x63),
|
||||
@@ -486,7 +511,13 @@ class SecurityManagerTest {
|
||||
)
|
||||
|
||||
assertTrue(securityManager.handleNoiseHandshake(routed))
|
||||
verify(mockDelegate).onKeyExchangeCompleted(otherPeerID, otherNoiseKey, null, null)
|
||||
verify(mockDelegate).onKeyExchangeCompleted(
|
||||
otherPeerID,
|
||||
otherNoiseKey,
|
||||
sessionToken,
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupKnownPeer(peerID: String, signingKey: ByteArray) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.bitchat.android.model
|
||||
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class AuthenticatedPeerStateTest {
|
||||
private val signingKey = ByteArray(32) { it.toByte() }
|
||||
|
||||
@Test
|
||||
fun `encoder matches canonical iOS bytes`() {
|
||||
val encoded = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey).encode()
|
||||
|
||||
assertArrayEquals(
|
||||
byteArrayOf(0x01, 0x01, 0x02, 0x00, 0x01, 0x02, 0x20) + signingKey,
|
||||
encoded
|
||||
)
|
||||
assertEquals(
|
||||
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey),
|
||||
AuthenticatedPeerState.decode(encoded)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decoder skips unknown TLVs and accepts either known-field order`() {
|
||||
val payload = byteArrayOf(
|
||||
0x01,
|
||||
0x7F, 0x02, 0x55, 0x66,
|
||||
0x02, 0x20
|
||||
) + signingKey + byteArrayOf(0x01, 0x01, 0x00)
|
||||
|
||||
assertEquals(
|
||||
AuthenticatedPeerState(PeerCapabilities.NONE, signingKey),
|
||||
AuthenticatedPeerState.decode(payload)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decoder rejects malformed duplicate missing and noncanonical fields`() {
|
||||
val validCapabilities = byteArrayOf(0x01, 0x01, 0x00)
|
||||
val validSigning = byteArrayOf(0x02, 0x20) + signingKey
|
||||
val invalid = listOf(
|
||||
byteArrayOf(),
|
||||
byteArrayOf(0x02) + validCapabilities + validSigning,
|
||||
byteArrayOf(0x01) + validCapabilities,
|
||||
byteArrayOf(0x01) + validSigning,
|
||||
byteArrayOf(0x01) + validCapabilities + validCapabilities + validSigning,
|
||||
byteArrayOf(0x01, 0x01, 0x02, 0x00, 0x00) + validSigning,
|
||||
byteArrayOf(0x01, 0x01, 0x00) + validSigning,
|
||||
byteArrayOf(0x01, 0x01, 0x09) + ByteArray(9) + validSigning,
|
||||
byteArrayOf(0x01, 0x02, 0x1F) + ByteArray(31) + validCapabilities,
|
||||
byteArrayOf(0x01, 0x7F),
|
||||
byteArrayOf(0x01, 0x7F, 0x02, 0x01)
|
||||
)
|
||||
|
||||
invalid.forEach { assertNull("Expected rejection for ${it.joinToString()}", AuthenticatedPeerState.decode(it)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Noise wrapper emits and decodes canonical 0x21`() {
|
||||
val state = AuthenticatedPeerState(PeerCapabilities.NONE, signingKey)
|
||||
val encoded = NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode()
|
||||
|
||||
assertEquals(0x21, encoded[0].toInt() and 0xFF)
|
||||
assertEquals(NoisePayloadType.PEER_STATE, NoisePayload.decode(encoded)?.type)
|
||||
}
|
||||
}
|
||||
+98
-3
@@ -10,6 +10,10 @@ import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class NoiseSessionManagerIdentityBindingTest {
|
||||
private data class TestIdentity(
|
||||
@@ -40,8 +44,12 @@ class NoiseSessionManagerIdentityBindingTest {
|
||||
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))
|
||||
val aliceSession = aliceManager.getAuthenticatedSession(bob.peerID)!!
|
||||
val bobSession = bobManager.getAuthenticatedSession(alice.peerID)!!
|
||||
val ciphertext = aliceManager.encryptForSession(plaintext, bob.peerID, aliceSession)
|
||||
val decrypted = bobManager.decryptWithSession(ciphertext, alice.peerID)
|
||||
assertArrayEquals(plaintext, decrypted.plaintext)
|
||||
assertArrayEquals(bobSession.sessionToken, decrypted.authenticatedSession.sessionToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -145,6 +153,7 @@ class NoiseSessionManagerIdentityBindingTest {
|
||||
|
||||
completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID)
|
||||
val originalSession = aliceManager.getSession(bob.peerID)
|
||||
val originalBinding = aliceManager.getAuthenticatedSession(bob.peerID)!!
|
||||
|
||||
// Simulate Bob restarting with the same persistent static identity and no session state.
|
||||
val restartedBobManager = manager(bob)
|
||||
@@ -157,12 +166,98 @@ class NoiseSessionManagerIdentityBindingTest {
|
||||
assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
|
||||
assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID))
|
||||
assertTrue(aliceAuthenticatedCallbacks == 2)
|
||||
val replacementBinding = aliceManager.getAuthenticatedSession(bob.peerID)!!
|
||||
assertFalse(originalBinding.sessionToken.contentEquals(replacementBinding.sessionToken))
|
||||
try {
|
||||
aliceManager.encryptForSession(
|
||||
"stale generation".toByteArray(),
|
||||
bob.peerID,
|
||||
originalBinding
|
||||
)
|
||||
fail("Expected stale generation-bound encryption to be rejected")
|
||||
} catch (_: NoiseSessionError.SessionGenerationChanged) {
|
||||
// Expected.
|
||||
}
|
||||
|
||||
val plaintext = "replacement transport".toByteArray()
|
||||
val ciphertext = restartedBobManager.encrypt(plaintext, alice.peerID)
|
||||
val ciphertext = restartedBobManager.encryptForSession(
|
||||
plaintext,
|
||||
alice.peerID,
|
||||
restartedBobManager.getAuthenticatedSession(alice.peerID)!!
|
||||
)
|
||||
assertArrayEquals(plaintext, aliceManager.decrypt(ciphertext, bob.peerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generation lease blocks replacement and rejects stale token afterward`() {
|
||||
val alice = identity()
|
||||
val bob = identity()
|
||||
val aliceManager = manager(alice)
|
||||
val originalBobManager = manager(bob)
|
||||
completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID)
|
||||
val originalBinding = aliceManager.getAuthenticatedSession(bob.peerID)!!
|
||||
|
||||
val restartedBobManager = manager(bob)
|
||||
val message1 = restartedBobManager.initiateHandshake(alice.peerID)!!
|
||||
val message2 = aliceManager.processHandshakeMessage(bob.peerID, message1)!!
|
||||
val message3 = restartedBobManager.processHandshakeMessage(alice.peerID, message2)!!
|
||||
|
||||
val leaseEntered = CountDownLatch(1)
|
||||
val releaseLease = CountDownLatch(1)
|
||||
val replacementStarted = CountDownLatch(1)
|
||||
val replacementCompleted = CountDownLatch(1)
|
||||
val replacementFinished = AtomicBoolean(false)
|
||||
val threadFailure = AtomicReference<Throwable?>(null)
|
||||
val leaseThread = Thread {
|
||||
try {
|
||||
assertTrue(
|
||||
aliceManager.withAuthenticatedSession(bob.peerID, originalBinding) {
|
||||
leaseEntered.countDown()
|
||||
releaseLease.await(2, TimeUnit.SECONDS)
|
||||
}
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
threadFailure.set(error)
|
||||
}
|
||||
}
|
||||
val replacementThread = Thread {
|
||||
try {
|
||||
replacementStarted.countDown()
|
||||
aliceManager.processHandshakeMessage(bob.peerID, message3)
|
||||
replacementFinished.set(true)
|
||||
} catch (error: Throwable) {
|
||||
threadFailure.set(error)
|
||||
} finally {
|
||||
replacementCompleted.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
leaseThread.start()
|
||||
assertTrue(leaseEntered.await(1, TimeUnit.SECONDS))
|
||||
replacementThread.start()
|
||||
assertTrue(replacementStarted.await(1, TimeUnit.SECONDS))
|
||||
try {
|
||||
assertFalse(
|
||||
"Replacement must wait while the old generation lease is active",
|
||||
replacementCompleted.await(100, TimeUnit.MILLISECONDS)
|
||||
)
|
||||
} finally {
|
||||
releaseLease.countDown()
|
||||
}
|
||||
leaseThread.join(2_000)
|
||||
replacementThread.join(2_000)
|
||||
assertTrue(replacementCompleted.await(1, TimeUnit.SECONDS))
|
||||
threadFailure.get()?.let { throw it }
|
||||
assertTrue(replacementFinished.get())
|
||||
|
||||
try {
|
||||
aliceManager.encryptForSession(byteArrayOf(1), bob.peerID, originalBinding)
|
||||
fail("Expected old token to be rejected after replacement")
|
||||
} catch (_: NoiseSessionError.SessionGenerationChanged) {
|
||||
// Expected.
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `peer ID derivation rejects malformed keys and non-wire claims`() {
|
||||
val peer = identity()
|
||||
|
||||
@@ -44,6 +44,7 @@ class MediaSendingManagerMigrationTest {
|
||||
state,
|
||||
MessageManager(state),
|
||||
mock(),
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
|
||||
getMeshService = { mesh }
|
||||
)
|
||||
file = kotlin.io.path.createTempFile("private-media", ".jpg").toFile().apply {
|
||||
@@ -57,28 +58,112 @@ class MediaSendingManagerMigrationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preflight rejection creates no local echo mapping or send`() {
|
||||
fun `preflight rejection creates only a visible failure and no file echo 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())
|
||||
val messages = state.privateChats.value[peerID].orEmpty()
|
||||
assertEquals(1, messages.size)
|
||||
assertTrue(messages.single().content.contains("too many fragments"))
|
||||
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
|
||||
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`() {
|
||||
fun `pinned downgrade rejection is visible without a file echo or send`() {
|
||||
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
|
||||
.thenReturn(
|
||||
PrivateMediaPreparation.Rejected(
|
||||
"Encrypted private media was previously pinned, but this session proved no support; send blocked"
|
||||
)
|
||||
)
|
||||
|
||||
manager.sendImageNote(peerID, null, file.absolutePath)
|
||||
|
||||
val messages = state.privateChats.value[peerID].orEmpty()
|
||||
assertEquals(1, messages.size)
|
||||
assertTrue(messages.single().content.contains("previously pinned"))
|
||||
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
|
||||
verify(mesh, never()).sendFilePrivate(any(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing Noise session retains first send and commits after proof resolution`() {
|
||||
val commits = AtomicInteger(0)
|
||||
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
|
||||
.thenReturn(PrivateMediaPreparation.NeedsHandshake)
|
||||
.thenAnswer { invocation ->
|
||||
PrivateMediaPreparation.Ready(
|
||||
PreparedPrivateMediaTransfer(
|
||||
transferId = invocation.getArgument<String>(2),
|
||||
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
|
||||
) {
|
||||
commits.incrementAndGet()
|
||||
true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
manager.retryPendingPrivateMedia(peerID)
|
||||
|
||||
assertEquals(1, commits.get())
|
||||
assertEquals(1, state.privateChats.value[peerID]?.size)
|
||||
verify(mesh, times(2)).prepareFilePrivate(eq(peerID), any(), any(), eq(false))
|
||||
verify(mesh, never()).sendFilePrivate(any(), any())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `awaiting peer state retains send and watchdog resolution offers legacy consent`() {
|
||||
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
|
||||
.thenReturn(PrivateMediaPreparation.AwaitingPeerState)
|
||||
.thenReturn(PrivateMediaPreparation.RequiresLegacyConsent("relay-visible warning"))
|
||||
|
||||
manager.sendImageNote(peerID, null, file.absolutePath)
|
||||
|
||||
verify(mesh, never()).initiateNoiseHandshake(peerID)
|
||||
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
|
||||
assertEquals(null, manager.legacyPrivateMediaConsent.value)
|
||||
|
||||
manager.retryPendingPrivateMedia(peerID)
|
||||
|
||||
assertNotNull(manager.legacyPrivateMediaConsent.value)
|
||||
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reentrant proof resolution during preparation cannot lose first send intent`() {
|
||||
val commits = AtomicInteger(0)
|
||||
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
|
||||
.thenAnswer {
|
||||
manager.retryPendingPrivateMedia(peerID)
|
||||
PrivateMediaPreparation.AwaitingPeerState
|
||||
}
|
||||
.thenAnswer { invocation ->
|
||||
PrivateMediaPreparation.Ready(
|
||||
PreparedPrivateMediaTransfer(
|
||||
transferId = invocation.getArgument<String>(2),
|
||||
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
|
||||
) {
|
||||
commits.incrementAndGet()
|
||||
true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
manager.sendImageNote(peerID, null, file.absolutePath)
|
||||
|
||||
assertEquals(1, commits.get())
|
||||
assertEquals(1, state.privateChats.value[peerID]?.size)
|
||||
verify(mesh, times(2)).prepareFilePrivate(eq(peerID), any(), any(), eq(false))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user