Authenticate private media capability per Noise session

This commit is contained in:
jack
2026-07-12 12:00:35 -04:00
parent 64ed730bf2
commit eb55cdb6a7
31 changed files with 2121 additions and 408 deletions
@@ -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
@@ -263,11 +278,17 @@ open class EncryptionService(private val context: Context) {
* authentication, not a self-certified identity payload.
*/
fun getAuthenticatedRemoteStaticKey(peerID: String): ByteArray? {
if (!noiseService.hasEstablishedSession(peerID)) return null
return noiseService.getPeerPublicKeyData(peerID)
?.takeIf { it.size == 32 }
?.copyOf()
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
@@ -33,6 +36,7 @@ class SecureIdentityStateManager {
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
@@ -317,25 +321,6 @@ class SecureIdentityStateManager {
// MARK: - Authenticated private-media capability pins
/**
* Persist an HSTS-style private-media capability pin. The caller must
* derive [fingerprint] directly from a Noise-authenticated remote static
* key after matching it to a signature-verified announcement.
*/
fun markPrivateMediaCapable(fingerprint: String) {
if (!isValidFingerprint(fingerprint)) return
synchronized(privateMediaPinsLock) {
// A controller that survived panic must never be able to restore a
// pin from an in-flight pre-wipe handshake callback.
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) return
val current = prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet())
?.mapTo(mutableSetOf()) { it.lowercase() }
?: mutableSetOf()
current.add(fingerprint.lowercase())
prefs.edit { putStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, current) }
}
}
fun isPrivateMediaCapable(fingerprint: String): Boolean {
if (!isValidFingerprint(fingerprint)) return false
return synchronized(privateMediaPinsLock) {
@@ -347,13 +332,64 @@ class SecureIdentityStateManager {
}
}
internal fun getPrivateMediaCapabilityPinsForTesting(): Set<String> =
synchronized(privateMediaPinsLock) {
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) {
return@synchronized emptySet()
/** 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()
}
prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet())?.toSet() ?: emptySet()
}
}
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.
@@ -429,12 +465,15 @@ class SecureIdentityStateManager {
/**
* Clear all identity data (for panic mode)
*/
@SuppressLint("UseKtx")
fun clearIdentityData() {
try {
synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch += 1
privateMediaPinsEpochAtCreation = privateMediaPinsEpoch
prefs.edit().clear().apply()
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) {
@@ -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) }
}
@@ -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,18 +52,53 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
val myPeerID: String = encryptionService.getIdentityFingerprint().take(16)
private val peerManager = PeerManager()
private val fragmentManager = FragmentManager()
private val privateMediaSecurity = PrivateMediaSecurityController(
peerInfoProvider = peerManager::getPeerInfo,
authenticatedRemoteStaticProvider = encryptionService::getAuthenticatedRemoteStaticKey,
pinStore = SecurePrivateMediaCapabilityPinStore(context)
)
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 ->
runCatching { encryptionService.encrypt(plaintext, peerID) }.getOrNull()
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
@@ -87,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
@@ -218,6 +254,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) { }
@@ -237,9 +274,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")
@@ -271,6 +314,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
@@ -330,10 +376,6 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
)
}
override fun onVerifiedAnnouncementProcessed(peerID: String) {
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
}
// Packet operations
override fun sendPacket(packet: BitchatPacket) {
// Sign the packet before broadcasting
@@ -358,13 +400,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 {
@@ -408,6 +456,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? {
@@ -786,6 +842,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}")
@@ -834,6 +916,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
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}")
}
@@ -855,6 +938,8 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning)
PrivateMediaBuildOutcome.NeedsHandshake ->
PrivateMediaPreparation.NeedsHandshake
PrivateMediaBuildOutcome.AwaitingPeerState ->
PrivateMediaPreparation.AwaitingPeerState
is PrivateMediaBuildOutcome.Rejected ->
PrivateMediaPreparation.Rejected(outcome.reason)
is PrivateMediaBuildOutcome.Ready -> {
@@ -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,18 +53,52 @@ class MeshCore(
private val peerManager = PeerManager()
val fragmentManager = FragmentManager()
private val privateMediaSecurity = PrivateMediaSecurityController(
peerInfoProvider = peerManager::getPeerInfo,
authenticatedRemoteStaticProvider = encryptionService::getAuthenticatedRemoteStaticKey,
pinStore = SecurePrivateMediaCapabilityPinStore(context)
)
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 ->
runCatching { encryptionService.encrypt(plaintext, peerID) }.getOrNull()
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
@@ -179,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) { }
@@ -189,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,
@@ -222,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 {
@@ -285,10 +331,6 @@ class MeshCore(
)
}
override fun onVerifiedAnnouncementProcessed(peerID: String) {
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
}
override fun sendPacket(packet: BitchatPacket) {
val signedPacket = signPacketBeforeBroadcast(packet)
dispatchGlobal(RoutedPacket(signedPacket))
@@ -310,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)
}
@@ -318,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)
}
@@ -334,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)
}
@@ -471,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
@@ -510,6 +592,7 @@ class MeshCore(
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}")
}
@@ -531,6 +614,8 @@ class MeshCore(
PrivateMediaPreparation.RequiresLegacyConsent(outcome.warning)
PrivateMediaBuildOutcome.NeedsHandshake ->
PrivateMediaPreparation.NeedsHandshake
PrivateMediaBuildOutcome.AwaitingPeerState ->
PrivateMediaPreparation.AwaitingPeerState
is PrivateMediaBuildOutcome.Rejected ->
PrivateMediaPreparation.Rejected(outcome.reason)
is PrivateMediaBuildOutcome.Ready -> {
@@ -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
@@ -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
@@ -629,7 +652,6 @@ interface MessageHandlerDelegate {
isVerified: Boolean,
capabilities: com.bitchat.android.model.PeerCapabilities? = null
): Boolean
fun onVerifiedAnnouncementProcessed(peerID: String) {}
// Packet operations
fun sendPacket(packet: BitchatPacket)
@@ -639,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,7 @@
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
@@ -162,6 +163,37 @@ class PeerManager {
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,
@@ -1,31 +1,15 @@
package com.bitchat.android.mesh
import android.content.Context
import com.bitchat.android.identity.SecureIdentityStateManager
import com.bitchat.android.model.PeerCapabilities
import java.security.MessageDigest
internal interface PrivateMediaCapabilityPinStore {
fun contains(fingerprint: String): Boolean
fun insert(fingerprint: String)
}
internal class SecurePrivateMediaCapabilityPinStore(context: Context) :
PrivateMediaCapabilityPinStore {
private val identityState = SecureIdentityStateManager(context.applicationContext)
override fun contains(fingerprint: String): Boolean =
identityState.isPrivateMediaCapable(fingerprint)
override fun insert(fingerprint: String) {
identityState.markPrivateMediaCapable(fingerprint)
}
}
import com.bitchat.android.noise.AuthenticatedNoiseSession
internal sealed interface PrivateMediaPolicyDecision {
data object Encrypted : 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
}
@@ -35,71 +19,46 @@ internal sealed interface PrivateMediaPolicyDecision {
* fingerprint. Announcements alone can never create a pin.
*/
internal class PrivateMediaSecurityController(
private val peerInfoProvider: (String) -> PeerInfo?,
private val authenticatedRemoteStaticProvider: (String) -> ByteArray?,
private val pinStore: PrivateMediaCapabilityPinStore
private val authenticatedSessionProvider: (String) -> AuthenticatedNoiseSession?,
private val peerStateStatusProvider: (
String,
AuthenticatedNoiseSession
) -> AuthenticatedPeerStateStatus,
private val isPrivateMediaPinned: (String) -> Boolean
) {
fun refreshAuthenticatedCapability(peerID: String): Boolean {
val remoteStatic = authenticatedRemoteStaticProvider(peerID)
?.takeIf { it.size == 32 }
?: return false
val peer = peerInfoProvider(peerID) ?: return false
if (!peer.hasVerifiedAnnouncement) return false
val announcedStatic = peer.verifiedAnnouncementNoisePublicKey ?: return false
if (!announcedStatic.contentEquals(remoteStatic)) return false
if (peer.capabilities?.contains(PeerCapabilities.PRIVATE_MEDIA) != true) return false
pinStore.insert(fingerprint(remoteStatic))
return true
}
fun sendPolicy(peerID: String): PrivateMediaPolicyDecision {
val remoteStatic = authenticatedRemoteStaticProvider(peerID)
?.takeIf { it.size == 32 }
val authenticatedSession = authenticatedSessionProvider(peerID)
?.takeIf { it.remoteStaticKey.size == 32 && it.sessionToken.size == 32 }
?: return PrivateMediaPolicyDecision.NeedsHandshake
val authenticatedFingerprint = fingerprint(remoteStatic)
// Once a fingerprint has authenticated encrypted media support, never
// downgrade it because a later announce omits or clears the bit.
if (pinStore.contains(authenticatedFingerprint)) {
return PrivateMediaPolicyDecision.Encrypted
}
val peer = peerInfoProvider(peerID)
?: return PrivateMediaPolicyDecision.Blocked(
"No signature-verified identity announcement is available"
)
if (!peer.hasVerifiedAnnouncement) {
return PrivateMediaPolicyDecision.Blocked(
"The peer identity announcement has not been verified"
)
}
val announcedStatic = peer.verifiedAnnouncementNoisePublicKey
?: return PrivateMediaPolicyDecision.Blocked(
"The verified announcement did not contain a Noise identity"
)
if (!announcedStatic.contentEquals(remoteStatic)) {
return PrivateMediaPolicyDecision.Blocked(
"The authenticated Noise identity does not match the verified announcement"
)
}
return if (peer.capabilities?.contains(PeerCapabilities.PRIVATE_MEDIA) == true) {
pinStore.insert(authenticatedFingerprint)
PrivateMediaPolicyDecision.Encrypted
} else {
// Both a missing TLV and an explicitly empty bitfield are legacy.
PrivateMediaPolicyDecision.RequiresLegacyConsent
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
}
}
}
}
internal fun authenticatedFingerprint(peerID: String): String? =
authenticatedRemoteStaticProvider(peerID)
?.takeIf { it.size == 32 }
?.let(::fingerprint)
private fun fingerprint(publicKey: ByteArray): String =
MessageDigest.getInstance("SHA-256")
.digest(publicKey)
.joinToString("") { "%02x".format(it) }
}
@@ -3,6 +3,7 @@ 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
@@ -30,6 +31,7 @@ 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
}
@@ -43,15 +45,26 @@ 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) -> ByteArray?,
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() }
@@ -61,10 +74,24 @@ internal class PrivateMediaTransferPreparer(
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 policy = policyProvider(recipientPeerID)
val mode = when (policy) {
PrivateMediaPolicyDecision.Encrypted -> PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
is PrivateMediaPolicyDecision.Encrypted -> PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
PrivateMediaPolicyDecision.RequiresLegacyConsent -> {
if (!allowLegacyFallback) {
return PrivateMediaBuildOutcome.RequiresLegacyConsent(
@@ -77,6 +104,8 @@ internal class PrivateMediaTransferPreparer(
}
PrivateMediaPolicyDecision.NeedsHandshake ->
return PrivateMediaBuildOutcome.NeedsHandshake
PrivateMediaPolicyDecision.AwaitingPeerState ->
return PrivateMediaBuildOutcome.AwaitingPeerState
is PrivateMediaPolicyDecision.Blocked ->
return PrivateMediaBuildOutcome.Rejected(policy.reason)
}
@@ -87,13 +116,34 @@ internal class PrivateMediaTransferPreparer(
val packet = when (mode) {
PrivateMediaWireMode.ENCRYPTED_NOISE_0X20 -> {
val plaintext = NoisePayload(NoisePayloadType.FILE_TRANSFER, filePayload).encode()
val ciphertext = try {
encrypt(plaintext, recipientPeerID)
val encryption = try {
encrypt(
plaintext,
recipientPeerID,
(policy as PrivateMediaPolicyDecision.Encrypted).authenticatedSession
)
} catch (_: Exception) {
null
} ?: return PrivateMediaBuildOutcome.Rejected(
"The authenticated Noise session could not encrypt this file"
)
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,
@@ -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.*
@@ -136,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 }
)
@@ -187,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
@@ -250,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 &&
@@ -429,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
}
@@ -358,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()
}
@@ -23,7 +23,9 @@ 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 {
@@ -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.
}
}
@@ -131,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(
@@ -931,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)
@@ -12,6 +12,9 @@ import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
data class LegacyPrivateMediaConsentRequest(
val requestId: String,
@@ -28,6 +31,7 @@ class MediaSendingManager(
private val state: ChatState,
private val messageManager: MessageManager,
private val channelManager: ChannelManager,
private val scope: CoroutineScope,
private val getMeshService: () -> MeshService
) {
// Helper to get current mesh service (may change after panic clear)
@@ -36,6 +40,7 @@ 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
@@ -57,6 +62,21 @@ class MediaSendingManager(
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)
*/
@@ -213,61 +233,23 @@ class MediaSendingManager(
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)}")
when (val preparation = meshService.prepareFilePrivate(
recipientPeerID = toPeerID,
file = filePacket,
val pending = PendingAutomaticPrivateMedia(
requestId = UUID.randomUUID().toString(),
peerID = toPeerID,
filePacket = filePacket,
filePath = filePath,
messageType = messageType,
transferId = transferId,
allowLegacyFallback = false
)) {
is PrivateMediaPreparation.Ready -> commitPreparedPrivateFile(
preparation = preparation,
toPeerID = toPeerID,
filePath = filePath,
messageType = messageType,
transferId = transferId
)
if (!reserveAutomaticPending(pending)) {
addPrivateMediaSystemMessage(
toPeerID,
"Private media was not sent because another secure media send is still pending."
)
is PrivateMediaPreparation.RequiresLegacyConsent -> {
val nickname = try {
meshService.getPeerNicknames()[toPeerID]
} catch (_: Exception) {
null
} ?: toPeerID.take(8)
val request = LegacyPrivateMediaConsentRequest(
requestId = UUID.randomUUID().toString(),
recipientNickname = nickname,
fileName = 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,
toPeerID,
filePacket,
filePath,
messageType,
transferId
)
_legacyPrivateMediaConsent.value = request
}
}
PrivateMediaPreparation.NeedsHandshake -> {
Log.i(TAG, "Private media needs a Noise handshake; initiating now, with no local echo")
try {
meshService.initiateNoiseHandshake(toPeerID)
} catch (e: Exception) {
Log.w(TAG, "Could not initiate private-media Noise handshake: ${e.message}")
}
}
is PrivateMediaPreparation.Rejected ->
Log.w(TAG, "Private media not sent: ${preparation.reason}")
return
}
evaluateAutomaticPending(pending)
}
/**
@@ -277,32 +259,23 @@ class MediaSendingManager(
*/
fun approveLegacyPrivateMedia(requestId: String) {
val pending = consumePendingConsent(requestId) ?: return
when (val preparation = meshService.prepareFilePrivate(
recipientPeerID = pending.peerID,
file = pending.filePacket,
val automatic = PendingAutomaticPrivateMedia(
requestId = UUID.randomUUID().toString(),
peerID = pending.peerID,
filePacket = pending.filePacket,
filePath = pending.filePath,
messageType = pending.messageType,
transferId = pending.transferId,
allowLegacyFallback = true
)) {
is PrivateMediaPreparation.Ready -> commitPreparedPrivateFile(
preparation,
)
if (!reserveAutomaticPending(automatic)) {
addPrivateMediaSystemMessage(
pending.peerID,
pending.filePath,
pending.messageType,
pending.transferId
"Private media was not sent because another secure media send is still pending."
)
is PrivateMediaPreparation.RequiresLegacyConsent ->
Log.w(TAG, "Legacy consent was consumed but policy still requested consent; send aborted")
PrivateMediaPreparation.NeedsHandshake -> {
Log.i(TAG, "Noise session disappeared before consent approval; initiating a new handshake")
try {
meshService.initiateNoiseHandshake(pending.peerID)
} catch (e: Exception) {
Log.w(TAG, "Could not re-initiate private-media Noise handshake: ${e.message}")
}
}
is PrivateMediaPreparation.Rejected ->
Log.w(TAG, "Private media policy changed before approval: ${preparation.reason}")
return
}
evaluateAutomaticPending(automatic)
}
fun cancelLegacyPrivateMedia(requestId: String) {
@@ -312,10 +285,221 @@ class MediaSendingManager(
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 fun retryPendingPrivateMediaOnScope(peerID: String) {
val pending = synchronized(pendingConsentLock) {
pendingAutomaticPrivateMedia
?.takeIf { it.peerID == peerID }
} ?: return
evaluateAutomaticPending(pending)
}
private 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 {
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
@@ -369,6 +553,10 @@ class MediaSendingManager(
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}")