Migrate private media without silent downgrades

This commit is contained in:
jack
2026-07-12 10:21:07 -04:00
parent b7f0b33d3a
commit 55f85d920c
36 changed files with 2430 additions and 303 deletions
@@ -254,6 +254,19 @@ 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? {
if (!noiseService.hasEstablishedSession(peerID)) return null
return noiseService.getPeerPublicKeyData(peerID)
?.takeIf { it.size == 32 }
?.copyOf()
}
/**
* Get current peer ID for a fingerprint (for peer ID rotation)
@@ -18,7 +18,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 +32,24 @@ 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"
// 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 +64,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 +314,46 @@ class SecureIdentityStateManager(private val context: Context) {
prefs.edit { putStringSet(KEY_CACHED_FINGERPRINT_NICKNAMES, current) }
}
}
// 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) {
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) {
return@synchronized false
}
prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet())
?.any { it.equals(fingerprint, ignoreCase = true) } == true
}
}
internal fun getPrivateMediaCapabilityPinsForTesting(): Set<String> =
synchronized(privateMediaPinsLock) {
if (privateMediaPinsEpochAtCreation != privateMediaPinsEpoch) {
return@synchronized emptySet()
}
prefs.getStringSet(KEY_PRIVATE_MEDIA_CAPABILITY_PINS, emptySet())?.toSet() ?: emptySet()
}
// MARK: - Peer ID Rotation Management (removed)
// Android now derives peer ID from the persisted Noise identity fingerprint.
@@ -370,7 +431,11 @@ class SecureIdentityStateManager(private val context: Context) {
*/
fun clearIdentityData() {
try {
prefs.edit().clear().apply()
synchronized(privateMediaPinsLock) {
privateMediaPinsEpoch += 1
privateMediaPinsEpochAtCreation = privateMediaPinsEpoch
prefs.edit().clear().apply()
}
Log.w(TAG, "All identity data cleared")
} catch (e: Exception) {
Log.e(TAG, "Failed to clear identity data: ${e.message}")
@@ -50,6 +50,23 @@ 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 privateMediaPreparer by lazy {
PrivateMediaTransferPreparer(
senderID = hexStringToByteArray(myPeerID),
ttl = MAX_TTL,
policyProvider = privateMediaSecurity::sendPolicy,
encrypt = { plaintext, peerID ->
runCatching { encryptionService.encrypt(plaintext, peerID) }.getOrNull()
},
finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict,
fragment = fragmentManager::createFragments
)
}
private val securityManager = SecurityManager(encryptionService, myPeerID)
private val storeForwardManager = StoreForwardManager()
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
@@ -218,6 +235,7 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic
// SecurityManager delegate for key exchange notifications
securityManager.delegate = object : SecurityManagerDelegate {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
// Send announcement and cached messages after key exchange
serviceScope.launch {
Log.d(TAG, "Key exchange completed with $peerID; sending follow-ups")
@@ -297,8 +315,19 @@ 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
)
}
override fun onVerifiedAnnouncementProcessed(peerID: String) {
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
}
// Packet operations
@@ -820,70 +849,63 @@ 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
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)
}
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
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 (!isBleTransportEnabled()) {
false
} else {
broadcastRoutedPacket(routed)
true
}
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)
}
)
}
} catch (e: Exception) {
Log.e(TAG, "❌ sendFilePrivate failed: ${e.message}", e)
Log.e(TAG, "❌ File: to=$recipientPeerID, name=${file.fileName}, size=${file.fileSize}")
}
}
@@ -1099,7 +1121,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")
@@ -1162,7 +1184,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")
@@ -1403,24 +1425,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()
@@ -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)
}
@@ -51,6 +51,23 @@ class MeshCore(
private val peerManager = PeerManager()
val fragmentManager = FragmentManager()
private val privateMediaSecurity = PrivateMediaSecurityController(
peerInfoProvider = peerManager::getPeerInfo,
authenticatedRemoteStaticProvider = encryptionService::getAuthenticatedRemoteStaticKey,
pinStore = SecurePrivateMediaCapabilityPinStore(context)
)
private val privateMediaPreparer by lazy {
PrivateMediaTransferPreparer(
senderID = MeshPacketUtils.hexStringToByteArray(myPeerID),
ttl = maxTtl,
policyProvider = privateMediaSecurity::sendPolicy,
encrypt = { plaintext, peerID ->
runCatching { encryptionService.encrypt(plaintext, peerID) }.getOrNull()
},
finalizeRoutedAndSigned = ::routeAndSignPrivateMediaStrict,
fragment = fragmentManager::createFragments
)
}
private val securityManager = SecurityManager(encryptionService, myPeerID)
private val storeForwardManager = StoreForwardManager()
private val messageHandler = MessageHandler(myPeerID, context.applicationContext)
@@ -158,6 +175,7 @@ class MeshCore(
securityManager.delegate = object : SecurityManagerDelegate {
override fun onKeyExchangeCompleted(peerID: String, peerPublicKeyData: ByteArray) {
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
scope.launch {
delay(100)
sendAnnouncementToPeer(peerID)
@@ -225,14 +243,26 @@ 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 onVerifiedAnnouncementProcessed(peerID: String) {
privateMediaSecurity.refreshAuthenticatedCapability(peerID)
}
override fun sendPacket(packet: BitchatPacket) {
@@ -455,31 +485,61 @@ 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 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)
}
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
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
)
val signed = signPacketBeforeBroadcast(packet)
val transferId = MeshPacketUtils.sha256Hex(tlv)
dispatchGlobal(RoutedPacket(signed, transferId = transferId))
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(transferId, built.wireMode) {
if (!isActive) {
false
} else {
dispatchGlobal(routed)
true
}
}
)
}
} catch (e: Exception) {
Log.e("MeshCore", "sendFilePrivate failed: ${e.message}", e)
}
}
@@ -600,7 +660,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,
@@ -621,7 +681,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,
@@ -806,28 +866,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)
@@ -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()
@@ -275,14 +275,20 @@ 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
// Promotion of security-sensitive capabilities happens only after the
// signature check above and must additionally bind to the authenticated
// Noise remote-static key in the transport service.
delegate?.onVerifiedAnnouncementProcessed(peerID)
// Update peer ID binding with noise public key for identity management
delegate?.updatePeerIDBinding(
newPeerID = peerID,
@@ -438,14 +444,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) {
@@ -604,7 +622,15 @@ 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
fun onVerifiedAnnouncementProcessed(peerID: String) {}
// Packet operations
fun sendPacket(packet: BitchatPacket)
@@ -1,6 +1,7 @@
package com.bitchat.android.mesh
import android.util.Log
import com.bitchat.android.model.PeerCapabilities
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
@@ -17,7 +18,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 +44,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 +65,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 +124,53 @@ 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()
)
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 +188,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 +201,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 +216,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,105 @@
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)
}
}
internal sealed interface PrivateMediaPolicyDecision {
data object Encrypted : PrivateMediaPolicyDecision
data object RequiresLegacyConsent : PrivateMediaPolicyDecision
data object NeedsHandshake : 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 peerInfoProvider: (String) -> PeerInfo?,
private val authenticatedRemoteStaticProvider: (String) -> ByteArray?,
private val pinStore: PrivateMediaCapabilityPinStore
) {
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 }
?: 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
}
}
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) }
}
@@ -0,0 +1,153 @@
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 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 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 class Rejected(val reason: String) : PrivateMediaBuildOutcome
}
/** 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 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 {
val policy = policyProvider(recipientPeerID)
val mode = when (policy) {
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
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 ciphertext = try {
encrypt(plaintext, recipientPeerID)
} catch (_: Exception) {
null
} ?: 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 maxPrivateFragments =
com.bitchat.android.util.AppConstants.Fragmentation.MAX_FRAGMENTS_PER_ID
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)
)
}
}
@@ -71,15 +71,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
@@ -154,21 +156,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)
}
/**
@@ -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 }
@@ -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})"
}
}
@@ -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()
}
@@ -10,5 +10,7 @@ data class RoutedPacket(
val packet: BitchatPacket,
val peerID: String? = null, // Who sent it (parsed from packet.senderID)
val relayAddress: String? = null, // Address it came from (for avoiding loopback)
val transferId: String? = null // Optional stable transfer ID for progress tracking
val transferId: String? = null, // Optional stable transfer ID for progress tracking
/** Exact fragments admitted during private-media prepare; never rebuild them at commit. */
val preparedPackets: List<BitchatPacket>? = null
)
@@ -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
@@ -184,6 +192,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
@@ -940,6 +950,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,20 @@ 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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
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)
@@ -29,6 +41,21 @@ class MediaSendingManager(
// 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
/**
* Send a voice note (audio file)
@@ -186,8 +213,133 @@ 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,
transferId = transferId,
allowLegacyFallback = false
)) {
is PrivateMediaPreparation.Ready -> commitPreparedPrivateFile(
preparation = preparation,
toPeerID = toPeerID,
filePath = filePath,
messageType = messageType,
transferId = transferId
)
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}")
}
}
/**
* 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) {
val pending = consumePendingConsent(requestId) ?: return
when (val preparation = meshService.prepareFilePrivate(
recipientPeerID = pending.peerID,
file = pending.filePacket,
transferId = pending.transferId,
allowLegacyFallback = true
)) {
is PrivateMediaPreparation.Ready -> commitPreparedPrivateFile(
preparation,
pending.peerID,
pending.filePath,
pending.messageType,
pending.transferId
)
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}")
}
}
fun cancelLegacyPrivateMedia(requestId: String) {
consumePendingConsent(requestId)
}
fun clearPendingPrivateMediaConsent() {
synchronized(pendingConsentLock) {
pendingPrivateMedia = null
_legacyPrivateMediaConsent.value = null
}
}
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,23 +349,29 @@ 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")
return
}
Log.d(TAG, "✅ Private media committed using ${preparation.transfer.wireMode}")
}
/**
@@ -1399,6 +1399,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.
*