mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Merge 605958b405047949d598384c8c6006316ab90840 into c127eb83ab94c069c32d37530d2faecd381cd2a8
This commit is contained in:
commit
964cf14e97
@ -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.
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user