Bind Noise sessions to claimed peer identities (#730)

* Bind Noise sessions to claimed peer identities

* Make Wi-Fi peer rebinding atomic

* Migrate private media without silent downgrades (#728)

* Migrate private media without silent downgrades

* Accept prerelease private media payloads

* Authenticate private media capability per Noise session

* updates

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jack@deck.local>
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>

* Authenticate BLE links before peer binding (#749)

* Authenticate BLE links before peer binding

* Fix BLE link authentication races

---------

Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>

* Use canonical Maven repository for Robolectric

---------

Co-authored-by: jack <jack@deck.local>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
Co-authored-by: a1denvalu3 <43107113+a1denvalu3@users.noreply.github.com>
This commit is contained in:
jack 2026-07-26 22:29:57 +01:00 committed by GitHub
parent 5a38aaf3e8
commit 84b24e347f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 6671 additions and 737 deletions

View File

@ -151,3 +151,12 @@ dependencies {
androidTestImplementation(libs.bundles.compose.testing)
debugImplementation(libs.androidx.compose.ui.tooling)
}
// Robolectric resolves Android runtime jars itself (outside Gradle dependency resolution).
// Its legacy repo1 endpoint rejects cold GitHub-hosted runners with HTTP 403.
tasks.withType<org.gradle.api.tasks.testing.Test>().configureEach {
systemProperty(
"robolectric.dependency.repo.url",
"https://repo.maven.apache.org/maven2"
)
}

View File

@ -7,6 +7,9 @@ import android.util.Log
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import com.bitchat.android.noise.NoiseEncryptionService
import com.bitchat.android.noise.NoiseHandshakeProcessingResult
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoiseDecryptionResult
import org.bouncycastle.crypto.AsymmetricCipherKeyPair
import org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator
import org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters
@ -190,6 +193,13 @@ open class EncryptionService(private val context: Context) {
}
return encrypted
}
@Throws(Exception::class)
fun encryptForSession(
data: ByteArray,
peerID: String,
expectedSession: AuthenticatedNoiseSession
): ByteArray = noiseService.encryptForSession(data, peerID, expectedSession)
/**
* Decrypt data from a specific peer using Noise transport encryption
@ -202,6 +212,12 @@ open class EncryptionService(private val context: Context) {
}
return decrypted
}
@Throws(Exception::class)
fun decryptWithSession(data: ByteArray, peerID: String): NoiseDecryptionResult {
return noiseService.decryptWithSession(data, peerID)
?: throw Exception("Failed generation-bound decryption from $peerID")
}
/**
* Sign data using our static identity key
@ -254,6 +270,25 @@ open class EncryptionService(private val context: Context) {
fun getPeerFingerprint(peerID: String): String? {
return noiseService.getPeerFingerprint(peerID)
}
/**
* Return the remote static key authenticated by the live Noise handshake.
* This deliberately bypasses announcement and PeerFingerprintManager
* caches; callers making downgrade decisions must bind to live channel
* authentication, not a self-certified identity payload.
*/
fun getAuthenticatedRemoteStaticKey(peerID: String): ByteArray? {
return getAuthenticatedSession(peerID)?.remoteStaticKey?.copyOf()
}
fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? =
noiseService.getAuthenticatedSession(peerID)
fun withAuthenticatedSession(
peerID: String,
expectedSession: AuthenticatedNoiseSession,
action: () -> Boolean
): Boolean = noiseService.withAuthenticatedSession(peerID, expectedSession, action)
/**
* Get current peer ID for a fingerprint (for peer ID rotation)
@ -265,9 +300,9 @@ open class EncryptionService(private val context: Context) {
/**
* Initiate a Noise handshake with a peer
*/
fun initiateHandshake(peerID: String): ByteArray? {
fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? {
Log.d(TAG, "🤝 Initiating Noise handshake with $peerID")
return noiseService.initiateHandshake(peerID)
return noiseService.initiateHandshake(peerID, replaceEstablished)
}
/**
@ -277,11 +312,24 @@ open class EncryptionService(private val context: Context) {
Log.d(TAG, "🤝 Processing handshake message from $peerID")
return noiseService.processHandshakeMessage(data, peerID)
}
/**
* Process one Noise handshake frame while preserving whether this exact call authenticated a
* new session. Unlike the response-only compatibility API, binding failures are propagated.
*/
@Throws(Exception::class)
open fun processHandshakeMessageWithResult(
data: ByteArray,
peerID: String
): NoiseHandshakeProcessingResult {
Log.d(TAG, "🤝 Processing typed handshake message from $peerID")
return noiseService.processHandshakeMessageWithResult(data, peerID)
}
/**
* Remove a peer session (called when peer disconnects)
*/
fun removePeer(peerID: String) {
open fun removePeer(peerID: String) {
establishedSessions.remove(peerID)
noiseService.removePeer(peerID)
onSessionLost?.invoke(peerID)

View File

@ -1,5 +1,6 @@
package com.bitchat.android.identity
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
@ -7,6 +8,8 @@ import androidx.security.crypto.MasterKey
import java.security.MessageDigest
import android.util.Base64
import android.util.Log
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.util.hexEncodedString
import androidx.core.content.edit
@ -18,7 +21,7 @@ import androidx.core.content.edit
* - Secure storage using Android EncryptedSharedPreferences
* - Fingerprint calculation and identity validation
*/
class SecureIdentityStateManager(private val context: Context) {
class SecureIdentityStateManager {
companion object {
private const val TAG = "SecureIdentityStateManager"
@ -32,12 +35,25 @@ class SecureIdentityStateManager(private val context: Context) {
private const val KEY_CACHED_PEER_NOISE_KEYS = "cached_peer_noise_keys"
private const val KEY_CACHED_NOISE_FINGERPRINTS = "cached_noise_fingerprints"
private const val KEY_CACHED_FINGERPRINT_NICKNAMES = "cached_fingerprint_nicknames"
private const val KEY_PRIVATE_MEDIA_CAPABILITY_PINS = "private_media_capability_pins_v1"
private const val KEY_AUTHENTICATED_PEER_STATES = "authenticated_peer_states_v1"
// BLE, Wi-Fi Aware, and Noise services each hold their own manager
// instance over the same encrypted preferences. Serialize pin updates
// process-wide so concurrent promotions cannot lose one another or
// race a panic wipe.
private val privateMediaPinsLock = Any()
private var privateMediaPinsEpoch = 0L
}
private val prefs: SharedPreferences
private val lock = Any()
init {
private var privateMediaPinsEpochAtCreation: Long
constructor(context: Context) {
privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch
}
// Create master key for encryption
val masterKey = MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
@ -52,6 +68,15 @@ class SecureIdentityStateManager(private val context: Context) {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
/** Test-only storage injection; production always uses encrypted prefs. */
internal constructor(prefs: SharedPreferences, testOnly: Boolean) {
require(testOnly) { "Plain SharedPreferences are test-only" }
privateMediaPinsEpochAtCreation = synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch
}
this.prefs = prefs
}
// MARK: - Static Key Management
@ -293,6 +318,78 @@ class SecureIdentityStateManager(private val context: Context) {
prefs.edit { putStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, current) }
}
}
// MARK: - Authenticated private-media capability pins
fun isPrivateMediaCapable(fingerprint: String): Boolean {
if (!isValidFingerprint(fingerprint)) return false
return synchronized(privateMediaPinsLock) {
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) {
return@synchronized false
}
prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet())
?.any { it.equals(fingerprint, ignoreCase = true) } == true
}
}
/** Persist capabilities and Ed25519 key from a decoded Noise 0x21 proof in one edit. */
@SuppressLint("UseKtx")
fun storeAuthenticatedPeerState(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit = {}
): Boolean {
if (!isValidFingerprint(fingerprint) || state.signingPublicKey.size != 32) return false
val normalizedFingerprint = fingerprint.lowercase()
return synchronized(privateMediaPinsLock) {
// A controller that survived panic must not republish pre-wipe proof state.
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized false
val records = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet())
?.toMutableSet() ?: mutableSetOf()
records.removeAll { it.startsWith("$normalizedFingerprint:") }
val capabilitiesHex = java.lang.Long.toUnsignedString(state.capabilities.rawValue, 16)
records.add(
"$normalizedFingerprint:$capabilitiesHex:${state.signingPublicKey.hexEncodedString()}"
)
val editor = prefs.edit().putStringSet(KEY_AUTHENTICATED_PEER_STATES, records)
if (state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) {
val pins = prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet())
?.mapTo(mutableSetOf()) { it.lowercase() } ?: mutableSetOf()
pins.add(normalizedFingerprint)
editor.putStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, pins)
}
// This result is a security boundary: do not publish the Ed key in memory unless the
// encrypted identity record and its HSTS pin were durably committed together.
editor.commit().also { committed ->
if (committed) onCommitted()
}
}
}
fun getAuthenticatedPeerState(fingerprint: String): AuthenticatedPeerState? {
if (!isValidFingerprint(fingerprint)) return null
return synchronized(privateMediaPinsLock) {
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return@synchronized null
val prefix = "${fingerprint.lowercase()}:"
val record = prefs.getStringSet(KEY_AUTHENTICATED_PEER_STATES, emptySet())
?.firstOrNull { it.startsWith(prefix) } ?: return@synchronized null
val fields = record.split(':', limit = 3)
if (fields.size != 3) return@synchronized null
val capabilities = runCatching {
PeerCapabilities(java.lang.Long.parseUnsignedLong(fields[1], 16))
}.getOrNull() ?: return@synchronized null
val signingKeyHex = fields[2]
if (!signingKeyHex.matches(Regex("^[0-9a-f]{64}$"))) return@synchronized null
val signingKey = runCatching {
signingKeyHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}.getOrNull() ?: return@synchronized null
AuthenticatedPeerState(capabilities, signingKey)
}
}
fun getAuthenticatedSigningKey(fingerprint: String): ByteArray? =
getAuthenticatedPeerState(fingerprint)?.signingPublicKey?.copyOf()
// MARK: - Peer ID Rotation Management (removed)
// Android now derives peer ID from the persisted Noise identity fingerprint.
@ -368,9 +465,16 @@ class SecureIdentityStateManager(private val context: Context) {
/**
* Clear all identity data (for panic mode)
*/
@SuppressLint("UseKtx")
fun clearIdentityData() {
try {
prefs.edit().clear().apply()
synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch += 1
privateMediaPinsEpochAtCreation = privateMediaPinsEpoch
if (!prefs.edit().clear().commit()) {
Log.e(TAG, "Identity preference wipe could not be committed")
}
}
Log.w(TAG, "All identity data cleared")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear identity data: ${e.message}")

View File

@ -0,0 +1,37 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.noise.NoisePeerIdentity
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.util.toHexString
/** Canonical, side-effect-free preflight for a self-signed mesh announcement. */
object AnnouncementIdentityValidator {
private const val MAX_CLOCK_SKEW_MS = 10 * 60 * 1_000L
fun verify(
packet: BitchatPacket,
claimedPeerID: String,
nowMs: Long = System.currentTimeMillis(),
verifyEd25519: (signature: ByteArray, data: ByteArray, publicKey: ByteArray) -> Boolean
): IdentityAnnouncement? {
if (packet.type != MessageType.ANNOUNCE.value) return null
val now = nowMs.coerceAtLeast(0).toULong()
val skew = if (packet.timestamp >= now) packet.timestamp - now else now - packet.timestamp
if (skew > MAX_CLOCK_SKEW_MS.toULong()) return null
val announcement = IdentityAnnouncement.decode(packet.payload) ?: return null
if (announcement.signingPublicKey.size != 32) return null
val derivedPeerID = NoisePeerIdentity.derivePeerID(announcement.noisePublicKey) ?: return null
if (packet.senderID.toHexString() != derivedPeerID || claimedPeerID != derivedPeerID) return null
val signature = packet.signature ?: return null
val canonicalData = packet.toBinaryDataForSigning() ?: return null
return if (verifyEd25519(signature, canonicalData, announcement.signingPublicKey)) {
announcement
} else {
null
}
}
}

View File

@ -0,0 +1,14 @@
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

@ -0,0 +1,235 @@
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.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoisePeerIdentity
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
internal interface AuthenticatedPeerStateStore {
fun load(fingerprint: String): AuthenticatedPeerState?
fun persist(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit
): Boolean
fun isPrivateMediaPinned(fingerprint: String): Boolean
}
internal class SecureAuthenticatedPeerStateStore(context: Context) : AuthenticatedPeerStateStore {
private val identityState = SecureIdentityStateManager(context.applicationContext)
override fun load(fingerprint: String): AuthenticatedPeerState? =
identityState.getAuthenticatedPeerState(fingerprint)
override fun persist(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit
): Boolean = identityState.storeAuthenticatedPeerState(fingerprint, state, onCommitted)
override fun isPrivateMediaPinned(fingerprint: String): Boolean =
identityState.isPrivateMediaCapable(fingerprint)
}
internal sealed interface AuthenticatedPeerStateStatus {
data object Missing : AuthenticatedPeerStateStatus
data object Awaiting : AuthenticatedPeerStateStatus
data object TimedOut : AuthenticatedPeerStateStatus
data class Proven(val state: AuthenticatedPeerState) : AuthenticatedPeerStateStatus
}
/** Fresh, generation-scoped authenticated peer-state exchange for Noise payload 0x21. */
internal class AuthenticatedPeerStateCoordinator(
private val scope: CoroutineScope,
private val authenticatedSessionProvider: (String) -> AuthenticatedNoiseSession?,
private val withAuthenticatedSession: (
String,
AuthenticatedNoiseSession,
() -> Boolean
) -> Boolean,
private val store: AuthenticatedPeerStateStore,
private val localStateProvider: () -> AuthenticatedPeerState,
private val applyAuthenticatedState: (String, ByteArray, AuthenticatedPeerState) -> Unit,
private val sendState: (String, AuthenticatedPeerState, AuthenticatedNoiseSession) -> Boolean,
private val onResolution: (String) -> Unit,
private val proofTimeoutMs: Long = 5_000L
) {
private data class SessionState(
val authenticatedSession: AuthenticatedNoiseSession,
val fingerprint: String,
var status: AuthenticatedPeerStateStatus,
var echoSent: Boolean,
var timeoutJob: Job? = null
)
private val lock = Any()
private val sessions = ConcurrentHashMap<String, SessionState>()
fun onSessionAuthenticated(
peerID: String,
authenticatedRemoteStatic: ByteArray,
authenticatedSessionToken: ByteArray
) {
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteStatic)) return
if (authenticatedSessionToken.size != 32 ||
authenticatedSessionToken.all { it == 0.toByte() }
) return
val authenticatedSession = AuthenticatedNoiseSession(
authenticatedRemoteStatic.copyOf(),
authenticatedSessionToken.copyOf()
)
ensureSession(peerID, authenticatedSession)
}
/** Install one watchdog/exchange for this exact live generation, without resetting it. */
private fun ensureSession(
peerID: String,
authenticatedSession: AuthenticatedNoiseSession
): SessionState? {
val authenticatedRemoteStatic = authenticatedSession.remoteStaticKey
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteStatic)) return null
if (authenticatedSession.sessionToken.size != 32 ||
authenticatedSession.sessionToken.all { it == 0.toByte() }
) return null
// Ignore a delayed callback or policy snapshot if a later generation is already active.
if (authenticatedSessionProvider(peerID) != authenticatedSession) return null
val session = SessionState(
authenticatedSession = authenticatedSession,
fingerprint = fingerprint(authenticatedRemoteStatic),
status = AuthenticatedPeerStateStatus.Awaiting,
echoSent = false
)
val installed = withAuthenticatedSession(peerID, authenticatedSession) {
synchronized(lock) {
val existing = sessions[peerID]
if (existing?.authenticatedSession == authenticatedSession) {
return@synchronized false
}
sessions.put(peerID, session)?.timeoutJob?.cancel()
true
}
}
if (!installed) return synchronized(lock) { sessions[peerID] }
// Emit for every authenticated generation/rekey. Failure does not relax the watchdog.
runCatching { sendState(peerID, localStateProvider(), authenticatedSession) }
val timeout = scope.launch {
delay(proofTimeoutMs)
val resolved = synchronized(lock) {
val current = sessions[peerID]
if (current !== session || current.status !is AuthenticatedPeerStateStatus.Awaiting) {
false
} else {
current.status = AuthenticatedPeerStateStatus.TimedOut
true
}
}
if (resolved) onResolution(peerID)
}
synchronized(lock) {
if (sessions[peerID] === session && session.status is AuthenticatedPeerStateStatus.Awaiting) {
session.timeoutJob = timeout
} else {
timeout.cancel()
}
}
return session
}
/** Accept the first valid proof for this generation; repeated equal proofs are idempotent. */
fun receive(
peerID: String,
state: AuthenticatedPeerState,
decryptedSession: AuthenticatedNoiseSession
): Boolean {
val remoteStatic = decryptedSession.remoteStaticKey
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, remoteStatic)) return false
if (decryptedSession.sessionToken.size != 32 ||
decryptedSession.sessionToken.all { it == 0.toByte() }
) return false
val currentFingerprint = fingerprint(remoteStatic)
var shouldEcho = false
var echoSession: AuthenticatedNoiseSession? = null
val accepted = withAuthenticatedSession(peerID, decryptedSession) {
synchronized(lock) {
val current = sessions[peerID] ?: return@synchronized false
if (current.fingerprint != currentFingerprint ||
current.authenticatedSession != decryptedSession
) return@synchronized false
val proven = current.status as? AuthenticatedPeerStateStatus.Proven
if (proven != null) return@synchronized proven.state == state
try {
// Persist before publishing the replacement Ed key in memory, so a restart
// cannot reopen copied-static first-announce poisoning. The Noise manager
// lease prevents this generation from being replaced during the transition.
if (!store.persist(currentFingerprint, state) {
// Publish while the persistence epoch lock is still held. A panic wipe
// can therefore happen before both operations or after both, never between.
applyAuthenticatedState(peerID, remoteStatic, state)
}
) return@synchronized false
current.timeoutJob?.cancel()
current.status = AuthenticatedPeerStateStatus.Proven(state)
if (!current.echoSent) {
current.echoSent = true
shouldEcho = true
echoSession = current.authenticatedSession
}
true
} catch (_: Exception) {
false
}
}
}
if (!accepted) return false
if (shouldEcho) {
val exactSession = echoSession ?: return false
runCatching { sendState(peerID, localStateProvider(), exactSession) }
}
onResolution(peerID)
return true
}
fun status(
peerID: String,
authenticatedSession: AuthenticatedNoiseSession
): AuthenticatedPeerStateStatus {
ensureSession(peerID, authenticatedSession)
val currentFingerprint = fingerprint(authenticatedSession.remoteStaticKey)
return synchronized(lock) {
sessions[peerID]?.takeIf {
it.fingerprint == currentFingerprint &&
it.authenticatedSession == authenticatedSession
}?.status
?: AuthenticatedPeerStateStatus.Missing
}
}
fun persistedSigningKeyFor(noisePublicKey: ByteArray): ByteArray? {
if (noisePublicKey.size != 32) return null
return store.load(fingerprint(noisePublicKey))?.signingPublicKey?.copyOf()
}
fun isPrivateMediaPinned(peerID: String): Boolean {
val authenticatedSession = authenticatedSessionProvider(peerID) ?: return false
return store.isPrivateMediaPinned(fingerprint(authenticatedSession.remoteStaticKey))
}
fun clear(peerID: String) {
synchronized(lock) { sessions.remove(peerID)?.timeoutJob?.cancel() }
}
private fun fingerprint(publicKey: ByteArray): String =
MessageDigest.getInstance("SHA-256")
.digest(publicKey)
.joinToString("") { "%02x".format(it) }
}

View File

