Merge 605958b405047949d598384c8c6006316ab90840 into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
heyaim 2026-09-14 12:20:36 -05:00 committed by GitHub
commit 964cf14e97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 709 additions and 12 deletions

View File

@ -390,6 +390,30 @@ 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. Each record is
* matched on its fingerprint field, 64 hex characters starting with the peer ID; a record
* whose field does not parse is skipped, and the matched record must still load.
*/
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 = fingerprintFieldFor(prefix, records) ?: return null
return fingerprint.takeIf { getAuthenticatedPeerState(it) != null }
}
/**
* The fingerprint field of the first record, in the order given, that parses to 64 hex
* characters starting with [prefix]. A record whose field does not parse is skipped, not
* taken for a match on the raw string.
*/
internal fun fingerprintFieldFor(prefix: String, records: Iterable<String>): String? =
records.asSequence()
.map { it.substringBefore(':').lowercase() }
.firstOrNull { isValidFingerprint(it) && it.startsWith(prefix) }
// 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,6 +171,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
)
com.bitchat.android.service.MeshServiceHolder.registerLivenessProbe("BLE") { peerID ->
peerManager.getPeerInfo(peerID) != null
}
com.bitchat.android.service.MeshServiceHolder.setGossipManager(gossipSyncManager) { packet ->
signPacketBeforeBroadcast(packet)
}
@ -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

@ -303,13 +303,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
}
@ -470,4 +473,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

