Merge remote-tracking branch 'origin/main' into codex/nostr-double-ratchet

# Conflicts:
#	app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt
#	app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt
This commit is contained in:
Dev 2026-07-27 20:13:49 +03:00
commit 0d51dffc61
23 changed files with 680 additions and 506 deletions

View File

@ -1,14 +0,0 @@
package com.bitchat.android.mesh
/**
* Ensures a Noise completion promotes only the BLE connection whose ANNOUNCE started that
* authentication attempt.
*/
internal object AuthenticatedBleLinkPolicy {
data class Claim(val deviceAddress: String, val linkID: String)
fun matches(claim: Claim?, authenticatedAddress: String?, authenticatedLinkID: String?): Boolean =
claim != null &&
claim.deviceAddress == authenticatedAddress &&
claim.linkID == authenticatedLinkID
}

View File

@ -92,8 +92,8 @@ class BluetoothConnectionManager(
// Public property for address-peer mapping
val addressPeerMap get() = connectionTracker.addressPeerMap
fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
connectionTracker.bindPeerIfCurrent(deviceAddress, linkID, peerID)
fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
connectionTracker.observePeerIfCurrent(deviceAddress, linkID, peerID)
fun getCurrentLinkID(deviceAddress: String): String? =
connectionTracker.getCurrentLinkID(deviceAddress)

View File

@ -32,7 +32,7 @@ class BluetoothConnectionTracker(
private val firstAnnounceSeen = ConcurrentHashMap<String, Boolean>()
// RSSI tracking from scan results (for devices we discover but may connect as servers)
private val scanRSSI = ConcurrentHashMap<String, Int>()
private val peerBindingLock = Any()
private val connectionStateLock = Any()
/**
* Consolidated device connection information
@ -77,9 +77,9 @@ class BluetoothConnectionTracker(
*/
fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
synchronized(peerBindingLock) {
synchronized(connectionStateLock) {
connectedDevices[deviceAddress] = deviceConn
// A mapping authenticates a GATT connection, not a reusable Bluetooth address.
// A route observation belongs to this GATT generation, not its reusable address.
addressPeerMap.remove(deviceAddress)
}
removePendingConnection(deviceAddress)
@ -91,7 +91,7 @@ class BluetoothConnectionTracker(
* Update a device connection
*/
fun updateDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
synchronized(peerBindingLock) {
synchronized(connectionStateLock) {
connectedDevices[deviceAddress] = deviceConn
}
}
@ -100,7 +100,7 @@ class BluetoothConnectionTracker(
deviceAddress: String,
linkID: String,
update: (DeviceConnection) -> DeviceConnection
): Boolean = synchronized(peerBindingLock) {
): Boolean = synchronized(connectionStateLock) {
val current = connectedDevices[deviceAddress] ?: return@synchronized false
if (current.linkID != linkID) return@synchronized false
connectedDevices[deviceAddress] = update(current)
@ -117,10 +117,16 @@ class BluetoothConnectionTracker(
fun getCurrentLinkID(deviceAddress: String): String? =
connectedDevices[deviceAddress]?.linkID
fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
synchronized(peerBindingLock) {
/**
* Records that the current link delivered a validated, non-relayed ANNOUNCE for [peerID].
*
* A peer may be reachable over more than one link, so observing one link must not discard the
* other observations. The link generation check prevents a late packet from an old GATT
* connection from being applied to a replacement connection that reused the same address.
*/
fun observePeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
synchronized(connectionStateLock) {
if (connectedDevices[deviceAddress]?.linkID != linkID) return@synchronized false
addressPeerMap.entries.removeIf { it.value == peerID && it.key != deviceAddress }
addressPeerMap[deviceAddress] = peerID
true
}
@ -265,7 +271,7 @@ class BluetoothConnectionTracker(
* Clean up a specific device connection
*/
fun cleanupDeviceConnection(deviceAddress: String) {
synchronized(peerBindingLock) {
synchronized(connectionStateLock) {
connectedDevices.remove(deviceAddress)
subscribedDevices.removeAll { it.address == deviceAddress }
addressPeerMap.remove(deviceAddress)
@ -277,7 +283,7 @@ class BluetoothConnectionTracker(
fun cleanupDeviceConnectionIfCurrent(
deviceAddress: String,
expectedLinkID: String
): Boolean = synchronized(peerBindingLock) {
): Boolean = synchronized(connectionStateLock) {
val current = connectedDevices[deviceAddress] ?: return@synchronized false
if (current.linkID != expectedLinkID) {
return@synchronized false

View File

@ -132,8 +132,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
private var announceJob: Job? = null
// Tracks whether this instance has been terminated via stopServices()
private var terminated = false
private val provisionalBleClaims =
ConcurrentHashMap<String, AuthenticatedBleLinkPolicy.Claim>()
init {
Log.i(TAG, "Initializing BluetoothMeshService for peer=$myPeerID")
@ -256,7 +254,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
delegate?.didUpdatePeerList(peerIDs)
}
override fun onPeerRemoved(peerID: String) {
provisionalBleClaims.remove(peerID)
authenticatedPeerState.clear(peerID)
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
// Remove from mesh graph topology to prevent routing through stale peers
@ -285,22 +282,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
authenticatedRemoteStaticKey,
authenticatedSessionToken
)
val expectedClaim = provisionalBleClaims.remove(peerID)
if (AuthenticatedBleLinkPolicy.matches(expectedClaim, directRelayAddress, ingressLinkID)) {
val authenticatedClaim = checkNotNull(expectedClaim)
if (connectionManager.bindPeerIfCurrent(
authenticatedClaim.deviceAddress,
authenticatedClaim.linkID,
peerID
)
) {
Log.i(TAG, "Authenticated BLE link $directRelayAddress as $peerID")
try { peerManager.refreshPeerList() } catch (_: Exception) { }
try { gossipSyncManager.scheduleInitialSyncToPeer(peerID, 1_000) } catch (_: Exception) { }
} else {
Log.w(TAG, "Ignoring Noise completion for stale BLE link $directRelayAddress")
}
}
// Send announcement and cached messages after key exchange
serviceScope.launch {
delay(100)
@ -603,40 +584,23 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
val result = messageHandler.handleAnnounceWithResult(routed)
if (result !is AnnounceHandlingResult.Accepted) return false
val deviceAddress = routed.relayAddress
val pid = routed.peerID
val linkID = routed.ingressLinkID
val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
val alreadyAuthenticated = deviceAddress != null &&
pid != null &&
connectionManager.addressPeerMap[deviceAddress] == pid
if (deviceAddress != null && linkID != null && pid != null && isDirect && !alreadyAuthenticated) {
try {
val claim = AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID)
registerProvisionalBleClaim(pid, claim)
val handshakeData = encryptionService.initiateHandshake(pid, replaceEstablished = true)
if (handshakeData != null) {
val handshake = signPacketBeforeBroadcast(
BitchatPacket(
version = 1u,
type = MessageType.NOISE_HANDSHAKE.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(pid),
timestamp = System.currentTimeMillis().toULong(),
payload = handshakeData,
ttl = MAX_TTL
)
)
if (!connectionManager.sendPacketToLink(deviceAddress, linkID, handshake)) {
provisionalBleClaims.remove(pid, claim)
Log.w(TAG, "Could not send Noise handshake on BLE link $deviceAddress")
}
} else {
provisionalBleClaims.remove(pid, claim)
}
} catch (e: Exception) {
provisionalBleClaims.remove(pid, AuthenticatedBleLinkPolicy.Claim(deviceAddress, linkID))
Log.w(TAG, "Could not authenticate provisional BLE claim for $pid: ${e.message}")
DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)?.let { observation ->
if (connectionManager.observePeerIfCurrent(
observation.relayAddress,
observation.ingressLinkID,
observation.peerID
)
) {
Log.d(
TAG,
"Observed direct BLE route ${observation.relayAddress} to ${observation.peerID}"
)
try { peerManager.refreshPeerList() } catch (_: Exception) { }
try {
gossipSyncManager.scheduleInitialSyncToPeer(observation.peerID, 1_000)
} catch (_: Exception) { }
} else {
Log.d(TAG, "Ignoring ANNOUNCE from stale BLE link ${observation.relayAddress}")
}
}
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
@ -741,13 +705,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
linkID: String?
) {
Log.i(TAG, "Device disconnected: ${device.address}")
val addr = device.address
clearProvisionalBleClaimsForLink(addr, linkID)
// refresh peer list on disconnect.
try { peerManager.refreshPeerList() } catch (_: Exception) { }
// ConnectionTracker already removes an authenticated mapping only when this exact
// ConnectionTracker already removes an observed mapping only when this exact
// link is still current. Do not remove by reusable address here: this may be a late
// disconnect callback from a replaced GATT connection.
}
@ -761,24 +723,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
}
private fun registerProvisionalBleClaim(
peerID: String,
claim: AuthenticatedBleLinkPolicy.Claim
) {
provisionalBleClaims[peerID] = claim
serviceScope.launch {
delay(BLE_AUTHENTICATION_TIMEOUT_MS)
provisionalBleClaims.remove(peerID, claim)
}
}
private fun clearProvisionalBleClaimsForLink(deviceAddress: String, linkID: String?) {
if (linkID == null) return
provisionalBleClaims.entries.removeIf { (_, claim) ->
claim.deviceAddress == deviceAddress && claim.linkID == linkID
}
}
/**
* Start the mesh service
*/
@ -1054,7 +998,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
*/
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
if (content.isEmpty() || recipientPeerID.isEmpty()) return
if (recipientNickname.isEmpty()) return
// Nicknames are presentation metadata. Routing and encryption are bound to the peer ID,
// so a temporarily unresolved nickname must never suppress a private message.
serviceScope.launch {
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()

View File

@ -0,0 +1,26 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
/**
* Describes transport reachability learned from an already-validated ANNOUNCE.
*
* This is deliberately only a routing observation. Noise authenticates the peer independently and
* must not be restarted merely to associate the current transport link with that peer.
*/
internal object DirectLinkAnnouncementPolicy {
data class Observation(
val peerID: String,
val relayAddress: String,
val ingressLinkID: String
)
fun observationFor(routed: RoutedPacket, maxTtl: UByte): Observation? {
if (routed.packet.ttl != maxTtl) return null
return Observation(
peerID = routed.peerID ?: return null,
relayAddress = routed.relayAddress ?: return null,
ingressLinkID = routed.ingressLinkID ?: return null
)
}
}

View File

@ -46,7 +46,6 @@ class MeshCore(
data class Hooks(
val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
val onAnnounceProcessed: ((RoutedPacket, Boolean) -> Unit)? = null,
val onDirectNoiseAuthenticated: ((String, String, String, ByteArray) -> Unit)? = null,
val readReceiptInterceptor: ((String, String) -> Boolean)? = null,
val onReadReceiptSent: ((String) -> Unit)? = null,
val announcementNicknameProvider: (() -> String?)? = null,
@ -158,12 +157,14 @@ class MeshCore(
isActive = false
announceJob?.cancel()
announceJob = null
directPeers.clear()
if (ownsGossipManager) {
gossipSyncManager.stop()
}
}
fun shutdown() {
directPeers.clear()
peerManager.shutdown()
fragmentManager.shutdown()
securityManager.shutdown()
@ -217,6 +218,7 @@ class MeshCore(
}
override fun onPeerRemoved(peerID: String) {
directPeers.remove(peerID)
authenticatedPeerState.clear(peerID)
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
try { encryptionService.removePeer(peerID) } catch (_: Exception) { }
@ -237,14 +239,6 @@ class MeshCore(
authenticatedRemoteStaticKey,
authenticatedSessionToken
)
if (directRelayAddress != null && ingressLinkID != null) {
hooks.onDirectNoiseAuthenticated?.invoke(
peerID,
directRelayAddress,
ingressLinkID,
authenticatedRemoteStaticKey
)
}
scope.launch {
delay(100)
sendAnnouncementToPeer(peerID)
@ -990,6 +984,7 @@ class MeshCore(
}
fun removePeer(peerID: String) {
directPeers.remove(peerID)
peerManager.removePeer(peerID)
}
@ -1043,44 +1038,6 @@ class MeshCore(
}
}
/**
* Starts a fresh replacement handshake on one exact direct transport generation.
* This authenticates provisional transport claims without broadcasting the challenge or
* accidentally sending it through a socket that later reused the same alias.
*/
fun initiateNoiseHandshakeOnLink(
peerID: String,
relayAddress: String,
ingressLinkID: String
): Boolean {
return try {
val handshakeData = encryptionService.initiateHandshake(
peerID,
replaceEstablished = true
) ?: return false
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_HANDSHAKE.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = handshakeData,
ttl = maxTtl
)
transport.sendPacketToLink(
relayAddress,
ingressLinkID,
signPacketBeforeBroadcast(packet)
)
} catch (e: Exception) {
Log.e(
"MeshCore",
"Failed to initiate link-bound Noise handshake with $peerID: ${e.message}"
)
false
}
}
fun getPeerFingerprint(peerID: String): String? = peerManager.getFingerprintForPeer(peerID)
fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
@ -1161,6 +1118,7 @@ class MeshCore(
}
fun clearAllInternalData() {
directPeers.clear()
fragmentManager.clearAllFragments()
storeForwardManager.clearAllCache()
securityManager.clearAllData()

View File

@ -76,7 +76,7 @@ class SecurityManager(private val encryptionService: EncryptionService, private
if (processedMessages.contains(messageID)) {
// Check for ANNOUNCE exception: allow if it looks like a direct neighbor (max TTL)
// This ensures we catch the "first announce" on a new connection for binding,
// This ensures we observe the same peer on a new direct transport connection,
// while still dropping looped/relayed duplicates.
val isFreshAnnounce = messageType == MessageType.ANNOUNCE &&
packet.ttl >= com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS

View File

@ -34,11 +34,18 @@ class NostrDirectMessageHandler(
private val meshDelegateHandler: MeshDelegateHandler,
private val scope: CoroutineScope,
private val repo: GeohashRepository,
private val dataManager: com.bitchat.android.ui.DataManager
private val dataManager: com.bitchat.android.ui.DataManager,
private val seenStoreProvider: () -> SeenMessageStore = {
SeenMessageStore.getInstance(application)
},
private val legacyNostrInboundAllowed: (String) -> Boolean = { senderPubkey ->
FavoritesPersistenceService.shared
.isLegacyNostrInboundAllowed(senderPubkey)
}
) {
companion object { private const val TAG = "NostrDirectMessageHandler" }
private val seenStore by lazy { SeenMessageStore.getInstance(application) }
private val seenStore by lazy(seenStoreProvider)
private val ndrService by lazy { NdrNostrService.getInstance(application) }
private val ndrAccountEpochs = NdrAccountEpochGuard()
private val ndrReceiveJobLock = Any()
@ -126,8 +133,7 @@ class NostrDirectMessageHandler(
val (content, rawSenderPubkey, rumorTimestamp) = decryptResult
val senderPubkey = rawSenderPubkey.lowercase()
val legacyAllowed = runCatching {
FavoritesPersistenceService.shared
.isLegacyNostrInboundAllowed(senderPubkey)
legacyNostrInboundAllowed(senderPubkey)
}.getOrDefault(false)
if (!legacyAllowed) {
Log.w(TAG, "Rejecting legacy DM for an NDR-pinned contact")
@ -140,7 +146,7 @@ class NostrDirectMessageHandler(
processEmbeddedBitChatContent(
content = content,
senderPubkey = senderPubkey,
timestamp = Date(giftWrap.createdAt * 1000L),
timestamp = Date(rumorTimestamp * 1000L),
geohash = geohash,
recipientIdentity = identity
)

View File

@ -4,10 +4,12 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
@ -47,6 +49,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.layout.RowScope
import com.bitchat.android.R
import com.bitchat.android.core.ui.component.button.BitChatBrandButton
import com.bitchat.android.core.ui.component.button.CloseButton
import com.bitchat.android.net.ArtiTorManager
import com.bitchat.android.net.TorMode
import com.bitchat.android.ui.theme.BitchatMotion
@ -156,7 +159,8 @@ internal fun rememberTorConnectionVisual(normal: Color): TorConnectionVisual {
/**
* Soft, slow brightness pulse used while Tor is connecting. Keeps scale fixed so layout
* does not shift; only opacity / a faint halo breathe.
* does not shift; only opacity / a faint halo breathe. Glow strength itself cross-fades so
* starting/stopping progress never pops.
*/
@Composable
internal fun TorAwareHeaderIcon(
@ -166,7 +170,12 @@ internal fun TorAwareHeaderIcon(
contentDescription: String?,
modifier: Modifier = Modifier,
) {
val pulse = if (isProgress) {
val progressFade by animateFloatAsState(
targetValue = if (isProgress) 1f else 0f,
animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing),
label = "torGlowFade"
)
val pulse = if (progressFade > 0.01f) {
val transition = rememberInfiniteTransition(label = "torGlow")
transition.animateFloat(
initialValue = 0.42f,
@ -187,7 +196,7 @@ internal fun TorAwareHeaderIcon(
contentAlignment = Alignment.Center,
modifier = modifier.size(HeaderIconSize)
) {
if (isProgress) {
if (progressFade > 0.01f) {
val glowBrush = remember(tint) {
Brush.radialGradient(
colorStops = arrayOf(
@ -200,7 +209,7 @@ internal fun TorAwareHeaderIcon(
Box(
modifier = Modifier
.requiredSize(HeaderIconSize + 14.dp)
.graphicsLayer { alpha = pulse * 0.85f }
.graphicsLayer { alpha = pulse * 0.85f * progressFade }
.background(glowBrush)
)
}
@ -210,7 +219,9 @@ internal fun TorAwareHeaderIcon(
modifier = Modifier
.size(HeaderIconSize)
.graphicsLayer {
alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f
// Idle = solid; in-progress = breathing opacity, lerped by [progressFade].
val breathing = 0.55f + pulse * 0.45f
alpha = 1f - progressFade * (1f - breathing)
},
tint = tint
)
@ -226,7 +237,12 @@ internal fun TorAwareHeaderIcon(
contentDescription: String?,
modifier: Modifier = Modifier,
) {
val pulse = if (isProgress) {
val progressFade by animateFloatAsState(
targetValue = if (isProgress) 1f else 0f,
animationSpec = tween(BitchatMotion.EMPHASIZED_MS, easing = FastOutSlowInEasing),
label = "torPainterGlowFade"
)
val pulse = if (progressFade > 0.01f) {
val transition = rememberInfiniteTransition(label = "torPainterGlow")
transition.animateFloat(
initialValue = 0.42f,
@ -245,7 +261,7 @@ internal fun TorAwareHeaderIcon(
contentAlignment = Alignment.Center,
modifier = modifier.size(HeaderIconSize)
) {
if (isProgress) {
if (progressFade > 0.01f) {
val glowBrush = remember(tint) {
Brush.radialGradient(
colorStops = arrayOf(
@ -258,7 +274,7 @@ internal fun TorAwareHeaderIcon(
Box(
modifier = Modifier
.requiredSize(HeaderIconSize + 14.dp)
.graphicsLayer { alpha = pulse * 0.85f }
.graphicsLayer { alpha = pulse * 0.85f * progressFade }
.background(glowBrush)
)
}
@ -268,13 +284,22 @@ internal fun TorAwareHeaderIcon(
modifier = Modifier
.size(HeaderIconSize)
.graphicsLayer {
alpha = if (isProgress) 0.55f + pulse * 0.45f else 1f
val breathing = 0.55f + pulse * 0.45f
alpha = 1f - progressFade * (1f - breathing)
},
tint = tint
)
}
}
/**
* Noise session status for private-chat headers.
*
* Same visual language as the main header's Tor-aware globe: tint cross-fades between states,
* and a soft radial glow pulse while the handshake is in flight. The glyph itself is the open
* lock until a session is established (or fails), then the closed lock both share the same
* baseline so a [Crossfade] reads as the shackle settling shut rather than an icon swap.
*/
@Composable
fun NoiseSessionIcon(
sessionState: String?,
@ -282,40 +307,60 @@ fun NoiseSessionIcon(
) {
val palette = LocalBitchatPalette.current
val colorScheme = MaterialTheme.colorScheme
// The pre-redesign colours for the first two states were `0x87878700`, i.e. alpha 0x87 with
// an all-but-transparent RGB - the icons were effectively invisible. They now use the
// palette's secondary text colour.
val (iconRes, color, contentDescription) = when (sessionState) {
"uninitialized" -> Triple(
R.drawable.ic_spec_lock_open,
colorScheme.onSurfaceVariant,
stringResource(R.string.cd_ready_for_handshake)
)
"handshaking" -> Triple(
R.drawable.ic_spec_sync,
colorScheme.onSurfaceVariant,
val (targetTint, isProgress, contentDescription) = when {
sessionState == "handshaking" -> Triple(
palette.accentOrange,
true,
stringResource(R.string.cd_handshake_in_progress)
)
"established" -> Triple(
R.drawable.ic_spec_lock,
sessionState == "established" -> Triple(
colorScheme.primary,
false,
stringResource(R.string.cd_encrypted)
)
else -> { // "failed" or any other state
Triple(
R.drawable.ic_spec_warning,
colorScheme.error,
stringResource(R.string.cd_handshake_failed)
)
}
sessionState?.startsWith("failed") == true -> Triple(
colorScheme.error,
false,
stringResource(R.string.cd_handshake_failed)
)
else -> Triple(
// Not yet started — quiet grey open lock.
colorScheme.onSurfaceVariant,
false,
stringResource(R.string.cd_ready_for_handshake)
)
}
Icon(
painter = painterResource(iconRes),
contentDescription = contentDescription,
modifier = modifier,
tint = color
// Closed once the handshake resolves (success or failure); open while idle or in flight.
val lockIconRes = when {
sessionState == "established" || sessionState?.startsWith("failed") == true ->
R.drawable.ic_spec_lock
else -> R.drawable.ic_spec_lock_open
}
// Match the tint wash so open → closed and grey → orange → green land together.
val lockTransitionMs = 480
val animatedTint by animateColorAsState(
targetValue = targetTint,
animationSpec = tween(durationMillis = lockTransitionMs, easing = FastOutSlowInEasing),
label = "noiseSessionTint"
)
Crossfade(
targetState = lockIconRes,
animationSpec = tween(durationMillis = lockTransitionMs, easing = FastOutSlowInEasing),
modifier = modifier,
label = "noiseLockGlyph"
) { iconRes ->
TorAwareHeaderIcon(
painter = painterResource(iconRes),
tint = animatedTint,
isProgress = isProgress,
contentDescription = contentDescription,
)
}
}
/**
@ -620,17 +665,7 @@ private fun ChannelHeader(
title = "#$channel",
onTitleClick = onSidebarClick
) {
ConversationHeaderAction(
onClick = onBackClick,
contentDescription = stringResource(R.string.close_plain)
) {
Icon(
painter = painterResource(R.drawable.ic_spec_close),
contentDescription = stringResource(R.string.close_plain),
modifier = Modifier.size(HeaderIconSize),
tint = colorScheme.onSurfaceVariant
)
}
CloseButton(onClick = onBackClick)
}
}

View File

@ -752,8 +752,11 @@ class ChatViewModel(
}
private fun nicknameForPeer(peerID: String): String? {
return state.peerNicknames.value[peerID]
?: try { mesh.getPeerNicknames()[peerID] } catch (_: Exception) { null }
val contact = ContactDirectory.resolve(peerID)
val meshPeerID = contact.meshPeerID ?: peerID
return contact.displayName
?: state.peerNicknames.value[meshPeerID]
?: try { mesh.getPeerNicknames()[meshPeerID] } catch (_: Exception) { null }
}
private fun sessionStateForPeer(peerID: String): NoiseSession.NoiseSessionState {

View File

@ -1024,17 +1024,7 @@ fun PrivateChatSheet(
}
val dismiss = LocalSheetDismiss.current
ConversationHeaderAction(
onClick = { dismiss?.invoke() ?: onDismiss() },
contentDescription = stringResource(R.string.close_plain)
) {
Icon(
painter = painterResource(R.drawable.ic_spec_close),
contentDescription = null,
modifier = Modifier.size(HeaderIconSize),
tint = colorScheme.onSurfaceVariant
)
}
CloseButton(onClick = { dismiss?.invoke() ?: onDismiss() })
}
}
}

View File

@ -1,39 +0,0 @@
package com.bitchat.android.wifiaware
/**
* Resolves an authenticated callback to the exact still-active ingress link that completed Noise.
* A relay/discovery ID alone is not sufficient because a replacement socket may reuse it.
*/
internal object AuthenticatedIngressLinkPolicy {
data class Claim(
val relayAddress: String,
val linkID: String
)
data class Link<T : Any>(
val relayAddress: String,
val transport: T
)
fun matches(
expected: Claim?,
authenticatedRelayAddress: String?,
authenticatedLinkID: String?
): Boolean =
expected != null &&
expected.relayAddress == authenticatedRelayAddress &&
expected.linkID == authenticatedLinkID
fun <T : Any> resolve(
authenticatedLinkID: String?,
authenticatedRelayAddress: String?,
links: Map<String, Link<T>>,
currentTransportForRelay: (String) -> T?
): Link<T>? {
val linkID = authenticatedLinkID ?: return null
val relayAddress = authenticatedRelayAddress ?: return null
val link = links[linkID] ?: return null
if (link.relayAddress != relayAddress) return null
return link.takeIf { currentTransportForRelay(relayAddress) === it.transport }
}
}

View File

@ -0,0 +1,22 @@
package com.bitchat.android.wifiaware
/** Resolves a packet to the exact still-active ingress link that delivered it. */
internal object IngressLinkPolicy {
data class Link<T : Any>(
val relayAddress: String,
val transport: T
)
fun <T : Any> resolve(
ingressLinkID: String?,
relayAddress: String?,
links: Map<String, Link<T>>,
currentTransportForRelay: (String) -> T?
): Link<T>? {
val linkID = ingressLinkID ?: return null
val relayAddress = relayAddress ?: return null
val link = links[linkID] ?: return null
if (link.relayAddress != relayAddress) return null
return link.takeIf { currentTransportForRelay(relayAddress) === it.transport }
}
}

View File

@ -100,9 +100,9 @@ class WifiAwareConnectionTracker(
}
/**
* Atomically require that [expectedSocket] is still the active provisional transport and, only
* then, promote it. This closes the gap where a replacement socket could land after validation
* but before mutation and the stale authenticated socket would become canonical.
* Atomically require that [expectedSocket] is still the active provisional transport before
* rebinding it. This closes the gap where a replacement socket could land after ANNOUNCE
* validation but before mutation and the stale socket would become canonical.
*/
fun rebindPeerIdIfCurrent(
previousPeerId: String,

View File

@ -13,6 +13,7 @@ import android.util.Log
import androidx.annotation.RequiresApi
import androidx.annotation.RequiresPermission
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.mesh.DirectLinkAnnouncementPolicy
import com.bitchat.android.mesh.FragmentingPacketSender
import com.bitchat.android.mesh.MeshCore
import com.bitchat.android.mesh.NdrMeshRoute
@ -77,7 +78,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private const val CLIENT_SOCKET_RETRY_DELAY_MS = 750L
private const val CLIENT_SOCKET_ATTEMPTS = 3
private const val CLIENT_ROLE_REVERSAL_FAILURES = 3
private const val WIFI_AUTHENTICATION_TIMEOUT_MS = 30_000L
// Discovery freshness window for reconnection maintenance
private const val DISCOVERY_STALE_MS = 5L * 60 * 1000
private const val DISCOVERY_IDLE_REFRESH_MS = 2L * 60 * 1000
@ -127,12 +127,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm)
private val ingressLinks = ConcurrentHashMap<
String,
AuthenticatedIngressLinkPolicy.Link<SyncedSocket>
IngressLinkPolicy.Link<SyncedSocket>
>()
private val provisionalWifiClaims =
ConcurrentHashMap<String, AuthenticatedIngressLinkPolicy.Claim>()
private val authenticatedWifiLinks =
ConcurrentHashMap<String, AuthenticatedIngressLinkPolicy.Claim>()
private val handleToPeerId = ConcurrentHashMap<PeerHandle, String>() // discovery mapping
private val discoveredTimestamps = ConcurrentHashMap<String, Long>() // peerID -> last seen time
// Subscribe-session-scoped handles only. PeerHandles are session-scoped, so a handle obtained
@ -178,38 +174,13 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
onMessageReceived = { message -> handleMessageReceived(message) },
onAnnounceProcessed = { routed, _ ->
routed.peerID?.let { pid ->
try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
// Discovery IDs from older clients can be provisional. A verified direct
// announce is enough to start a handshake for the canonical ID, but not to
// rebind the socket. A fresh challenge is sent through the exact transport
// generation, and only its same-link completion may promote that alias.
val relay = routed.relayAddress
val linkID = routed.ingressLinkID
if (
routed.packet.ttl == MAX_TTL &&
relay != null &&
linkID != null
) {
val claim = AuthenticatedIngressLinkPolicy.Claim(relay, linkID)
if (!AuthenticatedIngressLinkPolicy.matches(
authenticatedWifiLinks[pid],
relay,
linkID
)
) {
registerProvisionalWifiClaim(pid, claim)
if (!meshCore.initiateNoiseHandshakeOnLink(pid, relay, linkID)) {
provisionalWifiClaims.remove(pid, claim)
Log.w(TAG, "Could not send Noise challenge on Wi-Fi link for ${pid.take(8)}")
}
}
}
DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
?.let(::observeDirectIngressLink)
try {
meshCore.gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000)
} catch (_: Exception) { }
}
},
onDirectNoiseAuthenticated = { peerID, relayAddress, ingressLinkID, _ ->
promoteAuthenticatedIngressLink(peerID, relayAddress, ingressLinkID)
},
announcementNicknameProvider = {
try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null }
},
@ -589,8 +560,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
publishHandles.clear()
discoveredTimestamps.clear()
ingressLinks.clear()
provisionalWifiClaims.clear()
authenticatedWifiLinks.clear()
meshCore.shutdown()
@ -636,8 +605,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
publishHandles.clear()
discoveredTimestamps.clear()
ingressLinks.clear()
provisionalWifiClaims.clear()
authenticatedWifiLinks.clear()
}
} finally {
recoveryInProgress = false
@ -903,7 +870,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
// presence makes hasOpenServerSocket() true for the life of the process)
// and so we free the fd/port promptly.
connectionTracker.closeServerSocket(peerId)
try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {}
try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {}
listenerExec.execute { listenToPeer(synced, peerId) }
handleSubscriberKeepAlive(synced, peerId, pubSession, peerHandle)
@ -1158,7 +1124,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
activeSocket = synced
connectionTracker.onClientConnected(peerId, synced)
clientSocketFailures.remove(peerId)
try { meshCore.setDirectConnection(peerId, true) } catch (_: Exception) {}
try { meshCore.addOrUpdatePeer(peerId, peerId) } catch (_: Exception) {}
listenerExec.execute { listenToPeer(synced, peerId) }
handleServerKeepAlive(synced, peerId, peerHandle)
@ -1237,70 +1202,74 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
}
/**
* Promote a provisional discovery alias only when the exact, still-active socket delivered the
* Noise frame that completed authentication for the canonical peer ID.
* Records a validated, non-relayed ANNOUNCE as a direct route. The exact-link check keeps stale
* socket readers from rebinding a replacement connection, but Noise remains peer-scoped and is
* not restarted or coupled to this routing observation.
*/
private fun promoteAuthenticatedIngressLink(
canonicalPeerId: String,
relayAddress: String,
ingressLinkID: String
private fun observeDirectIngressLink(
observation: DirectLinkAnnouncementPolicy.Observation
) {
val expectedClaim = provisionalWifiClaims[canonicalPeerId]
if (!AuthenticatedIngressLinkPolicy.matches(
expectedClaim,
relayAddress,
ingressLinkID
)
) {
Log.w(TAG, "Ignoring unsolicited or cross-link Noise promotion for ${canonicalPeerId.take(8)}")
return
}
provisionalWifiClaims.remove(canonicalPeerId, expectedClaim)
val link = AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = ingressLinkID,
authenticatedRelayAddress = relayAddress,
val link = IngressLinkPolicy.resolve(
ingressLinkID = observation.ingressLinkID,
relayAddress = observation.relayAddress,
links = ingressLinks,
currentTransportForRelay = connectionTracker::getSocketForPeer
) ?: run {
Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: ingress link is stale or mismatched")
Log.d(
TAG,
"Ignoring direct ANNOUNCE for ${observation.peerID.take(8)}: ingress link is stale"
)
return
}
val provisionalPeerId = link.relayAddress
val existingCanonical = connectionTracker.canonicalPeerId(provisionalPeerId)
if (existingCanonical == canonicalPeerId) {
authenticatedWifiLinks[canonicalPeerId] =
AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID)
try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { }
if (existingCanonical == observation.peerID) {
try { meshCore.setDirectConnection(observation.peerID, true) } catch (_: Exception) { }
return
}
if (existingCanonical != provisionalPeerId) {
Log.w(TAG, "Refusing authenticated Wi-Fi rebind ${existingCanonical.take(8)} -> ${canonicalPeerId.take(8)} on existing alias")
Log.w(
TAG,
"Refusing Wi-Fi route change ${existingCanonical.take(8)} -> ${observation.peerID.take(8)} on existing alias"
)
return
}
if (!connectionTracker.rebindPeerIdIfCurrent(provisionalPeerId, canonicalPeerId, link.transport)) {
Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: provisional socket changed")
if (!connectionTracker.rebindPeerIdIfCurrent(
provisionalPeerId,
observation.peerID,
link.transport
)
) {
Log.d(
TAG,
"Ignoring direct ANNOUNCE for ${observation.peerID.take(8)}: provisional socket changed"
)
return
}
authenticatedWifiLinks[canonicalPeerId] =
AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID)
handleToPeerId.forEach { (handle, peerId) ->
if (peerId == provisionalPeerId) handleToPeerId[handle] = canonicalPeerId
if (peerId == provisionalPeerId) handleToPeerId[handle] = observation.peerID
}
subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[canonicalPeerId] = it }
publishHandles.remove(provisionalPeerId)?.let { publishHandles[canonicalPeerId] = it }
subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[observation.peerID] = it }
publishHandles.remove(provisionalPeerId)?.let { publishHandles[observation.peerID] = it }
val discoveredAt = discoveredTimestamps.remove(provisionalPeerId) ?: System.currentTimeMillis()
discoveredTimestamps[canonicalPeerId] = discoveredAt
discoveredTimestamps[observation.peerID] = discoveredAt
try { meshCore.setDirectConnection(provisionalPeerId, false) } catch (_: Exception) { }
try { meshCore.removePeer(provisionalPeerId) } catch (_: Exception) { }
try { meshCore.addOrUpdatePeer(canonicalPeerId, meshCore.getPeerNickname(canonicalPeerId) ?: canonicalPeerId) } catch (_: Exception) { }
try { meshCore.setDirectConnection(canonicalPeerId, true) } catch (_: Exception) { }
try { meshCore.gossipSyncManager.scheduleInitialSyncToPeer(canonicalPeerId, 1_000) } catch (_: Exception) { }
try {
meshCore.addOrUpdatePeer(
observation.peerID,
meshCore.getPeerNickname(observation.peerID) ?: observation.peerID
)
} catch (_: Exception) { }
try { meshCore.setDirectConnection(observation.peerID, true) } catch (_: Exception) { }
Log.i(TAG, "Noise-authenticated Wi-Fi peer ${provisionalPeerId.take(8)} -> ${canonicalPeerId.take(8)}")
Log.i(
TAG,
"Observed direct Wi-Fi route ${provisionalPeerId.take(8)} -> ${observation.peerID.take(8)}"
)
}
/**
@ -1313,7 +1282,7 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) {
val logicalPeerId = initialLogicalPeerId
val ingressLinkID = UUID.randomUUID().toString()
val ingressLink = AuthenticatedIngressLinkPolicy.Link(logicalPeerId, socket)
val ingressLink = IngressLinkPolicy.Link(logicalPeerId, socket)
ingressLinks[ingressLinkID] = ingressLink
while (isActive) {
val raw = socket.read() ?: break
@ -1328,10 +1297,11 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
val senderPeerHex = pkt.senderID?.toHexString()?.take(16) ?: continue
if (pkt.type == MessageType.ANNOUNCE.value && pkt.ttl >= MAX_TTL && senderPeerHex != logicalPeerId) {
// The socket's discovery identity remains provisional until Noise proves possession
// of the claimed static key on this link. A canonical self-signed announcement is
// only TOFU and cannot safely rebind/remove transport state on its own.
Log.d(TAG, "RX: deferred Wi-Fi peer rebind ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending Noise proof")
// Rebinding happens only after MeshCore validates and accepts this ANNOUNCE.
Log.d(
TAG,
"RX: Wi-Fi peer observation ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending ANNOUNCE validation"
)
}
// Route the packet:
@ -1341,7 +1311,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
}
ingressLinks.remove(ingressLinkID, ingressLink)
clearProvisionalWifiClaimsForLink(logicalPeerId, ingressLinkID)
// Breaking out of the loop means the socket is dead or service is stopping.
Log.i(TAG, "Disconnected from ${logicalPeerId.take(8)} (socket closed)")
@ -1349,27 +1318,6 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
socket.close()
}
private fun registerProvisionalWifiClaim(
peerID: String,
claim: AuthenticatedIngressLinkPolicy.Claim
) {
provisionalWifiClaims[peerID] = claim
serviceScope.launch {
delay(WIFI_AUTHENTICATION_TIMEOUT_MS)
if (provisionalWifiClaims.remove(peerID, claim)) {
Log.d(TAG, "Expired provisional Wi-Fi authentication claim for ${peerID.take(8)}")
} }
}
private fun clearProvisionalWifiClaimsForLink(relayAddress: String, linkID: String) {
provisionalWifiClaims.entries.removeIf { (_, claim) ->
claim.relayAddress == relayAddress && claim.linkID == linkID
}
authenticatedWifiLinks.entries.removeIf { (_, claim) ->
claim.relayAddress == relayAddress && claim.linkID == linkID
}
}
private fun handleNetworkFailure(peerId: String) {
serviceScope.launch {
if (!connectionTracker.isConnected(peerId)) {
@ -1715,9 +1663,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
ingressLinkID: String,
packet: BitchatPacket
): Boolean {
val link = AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = ingressLinkID,
authenticatedRelayAddress = relayAddress,
val link = IngressLinkPolicy.resolve(
ingressLinkID = ingressLinkID,
relayAddress = relayAddress,
links = ingressLinks,
currentTransportForRelay = connectionTracker::getSocketForPeer
) ?: return false

View File

@ -1,40 +0,0 @@
package com.bitchat.android.mesh
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AuthenticatedBleLinkPolicyTest {
private val claim = AuthenticatedBleLinkPolicy.Claim(
deviceAddress = "AA:BB:CC:DD:EE:FF",
linkID = "connection-a"
)
@Test
fun `accepts completion from exact claimed connection`() {
assertTrue(
AuthenticatedBleLinkPolicy.matches(
claim,
authenticatedAddress = claim.deviceAddress,
authenticatedLinkID = claim.linkID
)
)
}
@Test
fun `rejects replacement connection reusing device address`() {
assertFalse(
AuthenticatedBleLinkPolicy.matches(
claim,
authenticatedAddress = claim.deviceAddress,
authenticatedLinkID = "connection-b"
)
)
}
@Test
fun `rejects completion on another address or without a claim`() {
assertFalse(AuthenticatedBleLinkPolicy.matches(claim, "11:22:33:44:55:66", claim.linkID))
assertFalse(AuthenticatedBleLinkPolicy.matches(null, claim.deviceAddress, claim.linkID))
}
}

View File

@ -14,7 +14,7 @@ import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
class BluetoothConnectionTrackerLinkIdentityTest {
class BluetoothConnectionTrackerLinkObservationTest {
private val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob())
private val tracker = BluetoothConnectionTracker(scope, mock())
@ -46,8 +46,40 @@ class BluetoothConnectionTrackerLinkIdentityTest {
assertFalse(tracker.cleanupDeviceConnectionIfCurrent(address, "link-a"))
assertEquals("link-b", tracker.getCurrentLinkID(address))
assertTrue(tracker.bindPeerIfCurrent(address, "link-b", "0011223344556677"))
assertTrue(tracker.observePeerIfCurrent(address, "link-b", "0011223344556677"))
assertEquals("0011223344556677", tracker.addressPeerMap[address])
assertSame(device, tracker.getDeviceConnection(address)?.device)
}
@Test
fun `one peer can remain directly observed over multiple current links`() {
val firstAddress = "AA:BB:CC:DD:EE:01"
val secondAddress = "AA:BB:CC:DD:EE:02"
val firstDevice = mock<BluetoothDevice>()
val secondDevice = mock<BluetoothDevice>()
whenever(firstDevice.address).thenReturn(firstAddress)
whenever(secondDevice.address).thenReturn(secondAddress)
tracker.addDeviceConnection(
firstAddress,
BluetoothConnectionTracker.DeviceConnection(device = firstDevice, linkID = "link-a")
)
tracker.addDeviceConnection(
secondAddress,
BluetoothConnectionTracker.DeviceConnection(device = secondDevice, linkID = "link-b")
)
assertTrue(tracker.observePeerIfCurrent(firstAddress, "link-a", PEER_ID))
assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID))
assertTrue(tracker.observePeerIfCurrent(secondAddress, "link-b", PEER_ID))
assertEquals(2, tracker.addressPeerMap.values.count { it == PEER_ID })
assertTrue(tracker.cleanupDeviceConnectionIfCurrent(firstAddress, "link-a"))
assertEquals(PEER_ID, tracker.addressPeerMap[secondAddress])
assertTrue(tracker.addressPeerMap.containsValue(PEER_ID))
}
private companion object {
const val PEER_ID = "0011223344556677"
}
}

View File

@ -0,0 +1,64 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class DirectLinkAnnouncementPolicyTest {
@Test
fun `accepted max ttl announce is a direct routing observation`() {
val routed = announce(ttl = MAX_TTL)
assertEquals(
DirectLinkAnnouncementPolicy.Observation(PEER_ID, RELAY_ADDRESS, LINK_ID),
DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
)
}
@Test
fun `relayed announce is not a direct routing observation`() {
assertNull(
DirectLinkAnnouncementPolicy.observationFor(
announce(ttl = (MAX_TTL - 1u).toUByte()),
MAX_TTL
)
)
}
@Test
fun `repeated announce remains the same observation without transport authentication state`() {
val routed = announce(ttl = MAX_TTL)
val first = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
val second = DirectLinkAnnouncementPolicy.observationFor(routed, MAX_TTL)
assertEquals(first, second)
}
private fun announce(ttl: UByte) = RoutedPacket(
packet = BitchatPacket(
version = 1u,
type = MessageType.ANNOUNCE.value,
senderID = PEER_ID.hexToBytes(),
timestamp = 1u,
payload = byteArrayOf(1),
ttl = ttl
),
peerID = PEER_ID,
relayAddress = RELAY_ADDRESS,
ingressLinkID = LINK_ID
)
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private companion object {
const val PEER_ID = "0011223344556677"
const val RELAY_ADDRESS = "transport-neighbor"
const val LINK_ID = "current-link"
val MAX_TTL: UByte = 7u
}
}

View File

@ -0,0 +1,234 @@
package com.bitchat.android.nostr
import android.os.Build
import com.bitchat.android.services.AppStateStore
import com.bitchat.android.services.SeenMessageStore
import com.bitchat.android.ui.ChatState
import com.bitchat.android.ui.DataManager
import com.bitchat.android.ui.MeshDelegateHandler
import com.bitchat.android.ui.MessageManager
import com.bitchat.android.ui.NoiseSessionDelegate
import com.bitchat.android.ui.PrivateChatManager
import com.google.gson.Gson
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
@OptIn(ExperimentalCoroutinesApi::class)
class NostrDirectMessageHandlerTest {
private val gson = Gson()
private lateinit var scope: CoroutineScope
@Before
fun setUp() {
Dispatchers.setMain(UnconfinedTestDispatcher())
scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
AppStateStore.clear()
}
@After
fun tearDown() {
AppStateStore.clear()
scope.cancel()
Dispatchers.resetMain()
}
@Test
fun `private messages use authenticated rumor time instead of randomized gift wrap time`() {
val application = RuntimeEnvironment.getApplication()
val state = ChatState(scope).apply { setNickname("recipient") }
val dataManager = DataManager(application)
val messageManager = MessageManager(state)
val privateChatManager = PrivateChatManager(
state = state,
messageManager = messageManager,
dataManager = dataManager,
noiseSessionDelegate = mock<NoiseSessionDelegate>()
)
val seenStore = mock<SeenMessageStore>()
whenever(seenStore.hasDelivered(any())).thenReturn(true)
whenever(seenStore.hasRead(any())).thenReturn(false)
val handler = NostrDirectMessageHandler(
application = application,
state = state,
privateChatManager = privateChatManager,
meshDelegateHandler = mock<MeshDelegateHandler>(),
scope = scope,
repo = GeohashRepository(application, state, dataManager),
dataManager = dataManager,
seenStoreProvider = { seenStore },
legacyNostrInboundAllowed = { true }
)
val sender = NostrIdentity.generate()
val recipient = NostrIdentity.generate()
val now = (System.currentTimeMillis() / 1000).toInt()
val firstRumorTime = now - 120
val secondRumorTime = now - 60
val firstId = "first-real-time"
val secondId = "second-real-time"
val first = privateMessageGiftWrap(
content = requireNotNull(
NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content = "first",
messageID = firstId,
senderPeerID = "0011223344556677"
)
),
sender = sender,
recipient = recipient,
rumorCreatedAt = firstRumorTime,
giftWrapCreatedAt = now - 5
)
val second = privateMessageGiftWrap(
content = requireNotNull(
NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content = "second",
messageID = secondId,
senderPeerID = "0011223344556677"
)
),
sender = sender,
recipient = recipient,
rumorCreatedAt = secondRumorTime,
giftWrapCreatedAt = now - 86_400
)
handler.onGiftWrap(first, "", recipient)
waitForMessage(state, firstId)
handler.onGiftWrap(second, "", recipient)
waitForMessage(state, secondId)
val messages = state.getPrivateChatsValue().values.single()
assertEquals(listOf(firstId, secondId), messages.map { it.id })
assertEquals(firstRumorTime * 1000L, messages[0].timestamp.time)
assertEquals(secondRumorTime * 1000L, messages[1].timestamp.time)
}
@Test
fun `legacy inbound policy rejects before a valid gift wrap reaches chat state`() {
val application = RuntimeEnvironment.getApplication()
val state = ChatState(scope).apply { setNickname("recipient") }
val dataManager = DataManager(application)
val privateChatManager = PrivateChatManager(
state = state,
messageManager = MessageManager(state),
dataManager = dataManager,
noiseSessionDelegate = mock<NoiseSessionDelegate>()
)
val seenStore = mock<SeenMessageStore>()
val policyChecked = CompletableDeferred<String>()
val handler = NostrDirectMessageHandler(
application = application,
state = state,
privateChatManager = privateChatManager,
meshDelegateHandler = mock<MeshDelegateHandler>(),
scope = scope,
repo = GeohashRepository(application, state, dataManager),
dataManager = dataManager,
seenStoreProvider = { seenStore },
legacyNostrInboundAllowed = { senderPubkey ->
policyChecked.complete(senderPubkey)
false
}
)
val sender = NostrIdentity.generate()
val recipient = NostrIdentity.generate()
val now = (System.currentTimeMillis() / 1000).toInt()
val giftWrap = privateMessageGiftWrap(
content = requireNotNull(
NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content = "must-not-arrive",
messageID = "blocked-legacy",
senderPeerID = "0011223344556677"
)
),
sender = sender,
recipient = recipient,
rumorCreatedAt = now - 60,
giftWrapCreatedAt = now - 5
)
handler.onGiftWrap(giftWrap, "", recipient)
kotlinx.coroutines.runBlocking {
assertEquals(sender.publicKeyHex, withTimeout(5_000) { policyChecked.await() })
delay(10)
}
assertEquals(0, state.getPrivateChatsValue().values.flatten().size)
}
private fun waitForMessage(state: ChatState, messageId: String) {
kotlinx.coroutines.runBlocking {
withTimeout(5_000) {
while (state.getPrivateChatsValue().values.flatten().none { it.id == messageId }) {
delay(10)
}
}
}
}
private fun privateMessageGiftWrap(
content: String,
sender: NostrIdentity,
recipient: NostrIdentity,
rumorCreatedAt: Int,
giftWrapCreatedAt: Int
): NostrEvent {
val rumorBase = NostrEvent(
pubkey = sender.publicKeyHex,
createdAt = rumorCreatedAt,
kind = NostrKind.DIRECT_MESSAGE,
tags = listOf(listOf("p", recipient.publicKeyHex)),
content = content
)
val rumor = rumorBase.copy(id = rumorBase.computeEventIdHex())
val sealContent = NostrCrypto.encryptNIP44(
plaintext = gson.toJson(rumor),
recipientPublicKeyHex = recipient.publicKeyHex,
senderPrivateKeyHex = sender.privateKeyHex
)
val seal = NostrEvent(
pubkey = sender.publicKeyHex,
createdAt = giftWrapCreatedAt,
kind = NostrKind.SEAL,
tags = emptyList(),
content = sealContent
).sign(sender.privateKeyHex)
val (wrapPrivateKey, wrapPublicKey) = NostrCrypto.generateKeyPair()
val giftWrapContent = NostrCrypto.encryptNIP44(
plaintext = gson.toJson(seal),
recipientPublicKeyHex = recipient.publicKeyHex,
senderPrivateKeyHex = wrapPrivateKey
)
return NostrEvent(
pubkey = wrapPublicKey,
createdAt = giftWrapCreatedAt,
kind = NostrKind.GIFT_WRAP,
tags = listOf(listOf("p", recipient.publicKeyHex)),
content = giftWrapContent
).sign(wrapPrivateKey)
}
}

View File

@ -10,6 +10,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@ -104,4 +105,30 @@ class PrivateChatManagerTest {
verify(meshService).sendReadReceipt(message.id, meshPeerID, "bob")
}
@Test
fun `canonical conversation send does not require resolved nickname`() {
val conversationID =
ContactIdentityResolver.contactConversationIdForNoiseKey(ByteArray(32) { 4 })
var callbackInvoked = false
manager.sendPrivateMessage(
content = "hello",
peerID = conversationID,
recipientNickname = null,
senderNickname = "bob",
myPeerID = "self"
) { content, recipientID, nickname, _ ->
callbackInvoked = true
assertEquals("hello", content)
assertEquals(conversationID, recipientID)
assertEquals("", nickname)
}
assertTrue(callbackInvoked)
assertEquals(
"hello",
state.getPrivateChatsValue()[conversationID]?.single()?.content
)
}
}

View File

@ -1,93 +0,0 @@
package com.bitchat.android.wifiaware
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AuthenticatedIngressLinkPolicyTest {
@Test
fun `promotion claim must match the challenged relay and link`() {
val claim = AuthenticatedIngressLinkPolicy.Claim("provisional", "challenged-link")
assertTrue(
AuthenticatedIngressLinkPolicy.matches(
claim,
authenticatedRelayAddress = "provisional",
authenticatedLinkID = "challenged-link"
)
)
assertFalse(
AuthenticatedIngressLinkPolicy.matches(
claim,
authenticatedRelayAddress = "provisional",
authenticatedLinkID = "different-link"
)
)
assertFalse(
AuthenticatedIngressLinkPolicy.matches(
expected = null,
authenticatedRelayAddress = "provisional",
authenticatedLinkID = "challenged-link"
)
)
}
@Test
fun `authentication promotes only the exact ingress link`() {
val attackerSocket = Any()
val victimSocket = Any()
val links = mapOf(
"attacker-link" to AuthenticatedIngressLinkPolicy.Link("provisional-attacker", attackerSocket),
"victim-link" to AuthenticatedIngressLinkPolicy.Link("provisional-victim", victimSocket)
)
val current = mapOf(
"provisional-attacker" to attackerSocket,
"provisional-victim" to victimSocket
)
val resolved = AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = "victim-link",
authenticatedRelayAddress = "provisional-victim",
links = links,
currentTransportForRelay = current::get
)
assertSame(victimSocket, resolved?.transport)
}
@Test
fun `stale replaced or mismatched ingress links cannot be promoted`() {
val completedSocket = Any()
val replacementSocket = Any()
val links = mapOf(
"completed-link" to AuthenticatedIngressLinkPolicy.Link("provisional", completedSocket)
)
assertNull(
AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = "missing-link",
authenticatedRelayAddress = "provisional",
links = links,
currentTransportForRelay = { completedSocket }
)
)
assertNull(
AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = "completed-link",
authenticatedRelayAddress = "different-provisional",
links = links,
currentTransportForRelay = { completedSocket }
)
)
assertNull(
AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = "completed-link",
authenticatedRelayAddress = "provisional",
links = links,
currentTransportForRelay = { replacementSocket }
)
)
}
}

View File

@ -0,0 +1,64 @@
package com.bitchat.android.wifiaware
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
class IngressLinkPolicyTest {
@Test
fun `observation resolves only the exact ingress link`() {
val attackerSocket = Any()
val victimSocket = Any()
val links = mapOf(
"attacker-link" to IngressLinkPolicy.Link("provisional-attacker", attackerSocket),
"victim-link" to IngressLinkPolicy.Link("provisional-victim", victimSocket)
)
val current = mapOf(
"provisional-attacker" to attackerSocket,
"provisional-victim" to victimSocket
)
val resolved = IngressLinkPolicy.resolve(
ingressLinkID = "victim-link",
relayAddress = "provisional-victim",
links = links,
currentTransportForRelay = current::get
)
assertSame(victimSocket, resolved?.transport)
}
@Test
fun `stale replaced or mismatched ingress links cannot be observed`() {
val completedSocket = Any()
val replacementSocket = Any()
val links = mapOf(
"completed-link" to IngressLinkPolicy.Link("provisional", completedSocket)
)
assertNull(
IngressLinkPolicy.resolve(
ingressLinkID = "missing-link",
relayAddress = "provisional",
links = links,
currentTransportForRelay = { completedSocket }
)
)
assertNull(
IngressLinkPolicy.resolve(
ingressLinkID = "completed-link",
relayAddress = "different-provisional",
links = links,
currentTransportForRelay = { completedSocket }
)
)
assertNull(
IngressLinkPolicy.resolve(
ingressLinkID = "completed-link",
relayAddress = "provisional",
links = links,
currentTransportForRelay = { replacementSocket }
)
)
}
}

View File

@ -17,21 +17,21 @@ import java.net.Socket
class WifiAwareConnectionTrackerTest {
@Test
fun `compare and rebind rejects stale authenticated socket after replacement`() {
fun `compare and rebind rejects stale observed socket after replacement`() {
val tracker = WifiAwareConnectionTracker(
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
mock<ConnectivityManager>()
)
val authenticatedSocket = syncedSocket()
val observedSocket = syncedSocket()
val replacementSocket = syncedSocket()
tracker.onClientConnected("provisional", authenticatedSocket)
tracker.onClientConnected("provisional", observedSocket)
tracker.onClientConnected("provisional", replacementSocket)
assertFalse(
tracker.rebindPeerIdIfCurrent(
previousPeerId = "provisional",
resolvedPeerId = "canonical",
expectedSocket = authenticatedSocket
expectedSocket = observedSocket
)
)
assertSame(replacementSocket, tracker.getSocketForPeer("provisional"))
@ -49,7 +49,7 @@ class WifiAwareConnectionTrackerTest {
}
@Test
fun `authenticated provisional socket cannot displace existing canonical socket`() {
fun `observed provisional socket cannot displace existing canonical socket`() {
val tracker = WifiAwareConnectionTracker(
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
mock<ConnectivityManager>()