Authenticate private media capability per Noise session

This commit is contained in:
jack
2026-07-12 12:00:35 -04:00
parent 64ed730bf2
commit eb55cdb6a7
31 changed files with 2121 additions and 408 deletions
@@ -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())
}
}
@@ -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)
}
}
@@ -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