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.
*
+6 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name">bitchat</string>
<string name="permission_bluetooth_rationale">Bluetooth permission is required for peer-to-peer messaging without internet.</string>
<string name="permission_location_rationale">Location permission is required to discover nearby devices via Bluetooth.</string>
@@ -88,6 +88,11 @@
<string name="cd_encrypted">End-to-end encrypted</string>
<string name="cd_handshake_failed">Handshake failed</string>
<!-- One-shot private-media downgrade consent -->
<string name="private_media_legacy_title" tools:ignore="MissingTranslation">Send without end-to-end encryption?</string>
<string name="private_media_legacy_body" tools:ignore="MissingTranslation">%1$s cannot be sent encrypted to %2$s because their client is older. %3$s</string>
<string name="private_media_legacy_send_once" tools:ignore="MissingTranslation">Send this file once</string>
<!-- File viewer dialog -->
<string name="file_viewer_title">📎 File Received</string>
<string name="file_viewer_name">📄 %1$s</string>
@@ -5,6 +5,8 @@ import com.bitchat.android.protocol.MessageType
import com.bitchat.android.model.FragmentPayload
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -180,6 +182,68 @@ class FragmentManagerTest {
assertTrue("Payload content should match", originalPacket.payload.contentEquals(reassembledPacket.payload))
}
@Test
fun `inbound fragment set above 256 is rejected`() {
val payload = FragmentPayload(
fragmentID = ByteArray(8) { 1 },
index = 0,
total = 257,
originalType = MessageType.NOISE_ENCRYPTED.value,
data = byteArrayOf(1)
).encode()
val packet = BitchatPacket(
version = 1u,
type = MessageType.FRAGMENT.value,
senderID = hexStringToByteArray(senderID),
recipientID = hexStringToByteArray(recipientID),
timestamp = 1u,
payload = payload,
ttl = 7u
)
assertNull(fragmentManager.handleFragment(packet))
}
@Test
fun `fragment payload refuses UInt16 truncation`() {
assertThrows(IllegalArgumentException::class.java) {
FragmentPayload(
fragmentID = ByteArray(8) { 2 },
index = 0,
total = 65_536,
originalType = MessageType.NOISE_ENCRYPTED.value,
data = byteArrayOf(1)
).encode()
}
}
@Test
fun `generic public packet retains a 257 fragment outbound plan`() {
val randomPayload = ByteArray(180 * 1024).also { Random(0xB17C4A7).nextBytes(it) }
fun plan(contentSize: Int): List<BitchatPacket> = fragmentManager.createFragments(
BitchatPacket(
version = 2u,
type = MessageType.FILE_TRANSFER.value,
senderID = hexStringToByteArray(senderID),
recipientID = com.bitchat.android.protocol.SpecialRecipients.BROADCAST,
timestamp = 1u,
payload = randomPayload.copyOf(contentSize),
signature = ByteArray(64) { 7 },
ttl = 7u
)
)
var low = 1
var high = randomPayload.size
while (low < high) {
val mid = low + (high - low) / 2
if (plan(mid).size >= 257) high = mid else low = mid + 1
}
assertEquals(257, plan(low).size)
}
private fun hexStringToByteArray(hexString: String): ByteArray {
val result = ByteArray(8)
for (i in 0 until 8) {
@@ -1,6 +1,7 @@
package com.bitchat
import com.bitchat.android.mesh.PeerManager
import com.bitchat.android.model.PeerCapabilities
import junit.framework.TestCase.assertEquals
import org.junit.Test
@@ -31,6 +32,71 @@ class PeerManagerTest {
val emptyDeviceAddresses = emptyMap<String, String>()
@Test
fun peer_capabilities_are_retained_with_verified_identity() {
val capabilities = PeerCapabilities(
PeerCapabilities.PRIVATE_MEDIA.rawValue or (1L shl 15)
)
peerManager.updatePeerInfoFromVerifiedAnnouncement(
peerID = "peer-capabilities",
nickname = "alice",
noisePublicKey = ByteArray(32) { 1 },
signingPublicKey = ByteArray(32) { 2 },
isVerified = true,
capabilities = capabilities
)
assertEquals(capabilities, peerManager.getPeerInfo("peer-capabilities")?.capabilities)
}
@Test
fun normal_peer_updates_preserve_signed_absent_and_empty_capabilities() {
val peerID = "peer-capability-state"
val noiseKey = ByteArray(32) { 3 }
val signingKey = ByteArray(32) { 4 }
peerManager.updatePeerInfoFromVerifiedAnnouncement(
peerID,
"alice",
noiseKey,
signingKey,
true,
null
)
var info = peerManager.getPeerInfo(peerID)!!
assertEquals(true, info.hasVerifiedAnnouncement)
assertEquals(null, info.capabilities)
assertEquals(true, info.verifiedAnnouncementNoisePublicKey!!.contentEquals(noiseKey))
peerManager.updatePeerInfo(peerID, "alice2", noiseKey, signingKey, true)
info = peerManager.getPeerInfo(peerID)!!
assertEquals(true, info.hasVerifiedAnnouncement)
assertEquals(null, info.capabilities)
peerManager.updatePeerInfoFromVerifiedAnnouncement(
peerID,
"alice2",
noiseKey,
signingKey,
true,
PeerCapabilities.NONE
)
peerManager.updatePeerInfo(peerID, "alice3", noiseKey, signingKey, true)
info = peerManager.getPeerInfo(peerID)!!
assertEquals(true, info.hasVerifiedAnnouncement)
assertEquals(PeerCapabilities.NONE, info.capabilities)
val changedNoiseKey = ByteArray(32) { 7 }
peerManager.updatePeerInfo(peerID, "alice4", changedNoiseKey, signingKey, true)
info = peerManager.getPeerInfo(peerID)!!
assertEquals(PeerCapabilities.NONE, info.capabilities)
assertEquals(
true,
info.verifiedAnnouncementNoisePublicKey!!.contentEquals(noiseKey)
)
}
val testRSSI = mapOf(
"peer1" to 0,
"peer2" to 10,
@@ -257,4 +323,4 @@ class PeerManagerTest {
assertEquals(expectedLine2, actualLine2)
}
}
}
@@ -0,0 +1,50 @@
package com.bitchat.android.identity
import android.content.Context
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
class PrivateMediaCapabilityPinPersistenceTest {
private val fingerprint = "ab".repeat(32)
private lateinit var manager: SecureIdentityStateManager
private lateinit var prefs: android.content.SharedPreferences
@Before
fun setup() {
prefs = RuntimeEnvironment.getApplication().getSharedPreferences(
"private-media-pin-${UUID.randomUUID()}",
Context.MODE_PRIVATE
)
manager = SecureIdentityStateManager(prefs, testOnly = true)
manager.clearIdentityData()
}
@After
fun tearDown() {
manager.clearIdentityData()
}
@Test
fun `capability pin persists and panic identity wipe removes it`() {
manager.markPrivateMediaCapable(fingerprint)
val reloaded = SecureIdentityStateManager(prefs, testOnly = true)
assertTrue(reloaded.isPrivateMediaCapable(fingerprint))
reloaded.clearIdentityData()
// Simulate an old BLE/Wi-Fi controller finishing a pre-panic callback
// after another manager performed the wipe.
manager.markPrivateMediaCapable(fingerprint)
val afterPanic = SecureIdentityStateManager(prefs, testOnly = true)
assertFalse(afterPanic.isPrivateMediaCapable(fingerprint))
assertTrue(afterPanic.getPrivateMediaCapabilityPinsForTesting().isEmpty())
}
}
@@ -2,6 +2,8 @@ package com.bitchat.android.mesh
import android.os.Build
import com.bitchat.android.model.IdentityAnnouncement
import com.bitchat.android.model.BitchatFilePacket
import com.bitchat.android.model.PeerCapabilities
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
@@ -16,6 +18,7 @@ import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.anyOrNull
import org.mockito.kotlin.eq
import org.mockito.kotlin.isNull
import org.mockito.kotlin.mock
@@ -49,7 +52,11 @@ class MessageHandlerTest {
whenever(delegate.getPeerInfo(peerID)).thenReturn(null)
whenever(delegate.verifyEd25519Signature(any(), any(), any())).thenReturn(true)
whenever(delegate.updatePeerInfo(any(), any(), any(), any(), any())).thenReturn(true)
whenever(
delegate.updatePeerInfoFromVerifiedAnnouncement(
any(), any(), any(), any(), any(), anyOrNull()
)
).thenReturn(true)
}
@After
@@ -58,46 +65,161 @@ class MessageHandlerTest {
}
@Test
fun `handleAnnounce accepts announce within clock skew tolerance for identity binding`() = runBlocking {
val packet = announcePacket(ageMs = AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000)
fun `handleAnnounce accepts announce within clock skew tolerance for identity binding`() {
runBlocking {
val packet = announcePacket(ageMs = AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertTrue("Announce within clock skew tolerance should still store peer identity", result)
verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true))
verify(delegate).updatePeerIDBinding(eq(peerID), eq(nickname), any(), isNull())
assertTrue("Announce within clock skew tolerance should still store peer identity", result)
verify(delegate).updatePeerInfoFromVerifiedAnnouncement(
eq(peerID), eq(nickname), any(), any(), eq(true), isNull()
)
verify(delegate).onVerifiedAnnouncementProcessed(peerID)
verify(delegate).updatePeerIDBinding(eq(peerID), eq(nickname), any(), isNull())
}
}
@Test
fun `handleAnnounce accepts future announce within clock skew tolerance`() = runBlocking {
val packet = announcePacket(ageMs = -(AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000))
fun `handleAnnounce accepts future announce within clock skew tolerance`() {
runBlocking {
val packet = announcePacket(ageMs = -(AppConstants.Mesh.STALE_PEER_TIMEOUT_MS + 1_000))
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertTrue("Future announce within clock skew tolerance should still store peer identity", result)
verify(delegate).updatePeerInfo(eq(peerID), eq(nickname), any(), any(), eq(true))
Unit
assertTrue("Future announce within clock skew tolerance should still store peer identity", result)
verify(delegate).updatePeerInfoFromVerifiedAnnouncement(
eq(peerID), eq(nickname), any(), any(), eq(true), isNull()
)
}
}
@Test
fun `handleAnnounce rejects announce older than clock skew tolerance`() = runBlocking {
val packet = announcePacket(ageMs = announceClockSkewToleranceMs + 1_000)
fun `handleAnnounce stores advertised capabilities including unknown bits`() {
runBlocking {
val capabilities = PeerCapabilities(
PeerCapabilities.PRIVATE_MEDIA.rawValue or (1L shl 15)
)
val packet = announcePacket(ageMs = 0, capabilities = capabilities)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "relay-link"))
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertFalse("Announce older than clock skew tolerance should not store peer identity", result)
verify(delegate, never()).updatePeerInfo(any(), any(), any(), any(), any())
verify(delegate, never()).updatePeerIDBinding(any(), any(), any(), any())
assertTrue(result)
verify(delegate).updatePeerInfoFromVerifiedAnnouncement(
eq(peerID),
eq(nickname),
any(),
any(),
eq(true),
eq(capabilities)
)
}
}
@Test
fun `handleAnnounce rejects announce older than clock skew tolerance`() {
runBlocking {
val packet = announcePacket(ageMs = announceClockSkewToleranceMs + 1_000)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "relay-link"))
assertFalse("Announce older than clock skew tolerance should not store peer identity", result)
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
any(), any(), any(), any(), any(), anyOrNull()
)
verify(delegate, never()).onVerifiedAnnouncementProcessed(any())
verify(delegate, never()).updatePeerIDBinding(any(), any(), any(), any())
}
}
@Test
fun `handleAnnounce never promotes capability after invalid signature`() {
runBlocking {
whenever(delegate.verifyEd25519Signature(any(), any(), any())).thenReturn(false)
val packet = announcePacket(ageMs = 0, capabilities = PeerCapabilities.PRIVATE_MEDIA)
val result = handler.handleAnnounce(RoutedPacket(packet, peerID, "direct-link"))
assertFalse(result)
verify(delegate, never()).updatePeerInfoFromVerifiedAnnouncement(
any(), any(), any(), any(), any(), anyOrNull()
)
verify(delegate, never()).onVerifiedAnnouncementProcessed(any())
}
}
@Test
fun `directed raw private media requires a valid signature`() {
runBlocking {
whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST)
whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname)
whenever(delegate.verifySignature(any(), eq(peerID))).thenReturn(false)
val file = BitchatFilePacket(
fileName = "legacy.jpg",
fileSize = 3,
mimeType = "image/jpeg",
content = byteArrayOf(1, 2, 3)
)
val unsigned = BitchatPacket(
version = 2u,
type = MessageType.FILE_TRANSFER.value,
senderID = peerID.hexToBytes(),
recipientID = myPeerID.hexToBytes(),
timestamp = System.currentTimeMillis().toULong(),
payload = file.encode()!!,
signature = null,
ttl = 7u
)
handler.handleMessage(RoutedPacket(unsigned, peerID, "direct-link"))
handler.handleMessage(
RoutedPacket(unsigned.copy(signature = ByteArray(64) { 1 }), peerID, "direct-link")
)
verify(delegate, never()).onMessageReceived(any())
}
}
@Test
fun `valid signed directed raw private media remains interoperable`() {
runBlocking {
whenever(delegate.getBroadcastRecipient()).thenReturn(SpecialRecipients.BROADCAST)
whenever(delegate.getPeerNickname(peerID)).thenReturn(nickname)
whenever(delegate.verifySignature(any(), eq(peerID))).thenReturn(true)
val file = BitchatFilePacket(
fileName = "legacy-valid.jpg",
fileSize = 3,
mimeType = "image/jpeg",
content = byteArrayOf(1, 2, 3)
)
val packet = BitchatPacket(
version = 2u,
type = MessageType.FILE_TRANSFER.value,
senderID = peerID.hexToBytes(),
recipientID = myPeerID.hexToBytes(),
timestamp = System.currentTimeMillis().toULong(),
payload = file.encode()!!,
signature = signature,
ttl = 7u
)
handler.handleMessage(RoutedPacket(packet, peerID, "direct-link"))
verify(delegate).verifySignature(packet, peerID)
verify(delegate).onMessageReceived(any())
}
}
private fun announcePacket(
ageMs: Long,
ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte()
ttl: UByte = (AppConstants.MESSAGE_TTL_HOPS.toInt() - 1).toUByte(),
capabilities: PeerCapabilities? = null
): BitchatPacket {
val announcement = IdentityAnnouncement(
nickname = nickname,
noisePublicKey = noiseKey,
signingPublicKey = signingKey
signingPublicKey = signingKey,
capabilities = capabilities
)
return BitchatPacket(
version = 1u,
@@ -0,0 +1,144 @@
package com.bitchat.android.mesh
import com.bitchat.android.model.PeerCapabilities
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PrivateMediaSecurityTest {
private class MemoryPins : PrivateMediaCapabilityPinStore {
val pins = mutableSetOf<String>()
override fun contains(fingerprint: String): Boolean = fingerprint in pins
override fun insert(fingerprint: String) {
pins += fingerprint
}
}
private val peerID = "0011223344556677"
private val remoteStatic = ByteArray(32) { (it + 1).toByte() }
private var peerInfo: PeerInfo? = null
private var authenticatedRemoteStatic: ByteArray? = null
private val pins = MemoryPins()
private val controller = PrivateMediaSecurityController(
peerInfoProvider = { peerInfo },
authenticatedRemoteStaticProvider = { authenticatedRemoteStatic?.copyOf() },
pinStore = pins
)
@Test
fun `announce before handshake promotes only after authenticated key arrives`() {
peerInfo = peer(capabilities = PeerCapabilities.PRIVATE_MEDIA)
assertFalse(controller.refreshAuthenticatedCapability(peerID))
assertTrue(pins.pins.isEmpty())
authenticatedRemoteStatic = remoteStatic
assertTrue(controller.refreshAuthenticatedCapability(peerID))
assertEquals(PrivateMediaPolicyDecision.Encrypted, controller.sendPolicy(peerID))
assertEquals(1, pins.pins.size)
}
@Test
fun `handshake before announce promotes only after verified announce arrives`() {
authenticatedRemoteStatic = remoteStatic
assertFalse(controller.refreshAuthenticatedCapability(peerID))
assertTrue(pins.pins.isEmpty())
peerInfo = peer(capabilities = PeerCapabilities.PRIVATE_MEDIA)
assertTrue(controller.refreshAuthenticatedCapability(peerID))
assertEquals(PrivateMediaPolicyDecision.Encrypted, controller.sendPolicy(peerID))
}
@Test
fun `self certified or mismatched announcement never creates pin`() {
authenticatedRemoteStatic = remoteStatic
peerInfo = peer(
capabilities = PeerCapabilities.PRIVATE_MEDIA,
noiseKey = ByteArray(32) { 0x55 }
)
assertFalse(controller.refreshAuthenticatedCapability(peerID))
assertTrue(pins.pins.isEmpty())
assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Blocked)
// A normal peer refresh may carry a new current key, but it cannot
// transfer capability trust away from the key in the signed announce.
peerInfo = peer(
capabilities = PeerCapabilities.PRIVATE_MEDIA,
noiseKey = remoteStatic,
verifiedAnnouncementNoiseKey = ByteArray(32) { 0x55 }
)
assertFalse(controller.refreshAuthenticatedCapability(peerID))
assertTrue(controller.sendPolicy(peerID) is PrivateMediaPolicyDecision.Blocked)
peerInfo = peer(
capabilities = PeerCapabilities.PRIVATE_MEDIA,
hasVerifiedAnnouncement = false
)
assertFalse(controller.refreshAuthenticatedCapability(peerID))
assertTrue(pins.pins.isEmpty())
}
@Test
fun `signed absent and explicit empty capabilities both require one shot consent`() {
authenticatedRemoteStatic = remoteStatic
peerInfo = peer(capabilities = null)
assertEquals(
PrivateMediaPolicyDecision.RequiresLegacyConsent,
controller.sendPolicy(peerID)
)
peerInfo = peer(capabilities = PeerCapabilities.NONE)
assertEquals(
PrivateMediaPolicyDecision.RequiresLegacyConsent,
controller.sendPolicy(peerID)
)
assertTrue(pins.pins.isEmpty())
}
@Test
fun `pin is HSTS and prevents later capability downgrade`() {
authenticatedRemoteStatic = remoteStatic
peerInfo = peer(capabilities = PeerCapabilities.PRIVATE_MEDIA)
assertEquals(PrivateMediaPolicyDecision.Encrypted, controller.sendPolicy(peerID))
peerInfo = peer(capabilities = null)
assertEquals(PrivateMediaPolicyDecision.Encrypted, controller.sendPolicy(peerID))
}
@Test
fun `pin never bypasses requirement for a live authenticated static key`() {
authenticatedRemoteStatic = remoteStatic
peerInfo = peer(capabilities = PeerCapabilities.PRIVATE_MEDIA)
assertEquals(PrivateMediaPolicyDecision.Encrypted, controller.sendPolicy(peerID))
authenticatedRemoteStatic = null
assertEquals(PrivateMediaPolicyDecision.NeedsHandshake, controller.sendPolicy(peerID))
}
private fun peer(
capabilities: PeerCapabilities?,
noiseKey: ByteArray = remoteStatic,
hasVerifiedAnnouncement: Boolean = true,
verifiedAnnouncementNoiseKey: ByteArray = noiseKey
) = PeerInfo(
id = peerID,
nickname = "peer",
isConnected = true,
isDirectConnection = true,
noisePublicKey = noiseKey,
signingPublicKey = ByteArray(32) { 9 },
isVerifiedNickname = true,
lastSeen = 1,
capabilities = capabilities,
hasVerifiedAnnouncement = hasVerifiedAnnouncement,
verifiedAnnouncementNoisePublicKey = if (hasVerifiedAnnouncement) {
verifiedAnnouncementNoiseKey
} else {
null
}
)
}
@@ -0,0 +1,197 @@
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 org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.util.Random
@RunWith(RobolectricTestRunner::class)
class PrivateMediaTransferPreparerTest {
private val senderID = hex("0011223344556677")
private val recipientID = hex("8877665544332211")
private val fragmentManagers = mutableListOf<FragmentManager>()
@After
fun tearDown() {
fragmentManagers.forEach(FragmentManager::shutdown)
}
@Test
fun `encrypted mode emits deployed Noise 0x20 and signs before fragmentation`() {
var encryptedPlaintext: ByteArray? = null
val preparer = preparer(
policy = PrivateMediaPolicyDecision.Encrypted,
encrypt = { bytes, _ ->
encryptedPlaintext = bytes
byteArrayOf(0x41) + bytes
}
)
val outcome = preparer.prepare("peer", recipientID, file(64), false)
val ready = outcome as PrivateMediaBuildOutcome.Ready
assertEquals(MessageType.NOISE_ENCRYPTED.value, ready.built.packet.type)
assertNotNull(ready.built.packet.signature)
assertEquals(PrivateMediaWireMode.ENCRYPTED_NOISE_0X20, ready.built.wireMode)
val decoded = NoisePayload.decode(encryptedPlaintext!!)
assertEquals(NoisePayloadType.FILE_TRANSFER, decoded?.type)
assertEquals(0x20u.toUByte(), encryptedPlaintext!![0].toUByte())
}
@Test
fun `legacy mode requires consent and signing failure aborts`() {
val noConsent = preparer(policy = PrivateMediaPolicyDecision.RequiresLegacyConsent)
.prepare("peer", recipientID, file(64), false)
assertTrue(noConsent is PrivateMediaBuildOutcome.RequiresLegacyConsent)
val signingFailure = preparer(
policy = PrivateMediaPolicyDecision.RequiresLegacyConsent,
finalizer = { null }
).prepare("peer", recipientID, file(64), true)
assertTrue(signingFailure is PrivateMediaBuildOutcome.Rejected)
assertTrue((signingFailure as PrivateMediaBuildOutcome.Rejected).reason.contains("nothing was sent"))
val malformedSignature = preparer(
policy = PrivateMediaPolicyDecision.RequiresLegacyConsent,
finalizer = { packet -> packet.copy(signature = ByteArray(0)) }
).prepare("peer", recipientID, file(64), true)
assertTrue(malformedSignature is PrivateMediaBuildOutcome.Rejected)
}
@Test
fun `consented legacy mode is signed directed raw 0x22`() {
val outcome = preparer(policy = PrivateMediaPolicyDecision.RequiresLegacyConsent)
.prepare("peer", recipientID, file(64), true)
val ready = outcome as PrivateMediaBuildOutcome.Ready
assertEquals(MessageType.FILE_TRANSFER.value, ready.built.packet.type)
assertTrue(ready.built.packet.recipientID!!.contentEquals(recipientID))
assertNotNull(ready.built.packet.signature)
assertEquals(PrivateMediaWireMode.SIGNED_DIRECTED_RAW_0X22, ready.built.wireMode)
}
@Test
fun `handshake requirement returns before encoding signing encryption or fragmentation`() {
var encrypted = false
var finalized = false
var fragmented = false
val fragmentManager = FragmentManager().also { fragmentManagers += it }
val preparer = PrivateMediaTransferPreparer(
senderID = senderID,
ttl = 7u,
policyProvider = { PrivateMediaPolicyDecision.NeedsHandshake },
encrypt = { _, _ ->
encrypted = true
byteArrayOf(1)
},
finalizeRoutedAndSigned = {
finalized = true
it
},
fragment = { packet, maxFragments ->
fragmented = true
fragmentManager.createFragments(packet, maxFragments)
}
)
val outcome = preparer.prepare("peer", recipientID, file(64), false)
assertEquals(PrivateMediaBuildOutcome.NeedsHandshake, outcome)
assertTrue(!encrypted)
assertTrue(!finalized)
assertTrue(!fragmented)
}
@Test
fun `no route accepts 256 final fragments and rejects 257`() {
assertExactBoundary(route = null)
}
@Test
fun `source route accepts 256 final fragments and rejects 257`() {
assertExactBoundary(
route = listOf(
hex("1021324354657687"),
hex("2031425364758697"),
hex("30415263748596a7")
)
)
}
private fun assertExactBoundary(route: List<ByteArray>?) {
val randomContent = ByteArray(180 * 1024).also { Random(0xB17C4A7).nextBytes(it) }
val preparer = preparer(
policy = PrivateMediaPolicyDecision.Encrypted,
finalizer = { packet ->
packet.copy(
version = if (route == null) packet.version else 2u,
route = route,
signature = ByteArray(64) { 0x5A }
)
}
)
fun outcome(contentSize: Int): PrivateMediaBuildOutcome = preparer.prepare(
"peer",
recipientID,
BitchatFilePacket(
fileName = "boundary.bin",
fileSize = contentSize.toLong(),
mimeType = "application/octet-stream",
content = randomContent.copyOf(contentSize)
),
false
)
var low = 1
var high = randomContent.size
while (low < high) {
val mid = low + (high - low) / 2
if (outcome(mid) is PrivateMediaBuildOutcome.Rejected) high = mid else low = mid + 1
}
val accepted = outcome(low - 1) as PrivateMediaBuildOutcome.Ready
val rejected = outcome(low)
assertEquals(256, accepted.built.fragments.size)
assertTrue(rejected is PrivateMediaBuildOutcome.Rejected)
assertTrue((rejected as PrivateMediaBuildOutcome.Rejected).reason.contains("256"))
}
private fun preparer(
policy: PrivateMediaPolicyDecision,
encrypt: (ByteArray, String) -> ByteArray? = { bytes, _ -> byteArrayOf(0x01) + bytes },
finalizer: (BitchatPacket) -> BitchatPacket? = { packet ->
packet.copy(signature = ByteArray(64) { 0x33 })
}
): PrivateMediaTransferPreparer {
val fragmentManager = FragmentManager().also { fragmentManagers += it }
return PrivateMediaTransferPreparer(
senderID = senderID,
ttl = 7u,
policyProvider = { policy },
encrypt = encrypt,
finalizeRoutedAndSigned = finalizer,
fragment = fragmentManager::createFragments,
now = { 1_700_000_000_000uL }
)
}
private fun file(size: Int) = BitchatFilePacket(
fileName = "test.bin",
fileSize = size.toLong(),
mimeType = "application/octet-stream",
content = ByteArray(size) { it.toByte() }
)
private fun hex(value: String): ByteArray =
value.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
}
@@ -40,6 +40,7 @@ class SecurityManagerTest {
open class FakeEncryptionService : EncryptionService(RuntimeEnvironment.getApplication()) {
var shouldVerify: Boolean = true
var lastVerifySignature: ByteArray? = null
var lastVerifyData: ByteArray? = null
var lastVerifyKey: ByteArray? = null
override fun initialize() {
@@ -48,6 +49,7 @@ class SecurityManagerTest {
override fun verifyEd25519Signature(signature: ByteArray, data: ByteArray, publicKeyBytes: ByteArray): Boolean {
lastVerifySignature = signature
lastVerifyData = data
lastVerifyKey = publicKeyBytes
// Simple logic: if configured to verify, check if signature matches validSignature
@@ -90,6 +92,44 @@ class SecurityManagerTest {
assertFalse("Packet without signature should be rejected", result)
}
@Test
fun `verifySignature - verifies canonical packet with announced signing key`() {
setupKnownPeer(otherPeerID, otherSigningKey)
val packet = BitchatPacket(
version = 1u,
type = MessageType.FILE_TRANSFER.value,
senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID),
recipientID = MeshPacketUtils.hexStringToByteArray(myPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = dummyPayload,
signature = validSignature,
ttl = 10u
)
assertTrue(securityManager.verifySignature(packet, otherPeerID))
assertTrue(fakeEncryptionService.lastVerifySignature.contentEquals(validSignature))
assertTrue(fakeEncryptionService.lastVerifyData.contentEquals(packet.toBinaryDataForSigning()))
assertTrue(fakeEncryptionService.lastVerifyKey.contentEquals(otherSigningKey))
}
@Test
fun `verifySignature - rejects missing signature and unknown signing key`() {
val unsigned = BitchatPacket(
version = 1u,
type = MessageType.FILE_TRANSFER.value,
senderID = MeshPacketUtils.hexStringToByteArray(otherPeerID),
recipientID = MeshPacketUtils.hexStringToByteArray(myPeerID),
timestamp = System.currentTimeMillis().toULong(),
payload = dummyPayload,
ttl = 10u
)
assertFalse(securityManager.verifySignature(unsigned, otherPeerID))
unsigned.signature = validSignature
whenever(mockDelegate.getPeerInfo(otherPeerID)).thenReturn(null)
assertFalse(securityManager.verifySignature(unsigned, otherPeerID))
}
@Test
fun `validatePacket - rejects packet with invalid signature`() {
setupKnownPeer(otherPeerID, otherSigningKey)
@@ -107,6 +147,22 @@ class SecurityManagerTest {
assertFalse("Packet with invalid signature should be rejected", result)
}
@Test
fun `invalid packet does not poison duplicate detection for later valid packet`() {
setupKnownPeer(otherPeerID, otherSigningKey)
val packet = BitchatPacket(
type = MessageType.MESSAGE.value,
ttl = 10u,
senderID = otherPeerID,
payload = dummyPayload
)
packet.signature = invalidSignature
assertFalse(securityManager.validatePacket(packet, otherPeerID))
packet.signature = validSignature
assertTrue(securityManager.validatePacket(packet, otherPeerID))
}
@Test
fun `validatePacket - rejects packet from unknown peer (no key)`() {
whenever(mockDelegate.getPeerInfo(unknownPeerID)).thenReturn(null)
@@ -0,0 +1,75 @@
package com.bitchat.android.model
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class IdentityAnnouncementTest {
private val nickname = "peer"
private val noiseKey = ByteArray(32) { 0x11 }
private val signingKey = ByteArray(32) { 0x22 }
@Test
fun `private media capability uses iOS little-endian bytes`() {
assertArrayEquals(byteArrayOf(0x00, 0x01), PeerCapabilities.PRIVATE_MEDIA.encoded())
assertTrue(PeerCapabilities.decode(byteArrayOf(0x00, 0x01)).contains(PeerCapabilities.PRIVATE_MEDIA))
}
@Test
fun `legacy announcement without capability TLV still decodes`() {
val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!!
val decoded = IdentityAnnouncement.decode(legacy)!!
assertEquals(nickname, decoded.nickname)
assertArrayEquals(noiseKey, decoded.noisePublicKey)
assertArrayEquals(signingKey, decoded.signingPublicKey)
assertNull(decoded.capabilities)
}
@Test
fun `explicit empty capability TLV decodes as present but empty`() {
val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!!
val decoded = IdentityAnnouncement.decode(legacy + byteArrayOf(0x05, 0x00))!!
assertEquals(PeerCapabilities.NONE, decoded.capabilities)
}
@Test
fun `unknown capability bits and TLVs survive decode and re-encode`() {
val legacy = IdentityAnnouncement(nickname, noiseKey, signingKey).encode()!!
val wire = legacy + byteArrayOf(
0x05, 0x02, 0x00, 0x81.toByte(), // privateMedia plus unknown bit 15
0x7F, 0x03, 0x01, 0x02, 0x03
)
val decoded = IdentityAnnouncement.decode(wire)!!
assertEquals(0x8100L, decoded.capabilities?.rawValue)
assertEquals(1, decoded.unknownTLVs.size)
assertEquals(0x7F, decoded.unknownTLVs.single().type)
assertArrayEquals(byteArrayOf(0x01, 0x02, 0x03), decoded.unknownTLVs.single().value)
val roundTripped = IdentityAnnouncement.decode(decoded.encode()!!)!!
assertEquals(decoded.capabilities, roundTripped.capabilities)
assertEquals(decoded.unknownTLVs, roundTripped.unknownTLVs)
}
@Test
fun `local announcement send advertises private media`() {
val encoded = IdentityAnnouncement.forLocalPeer(nickname, noiseKey, signingKey).encode()!!
assertArrayEquals(
byteArrayOf(0x05, 0x02, 0x00, 0x01),
encoded.takeLast(4).toByteArray()
)
assertTrue(
IdentityAnnouncement.decode(encoded)!!
.capabilities!!
.contains(PeerCapabilities.PRIVATE_MEDIA)
)
}
}
@@ -0,0 +1,68 @@
package com.bitchat.android.service
import android.os.Build
import com.bitchat.android.model.RoutedPacket
import com.bitchat.android.protocol.BitchatPacket
import com.bitchat.android.protocol.MessageType
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.util.UUID
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
class TransportBridgeServiceTest {
private val targetId = "test-${UUID.randomUUID()}"
@After
fun tearDown() {
TransportBridgeService.unregister(targetId)
}
@Test
fun `bridged prepared plan retains exact payloads with decremented TTL`() {
var captured: RoutedPacket? = null
TransportBridgeService.register(
targetId,
object : TransportBridgeService.TransportLayer {
override fun send(packet: RoutedPacket) {
captured = packet
}
}
)
val packet = BitchatPacket(
version = 1u,
type = MessageType.NOISE_ENCRYPTED.value,
senderID = ByteArray(8) { 1 },
recipientID = ByteArray(8) { 2 },
timestamp = System.nanoTime().toULong(),
payload = byteArrayOf(3, 4, 5),
signature = ByteArray(64) { 6 },
ttl = 7u
)
val prepared = listOf(
packet.copy(type = MessageType.FRAGMENT.value, payload = byteArrayOf(10)),
packet.copy(type = MessageType.FRAGMENT.value, payload = byteArrayOf(11))
)
TransportBridgeService.broadcast(
sourceId = "source-${UUID.randomUUID()}",
packet = RoutedPacket(packet, preparedPackets = prepared)
)
val forwarded = captured
assertNotNull(forwarded)
assertEquals(6u.toUByte(), forwarded!!.packet.ttl)
assertEquals(2, forwarded.preparedPackets?.size)
forwarded.preparedPackets!!.zip(prepared).forEach { (actual, original) ->
assertEquals(6u.toUByte(), actual.ttl)
assertTrue(actual.payload.contentEquals(original.payload))
assertEquals(original.type, actual.type)
}
}
}
@@ -0,0 +1,156 @@
package com.bitchat.android.ui
import com.bitchat.android.mesh.MeshService
import com.bitchat.android.mesh.PreparedPrivateMediaTransfer
import com.bitchat.android.mesh.PrivateMediaPreparation
import com.bitchat.android.mesh.PrivateMediaWireMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import java.io.File
import java.util.concurrent.atomic.AtomicInteger
@RunWith(RobolectricTestRunner::class)
class MediaSendingManagerMigrationTest {
private val peerID = "8877665544332211"
private lateinit var state: ChatState
private lateinit var mesh: MeshService
private lateinit var manager: MediaSendingManager
private lateinit var file: File
@Before
fun setup() {
state = ChatState(CoroutineScope(SupervisorJob() + Dispatchers.Unconfined))
state.setNickname("me")
mesh = mock()
whenever(mesh.myPeerID).thenReturn("0011223344556677")
whenever(mesh.getPeerNicknames()).thenReturn(mapOf(peerID to "old peer"))
manager = MediaSendingManager(
state,
MessageManager(state),
mock(),
getMeshService = { mesh }
)
file = kotlin.io.path.createTempFile("private-media", ".jpg").toFile().apply {
writeBytes(ByteArray(128) { it.toByte() })
}
}
@After
fun tearDown() {
file.delete()
}
@Test
fun `preflight rejection creates no local echo mapping or send`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(PrivateMediaPreparation.Rejected("too many fragments"))
manager.sendImageNote(peerID, null, file.absolutePath)
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
assertEquals(null, manager.legacyPrivateMediaConsent.value)
verify(mesh, never()).sendFilePrivate(any(), any())
}
@Test
fun `missing Noise session initiates one handshake without echo prompt or media send`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(PrivateMediaPreparation.NeedsHandshake)
manager.sendImageNote(peerID, null, file.absolutePath)
verify(mesh, times(1)).initiateNoiseHandshake(peerID)
verify(mesh, never()).sendFilePrivate(any(), any())
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
assertEquals(null, manager.legacyPrivateMediaConsent.value)
}
@Test
fun `legacy consent is one shot rechecks policy and echoes only after approval`() {
val commits = AtomicInteger(0)
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(PrivateMediaPreparation.RequiresLegacyConsent("relay-visible warning"))
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(true)))
.thenAnswer { invocation ->
val transferId = invocation.getArgument<String>(2)
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(
transferId = transferId,
// Simulate the capability becoming authenticated while
// the consent dialog was open: recheck must upgrade.
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
) {
commits.incrementAndGet()
true
}
)
}
manager.sendImageNote(peerID, null, file.absolutePath)
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
val request = manager.legacyPrivateMediaConsent.value
assertNotNull(request)
manager.approveLegacyPrivateMedia(request!!.requestId)
manager.approveLegacyPrivateMedia(request.requestId)
assertEquals(1, commits.get())
assertEquals(1, state.privateChats.value[peerID]?.size)
assertEquals(null, manager.legacyPrivateMediaConsent.value)
verify(mesh, times(1)).prepareFilePrivate(eq(peerID), any(), any(), eq(false))
verify(mesh, times(1)).prepareFilePrivate(eq(peerID), any(), any(), eq(true))
}
@Test
fun `prepared transfer ID mismatch aborts before local echo or commit`() {
val commits = AtomicInteger(0)
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(
PrivateMediaPreparation.Ready(
PreparedPrivateMediaTransfer(
transferId = "wrong-transfer-id",
wireMode = PrivateMediaWireMode.ENCRYPTED_NOISE_0X20
) {
commits.incrementAndGet()
true
}
)
)
manager.sendImageNote(peerID, null, file.absolutePath)
assertEquals(0, commits.get())
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
}
@Test
fun `cancelled consent cannot later send or echo`() {
whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false)))
.thenReturn(PrivateMediaPreparation.RequiresLegacyConsent("relay-visible warning"))
manager.sendImageNote(peerID, null, file.absolutePath)
val request = manager.legacyPrivateMediaConsent.value!!
manager.cancelLegacyPrivateMedia(request.requestId)
manager.approveLegacyPrivateMedia(request.requestId)
assertTrue(state.privateChats.value[peerID].isNullOrEmpty())
verify(mesh, never()).prepareFilePrivate(eq(peerID), any(), any(), eq(true))
}
}