Verify replayed messages from peers that have left the mesh

A REQUEST_SYNC reply can carry a message from a peer that has left, on
LEAVE or after three minutes of silence. Normally the neighbor re-serves
that peer's announcement first, which restores the signing key. When the
key is not restored, the message arrives with no key on file and the
signature check rejects it. That happens in two cases. An iOS neighbor
that relaunched serves messages restored from disk, and that archive
holds no announcements. An Android neighbor that missed the LEAVE keeps
serving the peer for about three minutes while the receiving device
rejects the re-served announcement as a duplicate. iOS keeps a message
from a sender that has left when it has verified that sender before, by
falling back to its persisted identity. Android persists the signing key
for every peer that completed the authenticated peer-state exchange, and
the message path did not read it. This addresses the first bullet of
#622.

The signature check falls back to the persisted signing key when the
live registry has none, found by peer ID directly since a peer ID is the
prefix of its own fingerprint. The broadcast handler delivers a message
from a sender that has left under the cached nickname or the peer ID. A
message from a sender that has left is not archived, and nothing the
LEAVE purge removed re-enters the archive. Messages from present peers
and the receiving device's own broadcasts are archived as before. A live
key is always preferred, a sender with no key on file is rejected as
before, and a registry entry with an unverified nickname is dropped as
before. Receive-side only.

Seventeen tests across the security manager, the message handler, the
coordinator, the secure store and the sync archive.
This commit is contained in:
heyaim 2026-09-02 04:52:06 -05:00
parent 81ba775063
commit b9bf303d9a
13 changed files with 546 additions and 15 deletions

View File

@ -390,6 +390,19 @@ class SecureIdentityStateManager {
fun getAuthenticatedSigningKey(fingerprint: String): ByteArray? =
getAuthenticatedPeerState(fingerprint)?.signingPublicKey?.copyOf()
/**
* Fingerprint of the persisted authenticated peer state for a peer ID. A peer ID is the
* first sixteen hex characters of the fingerprint of its Noise static key, so a record
* whose fingerprint starts with the peer ID is that peer's; the record must still parse.
*/
fun findAuthenticatedFingerprintByPeerID(peerID: String): String? {
val prefix = peerID.lowercase()
if (prefix.length != 16 || !prefix.all { it in '0'..'9' || it in 'a'..'f' }) return null
val records = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet()) ?: return null
val fingerprint = records.firstOrNull { it.startsWith(prefix) }?.substringBefore(':') ?: return null
return fingerprint.takeIf { isValidFingerprint(it) && getAuthenticatedPeerState(it) != null }
}
// MARK: - Peer ID Rotation Management (removed)
// Android now derives peer ID from the persisted Noise identity fingerprint.

View File