@ -42,7 +42,12 @@ class BluetoothConnectionManager(
// Delegate for component managers to call back to main manager
private val componentDelegate = object : BluetoothConnectionManagerDelegate {
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?) {
override fun onPacketReceived(
packet: BitchatPacket,
peerID: String,
device: BluetoothDevice?,
ingressLinkID: String
) {
Log.d(TAG, "onPacketReceived: Packet received from ${device?.address} ($peerID)")
device?.let { bluetoothDevice ->
// Get current RSSI for this device and update if available
@ -54,7 +59,7 @@ class BluetoothConnectionManager(
if (peerID == myPeerID) return // Ignore messages from self
delegate?.onPacketReceived(packet, peerID, device)
delegate?.onPacketReceived(packet, peerID, device, ingressLinkID)
}
override fun onDeviceConnected(device: BluetoothDevice) {
@ -63,8 +68,8 @@ class BluetoothConnectionManager(
delegate?.onDeviceConnected(device)
}
override fun onDeviceDisconnected(device: BluetoothDevice) {
delegate?.onDeviceDisconnected(device)
override fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?) {
delegate?.onDeviceDisconnected(device, linkID)
}
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
@ -88,6 +93,12 @@ 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 getCurrentLinkID(deviceAddress: String): String? =
connectionTracker.getCurrentLinkID(deviceAddress)
private fun isBleTransportEnabled(): Boolean {
return try {
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().bleEnabled.value
@ -323,10 +334,10 @@ class BluetoothConnectionManager(
* Broadcast packet to connected devices with connection limit enforcement
* Automatically fragments large packets to fit within BLE MTU limits
*/
fun broadcastPacket(routed: RoutedPacket) {
if (!isActive || !isBleTransportEnabled()) return
packetBroadcaster.broadcastPacket(
fun broadcastPacket(routed: RoutedPacket): Boolean {
if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.broadcastPacket(
routed,
serverManager.getGattServer(),
serverManager.getCharacteristic()
@ -359,6 +370,17 @@ class BluetoothConnectionManager(
serverManager.getCharacteristic()
)
}
fun sendPacketToLink(deviceAddress: String, linkID: String, packet: BitchatPacket): Boolean {
if (!isActive || !isBleTransportEnabled()) return false
return packetBroadcaster.sendPacketToLink(
RoutedPacket(packet),
deviceAddress,
linkID,
serverManager.getGattServer(),
serverManager.getCharacteristic()
)
}
// Expose role controls for debug UI
@ -501,8 +523,13 @@ class BluetoothConnectionManager(
* Delegate interface for Bluetooth connection manager callbacks
*/
interface BluetoothConnectionManagerDelegate {
fun onPacketReceived(packet: BitchatPacket, peerID: String, device: BluetoothDevice?)
fun onPacketReceived(
packet: BitchatPacket,
peerID: String,
device: BluetoothDevice?,
ingressLinkID: String
)
fun onDeviceConnected(device: BluetoothDevice)
fun onDeviceDisconnected(device: BluetoothDevice)
fun onDeviceDisconnected(device: BluetoothDevice, linkID: String?)
fun onRSSIUpdated(deviceAddress: String, rssi: Int)
}

View File

@ -9,6 +9,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.UUID
/**
* Tracks all Bluetooth connections and handles cleanup
@ -31,6 +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()
/**
* Consolidated device connection information
@ -42,7 +44,9 @@ class BluetoothConnectionTracker(
val rssi: Int = Int.MIN_VALUE,
val isClient: Boolean = false,
val connectedAt: Long = System.currentTimeMillis(),
val peerID: String? = null
val peerID: String? = null,
/** Unique to this GATT connection, even when Android reuses the device address. */
val linkID: String = UUID.randomUUID().toString()
)
override fun start() {
@ -73,7 +77,11 @@ class BluetoothConnectionTracker(
*/
fun addDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
Log.d(TAG, "Tracker: Adding device connection for $deviceAddress (isClient: ${deviceConn.isClient}")
connectedDevices[deviceAddress] = deviceConn
synchronized(peerBindingLock) {
connectedDevices[deviceAddress] = deviceConn
// A mapping authenticates a GATT connection, not a reusable Bluetooth address.
addressPeerMap.remove(deviceAddress)
}
removePendingConnection(deviceAddress)
// Mark as awaiting first ANNOUNCE on this connection
firstAnnounceSeen[deviceAddress] = false
@ -83,7 +91,20 @@ class BluetoothConnectionTracker(
* Update a device connection
*/
fun updateDeviceConnection(deviceAddress: String, deviceConn: DeviceConnection) {
connectedDevices[deviceAddress] = deviceConn
synchronized(peerBindingLock) {
connectedDevices[deviceAddress] = deviceConn
}
}
fun updateDeviceConnectionIfCurrent(
deviceAddress: String,
linkID: String,
update: (DeviceConnection) -> DeviceConnection
): Boolean = synchronized(peerBindingLock) {
val current = connectedDevices[deviceAddress] ?: return@synchronized false
if (current.linkID != linkID) return@synchronized false
connectedDevices[deviceAddress] = update(current)
true
}
/**
@ -92,6 +113,17 @@ class BluetoothConnectionTracker(
fun getDeviceConnection(deviceAddress: String): DeviceConnection? {
return connectedDevices[deviceAddress]
}
fun getCurrentLinkID(deviceAddress: String): String? =
connectedDevices[deviceAddress]?.linkID
fun bindPeerIfCurrent(deviceAddress: String, linkID: String, peerID: String): Boolean =
synchronized(peerBindingLock) {
if (connectedDevices[deviceAddress]?.linkID != linkID) return@synchronized false
addressPeerMap.entries.removeIf { it.value == peerID && it.key != deviceAddress }
addressPeerMap[deviceAddress] = peerID
true
}
/**
* Get all connected devices
@ -233,13 +265,33 @@ class BluetoothConnectionTracker(
* Clean up a specific device connection
*/
fun cleanupDeviceConnection(deviceAddress: String) {
connectedDevices.remove(deviceAddress)?.let { deviceConn ->
synchronized(peerBindingLock) {
connectedDevices.remove(deviceAddress)
subscribedDevices.removeAll { it.address == deviceAddress }
addressPeerMap.remove(deviceAddress)
firstAnnounceSeen.remove(deviceAddress)
}
firstAnnounceSeen.remove(deviceAddress)
Log.d(TAG, "Cleaned up device connection for $deviceAddress")
}
fun cleanupDeviceConnectionIfCurrent(
deviceAddress: String,
expectedLinkID: String
): Boolean = synchronized(peerBindingLock) {
val current = connectedDevices[deviceAddress] ?: return@synchronized false
if (current.linkID != expectedLinkID) {
return@synchronized false
}
if (connectedDevices.remove(deviceAddress, current)) {
subscribedDevices.removeAll { it.address == deviceAddress }
addressPeerMap.remove(deviceAddress)
firstAnnounceSeen.remove(deviceAddress)
Log.d(TAG, "Cleaned up device connection for $deviceAddress")
true
} else {
false
}
}
/**
* Clean up all connections

View File

@ -532,6 +532,7 @@ class BluetoothGattClientManager(
if (!permissionManager.hasBluetoothPermissions()) return
val deviceAddress = device.address
val linkID = UUID.randomUUID().toString()
Log.i(TAG, "Connecting to bitchat device: $deviceAddress (peerID: $peerID)")
val gattCallback = object : BluetoothGattCallback() {
@ -553,11 +554,11 @@ class BluetoothGattClientManager(
}
} else {
Log.d(TAG, "Client: Cleanly disconnected from $deviceAddress")
connectionTracker.cleanupDeviceConnection(deviceAddress)
}
connectionTracker.cleanupDeviceConnectionIfCurrent(deviceAddress, linkID)
// Notify higher layers about device disconnection to update direct flags
delegate?.onDeviceDisconnected(gatt.device)
delegate?.onDeviceDisconnected(gatt.device, linkID)
connectionScope.launch {
delay(500) // CLEANUP_DELAY
@ -583,7 +584,8 @@ class BluetoothGattClientManager(
gatt = gatt,
rssi = rssi,
isClient = true,
peerID = peerID // Store the peerID discovered during scan
peerID = peerID, // Store the peerID discovered during scan
linkID = linkID
)
connectionTracker.addDeviceConnection(deviceAddress, deviceConn)
@ -602,9 +604,11 @@ class BluetoothGattClientManager(
if (service != null) {
val characteristic = service.getCharacteristic(AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID)
if (characteristic != null) {
connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn ->
val updatedConn = deviceConn.copy(characteristic = characteristic)
connectionTracker.updateDeviceConnection(deviceAddress, updatedConn)
if (connectionTracker.updateDeviceConnectionIfCurrent(
deviceAddress,
linkID
) { it.copy(characteristic = characteristic) }
) {
Log.d(TAG, "Client: Updated device connection with characteristic for $deviceAddress")
}
@ -644,7 +648,7 @@ class BluetoothGattClientManager(
if (packet != null) {
val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) }
Log.d(TAG, "Client: Parsed packet type ${packet.type} from $peerID")
delegate?.onPacketReceived(packet, peerID, gatt.device)
delegate?.onPacketReceived(packet, peerID, gatt.device, linkID)
} else {
Log.w(TAG, "Client: Failed to parse packet from ${gatt.device.address}, size: ${value.size} bytes")
Log.w(TAG, "Client: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}")
@ -657,9 +661,8 @@ class BluetoothGattClientManager(
Log.d(TAG, "Client: RSSI updated for $deviceAddress: $rssi dBm")
// Update the connection tracker with new RSSI value
connectionTracker.getDeviceConnection(deviceAddress)?.let { deviceConn ->
val updatedConn = deviceConn.copy(rssi = rssi)
connectionTracker.updateDeviceConnection(deviceAddress, updatedConn)
connectionTracker.updateDeviceConnectionIfCurrent(deviceAddress, linkID) {
it.copy(rssi = rssi)
}
} else {
Log.w(TAG, "Client: Failed to read RSSI for $deviceAddress, status: $status")

View File

@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.*
import java.util.concurrent.ConcurrentHashMap
/**
* Manages GATT server operations, advertising, and server-side connections
@ -43,6 +44,7 @@ class BluetoothGattServerManager(
// GATT server for peripheral mode
private var gattServer: BluetoothGattServer? = null
private val serverLinkIDs = ConcurrentHashMap<String, String>()
private var characteristic: BluetoothGattCharacteristic? = null
private var advertiseCallback: AdvertiseCallback? = null
private var advertiseRetryCount = 0
@ -124,6 +126,7 @@ class BluetoothGattServerManager(
// Ensure server is closed if present
gattServer?.close()
gattServer = null
serverLinkIDs.clear()
Log.i(TAG, "GATT server stopped (already inactive)")
return
}
@ -145,6 +148,7 @@ class BluetoothGattServerManager(
// Close GATT server
gattServer?.close()
gattServer = null
serverLinkIDs.clear()
Log.i(TAG, "GATT server stopped")
}
@ -178,6 +182,8 @@ class BluetoothGattServerManager(
when (newState) {
BluetoothProfile.STATE_CONNECTED -> {
Log.i(TAG, "Server: Device connected ${device.address}")
val linkID = UUID.randomUUID().toString()
serverLinkIDs[device.address] = linkID
// Get best available RSSI (scan RSSI for server connections)
val rssi = connectionTracker.getBestRSSI(device.address) ?: Int.MIN_VALUE
@ -185,7 +191,8 @@ class BluetoothGattServerManager(
val deviceConn = BluetoothConnectionTracker.DeviceConnection(
device = device,
rssi = rssi,
isClient = false
isClient = false,
linkID = linkID
)
connectionTracker.addDeviceConnection(device.address, deviceConn)
@ -198,9 +205,12 @@ class BluetoothGattServerManager(
}
BluetoothProfile.STATE_DISCONNECTED -> {
Log.i(TAG, "Server: Device disconnected ${device.address}")
connectionTracker.cleanupDeviceConnection(device.address)
val linkID = serverLinkIDs.remove(device.address)
if (linkID != null) {
connectionTracker.cleanupDeviceConnectionIfCurrent(device.address, linkID)
}
// Notify delegate about device disconnection so higher layers can update direct flags
delegate?.onDeviceDisconnected(device)
delegate?.onDeviceDisconnected(device, linkID)
}
}
}
@ -236,11 +246,25 @@ class BluetoothGattServerManager(
if (characteristic.uuid == AppConstants.Mesh.Gatt.CHARACTERISTIC_UUID) {
Log.i(TAG, "Server: Received packet from ${device.address}, size: ${value.size} bytes")
val linkID = serverLinkIDs[device.address]
if (linkID == null) {
Log.w(TAG, "Server: Dropping packet from stale connection ${device.address}")
if (responseNeeded) {
gattServer?.sendResponse(
device,
requestId,
BluetoothGatt.GATT_FAILURE,
0,
null
)
}
return
}
val packet = BitchatPacket.fromBinaryData(value)
if (packet != null) {
val peerID = packet.senderID.take(8).toByteArray().joinToString("") { "%02x".format(it) }
Log.d(TAG, "Server: Parsed packet type ${packet.type} from $peerID")
delegate?.onPacketReceived(packet, peerID, device)
delegate?.onPacketReceived(packet, peerID, device, linkID)
} else {
Log.w(TAG, "Server: Failed to parse packet from ${device.address}, size: ${value.size} bytes")
Log.w(TAG, "Server: Packet data: ${value.joinToString(" ") { "%02x".format(it) }}")

View File

@ -4,6 +4,8 @@ import android.content.Context
import android.util.Log
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.protocol.MessagePadding
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.model.IdentityAnnouncement
@ -19,6 +21,7 @@ import com.bitchat.android.services.VerificationService
import com.bitchat.android.service.TransportBridgeService
import kotlinx.coroutines.*
import java.util.*
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.sign
import kotlin.random.Random
@ -40,6 +43,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
companion object {
private const val TAG = "BluetoothMeshService"
private const val BLE_AUTHENTICATION_TIMEOUT_MS = 20_000L
private val MAX_TTL: UByte = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
}
@ -50,6 +54,58 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
val myPeerID: String = encryptionService.getIdentityFingerprint().take(16)
private val peerManager = PeerManager()
private val fragmentManager = FragmentManager()
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
private val authenticatedPeerState by lazy {
AuthenticatedPeerStateCoordinator(
scope = serviceScope,
authenticatedSessionProvider = encryptionService::getAuthenticatedSession,
withAuthenticatedSession = encryptionService::withAuthenticatedSession,
store = authenticatedPeerStateStore,
localStateProvider = {
AuthenticatedPeerState(
PeerCapabilities.LOCAL_SUPPORTED,
requireNotNull(encryptionService.getSigningPublicKey())
)
},
applyAuthenticatedState = peerManager::applyAuthenticatedPeerState,
sendState = ::sendAuthenticatedPeerState,
onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) }
)
}
private val privateMediaSecurity by lazy { PrivateMediaSecurityController(
authenticatedSessionProvider = encryptionService::getAuthenticatedSession,
peerStateStatusProvider = authenticatedPeerState::status,
isPrivateMediaPinned = authenticatedPeerState::isPrivateMediaPinned
) }
private val privateMediaPreparer by lazy {
PrivateMediaTransferPreparer(
senderID = hexStringToByteArray(myPeerID),
ttl = MAX_TTL,
policyProvider = privateMediaSecurity::sendPolicy,
encrypt = { plaintext, peerID, authenticatedSession ->
try {
PrivateMediaEncryptionResult.Success(
encryptionService.encryptForSession(
plaintext,
peerID,
authenticatedSession
)
)
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionGenerationChanged) {
PrivateMediaEncryptionResult.GenerationChanged
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotFound) {
PrivateMediaEncryptionResult.GenerationChanged
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotEstablished) {
PrivateMediaEncryptionResult.GenerationChanged
} catch (_: Exception) {
PrivateMediaEncryptionResult.Failed
}
},
finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict,
fragment = fragmentManager::createFragments
)
}
private val securityManager = SecurityManager(encryptionService, myPeerID)
private val storeForwardManager = StoreForwardManager()
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
@ -70,10 +126,11 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
var delegate: BluetoothMeshDelegate? = null
// Coroutines
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
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")
@ -127,10 +184,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
connectionManager.sendPacketToPeer(peerID, packet)
}
private fun broadcastRoutedPacket(routed: RoutedPacket) {
if (!isBleTransportEnabled()) return
connectionManager.broadcastPacket(routed)
private fun broadcastRoutedPacket(routed: RoutedPacket): Boolean {
if (!isBleTransportEnabled()) return false
val queued = connectionManager.broadcastPacket(routed)
if (!queued) return false
TransportBridgeService.broadcast("BLE", routed)
return true
}
private fun isBleTransportEnabled(): Boolean {
@ -201,6 +260,8 @@ 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
try { com.bitchat.android.services.meshgraph.MeshGraphService.getInstance().removePeer(peerID) } catch (_: Exception) { }
@ -217,7 +278,34 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// SecurityManager delegate for key exchange notifications
securityManager.delegate = object : SecurityManagerDelegate {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
override fun onKeyExchangeCompleted(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
authenticatedSessionToken: ByteArray,
directRelayAddress: String?,
ingressLinkID: String?
) {
authenticatedPeerState.onSessionAuthenticated(
peerID,
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 {
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
@ -249,6 +337,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
override fun getPeerInfo(peerID: String): PeerInfo? {
return peerManager.getPeerInfo(peerID)
}
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
}
// StoreForwardManager delegates
@ -297,10 +388,17 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
return peerManager.getPeerInfo(peerID)
}
override fun updatePeerInfo(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean): Boolean {
return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
override fun updatePeerInfoFromVerifiedAnnouncement(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean, capabilities: com.bitchat.android.model.PeerCapabilities?): Boolean {
return peerManager.updatePeerInfoFromVerifiedAnnouncement(
peerID,
nickname,
noisePublicKey,
signingPublicKey,
isVerified,
capabilities
)
}
// Packet operations
override fun sendPacket(packet: BitchatPacket) {
// Sign the packet before broadcasting
@ -325,13 +423,19 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
return securityManager.encryptForPeer(data, recipientPeerID)
}
override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? {
override fun decryptFromPeer(
encryptedData: ByteArray,
senderPeerID: String
): com.bitchat.android.noise.NoiseDecryptionResult? {
return securityManager.decryptFromPeer(encryptedData, senderPeerID)
}
override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean {
return encryptionService.verifyEd25519Signature(signature, data, publicKey)
}
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
// Noise protocol operations
override fun hasNoiseSession(peerID: String): Boolean {
@ -375,34 +479,13 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
null
}
}
override fun updatePeerIDBinding(newPeerID: String, nickname: String,
publicKey: ByteArray, previousPeerID: String?) {
Log.d(TAG, "Updating peer ID binding: $newPeerID (was: $previousPeerID) with nickname: $nickname and public key: ${publicKey.toHexString().take(16)}...")
// Update peer mapping in the PeerManager for peer ID rotation support
peerManager.addOrUpdatePeer(newPeerID, nickname)
// Store fingerprint for the peer via centralized fingerprint manager
val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
try {
com.bitchat.android.identity.SecureIdentityStateManager(context)
.cachePeerNoiseKey(newPeerID, publicKey.toHexString())
} catch (_: Exception) { }
// Index existing Nostr mapping by the new peerID if we have it
try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub ->
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub)
}
} catch (_: Exception) { }
// If there was a previous peer ID, remove it to avoid duplicates
previousPeerID?.let { oldPeerID ->
peerManager.removePeer(oldPeerID)
}
Log.d(TAG, "Updated peer ID binding: $newPeerID (was: $previousPeerID), fingerprint: ${fingerprint.take(16)}...")
override fun onAuthenticatedPeerStateReceived(
peerID: String,
state: AuthenticatedPeerState,
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
) {
authenticatedPeerState.receive(peerID, state, authenticatedSession)
}
// Message operations
@ -496,35 +579,48 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
serviceScope.launch { messageHandler.handleNoiseEncrypted(routed) }
}
override fun handleAnnounce(routed: RoutedPacket) {
serviceScope.launch {
// Process the announce
val isFirst = messageHandler.handleAnnounce(routed)
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
val result = messageHandler.handleAnnounceWithResult(routed)
if (result !is AnnounceHandlingResult.Accepted) return false
// Map device address -> peerID based on TTL (max TTL = direct neighbor)
// Matches iOS logic: any announce with max TTL on a link defines the direct peer
val deviceAddress = routed.relayAddress
val pid = routed.peerID
if (deviceAddress != null && pid != null) {
// Check if this is a direct connection (MAX TTL)
// Note: packet.ttl is UByte, compare with AppConstants.MESSAGE_TTL_HOPS
val isDirect = routed.packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
if (isDirect) {
// Bind or rebind this device address to the announcing peer
connectionManager.addressPeerMap[deviceAddress] = pid
Log.d(TAG, "Mapped device $deviceAddress to peer $pid (TTL=${routed.packet.ttl})")
// Mark as directly connected - refresh UI state
try { peerManager.refreshPeerList() } catch (_: Exception) { }
// Initial sync for this direct peer
try { gossipSyncManager.scheduleInitialSyncToPeer(pid, 1_000) } catch (_: Exception) { }
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}")
}
// Track for sync
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
}
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
return true
}
override fun handleMessage(routed: RoutedPacket) {
@ -582,7 +678,12 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// BluetoothConnectionManager delegates
connectionManager.delegate = object : BluetoothConnectionManagerDelegate {
override fun onPacketReceived(packet: BitchatPacket, peerID: String, device: android.bluetooth.BluetoothDevice?) {
override fun onPacketReceived(
packet: BitchatPacket,
peerID: String,
device: android.bluetooth.BluetoothDevice?,
ingressLinkID: String
) {
// Log incoming for debug graphs (do not double-count anywhere else)
try {
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance().logIncoming(
@ -593,7 +694,9 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
myPeerID = myPeerID
)
} catch (_: Exception) { }
packetProcessor.processPacket(RoutedPacket(packet, peerID, device?.address))
packetProcessor.processPacket(
RoutedPacket(packet, peerID, device?.address, ingressLinkID = ingressLinkID)
)
}
override fun onDeviceConnected(device: android.bluetooth.BluetoothDevice) {
@ -613,25 +716,20 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
} catch (_: Exception) { }
}
override fun onDeviceDisconnected(device: android.bluetooth.BluetoothDevice) {
override fun onDeviceDisconnected(
device: android.bluetooth.BluetoothDevice,
linkID: String?
) {
Log.d(TAG, "Device disconnected: ${device.address}")
val addr = device.address
// Remove mapping and, if that was the last direct path for the peer, clear direct flag
val peer = connectionManager.addressPeerMap[addr]
// ConnectionTracker has already removed the address mapping; be defensive either way
connectionManager.addressPeerMap.remove(addr)
clearProvisionalBleClaimsForLink(addr, linkID)
// refresh peer list on disconnect.
try { peerManager.refreshPeerList() } catch (_: Exception) { }
if (peer != null) {
// Verbose debug: device disconnected
try {
val nick = peerManager.getPeerNickname(peer) ?: "unknown"
com.bitchat.android.ui.debug.DebugSettingsManager.getInstance()
.logPeerDisconnection(peer, nick, addr)
} catch (_: Exception) { }
}
// ConnectionTracker already removes an authenticated 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.
}
override fun onRSSIUpdated(deviceAddress: String, rssi: Int) {
@ -642,6 +740,26 @@ 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)
if (provisionalBleClaims.remove(peerID, claim)) {
Log.d(TAG, "Expired provisional BLE authentication claim for $peerID")
}
}
}
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
@ -792,6 +910,32 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
/**
* Send a file over mesh as a broadcast MESSAGE (public mesh timeline/channels).
*/
private fun sendAuthenticatedPeerState(
peerID: String,
state: AuthenticatedPeerState,
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
): Boolean {
val plaintext = NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode()
val ciphertext = securityManager.encryptForPeer(
plaintext,
peerID,
authenticatedSession
) ?: return false
val packet = BitchatPacket(
version = if (ciphertext.size > 0xFFFF) 2u else 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = ciphertext,
ttl = MAX_TTL
)
val signed = signPacketBeforeBroadcast(packet)
if (signed.signature?.size != 64) return false
broadcastRoutedPacket(RoutedPacket(signed))
return true
}
fun sendFileBroadcast(file: com.bitchat.android.model.BitchatFilePacket) {
try {
Log.d(TAG, "📤 sendFileBroadcast: name=${file.fileName}, size=${file.fileSize}")
@ -824,70 +968,65 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
}
/**
* Send a file as an encrypted private message using Noise protocol
*/
/** Safe non-interactive entry point: encrypted sends commit; legacy sends require UI consent. */
fun sendFilePrivate(recipientPeerID: String, file: com.bitchat.android.model.BitchatFilePacket) {
try {
Log.d(TAG, "📤 sendFilePrivate (ENCRYPTED): to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}")
serviceScope.launch {
// Check if we have an established Noise session
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
try {
// Encode the file packet as TLV
val filePayload = file.encode()
if (filePayload == null) {
Log.e(TAG, "❌ Failed to encode file packet for private send")
return@launch
}
Log.d(TAG, "📦 Encoded file TLV: ${filePayload.size} bytes")
// Create NoisePayload wrapper (type byte + file TLV data) - same as iOS
val noisePayload = com.bitchat.android.model.NoisePayload(
type = com.bitchat.android.model.NoisePayloadType.FILE_TRANSFER,
data = filePayload
)
// Encrypt the payload using Noise
val encrypted = encryptionService.encrypt(noisePayload.encode(), recipientPeerID)
if (encrypted == null) {
Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID")
return@launch
}
Log.d(TAG, "🔐 Encrypted file payload: ${encrypted.size} bytes")
// Create NOISE_ENCRYPTED packet (not FILE_TRANSFER!)
val packet = BitchatPacket(
version = if (encrypted.size > 0xFFFF) 2u else 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = hexStringToByteArray(myPeerID),
recipientID = hexStringToByteArray(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = encrypted,
signature = null,
ttl = com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
)
// Sign and send the encrypted packet
val signed = signPacketBeforeBroadcast(packet)
// Use a stable transferId based on the unencrypted file TLV payload for progress tracking
val transferId = sha256Hex(filePayload)
broadcastRoutedPacket(RoutedPacket(signed, transferId = transferId))
Log.d(TAG, "✅ Sent encrypted file to $recipientPeerID")
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to encrypt file for $recipientPeerID: ${e.message}", e)
}
} else {
// No session - initiate handshake but don't queue file
Log.w(TAG, "⚠️ No Noise session with $recipientPeerID for file transfer, initiating handshake")
messageHandler.delegate?.initiateNoiseHandshake(recipientPeerID)
}
val payload = file.encode() ?: return
when (val prepared = prepareFilePrivate(
recipientPeerID,
file,
sha256Hex(payload),
allowLegacyFallback = false
)) {
is PrivateMediaPreparation.Ready -> prepared.transfer.commit()
is PrivateMediaPreparation.RequiresLegacyConsent ->
Log.w(TAG, "Private media requires explicit one-shot legacy consent")
PrivateMediaPreparation.NeedsHandshake -> {
Log.i(TAG, "Private media needs a Noise handshake; initiating without sending")
initiateNoiseHandshake(recipientPeerID)
}
PrivateMediaPreparation.AwaitingPeerState -> Unit
is PrivateMediaPreparation.Rejected ->
Log.w(TAG, "Private media blocked: ${prepared.reason}")
}
}
fun prepareFilePrivate(
recipientPeerID: String,
file: com.bitchat.android.model.BitchatFilePacket,
transferId: String,
allowLegacyFallback: Boolean
): PrivateMediaPreparation {
return when (val outcome = privateMediaPreparer.prepare(
recipientPeerID = recipientPeerID,
recipientID = hexStringToByteArray(recipientPeerID),
file = file,
allowLegacyFallback = allowLegacyFallback
)) {
is PrivateMediaBuildOutcome.RequiresLegacyConsent ->
PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning)
PrivateMediaBuildOutcome.NeedsHandshake ->
PrivateMediaPreparation.NeedsHandshake
PrivateMediaBuildOutcome.AwaitingPeerState ->
PrivateMediaPreparation.AwaitingPeerState
is PrivateMediaBuildOutcome.Rejected ->
PrivateMediaPreparation.Rejected(outcome.reason)
is PrivateMediaBuildOutcome.Ready -> {
val built = outcome.built
val routed = RoutedPacket(
packet = built.packet,
transferId = transferId,
preparedPackets = built.fragments
)
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(transferId, built.wireMode) {
if (!isActive || terminated || !isBleTransportEnabled()) {
false
} else {
broadcastRoutedPacket(routed)
}
}
)
}
} catch (e: Exception) {
Log.e(TAG, "❌ sendFilePrivate failed: ${e.message}", e)
Log.e(TAG, "❌ File: to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}")
}
}
@ -1100,7 +1239,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
// Create iOS-compatible IdentityAnnouncement with TLV encoding
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
var tlvPayload = announcement.encode()
if (tlvPayload == null) {
Log.e(TAG, "Failed to encode announcement as TLV")
@ -1163,7 +1302,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
}
// Create iOS-compatible IdentityAnnouncement with TLV encoding
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
var tlvPayload = announcement.encode()
if (tlvPayload == null) {
Log.e(TAG, "Failed to encode peer announcement as TLV")
@ -1404,24 +1543,44 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
/**
* Sign packet before broadcasting using our signing private key
*/
private fun applyRouteIfAvailable(packet: BitchatPacket): BitchatPacket {
return try {
val recipient = packet.recipientID
if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) {
val destination = recipient.joinToString("") { byte -> "%02x".format(byte) }
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(
myPeerID,
destination
)
if (path != null && path.size >= 3) {
val intermediates = path.subList(1, path.size - 1)
packet.copy(
route = intermediates.map(::hexStringToByteArray),
version = 2u
)
} else {
packet.copy(route = null)
}
} else {
packet
}
} catch (_: Exception) {
packet
}
}
/** Private media must never fall back to an unsigned packet. */
private fun routeAndSignPrivateMediaStrict(packet: BitchatPacket): BitchatPacket? {
val routed = applyRouteIfAvailable(packet)
val signingBytes = routed.toBinaryDataForSigning() ?: return null
val signature = encryptionService.signData(signingBytes) ?: return null
return routed.copy(signature = signature)
}
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
return try {
// Optionally compute and attach a source route for addressed packets
val withRoute = try {
val rec = packet.recipientID
if (rec != null && !rec.contentEquals(SpecialRecipients.BROADCAST)) {
val dest = rec.joinToString("") { b -> "%02x".format(b) }
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, dest)
if (path != null && path.size >= 3) {
// Exclude first (sender) and last (recipient); only intermediates
val intermediates = path.subList(1, path.size - 1)
val hopsBytes = intermediates.map { hexStringToByteArray(it) }
Log.d(TAG, "✅ Signed packet type ${packet.type} (route ${hopsBytes.size} hops: $intermediates)")
// Attach route and upgrade to v2 (required for HAS_ROUTE flag)
packet.copy(route = hopsBytes, version = 2u)
} else packet.copy(route = null)
} else packet
} catch (_: Exception) { packet }
val withRoute = applyRouteIfAvailable(packet)
// Get the canonical packet data for signing (without signature)
val packetDataForSigning = withRoute.toBinaryDataForSigning()

View File

@ -136,8 +136,8 @@ class BluetoothPacketBroadcaster(
routed: RoutedPacket,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
) {
fragmentingSender.send(routed, "BLE broadcast") { packet ->
): Boolean {
return fragmentingSender.send(routed, "BLE broadcast") { packet ->
broadcastSinglePacket(packet, gattServer, characteristic)
true
}
@ -163,6 +163,28 @@ class BluetoothPacketBroadcaster(
}
}
fun sendPacketToLink(
routed: RoutedPacket,
deviceAddress: String,
linkID: String,
gattServer: BluetoothGattServer?,
characteristic: BluetoothGattCharacteristic?
): Boolean = fragmentingSender.send(routed, "BLE link $deviceAddress") { packet ->
val data = packet.packet.toBinaryData(
padding = BLEPacketPaddingPolicy.shouldPadForBLE(packet.packet.type)
) ?: return@send false
val currentLink = connectionTracker.getDeviceConnection(deviceAddress)
?.takeIf { it.linkID == linkID }
?: return@send false
if (currentLink.isClient) {
return@send writeToDeviceConn(currentLink, data)
}
val serverTarget = connectionTracker.getSubscribedDevices()
.firstOrNull { it.address == deviceAddress }
?: return@send false
notifyDevice(serverTarget, data, gattServer, characteristic)
}
private fun sendSinglePacketToPeer(
routed: RoutedPacket,
targetPeerID: String,

View File

@ -51,97 +51,123 @@ class FragmentManager {
* Create fragments from a large packet - 100% iOS Compatible
* Matches iOS sendFragmentedPacket() implementation exactly
*/
fun createFragments(packet: BitchatPacket): List<BitchatPacket> {
/** Generic/public packets retain the full UInt16 fragment-count range. */
fun createFragments(packet: BitchatPacket): List<BitchatPacket> =
createFragments(packet, 0xFFFF)
/**
* Create a fragment plan with a caller-selected bound. Private media uses
* 256 for cross-platform admission; generic/public traffic retains the
* UInt16 wire limit.
*/
fun createFragments(packet: BitchatPacket, maxFragments: Int): List<BitchatPacket> {
try {
if (maxFragments !in 1..0xFFFF) {
Log.w(TAG, "Rejecting invalid outbound fragment limit: $maxFragments")
return emptyList()
}
Log.d(TAG, "🔀 Creating fragments for packet type ${packet.type}, payload: ${packet.payload.size} bytes")
val encoded = packet.toBinaryData()
val encoded = packet.toBinaryData()
if (encoded == null) {
Log.e(TAG, "❌ Failed to encode packet to binary data")
return emptyList()
}
Log.d(TAG, "📦 Encoded to ${encoded.size} bytes")
// Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix
val fullData = try {
// Fragment the unpadded frame; each fragment will be encoded (and padded) independently - iOS fix
val fullData = try {
MessagePadding.unpad(encoded)
} catch (e: Exception) {
Log.e(TAG, "❌ Failed to unpad data: ${e.message}", e)
return emptyList()
}
Log.d(TAG, "📏 Unpadded to ${fullData.size} bytes")
// iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue
if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) {
return listOf(packet) // No fragmentation needed
}
val fragments = mutableListOf<BitchatPacket>()
// iOS: let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
val fragmentID = FragmentPayload.generateFragmentID()
// iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize)
// Calculate dynamic fragment size to fit in MTU (512)
// Packet = Header + Sender + Recipient + Route + FragmentHeader + Payload + PaddingBuffer
val hasRoute = packet.route != null
val version = if (hasRoute) 2 else 1
val headerSize = if (version == 2) 15 else 13
val senderSize = 8
val recipientSize = if (packet.recipientID != null) 8 else 0
// Route: 1 byte count + 8 bytes per hop
val routeSize = if (hasRoute) (1 + (packet.route?.size ?: 0) * 8) else 0
val fragmentHeaderSize = 13 // FragmentPayload header
val paddingBuffer = 16 // MessagePadding.optimalBlockSize adds 16 bytes overhead
// 512 - Overhead
val packetOverhead = headerSize + senderSize + recipientSize + routeSize + fragmentHeaderSize + paddingBuffer
val maxDataSize = (512 - packetOverhead).coerceAtMost(MAX_FRAGMENT_SIZE)
if (maxDataSize <= 0) {
Log.e(TAG, "❌ Calculated maxDataSize is non-positive ($maxDataSize). Route too large?")
return emptyList()
}
// iOS logic: if data.count > 512 && packet.type != MessageType.fragment.rawValue
if (fullData.size <= FRAGMENT_SIZE_THRESHOLD) {
return listOf(packet) // No fragmentation needed
}
Log.d(TAG, "📏 Dynamic fragment size: $maxDataSize (MAX: $MAX_FRAGMENT_SIZE, Overhead: $packetOverhead)")
val fragments = mutableListOf<BitchatPacket>()
val fragmentChunks = stride(0, fullData.size, maxDataSize) { offset ->
val endOffset = minOf(offset + maxDataSize, fullData.size)
fullData.sliceArray(offset..<endOffset)
}
Log.d(TAG, "Creating ${fragmentChunks.size} fragments for ${fullData.size} byte packet (iOS compatible)")
// iOS: for (index, fragment) in fragments.enumerated()
for (index in fragmentChunks.indices) {
val fragmentData = fragmentChunks[index]
// Create iOS-compatible fragment payload
val fragmentPayload = FragmentPayload(
fragmentID = fragmentID,
index = index,
total = fragmentChunks.size,
originalType = packet.type,
data = fragmentData
)
// iOS: MessageType.fragment.rawValue (single fragment type)
// Fix: Fragments must inherit source route and use v2 if routed
val fragmentPacket = BitchatPacket(
version = if (packet.route != null) 2u else 1u,
type = MessageType.FRAGMENT.value,
ttl = packet.ttl,
senderID = packet.senderID,
recipientID = packet.recipientID,
timestamp = packet.timestamp,
payload = fragmentPayload.encode(),
route = packet.route,
signature = null // iOS: signature: nil
)
fragments.add(fragmentPacket)
}
Log.d(TAG, "✅ Created ${fragments.size} fragments successfully")
// iOS: let fragmentID = Data((0..<8).map { _ in UInt8.random(in: 0...255) })
val fragmentID = FragmentPayload.generateFragmentID()
// iOS: stride(from: 0, to: fullData.count, by: maxFragmentSize)
// Calculate dynamic fragment size to fit in MTU (512)
// Packet = Header + Sender + Recipient + Route + FragmentHeader + Payload + PaddingBuffer
val hasRoute = packet.route != null
val version = if (hasRoute) 2 else 1
val headerSize = if (version == 2) 15 else 13
val senderSize = 8
val recipientSize = if (packet.recipientID != null) 8 else 0
// Route: 1 byte count + 8 bytes per hop
val routeSize = if (hasRoute) (1 + (packet.route?.size ?: 0) * 8) else 0
val fragmentHeaderSize = 13 // FragmentPayload header
val paddingBuffer = 16 // MessagePadding.optimalBlockSize adds 16 bytes overhead
// 512 - Overhead
val packetOverhead = headerSize + senderSize + recipientSize + routeSize + fragmentHeaderSize + paddingBuffer
val maxDataSize = (512 - packetOverhead).coerceAtMost(MAX_FRAGMENT_SIZE)
if (maxDataSize <= 0) {
Log.e(TAG, "❌ Calculated maxDataSize is non-positive ($maxDataSize). Route too large?")
return emptyList()
}
Log.d(TAG, "📏 Dynamic fragment size: $maxDataSize (MAX: $MAX_FRAGMENT_SIZE, Overhead: $packetOverhead)")
val requiredFragments = (
(fullData.size.toLong() + maxDataSize.toLong() - 1L) / maxDataSize.toLong()
).toInt()
if (requiredFragments > maxFragments) {
Log.w(
TAG,
"Rejecting outbound packet requiring $requiredFragments fragments " +
"(caller cap: $maxFragments)"
)
return emptyList()
}
// Do not allocate chunk copies until the plan passes the hard bound.
val fragmentChunks = stride(0, fullData.size, maxDataSize) { offset ->
val endOffset = minOf(offset + maxDataSize, fullData.size)
fullData.sliceArray(offset..<endOffset)
}
Log.d(TAG, "Creating ${fragmentChunks.size} fragments for ${fullData.size} byte packet (iOS compatible)")
// iOS: for (index, fragment) in fragments.enumerated()
for (index in fragmentChunks.indices) {
val fragmentData = fragmentChunks[index]
// Create iOS-compatible fragment payload
val fragmentPayload = FragmentPayload(
fragmentID = fragmentID,
index = index,
total = fragmentChunks.size,
originalType = packet.type,
data = fragmentData
)
// iOS: MessageType.fragment.rawValue (single fragment type)
// Fix: Fragments must inherit source route and use v2 if routed
val fragmentPacket = BitchatPacket(
version = if (packet.route != null) 2u else 1u,
type = MessageType.FRAGMENT.value,
ttl = packet.ttl,
senderID = packet.senderID,
recipientID = packet.recipientID,
timestamp = packet.timestamp,
payload = fragmentPayload.encode(),
route = packet.route,
signature = null // iOS: signature: nil
)
fragments.add(fragmentPacket)
}
Log.d(TAG, "✅ Created ${fragments.size} fragments successfully")
return fragments
} catch (e: Exception) {
Log.e(TAG, "❌ Fragment creation failed: ${e.message}", e)

View File

@ -31,14 +31,20 @@ class FragmentingPacketSender(
sendSingle: (RoutedPacket) -> Boolean
): Boolean {
val transferId = transferIdFor(routed)
val packets = packetsForTransport(routed.packet) ?: return false
val packets = packetsForTransport(routed) ?: return false
val total = packets.size
if (total <= 1) {
if (transferId != null) {
TransferProgressManager.start(transferId, 1)
}
val sent = sendSingle(routed.copy(packet = packets.first(), transferId = transferId))
val sent = sendSingle(
routed.copy(
packet = packets.first(),
transferId = transferId,
preparedPackets = null
)
)
if (sent && transferId != null) {
TransferProgressManager.progress(transferId, 1, 1)
TransferProgressManager.complete(transferId, 1)
@ -57,7 +63,11 @@ class FragmentingPacketSender(
if (!isActive) return@launch
if (transferId != null && transferJobs[transferId]?.isCancelled == true) return@launch
val fragment = routed.copy(packet = packet, transferId = transferId)
val fragment = routed.copy(
packet = packet,
transferId = transferId,
preparedPackets = null
)
val delivered = try {
sendSingle(fragment)
} catch (e: Exception) {
@ -98,7 +108,17 @@ class FragmentingPacketSender(
return true
}
private fun packetsForTransport(packet: BitchatPacket): List<BitchatPacket>? {
private fun packetsForTransport(routed: RoutedPacket): List<BitchatPacket>? {
routed.preparedPackets?.let { prepared ->
if (prepared.isEmpty() ||
prepared.size > com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID) {
Log.e(logTag, "Rejected invalid prepared fragment plan (${prepared.size} packets)")
return null
}
return prepared
}
val packet = routed.packet
if (packet.type == MessageType.FRAGMENT.value) {
return listOf(packet)
}

View File

@ -5,6 +5,8 @@ import android.util.Log
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
@ -41,8 +43,8 @@ class MeshCore(
) {
data class Hooks(
val onMessageReceived: ((BitchatMessage) -> Unit)? = null,
val onPeerIdBindingUpdated: ((String, String, ByteArray, String?) -> 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,
@ -51,6 +53,57 @@ class MeshCore(
private val peerManager = PeerManager()
val fragmentManager = FragmentManager()
private val authenticatedPeerStateStore = SecureAuthenticatedPeerStateStore(context)
private val authenticatedPeerState by lazy {
AuthenticatedPeerStateCoordinator(
scope = scope,
authenticatedSessionProvider = encryptionService::getAuthenticatedSession,
withAuthenticatedSession = encryptionService::withAuthenticatedSession,
store = authenticatedPeerStateStore,
localStateProvider = {
AuthenticatedPeerState(
PeerCapabilities.LOCAL_SUPPORTED,
requireNotNull(encryptionService.getSigningPublicKey())
)
},
applyAuthenticatedState = peerManager::applyAuthenticatedPeerState,
sendState = ::sendAuthenticatedPeerState,
onResolution = { peerID -> delegate?.didResolvePrivateMediaPolicy(peerID) }
)
}
private val privateMediaSecurity by lazy { PrivateMediaSecurityController(
authenticatedSessionProvider = encryptionService::getAuthenticatedSession,
peerStateStatusProvider = authenticatedPeerState::status,
isPrivateMediaPinned = authenticatedPeerState::isPrivateMediaPinned
) }
private val privateMediaPreparer by lazy {
PrivateMediaTransferPreparer(
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
ttl = maxTtl,
policyProvider = privateMediaSecurity::sendPolicy,
encrypt = { plaintext, peerID, authenticatedSession ->
try {
PrivateMediaEncryptionResult.Success(
encryptionService.encryptForSession(
plaintext,
peerID,
authenticatedSession
)
)
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionGenerationChanged) {
PrivateMediaEncryptionResult.GenerationChanged
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotFound) {
PrivateMediaEncryptionResult.GenerationChanged
} catch (_: com.bitchat.android.noise.NoiseSessionError.SessionNotEstablished) {
PrivateMediaEncryptionResult.GenerationChanged
} catch (_: Exception) {
PrivateMediaEncryptionResult.Failed
}
},
finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict,
fragment = fragmentManager::createFragments
)
}
private val securityManager = SecurityManager(encryptionService, myPeerID)
private val storeForwardManager = StoreForwardManager()
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
@ -117,8 +170,20 @@ class MeshCore(
packetProcessor.shutdown()
}
fun processIncoming(packet: BitchatPacket, peerID: String?, relayAddress: String?) {
packetProcessor.processPacket(RoutedPacket(packet, peerID, relayAddress))
fun processIncoming(
packet: BitchatPacket,
peerID: String?,
relayAddress: String?,
ingressLinkID: String? = null
) {
packetProcessor.processPacket(
RoutedPacket(
packet = packet,
peerID = peerID,
relayAddress = relayAddress,
ingressLinkID = ingressLinkID
)
)
}
fun sendFromBridge(packet: RoutedPacket) {
@ -150,6 +215,7 @@ class MeshCore(
}
override fun onPeerRemoved(peerID: String) {
authenticatedPeerState.clear(peerID)
try { gossipSyncManager.removeAnnouncementForPeer(peerID) } catch (_: Exception) { }
try { encryptionService.removePeer(peerID) } catch (_: Exception) { }
try { peerManager.refreshPeerList() } catch (_: Exception) { }
@ -157,7 +223,26 @@ class MeshCore(
}
securityManager.delegate = object : SecurityManagerDelegate {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
override fun onKeyExchangeCompleted(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
authenticatedSessionToken: ByteArray,
directRelayAddress: String?,
ingressLinkID: String?
) {
authenticatedPeerState.onSessionAuthenticated(
peerID,
authenticatedRemoteStaticKey,
authenticatedSessionToken
)
if (directRelayAddress != null && ingressLinkID != null) {
hooks.onDirectNoiseAuthenticated?.invoke(
peerID,
directRelayAddress,
ingressLinkID,
authenticatedRemoteStaticKey
)
}
scope.launch {
delay(100)
sendAnnouncementToPeer(peerID)
@ -180,6 +265,9 @@ class MeshCore(
}
override fun getPeerInfo(peerID: String): PeerInfo? = peerManager.getPeerInfo(peerID)
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
}
storeForwardManager.delegate = object : StoreForwardManagerDelegate {
@ -225,14 +313,22 @@ class MeshCore(
return peerManager.getPeerInfo(peerID)
}
override fun updatePeerInfo(
override fun updatePeerInfoFromVerifiedAnnouncement(
peerID: String,
nickname: String,
noisePublicKey: ByteArray,
signingPublicKey: ByteArray,
isVerified: Boolean
isVerified: Boolean,
capabilities: com.bitchat.android.model.PeerCapabilities?
): Boolean {
return peerManager.updatePeerInfo(peerID, nickname, noisePublicKey, signingPublicKey, isVerified)
return peerManager.updatePeerInfoFromVerifiedAnnouncement(
peerID,
nickname,
noisePublicKey,
signingPublicKey,
isVerified,
capabilities
)
}
override fun sendPacket(packet: BitchatPacket) {
@ -256,7 +352,10 @@ class MeshCore(
return securityManager.encryptForPeer(data, recipientPeerID)
}
override fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? {
override fun decryptFromPeer(
encryptedData: ByteArray,
senderPeerID: String
): com.bitchat.android.noise.NoiseDecryptionResult? {
return securityManager.decryptFromPeer(encryptedData, senderPeerID)
}
@ -264,6 +363,9 @@ class MeshCore(
return encryptionService.verifyEd25519Signature(signature, data, publicKey)
}
override fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? =
authenticatedPeerState.persistedSigningKeyFor(noisePublicKey)
override fun hasNoiseSession(peerID: String): Boolean {
return encryptionService.hasEstablishedSession(peerID)
}
@ -280,24 +382,12 @@ class MeshCore(
}
}
override fun updatePeerIDBinding(
newPeerID: String,
nickname: String,
publicKey: ByteArray,
previousPeerID: String?
override fun onAuthenticatedPeerStateReceived(
peerID: String,
state: AuthenticatedPeerState,
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
) {
peerManager.addOrUpdatePeer(newPeerID, nickname)
val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
try {
com.bitchat.android.identity.SecureIdentityStateManager(context)
.cachePeerNoiseKey(newPeerID, publicKey.toHexString())
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub ->
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub)
}
} catch (_: Exception) { }
previousPeerID?.let { peerManager.removePeer(it) }
Log.d("MeshCore", "Updated peer ID binding: $newPeerID fp=${fingerprint.take(16)}")
hooks.onPeerIdBindingUpdated?.invoke(newPeerID, nickname, publicKey, previousPeerID)
authenticatedPeerState.receive(peerID, state, authenticatedSession)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
@ -359,12 +449,12 @@ class MeshCore(
scope.launch { messageHandler.handleNoiseEncrypted(routed) }
}
override fun handleAnnounce(routed: RoutedPacket) {
scope.launch {
val isFirst = messageHandler.handleAnnounce(routed)
hooks.onAnnounceProcessed?.invoke(routed, isFirst)
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
}
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
val result = messageHandler.handleAnnounceWithResult(routed)
if (result !is AnnounceHandlingResult.Accepted) return false
hooks.onAnnounceProcessed?.invoke(routed, result.isFirst)
try { gossipSyncManager.onPublicPacketSeen(routed.packet) } catch (_: Exception) { }
return true
}
override fun handleMessage(routed: RoutedPacket) {
@ -437,6 +527,32 @@ class MeshCore(
}
}
private fun sendAuthenticatedPeerState(
peerID: String,
state: AuthenticatedPeerState,
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
): Boolean {
val plaintext = NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode()
val ciphertext = securityManager.encryptForPeer(
plaintext,
peerID,
authenticatedSession
) ?: return false
val packet = BitchatPacket(
version = if (ciphertext.size > 0xFFFF) 2u else 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = MeshPacketUtils.hexStringToByteArray(peerID),
timestamp = System.currentTimeMillis().toULong(),
payload = ciphertext,
ttl = maxTtl
)
val signed = signPacketBeforeBroadcast(packet)
if (signed.signature?.size != 64) return false
dispatchGlobal(RoutedPacket(signed))
return true
}
fun sendFileBroadcast(file: BitchatFilePacket) {
try {
val payload = file.encode() ?: return
@ -462,31 +578,64 @@ class MeshCore(
}
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket) {
try {
scope.launch {
if (!encryptionService.hasEstablishedSession(recipientPeerID)) {
initiateNoiseHandshake(recipientPeerID)
return@launch
}
val tlv = file.encode() ?: return@launch
val np = NoisePayload(type = NoisePayloadType.FILE_TRANSFER, data = tlv).encode()
val enc = encryptionService.encrypt(np, recipientPeerID)
val packet = BitchatPacket(
version = if (enc.size > 0xFFFF) 2u else 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = enc,
signature = null,
ttl = maxTtl
)
val signed = signPacketBeforeBroadcast(packet)
val transferId = MeshPacketUtils.sha256Hex(tlv)
dispatchGlobal(RoutedPacket(signed, transferId = transferId))
val payload = file.encode() ?: return
when (val prepared = prepareFilePrivate(
recipientPeerID,
file,
MeshPacketUtils.sha256Hex(payload),
allowLegacyFallback = false
)) {
is PrivateMediaPreparation.Ready -> prepared.transfer.commit()
is PrivateMediaPreparation.RequiresLegacyConsent ->
Log.w("MeshCore", "Private media requires explicit one-shot legacy consent")
PrivateMediaPreparation.NeedsHandshake -> {
Log.i("MeshCore", "Private media needs a Noise handshake; initiating without sending")
initiateNoiseHandshake(recipientPeerID)
}
PrivateMediaPreparation.AwaitingPeerState -> Unit
is PrivateMediaPreparation.Rejected ->
Log.w("MeshCore", "Private media blocked: ${prepared.reason}")
}
}
fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,
transferId: String,
allowLegacyFallback: Boolean
): PrivateMediaPreparation {
return when (val outcome = privateMediaPreparer.prepare(
recipientPeerID = recipientPeerID,
recipientID = MeshPacketUtils.hexStringToByteArray(recipientPeerID),
file = file,
allowLegacyFallback = allowLegacyFallback
)) {
is PrivateMediaBuildOutcome.RequiresLegacyConsent ->
PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning)
PrivateMediaBuildOutcome.NeedsHandshake ->
PrivateMediaPreparation.NeedsHandshake
PrivateMediaBuildOutcome.AwaitingPeerState ->
PrivateMediaPreparation.AwaitingPeerState
is PrivateMediaBuildOutcome.Rejected ->
PrivateMediaPreparation.Rejected(outcome.reason)
is PrivateMediaBuildOutcome.Ready -> {
val built = outcome.built
val routed = RoutedPacket(
packet = built.packet,
transferId = transferId,
preparedPackets = built.fragments
)
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(transferId, built.wireMode) {
if (!isActive) {
false
} else {
dispatchGlobal(routed)
true
}
}
)
}
} catch (e: Exception) {
Log.e("MeshCore", "sendFilePrivate failed: ${e.message}", e)
}
}
@ -607,7 +756,7 @@ class MeshCore(
Log.e("MeshCore", "No signing public key available for announcement")
return@launch
}
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return@launch
val announcePacket = BitchatPacket(
type = MessageType.ANNOUNCE.value,
@ -628,7 +777,7 @@ class MeshCore(
?: myPeerID
val staticKey = encryptionService.getStaticPublicKey() ?: return
val signingKey = encryptionService.getSigningPublicKey() ?: return
val announcement = IdentityAnnouncement(nickname, staticKey, signingKey)
val announcement = IdentityAnnouncement.forLocalPeer(nickname, staticKey, signingKey)
val tlvPayload = buildAnnouncementPayload(announcement, nickname) ?: return
val packet = BitchatPacket(
type = MessageType.ANNOUNCE.value,
@ -749,6 +898,44 @@ 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)
@ -813,28 +1000,42 @@ class MeshCore(
encryptionService.clearPersistentIdentity()
}
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
private fun applyRouteIfAvailable(packet: BitchatPacket): BitchatPacket {
return try {
val withRoute = try {
val recipient = packet.recipientID
if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) {
val destination = recipient.toHexString()
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(myPeerID, destination)
if (path != null && path.size >= 3) {
val intermediates = path.subList(1, path.size - 1)
packet.copy(
route = intermediates.map { MeshPacketUtils.hexStringToByteArray(it) },
version = 2u
)
} else {
packet.copy(route = null)
}
val recipient = packet.recipientID
if (recipient != null && !recipient.contentEquals(SpecialRecipients.BROADCAST)) {
val destination = recipient.toHexString()
val path = com.bitchat.android.services.meshgraph.RoutePlanner.shortestPath(
myPeerID,
destination
)
if (path != null && path.size >= 3) {
val intermediates = path.subList(1, path.size - 1)
packet.copy(
route = intermediates.map { MeshPacketUtils.hexStringToByteArray(it) },
version = 2u
)
} else {
packet
packet.copy(route = null)
}
} catch (_: Exception) {
} else {
packet
}
} catch (_: Exception) {
packet
}
}
private fun routeAndSignPrivateMediaStrict(packet: BitchatPacket): BitchatPacket? {
val routed = applyRouteIfAvailable(packet)
val signingBytes = routed.toBinaryDataForSigning() ?: return null
val signature = encryptionService.signData(signingBytes) ?: return null
return routed.copy(signature = signature)
}
private fun signPacketBeforeBroadcast(packet: BitchatPacket): BitchatPacket {
return try {
val withRoute = applyRouteIfAvailable(packet)
val packetDataForSigning = withRoute.toBinaryDataForSigning() ?: return withRoute
val signature = encryptionService.signData(packetDataForSigning)

View File

@ -13,6 +13,8 @@ interface MeshDelegate {
fun didReceiveReadReceipt(messageID: String, recipientPeerID: String)
fun didReceiveVerifyChallenge(peerID: String, payload: ByteArray, timestampMs: Long) {}
fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {}
/** Current Noise generation either proved peer state or exhausted its 5-second watchdog. */
fun didResolvePrivateMediaPolicy(peerID: String) {}
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
fun getNickname(): String?
fun isFavorite(peerID: String): Boolean

View File

@ -21,6 +21,12 @@ interface MeshService {
fun sendVerifyResponse(peerID: String, noiseKeyHex: String, nonceA: ByteArray)
fun sendFileBroadcast(file: BitchatFilePacket)
fun sendFilePrivate(recipientPeerID: String, file: BitchatFilePacket)
fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,
transferId: String,
allowLegacyFallback: Boolean
): PrivateMediaPreparation
fun cancelFileTransfer(transferId: String): Boolean
fun sendBroadcastAnnounce()

View File

@ -13,6 +13,16 @@ interface MeshTransport {
fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean
/**
* Send through an exact transport generation rather than a reusable peer alias.
* Transports that cannot prove the link identity must decline the operation.
*/
fun sendPacketToLink(
relayAddress: String,
ingressLinkID: String,
packet: BitchatPacket
): Boolean = false
fun cancelTransfer(transferId: String): Boolean = false
fun getDeviceAddressForPeer(peerID: String): String? = null

View File

@ -4,7 +4,7 @@ import android.util.Log
import com.bitchat.android.favorites.FavoriteControlMessage
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
@ -13,6 +13,11 @@ import com.bitchat.android.util.toHexString
import kotlinx.coroutines.*
import java.util.*
sealed class AnnounceHandlingResult {
data class Accepted(val isFirst: Boolean) : AnnounceHandlingResult()
object Rejected : AnnounceHandlingResult()
}
/**
* Handles processing of different message types
* Extracted from BluetoothMeshService for better separation of concerns
@ -55,11 +60,12 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
try {
// Decrypt the message using the Noise service
val decryptedData = delegate?.decryptFromPeer(packet.payload, peerID)
if (decryptedData == null) {
val decryption = delegate?.decryptFromPeer(packet.payload, peerID)
if (decryption == null) {
Log.w(TAG, "Failed to decrypt Noise message from $peerID - may need handshake")
return
}
val decryptedData = decryption.plaintext
if (decryptedData.isEmpty()) {
Log.w(TAG, "Decrypted data is empty from $peerID")
@ -140,6 +146,19 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
Log.w(TAG, "⚠️ Failed to decode encrypted file transfer from $peerID")
}
}
com.bitchat.android.model.NoisePayloadType.PEER_STATE -> {
val authenticatedState = AuthenticatedPeerState.decode(noisePayload.data)
if (authenticatedState == null) {
Log.w(TAG, "Dropping malformed authenticated peer state from ${peerID.take(8)}")
} else {
delegate?.onAuthenticatedPeerStateReceived(
peerID,
authenticatedState,
decryption.authenticatedSession
)
}
}
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
// Handle delivery ACK exactly like iOS
@ -215,10 +234,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
* Handle announce message with TLV decoding and signature verification - exactly like iOS
*/
suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
return (handleAnnounceWithResult(routed) as? AnnounceHandlingResult.Accepted)?.isFirst ?: false
}
suspend fun handleAnnounceWithResult(routed: RoutedPacket): AnnounceHandlingResult {
val packet = routed.packet
val peerID = routed.peerID ?: "unknown"
if (peerID == myPeerID) return false
if (peerID == myPeerID) return AnnounceHandlingResult.Rejected
// Peers use wall-clock packet timestamps; tolerate moderate device clock skew
// during identity learning, or later signed messages cannot be verified.
@ -226,28 +249,29 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
val clockSkewMs = kotlin.math.abs(now - packet.timestamp.toLong())
if (clockSkewMs > ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS) {
Log.w(TAG, "Ignoring ANNOUNCE from ${peerID.take(8)} with excessive clock skew (${clockSkewMs}ms > ${ANNOUNCE_CLOCK_SKEW_TOLERANCE_MS}ms)")
return false
return AnnounceHandlingResult.Rejected
} else if (clockSkewMs > com.bitchat.android.util.AppConstants.Mesh.STALE_PEER_TIMEOUT_MS) {
Log.w(TAG, "Accepting ANNOUNCE from ${peerID.take(8)} within clock skew tolerance (${clockSkewMs}ms)")
}
// Try to decode as iOS-compatible IdentityAnnouncement with TLV format
val announcement = IdentityAnnouncement.decode(packet.payload)
val announcement = AnnouncementIdentityValidator.verify(packet, peerID) { signature, data, key ->
delegate?.verifyEd25519Signature(signature, data, key) ?: false
}
if (announcement == null) {
Log.w(TAG, "Failed to decode announce from $peerID as iOS-compatible TLV format")
return false
Log.w(TAG, "Rejecting malformed, unbound, or invalidly signed ANNOUNCE from ${peerID.take(8)}")
return AnnounceHandlingResult.Rejected
}
// Verify packet signature using the announced signing public key
var verified = false
if (packet.signature != null) {
// Verify that the packet was signed by the signing private key corresponding to the announced signing public key
verified = delegate?.verifyEd25519Signature(packet.signature!!, packet.toBinaryDataForSigning()!!, announcement.signingPublicKey) ?: false
if (!verified) {
Log.w(TAG, "⚠️ Signature verification for announce failed ${peerID.take(8)}")
}
val persistedSigningKey = delegate?.getAuthenticatedSigningKey(announcement.noisePublicKey)
if (persistedSigningKey != null &&
!persistedSigningKey.contentEquals(announcement.signingPublicKey)
) {
Log.w(TAG, "Rejecting ANNOUNCE Ed key that conflicts with authenticated peer state")
return AnnounceHandlingResult.Rejected
}
var verified = true
// Check for existing peer with different noise public key
// If existing peer has a different noise public key, do not consider this verified
val existingPeer = delegate?.getPeerInfo(peerID)
@ -257,10 +281,21 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
verified = false
}
if (
existingPeer?.signingPublicKey != null &&
!existingPeer.signingPublicKey!!.contentEquals(announcement.signingPublicKey)
) {
Log.w(
TAG,
"Rejecting signing-key replacement for ${peerID.take(8)} without authenticated peer-state proof"
)
verified = false
}
// Require verified announce; ignore otherwise (no backward compatibility)
if (!verified) {
Log.w(TAG, "❌ Ignoring unverified announce from ${peerID.take(8)}...")
return false
return AnnounceHandlingResult.Rejected
}
// Successfully decoded TLV format exactly like iOS
@ -274,22 +309,15 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
val signingPublicKey = announcement.signingPublicKey
// Update peer info with verification status through new method
val isFirstAnnounce = delegate?.updatePeerInfo(
val isFirstAnnounce = delegate?.updatePeerInfoFromVerifiedAnnouncement(
peerID = peerID,
nickname = nickname,
noisePublicKey = noisePublicKey,
signingPublicKey = signingPublicKey,
isVerified = true
isVerified = true,
capabilities = announcement.capabilities
) ?: false
// Update peer ID binding with noise public key for identity management
delegate?.updatePeerIDBinding(
newPeerID = peerID,
nickname = nickname,
publicKey = noisePublicKey,
previousPeerID = null
)
// Update mesh graph from gossip neighbors (only if TLV present)
try {
val neighborsOrNull = com.bitchat.android.services.meshgraph.GossipTLV.decodeNeighborsFromAnnouncementPayload(packet.payload)
@ -298,7 +326,7 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
} catch (_: Exception) { }
Log.d(TAG, "✅ Processed verified TLV announce: stored identity for $peerID")
return isFirstAnnounce
return AnnounceHandlingResult.Accepted(isFirstAnnounce)
}
/**
@ -437,14 +465,26 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
*/
private suspend fun handlePrivateMessage(packet: BitchatPacket, peerID: String) {
try {
// Verify signature if present
if (packet.signature != null && !delegate?.verifySignature(packet, peerID)!!) {
val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) ==
com.bitchat.android.protocol.MessageType.FILE_TRANSFER
val signatureIsValid = packet.signature != null &&
delegate?.verifySignature(packet, peerID) == true
// Migration fallback is visible to relays, so sender authenticity
// is mandatory. Never accept an unsigned directed raw file.
if (isFileTransfer && !signatureIsValid) {
Log.w(TAG, "Unsigned or invalid signed private file from $peerID")
return
}
// Preserve prior behavior for other directed packet types: verify
// a signature whenever one is present.
if (!isFileTransfer && packet.signature != null && !signatureIsValid) {
Log.w(TAG, "Invalid signature for private message from $peerID")
return
}
// Try file packet first (voice, image, etc.) and log outcome for FILE_TRANSFER
val isFileTransfer = com.bitchat.android.protocol.MessageType.fromValue(packet.type) == com.bitchat.android.protocol.MessageType.FILE_TRANSFER
val file = com.bitchat.android.model.BitchatFilePacket.decode(packet.payload)
if (file != null) {
if (isFileTransfer) {
@ -597,7 +637,14 @@ interface MessageHandlerDelegate {
fun getNetworkSize(): Int
fun getMyNickname(): String?
fun getPeerInfo(peerID: String): PeerInfo?
fun updatePeerInfo(peerID: String, nickname: String, noisePublicKey: ByteArray, signingPublicKey: ByteArray, isVerified: Boolean): Boolean
fun updatePeerInfoFromVerifiedAnnouncement(
peerID: String,
nickname: String,
noisePublicKey: ByteArray,
signingPublicKey: ByteArray,
isVerified: Boolean,
capabilities: com.bitchat.android.model.PeerCapabilities? = null
): Boolean
// Packet operations
fun sendPacket(packet: BitchatPacket)
@ -607,15 +654,22 @@ interface MessageHandlerDelegate {
// Cryptographic operations
fun verifySignature(packet: BitchatPacket, peerID: String): Boolean
fun encryptForPeer(data: ByteArray, recipientPeerID: String): ByteArray?
fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray?
fun decryptFromPeer(
encryptedData: ByteArray,
senderPeerID: String
): com.bitchat.android.noise.NoiseDecryptionResult?
fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKey: ByteArray): Boolean
fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = null
// Noise protocol operations
fun hasNoiseSession(peerID: String): Boolean
fun initiateNoiseHandshake(peerID: String)
fun processNoiseHandshakeMessage(payload: ByteArray, peerID: String): ByteArray?
fun updatePeerIDBinding(newPeerID: String, nickname: String,
publicKey: ByteArray, previousPeerID: String?)
fun onAuthenticatedPeerStateReceived(
peerID: String,
state: AuthenticatedPeerState,
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
) {}
// Message operations
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?

View File

@ -143,7 +143,7 @@ class PacketProcessor(private val myPeerID: String) {
// Handle public packet types (no address check needed)
when (messageType) {
MessageType.ANNOUNCE -> handleAnnounce(routed)
MessageType.ANNOUNCE -> validPacket = handleAnnounce(routed)
MessageType.MESSAGE -> handleMessage(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed) // treat same routing path; parsing happens in handler
MessageType.LEAVE -> handleLeave(routed)
@ -153,7 +153,7 @@ class PacketProcessor(private val myPeerID: String) {
// Handle private packet types (address check required)
if (packetRelayManager.isPacketAddressedToMe(packet)) {
when (messageType) {
MessageType.NOISE_HANDSHAKE -> handleNoiseHandshake(routed)
MessageType.NOISE_HANDSHAKE -> validPacket = handleNoiseHandshake(routed)
MessageType.NOISE_ENCRYPTED -> handleNoiseEncrypted(routed)
MessageType.FILE_TRANSFER -> handleMessage(routed)
else -> {
@ -179,10 +179,10 @@ class PacketProcessor(private val myPeerID: String) {
/**
* Handle Noise handshake message - SIMPLIFIED iOS-compatible version
*/
private suspend fun handleNoiseHandshake(routed: RoutedPacket) {
private suspend fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing Noise handshake from ${formatPeerForLog(peerID)}")
delegate?.handleNoiseHandshake(routed)
return delegate?.handleNoiseHandshake(routed) ?: false
}
/**
@ -197,10 +197,10 @@ class PacketProcessor(private val myPeerID: String) {
/**
* Handle announce message
*/
private suspend fun handleAnnounce(routed: RoutedPacket) {
private suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
val peerID = routed.peerID ?: "unknown"
Log.d(TAG, "Processing announce from ${formatPeerForLog(peerID)}")
delegate?.handleAnnounce(routed)
return delegate?.handleAnnounce(routed) ?: false
}
/**
@ -231,7 +231,14 @@ class PacketProcessor(private val myPeerID: String) {
val reassembledPacket = delegate?.handleFragment(routed.packet)
if (reassembledPacket != null) {
Log.d(TAG, "Fragment reassembled, processing complete message")
handleReceivedPacket(RoutedPacket(reassembledPacket, routed.peerID, routed.relayAddress))
handleReceivedPacket(
RoutedPacket(
packet = reassembledPacket,
peerID = routed.peerID,
relayAddress = routed.relayAddress,
ingressLinkID = routed.ingressLinkID
)
)
}
// Fragment relay is now handled by centralized PacketRelayManager
@ -314,7 +321,7 @@ interface PacketProcessorDelegate {
// Message type handlers
fun handleNoiseHandshake(routed: RoutedPacket): Boolean
fun handleNoiseEncrypted(routed: RoutedPacket)
fun handleAnnounce(routed: RoutedPacket)
suspend fun handleAnnounce(routed: RoutedPacket): Boolean
fun handleMessage(routed: RoutedPacket)
fun handleLeave(routed: RoutedPacket)
fun handleFragment(packet: BitchatPacket): BitchatPacket?

View File

@ -1,6 +1,8 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
@ -17,7 +19,11 @@ data class PeerInfo(
var noisePublicKey: ByteArray?,
var signingPublicKey: ByteArray?, // NEW: Ed25519 public key for verification
var isVerifiedNickname: Boolean, // NEW: Verification status flag
var lastSeen: Long // Using Long instead of Date for simplicity
var lastSeen: Long, // Using Long instead of Date for simplicity
var capabilities: PeerCapabilities? = null, // null means a signed old-client announce omitted TLV 0x05
var hasVerifiedAnnouncement: Boolean = false,
/** Noise key that the preserved capability state was actually signed alongside. */
var verifiedAnnouncementNoisePublicKey: ByteArray? = null
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@ -39,6 +45,14 @@ data class PeerInfo(
} else if (other.signingPublicKey != null) return false
if (isVerifiedNickname != other.isVerifiedNickname) return false
if (lastSeen != other.lastSeen) return false
if (capabilities != other.capabilities) return false
if (hasVerifiedAnnouncement != other.hasVerifiedAnnouncement) return false
val thisVerifiedAnnouncementKey = verifiedAnnouncementNoisePublicKey
val otherVerifiedAnnouncementKey = other.verifiedAnnouncementNoisePublicKey
if (thisVerifiedAnnouncementKey != null) {
if (otherVerifiedAnnouncementKey == null) return false
if (!thisVerifiedAnnouncementKey.contentEquals(otherVerifiedAnnouncementKey)) return false
} else if (otherVerifiedAnnouncementKey != null) return false
return true
}
@ -52,6 +66,9 @@ data class PeerInfo(
result = 31 * result + (signingPublicKey?.contentHashCode() ?: 0)
result = 31 * result + isVerifiedNickname.hashCode()
result = 31 * result + lastSeen.hashCode()
result = 31 * result + (capabilities?.hashCode() ?: 0)
result = 31 * result + hasVerifiedAnnouncement.hashCode()
result = 31 * result + (verifiedAnnouncementNoisePublicKey?.contentHashCode() ?: 0)
return result
}
}
@ -108,6 +125,84 @@ class PeerManager {
noisePublicKey: ByteArray,
signingPublicKey: ByteArray,
isVerified: Boolean
): Boolean {
val existing = peers[peerID]
return updatePeerInfoInternal(
peerID = peerID,
nickname = nickname,
noisePublicKey = noisePublicKey,
signingPublicKey = signingPublicKey,
isVerified = isVerified,
capabilities = existing?.capabilities,
hasVerifiedAnnouncement = existing?.hasVerifiedAnnouncement == true,
verifiedAnnouncementNoisePublicKey = existing?.verifiedAnnouncementNoisePublicKey
)
}
/**
* Apply the exact capability state from a signature-verified announce.
* A null value is meaningful: the peer signed an old-format announce that
* omitted TLV 0x05. Normal peer refreshes use [updatePeerInfo] and retain
* the last signed capability state instead of accidentally erasing it.
*/
fun updatePeerInfoFromVerifiedAnnouncement(
peerID: String,
nickname: String,
noisePublicKey: ByteArray,
signingPublicKey: ByteArray,
isVerified: Boolean,
capabilities: PeerCapabilities?
): Boolean = updatePeerInfoInternal(
peerID = peerID,
nickname = nickname,
noisePublicKey = noisePublicKey,
signingPublicKey = signingPublicKey,
isVerified = isVerified,
capabilities = capabilities,
hasVerifiedAnnouncement = true,
verifiedAnnouncementNoisePublicKey = noisePublicKey.copyOf()
)
/** Replace capability/Ed identity from Noise 0x21 in one peer-map mutation. */
@Synchronized
fun applyAuthenticatedPeerState(
peerID: String,
authenticatedNoisePublicKey: ByteArray,
state: AuthenticatedPeerState
) {
val existing = peers[peerID]
val announcementMatchesAuthenticatedState = existing?.hasVerifiedAnnouncement == true &&
existing.verifiedAnnouncementNoisePublicKey?.contentEquals(authenticatedNoisePublicKey) == true &&
existing.signingPublicKey?.contentEquals(state.signingPublicKey) == true
val replacement = PeerInfo(
id = peerID,
// A copied-static preannouncement cannot retain its attacker-chosen display name once
// authenticated peer state proves a different Ed key.
nickname = existing?.nickname?.takeIf { announcementMatchesAuthenticatedState } ?: peerID,
isConnected = true,
isDirectConnection = existing?.isDirectConnection ?: false,
noisePublicKey = authenticatedNoisePublicKey.copyOf(),
signingPublicKey = state.signingPublicKey.copyOf(),
isVerifiedNickname = existing?.isVerifiedNickname == true && announcementMatchesAuthenticatedState,
lastSeen = System.currentTimeMillis(),
capabilities = state.capabilities,
hasVerifiedAnnouncement = announcementMatchesAuthenticatedState,
verifiedAnnouncementNoisePublicKey = authenticatedNoisePublicKey.copyOf()
.takeIf { announcementMatchesAuthenticatedState }
)
peers[peerID] = replacement
if (existing == null || existing != replacement) notifyPeerListUpdate()
}
private fun updatePeerInfoInternal(
peerID: String,
nickname: String,
noisePublicKey: ByteArray,
signingPublicKey: ByteArray,
isVerified: Boolean,
capabilities: PeerCapabilities?,
hasVerifiedAnnouncement: Boolean,
verifiedAnnouncementNoisePublicKey: ByteArray?
): Boolean {
if (peerID == "unknown") return false
@ -125,6 +220,9 @@ class PeerManager {
val noiseKeyChanged = existingPeer != null && !keysMatch(existingPeer.noisePublicKey, noisePublicKey)
val signingKeyChanged = existingPeer != null && !keysMatch(existingPeer.signingPublicKey, signingPublicKey)
val connectedChanged = existingPeer != null && existingPeer.isConnected != true
val capabilitiesChanged = existingPeer != null && existingPeer.capabilities != capabilities
val announcementStateChanged = existingPeer != null &&
existingPeer.hasVerifiedAnnouncement != hasVerifiedAnnouncement
// Update or create peer info
val peerInfo = PeerInfo(
@ -135,7 +233,10 @@ class PeerManager {
noisePublicKey = noisePublicKey,
signingPublicKey = signingPublicKey,
isVerifiedNickname = isVerified,
lastSeen = now
lastSeen = now,
capabilities = capabilities,
hasVerifiedAnnouncement = hasVerifiedAnnouncement,
verifiedAnnouncementNoisePublicKey = verifiedAnnouncementNoisePublicKey?.copyOf()
)
peers[peerID] = peerInfo
@ -147,7 +248,8 @@ class PeerManager {
val shouldNotify = when {
isNewPeer && isVerified -> true
wasVerified != isVerified -> true
nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged -> true
nicknameChanged || noiseKeyChanged || signingKeyChanged || connectedChanged ||
capabilitiesChanged || announcementStateChanged -> true
else -> false
}

View File

@ -0,0 +1,64 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.noise.AuthenticatedNoiseSession
internal sealed interface PrivateMediaPolicyDecision {
data class Encrypted(
val authenticatedSession: AuthenticatedNoiseSession
) : PrivateMediaPolicyDecision
data object RequiresLegacyConsent : PrivateMediaPolicyDecision
data object NeedsHandshake : PrivateMediaPolicyDecision
data object AwaitingPeerState : PrivateMediaPolicyDecision
data class Blocked(val reason: String) : PrivateMediaPolicyDecision
}
/**
* Binds an advertised private-media capability to a live Noise remote-static
* key and persists an HSTS-style pin by that authenticated key's SHA-256
* fingerprint. Announcements alone can never create a pin.
*/
internal class PrivateMediaSecurityController(
private val authenticatedSessionProvider: (String) -> AuthenticatedNoiseSession?,
private val peerStateStatusProvider: (
String,
AuthenticatedNoiseSession
) -> AuthenticatedPeerStateStatus,
private val isPrivateMediaPinned: (String) -> Boolean
) {
fun sendPolicy(peerID: String): PrivateMediaPolicyDecision {
val authenticatedSession = authenticatedSessionProvider(peerID)
?.takeIf { it.remoteStaticKey.size == 32 && it.sessionToken.size == 32 }
?: return PrivateMediaPolicyDecision.NeedsHandshake
return when (val status = peerStateStatusProvider(peerID, authenticatedSession)) {
AuthenticatedPeerStateStatus.Awaiting -> PrivateMediaPolicyDecision.AwaitingPeerState
is AuthenticatedPeerStateStatus.Proven -> {
if (status.state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) {
PrivateMediaPolicyDecision.Encrypted(authenticatedSession)
} else if (isPrivateMediaPinned(peerID)) {
PrivateMediaPolicyDecision.Blocked(
"Encrypted private media was previously pinned, but this session proved no support; send blocked"
)
} else {
PrivateMediaPolicyDecision.RequiresLegacyConsent
}
}
AuthenticatedPeerStateStatus.Missing ->
// A live crypto session can become visible just before its authenticated callback
// installs the coordinator generation. Never treat that gap as a watchdog timeout.
PrivateMediaPolicyDecision.AwaitingPeerState
AuthenticatedPeerStateStatus.TimedOut -> {
if (isPrivateMediaPinned(peerID)) {
PrivateMediaPolicyDecision.Blocked(
"Encrypted private media was previously pinned, but this session did not provide authenticated peer state"
)
} else {
// A Noise-capable old client that does not know 0x21 may use explicit one-shot
// legacy consent after the five-second generation watchdog.
PrivateMediaPolicyDecision.RequiresLegacyConsent
}
}
}
}
}

View File

@ -0,0 +1,215 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import java.util.concurrent.atomic.AtomicBoolean
enum class PrivateMediaWireMode {
ENCRYPTED_NOISE_0X20,
SIGNED_DIRECTED_RAW_0X22
}
class PreparedPrivateMediaTransfer internal constructor(
val transferId: String,
val wireMode: PrivateMediaWireMode,
private val commitAction: () -> Boolean
) {
private val committed = AtomicBoolean(false)
/** A prepared transfer is single-use, including after a failed commit. */
fun commit(): Boolean {
if (!committed.compareAndSet(false, true)) return false
return commitAction()
}
}
sealed interface PrivateMediaPreparation {
data class Ready(val transfer: PreparedPrivateMediaTransfer) : PrivateMediaPreparation
data class RequiresLegacyConsent(val warning: String) : PrivateMediaPreparation
data object NeedsHandshake : PrivateMediaPreparation
data object AwaitingPeerState : PrivateMediaPreparation
data class Rejected(val reason: String) : PrivateMediaPreparation
}
internal data class BuiltPrivateMediaTransfer(
val packet: BitchatPacket,
val fragments: List<BitchatPacket>,
val wireMode: PrivateMediaWireMode
)
internal sealed interface PrivateMediaBuildOutcome {
data class Ready(val built: BuiltPrivateMediaTransfer) : PrivateMediaBuildOutcome
data class RequiresLegacyConsent(val warning: String) : PrivateMediaBuildOutcome
data object NeedsHandshake : PrivateMediaBuildOutcome
data object AwaitingPeerState : PrivateMediaBuildOutcome
data class Rejected(val reason: String) : PrivateMediaBuildOutcome
}
internal sealed interface PrivateMediaEncryptionResult {
data class Success(val ciphertext: ByteArray) : PrivateMediaEncryptionResult
data object GenerationChanged : PrivateMediaEncryptionResult
data object Failed : PrivateMediaEncryptionResult
}
/** Builds, routes, signs, and fragments exactly once before UI local echo. */
internal class PrivateMediaTransferPreparer(
private val senderID: ByteArray,
private val ttl: UByte,
private val policyProvider: (String) -> PrivateMediaPolicyDecision,
private val encrypt: (
ByteArray,
String,
AuthenticatedNoiseSession
) -> PrivateMediaEncryptionResult,
private val finalizeRoutedAndSigned: (BitchatPacket) -> BitchatPacket?,
private val fragment: (BitchatPacket, Int) -> List<BitchatPacket>,
private val now: () -> ULong = { System.currentTimeMillis().toULong() }
) {
fun prepare(
recipientPeerID: String,
recipientID: ByteArray,
file: BitchatFilePacket,
allowLegacyFallback: Boolean
): PrivateMediaBuildOutcome = prepare(
recipientPeerID,
recipientID,
file,
allowLegacyFallback,
generationRetriesRemaining = 1
)
private fun prepare(
recipientPeerID: String,
recipientID: ByteArray,
file: BitchatFilePacket,
allowLegacyFallback: Boolean,
generationRetriesRemaining: Int
): PrivateMediaBuildOutcome {
val maxPrivateFragments =
com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID
val absolutePayloadUpperBound =
maxPrivateFragments.toLong() *
com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENT_SIZE.toLong()
// The file content alone cannot exceed the total bytes carried by every
// possible fragment. Reject before TLV encoding, encryption, signing,
// and packet serialization make additional full-size copies.
if (file.content.size.toLong() > absolutePayloadUpperBound) {
return PrivateMediaBuildOutcome.Rejected(
"File exceeds the private-media v1 limit of 256 final mesh fragments"
)
}
val policy = policyProvider(recipientPeerID)
val mode = when (policy) {
is PrivateMediaPolicyDecision.Encrypted -> PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
PrivateMediaPolicyDecision.RequiresLegacyConsent -> {
if (!allowLegacyFallback) {
return PrivateMediaBuildOutcome.RequiresLegacyConsent(
"This older client cannot receive encrypted private media. " +
"Sending this one file will expose its contents to mesh relays, " +
"although the directed packet will still be signed."
)
}
PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22
}
PrivateMediaPolicyDecision.NeedsHandshake ->
return PrivateMediaBuildOutcome.NeedsHandshake
PrivateMediaPolicyDecision.AwaitingPeerState ->
return PrivateMediaBuildOutcome.AwaitingPeerState
is PrivateMediaPolicyDecision.Blocked ->
return PrivateMediaBuildOutcome.Rejected(policy.reason)
}
val filePayload = file.encode()
?: return PrivateMediaBuildOutcome.Rejected("Failed to encode private media")
val packet = when (mode) {
PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 -> {
val plaintext = NoisePayload(NoisePayloadType.FILE_TRANSFER, filePayload).encode()
val encryption = try {
encrypt(
plaintext,
recipientPeerID,
(policy as PrivateMediaPolicyDecision.Encrypted).authenticatedSession
)
} catch (_: Exception) {
PrivateMediaEncryptionResult.Failed
}
val ciphertext = when (encryption) {
is PrivateMediaEncryptionResult.Success -> encryption.ciphertext
PrivateMediaEncryptionResult.GenerationChanged -> {
if (generationRetriesRemaining > 0) {
return prepare(
recipientPeerID,
recipientID,
file,
allowLegacyFallback,
generationRetriesRemaining - 1
)
}
return PrivateMediaBuildOutcome.AwaitingPeerState
}
PrivateMediaEncryptionResult.Failed ->
return PrivateMediaBuildOutcome.Rejected(
"The authenticated Noise session could not encrypt this file"
)
}
BitchatPacket(
version = if (ciphertext.size > 0xFFFF) 2u else 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = senderID.copyOf(),
recipientID = recipientID.copyOf(),
timestamp = now(),
payload = ciphertext,
signature = null,
ttl = ttl
)
}
PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22 -> BitchatPacket(
version = 2u,
type = MessageType.FILE_TRANSFER.value,
senderID = senderID.copyOf(),
recipientID = recipientID.copyOf(),
timestamp = now(),
payload = filePayload,
signature = null,
ttl = ttl
)
}
val finalized = finalizeRoutedAndSigned(packet)
?: return PrivateMediaBuildOutcome.Rejected(
if (mode == PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22) {
"Could not sign the legacy private-media packet; nothing was sent"
} else {
"Could not sign the encrypted private-media packet; nothing was sent"
}
)
if (finalized.signature?.size != 64) {
return PrivateMediaBuildOutcome.Rejected(
"Could not produce a valid Ed25519 private-media signature; nothing was sent"
)
}
val fragments = fragment(finalized, maxPrivateFragments)
if (fragments.isEmpty()) {
return PrivateMediaBuildOutcome.Rejected(
"File exceeds the private-media v1 limit of 256 final mesh fragments"
)
}
if (fragments.size > maxPrivateFragments) {
return PrivateMediaBuildOutcome.Rejected(
"File exceeds the private-media v1 limit of 256 final mesh fragments"
)
}
return PrivateMediaBuildOutcome.Ready(
BuiltPrivateMediaTransfer(finalized, fragments, mode)
)
}
}

View File

@ -5,6 +5,8 @@ import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoiseDecryptionResult
import com.bitchat.android.util.toHexString
import kotlinx.coroutines.*
import java.util.*
@ -54,6 +56,22 @@ class SecurityManager(private val encryptionService: EncryptionService, private
val currentTime = System.currentTimeMillis()
val messageType = MessageType.fromValue(packet.type)
// LEAVE mutates presence immediately and cannot be safely replayed after the in-memory
// duplicate cache expires (or after an app restart). Bound it to the same five-minute
// security window used for duplicate retention while tolerating symmetric clock skew.
if (messageType == MessageType.LEAVE) {
val now = currentTime.coerceAtLeast(0).toULong()
val clockSkew = if (packet.timestamp >= now) {
packet.timestamp - now
} else {
now - packet.timestamp
}
if (clockSkew > MESSAGE_TIMEOUT.toULong()) {
Log.w(TAG, "Dropping stale or future-dated LEAVE from $peerID")
return false
}
}
// Duplicate detection
val messageID = generateMessageID(packet, peerID)
@ -71,15 +89,17 @@ class SecurityManager(private val encryptionService: EncryptionService, private
Log.d(TAG, "Allowing duplicate ANNOUNCE from direct neighbor: $messageID")
}
// Add to processed messages
processedMessages.add(messageID)
messageTimestamps[messageID] = currentTime
// Enforce mandatory signature verification
if (!verifyPacketSignature(packet, peerID)) {
Log.w(TAG, "Dropping packet from $peerID due to signature verification failure")
return false
}
// Record only authenticated packets. Recording an attacker-controlled
// invalid packet first would let it poison duplicate detection for a
// later legitimate packet with the same timestamp and payload.
processedMessages.add(messageID)
messageTimestamps[messageID] = currentTime
Log.d(TAG, "Packet validation passed for $peerID, messageID: $messageID")
return true
@ -102,19 +122,6 @@ class SecurityManager(private val encryptionService: EncryptionService, private
// Skip our own handshake messages
if (peerID == myPeerID) return false
// If we already have an established session but the peer is initiating a new handshake,
// drop the existing session so we can re-establish cleanly.
var forcedRehandshake = false
if (encryptionService.hasEstablishedSession(peerID)) {
Log.d(TAG, "Received new Noise handshake from $peerID with an existing session. Dropping old session to re-handshake.")
try {
encryptionService.removePeer(peerID)
forcedRehandshake = true
} catch (e: Exception) {
Log.w(TAG, "Failed to remove existing Noise session for $peerID: ${e.message}")
}
}
if (packet.payload.isEmpty()) {
Log.w(TAG, "Noise handshake packet has empty payload")
return false
@ -123,26 +130,46 @@ class SecurityManager(private val encryptionService: EncryptionService, private
// Prevent duplicate handshake processing
val exchangeKey = "$peerID-${packet.payload.sliceArray(0 until minOf(16, packet.payload.size)).contentHashCode()}"
if (!forcedRehandshake && processedKeyExchanges.contains(exchangeKey)) {
if (processedKeyExchanges.contains(exchangeKey)) {
Log.d(TAG, "Already processed handshake: $exchangeKey")
return false
}
Log.d(TAG, "Processing Noise handshake from $peerID (${packet.payload.size} bytes)")
processedKeyExchanges.add(exchangeKey)
try {
// Process the Noise handshake through the updated EncryptionService
val response = encryptionService.processHandshakeMessage(packet.payload, peerID)
// The session manager preserves an existing transport in a separate responder-candidate
// flow and reports whether this exact frame completed authentication. Never infer that
// from ambient session state: a rejected replacement may leave the old session active.
val result = encryptionService.processHandshakeMessageWithResult(packet.payload, peerID)
processedKeyExchanges.add(exchangeKey)
if (response != null) {
if (result.response != null) {
Log.d(TAG, "Successfully processed Noise handshake from $peerID, sending response")
// Send handshake response through delegate
delegate?.sendHandshakeResponse(peerID, response)
delegate?.sendHandshakeResponse(peerID, result.response)
}
// Check if session is now established (handshake complete)
if (encryptionService.hasEstablishedSession(peerID)) {
if (result.establishedNow) {
val authenticatedRemoteStaticKey = result.authenticatedRemoteStaticKey
if (authenticatedRemoteStaticKey == null) {
Log.e(TAG, "Bound Noise completion for $peerID omitted its authenticated static key")
return false
}
val authenticatedSessionToken = result.authenticatedSessionToken
if (authenticatedSessionToken?.size != 32 ||
authenticatedSessionToken.all { it == 0.toByte() }
) {
Log.e(TAG, "Bound Noise completion for $peerID omitted its generation token")
return false
}
val isDirectIngress = packet.ttl == com.bitchat.android.util.AppConstants.MESSAGE_TTL_HOPS
Log.d(TAG, "✅ Noise handshake completed with $peerID")
delegate?.onKeyExchangeCompleted(peerID, packet.payload)
delegate?.onKeyExchangeCompleted(
peerID = peerID,
authenticatedRemoteStaticKey = authenticatedRemoteStaticKey,
authenticatedSessionToken = authenticatedSessionToken,
directRelayAddress = routed.relayAddress.takeIf { isDirectIngress },
ingressLinkID = routed.ingressLinkID.takeIf { isDirectIngress }
)
}
return true
@ -154,21 +181,13 @@ class SecurityManager(private val encryptionService: EncryptionService, private
}
/**
* Verify packet signature
* Verify a packet signature against the signing key learned from the
* peer's verified announcement. Signatures cover the canonical packet,
* not only its payload; otherwise routing and recipient fields could be
* changed without invalidating the signature.
*/
fun verifySignature(packet: BitchatPacket, peerID: String): Boolean {
return packet.signature?.let { signature ->
try {
val isValid = encryptionService.verify(signature, packet.payload, peerID)
if (!isValid) {
Log.w(TAG, "Invalid signature for packet from $peerID")
}
isValid
} catch (e: Exception) {
Log.e(TAG, "Failed to verify signature from $peerID: ${e.message}")
false
}
} ?: true // No signature means verification passes
return verifyPacketSignature(packet, peerID)
}
/**
@ -194,13 +213,24 @@ class SecurityManager(private val encryptionService: EncryptionService, private
null
}
}
fun encryptForPeer(
data: ByteArray,
recipientPeerID: String,
expectedSession: AuthenticatedNoiseSession
): ByteArray? = try {
encryptionService.encryptForSession(data, recipientPeerID, expectedSession)
} catch (e: Exception) {
Log.e(TAG, "Noise generation changed before encrypting for $recipientPeerID: ${e.message}")
null
}
/**
* Decrypt payload from specific peer
*/
fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): ByteArray? {
fun decryptFromPeer(encryptedData: ByteArray, senderPeerID: String): NoiseDecryptionResult? {
return try {
encryptionService.decrypt(encryptedData, senderPeerID)
encryptionService.decryptWithSession(encryptedData, senderPeerID)
} catch (e: Exception) {
Log.e(TAG, "Failed to decrypt from $senderPeerID: ${e.message}")
null
@ -237,14 +267,52 @@ class SecurityManager(private val encryptionService: EncryptionService, private
*/
private fun verifyPacketSignature(packet: BitchatPacket, peerID: String): Boolean {
try {
// only verify ANNOUNCE, MESSAGE, and FILE_TRANSFER
// Public packets that mutate identity, presence, or user-visible state must prove the
// signing key learned from a verified announcement. LEAVE is included so an attacker
// cannot evict a claimed peer or amplify a forged departure through relay.
if (MessageType.fromValue(packet.type) !in setOf(
MessageType.ANNOUNCE,
MessageType.MESSAGE,
MessageType.FILE_TRANSFER
MessageType.FILE_TRANSFER,
MessageType.LEAVE
)) {
return true
}
if (MessageType.fromValue(packet.type) == MessageType.ANNOUNCE) {
val announcement = AnnouncementIdentityValidator.verify(packet, peerID) { signature, data, key ->
encryptionService.verifyEd25519Signature(signature, data, key)
} ?: run {
Log.w(TAG, "Rejecting malformed, unbound, or invalidly signed ANNOUNCE from $peerID")
return false
}
val persistedSigningKey = delegate?.getAuthenticatedSigningKey(announcement.noisePublicKey)
if (persistedSigningKey != null &&
!persistedSigningKey.contentEquals(announcement.signingPublicKey)
) {
Log.w(TAG, "Rejecting ANNOUNCE Ed key that conflicts with authenticated peer state for $peerID")
return false
}
val existingPeer = delegate?.getPeerInfo(peerID)
if (
existingPeer?.noisePublicKey != null &&
!existingPeer.noisePublicKey!!.contentEquals(announcement.noisePublicKey)
) {
Log.w(TAG, "Rejecting ANNOUNCE Noise-key replacement for $peerID")
return false
}
if (
existingPeer?.signingPublicKey != null &&
!existingPeer.signingPublicKey!!.contentEquals(announcement.signingPublicKey)
) {
Log.w(TAG, "Rejecting ANNOUNCE signing-key replacement without authenticated peer state for $peerID")
return false
}
return true
}
// 1. Mandatory Signature Check
if (packet.signature == null) {
Log.w(TAG, "❌ Signature check for $peerID: NO_SIGNATURE (packet type ${packet.type})")
@ -252,21 +320,8 @@ class SecurityManager(private val encryptionService: EncryptionService, private
}
// 2. Get Signing Public Key
var signingPublicKey: ByteArray? = null
if (MessageType.fromValue(packet.type) == MessageType.ANNOUNCE) {
// Special Case: ANNOUNCE packets carry their own signing key
try {
val announcement = com.bitchat.android.model.IdentityAnnouncement.decode(packet.payload)
signingPublicKey = announcement?.signingPublicKey
} catch (e: Exception) {
Log.w(TAG, "Failed to decode announcement for key extraction: ${e.message}")
}
} else {
// Standard Case: Get key from known peer info
val peerInfo = delegate?.getPeerInfo(peerID)
signingPublicKey = peerInfo?.signingPublicKey
}
val peerInfo = delegate?.getPeerInfo(peerID)
val signingPublicKey = peerInfo?.signingPublicKey
if (signingPublicKey == null) {
// If we don't have a key (and it's not an announce), we can't verify.
@ -416,7 +471,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private
* Delegate interface for security manager callbacks
*/
interface SecurityManagerDelegate {
fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray)
fun onKeyExchangeCompleted(
peerID: String,
authenticatedRemoteStaticKey: ByteArray,
authenticatedSessionToken: ByteArray,
directRelayAddress: String?,
ingressLinkID: String?
)
fun sendHandshakeResponse(peerID: String, response: ByteArray)
fun getPeerInfo(peerID: String): PeerInfo? // NEW: For signature verification
fun getAuthenticatedSigningKey(noisePublicKey: ByteArray): ByteArray? = null
}

View File

@ -130,6 +130,41 @@ class UnifiedMeshService(
}
}
override fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,
transferId: String,
allowLegacyFallback: Boolean
): PrivateMediaPreparation {
return when {
isBleReady(recipientPeerID) -> bluetooth.prepareFilePrivate(
recipientPeerID,
file,
transferId,
allowLegacyFallback
)
isWifiReady(recipientPeerID) -> wifiService()?.prepareFilePrivate(
recipientPeerID,
file,
transferId,
allowLegacyFallback
) ?: PrivateMediaPreparation.Rejected("Wi-Fi Aware transport is unavailable")
isBleConnected(recipientPeerID) || (isBleEnabled() && !isWifiConnected(recipientPeerID)) ->
bluetooth.prepareFilePrivate(
recipientPeerID,
file,
transferId,
allowLegacyFallback
)
else -> wifiService()?.prepareFilePrivate(
recipientPeerID,
file,
transferId,
allowLegacyFallback
) ?: PrivateMediaPreparation.Rejected("No local transport is available for this peer")
}
}
override fun cancelFileTransfer(transferId: String): Boolean {
val bleCancelled = try { bluetooth.cancelFileTransfer(transferId) } catch (_: Exception) { false }
val wifiCancelled = try { wifiService()?.cancelFileTransfer(transferId) == true } catch (_: Exception) { false }
@ -324,6 +359,10 @@ class UnifiedMeshService(
delegate?.didReceiveVerifyResponse(peerID, payload, timestampMs)
}
override fun didResolvePrivateMediaPolicy(peerID: String) {
delegate?.didResolvePrivateMediaPolicy(peerID)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return delegate?.decryptChannelMessage(encryptedContent, channel)
}

View File

@ -0,0 +1,86 @@
package com.bitchat.android.model
/**
* Canonical payload carried inside Noise payload type 0x21.
*
* Wire format:
* `[version=0x01][type=0x01][len=1...8][minimal LE capabilities]`
* `[type=0x02][len=32][Ed25519 public key]`
*
* Unknown TLVs are skipped. Both known fields must occur exactly once.
*/
data class AuthenticatedPeerState(
val capabilities: PeerCapabilities,
val signingPublicKey: ByteArray
) {
init {
require(signingPublicKey.size == SIGNING_PUBLIC_KEY_SIZE) {
"Ed25519 public key must be 32 bytes"
}
}
fun encode(): ByteArray {
val capabilityBytes = capabilities.encoded()
return buildList<Byte>(1 + 2 + capabilityBytes.size + 2 + signingPublicKey.size) {
add(VERSION.toByte())
add(CAPABILITIES_TLV.toByte())
add(capabilityBytes.size.toByte())
addAll(capabilityBytes.toList())
add(SIGNING_PUBLIC_KEY_TLV.toByte())
add(SIGNING_PUBLIC_KEY_SIZE.toByte())
addAll(signingPublicKey.toList())
}.toByteArray()
}
companion object {
const val VERSION = 0x01
private const val CAPABILITIES_TLV = 0x01
private const val SIGNING_PUBLIC_KEY_TLV = 0x02
private const val SIGNING_PUBLIC_KEY_SIZE = 32
fun decode(data: ByteArray): AuthenticatedPeerState? {
if (data.firstOrNull()?.toInt()?.and(0xFF) != VERSION) return null
var offset = 1
var capabilities: PeerCapabilities? = null
var signingPublicKey: ByteArray? = null
while (offset < data.size) {
if (offset + 2 > data.size) return null
val type = data[offset].toInt() and 0xFF
val length = data[offset + 1].toInt() and 0xFF
offset += 2
if (offset + length > data.size) return null
val value = data.copyOfRange(offset, offset + length)
offset += length
when (type) {
CAPABILITIES_TLV -> {
if (capabilities != null || length !in 1..8) return null
val decoded = PeerCapabilities.decode(value)
if (!decoded.encoded().contentEquals(value)) return null
capabilities = decoded
}
SIGNING_PUBLIC_KEY_TLV -> {
if (signingPublicKey != null || length != SIGNING_PUBLIC_KEY_SIZE) return null
signingPublicKey = value
}
else -> Unit
}
}
val decodedCapabilities = capabilities ?: return null
val decodedSigningKey = signingPublicKey ?: return null
return AuthenticatedPeerState(decodedCapabilities, decodedSigningKey)
}
}
override fun equals(other: Any?): Boolean =
this === other ||
(other is AuthenticatedPeerState &&
capabilities == other.capabilities &&
signingPublicKey.contentEquals(other.signingPublicKey))
override fun hashCode(): Int = 31 * capabilities.hashCode() + signingPublicKey.contentHashCode()
}

View File

@ -83,6 +83,9 @@ data class FragmentPayload(
* Matches iOS implementation exactly
*/
fun encode(): ByteArray {
require(index in 0..0xFFFF) { "Fragment index would truncate UInt16: $index" }
require(total in 1..0xFFFF) { "Fragment total would truncate UInt16: $total" }
require(index < total) { "Fragment index $index must be below total $total" }
val payload = ByteArray(HEADER_SIZE + data.size)
// Fragment ID (8 bytes)

View File

@ -2,7 +2,6 @@ package com.bitchat.android.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import com.bitchat.android.util.*
/**
* Identity announcement structure with TLV encoding
@ -12,7 +11,9 @@ import com.bitchat.android.util.*
data class IdentityAnnouncement(
val nickname: String,
val noisePublicKey: ByteArray, // Noise static public key (Curve25519.KeyAgreement)
val signingPublicKey: ByteArray // Ed25519 public key for signing
val signingPublicKey: ByteArray, // Ed25519 public key for signing
val capabilities: PeerCapabilities? = null,
val unknownTLVs: List<UnknownAnnouncementTLV> = emptyList()
) : Parcelable {
/**
@ -21,7 +22,8 @@ data class IdentityAnnouncement(
private enum class TLVType(val value: UByte) {
NICKNAME(0x01u),
NOISE_PUBLIC_KEY(0x02u),
SIGNING_PUBLIC_KEY(0x03u); // NEW: Ed25519 signing public key
SIGNING_PUBLIC_KEY(0x03u), // NEW: Ed25519 signing public key
CAPABILITIES(0x05u);
companion object {
fun fromValue(value: UByte): TLVType? {
@ -37,7 +39,8 @@ data class IdentityAnnouncement(
val nicknameData = nickname.toByteArray(Charsets.UTF_8)
// Check size limits
if (nicknameData.size > 255 || noisePublicKey.size > 255 || signingPublicKey.size > 255) {
if (nicknameData.size > 255 || noisePublicKey.size > 255 || signingPublicKey.size > 255 ||
unknownTLVs.any { it.value.size > 255 }) {
return null
}
@ -57,6 +60,21 @@ data class IdentityAnnouncement(
result.add(TLVType.SIGNING_PUBLIC_KEY.value.toByte())
result.add(signingPublicKey.size.toByte())
result.addAll(signingPublicKey.toList())
// Optional little-endian feature bitfield. Old clients skip this TLV.
capabilities?.encoded()?.let { capabilityBytes ->
result.add(TLVType.CAPABILITIES.value.toByte())
result.add(capabilityBytes.size.toByte())
result.addAll(capabilityBytes.toList())
}
// Preserve extensions this build does not understand. This includes
// gossip TLV 0x04 when an announcement is decoded through this model.
unknownTLVs.forEach { tlv ->
result.add(tlv.type.toByte())
result.add(tlv.value.size.toByte())
result.addAll(tlv.value.toList())
}
return result.toByteArray()
}
@ -73,6 +91,8 @@ data class IdentityAnnouncement(
var nickname: String? = null
var noisePublicKey: ByteArray? = null
var signingPublicKey: ByteArray? = null
var capabilities: PeerCapabilities? = null
val unknownTLVs = mutableListOf<UnknownAnnouncementTLV>()
while (offset + 2 <= dataCopy.size) {
// Read TLV type
@ -102,20 +122,36 @@ data class IdentityAnnouncement(
TLVType.SIGNING_PUBLIC_KEY -> {
signingPublicKey = value
}
TLVType.CAPABILITIES -> {
capabilities = PeerCapabilities.decode(value)
}
null -> {
// Unknown TLV; skip (tolerant decoder for forward compatibility)
continue
// Retain unknown extensions so callers can forward or
// re-encode the announcement without erasing them.
unknownTLVs += UnknownAnnouncementTLV(typeValue.toInt(), value)
}
}
}
// All three fields are required
return if (nickname != null && noisePublicKey != null && signingPublicKey != null) {
IdentityAnnouncement(nickname, noisePublicKey, signingPublicKey)
IdentityAnnouncement(nickname, noisePublicKey, signingPublicKey, capabilities, unknownTLVs)
} else {
null
}
}
/** Construct the announcement emitted by this Android build. */
fun forLocalPeer(
nickname: String,
noisePublicKey: ByteArray,
signingPublicKey: ByteArray
): IdentityAnnouncement = IdentityAnnouncement(
nickname = nickname,
noisePublicKey = noisePublicKey,
signingPublicKey = signingPublicKey,
capabilities = PeerCapabilities.LOCAL_SUPPORTED
)
}
// Override equals and hashCode since we use ByteArray
@ -128,6 +164,8 @@ data class IdentityAnnouncement(
if (nickname != other.nickname) return false
if (!noisePublicKey.contentEquals(other.noisePublicKey)) return false
if (!signingPublicKey.contentEquals(other.signingPublicKey)) return false
if (capabilities != other.capabilities) return false
if (unknownTLVs != other.unknownTLVs) return false
return true
}
@ -136,10 +174,12 @@ data class IdentityAnnouncement(
var result = nickname.hashCode()
result = 31 * result + noisePublicKey.contentHashCode()
result = 31 * result + signingPublicKey.contentHashCode()
result = 31 * result + (capabilities?.hashCode() ?: 0)
result = 31 * result + unknownTLVs.hashCode()
return result
}
override fun toString(): String {
return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}...)"
return "IdentityAnnouncement(nickname='$nickname', noisePublicKey=${noisePublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., signingPublicKey=${signingPublicKey.joinToString("") { "%02x".format(it) }.take(16)}..., capabilities=${capabilities?.rawValue})"
}
}

View File

@ -23,12 +23,23 @@ enum class NoisePayloadType(val value: UByte) {
DELIVERED(0x03u), // Message was delivered
VERIFY_CHALLENGE(0x10u), // Verification challenge
VERIFY_RESPONSE(0x11u), // Verification response
FILE_TRANSFER(0x20u);
FILE_TRANSFER(0x20u),
/** Authenticated capabilities + Ed25519 binding for the current Noise generation. */
PEER_STATE(0x21u);
companion object {
// #1434 prerelease iOS builds briefly emitted private files as 0x09. Keep this
// decode-only: every NoisePayload constructed by Android still encodes FILE_TRANSFER as
// its canonical 0x20 value, so the compatibility alias cannot leak into new traffic.
private val PRERELEASE_FILE_TRANSFER_RAW_VALUE = 0x09u.toUByte()
fun fromValue(value: UByte): NoisePayloadType? {
return values().find { it.value == value }
return if (value == PRERELEASE_FILE_TRANSFER_RAW_VALUE) {
FILE_TRANSFER
} else {
values().find { it.value == value }
}
}
}
}

View File

@ -0,0 +1,67 @@
package com.bitchat.android.model
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Feature bits advertised in IdentityAnnouncement TLV 0x05.
*
* The wire representation matches iOS: a minimal little-endian bitfield that
* always contains at least one byte. Unknown bits in the low 64 bits are kept
* so a decode/re-encode cycle does not erase capabilities added by newer
* clients.
*/
@Parcelize
data class PeerCapabilities(val rawValue: Long) : Parcelable {
fun contains(capability: PeerCapabilities): Boolean =
(rawValue and capability.rawValue) == capability.rawValue
fun encoded(): ByteArray {
var remaining = rawValue
val bytes = mutableListOf<Byte>()
do {
bytes += remaining.toByte()
remaining = remaining ushr 8
} while (remaining != 0L)
return bytes.toByteArray()
}
companion object {
val NONE = PeerCapabilities(0)
/** Noise-encrypted private BitchatFilePacket using payload type 0x20. */
val PRIVATE_MEDIA = PeerCapabilities(1L shl 8)
/** Capabilities implemented by this Android build. */
val LOCAL_SUPPORTED = PRIVATE_MEDIA
/**
* Decode the low 64 bits and ignore any future extension bytes, which
* is the same forward-compatible behavior used by iOS.
*/
fun decode(data: ByteArray): PeerCapabilities {
var rawValue = 0L
data.take(8).forEachIndexed { index, byte ->
rawValue = rawValue or ((byte.toLong() and 0xFF) shl (8 * index))
}
return PeerCapabilities(rawValue)
}
}
}
/** An announcement TLV not understood by this build, retained verbatim. */
@Parcelize
class UnknownAnnouncementTLV(
val type: Int,
val value: ByteArray
) : Parcelable {
init {
require(type in 0..0xFF) { "TLV type must fit in one byte" }
}
override fun equals(other: Any?): Boolean =
this === other ||
(other is UnknownAnnouncementTLV && type == other.type && value.contentEquals(other.value))
override fun hashCode(): Int = 31 * type + value.contentHashCode()
}

View File

@ -10,5 +10,10 @@ data class RoutedPacket(
val packet: BitchatPacket,
val peerID: String? = null, // Who sent it (parsed from packet.senderID)
val relayAddress: String? = null, // Address it came from (for avoiding loopback)
val transferId: String? = null // Optional stable transfer ID for progress tracking
val transferId: String? = null, // Optional stable transfer ID for progress tracking
/** Exact fragments admitted during private-media prepare; never rebuild them at commit. */
val preparedPackets: List<BitchatPacket>? = null,
// Opaque, process-local ingress identity. Unlike relayAddress, this distinguishes replacement
// sockets for the same provisional peer and must never be serialized onto the mesh.
val ingressLinkID: String? = null
)

View File

@ -149,6 +149,15 @@ class NoiseEncryptionService(private val context: Context) {
fun getPeerPublicKeyData(peerID: String): ByteArray? {
return sessionManager.getRemoteStaticKey(peerID)
}
fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? =
sessionManager.getAuthenticatedSession(peerID)
fun withAuthenticatedSession(
peerID: String,
expectedSession: AuthenticatedNoiseSession,
action: () -> Boolean
): Boolean = sessionManager.withAuthenticatedSession(peerID, expectedSession, action)
/**
* Clear persistent identity (for panic mode)
@ -179,9 +188,9 @@ class NoiseEncryptionService(private val context: Context) {
* Initiate a Noise handshake with a peer
* Returns the first handshake message to send
*/
fun initiateHandshake(peerID: String): ByteArray? {
fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? {
return try {
sessionManager.initiateHandshake(peerID)
sessionManager.initiateHandshake(peerID, replaceEstablished)
} catch (e: Exception) {
Log.e(TAG, "Failed to initiate handshake with $peerID: ${e.message}")
null
@ -194,12 +203,25 @@ class NoiseEncryptionService(private val context: Context) {
*/
fun processHandshakeMessage(data: ByteArray, peerID: String): ByteArray? {
return try {
sessionManager.processHandshakeMessage(peerID, data)
processHandshakeMessageWithResult(data, peerID).response
} catch (e: Exception) {
Log.e(TAG, "Failed to process handshake from $peerID: ${e.message}")
null
}
}
/**
* Typed handshake result for security-sensitive callers that must distinguish a null response
* from a newly authenticated session or a rejected handshake. Identity mismatch exceptions are
* intentionally propagated to the caller.
*/
@Throws(Exception::class)
fun processHandshakeMessageWithResult(
data: ByteArray,
peerID: String
): NoiseHandshakeProcessingResult {
return sessionManager.processHandshakeMessageWithResult(peerID, data)
}
/**
* Check if we have an established session with a peer
@ -234,6 +256,13 @@ class NoiseEncryptionService(private val context: Context) {
null
}
}
@Throws(Exception::class)
fun encryptForSession(
data: ByteArray,
peerID: String,
expectedSession: AuthenticatedNoiseSession
): ByteArray = sessionManager.encryptForSession(data, peerID, expectedSession)
/**
* Decrypt data from a specific peer using established Noise session
@ -251,6 +280,14 @@ class NoiseEncryptionService(private val context: Context) {
null
}
}
fun decryptWithSession(encryptedData: ByteArray, peerID: String): NoiseDecryptionResult? =
try {
sessionManager.decryptWithSession(encryptedData, peerID)
} catch (e: Exception) {
Log.e(TAG, "Failed generation-bound decryption from $peerID: ${e.message}")
null
}
// MARK: - Peer Management
@ -382,6 +419,20 @@ class NoiseEncryptionService(private val context: Context) {
// Store fingerprint mapping via centralized manager
// This is the ONLY place where fingerprints are stored - after successful Noise handshake
fingerprintManager.storeFingerprintForPeer(peerID, remoteStaticKey)
// Preserve the canonical peerID -> npub index, but only after Noise proves possession of
// the static key. Announcement-time indexing was unsafe because a peer can copy another
// party's public Noise key without possessing its private key.
try {
com.bitchat.android.favorites.FavoritesPersistenceService.shared
.findNostrPubkey(remoteStaticKey)
?.let { npub ->
com.bitchat.android.favorites.FavoritesPersistenceService.shared
.updateNostrPublicKeyForPeerID(peerID, npub)
}
} catch (_: Exception) {
// Favorites may not be initialized in isolated/background crypto tests.
}
// Calculate fingerprint for logging and callback
val fingerprint = calculateFingerprint(remoteStaticKey)

View File

@ -0,0 +1,30 @@
package com.bitchat.android.noise
import java.security.MessageDigest
/**
* Canonical binding between an authenticated Noise static key and its mesh wire identity.
*
* Mesh peer IDs are the first eight bytes of SHA-256(staticPublicKey), encoded as 16 lowercase
* hexadecimal characters. A self-signed announcement or completed Noise XX handshake is not
* sufficient on its own: the claimed wire ID must also match this derivation.
*/
object NoisePeerIdentity {
const val STATIC_PUBLIC_KEY_SIZE = 32
const val WIRE_PEER_ID_LENGTH = 16
private val wirePeerIDPattern = Regex("^[0-9a-f]{$WIRE_PEER_ID_LENGTH}$")
fun derivePeerID(staticPublicKey: ByteArray): String? {
if (staticPublicKey.size != STATIC_PUBLIC_KEY_SIZE) return null
return MessageDigest.getInstance("SHA-256")
.digest(staticPublicKey)
.take(8)
.joinToString("") { "%02x".format(it) }
}
fun matchesClaimedPeerID(claimedPeerID: String, staticPublicKey: ByteArray): Boolean {
if (!wirePeerIDPattern.matches(claimedPeerID)) return false
return derivePeerID(staticPublicKey) == claimedPeerID
}
}

View File

@ -451,27 +451,36 @@ class NoiseSession(
Log.d(TAG, "Completing XX handshake with $peerID")
try {
// Split handshake state into transport ciphers
val cipherPair = handshakeState?.split()
sendCipher = cipherPair?.getSender()
receiveCipher = cipherPair?.getReceiver()
// Extract remote static key if available
if (handshakeState?.hasRemotePublicKey() == true) {
val remoteDH = handshakeState?.getRemotePublicKey()
if (remoteDH != null) {
remoteStaticPublicKey = ByteArray(32)
remoteDH.getPublicKey(remoteStaticPublicKey!!, 0)
Log.d(TAG, "Remote static public key: ${remoteStaticPublicKey!!.joinToString("") { "%02x".format(it) }}")
}
val activeHandshake = handshakeState ?: throw NoiseSessionError.HandshakeFailed
// Authenticate the remote static key's claimed mesh identity before split creates
// transport ciphers or the session can become observable as Established.
if (!activeHandshake.hasRemotePublicKey()) throw NoiseSessionError.HandshakeFailed
val remoteDH = activeHandshake.getRemotePublicKey()
?: throw NoiseSessionError.HandshakeFailed
val authenticatedRemoteKey = ByteArray(NoisePeerIdentity.STATIC_PUBLIC_KEY_SIZE)
remoteDH.getPublicKey(authenticatedRemoteKey, 0)
val derivedPeerID = NoisePeerIdentity.derivePeerID(authenticatedRemoteKey)
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, authenticatedRemoteKey)) {
authenticatedRemoteKey.fill(0)
throw NoiseSessionError.PeerIdentityMismatch(peerID, derivedPeerID)
}
remoteStaticPublicKey = authenticatedRemoteKey
Log.d(TAG, "Remote static public key is bound to $peerID")
// Only a bound remote identity may derive transport ciphers.
val cipherPair = activeHandshake.split()
sendCipher = cipherPair.getSender()
receiveCipher = cipherPair.getReceiver()
// Extract handshake hash for channel binding
handshakeHash = handshakeState?.getHandshakeHash()
// getHandshakeHash() exposes the handshake state's backing array. Clone it before
// destroy() zeroizes that state, or every completed session appears to have the same
// all-zero channel-binding token.
handshakeHash = activeHandshake.getHandshakeHash().clone()
// Clean up handshake state
handshakeState?.destroy()
activeHandshake.destroy()
handshakeState = null
messagesSent = 0

View File

@ -3,6 +3,42 @@ package com.bitchat.android.noise
import android.util.Log
import java.util.concurrent.ConcurrentHashMap
data class NoiseHandshakeProcessingResult(
val response: ByteArray?,
val establishedNow: Boolean,
/** The bound remote static key only when this exact call completed authentication. */
val authenticatedRemoteStaticKey: ByteArray? = null,
/** Handshake hash identifying that exact authenticated Noise generation. */
val authenticatedSessionToken: ByteArray? = null
)
/** Atomic snapshot of the live authenticated Noise generation. */
class AuthenticatedNoiseSession(
remoteStaticKey: ByteArray,
sessionToken: ByteArray
) {
private val remoteStaticKeyBytes = remoteStaticKey.copyOf()
private val sessionTokenBytes = sessionToken.copyOf()
val remoteStaticKey: ByteArray get() = remoteStaticKeyBytes.copyOf()
val sessionToken: ByteArray get() = sessionTokenBytes.copyOf()
override fun equals(other: Any?): Boolean =
this === other ||
(other is AuthenticatedNoiseSession &&
remoteStaticKeyBytes.contentEquals(other.remoteStaticKeyBytes) &&
sessionTokenBytes.contentEquals(other.sessionTokenBytes))
override fun hashCode(): Int =
31 * remoteStaticKeyBytes.contentHashCode() + sessionTokenBytes.contentHashCode()
}
/** Plaintext and generation binding captured atomically from the session that decrypted it. */
data class NoiseDecryptionResult(
val plaintext: ByteArray,
val authenticatedSession: AuthenticatedNoiseSession
)
/**
* SIMPLIFIED Noise session manager - focuses on core functionality only
*/
@ -16,9 +52,13 @@ class NoiseSessionManager(
private const val TAG = "NoiseSessionManager"
private const val HANDSHAKE_TIMEOUT_MS = 20_000L
private const val HANDSHAKE_MESSAGE_1_SIZE = 32
private const val SESSION_TOKEN_SIZE = 32
}
private val sessions = ConcurrentHashMap<String, NoiseSession>()
// An inbound replacement handshake must prove its authenticated static-key binding before it
// can evict a working transport session. Keep responder candidates outside the active map.
private val responderCandidates = ConcurrentHashMap<String, NoiseSession>()
// Callbacks
var onSessionEstablished: ((String, ByteArray) -> Unit)? = null
@ -29,8 +69,10 @@ class NoiseSessionManager(
/**
* Add new session for a peer
*/
@Synchronized
fun addSession(peerID: String, session: NoiseSession) {
sessions[peerID] = session
val previous = sessions.put(peerID, session)
if (previous != null && previous !== session) previous.destroy()
Log.d(TAG, "Added new session for $peerID")
}
@ -45,16 +87,18 @@ class NoiseSessionManager(
/**
* Remove session for a peer
*/
@Synchronized
fun removeSession(peerID: String) {
sessions[peerID]?.destroy()
sessions.remove(peerID)
sessions.remove(peerID)?.destroy()
responderCandidates.remove(peerID)?.destroy()
Log.d(TAG, "Removed session for $peerID")
}
/**
* SIMPLIFIED: Initiate handshake - no tie breaker, just start
*/
fun initiateHandshake(peerID: String): ByteArray? {
@Synchronized
fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? {
Log.d(TAG, "initiateHandshake($peerID)")
val now = System.currentTimeMillis()
@ -62,8 +106,20 @@ class NoiseSessionManager(
if (existing != null) {
when {
existing.isEstablished() -> {
Log.d(TAG, "Handshake already established with $peerID, skipping initiate")
return null
if (!replaceEstablished) {
Log.d(TAG, "Handshake already established with $peerID, skipping initiate")
return null
}
val candidate = createSession(peerID, isInitiator = true)
responderCandidates.remove(peerID)?.destroy()
responderCandidates[peerID] = candidate
return try {
candidate.startHandshake()
} catch (e: Exception) {
responderCandidates.remove(peerID, candidate)
candidate.destroy()
throw e
}
}
existing.isHandshaking() -> {
if (!isHandshakeStale(existing, now)) {
@ -94,7 +150,7 @@ class NoiseSessionManager(
Log.d(TAG, "Started handshake with $peerID as INITIATOR")
return handshakeData
} catch (e: Exception) {
sessions.remove(peerID)
if (sessions.remove(peerID, session)) session.destroy()
throw e
}
}
@ -103,62 +159,156 @@ class NoiseSessionManager(
* Handle incoming handshake message
*/
fun processHandshakeMessage(peerID: String, message: ByteArray): ByteArray? {
Log.d(TAG, "processHandshakeMessage($peerID, ${message.size} bytes)")
try {
var session = getSession(peerID)
return processHandshakeMessageWithResult(peerID, message).response
}
// Collision handling: both sides initiated and we received message 1
if (session != null &&
session.isHandshaking() &&
session.isInitiatorRole() &&
message.size == HANDSHAKE_MESSAGE_1_SIZE
) {
val shouldYield = localPeerID > peerID
if (shouldYield) {
Log.d(TAG, "Handshake collision with $peerID; yielding to responder role")
removeSession(peerID)
session = null
@Synchronized
fun processHandshakeMessageWithResult(
peerID: String,
message: ByteArray
): NoiseHandshakeProcessingResult {
Log.d(TAG, "processHandshakeMessage($peerID, ${message.size} bytes)")
var activeSession: NoiseSession? = null
var isReplacementCandidate = false
var establishedRemoteKey: ByteArray? = null
var establishedSessionToken: ByteArray? = null
var response: ByteArray? = null
try {
val existingCandidate = responderCandidates[peerID]
if (existingCandidate != null) {
activeSession = if (message.size == HANDSHAKE_MESSAGE_1_SIZE) {
if (existingCandidate.isInitiatorRole()) {
val shouldYield = localPeerID > peerID
if (!shouldYield) {
Log.d(
TAG,
"Replacement handshake collision with $peerID; keeping initiator role"
)
return NoiseHandshakeProcessingResult(
response = null,
establishedNow = false
)
}
Log.d(
TAG,
"Replacement handshake collision with $peerID; yielding to responder role"
)
}
responderCandidates.remove(peerID, existingCandidate)
existingCandidate.destroy()
createSession(peerID, isInitiator = false).also {
responderCandidates[peerID] = it
}
} else {
Log.d(TAG, "Handshake collision with $peerID; keeping initiator role")
return null
existingCandidate
}
isReplacementCandidate = true
} else {
var session = getSession(peerID)
// Collision handling: both sides initiated and we received message 1.
if (session != null &&
session.isHandshaking() &&
session.isInitiatorRole() &&
message.size == HANDSHAKE_MESSAGE_1_SIZE
) {
val shouldYield = localPeerID > peerID
if (shouldYield) {
Log.d(TAG, "Handshake collision with $peerID; yielding to responder role")
if (sessions.remove(peerID, session)) session.destroy()
session = null
} else {
Log.d(TAG, "Handshake collision with $peerID; keeping initiator role")
return NoiseHandshakeProcessingResult(response = null, establishedNow = false)
}
}
activeSession = when {
session == null -> {
Log.d(TAG, "Creating new RESPONDER session for $peerID")
createSession(peerID, isInitiator = false).also { sessions[peerID] = it }
}
session.isEstablished() -> {
Log.d(
TAG,
"Validating replacement handshake for $peerID while preserving active session"
)
isReplacementCandidate = true
createSession(peerID, isInitiator = false).also {
responderCandidates[peerID] = it
}
}
session.isHandshaking() &&
!session.isInitiatorRole() &&
message.size == HANDSHAKE_MESSAGE_1_SIZE -> {
// A restarted responder handshake can replace an incomplete session because
// there is no working transport state to preserve.
if (sessions.remove(peerID, session)) session.destroy()
createSession(peerID, isInitiator = false).also { sessions[peerID] = it }
}
else -> session
}
}
// If no session exists, create one as responder
if (session == null) {
Log.d(TAG, "Creating new RESPONDER session for $peerID")
session = NoiseSession(
peerID = peerID,
isInitiator = false,
localStaticPrivateKey = localStaticPrivateKey,
localStaticPublicKey = localStaticPublicKey
)
addSession(peerID, session)
}
// Process handshake message
val response = session.processHandshakeMessage(message)
// Check if session is established
val session = activeSession ?: throw NoiseSessionError.InvalidState
response = session.processHandshakeMessage(message)
if (session.isEstablished()) {
Log.d(TAG, "✅ Session ESTABLISHED with $peerID")
val remoteStaticKey = session.getRemoteStaticPublicKey()
if (remoteStaticKey != null) {
onSessionEstablished?.invoke(peerID, remoteStaticKey)
?: throw NoiseSessionError.HandshakeFailed
val derivedPeerID = NoisePeerIdentity.derivePeerID(remoteStaticKey)
if (!NoisePeerIdentity.matchesClaimedPeerID(peerID, remoteStaticKey)) {
throw NoiseSessionError.PeerIdentityMismatch(peerID, derivedPeerID)
}
val sessionToken = session.getHandshakeHash()
?.takeIf { it.size == SESSION_TOKEN_SIZE && it.any { byte -> byte != 0.toByte() } }
?: throw NoiseSessionError.HandshakeFailed
if (isReplacementCandidate) {
responderCandidates.remove(peerID, session)
val previous = sessions.put(peerID, session)
if (previous != null && previous !== session) previous.destroy()
}
establishedRemoteKey = remoteStaticKey
establishedSessionToken = sessionToken
Log.d(TAG, "✅ Session ESTABLISHED with bound identity $peerID")
}
return response
} catch (e: Exception) {
val session = activeSession
if (session != null) {
if (isReplacementCandidate) {
responderCandidates.remove(peerID, session)
} else {
sessions.remove(peerID, session)
}
session.destroy()
}
Log.e(TAG, "Handshake failed with $peerID: ${e.message}")
sessions.remove(peerID)
onSessionFailed?.invoke(peerID, e)
runCatching { onSessionFailed?.invoke(peerID, e) }
throw e
}
establishedRemoteKey?.let { onSessionEstablished?.invoke(peerID, it) }
return NoiseHandshakeProcessingResult(
response = response,
establishedNow = establishedRemoteKey != null,
authenticatedRemoteStaticKey = establishedRemoteKey?.clone(),
authenticatedSessionToken = establishedSessionToken?.clone()
)
}
private fun createSession(peerID: String, isInitiator: Boolean): NoiseSession = NoiseSession(
peerID = peerID,
isInitiator = isInitiator,
localStaticPrivateKey = localStaticPrivateKey,
localStaticPublicKey = localStaticPublicKey
)
private fun isHandshakeStale(session: NoiseSession, nowMs: Long): Boolean {
val lastActivity = session.getLastHandshakeActivityMs() ?: session.getHandshakeStartMs()
if (lastActivity == null) return false
@ -168,6 +318,7 @@ class NoiseSessionManager(
/**
* SIMPLIFIED: Encrypt data
*/
@Synchronized
fun encrypt(data: ByteArray, peerID: String): ByteArray {
val session = getSession(peerID) ?: throw IllegalStateException("No session found for $peerID")
if (!session.isEstablished()) {
@ -175,11 +326,29 @@ class NoiseSessionManager(
}
return session.encrypt(data)
}
/** Encrypt only if the exact generation that authorized the operation is still active. */
@Synchronized
fun encryptForSession(
data: ByteArray,
peerID: String,
expectedSession: AuthenticatedNoiseSession
): ByteArray {
val session = getSession(peerID) ?: throw NoiseSessionError.SessionNotFound
if (!session.isEstablished()) throw NoiseSessionError.SessionNotEstablished
val current = authenticatedSession(session) ?: throw NoiseSessionError.SessionNotEstablished
if (current != expectedSession) throw NoiseSessionError.SessionGenerationChanged
return session.encrypt(data)
}
/**
* SIMPLIFIED: Decrypt data
*/
fun decrypt(encryptedData: ByteArray, peerID: String): ByteArray {
fun decrypt(encryptedData: ByteArray, peerID: String): ByteArray =
decryptWithSession(encryptedData, peerID).plaintext
@Synchronized
fun decryptWithSession(encryptedData: ByteArray, peerID: String): NoiseDecryptionResult {
val session = getSession(peerID)
if (session == null) {
Log.e(TAG, "No session found for $peerID when trying to decrypt")
@ -189,7 +358,10 @@ class NoiseSessionManager(
Log.e(TAG, "Session not established with $peerID when trying to decrypt")
throw IllegalStateException("Session not established with $peerID")
}
return session.decrypt(encryptedData)
val plaintext = session.decrypt(encryptedData)
val authenticatedSession = authenticatedSession(session)
?: throw IllegalStateException("Established session for $peerID has no channel binding")
return NoiseDecryptionResult(plaintext, authenticatedSession)
}
/**
@ -211,15 +383,42 @@ class NoiseSessionManager(
/**
* Get remote static public key for a peer (if session established)
*/
fun getRemoteStaticKey(peerID: String): ByteArray? {
return getSession(peerID)?.getRemoteStaticPublicKey()
fun getRemoteStaticKey(peerID: String): ByteArray? =
getAuthenticatedSession(peerID)?.remoteStaticKey
@Synchronized
fun getAuthenticatedSession(peerID: String): AuthenticatedNoiseSession? {
val session = getSession(peerID) ?: return null
if (!session.isEstablished()) return null
return authenticatedSession(session)
}
/** Execute a state transition while preventing replacement/removal of the expected session. */
@Synchronized
fun withAuthenticatedSession(
peerID: String,
expectedSession: AuthenticatedNoiseSession,
action: () -> Boolean
): Boolean {
val session = getSession(peerID) ?: return false
if (!session.isEstablished()) return false
if (authenticatedSession(session) != expectedSession) return false
return action()
}
/**
* Get handshake hash for channel binding (if session established)
*/
fun getHandshakeHash(peerID: String): ByteArray? {
return getSession(peerID)?.getHandshakeHash()
return getAuthenticatedSession(peerID)?.sessionToken
}
private fun authenticatedSession(session: NoiseSession): AuthenticatedNoiseSession? {
val remoteStaticKey = session.getRemoteStaticPublicKey()?.takeIf { it.size == 32 } ?: return null
val sessionToken = session.getHandshakeHash()?.takeIf {
it.size == SESSION_TOKEN_SIZE && it.any { byte -> byte != 0.toByte() }
} ?: return null
return AuthenticatedNoiseSession(remoteStaticKey, sessionToken)
}
/**
@ -239,6 +438,7 @@ class NoiseSessionManager(
fun getDebugInfo(): String = buildString {
appendLine("=== Noise Session Manager Debug ===")
appendLine("Active sessions: ${sessions.size}")
appendLine("Responder candidates: ${responderCandidates.size}")
appendLine("")
if (sessions.isNotEmpty()) {
@ -252,9 +452,12 @@ class NoiseSessionManager(
/**
* Shutdown manager and clean up all sessions
*/
@Synchronized
fun shutdown() {
sessions.values.forEach { it.destroy() }
responderCandidates.values.forEach { it.destroy() }
sessions.clear()
responderCandidates.clear()
Log.d(TAG, "Noise session manager shut down")
}
}
@ -268,4 +471,8 @@ sealed class NoiseSessionError(message: String, cause: Throwable? = null) : Exce
object InvalidState : NoiseSessionError("Session in invalid state")
object HandshakeFailed : NoiseSessionError("Handshake failed")
object AlreadyEstablished : NoiseSessionError("Session already established")
object SessionGenerationChanged : NoiseSessionError("Noise session generation changed")
class PeerIdentityMismatch(claimedPeerID: String, derivedPeerID: String?) : NoiseSessionError(
"Authenticated Noise key derives to ${derivedPeerID ?: "invalid"}, not claimed peer $claimedPeerID"
)
}

View File

@ -216,7 +216,8 @@ class NostrDirectMessageHandler(
}
}
NoisePayloadType.VERIFY_CHALLENGE,
NoisePayloadType.VERIFY_RESPONSE -> Unit // Ignore verification payloads in Nostr direct messages
NoisePayloadType.VERIFY_RESPONSE,
NoisePayloadType.PEER_STATE -> Unit // Peer state is bound to a live mesh Noise generation.
}
}

View File

@ -75,7 +75,15 @@ object TransportBridgeService {
val targets = transports.filterKeys { it != sourceId }
if (targets.isEmpty()) return
val forwardedPacket = prepareForwardedPacket("broadcast", packet.packet) ?: return
val forwarded = packet.copy(packet = forwardedPacket)
// Prepared private-media fragments must remain the admitted plan when
// crossing transports, but relay TTL still has to advance on every
// hop. TTL is excluded from the signature and does not affect size.
val forwarded = packet.copy(
packet = forwardedPacket,
preparedPackets = packet.preparedPackets?.map { prepared ->
prepared.copy(ttl = forwardedPacket.ttl)
}
)
// Log.v(TAG, "Bridging packet type ${packet.packet.type} from $sourceId to ${targets.keys}")

View File

@ -59,6 +59,7 @@ fun ChatScreen(viewModel: ChatViewModel) {
val privateChatSheetPeer by viewModel.privateChatSheetPeer.collectAsStateWithLifecycle()
val showVerificationSheet by viewModel.showVerificationSheet.collectAsStateWithLifecycle()
val showSecurityVerificationSheet by viewModel.showSecurityVerificationSheet.collectAsStateWithLifecycle()
val legacyPrivateMediaConsent by viewModel.legacyPrivateMediaConsent.collectAsStateWithLifecycle()
var messageText by remember { mutableStateOf(TextFieldValue("")) }
var showPasswordPrompt by remember { mutableStateOf(false) }
@ -344,6 +345,33 @@ fun ChatScreen(viewModel: ChatViewModel) {
showMeshPeerListSheet = showMeshPeerListSheet,
onMeshPeerListDismiss = viewModel::hideMeshPeerList,
)
legacyPrivateMediaConsent?.let { request ->
AlertDialog(
onDismissRequest = { viewModel.cancelLegacyPrivateMedia(request.requestId) },
title = { Text(stringResource(com.bitchat.android.R.string.private_media_legacy_title)) },
text = {
Text(
stringResource(
com.bitchat.android.R.string.private_media_legacy_body,
request.fileName,
request.recipientNickname,
request.warning
)
)
},
confirmButton = {
TextButton(onClick = { viewModel.approveLegacyPrivateMedia(request.requestId) }) {
Text(stringResource(com.bitchat.android.R.string.private_media_legacy_send_once))
}
},
dismissButton = {
TextButton(onClick = { viewModel.cancelLegacyPrivateMedia(request.requestId) }) {
Text(stringResource(android.R.string.cancel))
}
}
)
}
}
@Composable

View File

@ -65,6 +65,14 @@ class ChatViewModel(
mediaSendingManager.sendImageNote(toPeerIDOrNull, channelOrNull, filePath)
}
fun approveLegacyPrivateMedia(requestId: String) {
mediaSendingManager.approveLegacyPrivateMedia(requestId)
}
fun cancelLegacyPrivateMedia(requestId: String) {
mediaSendingManager.cancelLegacyPrivateMedia(requestId)
}
fun getCurrentNpub(): String? {
return try {
NostrIdentityBridge
@ -121,7 +129,12 @@ class ChatViewModel(
val verifiedFingerprints = verificationHandler.verifiedFingerprints
// Media file sending manager
private val mediaSendingManager = MediaSendingManager(state, messageManager, channelManager) { mesh }
private val mediaSendingManager = MediaSendingManager(
state,
messageManager,
channelManager,
viewModelScope
) { mesh }
// Delegate handler for mesh callbacks
private val meshDelegateHandler = MeshDelegateHandler(
@ -182,6 +195,8 @@ class ChatViewModel(
val privateChatSheetPeer: StateFlow<String?> = state.privateChatSheetPeer
val showVerificationSheet: StateFlow<Boolean> = state.showVerificationSheet
val showSecurityVerificationSheet: StateFlow<Boolean> = state.showSecurityVerificationSheet
val legacyPrivateMediaConsent: StateFlow<LegacyPrivateMediaConsentRequest?> =
mediaSendingManager.legacyPrivateMediaConsent
val selectedLocationChannel: StateFlow<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
val isTeleported: StateFlow<Boolean> = state.isTeleported
val geohashPeople: StateFlow<List<GeoPerson>> = state.geohashPeople
@ -919,6 +934,10 @@ class ChatViewModel(
override fun didReceiveVerifyResponse(peerID: String, payload: ByteArray, timestampMs: Long) {
verificationHandler.didReceiveVerifyResponse(peerID, payload)
}
override fun didResolvePrivateMediaPolicy(peerID: String) {
mediaSendingManager.retryPendingPrivateMedia(peerID)
}
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
return meshDelegateHandler.decryptChannelMessage(encryptedContent, channel)
@ -936,6 +955,10 @@ class ChatViewModel(
fun panicClearAllData() {
Log.w(TAG, "🚨 PANIC MODE ACTIVATED - Clearing all sensitive data")
// A pending one-shot downgrade confirmation must not survive panic or
// become actionable against the fresh post-wipe identity.
mediaSendingManager.clearPendingPrivateMediaConsent()
// Clear all UI managers
messageManager.clearAllMessages()

View File

@ -5,8 +5,26 @@ import com.bitchat.android.mesh.MeshService
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.BitchatMessageType
import com.bitchat.android.mesh.PrivateMediaPreparation
import java.util.Date
import java.security.MessageDigest
import java.util.UUID
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class LegacyPrivateMediaConsentRequest(
val requestId: String,
val recipientNickname: String,
val fileName: String,
val warning: String
)
/**
* Handles media file sending operations (voice notes, images, generic files)
@ -16,6 +34,8 @@ class MediaSendingManager(
private val state: ChatState,
private val messageManager: MessageManager,
private val channelManager: ChannelManager,
private val scope: CoroutineScope,
private val mediaWorkDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val getMeshService: () -> MeshService
) {
// Helper to get current mesh service (may change after panic clear)
@ -24,35 +44,78 @@ class MediaSendingManager(
companion object {
private const val TAG = "MediaSendingManager"
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit
private const val PENDING_PRIVATE_MEDIA_TIMEOUT_MS = 15_000L
}
// Track in-flight transfer progress: transferId -> messageId and reverse
private val transferMessageMap = mutableMapOf<String, String>()
private val messageTransferMap = mutableMapOf<String, String>()
private val pendingConsentLock = Any()
private val _legacyPrivateMediaConsent = MutableStateFlow<LegacyPrivateMediaConsentRequest?>(null)
val legacyPrivateMediaConsent: StateFlow<LegacyPrivateMediaConsentRequest?> =
_legacyPrivateMediaConsent.asStateFlow()
private data class PendingPrivateMedia(
val request: LegacyPrivateMediaConsentRequest,
val peerID: String,
val filePacket: BitchatFilePacket,
val filePath: String,
val messageType: BitchatMessageType,
val transferId: String
)
private var pendingPrivateMedia: PendingPrivateMedia? = null
private data class PendingAutomaticPrivateMedia(
val requestId: String,
val peerID: String,
val filePacket: BitchatFilePacket,
val filePath: String,
val messageType: BitchatMessageType,
val transferId: String,
val allowLegacyFallback: Boolean
)
private var pendingAutomaticPrivateMedia: PendingAutomaticPrivateMedia? = null
private var evaluatingAutomaticRequestId: String? = null
private var automaticRetryRequestedFor: String? = null
private var pendingAutomaticTimeoutRequestId: String? = null
/**
* Send a voice note (audio file)
*/
fun sendVoiceNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
try {
val file = java.io.File(filePath)
if (!file.exists()) {
Log.e(TAG, "❌ File does not exist: $filePath")
return
}
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
return
}
scope.launch {
sendVoiceNoteAsync(toPeerIDOrNull, channelOrNull, filePath)
}
}
val filePacket = BitchatFilePacket(
fileName = file.name,
fileSize = file.length(),
mimeType = "audio/mp4",
content = file.readBytes()
)
private suspend fun sendVoiceNoteAsync(
toPeerIDOrNull: String?,
channelOrNull: String?,
filePath: String
) {
try {
val filePacket = withContext(mediaWorkDispatcher) {
val file = java.io.File(filePath)
if (!file.exists()) {
Log.e(TAG, "❌ File does not exist: $filePath")
return@withContext null
}
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
return@withContext null
}
BitchatFilePacket(
fileName = file.name,
fileSize = file.length(),
mimeType = "audio/mp4",
content = file.readBytes()
)
} ?: return
if (toPeerIDOrNull != null) {
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Audio)
@ -68,26 +131,38 @@ class MediaSendingManager(
* Send an image file
*/
fun sendImageNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
try {
Log.d(TAG, "🔄 Starting image send: $filePath")
val file = java.io.File(filePath)
if (!file.exists()) {
Log.e(TAG, "❌ File does not exist: $filePath")
return
}
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
return
}
scope.launch {
sendImageNoteAsync(toPeerIDOrNull, channelOrNull, filePath)
}
}
val filePacket = BitchatFilePacket(
fileName = file.name,
fileSize = file.length(),
mimeType = "image/jpeg",
content = file.readBytes()
)
private suspend fun sendImageNoteAsync(
toPeerIDOrNull: String?,
channelOrNull: String?,
filePath: String
) {
try {
val filePacket = withContext(mediaWorkDispatcher) {
Log.d(TAG, "🔄 Starting image send: $filePath")
val file = java.io.File(filePath)
if (!file.exists()) {
Log.e(TAG, "❌ File does not exist: $filePath")
return@withContext null
}
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
return@withContext null
}
BitchatFilePacket(
fileName = file.name,
fileSize = file.length(),
mimeType = "image/jpeg",
content = file.readBytes()
)
} ?: return
if (toPeerIDOrNull != null) {
sendPrivateFile(toPeerIDOrNull, filePacket, filePath, BitchatMessageType.Image)
@ -106,49 +181,67 @@ class MediaSendingManager(
* Send a generic file
*/
fun sendFileNote(toPeerIDOrNull: String?, channelOrNull: String?, filePath: String) {
scope.launch {
sendFileNoteAsync(toPeerIDOrNull, channelOrNull, filePath)
}
}
private suspend fun sendFileNoteAsync(
toPeerIDOrNull: String?,
channelOrNull: String?,
filePath: String
) {
try {
Log.d(TAG, "🔄 Starting file send: $filePath")
val file = java.io.File(filePath)
if (!file.exists()) {
Log.e(TAG, "❌ File does not exist: $filePath")
return
}
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
return
}
val filePacket = withContext(mediaWorkDispatcher) {
Log.d(TAG, "🔄 Starting file send: $filePath")
val file = java.io.File(filePath)
if (!file.exists()) {
Log.e(TAG, "❌ File does not exist: $filePath")
return@withContext null
}
Log.d(TAG, "📁 File exists: size=${file.length()} bytes, name=${file.name}")
// Use the real MIME type based on extension; fallback to octet-stream
val mimeType = try {
com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name)
} catch (_: Exception) {
"application/octet-stream"
}
Log.d(TAG, "🏷️ MIME type: $mimeType")
if (file.length() > MAX_FILE_SIZE) {
Log.e(TAG, "❌ File too large: ${file.length()} bytes (max: $MAX_FILE_SIZE)")
return@withContext null
}
// Try to preserve the original file name if our copier prefixed it earlier
val originalName = run {
val name = file.name
val base = name.substringBeforeLast('.')
val ext = name.substringAfterLast('.', "").let { if (it.isNotBlank()) ".${it}" else "" }
val stripped = Regex("^send_\\d+_(.+)$").matchEntire(base)?.groupValues?.getOrNull(1) ?: base
stripped + ext
}
Log.d(TAG, "📝 Original filename: $originalName")
// Use the real MIME type based on extension; fallback to octet-stream
val mimeType = try {
com.bitchat.android.features.file.FileUtils.getMimeTypeFromExtension(file.name)
} catch (_: Exception) {
"application/octet-stream"
}
Log.d(TAG, "🏷️ MIME type: $mimeType")
val filePacket = BitchatFilePacket(
fileName = originalName,
fileSize = file.length(),
mimeType = mimeType,
content = file.readBytes()
)
// Try to preserve the original file name if our copier prefixed it earlier
val originalName = run {
val name = file.name
val base = name.substringBeforeLast('.')
val ext = name.substringAfterLast('.', "").let {
if (it.isNotBlank()) ".${it}" else ""
}
val stripped = Regex("^send_\\d+_(.+)$")
.matchEntire(base)
?.groupValues
?.getOrNull(1)
?: base
stripped + ext
}
Log.d(TAG, "📝 Original filename: $originalName")
BitchatFilePacket(
fileName = originalName,
fileSize = file.length(),
mimeType = mimeType,
content = file.readBytes()
)
} ?: return
Log.d(TAG, "📦 Created file packet successfully")
val messageType = when {
mimeType.lowercase().startsWith("image/") -> BitchatMessageType.Image
mimeType.lowercase().startsWith("audio/") -> BitchatMessageType.Audio
filePacket.mimeType.lowercase().startsWith("image/") -> BitchatMessageType.Image
filePacket.mimeType.lowercase().startsWith("audio/") -> BitchatMessageType.Audio
else -> BitchatMessageType.File
}
@ -168,26 +261,324 @@ class MediaSendingManager(
/**
* Send a file privately (encrypted)
*/
private fun sendPrivateFile(
private suspend fun sendPrivateFile(
toPeerID: String,
filePacket: BitchatFilePacket,
filePath: String,
messageType: BitchatMessageType
) {
val payload = filePacket.encode()
if (payload == null) {
Log.e(TAG, "❌ Failed to encode file packet for private send")
return
}
val payload = withContext(mediaWorkDispatcher) { filePacket.encode() }
?: run {
Log.e(TAG, "❌ Failed to encode file packet for private send")
return
}
Log.d(TAG, "🔒 Encoded private packet: ${payload.size} bytes")
val transferId = sha256Hex(payload)
val contentHash = sha256Hex(filePacket.content)
val (transferId, contentHash) = withContext(mediaWorkDispatcher) {
sha256Hex(payload) to sha256Hex(filePacket.content)
}
Log.d(TAG, "📤 FILE_TRANSFER send (private): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, to=${toPeerID.take(8)} transferId=${transferId.take(16)}…")
val pending = PendingAutomaticPrivateMedia(
requestId = UUID.randomUUID().toString(),
peerID = toPeerID,
filePacket = filePacket,
filePath = filePath,
messageType = messageType,
transferId = transferId,
allowLegacyFallback = false
)
if (!reserveAutomaticPending(pending)) {
addPrivateMediaSystemMessage(
toPeerID,
"Private media was not sent because another secure media send is still pending."
)
return
}
evaluateAutomaticPending(pending)
}
/**
* Consume consent exactly once, then re-run policy and final-packet
* admission. A capability pin appearing while the dialog was open upgrades
* this send to encrypted rather than forcing the legacy path.
*/
fun approveLegacyPrivateMedia(requestId: String) {
scope.launch {
approveLegacyPrivateMediaAsync(requestId)
}
}
private suspend fun approveLegacyPrivateMediaAsync(requestId: String) {
val pending = consumePendingConsent(requestId) ?: return
val automatic = PendingAutomaticPrivateMedia(
requestId = UUID.randomUUID().toString(),
peerID = pending.peerID,
filePacket = pending.filePacket,
filePath = pending.filePath,
messageType = pending.messageType,
transferId = pending.transferId,
allowLegacyFallback = true
)
if (!reserveAutomaticPending(automatic)) {
addPrivateMediaSystemMessage(
pending.peerID,
"Private media was not sent because another secure media send is still pending."
)
return
}
evaluateAutomaticPending(automatic)
}
fun cancelLegacyPrivateMedia(requestId: String) {
consumePendingConsent(requestId)
}
fun clearPendingPrivateMediaConsent() {
synchronized(pendingConsentLock) {
pendingPrivateMedia = null
pendingAutomaticPrivateMedia = null
evaluatingAutomaticRequestId = null
automaticRetryRequestedFor = null
pendingAutomaticTimeoutRequestId = null
_legacyPrivateMediaConsent.value = null
}
}
/** Retry the exact first-send intent after peer-state proof or watchdog resolution. */
fun retryPendingPrivateMedia(peerID: String) {
scope.launch { retryPendingPrivateMediaOnScope(peerID) }
}
private suspend fun retryPendingPrivateMediaOnScope(peerID: String) {
val pending = synchronized(pendingConsentLock) {
pendingAutomaticPrivateMedia
?.takeIf { it.peerID == peerID }
} ?: return
evaluateAutomaticPending(pending)
}
private suspend fun evaluateAutomaticPending(pending: PendingAutomaticPrivateMedia) {
val acquired = synchronized(pendingConsentLock) {
if (pendingAutomaticPrivateMedia?.requestId != pending.requestId) {
return@synchronized false
}
if (evaluatingAutomaticRequestId == pending.requestId) {
automaticRetryRequestedFor = pending.requestId
return@synchronized false
}
evaluatingAutomaticRequestId = pending.requestId
true
}
if (!acquired) return
while (true) {
val preparation = try {
withContext(mediaWorkDispatcher) {
meshService.prepareFilePrivate(
recipientPeerID = pending.peerID,
file = pending.filePacket,
transferId = pending.transferId,
allowLegacyFallback = pending.allowLegacyFallback
)
}
} catch (error: Exception) {
PrivateMediaPreparation.Rejected(
error.message ?: "Secure private-media preparation failed"
)
}
val stillCurrent = synchronized(pendingConsentLock) {
pendingAutomaticPrivateMedia?.requestId == pending.requestId
}
if (stillCurrent) handlePrivatePreparation(preparation, pending)
val rerun = synchronized(pendingConsentLock) {
if (evaluatingAutomaticRequestId == pending.requestId) {
evaluatingAutomaticRequestId = null
}
val requested = automaticRetryRequestedFor == pending.requestId &&
pendingAutomaticPrivateMedia?.requestId == pending.requestId
if (automaticRetryRequestedFor == pending.requestId) {
automaticRetryRequestedFor = null
}
if (requested) evaluatingAutomaticRequestId = pending.requestId
requested
}
if (!rerun) return
}
}
private fun handlePrivatePreparation(
preparation: PrivateMediaPreparation,
pending: PendingAutomaticPrivateMedia
) {
when (preparation) {
is PrivateMediaPreparation.Ready -> {
clearAutomaticPending(pending.requestId)
commitPreparedPrivateFile(
preparation,
pending.peerID,
pending.filePath,
pending.messageType,
pending.transferId
)
}
is PrivateMediaPreparation.RequiresLegacyConsent -> {
clearAutomaticPending(pending.requestId)
if (pending.allowLegacyFallback) {
Log.w(TAG, "Legacy consent was consumed but policy still requested consent; send aborted")
addPrivateMediaSystemMessage(
pending.peerID,
"Private media was not sent because its security policy changed."
)
return
}
val nickname = try {
meshService.getPeerNicknames()[pending.peerID]
} catch (_: Exception) {
null
} ?: pending.peerID.take(8)
val request = LegacyPrivateMediaConsentRequest(
requestId = UUID.randomUUID().toString(),
recipientNickname = nickname,
fileName = pending.filePacket.fileName,
warning = preparation.warning
)
synchronized(pendingConsentLock) {
if (pendingPrivateMedia != null) {
Log.w(TAG, "A legacy private-media consent prompt is already pending")
return
}
pendingPrivateMedia = PendingPrivateMedia(
request,
pending.peerID,
pending.filePacket,
pending.filePath,
pending.messageType,
pending.transferId
)
_legacyPrivateMediaConsent.value = request
}
}
PrivateMediaPreparation.NeedsHandshake -> {
ensureAutomaticPendingTimeout(pending)
Log.i(TAG, "Private media needs a Noise handshake; retaining first-send intent")
try {
meshService.initiateNoiseHandshake(pending.peerID)
} catch (e: Exception) {
Log.w(TAG, "Could not initiate private-media Noise handshake: ${e.message}")
}
}
PrivateMediaPreparation.AwaitingPeerState -> {
ensureAutomaticPendingTimeout(pending)
Log.i(TAG, "Private media is waiting for authenticated peer state; first-send intent retained")
}
is PrivateMediaPreparation.Rejected -> {
clearAutomaticPending(pending.requestId)
Log.w(TAG, "Private media not sent: ${preparation.reason}")
addPrivateMediaSystemMessage(
pending.peerID,
"Private media was not sent: ${preparation.reason}"
)
}
}
}
private fun reserveAutomaticPending(pending: PendingAutomaticPrivateMedia): Boolean =
synchronized(pendingConsentLock) {
if (pendingAutomaticPrivateMedia != null) return@synchronized false
pendingAutomaticPrivateMedia = pending
true
}
private fun ensureAutomaticPendingTimeout(pending: PendingAutomaticPrivateMedia) {
val shouldStart = synchronized(pendingConsentLock) {
if (pendingAutomaticPrivateMedia?.requestId != pending.requestId ||
pendingAutomaticTimeoutRequestId == pending.requestId
) return@synchronized false
pendingAutomaticTimeoutRequestId = pending.requestId
true
}
if (!shouldStart) return
scope.launch {
delay(PENDING_PRIVATE_MEDIA_TIMEOUT_MS)
val expired = synchronized(pendingConsentLock) {
if (pendingAutomaticPrivateMedia?.requestId != pending.requestId) false
else {
pendingAutomaticPrivateMedia = null
if (automaticRetryRequestedFor == pending.requestId) {
automaticRetryRequestedFor = null
}
if (pendingAutomaticTimeoutRequestId == pending.requestId) {
pendingAutomaticTimeoutRequestId = null
}
true
}
}
if (expired) {
addPrivateMediaSystemMessage(
pending.peerID,
"Private media was not sent because secure session setup timed out."
)
}
}
}
private fun clearAutomaticPending(requestId: String) {
synchronized(pendingConsentLock) {
if (pendingAutomaticPrivateMedia?.requestId == requestId) {
pendingAutomaticPrivateMedia = null
}
if (automaticRetryRequestedFor == requestId) automaticRetryRequestedFor = null
if (pendingAutomaticTimeoutRequestId == requestId) {
pendingAutomaticTimeoutRequestId = null
}
}
}
private fun addPrivateMediaSystemMessage(peerID: String, text: String) {
messageManager.addPrivateMessageNoUnread(
peerID,
BitchatMessage(
sender = "system",
content = text,
timestamp = Date(),
isRelay = false,
isPrivate = true,
senderPeerID = peerID
)
)
}
private fun consumePendingConsent(requestId: String): PendingPrivateMedia? {
return synchronized(pendingConsentLock) {
val pending = pendingPrivateMedia
if (pending?.request?.requestId != requestId) return@synchronized null
pendingPrivateMedia = null
_legacyPrivateMediaConsent.value = null
pending
}
}
private fun commitPreparedPrivateFile(
preparation: PrivateMediaPreparation.Ready,
toPeerID: String,
filePath: String,
messageType: BitchatMessageType,
transferId: String
) {
if (preparation.transfer.transferId != transferId) {
Log.e(TAG, "Prepared private-media transfer ID changed; send aborted")
return
}
val msg = BitchatMessage(
id = java.util.UUID.randomUUID().toString().uppercase(), // Generate unique ID for each message
id = UUID.randomUUID().toString().uppercase(),
sender = state.getNicknameValue() ?: "me",
content = filePath,
type = messageType,
@ -197,43 +588,54 @@ class MediaSendingManager(
recipientNickname = try { meshService.getPeerNicknames()[toPeerID] } catch (_: Exception) { null },
senderPeerID = meshService.myPeerID
)
// Preparation already built and admitted the exact final packet. Map
// progress before commit so the first asynchronous event cannot race us.
messageManager.addPrivateMessage(toPeerID, msg)
synchronized(transferMessageMap) {
transferMessageMap[transferId] = msg.id
messageTransferMap[msg.id] = transferId
}
// Seed progress so delivery icons render for media
messageManager.updateMessageDeliveryStatus(
msg.id,
com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(0, 100)
)
Log.d(TAG, "📤 Calling meshService.sendFilePrivate to $toPeerID")
meshService.sendFilePrivate(toPeerID, filePacket)
Log.d(TAG, "✅ File send completed successfully")
if (!preparation.transfer.commit()) {
messageManager.removeMessageById(msg.id)
synchronized(transferMessageMap) {
transferMessageMap.remove(transferId)
messageTransferMap.remove(msg.id)
}
Log.w(TAG, "Prepared private-media commit failed; local echo rolled back")
addPrivateMediaSystemMessage(
toPeerID,
"Private media was not sent because the prepared transfer could not be committed."
)
return
}
Log.d(TAG, "✅ Private media committed using ${preparation.transfer.wireMode}")
}
/**
* Send a file publicly (broadcast or channel)
*/
private fun sendPublicFile(
private suspend fun sendPublicFile(
channelOrNull: String?,
filePacket: BitchatFilePacket,
filePath: String,
messageType: BitchatMessageType
) {
val payload = filePacket.encode()
if (payload == null) {
Log.e(TAG, "❌ Failed to encode file packet for broadcast send")
return
}
val payload = withContext(mediaWorkDispatcher) { filePacket.encode() }
?: run {
Log.e(TAG, "❌ Failed to encode file packet for broadcast send")
return
}
Log.d(TAG, "🔓 Encoded broadcast packet: ${payload.size} bytes")
val transferId = sha256Hex(payload)
val contentHash = sha256Hex(filePacket.content)
val (transferId, contentHash) = withContext(mediaWorkDispatcher) {
sha256Hex(payload) to sha256Hex(filePacket.content)
}
Log.d(TAG, "📤 FILE_TRANSFER send (broadcast): name='${filePacket.fileName}', size=${filePacket.fileSize}, mime='${filePacket.mimeType}', sha256=$contentHash, transferId=${transferId.take(16)}…")
@ -266,7 +668,9 @@ class MediaSendingManager(
)
Log.d(TAG, "📤 Calling meshService.sendFileBroadcast")
meshService.sendFileBroadcast(filePacket)
withContext(mediaWorkDispatcher) {
meshService.sendFileBroadcast(filePacket)
}
Log.d(TAG, "✅ File broadcast completed successfully")
}

View File

@ -0,0 +1,39 @@
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

@ -23,6 +23,7 @@ class WifiAwareConnectionTracker(
// Active resources per peer
val peerSockets = ConcurrentHashMap<String, SyncedSocket>()
private val socketAliases = ConcurrentHashMap<String, String>()
private val socketBindingLock = Any()
val serverSockets = ConcurrentHashMap<String, ServerSocket>()
val networkCallbacks = ConcurrentHashMap<String, ConnectivityManager.NetworkCallback>()
@ -32,24 +33,26 @@ class WifiAwareConnectionTracker(
}
override fun disconnect(id: String) {
Log.d(TAG, "Disconnecting peer $id")
val canonicalId = resolveCanonicalPeerId(id)
// 1. Close client socket
peerSockets.remove(canonicalId)?.let {
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") }
}
socketAliases.entries.removeIf { it.key == id || it.key == canonicalId || it.value == canonicalId }
synchronized(socketBindingLock) {
Log.d(TAG, "Disconnecting peer $id")
val canonicalId = resolveCanonicalPeerId(id)
// 2. Close server socket
serverSockets.remove(canonicalId)?.let {
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $id: ${e.message}") }
}
// 1. Close client socket
peerSockets.remove(canonicalId)?.let {
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing socket for $id: ${e.message}") }
}
socketAliases.entries.removeIf { it.key == id || it.key == canonicalId || it.value == canonicalId }
// Ensure any pending/active network request is explicitly released
releaseNetworkRequest(canonicalId)
removePendingConnection(id)
removePendingConnection(canonicalId)
// 2. Close server socket
serverSockets.remove(canonicalId)?.let {
try { it.close() } catch (e: Exception) { Log.w(TAG, "Error closing server socket for $id: ${e.message}") }
}
// Ensure any pending/active network request is explicitly released
releaseNetworkRequest(canonicalId)
removePendingConnection(id)
removePendingConnection(canonicalId)
}
}
fun releaseNetworkRequest(id: String) {
@ -71,12 +74,16 @@ class WifiAwareConnectionTracker(
* Successfully established a client connection
*/
fun onClientConnected(peerId: String, socket: SyncedSocket) {
// Close previous socket if one exists to prevent zombie readers
peerSockets[peerId]?.let {
try { it.close() } catch (_: Exception) {}
synchronized(socketBindingLock) {
val canonicalPeerId = resolveCanonicalPeerId(peerId)
// Close previous socket if one exists to prevent zombie readers
peerSockets[canonicalPeerId]?.let {
try { it.close() } catch (_: Exception) {}
}
peerSockets[canonicalPeerId] = socket
removePendingConnection(peerId) // Clear retry state on success
if (canonicalPeerId != peerId) removePendingConnection(canonicalPeerId)
}
peerSockets[peerId] = socket
removePendingConnection(peerId) // Clear retry state on success
}
fun getSocketForPeer(peerId: String): SyncedSocket? {
@ -87,6 +94,37 @@ class WifiAwareConnectionTracker(
fun canonicalPeerId(peerId: String): String = resolveCanonicalPeerId(peerId)
fun rebindPeerId(previousPeerId: String, resolvedPeerId: String, socket: SyncedSocket): String {
return synchronized(socketBindingLock) {
rebindPeerIdLocked(previousPeerId, resolvedPeerId, socket)
}
}
/**
* 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.
*/
fun rebindPeerIdIfCurrent(
previousPeerId: String,
resolvedPeerId: String,
expectedSocket: SyncedSocket
): Boolean = synchronized(socketBindingLock) {
val previousCanonical = resolveCanonicalPeerId(previousPeerId)
if (peerSockets[previousCanonical] !== expectedSocket) return@synchronized false
val resolvedCanonical = resolveCanonicalPeerId(resolvedPeerId)
val existingResolvedSocket = peerSockets[resolvedCanonical]
if (existingResolvedSocket != null && existingResolvedSocket !== expectedSocket) {
return@synchronized false
}
rebindPeerIdLocked(previousPeerId, resolvedPeerId, expectedSocket)
true
}
private fun rebindPeerIdLocked(
previousPeerId: String,
resolvedPeerId: String,
socket: SyncedSocket
): String {
if (previousPeerId == resolvedPeerId) {
peerSockets[resolvedPeerId] = socket
return resolvedPeerId

View File

@ -42,6 +42,7 @@ import java.net.ServerSocket
import java.net.Socket
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
@ -74,6 +75,7 @@ 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
@ -121,6 +123,14 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
// Transport state
private val connectionTracker = WifiAwareConnectionTracker(serviceScope, cm)
private val ingressLinks = ConcurrentHashMap<
String,
AuthenticatedIngressLinkPolicy.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
@ -167,8 +177,40 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
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 exact Wi-Fi link for ${pid.take(8)}"
)
}
}
}
}
},
onDirectNoiseAuthenticated = { peerID, relayAddress, ingressLinkID, _ ->
promoteAuthenticatedIngressLink(peerID, relayAddress, ingressLinkID)
},
announcementNicknameProvider = {
try { com.bitchat.android.services.NicknameProvider.getNickname(context, myPeerID) } catch (_: Exception) { null }
},
@ -562,6 +604,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
subscribeHandles.clear()
publishHandles.clear()
discoveredTimestamps.clear()
ingressLinks.clear()
provisionalWifiClaims.clear()
authenticatedWifiLinks.clear()
meshCore.shutdown()
@ -606,6 +651,9 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
subscribeHandles.clear()
publishHandles.clear()
discoveredTimestamps.clear()
ingressLinks.clear()
provisionalWifiClaims.clear()
authenticatedWifiLinks.clear()
}
} finally {
recoveryInProgress = false
@ -1241,6 +1289,85 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
else -> myPeerID < peerId
}
/**
* Promote a provisional discovery alias only when the exact, still-active socket delivered the
* Noise frame that completed authentication for the canonical peer ID.
*/
private fun promoteAuthenticatedIngressLink(
canonicalPeerId: String,
relayAddress: String,
ingressLinkID: String
) {
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,
links = ingressLinks,
currentTransportForRelay = connectionTracker::getSocketForPeer
) ?: run {
Log.w(TAG, "Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: ingress link is stale or mismatched")
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) { }
return
}
if (existingCanonical != provisionalPeerId) {
Log.w(
TAG,
"Refusing authenticated Wi-Fi rebind ${existingCanonical.take(8)} -> ${canonicalPeerId.take(8)} on an existing alias"
)
return
}
if (!connectionTracker.rebindPeerIdIfCurrent(provisionalPeerId, canonicalPeerId, link.transport)) {
Log.w(
TAG,
"Ignoring Noise link promotion for ${canonicalPeerId.take(8)}: provisional socket changed before rebind"
)
return
}
authenticatedWifiLinks[canonicalPeerId] =
AuthenticatedIngressLinkPolicy.Claim(relayAddress, ingressLinkID)
handleToPeerId.forEach { (handle, peerId) ->
if (peerId == provisionalPeerId) handleToPeerId[handle] = canonicalPeerId
}
subscribeHandles.remove(provisionalPeerId)?.let { subscribeHandles[canonicalPeerId] = it }
publishHandles.remove(provisionalPeerId)?.let { publishHandles[canonicalPeerId] = it }
val discoveredAt = discoveredTimestamps.remove(provisionalPeerId) ?: System.currentTimeMillis()
discoveredTimestamps[canonicalPeerId] = 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) { }
Log.i(
TAG,
"Noise-authenticated Wi-Fi peer ${provisionalPeerId.take(8)} -> ${canonicalPeerId.take(8)} on exact ingress link"
)
}
/**
* Listens for incoming packets from a connected peer and dispatches them through
* the packet processor.
@ -1249,7 +1376,10 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
* @param initialLogicalPeerId Temporary identifier before peer ID resolution
*/
private fun listenToPeer(socket: SyncedSocket, initialLogicalPeerId: String) {
var logicalPeerId = initialLogicalPeerId
val logicalPeerId = initialLogicalPeerId
val ingressLinkID = UUID.randomUUID().toString()
val ingressLink = AuthenticatedIngressLinkPolicy.Link(logicalPeerId, socket)
ingressLinks[ingressLinkID] = ingressLink
while (isActive) {
val raw = socket.read() ?: break
@ -1263,29 +1393,24 @@ 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) {
val previousPeerId = logicalPeerId
logicalPeerId = connectionTracker.rebindPeerId(previousPeerId, senderPeerHex, socket)
handleToPeerId.forEach { (handle, peerId) ->
if (peerId == previousPeerId) {
handleToPeerId[handle] = senderPeerHex
}
}
subscribeHandles.remove(previousPeerId)?.let { subscribeHandles[senderPeerHex] = it }
discoveredTimestamps.remove(previousPeerId)
discoveredTimestamps[senderPeerHex] = System.currentTimeMillis()
try { meshCore.setDirectConnection(previousPeerId, false) } catch (_: Exception) { }
try { meshCore.removePeer(previousPeerId) } catch (_: Exception) { }
try { meshCore.setDirectConnection(senderPeerHex, true) } catch (_: Exception) { }
publishHandles.remove(previousPeerId)?.let { publishHandles[senderPeerHex] = it }
Log.i(TAG, "RX: rebound Wi-Fi direct peer ${previousPeerId.take(8)} -> ${senderPeerHex.take(8)}")
// 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.w(
TAG,
"RX: deferred Wi-Fi peer rebind ${logicalPeerId.take(8)} -> ${senderPeerHex.take(8)} pending Noise proof"
)
}
// Route the packet:
// - peerID = Originator (who signed it)
// - relayAddress = Neighbor (who sent it to us over this socket)
Log.d(TAG, "RX: packet type=${pkt.type} from ${senderPeerHex.take(8)} via ${logicalPeerId.take(8)} (bytes=${raw.size})")
meshCore.processIncoming(pkt, senderPeerHex, logicalPeerId)
meshCore.processIncoming(pkt, senderPeerHex, logicalPeerId, ingressLinkID)
}
ingressLinks.remove(ingressLinkID, ingressLink)
clearProvisionalWifiClaimsForLink(logicalPeerId, ingressLinkID)
// Breaking out of the loop means the socket is dead or service is stopping.
Log.i(TAG, "Socket loop terminated for ${logicalPeerId.take(8)} removing peer.")
@ -1293,6 +1418,28 @@ 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 {
Log.d(TAG, "Network failure cleanup for: $peerId")
@ -1399,6 +1546,18 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
meshCore.sendFilePrivate(recipientPeerID, file)
}
override fun prepareFilePrivate(
recipientPeerID: String,
file: BitchatFilePacket,
transferId: String,
allowLegacyFallback: Boolean
): com.bitchat.android.mesh.PrivateMediaPreparation = meshCore.prepareFilePrivate(
recipientPeerID,
file,
transferId,
allowLegacyFallback
)
/**
* Attempts to cancel an in-flight file transfer identified by its transferId.
*
@ -1570,6 +1729,29 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor
override fun sendPacketToPeer(peerID: String, packet: BitchatPacket): Boolean {
return this@WifiAwareMeshService.sendPacketToPeer(peerID, packet)
}
override fun sendPacketToLink(
relayAddress: String,
ingressLinkID: String,
packet: BitchatPacket
): Boolean {
val link = AuthenticatedIngressLinkPolicy.resolve(
authenticatedLinkID = ingressLinkID,
authenticatedRelayAddress = relayAddress,
links = ingressLinks,
currentTransportForRelay = connectionTracker::getSocketForPeer
) ?: return false
val data = packet.toBinaryData() ?: return false
return try {
link.transport.write(data)
true
} catch (e: IOException) {
Log.e(
TAG,
"TX: exact-link write to ${relayAddress.take(8)} failed: ${e.message}"
)
false
}
}
override fun cancelTransfer(transferId: String): Boolean {
return fragmentingSender.cancelTransfer(transferId)
}

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name">bitchat</string>
<string name="permission_bluetooth_rationale">Bluetooth permission is required for peer-to-peer messaging without internet.</string>
<string name="permission_location_rationale">Location permission is required to discover nearby devices via Bluetooth.</string>
@ -88,6 +88,11 @@
<string name="cd_encrypted">End-to-end encrypted</string>
<string name="cd_handshake_failed">Handshake failed</string>
<!-- One-shot private-media downgrade consent -->
<string name="private_media_legacy_title" tools:ignore="MissingTranslation">Send without end-to-end encryption?</string>
<string name="private_media_legacy_body" tools:ignore="MissingTranslation">%1$s cannot be sent encrypted to %2$s because their client is older. %3$s</string>
<string name="private_media_legacy_send_once" tools:ignore="MissingTranslation">Send this file once</string>
<!-- File viewer dialog -->
<string name="file_viewer_title">📎 File Received</string>
<string name="file_viewer_name">📄 %1$s</string>

View File

@ -5,6 +5,8 @@ import com.bitchat.android.protocol.MessageType
import com.bitchat.android.model.FragmentPayload
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@ -180,6 +182,68 @@ class FragmentManagerTest {
assertTrue("Payload content should match", originalPacket.payload.contentEquals(reassembledPacket.payload))
}
@Test
fun `inbound fragment set above 256 is rejected`() {
val payload = FragmentPayload(
fragmentID = ByteArray(8) { 1 },
index = 0,
total = 257,
originalType = MessageType.NOISE_ENCRYPTED.value,
data = byteArrayOf(1)
).encode()
val packet = BitchatPacket(
version = 1u,
type = MessageType.FRAGMENT.value,
senderID = hexStringToByteArray(senderID),
recipientID = hexStringToByteArray(recipientID),
timestamp = 1u,
payload = payload,
ttl = 7u
)
assertNull(fragmentManager.handleFragment(packet))
}
@Test
fun `fragment payload refuses UInt16 truncation`() {
assertThrows(IllegalArgumentException::class.java) {
FragmentPayload(
fragmentID = ByteArray(8) { 2 },
index = 0,
total = 65_536,
originalType = MessageType.NOISE_ENCRYPTED.value,
data = byteArrayOf(1)
).encode()
}
}
@Test
fun `generic public packet retains a 257 fragment outbound plan`() {
val randomPayload = ByteArray(180 * 1024).also { Random(0xB17C4A7).nextBytes(it) }
fun plan(contentSize: Int): List<BitchatPacket> = fragmentManager.createFragments(
BitchatPacket(
version = 2u,
type = MessageType.FILE_TRANSFER.value,
senderID = hexStringToByteArray(senderID),
recipientID = com.bitchat.android.protocol.SpecialRecipients.BROADCAST,
timestamp = 1u,
payload = randomPayload.copyOf(contentSize),
signature = ByteArray(64) { 7 },
ttl = 7u
)
)
var low = 1
var high = randomPayload.size
while (low < high) {
val mid = low + (high - low) / 2
if (plan(mid).size >= 257) high = mid else low = mid + 1
}
assertEquals(257, plan(low).size)
}
private fun hexStringToByteArray(hexString: String): ByteArray {
val result = ByteArray(8)
for (i in 0 until 8) {

View File

@ -12,6 +12,11 @@ fun e(tag: String, msg: String): Int {
return 0;
}
fun e(tag: String, msg: String, throwable: Throwable): Int {
println("ERROR: $tag: $msg (${throwable.message})")
return 0;
}
fun w(tag: String, msg: String): Int {
println("WARN: $tag: $msg")
return 0;
@ -25,4 +30,4 @@ fun v(tag: String, msg: String): Int {
fun i(tag: String, msg: String): Int {
println("INFO: $tag: $msg")
return 0;
}
}

View File

@ -1,6 +1,7 @@
package com.bitchat
import com.bitchat.android.mesh.PeerManager
import com.bitchat.android.model.PeerCapabilities
import junit.framework.TestCase.assertEquals
import org.junit.Test
@ -31,6 +32,71 @@ class PeerManagerTest {
val emptyDeviceAddresses = emptyMap<String, String>()
@Test
fun peer_capabilities_are_retained_with_verified_identity() {
val capabilities = PeerCapabilities(
PeerCapabilities.PRIVATE_MEDIA.rawValue or (1L shl 15)
)
peerManager.updatePeerInfoFromVerifiedAnnouncement(
peerID = "peer-capabilities",
nickname = "alice",
noisePublicKey = ByteArray(32) { 1 },
signingPublicKey = ByteArray(32) { 2 },
isVerified = true,
capabilities = capabilities
)
assertEquals(capabilities, peerManager.getPeerInfo("peer-capabilities")?.capabilities)
}
@Test
fun normal_peer_updates_preserve_signed_absent_and_empty_capabilities() {
val peerID = "peer-capability-state"
val noiseKey = ByteArray(32) { 3 }
val signingKey = ByteArray(32) { 4 }
peerManager.updatePeerInfoFromVerifiedAnnouncement(
peerID,
"alice",
noiseKey,
signingKey,
true,
null
)
var info = peerManager.getPeerInfo(peerID)!!
assertEquals(true, info.hasVerifiedAnnouncement)
assertEquals(null, info.capabilities)
assertEquals(true, info.verifiedAnnouncementNoisePublicKey!!.contentEquals(noiseKey))
peerManager.updatePeerInfo(peerID, "alice2", noiseKey, signingKey, true)
info = peerManager.getPeerInfo(peerID)!!
assertEquals(true, info.hasVerifiedAnnouncement)
assertEquals(null, info.capabilities)
peerManager.updatePeerInfoFromVerifiedAnnouncement(
peerID,
"alice2",
noiseKey,
signingKey,
true,
PeerCapabilities.NONE
)
peerManager.updatePeerInfo(peerID, "alice3", noiseKey, signingKey, true)
info = peerManager.getPeerInfo(peerID)!!
assertEquals(true, info.hasVerifiedAnnouncement)
assertEquals(PeerCapabilities.NONE, info.capabilities)
val changedNoiseKey = ByteArray(32) { 7 }
peerManager.updatePeerInfo(peerID, "alice4", changedNoiseKey, signingKey, true)
info = peerManager.getPeerInfo(peerID)!!
assertEquals(PeerCapabilities.NONE, info.capabilities)
assertEquals(
true,
info.verifiedAnnouncementNoisePublicKey!!.contentEquals(noiseKey)
)
}
val testRSSI = mapOf(
"peer1" to 0,
"peer2" to 10,
@ -257,4 +323,4 @@ class PeerManagerTest {
assertEquals(expectedLine2, actualLine2)
}
}
}

View File

@ -0,0 +1,73 @@
package com.bitchat.android.identity
import android.content.Context
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.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
class PrivateMediaCapabilityPinPersistenceTest {
private val fingerprint = "ab".repeat(32)
private lateinit var manager: SecureIdentityStateManager
private lateinit var prefs: android.content.SharedPreferences
@Before
fun setup() {
prefs = RuntimeEnvironment.getApplication().getSharedPreferences(
"private-media-pin-${UUID.randomUUID()}",
Context.MODE_PRIVATE
)
manager = SecureIdentityStateManager(prefs, testOnly = true)
manager.clearIdentityData()
}
@After
fun tearDown() {
manager.clearIdentityData()
}
@Test
fun `authenticated Ed key and capabilities persist rotate and clear atomically`() {
val firstKey = ByteArray(32) { 0x11 }
val rotatedKey = ByteArray(32) { 0x22 }
assertTrue(manager.storeAuthenticatedPeerState(
fingerprint,
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, firstKey)
))
val reloaded = SecureIdentityStateManager(prefs, testOnly = true)
assertArrayEquals(firstKey, reloaded.getAuthenticatedSigningKey(fingerprint))
assertEquals(
PeerCapabilities.PRIVATE_MEDIA,
reloaded.getAuthenticatedPeerState(fingerprint)?.capabilities
)
assertTrue(reloaded.isPrivateMediaCapable(fingerprint))
assertTrue(reloaded.storeAuthenticatedPeerState(
fingerprint,
AuthenticatedPeerState(PeerCapabilities.NONE, rotatedKey)
))
assertArrayEquals(rotatedKey, reloaded.getAuthenticatedSigningKey(fingerprint))
// HSTS-style private-media history is not erased by a no-bit proof.
assertTrue(reloaded.isPrivateMediaCapable(fingerprint))
reloaded.clearIdentityData()
assertFalse(manager.storeAuthenticatedPeerState(
fingerprint,
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, firstKey)
))
val afterPanic = SecureIdentityStateManager(prefs, testOnly = true)
assertEquals(null, afterPanic.getAuthenticatedPeerState(fingerprint))
assertFalse(afterPanic.isPrivateMediaCapable(fingerprint))
}
}

View File

@ -0,0 +1,40 @@
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

@ -0,0 +1,299 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoisePeerIdentity
import java.security.MessageDigest
import java.util.concurrent.CopyOnWriteArrayList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AuthenticatedPeerStateCoordinatorTest {
private class MemoryStore : AuthenticatedPeerStateStore {
val states = mutableMapOf<String, AuthenticatedPeerState>()
val pins = mutableSetOf<String>()
override fun load(fingerprint: String): AuthenticatedPeerState? = states[fingerprint]
override fun persist(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit
): Boolean {
states[fingerprint] = state
if (state.capabilities.contains(PeerCapabilities.PRIVATE_MEDIA)) pins += fingerprint
onCommitted()
return true
}
override fun isPrivateMediaPinned(fingerprint: String): Boolean = fingerprint in pins
}
private val remoteStatic = ByteArray(32) { 0x33 }
private val peerID = NoisePeerIdentity.derivePeerID(remoteStatic)!!
private val firstSession = AuthenticatedNoiseSession(
remoteStatic,
ByteArray(32) { 0x71 }
)
private val secondSession = AuthenticatedNoiseSession(
remoteStatic,
ByteArray(32) { 0x72 }
)
private val localState = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 0x44 })
private val remoteState = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 0x55 })
@Test
fun `every generation emits and first valid proof echoes exactly once`() {
val store = MemoryStore()
val sent = CopyOnWriteArrayList<AuthenticatedPeerState>()
val applied = CopyOnWriteArrayList<AuthenticatedPeerState>()
var activeSession = firstSession
val coordinator = AuthenticatedPeerStateCoordinator(
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
authenticatedSessionProvider = { activeSession },
withAuthenticatedSession = { _, expected, action ->
if (expected == activeSession) action() else false
},
store = store,
localStateProvider = { localState },
applyAuthenticatedState = { _, key, state ->
assertArrayEquals(remoteStatic, key)
applied += state
},
sendState = { _, state, _ -> sent.add(state) },
onResolution = {},
proofTimeoutMs = 5_000
)
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession))
assertEquals(1, sent.size)
assertTrue(coordinator.receive(peerID, remoteState, firstSession))
assertEquals(2, sent.size)
assertTrue(coordinator.receive(peerID, remoteState, firstSession))
assertEquals("Repeated proof must not echo again", 2, sent.size)
assertEquals(1, applied.size)
val different = AuthenticatedPeerState(PeerCapabilities.NONE, ByteArray(32) { 0x66 })
assertFalse(
"A generation cannot replace its first proof",
coordinator.receive(peerID, different, firstSession)
)
activeSession = secondSession
// Policy can observe the crypto swap before its completion callback. It must adopt the
// new token as Awaiting instead of reusing gen-N Proven state.
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, secondSession))
assertEquals(3, sent.size)
coordinator.onSessionAuthenticated(peerID, remoteStatic, secondSession.sessionToken)
assertEquals(3, sent.size)
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, secondSession))
assertFalse(coordinator.receive(peerID, remoteState, firstSession))
assertTrue(coordinator.receive(peerID, different, secondSession))
assertEquals(4, sent.size)
assertEquals(2, applied.size)
}
@Test
fun `live generation is adopted once and watchdog is never reset by policy reads`() = runBlocking {
val sent = CopyOnWriteArrayList<AuthenticatedPeerState>()
val resolutions = CopyOnWriteArrayList<String>()
val coordinator = AuthenticatedPeerStateCoordinator(
scope = this,
authenticatedSessionProvider = { firstSession },
withAuthenticatedSession = { _, expected, action ->
if (expected == firstSession) action() else false
},
store = MemoryStore(),
localStateProvider = { localState },
applyAuthenticatedState = { _, _, _ -> },
sendState = { _, state, _ -> sent += state; true },
onResolution = resolutions::add,
proofTimeoutMs = 20
)
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession))
delay(10)
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession))
delay(25)
assertEquals(AuthenticatedPeerStateStatus.TimedOut, coordinator.status(peerID, firstSession))
assertEquals(1, sent.size)
assertEquals(listOf(peerID), resolutions)
}
@Test
fun `delayed old completion cannot replace a newer tracked generation`() {
val sentTokens = CopyOnWriteArrayList<ByteArray>()
var activeSession = secondSession
val coordinator = AuthenticatedPeerStateCoordinator(
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
authenticatedSessionProvider = { activeSession },
withAuthenticatedSession = { _, expected, action ->
if (expected == activeSession) action() else false
},
store = MemoryStore(),
localStateProvider = { localState },
applyAuthenticatedState = { _, _, _ -> },
sendState = { _, _, session -> sentTokens += session.sessionToken; true },
onResolution = {},
proofTimeoutMs = 5_000
)
coordinator.onSessionAuthenticated(peerID, remoteStatic, secondSession.sessionToken)
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, secondSession))
assertEquals(1, sentTokens.size)
assertArrayEquals(secondSession.sessionToken, sentTokens.single())
}
@Test
fun `watchdog resolves no-proof generation without trusting persisted prior state`() = runBlocking {
val store = MemoryStore()
store.states[fingerprint(remoteStatic)] = remoteState
val resolutions = CopyOnWriteArrayList<String>()
val coordinator = AuthenticatedPeerStateCoordinator(
scope = this,
authenticatedSessionProvider = { firstSession },
withAuthenticatedSession = { _, expected, action ->
if (expected == firstSession) action() else false
},
store = store,
localStateProvider = { localState },
applyAuthenticatedState = { _, _, _ -> },
sendState = { _, _, _ -> true },
onResolution = resolutions::add,
proofTimeoutMs = 20
)
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
delay(50)
assertEquals(AuthenticatedPeerStateStatus.TimedOut, coordinator.status(peerID, firstSession))
assertEquals(listOf(peerID), resolutions)
}
@Test
fun `copied-static preannounce Ed key is replaced by authenticated proof`() {
val peerManager = PeerManager()
val attackerEd = ByteArray(32) { 0x11 }
val victimEd = ByteArray(32) { 0x22 }
peerManager.updatePeerInfoFromVerifiedAnnouncement(
peerID,
"attacker-name",
remoteStatic,
attackerEd,
true,
PeerCapabilities.PRIVATE_MEDIA
)
val coordinator = AuthenticatedPeerStateCoordinator(
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
authenticatedSessionProvider = { firstSession },
withAuthenticatedSession = { _, expected, action ->
if (expected == firstSession) action() else false
},
store = MemoryStore(),
localStateProvider = { localState },
applyAuthenticatedState = peerManager::applyAuthenticatedPeerState,
sendState = { _, _, _ -> true },
onResolution = {},
proofTimeoutMs = 5_000
)
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
assertTrue(
coordinator.receive(
peerID,
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, victimEd),
firstSession
)
)
val peer = peerManager.getPeerInfo(peerID)!!
assertArrayEquals(victimEd, peer.signingPublicKey)
assertEquals(peerID, peer.nickname)
assertFalse("Copied self-signed nickname must lose verified status", peer.isVerifiedNickname)
assertFalse(peer.hasVerifiedAnnouncement)
}
@Test
fun `failed durable persistence cannot publish peer state in memory`() {
val applied = CopyOnWriteArrayList<AuthenticatedPeerState>()
val store = object : AuthenticatedPeerStateStore {
override fun load(fingerprint: String): AuthenticatedPeerState? = null
override fun persist(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit
): Boolean = false
override fun isPrivateMediaPinned(fingerprint: String): Boolean = false
}
val coordinator = AuthenticatedPeerStateCoordinator(
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
authenticatedSessionProvider = { firstSession },
withAuthenticatedSession = { _, expected, action ->
if (expected == firstSession) action() else false
},
store = store,
localStateProvider = { localState },
applyAuthenticatedState = { _, _, state -> applied += state },
sendState = { _, _, _ -> true },
onResolution = {},
proofTimeoutMs = 5_000
)
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
assertFalse(coordinator.receive(peerID, remoteState, firstSession))
assertEquals(AuthenticatedPeerStateStatus.Awaiting, coordinator.status(peerID, firstSession))
assertTrue(applied.isEmpty())
}
@Test
fun `stale decryption lease cannot persist or apply after generation replacement`() {
var activeSession = firstSession
var persistCalls = 0
var applyCalls = 0
val store = object : AuthenticatedPeerStateStore {
override fun load(fingerprint: String): AuthenticatedPeerState? = null
override fun persist(
fingerprint: String,
state: AuthenticatedPeerState,
onCommitted: () -> Unit
): Boolean {
persistCalls += 1
onCommitted()
return true
}
override fun isPrivateMediaPinned(fingerprint: String): Boolean = false
}
val coordinator = AuthenticatedPeerStateCoordinator(
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
authenticatedSessionProvider = { activeSession },
withAuthenticatedSession = { _, expected, action ->
if (expected == activeSession) action() else false
},
store = store,
localStateProvider = { localState },
applyAuthenticatedState = { _, _, _ -> applyCalls += 1 },
sendState = { _, _, _ -> true },
onResolution = {},
proofTimeoutMs = 5_000
)
coordinator.onSessionAuthenticated(peerID, remoteStatic, firstSession.sessionToken)
activeSession = secondSession
assertFalse(coordinator.receive(peerID, remoteState, firstSession))
assertEquals(0, persistCalls)
assertEquals(0, applyCalls)
}
private fun fingerprint(key: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(key).joinToString("") { "%02x".format(it) }
}

View File

@ -0,0 +1,45 @@
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.After
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class BluetoothConnectionManagerTest {
private lateinit var manager: BluetoothConnectionManager
@Before
fun setUp() {
manager = BluetoothConnectionManager(
RuntimeEnvironment.getApplication(),
"0011223344556677"
)
}
@After
fun tearDown() {
manager.stopServices()
}
@Test
fun `inactive manager rejects a broadcast instead of reporting it queued`() {
val packet = BitchatPacket(
version = 1u,
type = MessageType.MESSAGE.value,
senderID = byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7),
recipientID = null,
timestamp = 1uL,
payload = byteArrayOf(1),
ttl = 7u
)
assertFalse(manager.broadcastPacket(RoutedPacket(packet)))
}
}

View File

@ -0,0 +1,53 @@
package com.bitchat.android.mesh
import android.bluetooth.BluetoothDevice
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
class BluetoothConnectionTrackerLinkIdentityTest {
private val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob())
private val tracker = BluetoothConnectionTracker(scope, mock())
@After
fun tearDown() {
scope.cancel()
}
@Test
fun `stale connection callbacks cannot mutate or remove replacement link`() {
val address = "AA:BB:CC:DD:EE:FF"
val device = mock<BluetoothDevice>()
whenever(device.address).thenReturn(address)
tracker.addDeviceConnection(
address,
BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-a")
)
tracker.addDeviceConnection(
address,
BluetoothConnectionTracker.DeviceConnection(device = device, linkID = "link-b")
)
assertFalse(
tracker.updateDeviceConnectionIfCurrent(address, "link-a") {
it.copy(rssi = -10)
}
)
assertFalse(tracker.cleanupDeviceConnectionIfCurrent(address, "link-a"))
assertEquals("link-b", tracker.getCurrentLinkID(address))
assertTrue(tracker.bindPeerIfCurrent(address, "link-b", "0011223344556677"))
assertEquals("0011223344556677", tracker.addressPeerMap[address])
assertSame(device, tracker.getDeviceConnection(address)?.device)
}
}

View File

@ -2,7 +2,15 @@ package com.bitchat.android.mesh
import android.os.Build
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.NoisePeerIdentity
import com.bitchat.android.noise.AuthenticatedNoiseSession
import com.bitchat.android.noise.NoiseDecryptionResult
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
@ -16,8 +24,8 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.eq
import org.mockito.kotlin.isNull
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.verify
@ -33,9 +41,13 @@ class MessageHandlerTest {
private lateinit var delegate: MessageHandlerDelegate
private val myPeerID = "1111222233334444"
private val peerID = "aaaabbbbccccdddd"
private val nickname = "peer"
private val noiseKey = ByteArray(32) { 0x0B }
private val peerID = NoisePeerIdentity.derivePeerID(noiseKey)!!
private val authenticatedSession = AuthenticatedNoiseSession(
noiseKey,
ByteArray(32) { 0x5D }
)
private val nickname = "peer"
private val signingKey = ByteArray(32) { 0x0A }
private val signature = ByteArray(64) { 1 }
private val announceClockSkewToleranceMs = 10 * 60 * 1000L
@ -49,7 +61,11 @@ class MessageHandlerTest {
whenever(delegate.getPeerInfo(peerID)).thenReturn(null)
whenever(delegate.verifyEd25519Signature(any(), any(), any())).thenReturn(true)
whenever(delegate.updatePeerInfo(any(), any(), any(), any(), any())).thenReturn(true)
whenever(
delegate.updatePeerInfoFromVerifiedAnnouncement(
any(), any(), any(), any(), any(), anyOrNull()
)
).thenReturn(true)
}
@After
@ -58,46 +74,317 @@ class MessageHandlerTest {
}
@Test
fun `handleAnnounce accepts announce within clock skew tolerance for identity binding`() = runBlocking {
val packet = announcePacket(ageMs = AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000)
fun `handleAnnounce accepts announce within clock skew tolerance for identity binding`() {
runBlocking {
val packet = announcePacket(ageMs = AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertTrue("Announce within clock skew tolerance should still store peer identity", result)
verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true))
verify(delegate).updatePeerIDBinding(eq(peerID), eq(nickname), any(), isNull())
assertTrue("Announce within clock skew tolerance should still store peer identity", result)
verify(delegate).updatePeerInfoFromVerifiedAnnouncement(
eq(peerID), eq(nickname), any(), any(), eq(true), anyOrNull()
)
}
}
@Test
fun `handleAnnounce accepts future announce within clock skew tolerance`() = runBlocking {
val packet = announcePacket(ageMs = -(AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000))
fun `handleAnnounce accepts future announce within clock skew tolerance`() {
runBlocking {
val packet = announcePacket(ageMs = -(AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000))
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertTrue("Future announce within clock skew tolerance should still store peer identity", result)
verify(delegate).updatePeerInfoFromVerifiedAnnouncement(
eq(peerID), eq(nickname), any(), any(), eq(true), anyOrNull()
)
}
}
@Test
fun `handleAnnounce stores advertised capabilities including unknown bits`() {
runBlocking {
val capabilities = PeerCapabilities(
PeerCapabilities.PRIVATE_MEDIA.rawValue or (1L shl 15)
)
val packet = announcePacket(ageMs = 0, capabilities = capabilities)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertTrue(result)
verify(delegate).updatePeerInfoFromVerifiedAnnouncement(
eq(peerID),
eq(nickname),
any(),
any(),
eq(true),
eq(capabilities)
)
}
}
@Test
fun `handleAnnounce rejects announce older than clock skew tolerance`() {
runBlocking {
val packet = announcePacket(ageMs = announceClockSkewToleranceMs + 1_000)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "relay-link"))
assertFalse("Announce older than clock skew tolerance should not store peer identity", result)
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
any(), any(), any(), any(), any(), anyOrNull()
)
}
}
@Test
fun `handleAnnounce never promotes capability after invalid signature`() {
runBlocking {
whenever(delegate.verifyEd25519Signature(any(), any(), any())).thenReturn(false)
val packet = announcePacket(ageMs = 0, capabilities = PeerCapabilities.PRIVATE_MEDIA)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertFalse(result)
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
any(), any(), any(), any(), any(), anyOrNull()
)
}
}
@Test
fun `directed raw private media requires a valid signature`() {
runBlocking {
whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST)
whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname)
whenever(delegate.verifySignature(any(), eq(peerID))).thenReturn(false)
val file = BitchatFilePacket(
fileName = "legacy.jpg",
fileSize = 3,
mimeType = "image/jpeg",
content = byteArrayOf(1, 2, 3)
)
val unsigned = BitchatPacket(
version = 2u,
type = MessageType.FILE_TRANSFER.value,
senderID = peerID.hexToBytes(),
recipientID = myPeerID.hexToBytes(),
timestamp = System.currentTimeMillis().toULong(),
payload = file.encode()!!,
signature = null,
ttl = 7u
)
handler.handleMessage(RoutedPacket(unsigned, peerID, "direct-link"))
handler.handleMessage(
RoutedPacket(unsigned.copy(signature = ByteArray(64) { 1 }), peerID, "direct-link")
)
verify(delegate, never()).onMessageReceived(any())
}
}
@Test
fun `valid signed directed raw private media remains interoperable`() {
runBlocking {
whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST)
whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname)
whenever(delegate.verifySignature(any(), eq(peerID))).thenReturn(true)
val file = BitchatFilePacket(
fileName = "legacy-valid.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 = myPeerID.hexToBytes(),
timestamp = System.currentTimeMillis().toULong(),
payload = file.encode()!!,
signature = signature,
ttl = 7u
)
handler.handleMessage(RoutedPacket(packet, peerID, "direct-link"))
verify(delegate).verifySignature(packet, peerID)
verify(delegate).onMessageReceived(any())
}
}
@Test
fun `encrypted prerelease iOS Noise 0x09 private media is delivered`() {
runBlocking {
val file = BitchatFilePacket(
fileName = "prerelease-ios.pdf",
fileSize = 4,
mimeType = "application/pdf",
content = byteArrayOf(1, 2, 3, 4)
)
val prereleasePlaintext = byteArrayOf(0x09) + file.encode()!!
val ciphertext = byteArrayOf(0x41, 0x42, 0x43)
whenever(delegate.decryptFromPeer(any(), eq(peerID))).thenReturn(
NoiseDecryptionResult(prereleasePlaintext, authenticatedSession)
)
whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname)
whenever(delegate.getMyNickname()).thenReturn("me")
whenever(delegate.encryptForPeer(any(), eq(peerID))).thenReturn(byteArrayOf(0x55))
val outerPacket = BitchatPacket(
version = 2u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = peerID.hexToBytes(),
recipientID = myPeerID.hexToBytes(),
timestamp = System.currentTimeMillis().toULong(),
payload = ciphertext,
signature = signature,
ttl = 7u
)
handler.handleNoiseEncrypted(RoutedPacket(outerPacket, peerID, "direct-link"))
verify(delegate).decryptFromPeer(ciphertext, peerID)
verify(delegate).onMessageReceived(any())
}
}
@Test
fun `valid encrypted peer state is delivered and malformed state is ignored`() = runBlocking {
val state = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey)
val validCiphertext = byteArrayOf(0x31)
val malformedCiphertext = byteArrayOf(0x32)
whenever(delegate.decryptFromPeer(validCiphertext, peerID)).thenReturn(
NoiseDecryptionResult(
NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode(),
authenticatedSession
)
)
whenever(delegate.decryptFromPeer(malformedCiphertext, peerID)).thenReturn(
NoiseDecryptionResult(
NoisePayload(NoisePayloadType.PEER_STATE, byteArrayOf(0x01, 0x01)).encode(),
authenticatedSession
)
)
val base = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = peerID.hexToBytes(),
recipientID = myPeerID.hexToBytes(),
timestamp = System.currentTimeMillis().toULong(),
payload = validCiphertext,
ttl = 7u
)
handler.handleNoiseEncrypted(RoutedPacket(base, peerID, "direct-link"))
handler.handleNoiseEncrypted(
RoutedPacket(base.copy(payload = malformedCiphertext), peerID, "direct-link")
)
verify(delegate).onAuthenticatedPeerStateReceived(peerID, state, authenticatedSession)
}
@Test
fun `first self-signed announce cannot claim an ID derived from another Noise key`() = runBlocking {
val attackerNoiseKey = ByteArray(32) { 0x6B }
val packet = announcePacket(ageMs = 0, noisePublicKey = attackerNoiseKey)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertTrue("Future announce within clock skew tolerance should still store peer identity", result)
verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true))
assertFalse("A valid self-signature cannot bind an attacker key to a victim ID", result)
verify(delegate, never()).verifyEd25519Signature(any(), any(), any())
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
any(), any(), any(), any(), any(), anyOrNull()
)
Unit
}
@Test
fun `handleAnnounce rejects announce older than clock skew tolerance`() = runBlocking {
val packet = announcePacket(ageMs = announceClockSkewToleranceMs + 1_000)
fun `announce requires a 32-byte Noise static key before peer update`() = runBlocking {
val malformedNoiseKey = ByteArray(31) { 0x0B }
val packet = announcePacket(ageMs = 0, noisePublicKey = malformedNoiseKey)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "relay-link"))
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertFalse("Announce older than clock skew tolerance should not store peer identity", result)
verify(delegate, never()).updatePeerInfo(any(), any(), any(), any(), any())
verify(delegate, never()).updatePeerIDBinding(any(), any(), any(), any())
assertFalse(result)
verify(delegate, never()).verifyEd25519Signature(any(), any(), any())
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
any(), any(), any(), any(), any(), anyOrNull()
)
Unit
}
@Test
fun `announce packet sender cannot be processed under a different routed peer ID`() = runBlocking {
val otherPeerID = NoisePeerIdentity.derivePeerID(ByteArray(32) { 0x21 })!!
val packet = announcePacket(ageMs = 0)
val result = handler.handleAnnounce(RoutedPacket(packet, otherPeerID, "relay-link"))
assertFalse(result)
verify(delegate, never()).verifyEd25519Signature(any(), any(), any())
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
any(), any(), any(), any(), any(), anyOrNull()
)
Unit
}
@Test
fun `known peer signing key cannot be replaced without bound Noise session`() = runBlocking {
whenever(delegate.getPeerInfo(peerID)).thenReturn(peerInfo(signingPublicKey = ByteArray(32) { 0x44 }))
whenever(delegate.hasNoiseSession(peerID)).thenReturn(false)
val packet = announcePacket(ageMs = 0)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertFalse(result)
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
any(), any(), any(), any(), any(), anyOrNull()
)
Unit
}
@Test
fun `ambient bound Noise session does not authorize signing key replacement`() = runBlocking {
whenever(delegate.getPeerInfo(peerID)).thenReturn(peerInfo(signingPublicKey = ByteArray(32) { 0x44 }))
whenever(delegate.hasNoiseSession(peerID)).thenReturn(true)
val packet = announcePacket(ageMs = 0)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertFalse(result)
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
any(), any(), any(), any(), any(), anyOrNull()
)
Unit
}
@Test
fun `persisted authenticated Ed key rejects copied-static preannounce after restart`() = runBlocking {
whenever(delegate.getAuthenticatedSigningKey(any())).thenReturn(ByteArray(32) { 0x44 })
val packet = announcePacket(ageMs = 0)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertFalse(result)
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
any(), any(), any(), any(), any(), anyOrNull()
)
Unit
}
private fun announcePacket(
ageMs: Long,
ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte()
ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(),
capabilities: PeerCapabilities? = null,
noisePublicKey: ByteArray = noiseKey
): BitchatPacket {
val announcement = IdentityAnnouncement(
nickname = nickname,
noisePublicKey = noiseKey,
signingPublicKey = signingKey
noisePublicKey = noisePublicKey,
signingPublicKey = signingKey,
capabilities = capabilities
)
return BitchatPacket(
version = 1u,
@ -114,4 +401,15 @@ class MessageHandlerTest {
private fun String.hexToBytes(): ByteArray {
return chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
private fun peerInfo(signingPublicKey: ByteArray) = PeerInfo(
id = peerID,
nickname = nickname,
isConnected = true,
isDirectConnection = true,
noisePublicKey = noiseKey,
signingPublicKey = signingPublicKey,
isVerifiedNickname = true,
lastSeen = System.currentTimeMillis()
)
}

View File

@ -0,0 +1,143 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.protocol.SpecialRecipients
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PacketProcessorAnnounceSideEffectTest {
private val processors = mutableListOf<PacketProcessor>()
@After
fun tearDown() {
processors.forEach(PacketProcessor::shutdown)
}
@Test
fun `rejected announce does not update last seen or relay`() = runBlocking {
val delegate = RecordingDelegate(acceptAnnounce = false)
val processor = processor(delegate)
processor.processPacket(announce())
withTimeout(1_000) { delegate.handled.await() }
assertNull(withTimeoutOrNull(250) { delegate.lastSeen.await() })
assertEquals(0, delegate.relayCount)
}
@Test
fun `accepted announce unlocks post-handler side effects`() = runBlocking {
val delegate = RecordingDelegate(acceptAnnounce = true)
val processor = processor(delegate)
processor.processPacket(announce())
assertEquals(PEER_ID, withTimeout(1_000) { delegate.lastSeen.await() })
}
@Test
fun `rejected handshake does not update last seen`() = runBlocking {
val delegate = RecordingDelegate(acceptAnnounce = true, acceptHandshake = false)
val processor = processor(delegate)
processor.processPacket(handshake())
withTimeout(1_000) { delegate.handshakeHandled.await() }
assertNull(withTimeoutOrNull(250) { delegate.lastSeen.await() })
}
@Test
fun `accepted handshake updates last seen`() = runBlocking {
val delegate = RecordingDelegate(acceptAnnounce = true, acceptHandshake = true)
val processor = processor(delegate)
processor.processPacket(handshake())
assertEquals(PEER_ID, withTimeout(1_000) { delegate.lastSeen.await() })
}
private fun processor(delegate: RecordingDelegate): PacketProcessor =
PacketProcessor(MY_PEER_ID).also {
it.delegate = delegate
processors += it
}
private fun announce(): RoutedPacket {
val packet = BitchatPacket(
version = 1u,
type = MessageType.ANNOUNCE.value,
senderID = PEER_ID.hexToBytes(),
recipientID = SpecialRecipients.BROADCAST,
timestamp = System.currentTimeMillis().toULong(),
payload = byteArrayOf(0x01),
ttl = 7u
)
return RoutedPacket(packet, PEER_ID, "direct-link")
}
private fun handshake(): RoutedPacket {
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_HANDSHAKE.value,
senderID = PEER_ID.hexToBytes(),
recipientID = MY_PEER_ID.hexToBytes(),
timestamp = System.currentTimeMillis().toULong(),
payload = byteArrayOf(0x01),
ttl = 7u
)
return RoutedPacket(packet, PEER_ID, "direct-link")
}
private class RecordingDelegate(
private val acceptAnnounce: Boolean,
private val acceptHandshake: Boolean = false
) : PacketProcessorDelegate {
val handled = CompletableDeferred<Unit>()
val handshakeHandled = CompletableDeferred<Unit>()
val lastSeen = CompletableDeferred<String>()
@Volatile var relayCount = 0
override fun validatePacketSecurity(packet: BitchatPacket, peerID: String) = true
override fun updatePeerLastSeen(peerID: String) {
lastSeen.complete(peerID)
}
override fun getPeerNickname(peerID: String): String? = null
override fun getNetworkSize() = 1
override fun getBroadcastRecipient(): ByteArray = SpecialRecipients.BROADCAST
override fun handleNoiseHandshake(routed: RoutedPacket): Boolean {
handshakeHandled.complete(Unit)
return acceptHandshake
}
override fun handleNoiseEncrypted(routed: RoutedPacket) = Unit
override suspend fun handleAnnounce(routed: RoutedPacket): Boolean {
handled.complete(Unit)
return acceptAnnounce
}
override fun handleMessage(routed: RoutedPacket) = Unit
override fun handleLeave(routed: RoutedPacket) = Unit
override fun handleFragment(packet: BitchatPacket): BitchatPacket? = null
override fun handleRequestSync(routed: RoutedPacket) = Unit
override fun sendAnnouncementToPeer(peerID: String) = Unit
override fun sendCachedMessages(peerID: String) = Unit
override fun relayPacket(routed: RoutedPacket) {
relayCount += 1
}
override fun sendToPeer(peerID: String, routed: RoutedPacket) = false
}
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
private companion object {
const val MY_PEER_ID = "1111222233334444"
const val PEER_ID = "aaaabbbbccccdddd"
}
}

View File

@ -0,0 +1,69 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.AuthenticatedPeerState
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.noise.AuthenticatedNoiseSession
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PrivateMediaSecurityTest {
private val peerID = "0011223344556677"
private var authenticatedSession: AuthenticatedNoiseSession? = AuthenticatedNoiseSession(
ByteArray(32) { (it + 1).toByte() },
ByteArray(32) { 0x51 }
)
private var status: AuthenticatedPeerStateStatus = AuthenticatedPeerStateStatus.Awaiting
private var pinned = false
private val controller = PrivateMediaSecurityController(
authenticatedSessionProvider = { authenticatedSession },
peerStateStatusProvider = { _, _ -> status },
isPrivateMediaPinned = { pinned }
)
@Test
fun `live session waits for fresh generation proof`() {
assertEquals(PrivateMediaPolicyDecision.AwaitingPeerState, controller.sendPolicy(peerID))
}
@Test
fun `valid private-media proof enables encrypted wire`() {
status = AuthenticatedPeerStateStatus.Proven(
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, ByteArray(32) { 1 })
)
assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Encrypted)
}
@Test
fun `no-bit proof permits consent only when capability was never pinned`() {
status = AuthenticatedPeerStateStatus.Proven(
AuthenticatedPeerState(PeerCapabilities.NONE, ByteArray(32) { 1 })
)
assertEquals(PrivateMediaPolicyDecision.RequiresLegacyConsent, controller.sendPolicy(peerID))
pinned = true
assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Blocked)
}
@Test
fun `no-proof timeout permits old-client consent but blocks pinned downgrade`() {
status = AuthenticatedPeerStateStatus.TimedOut
assertEquals(PrivateMediaPolicyDecision.RequiresLegacyConsent, controller.sendPolicy(peerID))
pinned = true
assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Blocked)
}
@Test
fun `live session missing coordinator generation waits and never unlocks legacy`() {
status = AuthenticatedPeerStateStatus.Missing
assertEquals(PrivateMediaPolicyDecision.AwaitingPeerState, controller.sendPolicy(peerID))
}
@Test
fun `pin never bypasses requirement for live authenticated session`() {
pinned = true
authenticatedSession = null
assertEquals(PrivateMediaPolicyDecision.NeedsHandshake, controller.sendPolicy(peerID))
}
}

View File

@ -0,0 +1,349 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.NoisePayload
import com.bitchat.android.model.NoisePayloadType
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import com.bitchat.android.noise.AuthenticatedNoiseSession
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.util.Random
@RunWith(RobolectricTestRunner::class)
class PrivateMediaTransferPreparerTest {
private val senderID = hex("0011223344556677")
private val recipientID = hex("8877665544332211")
private val authenticatedSession = AuthenticatedNoiseSession(
ByteArray(32) { 0x21 },
ByteArray(32) { 0x22 }
)
private val fragmentManagers = mutableListOf<FragmentManager>()
@After
fun tearDown() {
fragmentManagers.forEach(FragmentManager::shutdown)
}
@Test
fun `encrypted mode emits deployed Noise 0x20 and signs before fragmentation`() {
var encryptedPlaintext: ByteArray? = null
val preparer = preparer(
policy = PrivateMediaPolicyDecision.Encrypted(authenticatedSession),
encrypt = { bytes, _, _ ->
encryptedPlaintext = bytes
PrivateMediaEncryptionResult.Success(byteArrayOf(0x41) + bytes)
}
)
val outcome = preparer.prepare("peer", recipientID, file(64), false)
val ready = outcome as PrivateMediaBuildOutcome.Ready
assertEquals(MessageType.NOISE_ENCRYPTED.value, ready.built.packet.type)
assertNotNull(ready.built.packet.signature)
assertEquals(PrivateMediaWireMode.ENCRYPTED_NOISE_0X20, ready.built.wireMode)
val decoded = NoisePayload.decode(encryptedPlaintext!!)
assertEquals(NoisePayloadType.FILE_TRANSFER, decoded?.type)
assertEquals(0x20u.toUByte(), encryptedPlaintext!![0].toUByte())
}
@Test
fun `prerelease iOS Noise 0x09 decodes as file transfer but re-encodes canonical 0x20`() {
val filePayload = file(3).encode()!!
val prereleasePayload = byteArrayOf(0x09) + filePayload
val decoded = NoisePayload.decode(prereleasePayload)
assertEquals(NoisePayloadType.FILE_TRANSFER, decoded?.type)
assertTrue(filePayload.contentEquals(decoded?.data))
assertEquals(0x20u.toUByte(), decoded!!.encode()[0].toUByte())
}
@Test
fun `legacy mode requires consent and signing failure aborts`() {
val noConsent = preparer(policy = PrivateMediaPolicyDecision.RequiresLegacyConsent)
.prepare("peer", recipientID, file(64), false)
assertTrue(noConsent is PrivateMediaBuildOutcome.RequiresLegacyConsent)
val signingFailure = preparer(
policy = PrivateMediaPolicyDecision.RequiresLegacyConsent,
finalizer = { null }
).prepare("peer", recipientID, file(64), true)
assertTrue(signingFailure is PrivateMediaBuildOutcome.Rejected)
assertTrue((signingFailure as PrivateMediaBuildOutcome.Rejected).reason.contains("nothing was sent"))
val malformedSignature = preparer(
policy = PrivateMediaPolicyDecision.RequiresLegacyConsent,
finalizer = { packet -> packet.copy(signature = ByteArray(0)) }
).prepare("peer", recipientID, file(64), true)
assertTrue(malformedSignature is PrivateMediaBuildOutcome.Rejected)
}
@Test
fun `consented legacy mode is signed directed raw 0x22`() {
val outcome = preparer(policy = PrivateMediaPolicyDecision.RequiresLegacyConsent)
.prepare("peer", recipientID, file(64), true)
val ready = outcome as PrivateMediaBuildOutcome.Ready
assertEquals(MessageType.FILE_TRANSFER.value, ready.built.packet.type)
assertTrue(ready.built.packet.recipientID!!.contentEquals(recipientID))
assertNotNull(ready.built.packet.signature)
assertEquals(PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22, ready.built.wireMode)
}
@Test
fun `handshake requirement returns before encoding signing encryption or fragmentation`() {
var encrypted = false
var finalized = false
var fragmented = false
val fragmentManager = FragmentManager().also { fragmentManagers += it }
val preparer = PrivateMediaTransferPreparer(
senderID = senderID,
ttl = 7u,
policyProvider = { PrivateMediaPolicyDecision.NeedsHandshake },
encrypt = { _, _, _ ->
encrypted = true
PrivateMediaEncryptionResult.Success(byteArrayOf(1))
},
finalizeRoutedAndSigned = {
finalized = true
it
},
fragment = { packet, maxFragments ->
fragmented = true
fragmentManager.createFragments(packet, maxFragments)
}
)
val outcome = preparer.prepare("peer", recipientID, file(64), false)
assertEquals(PrivateMediaBuildOutcome.NeedsHandshake, outcome)
assertTrue(!encrypted)
assertTrue(!finalized)
assertTrue(!fragmented)
}
@Test
fun `peer-state wait returns before encoding signing encryption or fragmentation`() {
var encrypted = false
var finalized = false
var fragmented = false
val fragmentManager = FragmentManager().also { fragmentManagers += it }
val preparer = PrivateMediaTransferPreparer(
senderID = senderID,
ttl = 7u,
policyProvider = { PrivateMediaPolicyDecision.AwaitingPeerState },
encrypt = { _, _, _ ->
encrypted = true
PrivateMediaEncryptionResult.Success(byteArrayOf(1))
},
finalizeRoutedAndSigned = {
finalized = true
it
},
fragment = { packet, maxFragments ->
fragmented = true
fragmentManager.createFragments(packet, maxFragments)
}
)
val outcome = preparer.prepare("peer", recipientID, file(64), false)
assertEquals(PrivateMediaBuildOutcome.AwaitingPeerState, outcome)
assertTrue(!encrypted)
assertTrue(!finalized)
assertTrue(!fragmented)
}
@Test
fun `impossible content size rejects before policy encryption signing or fragmentation`() {
var policyChecked = false
var encrypted = false
var finalized = false
var fragmented = false
val absolutePayloadUpperBound =
com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID *
com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENT_SIZE
val preparer = PrivateMediaTransferPreparer(
senderID = senderID,
ttl = 7u,
policyProvider = {
policyChecked = true
PrivateMediaPolicyDecision.Encrypted(authenticatedSession)
},
encrypt = { _, _, _ ->
encrypted = true
PrivateMediaEncryptionResult.Success(byteArrayOf(1))
},
finalizeRoutedAndSigned = {
finalized = true
it
},
fragment = { _, _ ->
fragmented = true
emptyList()
}
)
val outcome = preparer.prepare(
"peer",
recipientID,
file(absolutePayloadUpperBound + 1),
false
)
assertTrue(outcome is PrivateMediaBuildOutcome.Rejected)
assertTrue((outcome as PrivateMediaBuildOutcome.Rejected).reason.contains("256"))
assertTrue(!policyChecked)
assertTrue(!encrypted)
assertTrue(!finalized)
assertTrue(!fragmented)
}
@Test
fun `no route accepts 256 final fragments and rejects 257`() {
assertExactBoundary(route = null)
}
@Test
fun `source route accepts 256 final fragments and rejects 257`() {
assertExactBoundary(
route = listOf(
hex("1021324354657687"),
hex("2031425364758697"),
hex("30415263748596a7")
)
)
}
@Test
fun `generation churn re-runs policy once instead of losing the send intent`() {
val replacementSession = AuthenticatedNoiseSession(
authenticatedSession.remoteStaticKey,
ByteArray(32) { 0x23 }
)
var policyCalls = 0
val fragmentManager = FragmentManager().also { fragmentManagers += it }
val preparer = PrivateMediaTransferPreparer(
senderID = senderID,
ttl = 7u,
policyProvider = {
policyCalls += 1
PrivateMediaPolicyDecision.Encrypted(
if (policyCalls == 1) authenticatedSession else replacementSession
)
},
encrypt = { bytes, _, session ->
if (session == authenticatedSession) {
PrivateMediaEncryptionResult.GenerationChanged
} else {
PrivateMediaEncryptionResult.Success(byteArrayOf(0x41) + bytes)
}
},
finalizeRoutedAndSigned = { it.copy(signature = ByteArray(64) { 0x33 }) },
fragment = fragmentManager::createFragments
)
val outcome = preparer.prepare("peer", recipientID, file(64), false)
assertTrue(outcome is PrivateMediaBuildOutcome.Ready)
assertEquals(2, policyCalls)
}
@Test
fun `repeated generation churn remains retryable`() {
val fragmentManager = FragmentManager().also { fragmentManagers += it }
val preparer = PrivateMediaTransferPreparer(
senderID = senderID,
ttl = 7u,
policyProvider = { PrivateMediaPolicyDecision.Encrypted(authenticatedSession) },
encrypt = { _, _, _ -> PrivateMediaEncryptionResult.GenerationChanged },
finalizeRoutedAndSigned = { it.copy(signature = ByteArray(64) { 0x33 }) },
fragment = fragmentManager::createFragments
)
assertEquals(
PrivateMediaBuildOutcome.AwaitingPeerState,
preparer.prepare("peer", recipientID, file(64), false)
)
}
private fun assertExactBoundary(route: List<ByteArray>?) {
val randomContent = ByteArray(180 * 1024).also { Random(0xB17C4A7).nextBytes(it) }
val preparer = preparer(
policy = PrivateMediaPolicyDecision.Encrypted(authenticatedSession),
finalizer = { packet ->
packet.copy(
version = if (route == null) packet.version else 2u,
route = route,
signature = ByteArray(64) { 0x5A }
)
}
)
fun outcome(contentSize: Int): PrivateMediaBuildOutcome = preparer.prepare(
"peer",
recipientID,
BitchatFilePacket(
fileName = "boundary.bin",
fileSize = contentSize.toLong(),
mimeType = "application/octet-stream",
content = randomContent.copyOf(contentSize)
),
false
)
var low = 1
var high = randomContent.size
while (low < high) {
val mid = low + (high - low) / 2
if (outcome(mid) is PrivateMediaBuildOutcome.Rejected) high = mid else low = mid + 1
}
val accepted = outcome(low - 1) as PrivateMediaBuildOutcome.Ready
val rejected = outcome(low)
assertEquals(256, accepted.built.fragments.size)
assertTrue(rejected is PrivateMediaBuildOutcome.Rejected)
assertTrue((rejected as PrivateMediaBuildOutcome.Rejected).reason.contains("256"))
}
private fun preparer(
policy: PrivateMediaPolicyDecision,
encrypt: (
ByteArray,
String,
AuthenticatedNoiseSession
) -> PrivateMediaEncryptionResult = { bytes, _, _ ->
PrivateMediaEncryptionResult.Success(byteArrayOf(0x01) + bytes)
},
finalizer: (BitchatPacket) -> BitchatPacket? = { packet ->
packet.copy(signature = ByteArray(64) { 0x33 })
}
): PrivateMediaTransferPreparer {
val fragmentManager = FragmentManager().also { fragmentManagers += it }
return PrivateMediaTransferPreparer(
senderID = senderID,
ttl = 7u,
policyProvider = { policy },
encrypt = encrypt,
finalizeRoutedAndSigned = finalizer,
fragment = fragmentManager::createFragments,
now = { 1_700_000_000_000uL }
)
}
private fun file(size: Int) = BitchatFilePacket(
fileName = "test.bin",
fileSize = size.toLong(),
mimeType = "application/octet-stream",
content = ByteArray(size) { it.toByte() }
)
private fun hex(value: String): ByteArray =
value.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}

View File

@ -3,8 +3,13 @@ package com.bitchat.android.mesh
import android.os.Build
import com.bitchat.android.crypto.EncryptionService
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.noise.NoiseHandshakeProcessingResult
import com.bitchat.android.noise.NoisePeerIdentity
import com.bitchat.android.noise.NoiseSessionError
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@ -26,21 +31,26 @@ class SecurityManagerTest {
private val myPeerID = "1111222233334444"
private val otherPeerID = "aaaabbbbccccdddd"
private val unknownPeerID = "9999888877776666"
// Key pairs (using dummy bytes for mock verification)
private val otherSigningKey = ByteArray(32) { 0xA }
private val otherNoiseKey = ByteArray(32) { 0xB }
private val sessionToken = ByteArray(32) { 0x5C }
private val unknownPeerID = NoisePeerIdentity.derivePeerID(otherNoiseKey)!!
private val dummyPayload = "Hello World".toByteArray()
private val validSignature = ByteArray(64) { 1 }
private val invalidSignature = ByteArray(64) { 0 }
// Key pairs (using dummy bytes for mock verification)
private val otherSigningKey = ByteArray(32) { 0xA }
private val otherNoiseKey = ByteArray(32) { 0xB }
// Fake implementation to bypass initialization issues in tests
open class FakeEncryptionService : EncryptionService(RuntimeEnvironment.getApplication()) {
var shouldVerify: Boolean = true
var lastVerifySignature: ByteArray? = null
var lastVerifyData: ByteArray? = null
var lastVerifyKey: ByteArray? = null
var handshakeResult = NoiseHandshakeProcessingResult(null, false)
var handshakeError: Exception? = null
var handshakeCalls = 0
var removePeerCalls = 0
override fun initialize() {
// Do nothing to avoid KeyStore access in tests
@ -48,6 +58,7 @@ class SecurityManagerTest {
override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
lastVerifySignature = signature
lastVerifyData = data
lastVerifyKey = publicKeyBytes
// Simple logic: if configured to verify, check if signature matches validSignature
@ -57,6 +68,19 @@ class SecurityManagerTest {
}
return false
}
override fun processHandshakeMessageWithResult(
data: ByteArray,
peerID: String
): NoiseHandshakeProcessingResult {
handshakeCalls += 1
handshakeError?.let { throw it }
return handshakeResult
}
override fun removePeer(peerID: String) {
removePeerCalls += 1
}
}
@Before
@ -90,6 +114,44 @@ class SecurityManagerTest {
assertFalse("Packet without signature should be rejected", result)
}
@Test
fun `verifySignature - verifies canonical packet with announced signing key`() {
setupKnownPeer(otherPeerID, otherSigningKey)
val packet = BitchatPacket(
version = 1u,
type = MessageType.FILE_TRANSFER.value,
senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID),
recipientID = MeshPacketUtils.hexStringToByteArray(myPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = dummyPayload,
signature = validSignature,
ttl = 10u
)
assertTrue(securityManager.verifySignature(packet, otherPeerID))
assertTrue(fakeEncryptionService.lastVerifySignature.contentEquals(validSignature))
assertTrue(fakeEncryptionService.lastVerifyData.contentEquals(packet.toBinaryDataForSigning()))
assertTrue(fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey))
}
@Test
fun `verifySignature - rejects missing signature and unknown signing key`() {
val unsigned = BitchatPacket(
version = 1u,
type = MessageType.FILE_TRANSFER.value,
senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID),
recipientID = MeshPacketUtils.hexStringToByteArray(myPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = dummyPayload,
ttl = 10u
)
assertFalse(securityManager.verifySignature(unsigned, otherPeerID))
unsigned.signature = validSignature
whenever(mockDelegate.getPeerInfo(otherPeerID)).thenReturn(null)
assertFalse(securityManager.verifySignature(unsigned, otherPeerID))
}
@Test
fun `validatePacket - rejects packet with invalid signature`() {
setupKnownPeer(otherPeerID, otherSigningKey)
@ -107,6 +169,22 @@ class SecurityManagerTest {
assertFalse("Packet with invalid signature should be rejected", result)
}
@Test
fun `invalid packet does not poison duplicate detection for later valid packet`() {
setupKnownPeer(otherPeerID, otherSigningKey)
val packet = BitchatPacket(
type = MessageType.MESSAGE.value,
ttl = 10u,
senderID = otherPeerID,
payload = dummyPayload
)
packet.signature = invalidSignature
assertFalse(securityManager.validatePacket(packet, otherPeerID))
packet.signature = validSignature
assertTrue(securityManager.validatePacket(packet, otherPeerID))
}
@Test
fun `validatePacket - rejects packet from unknown peer (no key)`() {
whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null)
@ -141,6 +219,63 @@ class SecurityManagerTest {
assertTrue("Valid signed packet from known peer should be accepted", result)
}
@Test
fun `validatePacket rejects unsigned and invalidly signed LEAVE packets`() {
setupKnownPeer(otherPeerID, otherSigningKey)
val unsigned = BitchatPacket(
type = MessageType.LEAVE.value,
ttl = 7u,
senderID = otherPeerID,
payload = byteArrayOf()
)
assertFalse("Unsigned LEAVE must not evict or relay the claimed peer", securityManager.validatePacket(unsigned, otherPeerID))
val invalid = BitchatPacket(
type = MessageType.LEAVE.value,
ttl = 7u,
senderID = otherPeerID,
payload = "forged".toByteArray()
).also { it.signature = invalidSignature }
assertFalse("Bad LEAVE signature must be rejected", securityManager.validatePacket(invalid, otherPeerID))
}
@Test
fun `validatePacket accepts signed LEAVE from known peer`() {
setupKnownPeer(otherPeerID, otherSigningKey)
val packet = BitchatPacket(
type = MessageType.LEAVE.value,
ttl = 7u,
senderID = otherPeerID,
payload = byteArrayOf()
).also { it.signature = validSignature }
assertTrue("A valid signed LEAVE remains wire-compatible", securityManager.validatePacket(packet, otherPeerID))
assertTrue(fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey))
}
@Test
fun `validatePacket rejects signed LEAVE outside replay window`() {
setupKnownPeer(otherPeerID, otherSigningKey)
val stale = BitchatPacket(
type = MessageType.LEAVE.value,
ttl = 7u,
senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID),
timestamp = (System.currentTimeMillis() - 5 * 60 * 1_000L - 1).toULong(),
payload = byteArrayOf()
).also { it.signature = validSignature }
val future = BitchatPacket(
type = MessageType.LEAVE.value,
ttl = 7u,
senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID),
timestamp = (System.currentTimeMillis() + 5 * 60 * 1_000L + 1_000).toULong(),
payload = byteArrayOf()
).also { it.signature = validSignature }
assertFalse("Captured LEAVE must expire even after replay-cache loss", securityManager.validatePacket(stale, otherPeerID))
assertFalse("Future-dated LEAVE must not extend its replay lifetime", securityManager.validatePacket(future, otherPeerID))
}
@Test
fun `validatePacket - accepts ANNOUNCE packet from unknown peer (extracts key)`() {
val announcement = IdentityAnnouncement(
@ -205,6 +340,48 @@ class SecurityManagerTest {
assertFalse("ANNOUNCE with malformed payload should be rejected (cannot extract key)", result)
}
@Test
fun `validatePacket rejects self-signed announce whose Noise key derives another sender ID`() {
val attackerNoiseKey = ByteArray(32) { 0x6B }
val announcement = IdentityAnnouncement("Attacker", attackerNoiseKey, otherSigningKey)
val packet = BitchatPacket(
type = MessageType.ANNOUNCE.value,
ttl = 7u,
senderID = unknownPeerID,
payload = announcement.encode()!!
).also { it.signature = validSignature }
assertFalse(securityManager.validatePacket(packet, unknownPeerID))
}
@Test
fun `validatePacket rejects announce packet under a different routed sender`() {
val announcement = IdentityAnnouncement("Peer", otherNoiseKey, otherSigningKey)
val packet = BitchatPacket(
type = MessageType.ANNOUNCE.value,
ttl = 7u,
senderID = unknownPeerID,
payload = announcement.encode()!!
).also { it.signature = validSignature }
assertFalse(securityManager.validatePacket(packet, otherPeerID))
}
@Test
fun `validatePacket rejects announce conflicting with persisted authenticated Ed key`() {
whenever(mockDelegate.getAuthenticatedSigningKey(otherNoiseKey))
.thenReturn(ByteArray(32) { 0x44 })
val announcement = IdentityAnnouncement("Copied", otherNoiseKey, otherSigningKey)
val packet = BitchatPacket(
type = MessageType.ANNOUNCE.value,
ttl = 7u,
senderID = unknownPeerID,
payload = announcement.encode()!!
).also { it.signature = validSignature }
assertFalse(securityManager.validatePacket(packet, unknownPeerID))
}
@Test
fun `validatePacket - ignores own packets`() {
val packet = BitchatPacket(
@ -270,6 +447,101 @@ class SecurityManagerTest {
assertTrue("Fresh duplicate ANNOUNCE should be accepted", securityManager.validatePacket(packet3, unknownPeerID))
}
@Test
fun `replacement message one sends response without evicting or falsely completing`() = runBlocking {
val response = byteArrayOf(0x31, 0x32)
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(response, false)
val routed = handshakePacket(byteArrayOf(0x01, 0x02, 0x03))
val accepted = securityManager.handleNoiseHandshake(routed)
assertTrue(accepted)
assertTrue(fakeEncryptionService.removePeerCalls == 0)
verify(mockDelegate).sendHandshakeResponse(otherPeerID, response)
verify(mockDelegate, never()).onKeyExchangeCompleted(
any(), any(), any(), anyOrNull(), anyOrNull()
)
}
@Test
fun `identity mismatch preserves peer and does not poison retry or completion`() = runBlocking {
val routed = handshakePacket(byteArrayOf(0x41, 0x42, 0x43))
fakeEncryptionService.handshakeError = NoiseSessionError.PeerIdentityMismatch(
otherPeerID,
"0000000000000000"
)
assertFalse(securityManager.handleNoiseHandshake(routed))
assertTrue(fakeEncryptionService.removePeerCalls == 0)
verify(mockDelegate, never()).sendHandshakeResponse(any(), any())
verify(mockDelegate, never()).onKeyExchangeCompleted(
any(), any(), any(), anyOrNull(), anyOrNull()
)
fakeEncryptionService.handshakeError = null
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
response = null,
establishedNow = true,
authenticatedRemoteStaticKey = otherNoiseKey,
authenticatedSessionToken = sessionToken
)
assertTrue("Failed frames must not poison the processed-exchange cache", securityManager.handleNoiseHandshake(routed))
assertTrue(fakeEncryptionService.handshakeCalls == 2)
verify(mockDelegate).onKeyExchangeCompleted(
otherPeerID,
otherNoiseKey,
sessionToken,
"direct-link",
"direct-link-token"
)
}
@Test
fun `completion callback fires only for the frame that establishes a bound session`() = runBlocking {
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
response = null,
establishedNow = true,
authenticatedRemoteStaticKey = otherNoiseKey,
authenticatedSessionToken = sessionToken
)
val routed = handshakePacket(byteArrayOf(0x51, 0x52, 0x53))
assertTrue(securityManager.handleNoiseHandshake(routed))
verify(mockDelegate, times(1)).onKeyExchangeCompleted(
otherPeerID,
otherNoiseKey,
sessionToken,
"direct-link",
"direct-link-token"
)
verify(mockDelegate, never()).sendHandshakeResponse(any(), any())
assertTrue(fakeEncryptionService.removePeerCalls == 0)
}
@Test
fun `relayed completion does not authenticate the relay as the peer link`() = runBlocking {
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
response = null,
establishedNow = true,
authenticatedRemoteStaticKey = otherNoiseKey,
authenticatedSessionToken = sessionToken
)
val routed = handshakePacket(
payload = byteArrayOf(0x61, 0x62, 0x63),
ttl = 6u
)
assertTrue(securityManager.handleNoiseHandshake(routed))
verify(mockDelegate).onKeyExchangeCompleted(
otherPeerID,
otherNoiseKey,
sessionToken,
null,
null
)
}
private fun setupKnownPeer(peerID: String, signingKey: ByteArray) {
val info = PeerInfo(
id = peerID,
@ -283,4 +555,25 @@ class SecurityManagerTest {
)
whenever(mockDelegate.getPeerInfo(peerID)).thenReturn(info)
}
private fun handshakePacket(payload: ByteArray, ttl: UByte = 7u): RoutedPacket {
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_HANDSHAKE.value,
senderID = otherPeerID.hexToBytes(),
recipientID = myPeerID.hexToBytes(),
timestamp = System.currentTimeMillis().toULong(),
payload = payload,
ttl = ttl
)
return RoutedPacket(
packet = packet,
peerID = otherPeerID,
relayAddress = "direct-link",
ingressLinkID = "direct-link-token"
)
}
private fun String.hexToBytes(): ByteArray =
chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}

View File

@ -0,0 +1,68 @@
package com.bitchat.android.model
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AuthenticatedPeerStateTest {
private val signingKey = ByteArray(32) { it.toByte() }
@Test
fun `encoder matches canonical iOS bytes`() {
val encoded = AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey).encode()
assertArrayEquals(
byteArrayOf(0x01, 0x01, 0x02, 0x00, 0x01, 0x02, 0x20) + signingKey,
encoded
)
assertEquals(
AuthenticatedPeerState(PeerCapabilities.PRIVATE_MEDIA, signingKey),
AuthenticatedPeerState.decode(encoded)
)
}
@Test
fun `decoder skips unknown TLVs and accepts either known-field order`() {
val payload = byteArrayOf(
0x01,
0x7F, 0x02, 0x55, 0x66,
0x02, 0x20
) + signingKey + byteArrayOf(0x01, 0x01, 0x00)
assertEquals(
AuthenticatedPeerState(PeerCapabilities.NONE, signingKey),
AuthenticatedPeerState.decode(payload)
)
}
@Test
fun `decoder rejects malformed duplicate missing and noncanonical fields`() {
val validCapabilities = byteArrayOf(0x01, 0x01, 0x00)
val validSigning = byteArrayOf(0x02, 0x20) + signingKey
val invalid = listOf(
byteArrayOf(),
byteArrayOf(0x02) + validCapabilities + validSigning,
byteArrayOf(0x01) + validCapabilities,
byteArrayOf(0x01) + validSigning,
byteArrayOf(0x01) + validCapabilities + validCapabilities + validSigning,
byteArrayOf(0x01, 0x01, 0x02, 0x00, 0x00) + validSigning,
byteArrayOf(0x01, 0x01, 0x00) + validSigning,
byteArrayOf(0x01, 0x01, 0x09) + ByteArray(9) + validSigning,
byteArrayOf(0x01, 0x02, 0x1F) + ByteArray(31) + validCapabilities,
byteArrayOf(0x01, 0x7F),
byteArrayOf(0x01, 0x7F, 0x02, 0x01)
)
invalid.forEach { assertNull("Expected rejection for ${it.joinToString()}", AuthenticatedPeerState.decode(it)) }
}
@Test
fun `Noise wrapper emits and decodes canonical 0x21`() {
val state = AuthenticatedPeerState(PeerCapabilities.NONE, signingKey)
val encoded = NoisePayload(NoisePayloadType.PEER_STATE, state.encode()).encode()
assertEquals(0x21, encoded[0].toInt() and 0xFF)
assertEquals(NoisePayloadType.PEER_STATE, NoisePayload.decode(encoded)?.type)
}
}

View File

@ -0,0 +1,75 @@
package com.bitchat.android.model
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class IdentityAnnouncementTest {
private val nickname = "peer"
private val noiseKey = ByteArray(32) { 0x11 }
private val signingKey = ByteArray(32) { 0x22 }
@Test
fun `private media capability uses iOS little-endian bytes`() {
assertArrayEquals(byteArrayOf(0x00, 0x01), PeerCapabilities.PRIVATE_MEDIA.encoded())
assertTrue(PeerCapabilities.decode(byteArrayOf(0x00, 0x01)).contains(PeerCapabilities.PRIVATE_MEDIA))
}
@Test
fun `legacy announcement without capability TLV still decodes`() {
val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!!
val decoded = IdentityAnnouncement.decode(legacy)!!
assertEquals(nickname, decoded.nickname)
assertArrayEquals(noiseKey, decoded.noisePublicKey)
assertArrayEquals(signingKey, decoded.signingPublicKey)
assertNull(decoded.capabilities)
}
@Test
fun `explicit empty capability TLV decodes as present but empty`() {
val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!!
val decoded = IdentityAnnouncement.decode(legacy + byteArrayOf(0x05, 0x00))!!
assertEquals(PeerCapabilities.NONE, decoded.capabilities)
}
@Test
fun `unknown capability bits and TLVs survive decode and re-encode`() {
val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!!
val wire = legacy + byteArrayOf(
0x05, 0x02, 0x00, 0x81.toByte(), // privateMedia plus unknown bit 15
0x7F, 0x03, 0x01, 0x02, 0x03
)
val decoded = IdentityAnnouncement.decode(wire)!!
assertEquals(0x8100L, decoded.capabilities?.rawValue)
assertEquals(1, decoded.unknownTLVs.size)
assertEquals(0x7F, decoded.unknownTLVs.single().type)
assertArrayEquals(byteArrayOf(0x01, 0x02, 0x03), decoded.unknownTLVs.single().value)
val roundTripped = IdentityAnnouncement.decode(decoded.encode()!!)!!
assertEquals(decoded.capabilities, roundTripped.capabilities)
assertEquals(decoded.unknownTLVs, roundTripped.unknownTLVs)
}
@Test
fun `local announcement send advertises private media`() {
val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!!
assertArrayEquals(
byteArrayOf(0x05, 0x02, 0x00, 0x01),
encoded.takeLast(4).toByteArray()
)
assertTrue(
IdentityAnnouncement.decode(encoded)!!
.capabilities!!
.contains(PeerCapabilities.PRIVATE_MEDIA)
)
}
}

View File

@ -0,0 +1,384 @@
package com.bitchat.android.noise
import com.bitchat.android.noise.southernstorm.protocol.Noise
import org.junit.After
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
class NoiseSessionManagerIdentityBindingTest {
private data class TestIdentity(
val privateKey: ByteArray,
val publicKey: ByteArray,
val peerID: String
)
private val managers = mutableListOf<NoiseSessionManager>()
@After
fun tearDown() {
managers.forEach(NoiseSessionManager::shutdown)
}
@Test
fun `valid derived identities establish in initiator and responder roles`() {
val alice = identity()
val bob = identity()
val aliceManager = manager(alice)
val bobManager = manager(bob)
completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID)
assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
assertTrue(bobManager.hasEstablishedSession(alice.peerID))
assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID))
assertArrayEquals(alice.publicKey, bobManager.getRemoteStaticKey(alice.peerID))
val plaintext = "bound transport".toByteArray()
val aliceSession = aliceManager.getAuthenticatedSession(bob.peerID)!!
val bobSession = bobManager.getAuthenticatedSession(alice.peerID)!!
val ciphertext = aliceManager.encryptForSession(plaintext, bob.peerID, aliceSession)
val decrypted = bobManager.decryptWithSession(ciphertext, alice.peerID)
assertArrayEquals(plaintext, decrypted.plaintext)
assertArrayEquals(bobSession.sessionToken, decrypted.authenticatedSession.sessionToken)
}
@Test
fun `initiator rejects remote static key before returning message three`() {
val alice = identity()
val bob = identity()
val victim = identity()
val aliceManager = manager(alice)
val bobManager = manager(bob)
var authenticatedCallbacks = 0
aliceManager.onSessionEstablished = { _, _ -> authenticatedCallbacks += 1 }
val message1 = aliceManager.initiateHandshake(victim.peerID)!!
val message2 = bobManager.processHandshakeMessage(alice.peerID, message1)!!
expectIdentityMismatch {
aliceManager.processHandshakeMessage(victim.peerID, message2)
}
assertFalse(aliceManager.hasEstablishedSession(victim.peerID))
assertNull(aliceManager.getSession(victim.peerID))
assertTrue("No authenticated callback may escape a mismatched initiator", authenticatedCallbacks == 0)
}
@Test
fun `responder rejects authenticated initiator key under a different claimed ID`() {
val alice = identity()
val bob = identity()
val victim = identity()
val aliceManager = manager(alice)
val bobManager = manager(bob)
var authenticatedCallbacks = 0
bobManager.onSessionEstablished = { _, _ -> authenticatedCallbacks += 1 }
val message1 = aliceManager.initiateHandshake(bob.peerID)!!
val message2 = bobManager.processHandshakeMessage(victim.peerID, message1)!!
val message3 = aliceManager.processHandshakeMessage(bob.peerID, message2)!!
expectIdentityMismatch {
bobManager.processHandshakeMessage(victim.peerID, message3)
}
assertFalse(bobManager.hasEstablishedSession(victim.peerID))
assertNull(bobManager.getSession(victim.peerID))
assertTrue("No authenticated callback may escape a mismatched responder", authenticatedCallbacks == 0)
}
@Test
fun `mismatched responder replacement preserves established session and transport keys`() {
val alice = identity()
val bob = identity()
val attacker = identity()
val aliceManager = manager(alice)
val bobManager = manager(bob)
val attackerManager = manager(attacker)
var aliceAuthenticatedCallbacks = 0
aliceManager.onSessionEstablished = { _, _ -> aliceAuthenticatedCallbacks += 1 }
completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID)
val originalSession = aliceManager.getSession(bob.peerID)
assertTrue(aliceAuthenticatedCallbacks == 1)
val attackerMessage1 = attackerManager.initiateHandshake(alice.peerID)!!
val aliceMessage2 = aliceManager.processHandshakeMessage(bob.peerID, attackerMessage1)!!
val attackerMessage3 = attackerManager.processHandshakeMessage(alice.peerID, aliceMessage2)!!
expectIdentityMismatch {
aliceManager.processHandshakeMessage(bob.peerID, attackerMessage3)
}
assertSame("Rejected candidate must not replace the working session", originalSession, aliceManager.getSession(bob.peerID))
assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID))
assertTrue("Rejected replacement must not fire authentication callback", aliceAuthenticatedCallbacks == 1)
val plaintext = "original session survives".toByteArray()
val ciphertext = aliceManager.encrypt(plaintext, bob.peerID)
assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID))
// The failed candidate must be fully removed so a later valid restart can replace cleanly.
val restartedBobManager = manager(bob)
val validMessage1 = restartedBobManager.initiateHandshake(alice.peerID)!!
val validMessage2 = aliceManager.processHandshakeMessage(bob.peerID, validMessage1)!!
val validMessage3 = restartedBobManager.processHandshakeMessage(alice.peerID, validMessage2)!!
assertNull(aliceManager.processHandshakeMessage(bob.peerID, validMessage3))
assertTrue(aliceAuthenticatedCallbacks == 2)
val retriedPlaintext = "valid retry promoted".toByteArray()
val retriedCiphertext = restartedBobManager.encrypt(retriedPlaintext, alice.peerID)
assertArrayEquals(retriedPlaintext, aliceManager.decrypt(retriedCiphertext, bob.peerID))
}
@Test
fun `valid responder replacement promotes only after bound handshake completes`() {
val alice = identity()
val bob = identity()
val aliceManager = manager(alice)
val originalBobManager = manager(bob)
var aliceAuthenticatedCallbacks = 0
aliceManager.onSessionEstablished = { _, _ -> aliceAuthenticatedCallbacks += 1 }
completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID)
val originalSession = aliceManager.getSession(bob.peerID)
val originalBinding = aliceManager.getAuthenticatedSession(bob.peerID)!!
// Simulate Bob restarting with the same persistent static identity and no session state.
val restartedBobManager = manager(bob)
val message1 = restartedBobManager.initiateHandshake(alice.peerID)!!
val message2 = aliceManager.processHandshakeMessage(bob.peerID, message1)!!
val message3 = restartedBobManager.processHandshakeMessage(alice.peerID, message2)!!
assertNull(aliceManager.processHandshakeMessage(bob.peerID, message3))
assertNotSame(originalSession, aliceManager.getSession(bob.peerID))
assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
assertArrayEquals(bob.publicKey, aliceManager.getRemoteStaticKey(bob.peerID))
assertTrue(aliceAuthenticatedCallbacks == 2)
val replacementBinding = aliceManager.getAuthenticatedSession(bob.peerID)!!
assertFalse(originalBinding.sessionToken.contentEquals(replacementBinding.sessionToken))
try {
aliceManager.encryptForSession(
"stale generation".toByteArray(),
bob.peerID,
originalBinding
)
fail("Expected stale generation-bound encryption to be rejected")
} catch (_: NoiseSessionError.SessionGenerationChanged) {
// Expected.
}
val plaintext = "replacement transport".toByteArray()
val ciphertext = restartedBobManager.encryptForSession(
plaintext,
alice.peerID,
restartedBobManager.getAuthenticatedSession(alice.peerID)!!
)
assertArrayEquals(plaintext, aliceManager.decrypt(ciphertext, bob.peerID))
}
@Test
fun `generation lease blocks replacement and rejects stale token afterward`() {
val alice = identity()
val bob = identity()
val aliceManager = manager(alice)
val originalBobManager = manager(bob)
completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID)
val originalBinding = aliceManager.getAuthenticatedSession(bob.peerID)!!
val restartedBobManager = manager(bob)
val message1 = restartedBobManager.initiateHandshake(alice.peerID)!!
val message2 = aliceManager.processHandshakeMessage(bob.peerID, message1)!!
val message3 = restartedBobManager.processHandshakeMessage(alice.peerID, message2)!!
val leaseEntered = CountDownLatch(1)
val releaseLease = CountDownLatch(1)
val replacementStarted = CountDownLatch(1)
val replacementCompleted = CountDownLatch(1)
val replacementFinished = AtomicBoolean(false)
val threadFailure = AtomicReference<Throwable?>(null)
val leaseThread = Thread {
try {
assertTrue(
aliceManager.withAuthenticatedSession(bob.peerID, originalBinding) {
leaseEntered.countDown()
releaseLease.await(2, TimeUnit.SECONDS)
}
)
} catch (error: Throwable) {
threadFailure.set(error)
}
}
val replacementThread = Thread {
try {
replacementStarted.countDown()
aliceManager.processHandshakeMessage(bob.peerID, message3)
replacementFinished.set(true)
} catch (error: Throwable) {
threadFailure.set(error)
} finally {
replacementCompleted.countDown()
}
}
leaseThread.start()
assertTrue(leaseEntered.await(1, TimeUnit.SECONDS))
replacementThread.start()
assertTrue(replacementStarted.await(1, TimeUnit.SECONDS))
try {
assertFalse(
"Replacement must wait while the old generation lease is active",
replacementCompleted.await(100, TimeUnit.MILLISECONDS)
)
} finally {
releaseLease.countDown()
}
leaseThread.join(2_000)
replacementThread.join(2_000)
assertTrue(replacementCompleted.await(1, TimeUnit.SECONDS))
threadFailure.get()?.let { throw it }
assertTrue(replacementFinished.get())
try {
aliceManager.encryptForSession(byteArrayOf(1), bob.peerID, originalBinding)
fail("Expected old token to be rejected after replacement")
} catch (_: NoiseSessionError.SessionGenerationChanged) {
// Expected.
}
}
@Test
fun `fresh initiator replacement preserves active session until authentication completes`() {
val alice = identity()
val bob = identity()
val aliceManager = manager(alice)
val originalBobManager = manager(bob)
completeHandshake(aliceManager, alice.peerID, originalBobManager, bob.peerID)
val originalSession = aliceManager.getSession(bob.peerID)
val restartedBobManager = manager(bob)
val message1 = aliceManager.initiateHandshake(bob.peerID, replaceEstablished = true)!!
assertSame(originalSession, aliceManager.getSession(bob.peerID))
assertTrue(aliceManager.hasEstablishedSession(bob.peerID))
val message2 = restartedBobManager.processHandshakeMessage(alice.peerID, message1)!!
val message3 = aliceManager.processHandshakeMessage(bob.peerID, message2)!!
assertNull(restartedBobManager.processHandshakeMessage(alice.peerID, message3))
assertNotSame(originalSession, aliceManager.getSession(bob.peerID))
val plaintext = "fresh link authenticated".toByteArray()
val ciphertext = aliceManager.encrypt(plaintext, bob.peerID)
assertArrayEquals(plaintext, restartedBobManager.decrypt(ciphertext, alice.peerID))
}
@Test
fun `simultaneous initiator replacements use peer ID tie break and complete`() {
val alice = identity()
val bob = identity()
val aliceManager = manager(alice)
val bobManager = manager(bob)
completeHandshake(aliceManager, alice.peerID, bobManager, bob.peerID)
val originalAliceSession = aliceManager.getSession(bob.peerID)
val originalBobSession = bobManager.getSession(alice.peerID)
val aliceMessage1 = aliceManager.initiateHandshake(
bob.peerID,
replaceEstablished = true
)!!
val bobMessage1 = bobManager.initiateHandshake(
alice.peerID,
replaceEstablished = true
)!!
val aliceCollisionResponse = aliceManager.processHandshakeMessage(
bob.peerID,
bobMessage1
)
val bobCollisionResponse = bobManager.processHandshakeMessage(
alice.peerID,
aliceMessage1
)
if (alice.peerID < bob.peerID) {
assertNull(aliceCollisionResponse)
val message2 = bobCollisionResponse!!
val message3 = aliceManager.processHandshakeMessage(bob.peerID, message2)!!
assertNull(bobManager.processHandshakeMessage(alice.peerID, message3))
} else {
assertNull(bobCollisionResponse)
val message2 = aliceCollisionResponse!!
val message3 = bobManager.processHandshakeMessage(alice.peerID, message2)!!
assertNull(aliceManager.processHandshakeMessage(bob.peerID, message3))
}
assertNotSame(originalAliceSession, aliceManager.getSession(bob.peerID))
assertNotSame(originalBobSession, bobManager.getSession(alice.peerID))
val plaintext = "collision replacement transport".toByteArray()
val ciphertext = aliceManager.encrypt(plaintext, bob.peerID)
assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID))
}
@Test
fun `peer ID derivation rejects malformed keys and non-wire claims`() {
val peer = identity()
assertTrue(NoisePeerIdentity.matchesClaimedPeerID(peer.peerID, peer.publicKey))
assertFalse(NoisePeerIdentity.matchesClaimedPeerID(peer.peerID.uppercase(), peer.publicKey))
assertFalse(NoisePeerIdentity.matchesClaimedPeerID("not-a-wire-id", peer.publicKey))
assertFalse(NoisePeerIdentity.matchesClaimedPeerID(peer.peerID, ByteArray(31)))
assertNull(NoisePeerIdentity.derivePeerID(ByteArray(31)))
}
private fun completeHandshake(
initiator: NoiseSessionManager,
initiatorPeerID: String,
responder: NoiseSessionManager,
responderPeerID: String
) {
val message1 = initiator.initiateHandshake(responderPeerID)!!
val message2 = responder.processHandshakeMessage(initiatorPeerID, message1)!!
val message3 = initiator.processHandshakeMessage(responderPeerID, message2)!!
assertNull(responder.processHandshakeMessage(initiatorPeerID, message3))
}
private fun expectIdentityMismatch(block: () -> Unit) {
try {
block()
fail("Expected authenticated Noise key to be rejected for the claimed peer ID")
} catch (_: NoiseSessionError.PeerIdentityMismatch) {
// Expected.
}
}
private fun manager(identity: TestIdentity): NoiseSessionManager = NoiseSessionManager(
localStaticPrivateKey = identity.privateKey,
localStaticPublicKey = identity.publicKey,
localPeerID = identity.peerID
).also { managers += it }
private fun identity(): TestIdentity {
val dh = Noise.createDH("25519")
return try {
dh.generateKeyPair()
val privateKey = ByteArray(32)
val publicKey = ByteArray(32)
dh.getPrivateKey(privateKey, 0)
dh.getPublicKey(publicKey, 0)
TestIdentity(privateKey, publicKey, NoisePeerIdentity.derivePeerID(publicKey)!!)
} finally {
dh.destroy()
}
}
}

View File

@ -0,0 +1,68 @@
package com.bitchat.android.service
import android.os.Build
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
class TransportBridgeServiceTest {
private val targetId = "test-${UUID.randomUUID()}"
@After
fun tearDown() {
TransportBridgeService.unregister(targetId)
}
@Test
fun `bridged prepared plan retains exact payloads with decremented TTL`() {
var captured: RoutedPacket? = null
TransportBridgeService.register(
targetId,
object : TransportBridgeService.TransportLayer {
override fun send(packet: RoutedPacket) {
captured = packet
}
}
)
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = ByteArray(8) { 1 },
recipientID = ByteArray(8) { 2 },
timestamp = System.nanoTime().toULong(),
payload = byteArrayOf(3, 4, 5),
signature = ByteArray(64) { 6 },
ttl = 7u
)
val prepared = listOf(
packet.copy(type = MessageType.FRAGMENT.value, payload = byteArrayOf(10)),
packet.copy(type = MessageType.FRAGMENT.value, payload = byteArrayOf(11))
)
TransportBridgeService.broadcast(
sourceId = "source-${UUID.randomUUID()}",
packet = RoutedPacket(packet, preparedPackets = prepared)
)
val forwarded = captured
assertNotNull(forwarded)
assertEquals(6u.toUByte(), forwarded!!.packet.ttl)
assertEquals(2, forwarded.preparedPackets?.size)
forwarded.preparedPackets!!.zip(prepared).forEach { (actual, original) ->
assertEquals(6u.toUByte(), actual.ttl)
assertTrue(actual.payload.contentEquals(original.payload))
assertEquals(original.type, actual.type)
}
}
}

View File

@ -0,0 +1,303 @@
package com.bitchat.android.ui
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PreparedPrivateMediaTransfer
import com.bitchat.android.mesh.PrivateMediaPreparation
import com.bitchat.android.mesh.PrivateMediaWireMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
@RunWith(RobolectricTestRunner::class)
class MediaSendingManagerMigrationTest {
private val peerID = "8877665544332211"
private lateinit var state: ChatState
private lateinit var mesh: MeshService
private lateinit var manager: MediaSendingManager
private lateinit var file: File
@Before
fun setup() {
state = ChatState(CoroutineScope(SupervisorJob() + Dispatchers.Unconfined))
state.setNickname("me")
mesh = mock()
whenever(mesh.myPeerID).thenReturn("0011223344556677")
whenever(mesh.getPeerNicknames()).thenReturn(mapOf(peerID to "old peer"))
manager = MediaSendingManager(
state,
MessageManager(state),
mock(),
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
mediaWorkDispatcher = Dispatchers.Unconfined,
getMeshService = { mesh }
)
file = kotlin.io.path.createTempFile("private-media", ".jpg").toFile().apply {
writeBytes(ByteArray(128) { it.toByte() })
}
}
@After
fun tearDown() {
file.delete()
}
@Test
fun `preflight rejection creates only a visible failure and no file echo or send`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(PrivateMediaPreparation.Rejected("too many fragments"))
manager.sendImageNote(peerID, null, file.absolutePath)
val messages = state.privateChats.value[peerID].orEmpty()
assertEquals(1, messages.size)
assertTrue(messages.single().content.contains("too many fragments"))
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
assertEquals(null, manager.legacyPrivateMediaConsent.value)
verify(mesh, never()).sendFilePrivate(any(), any())
}
@Test
fun `private preparation runs on the configured media worker`() {
val executor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "private-media-test-worker")
}
val dispatcher = executor.asCoroutineDispatcher()
try {
val preparationThread = AtomicReference<String>()
val prepared = CountDownLatch(1)
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenAnswer {
preparationThread.set(Thread.currentThread().name)
prepared.countDown()
PrivateMediaPreparation.Rejected("test complete")
}
val asynchronousManager = MediaSendingManager(
state,
MessageManager(state),
mock(),
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
mediaWorkDispatcher = dispatcher,
getMeshService = { mesh }
)
asynchronousManager.sendImageNote(peerID, null, file.absolutePath)
assertTrue(prepared.await(5, TimeUnit.SECONDS))
assertTrue(preparationThread.get().contains("private-media-test-worker"))
} finally {
dispatcher.close()
executor.shutdownNow()
}
}
@Test
fun `pinned downgrade rejection is visible without a file echo or send`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(
PrivateMediaPreparation.Rejected(
"Encrypted private media was previously pinned, but this session proved no support; send blocked"
)
)
manager.sendImageNote(peerID, null, file.absolutePath)
val messages = state.privateChats.value[peerID].orEmpty()
assertEquals(1, messages.size)
assertTrue(messages.single().content.contains("previously pinned"))
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
verify(mesh, never()).sendFilePrivate(any(), any())
}
@Test
fun `missing Noise session retains first send and commits after proof resolution`() {
val commits = AtomicInteger(0)
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(PrivateMediaPreparation.NeedsHandshake)
.thenAnswer { invocation ->
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(
transferId = invocation.getArgument<String>(2),
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
) {
commits.incrementAndGet()
true
}
)
}
manager.sendImageNote(peerID, null, file.absolutePath)
verify(mesh, times(1)).initiateNoiseHandshake(peerID)
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
assertEquals(null, manager.legacyPrivateMediaConsent.value)
manager.retryPendingPrivateMedia(peerID)
assertEquals(1, commits.get())
assertEquals(1, state.privateChats.value[peerID]?.size)
verify(mesh, times(2)).prepareFilePrivate(eq(peerID), any(), any(), eq(false))
verify(mesh, never()).sendFilePrivate(any(), any())
}
@Test
fun `awaiting peer state retains send and watchdog resolution offers legacy consent`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(PrivateMediaPreparation.AwaitingPeerState)
.thenReturn(PrivateMediaPreparation.RequiresLegacyConsent("relay-visible warning"))
manager.sendImageNote(peerID, null, file.absolutePath)
verify(mesh, never()).initiateNoiseHandshake(peerID)
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
assertEquals(null, manager.legacyPrivateMediaConsent.value)
manager.retryPendingPrivateMedia(peerID)
assertNotNull(manager.legacyPrivateMediaConsent.value)
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
}
@Test
fun `reentrant proof resolution during preparation cannot lose first send intent`() {
val commits = AtomicInteger(0)
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenAnswer {
manager.retryPendingPrivateMedia(peerID)
PrivateMediaPreparation.AwaitingPeerState
}
.thenAnswer { invocation ->
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(
transferId = invocation.getArgument<String>(2),
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
) {
commits.incrementAndGet()
true
}
)
}
manager.sendImageNote(peerID, null, file.absolutePath)
assertEquals(1, commits.get())
assertEquals(1, state.privateChats.value[peerID]?.size)
verify(mesh, times(2)).prepareFilePrivate(eq(peerID), any(), any(), eq(false))
}
@Test
fun `legacy consent is one shot rechecks policy and echoes only after approval`() {
val commits = AtomicInteger(0)
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(PrivateMediaPreparation.RequiresLegacyConsent("relay-visible warning"))
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(true)))
.thenAnswer { invocation ->
val transferId = invocation.getArgument<String>(2)
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(
transferId = transferId,
// Simulate the capability becoming authenticated while
// the consent dialog was open: recheck must upgrade.
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
) {
commits.incrementAndGet()
true
}
)
}
manager.sendImageNote(peerID, null, file.absolutePath)
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
val request = manager.legacyPrivateMediaConsent.value
assertNotNull(request)
manager.approveLegacyPrivateMedia(request!!.requestId)
manager.approveLegacyPrivateMedia(request.requestId)
assertEquals(1, commits.get())
assertEquals(1, state.privateChats.value[peerID]?.size)
assertEquals(null, manager.legacyPrivateMediaConsent.value)
verify(mesh, times(1)).prepareFilePrivate(eq(peerID), any(), any(), eq(false))
verify(mesh, times(1)).prepareFilePrivate(eq(peerID), any(), any(), eq(true))
}
@Test
fun `prepared transfer ID mismatch aborts before local echo or commit`() {
val commits = AtomicInteger(0)
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(
transferId = "wrong-transfer-id",
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
) {
commits.incrementAndGet()
true
}
)
)
manager.sendImageNote(peerID, null, file.absolutePath)
assertEquals(0, commits.get())
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
}
@Test
fun `failed prepared commit rolls back the local file echo`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenAnswer { invocation ->
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(
transferId = invocation.getArgument(2),
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
) {
false
}
)
}
manager.sendImageNote(peerID, null, file.absolutePath)
val messages = state.privateChats.value[peerID].orEmpty()
assertEquals(1, messages.size)
assertTrue(messages.single().content.contains("could not be committed"))
assertTrue(messages.none { it.type == com.bitchat.android.model.BitchatMessageType.Image })
}
@Test
fun `cancelled consent cannot later send or echo`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(PrivateMediaPreparation.RequiresLegacyConsent("relay-visible warning"))
manager.sendImageNote(peerID, null, file.absolutePath)
val request = manager.legacyPrivateMediaConsent.value!!
manager.cancelLegacyPrivateMedia(request.requestId)
manager.approveLegacyPrivateMedia(request.requestId)
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
verify(mesh, never()).prepareFilePrivate(eq(peerID), any(), any(), eq(true))
}
}

View File

@ -0,0 +1,93 @@
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,81 @@
package com.bitchat.android.wifiaware
import android.net.ConnectivityManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.net.Socket
class WifiAwareConnectionTrackerTest {
@Test
fun `compare and rebind rejects stale authenticated socket after replacement`() {
val tracker = WifiAwareConnectionTracker(
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
mock<ConnectivityManager>()
)
val authenticatedSocket = syncedSocket()
val replacementSocket = syncedSocket()
tracker.onClientConnected("provisional", authenticatedSocket)
tracker.onClientConnected("provisional", replacementSocket)
assertFalse(
tracker.rebindPeerIdIfCurrent(
previousPeerId = "provisional",
resolvedPeerId = "canonical",
expectedSocket = authenticatedSocket
)
)
assertSame(replacementSocket, tracker.getSocketForPeer("provisional"))
assertNull(tracker.getSocketForPeer("canonical"))
assertTrue(
tracker.rebindPeerIdIfCurrent(
previousPeerId = "provisional",
resolvedPeerId = "canonical",
expectedSocket = replacementSocket
)
)
assertSame(replacementSocket, tracker.getSocketForPeer("canonical"))
assertSame(replacementSocket, tracker.getSocketForPeer("provisional"))
}
@Test
fun `authenticated provisional socket cannot displace existing canonical socket`() {
val tracker = WifiAwareConnectionTracker(
CoroutineScope(SupervisorJob() + Dispatchers.Unconfined),
mock<ConnectivityManager>()
)
val provisionalSocket = syncedSocket()
val canonicalSocket = syncedSocket()
tracker.onClientConnected("provisional", provisionalSocket)
tracker.onClientConnected("canonical", canonicalSocket)
assertFalse(
tracker.rebindPeerIdIfCurrent(
previousPeerId = "provisional",
resolvedPeerId = "canonical",
expectedSocket = provisionalSocket
)
)
assertSame(provisionalSocket, tracker.getSocketForPeer("provisional"))
assertSame(canonicalSocket, tracker.getSocketForPeer("canonical"))
assertTrue("Rejected promotion must not alias the provisional ID", tracker.canonicalPeerId("provisional") == "provisional")
}
private fun syncedSocket(): SyncedSocket {
val raw = mock<Socket> {
on { getInputStream() } doReturn ByteArrayInputStream(byteArrayOf())
on { getOutputStream() } doReturn ByteArrayOutputStream()
}
return SyncedSocket(raw)
}
}

View File

@ -8,7 +8,10 @@ Status: optional and backward-compatible.
- Outer packet: BitChat binary packet with `type = 0x01` (ANNOUNCE). Header is unchanged.
- Payload: A sequence of TLVs. Unknown TLVs MUST be ignored for forward compatibility.
- Signature: The packet MAY be signed using the Ed25519 public key carried in TLV `0x03`. The gossip TLV (if present) is part of the payload and therefore covered by the signature.
- Signature: The packet is signed using the Ed25519 public key carried in TLV
`0x03`. The gossip and capability TLVs are part of the payload and therefore
covered by the signature. Current clients require a valid signature before
applying identity or capability state.
## TLV Format
@ -24,6 +27,12 @@ Existing TLVs (unchanged):
- `0x02` NOISE_PUBLIC_KEY: Noise static public key bytes (typically 32 bytes for X25519)
- `0x03` SIGNING_PUBLIC_KEY: Ed25519 public key bytes (typically 32 bytes)
Other optional extension:
- `0x05` CAPABILITIES: Minimal little-endian feature bitfield. Bit 8 advertises
authenticated Noise private media (`PRIVATE_MEDIA_V1`); its exact value is
`00 01`. An empty value is valid and means no advertised capabilities.
New TLV (optional):
- `0x04` DIRECT_NEIGHBORS: Concatenation of up to 10 peer IDs, each encoded as exactly 8 bytes. There is no inner count; the number of neighbors is `length / 8`. If `length` is not a multiple of 8, trailing partial bytes MUST be ignored.
@ -44,7 +53,9 @@ This matches the on‑wire 8‑byte `senderID`/`recipientID` encoding used in th
- Optionally append TLV `0x04` with up to 10 unique, directly connected peer IDs.
- Remove duplicates before encoding.
- Order is arbitrary and not semantically significant.
- Sign the ANNOUNCE packet so the gossip TLV is covered (recommended):
- Current capable senders also include TLV `0x05` with private-media bit 8 set
in every broadcast and peer-directed announcement.
- Sign the ANNOUNCE packet so all extension TLVs are covered:
- Signature algorithm: Ed25519 using the key in TLV `0x03`.
- Signature input: the binary packet encoding with the signature field omitted and the TTL normalized to `0`. This allows TTL to change during relays without invalidating the signature.
- The payload may be compressed per the base protocol; the gossip TLV is encoded prior to optional compression.
@ -53,6 +64,11 @@ This matches the on‑wire 8‑byte `senderID`/`recipientID` encoding used in th
- Decompress payload if the packet’s compression flag is set, then parse TLVs in order.
- Parse TLVs `0x01`..`0x03` as usual; ignore any unknown TLVs.
- Parse TLV `0x05`, when present, as a little-endian bitfield. Absence and an
explicitly empty value remain valid legacy capability states. A
security-sensitive bit is trusted only after the signed announcement key is
bound to the matching remote static key from a live Noise handshake; see
`PRIVATE_MEDIA_V1.md`.
- If a `0x04` TLV is present:
- Interpret the value as `N = length / 8` peer IDs (ignore trailing non‑aligned bytes).
- Each 8‑byte chunk is decoded back to a 16‑hex‑char peer ID string (lowercase).
@ -76,8 +92,8 @@ ANNOUNCE payload TLVs (concatenated):
- `02 [len=32] [32 bytes X25519 pubkey]`
- `03 [len=32] [32 bytes Ed25519 pubkey]`
- `04 [len=8*M] [peerID1(8) || peerID2(8) || ... || peerIDM(8)]` (optional)
- `05 02 00 01` (optional private-media-v1 capability)
Where each `peerIDk(8)` is the 8‑byte binary form of the peer ID as specified above.
That’s the entire change; the outer packet header, message type, and relay/TTL behavior are unchanged.

View File

@ -0,0 +1,62 @@
# Noise peer-ID binding
Mesh wire peer IDs are exactly 16 lowercase hexadecimal characters derived as
`hex(SHA-256(noiseStaticPublicKey)[0..<8])`. Android enforces that binding at
both identity entry points:
- A verified announcement must carry a 32-byte Noise static key whose derived
ID matches both the packet sender and routed sender.
- A Noise XX initiator or responder must authenticate a remote static key whose
derived ID matches the claimed session key before transport ciphers are
exposed or an authentication callback runs.
Inbound rehandshakes use a separate responder candidate. An established
session remains active until the candidate completes and passes the binding;
failure or mismatch destroys only the candidate. BLE mappings, Wi-Fi socket
rebinds, gossip, sync, and peer-last-seen effects run only after announcement
validation succeeds. A Wi-Fi discovery identity is not destructively rebound
from a self-signed announce. A direct announce creates only a provisional claim
and starts a fresh replacement handshake on that exact transport generation.
The alias is promoted only when the same challenged, still-active socket
delivers the Noise frame that completes bound authentication. A peer-ID-only,
unsolicited, cross-link, or stale-socket callback cannot authorize that rebind;
the expected-socket comparison and alias mutation are atomic with socket
replacement.
Promotion also refuses to displace a different live socket already authenticated
under the canonical peer ID.
Leave packets use the existing signed wire format and are accepted only when
the signature matches the key learned from a verified announcement and the
timestamp is within the five-minute security window. Invalid, stale, future,
or unsigned leaves therefore cannot evict the claimed peer or be relayed. A
valid leave removes the peer through the normal peer-manager path, which also
clears its active Noise session.
Announcements no longer write fingerprint mappings. Those mappings are created
only by the authenticated Noise-session callback. A known peer's signing key
also cannot change based on an announcement or merely because some session for
that peer ID is active. Rotation requires an authenticated peer-state proof
tied to the exact Noise channel; ambient session presence is not enough.
The same authenticated callback restores any existing Noise-key-to-Nostr
relationship under the canonical 16-hex mesh ID; unproven announcements never
write that routing index.
Generation-sensitive consumers use the 32-byte Noise handshake hash as a local
session token. The hash is cloned before handshake-state zeroization, and
session lookup, decrypt, expected-token encrypt, and leased identity mutation
share the Noise manager lock. A same-static replacement therefore cannot reuse
the previous generation's proof or destroy a session while a bound mutation is
in progress.
## Remaining TOFU boundary
The first public announcement is still self-signed trust-on-first-use. An
attacker can copy a public Noise key and self-sign an announcement, but cannot
complete the bound Noise handshake for that ID. Public-mesh identity admission
is intentionally not gated behind an automatic handshake in this change; doing
so is a separate availability/protocol decision.
Consequently, discovery metadata or capability bits in an announcement are
hints, not proof of Noise-key possession. Security-sensitive capabilities must
be confirmed inside the authenticated Noise channel before they are pinned or
used to authorize a downgrade-sensitive behavior.

168
docs/PRIVATE_MEDIA_V1.md Normal file
View File

@ -0,0 +1,168 @@
# Private media v1 interoperability and migration
Private media reuses the canonical `BitchatFilePacket` TLV. A capable sender
wraps the complete encoded file TLV as Noise payload type `0x20`, encrypts it
for the recipient, and only then fragments the final outer packet.
## Encrypted wire contract
- Outer packet type: `MessageType.NOISE_ENCRYPTED` (`0x11`).
- Decrypted Noise payload type: `NoisePayloadType.FILE_TRANSFER` (`0x20`).
- Noise payload data: one complete encoded `BitchatFilePacket`.
- Outer recipient: the target peer ID; never broadcast for private media.
Prerelease iOS builds of #1434 briefly emitted the inner file type as `0x09`.
Android accepts that value on decode and immediately canonicalizes it to
`NoisePayloadType.FILE_TRANSFER`; every Android encode remains `0x20`. Do not
allocate or emit a second Noise payload type for this format.
The decode-only `0x09` alias may be removed only after every TestFlight/internal
build that emitted it has expired and the project's minimum-supported-client
policy excludes those builds. Track that release criterion explicitly; do not
remove the alias on an arbitrary calendar date.
## Discovery hint
Identity announcement TLV `0x05` is a minimal little-endian bitfield. Bit 8
means the peer implements private-media v1, so its exact encoding is:
```text
05 02 00 01
| | |----- capability bytes: 0x0100 little-endian
| |-------- value length
|----------- capabilities TLV
```
Current Android builds include this TLV in broadcast and peer-directed
announcements over both BLE and Wi-Fi Aware. Older clients safely skip the
unknown TLV. Its absence, or a present TLV with bit 8 clear, does not invalidate
the announcement.
Decoders retain unknown low-64-bit capability bits and unknown announcement
TLVs so a decode/re-encode cycle does not erase newer extensions.
The announcement bit is discovery metadata only. A self-signed announcement
does not prove possession of its public Noise key, so it never authorizes
encrypted media, creates a capability pin, or satisfies a pending send.
## Authenticated peer state (`0x21`)
Every newly authenticated Noise generation, including a rekey with the same
static key, exchanges a fresh peer-state proof. Its decrypted Noise payload
type is `0x21`, followed by this canonical byte sequence:
```text
01 version
01 <len 1...8> <value> capabilities, minimal little-endian
02 20 <32 bytes> Ed25519 signing public key
```
Both known TLVs are required exactly once. Decoders reject an unknown version,
truncated TLV, duplicate known TLV, a capability length outside `1...8`, a
non-minimal capability value, or an Ed25519 key whose length is not 32. Unknown
TLVs are skipped for forward compatibility, and the two known TLVs may arrive
in either order.
Each endpoint sends `0x21` when the generation authenticates and echoes its
local state at most once after accepting the peer's first valid proof for that
generation. Repeated identical proofs are idempotent; a different second proof
cannot replace the first within that generation. A five-second generation
watchdog distinguishes an older client that ignores `0x21` from a new client
that supplied a proof. Persisted state from a previous connection never
satisfies the fresh-generation watchdog.
Locally, the Noise handshake hash is the generation token. Decryption returns
that token with the plaintext, coordinator mutations hold a lease on that exact
session, and private-media encryption accepts the policy decision only while
the same token remains active. A same-static rekey therefore cannot reuse an
older proof or race policy into encrypting on an unproved generation.
The proof is bound by the Noise channel to the exact authenticated 32-byte
remote static key and canonical peer ID. Store its capabilities and 32-byte
Ed25519 key under the SHA-256 fingerprint of that static key in encrypted
identity state. A proof with bit 8 creates an HSTS-style private-media pin; a
later no-bit proof does not erase that history. Panic wipe clears both records
and prevents an in-flight pre-wipe controller from restoring them.
The persisted Ed25519 key is consulted before accepting later announcements.
This lets a valid proof recover from a copied-static, wrong-Ed preannouncement,
while preventing that preannouncement from winning again after restart. A
fresh proof in a later Noise generation may intentionally rotate the Ed25519
key; an announcement by itself cannot.
## Mixed-client send policy
- No live authenticated Noise remote-static key: retain the first send intent,
emit no media or local echo, and initiate one Noise handshake.
- Live generation waiting for `0x21`: retain that same intent while the
five-second peer-state watchdog runs.
- Fresh proof with bit 8: automatically retry the retained intent and send
encrypted `0x11` / `0x20` after final-packet admission.
- Fresh proof without bit 8, or a five-second no-proof timeout for an unpinned
old client: retry the retained intent by showing the existing explicit
one-shot legacy consent prompt.
- A previously pinned identity that proves no bit, or fails to prove state in
the current generation, is visibly blocked. It cannot silently fall back.
The exact automatic intent is reserved before its first policy evaluation, so a
proof or timeout callback racing that evaluation cannot be lost. It is bounded
and singular, and expires after 15 seconds with a visible system message.
Retries are serialized and always re-run current policy and exact packet
admission. No waiting, rejected, expired, or cancelled attempt creates a local
file echo or transmits raw media; terminal rejection is shown as a system
message rather than failing silently.
The legacy consent path sends a recipient-directed raw
`MessageType.FILE_TRANSFER` (`0x22`) packet. Its contents are visible to relays,
so the UI must say that it is not end-to-end encrypted. The final routed packet
must carry a valid Ed25519 signature over the canonical packet bytes; signing
failure aborts the send. Receivers reject unsigned or invalid signed directed
raw files.
Consent is consumed at most once. On approval the sender re-runs the policy and
packet admission checks. If a bit-8 proof arrived while the dialog was open,
the send upgrades to encrypted mode. Cancellation, duplicate approval, panic
wipe, and changed security state cannot cause a later send.
## Final-packet admission
Before creating a local echo or progress mapping, the sender builds the exact
encrypted-or-legacy packet, attaches its final source route, signs it, and
creates the exact transport fragment plan. Commit sends that prepared plan
without rebuilding it.
One packet may use at most 256 fragments. This limit is checked after route,
signature, encryption, and envelope overhead are known; therefore there is no
single safe file-byte estimate for every route. Fragment totals and indices
must also fit their unsigned 16-bit wire fields without truncation. A rejected
plan creates no local echo and sends no fragments.
The 256-fragment limit is a transport/reassembly safety bound, not a capability
negotiated through bit 8. A future larger transfer protocol needs a separate
capability and bounded streaming design.
## Compatibility summary
- New Android to new iOS/Android: generation-scoped authenticated `0x21`
exchange, then encrypted Noise `0x20`.
- Prerelease iOS to new Android: encrypted Noise `0x09` is decoded,
canonicalized to `0x20`, and delivered during the migration window.
- New Android to an older client: the unknown `0x21` is ignored; after the
five-second watchdog, an unpinned identity may use one explicitly consented,
relay-visible signed raw `0x22` transfer.
- Old clients receiving a new announcement: ignore TLV `0x05` and continue
operating normally. Old clients receiving `0x21` drop the unknown inner type
without affecting their existing Noise session or private messages.
- A previously capable authenticated identity cannot force a silent downgrade
by omitting `0x21`, clearing the bit, or changing only its announcement.
Do not remove the `0x21` watchdog/legacy-consent migration path until the
minimum-supported Android and iOS versions both emit an authenticated bit-8
`0x21` proof on every Noise generation, and the released legacy population has
aged out under the project's explicit support policy. Merely observing an
announcement bit or waiting for an arbitrary date is not sufficient. The HSTS
pin remains necessary after that point; removing old-client consent must not
re-enable a raw automatic fallback.
Public media remains signed broadcast `MessageType.FILE_TRANSFER` (`0x22`) and
is outside this private-media capability.

View File

@ -3,13 +3,20 @@
This document is the exhaustive implementation guide for Bitchat’s Bluetooth file transfer protocol for voice notes (audio) and images, including interactive features like waveform seeking. It describes the on‑wire packet format (both v1 and v2), fragmentation/progress/cancellation, sender/receiver behaviors, and the complete UX we implemented in the Android client so that other implementers can interoperate and match the user experience precisely.
**Protocol Versions:**
- **v1**: Original protocol with 2‑byte payload length (≤ 64 KiB files)
- **v2**: Extended protocol with 4-byte payload length (≤ 4 GiB files) - use for all file transfers
- File transfer packets use v2 format by default for optimal compatibility
- **v1**: Original envelope with a 2-byte payload length.
- **v2**: Extended envelope with a 4-byte payload length.
- Public and legacy raw file-transfer envelopes use v2. A Noise-encrypted
private envelope may use v1 when its ciphertext fits, and source routing
upgrades the final envelope to v2.
- The payload-length field is not the practical transfer limit. Private-media
admission is limited to 256 final fragments after route, signature,
encryption, and envelope overhead. Generic/public outbound fragmentation
retains the UInt16 wire range; receivers may impose a lower safety bound.
**Interactive Features:**
- **Waveform Seeking**: Tap anywhere on audio waveforms to jump to that playback position
- **Large File Support**: v2 protocol enables multi-GiB file transfers through fragmentation
- **Bounded Transfer Support**: v2 removes the 16-bit envelope-length limit,
while bounded fragmentation protects receivers from unbounded reassembly.
- **Unified Experience**: Identical UX between platforms with enhanced user control
The guide is organized into:
@ -34,12 +41,15 @@ Bitchat BLE transport carries application messages inside the common `BitchatPac
Fields (subset relevant to file transfer):
- `version: UByte` — protocol version (`1` for v1, `2` for v2 with extended payload length).
- `type: UByte` — message type. File transfer uses `MessageType.FILE_TRANSFER (0x22)`.
- `type: UByte` — public file transfer uses `MessageType.FILE_TRANSFER (0x22)`;
private file transfer uses outer `MessageType.NOISE_ENCRYPTED (0x11)`.
- `senderID: ByteArray (8)` — 8‑byte binary peer ID.
- `recipientID: ByteArray (8)` — 8‑byte recipient. For public: `SpecialRecipients.BROADCAST (0xFF…FF)`; for private: the target peer’s 8‑byte ID.
- `timestamp: ULong` — milliseconds since epoch.
- `payload: ByteArray` — TLV file payload (see below).
- `signature: ByteArray?` — optional signature (present for private sends in our implementation, to match iOS integrity path).
- `payload: ByteArray` — the public form contains the file TLV directly. The
private form contains Noise ciphertext which authenticates payload type
`FILE_TRANSFER (0x20)` followed by the same file TLV.
- `signature: ByteArray?` — packet signature added by the mesh send path.
- `ttl: UByte` — hop TTL (we use `MAX_TTL` for broadcast, `7` for private).
Envelope creation and broadcast paths are implemented in:
@ -48,7 +58,9 @@ Envelope creation and broadcast paths are implemented in:
- `app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/BluetoothConnectionManager.kt)
- `app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt` (/Users/cc/git/bitchat-android/app/src/main/java/com/bitchat/android/mesh/PacketProcessor.kt)
Private sends are additionally encrypted at the higher layer (Noise) for text messages, but file transfers use the `FILE_TRANSFER` message type in the clear at the envelope level with content carried inside a TLV. See code for any deployment‑specific enforcement.
Private sends encrypt the complete file TLV as Noise payload `0x20` before
outer fragmentation. See `PRIVATE_MEDIA_V1.md` for the capability and mixed-
client interoperability contract.
### 1.2 Binary Protocol Extensions (v2)
@ -77,19 +89,23 @@ PayloadLength: 4 bytes (big-endian, max ~4 GiB)
```
- **Header Size**: Increased from 13 to 15 bytes.
- **Payload Length Field**: Extended from 16 bits (2 bytes) to 32 bits (4 bytes), allowing file transfers up to ~4 GiB.
- **Backward Compatibility**: Clients must support both v1 and v2 decoding. File transfer packets always use v2.
- **Payload Length Field**: Extended from 16 bits (2 bytes) to 32 bits (4
bytes). That is the field's theoretical range, not the permitted mesh
reassembly size.
- **Backward Compatibility**: Clients must support both v1 and v2 decoding.
- **Implementation**: See `BinaryProtocol.kt` with `getHeaderSize(version)` logic.
#### Use Cases for v2
- **Large Audio Files**: Professional recordings, podcasts, or music samples.
- **High-Resolution Images**: Full-resolution photos from modern smartphones.
- **Future File Types**: PDFs, documents, archives, or other large media.
#### Use cases for v2
v2 carries file payloads that exceed the v1 envelope-length field and carries
source-route metadata. It does not imply multi-gigabyte mesh transfer support.
#### Interoperability Requirements
- Clients receiving v2 packets must decode 4-byte `PayloadLength` fields.
- Clients sending file transfers should preferentially use v2 format.
- Fragmentation still applies: large files are split into fragments that fit within BLE MTU constraints (~128 KiB per fragment).
- Fragmentation still applies. Each serialized mesh fragment fits the 512-byte
transport threshold; the data portion is at most 469 bytes and becomes
smaller when recipient or source-route overhead is present.
### 1.3 File Transfer TLV payload (BitchatFilePacket)
@ -114,7 +130,8 @@ Encoding rules:
- Standard TLVs use `1 byte type + 2 bytes big‑endian length + value`.
- CONTENT uses a 4‑byte big‑endian length to allow payloads well beyond 64 KiB.
- With the v2 envelope (4‑byte payload length), CONTENT can be large; transport still fragments oversize packets to fit BLE MTU.
- With the v2 envelope (4-byte payload length), CONTENT can exceed 64 KiB, but
sender and receiver fragmentation policies still bound practical transfers.
- Implementations should validate TLV boundaries; decoding should fail fast on malformed structures.
Decoding rules (v2):
@ -140,8 +157,13 @@ Legacy Compatibility (optional, for mixed‑version meshes):
File transfers reuse the mesh broadcaster’s fragmentation logic:
- `BluetoothPacketBroadcaster` checks if the serialized envelope exceeds the configured MTU and splits it into fragments via `FragmentManager`.
- Fragments are sent with a short inter‑fragment delay (currently ~200 ms; matches iOS/Rust behavior notes in code).
- Fragments are sent with a short inter-fragment delay (currently 20 ms).
- When only one fragment is needed, send as a single packet.
- Android receivers reject fragment sets declaring more than 256 fragments.
Private senders use that same 256-fragment limit and calculate it from the
exact final routed, signed, and encrypted packet before creating a local
echo. Generic/public outbound planning retains its prior UInt16 count range,
so this private-media migration does not silently change public sending.
### 2.2 Transfer ID and progress events
@ -216,24 +238,38 @@ Files:
- Files saved under `files/voicenotes/outgoing/voice_YYYYMMDD_HHMMSS.m4a`.
2) Local echo
- We create a `BitchatMessage` with content `"[voice] <path>"` and add to the appropriate timeline (public/channel/private).
- For private: `messageManager.addPrivateMessage(peerID, message)`. For public/channel: `messageManager.addMessage(message)` or add to channel.
- Public/channel sends create their timeline entry before dispatch.
- Private sends first finish capability policy and exact final-fragment
admission. They create the local echo and progress mapping only for an
admitted plan, then atomically commit that prepared plan.
3) Packet creation
- Build a `BitchatFilePacket`:
- `fileName`: basename (e.g., `voice_… .m4a`)
- `fileSize`: file length
- `mimeType`: `audio/mp4`
- `content`: full bytes (ensure content ≤ 64 KiB; with chosen codec params typical short notes fit fragmentation constraints)
- `content`: full bytes; final-packet admission determines whether it fits
the 256-fragment limit.
- Encode TLV; compute `transferId = sha256Hex(payload)`.
- Map `transferId → messageId` for UI progress.
4) Send
- Public: `BluetoothMeshService.sendFileBroadcast(filePacket)`.
- Private: `BluetoothMeshService.sendFilePrivate(peerID, filePacket)`.
- Private UI: `prepareFilePrivate(...)`, followed by one-shot commit of a
ready plan. The non-interactive `sendFilePrivate(...)` entry point commits
only encrypted-ready plans and never silently chooses a legacy downgrade.
- Broadcaster handles fragmentation and progress emission.
5) Waveform
5) Mixed-client private migration
- A verified legacy recipient with no private-media capability requires a
user warning and one-shot consent.
- Approval rechecks policy. The fallback is recipient-directed raw `0x22`,
visible to relays, and must have a valid Ed25519 packet signature.
- No authenticated Noise identity starts a handshake without a local echo
or media send; the user retries after it completes. Signing failure or a
private plan above 256 final fragments aborts without an echo or send.
6) Waveform
- We extract a 120‑bin waveform from the recorded file (the same extractor used for the receiver) and cache by file path, so sender and receiver waveforms are identical.
Core files:
@ -373,8 +409,10 @@ Files:
- Path markers in messages
- We use simple content markers: `"[voice] <abs path>", "[image] <abs path>", "[file] <abs path>"` for local rendering. These are not sent on the wire; the actual file bytes are inside the TLV payload.
- Progress math for images relies on `(sent / total)` from `TransferProgressManager` (fragment‑level granularity). The block grid density can be tuned; currently 24×16.
- Private vs public: both use the same file TLV; only the envelope `recipientID` differs. Private may have signatures; code shows a signing step consistent with iOS behavior prior to broadcast to ensure integrity.
- BLE timing: there is a 200 ms inter‑fragment delay for stability. Adjust as needed for your radio stack while maintaining compatibility.
- Private vs public: both use the same file TLV. Private media wraps it in a
Noise payload of type `0x20`; public media carries it directly in a `0x22`
packet.
- BLE timing: there is a 20 ms inter-fragment delay. Adjust as needed for your radio stack while maintaining compatibility.
---
@ -426,7 +464,9 @@ Fullscreen image:
- FILE_NAME and MIME_TYPE: `type(1) + len(2) + value`
- FILE_SIZE: `type(1) + len(2=4) + value(4, UInt32 BE)`
- CONTENT: `type(1) + len(4) + value`
3. Embed the TLV into a `BitchatPacket` envelope with `type = FILE_TRANSFER (0x22)` and the correct `recipientID` (broadcast vs private).
3. For public media, embed the TLV in `FILE_TRANSFER (0x22)`. For private media,
prefix the TLV with Noise payload type `0x20`, encrypt it for the recipient,
and put the ciphertext in `NOISE_ENCRYPTED (0x11)`.
4. Fragment, send, and report progress using a transfer ID derived from `sha256(payload)` so the UI can map progress to a message.
5. Support cancellation at the fragment sender: stop sending remaining fragments and propagate a cancel to the UI (we remove the message).
6. On receive, decode TLV, persist to an app directory (separate audio/images/other), and create a chat message with content marker `"[voice] path"`, `"[image] path"`, or `"[file] path"` for local rendering.