@ -6,6 +6,7 @@ import com.bitchat.android.mesh.UnifiedMeshService
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.sync.GossipSyncManager
import java.util.concurrent.ConcurrentHashMap
/**
* Process-wide holder to share a single BluetoothMeshService instance
@ -19,6 +20,14 @@ object MeshServiceHolder {
private val activeGossipOwners = mutableSetOf<String>()
// One liveness probe per transport, keyed like the gossip owners above. The shared gossip
// manager archives a broadcast only when its sender is in a live peer registry, and every
// transport keeps its own registry: Bluetooth registers its lookup, Wi-Fi Aware registers
// its own while it runs. A sender known to any transport is live. The Bluetooth service
// registers before any delegate exists, so the empty map is never consulted in practice;
// if it were, the answer is true, which archives as the manager did before the guard.
private val livenessProbes = ConcurrentHashMap<String, (String) -> Boolean>()
@Synchronized
fun setGossipManager(
mgr: GossipSyncManager,
@ -35,6 +44,22 @@ object MeshServiceHolder {
}
}
fun registerLivenessProbe(owner: String, probe: (String) -> Boolean) {
livenessProbes[owner] = probe
}
fun unregisterLivenessProbe(owner: String) {
livenessProbes.remove(owner)
}
/** True when any transport's live peer registry holds this peer. */
fun hasLivePeer(peerID: String): Boolean {
if (livenessProbes.isEmpty()) return true
return livenessProbes.values.any { probe ->
try { probe(peerID) } catch (_: Exception) { false }
}
}
@Synchronized
fun startSharedGossip(owner: String) {
val wasIdle = activeGossipOwners.isEmpty()
@ -55,6 +80,8 @@ object MeshServiceHolder {
private class TransportGossipDelegate(
private val signer: (BitchatPacket) -> BitchatPacket
) : GossipSyncManager.Delegate {
override fun hasLivePeer(peerID: String): Boolean = MeshServiceHolder.hasLivePeer(peerID)
override fun sendPacket(packet: BitchatPacket) {
TransportBridgeService.broadcastFromLocal(RoutedPacket(packet))
}
@ -140,6 +167,7 @@ object MeshServiceHolder {
try { sharedGossipSyncManager?.stop() } catch (_: Exception) { }
sharedGossipSyncManager = null
activeGossipOwners.clear()
livenessProbes.clear()
meshService = null
unifiedMeshService = null
}

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

@ -515,6 +515,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
TransportBridgeService.register("WIFI", this)
meshCore.startCore()
com.bitchat.android.service.MeshServiceHolder.registerLivenessProbe("WIFI") { peerID ->
meshCore.getPeerInfo(peerID) != null
}
com.bitchat.android.service.MeshServiceHolder.startSharedGossip("WIFI")
startPeriodicConnectionMaintenance()
connectionTracker.start()
@ -532,6 +535,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
// Unregister from bridge
TransportBridgeService.unregister("WIFI")
com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("WIFI")
com.bitchat.android.service.MeshServiceHolder.unregisterLivenessProbe("WIFI")
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { }
try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("WIFI") } catch (_: Exception) { }
@ -578,6 +582,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
isActive = false
TransportBridgeService.unregister("WIFI")
com.bitchat.android.service.MeshServiceHolder.stopSharedGossip("WIFI")
com.bitchat.android.service.MeshServiceHolder.unregisterLivenessProbe("WIFI")
try { com.bitchat.android.services.AppStateStore.clearTransportPeers("WIFI") } catch (_: Exception) { }
try { com.bitchat.android.services.AppStateStore.clearTransportDirectPeers("WIFI") } catch (_: Exception) { }
val oldPublishSession = publishSession

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,66 @@
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))
}
@Test
fun `a record whose fingerprint field is not 64 hex is ignored`() {
val (_, identity) = freshStore()
// The raw string starts with the peer ID but the fingerprint field is only 16 characters.
// Examined first, it must be skipped and the real record still found.
val malformed = "$peerID:0:${"00".repeat(32)}"
val real = "$fingerprint:0:${"5a".repeat(32)}"
assertEquals(fingerprint, identity.fingerprintFieldFor(peerID, listOf(malformed, real)))
}
}

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,115 @@
package com.bitchat.android.service
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 com.bitchat.android.sync.GossipSyncManager
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.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* The shared gossip manager archives a broadcast only when its sender is in a live peer
* registry, and every transport keeps its own registry. A sender known only to the Wi-Fi
* Aware registry must count as live, or its broadcasts would never be archived for sync.
*/
class MeshServiceHolderLivenessTest {
private lateinit var scope: CoroutineScope
private lateinit var manager: GossipSyncManager
private val blePeer = "aaaaaaaaaaaaaaaa"
private val wifiSender = ByteArray(8) { 0x22 }
private val wifiPeer = wifiSender.joinToString("") { "%02x".format(it) }
private val stranger = "cccccccccccccccc"
private val requester = "aabbccddeeff0011"
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() {
scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
manager = GossipSyncManager(myPeerID = "1122334455667788", scope = scope, configProvider = config)
MeshServiceHolder.unregisterLivenessProbe("WIFI")
MeshServiceHolder.registerLivenessProbe("BLE") { it == blePeer }
MeshServiceHolder.setGossipManager(manager) { it }
}
@After
fun tearDown() {
MeshServiceHolder.unregisterLivenessProbe("WIFI")
MeshServiceHolder.unregisterLivenessProbe("BLE")
scope.cancel()
}
private fun broadcastFromWifiSender(): BitchatPacket = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = wifiSender,
recipientID = SpecialRecipients.BROADCAST,
timestamp = (System.currentTimeMillis() - 1000L).toULong(),
payload = "over wifi".toByteArray(),
signature = ByteArray(64) { 0x33 },
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 peer known only to the Wi-Fi Aware registry is live once its probe is registered`() {
val delegate = manager.delegate!!
assertFalse("before the Wi-Fi probe exists only Bluetooth answers", delegate.hasLivePeer(wifiPeer))
MeshServiceHolder.registerLivenessProbe("WIFI") { it == wifiPeer }
assertTrue(delegate.hasLivePeer(wifiPeer))
assertTrue("the Bluetooth registry still counts", delegate.hasLivePeer(blePeer))
assertFalse("a peer in no registry is absent", delegate.hasLivePeer(stranger))
}
@Test
fun `removing a transport's probe makes its peers absent again`() {
val delegate = manager.delegate!!
MeshServiceHolder.registerLivenessProbe("WIFI") { it == wifiPeer }
assertTrue(delegate.hasLivePeer(wifiPeer))
MeshServiceHolder.unregisterLivenessProbe("WIFI")
assertFalse(delegate.hasLivePeer(wifiPeer))
assertTrue(delegate.hasLivePeer(blePeer))
}
@Test
fun `a broadcast from a Wi-Fi-only sender is archived and served`() {
MeshServiceHolder.registerLivenessProbe("WIFI") { it == wifiPeer }
val original = broadcastFromWifiSender()
manager.onPublicPacketSeen(original)
val sent = mutableListOf<BitchatPacket>()
manager.delegate = object : GossipSyncManager.Delegate {
override fun sendPacket(packet: BitchatPacket) = Unit
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket) {
sent += packet
}
override fun signPacketForBroadcast(packet: BitchatPacket): BitchatPacket = packet
}
manager.handleRequestSync(requester, requestForNothingHeld())
assertEquals("the Wi-Fi-only sender's message must be served", 1, sent.size)
assertTrue(sent[0].payload.contentEquals(original.payload))
}
}

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)
}
}