mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 03:25:20 +00:00
Migrate private media without silent downgrades
This commit is contained in:
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+50
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user