@ -20,10 +20,16 @@ internal interface AuthenticatedPeerStateStore {
onCommitted: () -> Unit
): Boolean
fun isPrivateMediaPinned(fingerprint: String): Boolean
/** Fingerprint of the persisted authenticated state whose peer ID this is; survives the live registry. */
fun persistedFingerprintFor(peerID: String): String? = null
/** Nickname persisted for a fingerprint; only meaningful next to a persisted signing key. */
fun persistedNickname(fingerprint: String): String? = null
}
internal class SecureAuthenticatedPeerStateStore(context: Context) : AuthenticatedPeerStateStore {
private val identityState = SecureIdentityStateManager(context.applicationContext)
internal class SecureAuthenticatedPeerStateStore(
context: Context,
private val identityState: SecureIdentityStateManager = SecureIdentityStateManager(context.applicationContext)
) : AuthenticatedPeerStateStore {
override fun load(fingerprint: String): AuthenticatedPeerState? =
identityState.getAuthenticatedPeerState(fingerprint)
@ -36,6 +42,12 @@ internal class SecureAuthenticatedPeerStateStore(context: Context) : Authenticat
override fun isPrivateMediaPinned(fingerprint: String): Boolean =
identityState.isPrivateMediaCapable(fingerprint)
override fun persistedFingerprintFor(peerID: String): String? =
identityState.findAuthenticatedFingerprintByPeerID(peerID)
override fun persistedNickname(fingerprint: String): String? =
identityState.getCachedFingerprintNickname(fingerprint)
}
internal sealed interface AuthenticatedPeerStateStatus {
@ -219,6 +231,28 @@ internal class AuthenticatedPeerStateCoordinator(
return store.load(fingerprint(noisePublicKey))?.signingPublicKey?.copyOf()
}
/**
* Signing key persisted for a peer ID this device authenticated before. The live registry
* forgets a peer on LEAVE or timeout; the persisted identity does not, so a packet the peer
* signed while present can still be verified after it has gone. A peer ID is the prefix
* of its own fingerprint, so no separate map is needed.
*/
fun persistedSigningKeyFor(peerID: String): ByteArray? {
val fingerprint = store.persistedFingerprintFor(peerID) ?: return null
return store.load(fingerprint)?.signingPublicKey?.copyOf()
}
/**
* Display name for a peer ID whose persisted signing key vouches for it: the nickname
* cached while the peer was present, else the peer ID itself, as the registry does. Null
* when no signing key is persisted, so a cached name alone never names a sender.
*/
fun persistedNicknameFor(peerID: String): String? {
val fingerprint = store.persistedFingerprintFor(peerID) ?: return null
if (store.load(fingerprint)?.signingPublicKey == null) return null
return store.persistedNickname(fingerprint)?.takeIf { it.isNotBlank() } ?: peerID
}
fun isPrivateMediaPinned(peerID: String): Boolean {
val authenticatedSession = authenticatedSessionProvider(peerID) ?: return false
return store.isPrivateMediaPinned(fingerprint(authenticatedSession.remoteStaticKey))

View File

@ -171,7 +171,10 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
)
com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet ->
com.bitchat.android.service.MeshServiceHolder.setGossipManager(
gossipSyncManager,
hasLivePeer = { peerID -> peerManager.getPeerInfo(peerID) != null }
) { packet ->
signPacketBeforeBroadcast(packet)
}
if (isBleTransportEnabled()) {
@ -323,6 +326,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
override fun getPersistedSigningKey(peerID: String): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(peerID)
}
// StoreForwardManager delegates
@ -419,6 +425,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
override fun getPersistedPeerNickname(peerID: String): String? =
authenticatedPeerState.persistedNicknameFor(peerID)
// Noise protocol operations
override fun hasNoiseSession(peerID: String): Boolean {

View File

@ -143,7 +143,9 @@ class MeshCore(
TransportBridgeService.sendToPeer(transport.id, peerID, packet)
}
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
override fun hasLivePeer(peerID: String): Boolean = peerManager.getPeerInfo(peerID) != null
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket {
return signPacketBeforeBroadcast(packet)
}
}
@ -267,6 +269,9 @@ class MeshCore(
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
override fun getPersistedSigningKey(peerID: String): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(peerID)
}
storeForwardManager.delegate = object : StoreForwardManagerDelegate {
@ -365,6 +370,9 @@ class MeshCore(
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
override fun getPersistedPeerNickname(peerID: String): String? =
authenticatedPeerState.persistedNicknameFor(peerID)
override fun hasNoiseSession(peerID: String): Boolean {
return encryptionService.hasEstablishedSession(peerID)
}

View File

@ -454,11 +454,21 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
// Enforce: only accept public messages from verified peers we know
// Only accept public messages from senders whose identity is established: a verified
// peer in the live registry, or a sender the registry has forgotten whose signing key
// this device persisted. A peer still in the registry with an unverified nickname is
// dropped as before; the persisted name must not override that registry decision.
val peerInfo = delegate?.getPeerInfo(peerID)
if (peerInfo == null || !peerInfo.isVerifiedNickname) {
Log.w(TAG, "Dropping public message from unverified peer ${peerID.take(8)}")
return
val senderNickname = when {
peerInfo == null -> delegate?.getPersistedPeerNickname(peerID) ?: run {
Log.w(TAG, "Dropping public message from unknown peer ${peerID.take(8)}")
return
}
peerInfo.isVerifiedNickname -> delegate?.getPeerNickname(peerID) ?: "unknown"
else -> {
Log.w(TAG, "Dropping public message from unverified peer ${peerID.take(8)}")
return
}
}
try {
@ -470,7 +480,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
val savedPath = com.bitchat.android.features.file.FileUtils.saveIncomingFile(appContext, file)
val message = BitchatMessage(
id = PacketIdUtil.computeIdHex(packet).uppercase(),
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
sender = senderNickname,
content = savedPath,
type = com.bitchat.android.features.file.FileUtils.messageTypeForMime(file.mimeType),
senderPeerID = peerID,
@ -487,7 +497,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
// Fallback: plain text
val message = BitchatMessage(
id = PacketIdUtil.computeIdHex(packet).uppercase(),
sender = delegate?.getPeerNickname(peerID) ?: "unknown",
sender = senderNickname,
content = String(packet.payload, Charsets.UTF_8),
senderPeerID = peerID,
timestamp = Date(packet.timestamp.toLong())
@ -716,6 +726,8 @@ interface MessageHandlerDelegate {
): com.bitchat.android.noise.NoiseDecryptionResult?
fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean
fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = null
/** Nickname persisted for a peer this device authenticated before, for senders no longer in the live registry. */
fun getPersistedPeerNickname(peerID: String): String? = null
// Noise protocol operations
fun hasNoiseSession(peerID: String): Boolean

View File

@ -314,13 +314,16 @@ class SecurityManager(private val encryptionService: EncryptionService, private
return false
}
// 2. Get Signing Public Key
// 2. Get Signing Public Key: the live registry first, then the identity persisted
// for a peer this device authenticated before. A sync replay from a peer that has
// since left carries that peer's signature, and the live registry has forgotten it.
val peerInfo = delegate?.getPeerInfo(peerID)
val signingPublicKey = peerInfo?.signingPublicKey
?: delegate?.getPersistedSigningKey(peerID)
if (signingPublicKey == null) {
// If we don't have a key (and it's not an announce), we can't verify.
// For security, we must reject packets from unknown peers unless it's an announce.
// No live and no persisted key: nothing can verify this sender. Reject, as
// for any unknown peer; only an ANNOUNCE may introduce a key.
Log.w(TAG, "Signature check for $peerID: NO_SIGNING_KEY_AVAILABLE (packet type ${packet.type})")
return false
}
@ -481,4 +484,6 @@ interface SecurityManagerDelegate {
fun sendHandshakeResponse(peerID: String, response: ByteArray)
fun getPeerInfo(peerID: String): PeerInfo? // NEW: For signature verification
fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = null
/** Signing key persisted for a peer this device authenticated before, for senders no longer in the live registry. */
fun getPersistedSigningKey(peerID: String): ByteArray? = null
}

View File

@ -22,6 +22,7 @@ object MeshServiceHolder {
@Synchronized
fun setGossipManager(
mgr: GossipSyncManager,
hasLivePeer: (String) -> Boolean = { true },
signer: (BitchatPacket) -> BitchatPacket
) {
val previous = sharedGossipSyncManager
@ -29,7 +30,7 @@ object MeshServiceHolder {
try { previous?.stop() } catch (_: Exception) { }
}
sharedGossipSyncManager = mgr
mgr.delegate = TransportGossipDelegate(signer)
mgr.delegate = TransportGossipDelegate(signer, hasLivePeer)
if (activeGossipOwners.isNotEmpty()) {
mgr.start()
}
@ -53,8 +54,11 @@ object MeshServiceHolder {
}
private class TransportGossipDelegate(
private val signer: (BitchatPacket) -> BitchatPacket
private val signer: (BitchatPacket) -> BitchatPacket,
private val livePeer: (String) -> Boolean
) : GossipSyncManager.Delegate {
override fun hasLivePeer(peerID: String): Boolean = livePeer(peerID)
override fun sendPacket(packet: BitchatPacket) {
TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
}

View File

@ -20,6 +20,8 @@ class GossipSyncManager(
private val configProvider: ConfigProvider
) {
interface Delegate {
/** True when the live peer registry holds this sender; the default keeps today's archiving for every implementor. */
fun hasLivePeer(peerID: String): Boolean = true
fun sendPacket(packet: BitchatPacket)
fun sendPacketToPeer(peerID: String, packet: BitchatPacket)
fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket
@ -113,6 +115,16 @@ class GossipSyncManager(
val id = idBytes.joinToString("") { b -> "%02x".format(b) }
if (isBroadcastMessage) {
// A message from a sender the live registry no longer holds is never archived: the
// LEAVE and stale purges remove that sender's announcement and messages together, and
// a message accepted on a persisted key alone must not re-enter the archive behind
// that purge, or it would be re-served with nothing left to prune it. Present peers
// and this device's own broadcasts are archived exactly as before.
val sender = packet.senderID.joinToString("") { b -> "%02x".format(b) }
if (sender != myPeerID && delegate?.hasLivePeer(sender) == false) {
Log.d(TAG, "Not archiving message from ${sender.take(8)}: sender not in the live registry")
return
}
synchronized(messages) {
messages[id] = packet
// Enforce capacity (remove oldest when exceeded)

View File

@ -13,6 +13,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@ -22,7 +23,12 @@ class AuthenticatedPeerStateCoordinatorTest {
val states = mutableMapOf<String, AuthenticatedPeerState>()
val pins = mutableSetOf<String>()
val nicknames = mutableMapOf<String, String>()
override fun load(fingerprint: String): AuthenticatedPeerState? = states[fingerprint]
override fun persistedFingerprintFor(peerID: String): String? =
states.keys.firstOrNull { it.startsWith(peerID.lowercase()) }
override fun persistedNickname(fingerprint: String): String? = nicknames[fingerprint]
override fun persist(
fingerprint: String,
state: AuthenticatedPeerState,
@ -296,4 +302,64 @@ class AuthenticatedPeerStateCoordinatorTest {
private fun fingerprint(key: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(key).joinToString("") { "%02x".format(it) }
@Test
fun `a departed peer's signing key and nickname resolve by peer ID through the fingerprint prefix`() {
val store = MemoryStore()
val fingerprint = sha256Hex(remoteStatic)
store.states[fingerprint] = remoteState
store.nicknames[fingerprint] = "relay-a"
val coordinator = coordinatorOver(store)
assertArrayEquals(remoteState.signingPublicKey, coordinator.persistedSigningKeyFor(peerID))
assertEquals("relay-a", coordinator.persistedNicknameFor(peerID))
assertNull(coordinator.persistedSigningKeyFor("0000000000000000"))
assertNull(coordinator.persistedNicknameFor("0000000000000000"))
}
@Test
fun `a persisted key with no cached nickname names the peer by its ID`() {
val store = MemoryStore()
store.states[sha256Hex(remoteStatic)] = remoteState
val coordinator = coordinatorOver(store)
assertEquals(peerID, coordinator.persistedNicknameFor(peerID))
}
@Test
fun `a cached nickname without a persisted signing key names nobody`() {
val store = MemoryStore()
store.nicknames[sha256Hex(remoteStatic)] = "relay-b"
val coordinator = coordinatorOver(store)
assertNull(coordinator.persistedSigningKeyFor(peerID))
assertNull("a nickname alone must not vouch for a sender", coordinator.persistedNicknameFor(peerID))
}
@Test
fun `an accepted proof is resolvable by peer ID afterwards`() {
val store = MemoryStore()
val coordinator = coordinatorOver(store)
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
assertTrue(coordinator.receive(peerID, remoteState, firstSession))
assertArrayEquals(remoteState.signingPublicKey, coordinator.persistedSigningKeyFor(peerID))
}
private fun coordinatorOver(store: MemoryStore): AuthenticatedPeerStateCoordinator =
AuthenticatedPeerStateCoordinator(
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
authenticatedSessionProvider = { firstSession },
withAuthenticatedSession = { _, _, action -> action() },
store = store,
localStateProvider = { localState },
applyAuthenticatedState = { _, _, _ -> },
sendState = { _, _, _ -> true },
onResolution = {},
proofTimeoutMs = 5_000
)
private fun sha256Hex(bytes: ByteArray): String =
java.security.MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) }
}

View File

@ -21,6 +21,9 @@ import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Assert.assertEquals
import org.mockito.kotlin.argumentCaptor
import com.bitchat.android.model.BitchatMessage
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
@ -483,4 +486,135 @@ class MessageHandlerTest {
isVerifiedNickname = true,
lastSeen = System.currentTimeMillis()
)
@Test
fun `a broadcast from a departed peer with a persisted identity is delivered under its persisted nickname`() = runBlocking {
whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST)
whenever(delegate.getPeerInfo(peerID)).thenReturn(null)
whenever(delegate.getPeerNickname(peerID)).thenReturn(null)
whenever(delegate.getPersistedPeerNickname(peerID)).thenReturn("relay-a")
val packet = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = peerID.hexToBytes(),
recipientID = null,
timestamp = System.currentTimeMillis().toULong(),
payload = "hello from earlier".toByteArray(),
signature = ByteArray(64) { 1 },
ttl = 0u
)
handler.handleMessage(RoutedPacket(packet, peerID, "direct-link"))
val captor = argumentCaptor<BitchatMessage>()
verify(delegate).onMessageReceived(captor.capture())
assertEquals("relay-a", captor.firstValue.sender)
assertEquals(peerID, captor.firstValue.senderPeerID)
}
@Test
fun `a broadcast from an unknown peer with no persisted identity is still dropped`() = runBlocking {
whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST)
whenever(delegate.getPeerInfo(peerID)).thenReturn(null)
whenever(delegate.getPersistedPeerNickname(peerID)).thenReturn(null)
val packet = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = peerID.hexToBytes(),
recipientID = null,
timestamp = System.currentTimeMillis().toULong(),
payload = "hello".toByteArray(),
signature = ByteArray(64) { 1 },
ttl = 0u
)
handler.handleMessage(RoutedPacket(packet, peerID, "direct-link"))
verify(delegate, never()).onMessageReceived(any())
}
@Test
fun `a broadcast file from a departed peer with a persisted identity is delivered under its persisted nickname`() = runBlocking {
whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST)
whenever(delegate.getPeerInfo(peerID)).thenReturn(null)
whenever(delegate.getPeerNickname(peerID)).thenReturn(null)
whenever(delegate.getPersistedPeerNickname(peerID)).thenReturn("relay-a")
val file = BitchatFilePacket(
fileName = "earlier.jpg",
fileSize = 3,
mimeType = "image/jpeg",
content = byteArrayOf(1, 2, 3)
)
val packet = BitchatPacket(
version = 2u,
type = MessageType.FILE_TRANSFER.value,
senderID = peerID.hexToBytes(),
recipientID = null,
timestamp = System.currentTimeMillis().toULong(),
payload = file.encode()!!,
signature = ByteArray(64) { 1 },
ttl = 0u
)
handler.handleMessage(RoutedPacket(packet, peerID, "direct-link"))
val captor = argumentCaptor<BitchatMessage>()
verify(delegate).onMessageReceived(captor.capture())
assertEquals("relay-a", captor.firstValue.sender)
}
@Test
fun `a broadcast from a live peer whose nickname is unverified is still dropped despite a persisted nickname`() = runBlocking {
whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST)
whenever(delegate.getPeerInfo(peerID)).thenReturn(
PeerInfo(
id = peerID, nickname = peerID, isConnected = true, isDirectConnection = true,
noisePublicKey = noiseKey, signingPublicKey = signingKey, isVerifiedNickname = false,
lastSeen = System.currentTimeMillis()
)
)
whenever(delegate.getPersistedPeerNickname(peerID)).thenReturn("relay-a")
val packet = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = peerID.hexToBytes(),
recipientID = null,
timestamp = System.currentTimeMillis().toULong(),
payload = "hello".toByteArray(),
signature = ByteArray(64) { 1 },
ttl = 0u
)
handler.handleMessage(RoutedPacket(packet, peerID, "direct-link"))
verify(delegate, never()).onMessageReceived(any())
}
@Test
fun `a re-served announce past the skew window restores no key but the message still lands on a persisted identity`() = runBlocking {
whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST)
whenever(delegate.getPeerInfo(peerID)).thenReturn(null)
whenever(delegate.getPeerNickname(peerID)).thenReturn(null)
whenever(delegate.getPersistedPeerNickname(peerID)).thenReturn("relay-a")
val staleAnnounce = announcePacket(ageMs = announceClockSkewToleranceMs + 60_000)
assertFalse(handler.handleAnnounce(RoutedPacket(staleAnnounce, peerID, "direct-link")))
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(any(), any(), any(), any(), any(), anyOrNull())
val message = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = peerID.hexToBytes(),
recipientID = null,
timestamp = (System.currentTimeMillis() - announceClockSkewToleranceMs - 60_000).toULong(),
payload = "from before they left".toByteArray(),
signature = ByteArray(64) { 1 },
ttl = 0u
)
handler.handleMessage(RoutedPacket(message, peerID, "direct-link"))
val captor = argumentCaptor<BitchatMessage>()
verify(delegate).onMessageReceived(captor.capture())
assertEquals("relay-a", captor.firstValue.sender)
}
}

View File

@ -0,0 +1,55 @@
package com.bitchat.android.mesh
import android.content.Context
import com.bitchat.android.identity.SecureIdentityStateManager
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.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.util.UUID
/**
* Pins the persisted lookups the message path relies on for senders the live
* registry has forgotten: the store must resolve a peer ID to its persisted
* fingerprint, and a fingerprint to its persisted nickname, through the same
* records the app already keeps.
*/
@RunWith(RobolectricTestRunner::class)
class SecureAuthenticatedPeerStateStoreTest {
private val fingerprint = "ab".repeat(32)
private val peerID = fingerprint.take(16)
private val signingKey = ByteArray(32) { 0x5A }
private fun freshStore(): Pair<SecureAuthenticatedPeerStateStore, SecureIdentityStateManager> {
val context = RuntimeEnvironment.getApplication()
val prefs = context.getSharedPreferences("store-test-${UUID.randomUUID()}", Context.MODE_PRIVATE)
val identity = SecureIdentityStateManager(prefs, testOnly = true)
return SecureAuthenticatedPeerStateStore(context, identity) to identity
}
@Test
fun `a departed peer resolves by peer ID to its persisted state and nickname`() {
val (store, identity) = freshStore()
identity.cacheFingerprintNickname(fingerprint, "relay-a")
identity.storeAuthenticatedPeerState(fingerprint, AuthenticatedPeerState(PeerCapabilities.NONE, signingKey))
assertEquals(fingerprint, store.persistedFingerprintFor(peerID))
assertEquals("relay-a", store.persistedNickname(fingerprint))
assertArrayEquals(signingKey, store.load(fingerprint)?.signingPublicKey)
}
@Test
fun `a peer this device never persisted resolves to nothing`() {
val (store, identity) = freshStore()
identity.cacheFingerprintNickname(fingerprint, "relay-a")
assertNull("a cached nickname is not a persisted identity", store.persistedFingerprintFor(peerID))
assertNull(store.load(fingerprint))
}
}

View File

@ -11,6 +11,7 @@ import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
@ -202,6 +203,65 @@ class SecurityManagerTest {
assertFalse("Packet from unknown peer should be rejected (cannot verify signature)", result)
}
@Test
fun `validatePacket accepts a signed MESSAGE from a departed peer whose identity is persisted`() {
whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null)
whenever(mockDelegate.getPersistedSigningKey(unknownPeerID)).thenReturn(otherSigningKey)
val packet = BitchatPacket(
type = MessageType.MESSAGE.value,
ttl = 0u,
senderID = unknownPeerID,
payload = dummyPayload
)
packet.signature = validSignature
val result = securityManager.validatePacket(packet, unknownPeerID)
assertTrue("a persisted signing key must verify a sender no longer in the live registry", result)
assertArrayEquals(otherSigningKey, fakeEncryptionService.lastVerifyKey)
}
@Test
fun `validatePacket prefers the live registry key over a persisted one`() {
val liveKey = ByteArray(32) { 0x1C }
whenever(mockDelegate.getPeerInfo(otherPeerID)).thenReturn(
PeerInfo(
id = otherPeerID, nickname = "live", isConnected = true, isDirectConnection = true,
noisePublicKey = otherNoiseKey, signingPublicKey = liveKey, isVerifiedNickname = true,
lastSeen = System.currentTimeMillis()
)
)
whenever(mockDelegate.getPersistedSigningKey(otherPeerID)).thenReturn(otherSigningKey)
val packet = BitchatPacket(
type = MessageType.MESSAGE.value,
ttl = 0u,
senderID = otherPeerID,
payload = dummyPayload
)
packet.signature = validSignature
assertTrue(securityManager.validatePacket(packet, otherPeerID))
assertArrayEquals("a present peer is verified against its live key, never the persisted one", liveKey, fakeEncryptionService.lastVerifyKey)
}
@Test
fun `validatePacket still rejects a MESSAGE with neither a live nor a persisted key`() {
whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null)
whenever(mockDelegate.getPersistedSigningKey(unknownPeerID)).thenReturn(null)
val packet = BitchatPacket(
type = MessageType.MESSAGE.value,
ttl = 0u,
senderID = unknownPeerID,
payload = dummyPayload
)
packet.signature = validSignature
assertFalse("an unknown sender with nothing persisted stays rejected", securityManager.validatePacket(packet, unknownPeerID))
}
@Test
fun `validatePacket - accepts packet with valid signature from known peer`() {
setupKnownPeer(otherPeerID, otherSigningKey)

View File

@ -0,0 +1,119 @@
package com.bitchat.android.sync
import com.bitchat.android.model.RequestSyncPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* The LEAVE and stale purges remove a sender's announcement and messages together. A
* message accepted on a persisted key alone, from a sender the live registry no longer
* holds, must not re-enter the archive behind that purge: nothing would prune it and every
* requester would be served it again. Present peers and this device's own broadcasts are
* archived exactly as before.
*/
class GossipSyncArchiveGuardTest {
private val sent = mutableListOf<Pair<String, BitchatPacket>>()
private lateinit var scope: CoroutineScope
private lateinit var manager: GossipSyncManager
private val requester = "aabbccddeeff0011"
private val sender = ByteArray(8) { 0x11 }
private val senderID = sender.joinToString("") { "%02x".format(it) }
private var livePeers = setOf<String>()
private val delegate = object : GossipSyncManager.Delegate {
override fun hasLivePeer(peerID: String): Boolean = peerID in livePeers
override fun sendPacket(packet: BitchatPacket) = Unit
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
sent += peerID to packet
}
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket = packet
}
private val config = object : GossipSyncManager.ConfigProvider {
override fun seenCapacity(): Int = 100
override fun gcsMaxBytes(): Int = 400
override fun gcsTargetFpr(): Double = 0.01
}
@Before
fun setUp() {
sent.clear()
livePeers = emptySet()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
manager = GossipSyncManager(myPeerID = "1122334455667788", scope = scope, configProvider = config)
manager.delegate = delegate
}
@After
fun tearDown() {
scope.cancel()
}
private fun broadcastMessage(ageMillis: Long): BitchatPacket = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = sender,
recipientID = SpecialRecipients.BROADCAST,
timestamp = (System.currentTimeMillis() - ageMillis).toULong(),
payload = "from before they left".toByteArray(),
signature = ByteArray(64) { 0x22 },
ttl = 0u
)
private fun announce(ageMillis: Long): BitchatPacket = BitchatPacket(
version = 1u,
type = MessageType.ANNOUNCE.value,
senderID = sender,
recipientID = null,
timestamp = (System.currentTimeMillis() - ageMillis).toULong(),
payload = "nickname".toByteArray(),
signature = ByteArray(64) { 0x44 },
ttl = 7u
)
/** A filter the requester builds when it holds nothing: everything we have is missing. */
private fun requestForNothingHeld() = RequestSyncPacket(p = 7, m = 1, data = ByteArray(0))
@Test
fun `a message from a sender the live registry has forgotten is not archived or served`() {
manager.onPublicPacketSeen(broadcastMessage(ageMillis = 60_000L))
manager.handleRequestSync(requester, requestForNothingHeld())
assertTrue("nothing must be served for a sender the registry has forgotten", sent.isEmpty())
}
@Test
fun `a message from a present peer is archived and served even with no announcement stored`() {
livePeers = setOf(senderID)
manager.onPublicPacketSeen(broadcastMessage(ageMillis = 60_000L))
manager.handleRequestSync(requester, requestForNothingHeld())
assertEquals("a present peer's message is archived as before", 1, sent.size)
assertEquals(MessageType.MESSAGE.value, sent.single().second.type)
}
@Test
fun `this device's own broadcasts are archived regardless of the registry`() {
val ownSender = "1122334455667788".chunked(2).map { it.toInt(16).toByte() }.toByteArray()
manager.onPublicPacketSeen(broadcastMessage(ageMillis = 1_000L).copy(senderID = ownSender))
manager.handleRequestSync(requester, requestForNothingHeld())
assertEquals("own broadcasts must keep being served", 1, sent.size)
}
}