mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
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:
parent
81ba775063
commit
b9bf303d9a
@ -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.
|
||||
|
||||
@ -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,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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,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))
|
||||
}
|
||||
}
|
||||
@ -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,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