mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-15 06:56:30 +00:00
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>
This commit is contained in:
parent
b87239cc86
commit
624776667a
@ -8,6 +8,8 @@ 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
|
||||
@ -191,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
|
||||
@ -203,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
|
||||
@ -255,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)
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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) }
|
||||
}
|
||||
@ -323,10 +323,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()
|
||||
|
||||
@ -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
|
||||
@ -50,6 +52,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,7 +124,6 @@ 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
|
||||
@ -127,10 +180,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 +256,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
delegate?.didUpdatePeerList(peerIDs)
|
||||
}
|
||||
override fun onPeerRemoved(peerID: String) {
|
||||
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) { }
|
||||
@ -220,9 +276,15 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
override fun onKeyExchangeCompleted(
|
||||
peerID: String,
|
||||
authenticatedRemoteStaticKey: ByteArray,
|
||||
authenticatedSessionToken: ByteArray,
|
||||
directRelayAddress: String?,
|
||||
ingressLinkID: String?
|
||||
) {
|
||||
authenticatedPeerState.onSessionAuthenticated(
|
||||
peerID,
|
||||
authenticatedRemoteStaticKey,
|
||||
authenticatedSessionToken
|
||||
)
|
||||
// Send announcement and cached messages after key exchange
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
|
||||
@ -254,6 +316,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
|
||||
@ -302,10 +367,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
|
||||
@ -330,13 +402,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 {
|
||||
@ -380,6 +458,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticatedPeerStateReceived(
|
||||
peerID: String,
|
||||
state: AuthenticatedPeerState,
|
||||
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
|
||||
) {
|
||||
authenticatedPeerState.receive(peerID, state, authenticatedSession)
|
||||
}
|
||||
|
||||
// Message operations
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
@ -758,6 +844,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}")
|
||||
@ -790,70 +902,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}")
|
||||
}
|
||||
}
|
||||
|
||||
@ -1069,7 +1176,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")
|
||||
@ -1132,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 peer announcement as TLV")
|
||||
@ -1373,24 +1480,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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -162,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) { }
|
||||
@ -172,9 +226,15 @@ class MeshCore(
|
||||
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,
|
||||
@ -205,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 {
|
||||
@ -250,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) {
|
||||
@ -281,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)
|
||||
}
|
||||
|
||||
@ -289,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)
|
||||
}
|
||||
@ -305,6 +382,14 @@ class MeshCore(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAuthenticatedPeerStateReceived(
|
||||
peerID: String,
|
||||
state: AuthenticatedPeerState,
|
||||
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
|
||||
) {
|
||||
authenticatedPeerState.receive(peerID, state, authenticatedSession)
|
||||
}
|
||||
|
||||
override fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String? {
|
||||
return delegate?.decryptChannelMessage(encryptedContent, channel)
|
||||
}
|
||||
@ -442,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
|
||||
@ -467,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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -612,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,
|
||||
@ -633,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,
|
||||
@ -818,28 +962,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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -3,6 +3,7 @@ package com.bitchat.android.mesh
|
||||
import android.util.Log
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.BitchatMessageType
|
||||
import com.bitchat.android.model.AuthenticatedPeerState
|
||||
import com.bitchat.android.model.RoutedPacket
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.protocol.MessageType
|
||||
@ -59,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")
|
||||
@ -145,6 +147,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
|
||||
@ -248,6 +263,14 @@ class MessageHandler(private val myPeerID: String, private val appContext: andro
|
||||
return AnnounceHandlingResult.Rejected
|
||||
}
|
||||
|
||||
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
|
||||
@ -287,12 +310,13 @@ 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 mesh graph from gossip neighbors (only if TLV present)
|
||||
@ -442,14 +466,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) {
|
||||
@ -608,7 +644,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)
|
||||
@ -618,13 +661,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 onAuthenticatedPeerStateReceived(
|
||||
peerID: String,
|
||||
state: AuthenticatedPeerState,
|
||||
authenticatedSession: com.bitchat.android.noise.AuthenticatedNoiseSession
|
||||
) {}
|
||||
|
||||
// Message operations
|
||||
fun decryptChannelMessage(encryptedContent: ByteArray, channel: String): String?
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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.*
|
||||
@ -71,15 +73,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
|
||||
@ -134,11 +138,19 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
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 = peerID,
|
||||
authenticatedRemoteStaticKey = authenticatedRemoteStaticKey,
|
||||
authenticatedSessionToken = authenticatedSessionToken,
|
||||
directRelayAddress = routed.relayAddress.takeIf { isDirectIngress },
|
||||
ingressLinkID = routed.ingressLinkID.takeIf { isDirectIngress }
|
||||
)
|
||||
@ -153,21 +165,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -193,13 +197,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
|
||||
@ -256,6 +271,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private
|
||||
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 &&
|
||||
@ -435,9 +458,11 @@ interface SecurityManagerDelegate {
|
||||
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
|
||||
}
|
||||
|
||||
@ -129,6 +129,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 }
|
||||
@ -323,6 +358,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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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})"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -11,6 +11,8 @@ data class RoutedPacket(
|
||||
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
|
||||
/** 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
|
||||
|
||||
@ -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)
|
||||
@ -247,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
|
||||
@ -264,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
|
||||
|
||||
|
||||
@ -474,7 +474,10 @@ class NoiseSession(
|
||||
receiveCipher = cipherPair.getReceiver()
|
||||
|
||||
// Extract handshake hash for channel binding
|
||||
handshakeHash = activeHandshake.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
|
||||
activeHandshake.destroy()
|
||||
|
||||
@ -7,7 +7,36 @@ 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
|
||||
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
|
||||
)
|
||||
|
||||
/**
|
||||
@ -23,6 +52,7 @@ 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>()
|
||||
@ -130,6 +160,7 @@ class NoiseSessionManager(
|
||||
var activeSession: NoiseSession? = null
|
||||
var isReplacementCandidate = false
|
||||
var establishedRemoteKey: ByteArray? = null
|
||||
var establishedSessionToken: ByteArray? = null
|
||||
var response: ByteArray? = null
|
||||
|
||||
try {
|
||||
@ -204,6 +235,10 @@ class NoiseSessionManager(
|
||||
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)
|
||||
@ -211,6 +246,7 @@ class NoiseSessionManager(
|
||||
}
|
||||
|
||||
establishedRemoteKey = remoteStaticKey
|
||||
establishedSessionToken = sessionToken
|
||||
Log.d(TAG, "✅ Session ESTABLISHED with bound identity $peerID")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@ -232,7 +268,8 @@ class NoiseSessionManager(
|
||||
return NoiseHandshakeProcessingResult(
|
||||
response = response,
|
||||
establishedNow = establishedRemoteKey != null,
|
||||
authenticatedRemoteStaticKey = establishedRemoteKey?.clone()
|
||||
authenticatedRemoteStaticKey = establishedRemoteKey?.clone(),
|
||||
authenticatedSessionToken = establishedSessionToken?.clone()
|
||||
)
|
||||
}
|
||||
|
||||
@ -252,6 +289,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()) {
|
||||
@ -259,11 +297,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")
|
||||
@ -273,7 +329,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -295,15 +354,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -356,6 +442,7 @@ 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"
|
||||
)
|
||||
|
||||
@ -191,7 +191,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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -67,6 +67,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
|
||||
@ -123,7 +131,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(
|
||||
@ -184,6 +197,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
|
||||
@ -921,6 +936,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)
|
||||
@ -940,6 +959,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()
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
|
||||
@ -1473,6 +1473,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.
|
||||
*
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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) }
|
||||
}
|
||||
@ -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)))
|
||||
}
|
||||
}
|
||||
@ -2,8 +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
|
||||
@ -17,6 +24,7 @@ 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.mock
|
||||
import org.mockito.kotlin.never
|
||||
@ -35,6 +43,10 @@ class MessageHandlerTest {
|
||||
private val myPeerID = "1111222233334444"
|
||||
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 }
|
||||
@ -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,36 +74,215 @@ 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))
|
||||
Unit
|
||||
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"))
|
||||
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))
|
||||
Unit
|
||||
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 rejects announce older than clock skew tolerance`() = runBlocking {
|
||||
val packet = announcePacket(ageMs = announceClockSkewToleranceMs + 1_000)
|
||||
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, "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())
|
||||
Unit
|
||||
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
|
||||
@ -99,7 +294,9 @@ class MessageHandlerTest {
|
||||
|
||||
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()).updatePeerInfo(any(), any(), any(), any(), any())
|
||||
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
|
||||
any(), any(), any(), any(), any(), anyOrNull()
|
||||
)
|
||||
Unit
|
||||
}
|
||||
|
||||
@ -112,7 +309,9 @@ class MessageHandlerTest {
|
||||
|
||||
assertFalse(result)
|
||||
verify(delegate, never()).verifyEd25519Signature(any(), any(), any())
|
||||
verify(delegate, never()).updatePeerInfo(any(), any(), any(), any(), any())
|
||||
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
|
||||
any(), any(), any(), any(), any(), anyOrNull()
|
||||
)
|
||||
Unit
|
||||
}
|
||||
|
||||
@ -125,7 +324,9 @@ class MessageHandlerTest {
|
||||
|
||||
assertFalse(result)
|
||||
verify(delegate, never()).verifyEd25519Signature(any(), any(), any())
|
||||
verify(delegate, never()).updatePeerInfo(any(), any(), any(), any(), any())
|
||||
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
|
||||
any(), any(), any(), any(), any(), anyOrNull()
|
||||
)
|
||||
Unit
|
||||
}
|
||||
|
||||
@ -138,7 +339,9 @@ class MessageHandlerTest {
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
|
||||
|
||||
assertFalse(result)
|
||||
verify(delegate, never()).updatePeerInfo(any(), any(), any(), any(), any())
|
||||
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
|
||||
any(), any(), any(), any(), any(), anyOrNull()
|
||||
)
|
||||
Unit
|
||||
}
|
||||
|
||||
@ -151,19 +354,37 @@ class MessageHandlerTest {
|
||||
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
|
||||
|
||||
assertFalse(result)
|
||||
verify(delegate, never()).updatePeerInfo(any(), any(), any(), any(), any())
|
||||
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(),
|
||||
capabilities: PeerCapabilities? = null,
|
||||
noisePublicKey: ByteArray = noiseKey
|
||||
): BitchatPacket {
|
||||
val announcement = IdentityAnnouncement(
|
||||
nickname = nickname,
|
||||
noisePublicKey = noisePublicKey,
|
||||
signingPublicKey = signingKey
|
||||
signingPublicKey = signingKey,
|
||||
capabilities = capabilities
|
||||
)
|
||||
return BitchatPacket(
|
||||
version = 1u,
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
@ -34,6 +34,7 @@ class SecurityManagerTest {
|
||||
// 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()
|
||||
@ -44,6 +45,7 @@ class SecurityManagerTest {
|
||||
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
|
||||
@ -56,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
|
||||
@ -111,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)
|
||||
@ -128,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)
|
||||
@ -288,6 +345,21 @@ class SecurityManagerTest {
|
||||
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(
|
||||
@ -364,7 +436,9 @@ class SecurityManagerTest {
|
||||
assertTrue(accepted)
|
||||
assertTrue(fakeEncryptionService.removePeerCalls == 0)
|
||||
verify(mockDelegate).sendHandshakeResponse(otherPeerID, response)
|
||||
verify(mockDelegate, never()).onKeyExchangeCompleted(any(), any(), anyOrNull(), anyOrNull())
|
||||
verify(mockDelegate, never()).onKeyExchangeCompleted(
|
||||
any(), any(), any(), anyOrNull(), anyOrNull()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -378,19 +452,23 @@ class SecurityManagerTest {
|
||||
assertFalse(securityManager.handleNoiseHandshake(routed))
|
||||
assertTrue(fakeEncryptionService.removePeerCalls == 0)
|
||||
verify(mockDelegate, never()).sendHandshakeResponse(any(), any())
|
||||
verify(mockDelegate, never()).onKeyExchangeCompleted(any(), any(), anyOrNull(), anyOrNull())
|
||||
verify(mockDelegate, never()).onKeyExchangeCompleted(
|
||||
any(), any(), any(), anyOrNull(), anyOrNull()
|
||||
)
|
||||
|
||||
fakeEncryptionService.handshakeError = null
|
||||
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
|
||||
response = null,
|
||||
establishedNow = true,
|
||||
authenticatedRemoteStaticKey = otherNoiseKey
|
||||
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"
|
||||
)
|
||||
@ -401,7 +479,8 @@ class SecurityManagerTest {
|
||||
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
|
||||
response = null,
|
||||
establishedNow = true,
|
||||
authenticatedRemoteStaticKey = otherNoiseKey
|
||||
authenticatedRemoteStaticKey = otherNoiseKey,
|
||||
authenticatedSessionToken = sessionToken
|
||||
)
|
||||
val routed = handshakePacket(byteArrayOf(0x51, 0x52, 0x53))
|
||||
|
||||
@ -410,6 +489,7 @@ class SecurityManagerTest {
|
||||
verify(mockDelegate, times(1)).onKeyExchangeCompleted(
|
||||
otherPeerID,
|
||||
otherNoiseKey,
|
||||
sessionToken,
|
||||
"direct-link",
|
||||
"direct-link-token"
|
||||
)
|
||||
@ -422,7 +502,8 @@ class SecurityManagerTest {
|
||||
fakeEncryptionService.handshakeResult = NoiseHandshakeProcessingResult(
|
||||
response = null,
|
||||
establishedNow = true,
|
||||
authenticatedRemoteStaticKey = otherNoiseKey
|
||||
authenticatedRemoteStaticKey = otherNoiseKey,
|
||||
authenticatedSessionToken = sessionToken
|
||||
)
|
||||
val routed = handshakePacket(
|
||||
payload = byteArrayOf(0x61, 0x62, 0x63),
|
||||
@ -430,7 +511,13 @@ class SecurityManagerTest {
|
||||
)
|
||||
|
||||
assertTrue(securityManager.handleNoiseHandshake(routed))
|
||||
verify(mockDelegate).onKeyExchangeCompleted(otherPeerID, otherNoiseKey, null, null)
|
||||
verify(mockDelegate).onKeyExchangeCompleted(
|
||||
otherPeerID,
|
||||
otherNoiseKey,
|
||||
sessionToken,
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupKnownPeer(peerID: String, signingKey: ByteArray) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,10 @@ 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(
|
||||
@ -40,8 +44,12 @@ class NoiseSessionManagerIdentityBindingTest {
|
||||
assertArrayEquals(alice.publicKey, bobManager.getRemoteStaticKey(alice.peerID))
|
||||
|
||||
val plaintext = "bound transport".toByteArray()
|
||||
val ciphertext = aliceManager.encrypt(plaintext, bob.peerID)
|
||||
assertArrayEquals(plaintext, bobManager.decrypt(ciphertext, alice.peerID))
|
||||
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
|
||||
@ -145,6 +153,7 @@ class NoiseSessionManagerIdentityBindingTest {
|
||||
|
||||
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)
|
||||
@ -157,12 +166,98 @@ class NoiseSessionManagerIdentityBindingTest {
|
||||
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.encrypt(plaintext, alice.peerID)
|
||||
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 `peer ID derivation rejects malformed keys and non-wire claims`() {
|
||||
val peer = identity()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -38,6 +38,13 @@ 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
|
||||
|
||||
168
docs/PRIVATE_MEDIA_V1.md
Normal file
168
docs/PRIVATE_MEDIA_V1.md
Normal 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.
|
||||
@ -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.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user