diff --git a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt index b15a62545..1248b94c2 100644 --- a/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/crypto/EncryptionService.kt @@ -163,13 +163,17 @@ open class EncryptionService(private val context: Context) { /** * Clear persistent identity (for panic mode) */ - fun clearPersistentIdentity() { - noiseService.clearPersistentIdentity() + /** Returns true only when both the Noise identity and the signing key were wiped from disk. */ + fun clearPersistentIdentity(): Boolean { + val noiseCleared = noiseService.clearPersistentIdentity() establishedSessions.clear() // Clear Ed25519 signing key from preferences try { - prefs.edit { remove(ED25519_PRIVATE_KEY_PREF) } + if (!prefs.edit().remove(ED25519_PRIVATE_KEY_PREF).commit()) { + Log.e(TAG, "❌ Ed25519 signing key wipe could not be committed") + return false + } Log.d(TAG, "🗑️ Cleared Ed25519 signing keys from preferences") // Generate new keys immediately @@ -179,7 +183,9 @@ open class EncryptionService(private val context: Context) { Log.d(TAG, "✅ Rotated Ed25519 signing keys in memory") } catch (e: Exception) { Log.e(TAG, "❌ Failed to clear Ed25519 keys: ${e.message}") + return false } + return noiseCleared } /** diff --git a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt index da6898e8b..1f3936d6c 100644 --- a/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt +++ b/app/src/main/java/com/bitchat/android/features/file/FileUtils.kt @@ -280,7 +280,9 @@ object FileUtils { * Recursively delete all media files (incoming and outgoing) * Used for Panic Mode cleanup */ - fun clearAllMedia(context: Context) { + /** Returns true only when every media directory and the cache directory are gone. */ + fun clearAllMedia(context: Context): Boolean { + var cleared = true try { // Clear files dir subdirectories (legacy storage and outgoing) val filesDir = context.filesDir @@ -295,8 +297,12 @@ object FileUtils { dirsToClear.forEach { subDir -> val dir = File(filesDir, subDir) if (dir.exists()) { - dir.deleteRecursively() - Log.d(TAG, "Deleted media directory from filesDir: $subDir") + if (dir.deleteRecursively()) { + Log.d(TAG, "Deleted media directory from filesDir: $subDir") + } else { + Log.e(TAG, "Failed to delete media directory from filesDir: $subDir") + cleared = false + } } } @@ -312,18 +318,27 @@ object FileUtils { cacheDirsToClear.forEach { subDir -> val dir = File(cacheDir, subDir) if (dir.exists()) { - dir.deleteRecursively() - Log.d(TAG, "Deleted media directory from cacheDir: $subDir") + if (dir.deleteRecursively()) { + Log.d(TAG, "Deleted media directory from cacheDir: $subDir") + } else { + Log.e(TAG, "Failed to delete media directory from cacheDir: $subDir") + cleared = false + } } } // Also clear entire cache dir as a catch-all - context.cacheDir.deleteRecursively() - Log.d(TAG, "Cleared entire cache directory") - + if (context.cacheDir.deleteRecursively()) { + Log.d(TAG, "Cleared entire cache directory") + } else { + Log.e(TAG, "Failed to clear entire cache directory") + cleared = false + } } catch (e: Exception) { Log.e(TAG, "Failed to clear media files", e) + cleared = false } + return cleared } /** diff --git a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt index 111318586..ca63776c9 100644 --- a/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt +++ b/app/src/main/java/com/bitchat/android/identity/SecureIdentityStateManager.kt @@ -488,18 +488,23 @@ class SecureIdentityStateManager { * Clear all identity data (for panic mode) */ @SuppressLint("UseKtx") - fun clearIdentityData() { + /** Returns true only when the wipe was committed to disk. */ + fun clearIdentityData(): Boolean { try { - synchronized(privateMediaPinsLock) { + val committed = synchronized(privateMediaPinsLock) { privateMediaPinsEpoch += 1 privateMediaPinsEpochAtCreation = privateMediaPinsEpoch - if (!prefs.edit().clear().commit()) { - Log.e(TAG, "Identity preference wipe could not be committed") - } + prefs.edit().clear().commit() + } + if (!committed) { + Log.e(TAG, "Identity preference wipe could not be committed") + return false } Log.w(TAG, "All identity data cleared") + return true } catch (e: Exception) { Log.e(TAG, "Failed to clear identity data: ${e.message}") + return false } } diff --git a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt index 66119cc5d..8c2080292 100644 --- a/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/BluetoothMeshService.kt @@ -1639,13 +1639,14 @@ class BluetoothMeshService(private val context: Context) : TransportBridgeServic /** * Clear all encryption and cryptographic data (for panic mode) */ - fun clearAllEncryptionData() { + fun clearAllEncryptionData(): Boolean { Log.w(TAG, "Clearing all encryption data") - try { + return try { // Clear encryption service persistent identity (includes Ed25519 signing keys) encryptionService.clearPersistentIdentity() } catch (e: Exception) { Log.e(TAG, "Error clearing encryption data: ${e.message}") + false } } } diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt index cc8195b72..53f7804ea 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshCore.kt @@ -1037,9 +1037,8 @@ class MeshCore( try { gossipSyncManager.clear() } catch (_: Exception) { } } - fun clearAllEncryptionData() { + fun clearAllEncryptionData(): Boolean = encryptionService.clearPersistentIdentity() - } private fun applyRouteIfAvailable(packet: BitchatPacket): BitchatPacket { return try { diff --git a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt index 7b357149d..31cb7d2fa 100644 --- a/app/src/main/java/com/bitchat/android/mesh/MeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/MeshService.kt @@ -59,5 +59,6 @@ interface MeshService { fun getDebugStatus(): String fun clearAllInternalData() - fun clearAllEncryptionData() + /** Returns true only when every transport's persistent identity was wiped from disk. */ + fun clearAllEncryptionData(): Boolean } diff --git a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt index 3efe758d2..23f329e57 100644 --- a/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt +++ b/app/src/main/java/com/bitchat/android/mesh/UnifiedMeshService.kt @@ -380,9 +380,10 @@ class UnifiedMeshService( try { wifiService()?.clearAllInternalData() } catch (_: Exception) { } } - override fun clearAllEncryptionData() { - try { bluetooth.clearAllEncryptionData() } catch (_: Exception) { } - try { wifiService()?.clearAllEncryptionData() } catch (_: Exception) { } + override fun clearAllEncryptionData(): Boolean { + val bluetoothCleared = try { bluetooth.clearAllEncryptionData() } catch (_: Exception) { false } + val wifiCleared = try { wifiService()?.clearAllEncryptionData() ?: true } catch (_: Exception) { false } + return bluetoothCleared && wifiCleared } override fun didReceiveMessage(message: BitchatMessage) { diff --git a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt index 909ca82b4..604c88055 100644 --- a/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt +++ b/app/src/main/java/com/bitchat/android/noise/NoiseEncryptionService.kt @@ -159,11 +159,11 @@ class NoiseEncryptionService(private val context: Context) { /** * Clear persistent identity (for panic mode) */ - fun clearPersistentIdentity() { + fun clearPersistentIdentity(): Boolean { Log.w(TAG, "Panic: clearing persistent identity and rotating in-memory keys") // 1. Clear storage - identityStateManager.clearIdentityData() + val storageCleared = identityStateManager.clearIdentityData() // 2. Clear all sessions immediately if (::sessionManager.isInitialized) { @@ -175,7 +175,8 @@ class NoiseEncryptionService(private val context: Context) { // 4. Re-initialize SessionManager with new keys initializeSessionManager() - + + return storageCleared } // MARK: - Handshake Management diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt index 755d9d34f..26ec5885a 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedBitChat.kt @@ -8,6 +8,7 @@ import com.bitchat.android.model.NoisePayloadType import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import com.bitchat.android.services.ContactIdentityResolver +import java.security.SecureRandom import java.util.* /** @@ -16,6 +17,8 @@ import java.util.* object NostrEmbeddedBitChat { private const val TAG = "NostrEmbeddedBitChat" + private const val SENDER_ID_SIZE = 8 + private val secureRandom = SecureRandom() /** * Build a `bitchat1:` base64url-encoded BitChat packet carrying a private message for Nostr DMs. @@ -100,11 +103,11 @@ object NostrEmbeddedBitChat { /** * Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs). + * The sender ID is random per envelope; see [unlinkableSenderID]. */ fun encodeAckForNostrNoRecipient( type: NoisePayloadType, - messageID: String, - senderPeerID: String + messageID: String ): String? { if (type != NoisePayloadType.DELIVERED && type != NoisePayloadType.READ_RECEIPT) { return null @@ -119,7 +122,7 @@ object NostrEmbeddedBitChat { val packet = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(senderPeerID), + senderID = unlinkableSenderID(), recipientID = null, // No recipient for geohash DMs timestamp = System.currentTimeMillis().toULong(), payload = payload, @@ -137,11 +140,11 @@ object NostrEmbeddedBitChat { /** * Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs). + * The sender ID is random per envelope; see [unlinkableSenderID]. */ fun encodePMForNostrNoRecipient( content: String, - messageID: String, - senderPeerID: String + messageID: String ): String? { try { val pm = PrivateMessagePacket(messageID = messageID, content = content) @@ -154,7 +157,7 @@ object NostrEmbeddedBitChat { val packet = BitchatPacket( version = 1u, type = MessageType.NOISE_ENCRYPTED.value, - senderID = hexStringToByteArray(senderPeerID), + senderID = unlinkableSenderID(), recipientID = null, // No recipient for geohash DMs timestamp = System.currentTimeMillis().toULong(), payload = payload, @@ -170,6 +173,19 @@ object NostrEmbeddedBitChat { } } + /** + * Sender ID for the recipient-less envelopes above. They go out under a + * per-geohash identity (and as the acks for inbound account DMs), where no + * receiver reads this field: iOS and Android both key these conversations + * on the Nostr sender pubkey. The mesh peer ID must never go here: it is the + * stable Noise fingerprint prefix, so anyone who DMs a geohash identity (and + * gets the automatic DELIVERED ack back) could link that persona to the + * device and to its other geohash personas. Fresh random bytes per envelope + * keep even our own envelopes unlinkable. + */ + private fun unlinkableSenderID(): ByteArray = + ByteArray(SENDER_ID_SIZE).also { secureRandom.nextBytes(it) } + /** * Normalize recipient peer ID (matches iOS implementation) */ diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt index 665bfd501..fe8b684a1 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrIdentity.kt @@ -205,19 +205,25 @@ object NostrIdentityBridge { /** * Clear all Nostr identity data */ - fun clearAllAssociations(context: Context) { + /** Returns true only when the Nostr key and geohash seed were wiped from disk. */ + fun clearAllAssociations(context: Context): Boolean { val stateManager = SecureIdentityStateManager(context) // Clear cache first geohashIdentityCache.clear() // Clear Nostr private key using public methods instead of reflection - try { - stateManager.clearSecureValues(NOSTR_PRIVATE_KEY, DEVICE_SEED_KEY) - - Log.i(TAG, "Cleared all Nostr identity data and cache") + return try { + val cleared = stateManager.clearSecureValuesSynchronously(NOSTR_PRIVATE_KEY, DEVICE_SEED_KEY) + if (cleared) { + Log.i(TAG, "Cleared all Nostr identity data and cache") + } else { + Log.e(TAG, "Nostr identity wipe could not be committed") + } + cleared } catch (e: Exception) { Log.e(TAG, "Failed to clear Nostr data: ${e.message}") + false } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt index f39324fd6..9042a53dd 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrTransport.kt @@ -16,6 +16,8 @@ import java.util.concurrent.ConcurrentLinkedQueue */ class NostrTransport( private val context: Context, + // BLE short peer ID embedded in favorites (recipient-addressed) envelopes. + // Geohash envelopes never carry it (see NostrEmbeddedBitChat). var senderPeerID: String = "" ) { @@ -310,8 +312,7 @@ class NostrTransport( try { val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient( type = NoisePayloadType.DELIVERED, - messageID = messageID, - senderPeerID = senderPeerID + messageID = messageID ) if (embedded == null) return@launch @@ -343,8 +344,7 @@ class NostrTransport( try { val embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient( type = NoisePayloadType.READ_RECEIPT, - messageID = messageID, - senderPeerID = senderPeerID + messageID = messageID ) if (embedded == null) return@launch @@ -401,8 +401,7 @@ class NostrTransport( // Build embedded BitChat packet without recipient peer ID val embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient( content = content, - messageID = messageID, - senderPeerID = senderPeerID + messageID = messageID ) ?: run { Log.e(TAG, "NostrTransport: failed to embed geohash PM packet") return@launch diff --git a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt index 6a0445b06..f2a86f691 100644 --- a/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt +++ b/app/src/main/java/com/bitchat/android/protocol/BinaryProtocol.kt @@ -436,6 +436,11 @@ object BinaryProtocol { return null } + // Per-type ceiling on the decoded payload, compressed or not, checked + // before any allocation: see PacketPayloadLimits for why one + // frame-wide cap is not enough. + val maxPayloadSize = PacketPayloadLimits.maxPayloadBytes(type) + var expectedSize = headerSize + SENDER_ID_SIZE + payloadLength.toInt() if (hasRecipient) expectedSize += RECIPIENT_ID_SIZE var routeCount = 0 @@ -498,11 +503,10 @@ object BinaryProtocol { buffer.getShort().toUShort().toInt() } - val maxExpandedSize = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH - if (originalSize <= 0 || originalSize > maxExpandedSize) { + if (originalSize <= 0 || originalSize > maxPayloadSize) { Log.w( "BinaryProtocol", - "Expanded payload size $originalSize is outside the allowed range 1..$maxExpandedSize" + "Expanded payload size $originalSize for type $type is outside the allowed range 1..$maxPayloadSize" ) return null } @@ -514,9 +518,8 @@ object BinaryProtocol { return null } // Security check: Compression bomb protection - val ratio = originalSize.toDouble() / compressedSize.toDouble() - if (ratio > 50_000.0) { - Log.w("BinaryProtocol", "🚫 Suspicious compression ratio: ${ratio}:1") + if (originalSize.toLong() > compressedSize.toLong() * PacketPayloadLimits.MAX_DEFLATE_RATIO) { + Log.w("BinaryProtocol", "🚫 Impossible compression ratio: $originalSize bytes from $compressedSize") return null } @@ -539,6 +542,12 @@ object BinaryProtocol { } expandedPayload } else { + // Uncompressed payloads get the same ceiling, or a large frame + // could arrive as small compressed fragments and be reassembled. + if (payloadLength > maxPayloadSize.toUInt()) { + Log.w("BinaryProtocol", "Payload for type $type is $payloadLength bytes (cap $maxPayloadSize)") + return null + } val payloadBytes = ByteArray(payloadLength.toInt()) buffer.get(payloadBytes) payloadBytes diff --git a/app/src/main/java/com/bitchat/android/protocol/PacketPayloadLimits.kt b/app/src/main/java/com/bitchat/android/protocol/PacketPayloadLimits.kt new file mode 100644 index 000000000..e1a897d8d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/protocol/PacketPayloadLimits.kt @@ -0,0 +1,76 @@ +package com.bitchat.android.protocol + +/** + * Receive-side ceilings on how large a packet's payload may be, per type. + * + * A compressed frame declares its original size, and raw DEFLATE lets ~1 KB on + * air stand for ~1 MB decoded; fragment reassembly lets a large uncompressed + * frame arrive as a stream of small, compressed fragments. Accepted packets are + * then held at their decoded size, relayed, and re-served by gossip sync, so a + * single frame-wide ceiling turns airtime into memory on every node. + * [BinaryProtocol] enforces these caps on every decoded payload, compressed or + * not. Only the types that carry media keep the framed-file ceiling; every other + * type is capped at no less than twice the largest payload any shipping iOS or + * Android encoder produces for it. Receive-side only: senders are unchanged. + * + * Values match iOS `PacketPayloadLimits` (bitchat#1719), except that media types + * keep Android's own [com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH]. + * + * | Type | Largest legitimate payload | Cap | + * |--------------------------------|------------------------------------------------------|--------------------| + * | FILE_TRANSFER, NOISE_ENCRYPTED | file + name/MIME TLVs (+ Noise framing for private) | MAX_PAYLOAD_LENGTH | + * | MESSAGE, group message | 65,535 B: public messages are v1 frames | 128 KiB | + * | courier envelope, Nostr carrier| 16 KiB ciphertext/event + TLVs (iOS only) | 64 KiB | + * | REQUEST_SYNC | ~2.1 KB: 1,024 B GCS filter + fragment-ID filter | 8 KiB | + * | FRAGMENT | <= 513 B: 13 B header + chunk | 1,280 B | + * | ANNOUNCE, LEAVE, NOISE_HANDSHAKE, VOICE_FRAME, board post, prekey bundle, ping, pong | <= ~750 B | 4 KiB | + * | unknown / future types | a v1 frame (65,535 B) stays relayable | 128 KiB | + * + * A future type that needs more than a v1 frame must be added here before it + * ships, or current receivers will drop it at decode. + */ +internal object PacketPayloadLimits { + /** + * Raw DEFLATE's hard expansion ceiling: every 258-byte match costs at least a + * 1-bit length code plus a 1-bit distance code. A declared original size + * beyond this multiple of the compressed bytes cannot be produced by any + * stream, so it is rejected before the output buffer is allocated. + */ + const val MAX_DEFLATE_RATIO = 1032 + + private const val FRAGMENT_BYTES = 1_280 + private const val CONTROL_BYTES = 4 * 1024 + private const val REQUEST_SYNC_BYTES = 8 * 1024 + private const val ENVELOPE_BYTES = 64 * 1024 + private const val TWICE_V1_FRAME_BYTES = 128 * 1024 + + // iOS-only types this client does not decode yet; capped the same way iOS caps them + // so they stay relayable without becoming a memory amplifier. + private const val COURIER_ENVELOPE: UByte = 0x04u + private const val BOARD_POST: UByte = 0x23u + private const val PREKEY_BUNDLE: UByte = 0x24u + private const val GROUP_MESSAGE: UByte = 0x25u + private const val PING: UByte = 0x26u + private const val PONG: UByte = 0x27u + private const val NOSTR_CARRIER: UByte = 0x28u + + /** Largest decoded payload accepted for a packet of [type], whether it arrived compressed or not. */ + fun maxPayloadBytes(type: UByte): Int { + when (MessageType.fromValue(type)) { + MessageType.FILE_TRANSFER, MessageType.NOISE_ENCRYPTED -> + return com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH + MessageType.MESSAGE -> return TWICE_V1_FRAME_BYTES + MessageType.REQUEST_SYNC -> return REQUEST_SYNC_BYTES + MessageType.FRAGMENT -> return FRAGMENT_BYTES + MessageType.ANNOUNCE, MessageType.LEAVE, MessageType.NOISE_HANDSHAKE, + MessageType.VOICE_FRAME -> return CONTROL_BYTES + null -> Unit + } + return when (type) { + GROUP_MESSAGE -> TWICE_V1_FRAME_BYTES + COURIER_ENVELOPE, NOSTR_CARRIER -> ENVELOPE_BYTES + BOARD_POST, PREKEY_BUNDLE, PING, PONG -> CONTROL_BYTES + else -> TWICE_V1_FRAME_BYTES + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 80527d1a9..2a689d236 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -90,6 +90,9 @@ fun ChatScreen(viewModel: ChatViewModel) { var passwordInput by remember { mutableStateOf("") } var showLocationChannelsSheet by remember { mutableStateOf(false) } var showLocationNotesSheet by remember { mutableStateOf(false) } + // Triple-tapping the logo asks first: the same logo opens App Info on a + // single tap, so a fumbled tap must never be able to wipe the device. + var showPanicConfirmation by remember { mutableStateOf(false) } var showUserSheet by remember { mutableStateOf(false) } var selectedUserForSheet by remember { mutableStateOf("") } var selectedMessageForSheet by remember { mutableStateOf(null) } @@ -458,7 +461,7 @@ fun ChatScreen(viewModel: ChatViewModel) { colorScheme = colorScheme, onSidebarToggle = { viewModel.showMeshPeerList() }, onShowAppInfo = { viewModel.showAppInfo() }, - onPanicClear = { viewModel.panicClearAllData() }, + onPanicClear = { showPanicConfirmation = true }, onLocationChannelsClick = { showLocationChannelsSheet = true }, onLocationNotesClick = { nearbyNotesController.reveal() @@ -559,6 +562,32 @@ fun ChatScreen(viewModel: ChatViewModel) { onMeshPeerListDismiss = viewModel::hideMeshPeerList, ) + if (showPanicConfirmation) { + AlertDialog( + onDismissRequest = { showPanicConfirmation = false }, + title = { Text(stringResource(com.bitchat.android.R.string.panic_confirm_title)) }, + text = { Text(stringResource(com.bitchat.android.R.string.panic_confirm_body)) }, + confirmButton = { + TextButton( + onClick = { + showPanicConfirmation = false + viewModel.panicClearAllData() + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(stringResource(com.bitchat.android.R.string.panic_confirm_action)) + } + }, + dismissButton = { + TextButton(onClick = { showPanicConfirmation = false }) { + Text(stringResource(android.R.string.cancel)) + } + } + ) + } + legacyPrivateMediaConsent?.let { request -> AlertDialog( onDismissRequest = { viewModel.cancelLegacyPrivateMedia(request.requestId) }, diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index 29763132f..b7f38eb07 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1466,25 +1466,35 @@ class ChatViewModel( dataManager.clearAllData() conversationListPreferences.clearAll() + // Every stage that wipes data at rest reports here. The success message is a + // security assurance, so it requires all of them, not just the conversation database. + var wipeComplete = true + // Clear seen message store and MessageRouter outbox try { com.bitchat.android.services.SeenMessageStore.getInstance(getApplication()).clear() - } catch (_: Exception) { } + } catch (_: Exception) { + wipeComplete = false + } try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.clearAll() - } catch (_: Exception) { } + } catch (_: Exception) { + wipeComplete = false + } // The spam filter keeps recent message text and sender keys for its repeat checks com.bitchat.android.spam.SpamFilter.shared.reset() // Clear all cryptographic data - clearAllCryptographicData() + if (!clearAllCryptographicData()) wipeComplete = false // Clear all notifications notificationManager.clearAllNotifications(removeConversationShortcuts = true) // Clear all media files - com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication()) + if (!com.bitchat.android.features.file.FileUtils.clearAllMedia(getApplication())) { + wipeComplete = false + } // Clear Nostr/geohash state, keys, connections, bookmarks, and reinitialize from scratch try { @@ -1492,16 +1502,21 @@ class ChatViewModel( try { val store = com.bitchat.android.geohash.GeohashBookmarksStore.getInstance(getApplication()) store.clearAll() - } catch (_: Exception) { } + } catch (_: Exception) { + wipeComplete = false + } try { val locationManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication()) locationManager.clearPersistedChannel() - } catch (_: Exception) { } + } catch (_: Exception) { + wipeComplete = false + } - geohashViewModel.panicReset() + if (!geohashViewModel.panicReset()) wipeComplete = false } catch (e: Exception) { Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}") + wipeComplete = false } // Reset nickname @@ -1513,6 +1528,11 @@ class ChatViewModel( // Privacy wins over availability: keep private-message admission and transports // stopped if SQLite could not prove that the conversation history was erased. Log.e(TAG, "🚨 PANIC MODE INCOMPLETE - conversation database wipe failed") + // A wipe that does not commit must be as visible as the wipe itself: the + // person who triggered it may be relying on the device being clean. + messageManager.addSystemMessage( + getApplication().getString(com.bitchat.android.R.string.system_panic_incomplete) + ) return } @@ -1521,6 +1541,17 @@ class ChatViewModel( .resumePrivateConversationsAfterPanic() recreateMeshServiceAfterPanic() + // In a duress scenario "did it work?" must not be a guess: the natural + // response to uncertainty is to trigger the wipe again. Announce success + // only when every stage confirmed its wipe. + val outcome = if (wipeComplete) { + com.bitchat.android.R.string.system_panic_completed + } else { + Log.e(TAG, "🚨 PANIC MODE INCOMPLETE - a wipe stage did not confirm") + com.bitchat.android.R.string.system_panic_incomplete + } + messageManager.addSystemMessage(getApplication().getString(outcome)) + Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}") } @@ -1570,34 +1601,41 @@ class ChatViewModel( /** * Clear all cryptographic data including persistent identity */ - private fun clearAllCryptographicData() { + /** Returns true only when every identity, key, and favorites store confirmed its wipe. */ + private fun clearAllCryptographicData(): Boolean { + var cleared = true try { // Clear encryption service persistent identity (Ed25519 signing keys) - mesh.clearAllEncryptionData() + if (!mesh.clearAllEncryptionData()) cleared = false // Clear secure identity state (if used) try { val identityManager = SecureIdentityStateManager(getApplication()) - identityManager.clearIdentityData() + if (!identityManager.clearIdentityData()) cleared = false // Also clear secure values used by FavoritesPersistenceService (favorites + peerID index) - try { - identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index") - } catch (_: Exception) { } + if (!identityManager.clearSecureValuesSynchronously("favorite_relationships", "favorite_peerid_index")) { + cleared = false + } Log.d(TAG, "✅ Cleared secure identity state and secure favorites store") } catch (e: Exception) { - Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}") + Log.e(TAG, "❌ Failed to clear secure identity state: ${e.message}") + cleared = false } // Clear FavoritesPersistenceService persistent relationships try { FavoritesPersistenceService.shared.clearAllFavorites() Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships") - } catch (_: Exception) { } + } catch (_: Exception) { + cleared = false + } Log.d(TAG, "✅ Cleared all cryptographic data") } catch (e: Exception) { Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}") + cleared = false } + return cleared } /** diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt index dadcad71b..48c8b6056 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashViewModel.kt @@ -111,7 +111,8 @@ class GeohashViewModel( } } - fun panicReset() { + /** Returns true only when the persisted Nostr identity was wiped; the rest is in-memory teardown. */ + fun panicReset(): Boolean { repo.clearAll() GeohashAliasRegistry.clear() GeohashConversationRegistry.clear() @@ -120,10 +121,15 @@ class GeohashViewModel( activeChannelGeohash = null geoTimer?.cancel() geoTimer = null - try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {} + val identityCleared = try { + NostrIdentityBridge.clearAllAssociations(getApplication()) + } catch (_: Exception) { + false + } NostrBackgroundRuntime.resetSubscriptions() try { com.bitchat.android.nostr.NostrRelayManager.getInstance(getApplication()).clearAllOnPanic() } catch (_: Exception) {} try { com.bitchat.android.nostr.LocationNotesManager.getInstance().stop() } catch (_: Exception) {} + return identityCleared } fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) { diff --git a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt index 02bfb2d34..93aabb17b 100644 --- a/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt +++ b/app/src/main/java/com/bitchat/android/wifi-aware/WifiAwareMeshService.kt @@ -1602,9 +1602,8 @@ class WifiAwareMeshService(private val context: Context) : MeshService, Transpor meshCore.clearAllInternalData() } - override fun clearAllEncryptionData() { + override fun clearAllEncryptionData(): Boolean = meshCore.clearAllEncryptionData() - } /** Utility extension to safely close server sockets. */ private fun ServerSocket.closeQuietly() = try { close() } catch (_: Exception) {} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d656fdb82..7f20c1eb0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -136,6 +136,13 @@ %1$s cannot be sent encrypted to %2$s because their client is older. %3$s Send this file once + + Wipe all data? + This permanently deletes your identity, keys, messages, and media on this device and creates a new identity. It can\'t be undone. + Wipe + all data wiped — new identity created + wipe incomplete — some data may remain. triple-tap the logo to try again. + 📎 File Received 📄 %1$s diff --git a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt index c927257f7..22aaaa3e2 100644 --- a/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt +++ b/app/src/test/java/com/bitchat/android/protocol/BinaryProtocolTest.kt @@ -1008,7 +1008,7 @@ class BinaryProtocolTest { @Test fun `v2 compression bomb is rejected`() { // Valid raw deflate final empty stored block (1 byte). - // Claim a huge original size to exceed the 50,000:1 ratio guard. + // Claim a huge original size to exceed DEFLATE's 1032:1 expansion bound. // ratio = 10,000,000 / 1 = 10,000,000:1 val compressedData = byteArrayOf(0x03) val declaredOriginalSize = 10_000_000 @@ -1042,7 +1042,7 @@ class BinaryProtocolTest { val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size)) val result = BinaryProtocol.decode(padded) - assertNull("v2 compression bomb (ratio > 50,000:1) must be rejected", result) + assertNull("v2 compression bomb (ratio > 1032:1) must be rejected", result) } @Test @@ -1051,7 +1051,9 @@ class BinaryProtocolTest { val raw = compressedPacket( version = 2u, originalSize = max, - compressedData = ByteArray(256) { it.toByte() } + // Enough deflate bytes that the declared size stays within DEFLATE's 1032:1 bound. + compressedData = ByteArray(max / PacketPayloadLimits.MAX_DEFLATE_RATIO + 1) { it.toByte() }, + type = MessageType.FILE_TRANSFER.value ) var calls = 0 @@ -1071,7 +1073,8 @@ class BinaryProtocolTest { val raw = compressedPacket( version = 2u, originalSize = max + 1, - compressedData = ByteArray(256) { it.toByte() } + compressedData = ByteArray(max / PacketPayloadLimits.MAX_DEFLATE_RATIO + 1) { it.toByte() }, + type = MessageType.FILE_TRANSFER.value ) var calls = 0 @@ -1144,7 +1147,7 @@ class BinaryProtocolTest { val raw = compressedPacket( version = 1u, originalSize = originalSize, - compressedData = byteArrayOf(0x01, 0x02) + compressedData = ByteArray(64) { it.toByte() } ) var calls = 0 @@ -1359,20 +1362,19 @@ class BinaryProtocolTest { * * Crafts a packet where the declared original size in the compressed * payload section is absurdly large relative to the compressed data, - * exceeding the 50,000:1 ratio guard in decodeCore(). Verifies the + * exceeding DEFLATE's 1032:1 expansion bound in decodeCore(). Verifies the * decoder returns null instead of attempting decompression. * * Without this check, an attacker could send a tiny packet claiming * to decompress into gigabytes, causing an OOM crash that kills the - * mesh service and disconnects all peers. The 50,000:1 threshold - * blocks this while still allowing legitimate compression ratios - * (typical text compresses ~3:1 to ~10:1). + * mesh service and disconnects all peers. No raw DEFLATE stream can + * expand past 1032:1, so the bound rejects only impossible declarations. */ @Test fun `compression bomb is rejected`() { // Valid raw deflate final empty stored block (1 byte). // v1 uses a 2-byte (UShort) original-size field, so declared size - // must fit in 0..65535. ratio = 60,000 / 1 = 60,000:1 > 50,000:1 + // must fit in 0..65535. ratio = 60,000 / 1 = 60,000:1 > 1032:1 val tinyCompressed = byteArrayOf(0x03) val declaredOriginalSize = 60_000 @@ -1406,7 +1408,7 @@ class BinaryProtocolTest { val padded = MessagePadding.pad(raw, MessagePadding.optimalBlockSize(raw.size)) val result = BinaryProtocol.decode(padded) - assertNull("Compression bomb (ratio > 50,000:1) must be rejected", result) + assertNull("Compression bomb (ratio > 1032:1) must be rejected", result) } /** diff --git a/app/src/test/java/com/bitchat/android/protocol/PacketPayloadLimitsTest.kt b/app/src/test/java/com/bitchat/android/protocol/PacketPayloadLimitsTest.kt new file mode 100644 index 000000000..9f9e555de --- /dev/null +++ b/app/src/test/java/com/bitchat/android/protocol/PacketPayloadLimitsTest.kt @@ -0,0 +1,165 @@ +package com.bitchat.android.protocol + +import com.bitchat.android.util.AppConstants +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.nio.ByteBuffer +import java.nio.ByteOrder + +/** + * Per-type payload caps at decode: a payload may reach its type's cap and no + * further, whether it inflates from a few bytes on the wire or arrives + * uncompressed (as a reassembled fragment stream does). + */ +class PacketPayloadLimitsTest { + + private val senderID = ByteArray(8) { (it + 1).toByte() } + private val fixedTimestamp = 1709600000000uL + + private val cappedTypes: List = listOf( + MessageType.MESSAGE.value, + MessageType.FRAGMENT.value, + MessageType.ANNOUNCE.value, + MessageType.LEAVE.value, + MessageType.NOISE_HANDSHAKE.value, + MessageType.REQUEST_SYNC.value, + MessageType.VOICE_FRAME.value, + 0x04u, // courier envelope (iOS only) + 0x23u, // board post (iOS only) + 0x25u, // group message (iOS only) + 0x28u, // Nostr carrier (iOS only) + 0x7Fu // unassigned: future types keep a v1 frame's worth + ) + + @Test + fun `caps match the iOS table`() { + assertEquals(128 * 1024, PacketPayloadLimits.maxPayloadBytes(MessageType.MESSAGE.value)) + assertEquals(1_280, PacketPayloadLimits.maxPayloadBytes(MessageType.FRAGMENT.value)) + assertEquals(4 * 1024, PacketPayloadLimits.maxPayloadBytes(MessageType.ANNOUNCE.value)) + assertEquals(8 * 1024, PacketPayloadLimits.maxPayloadBytes(MessageType.REQUEST_SYNC.value)) + assertEquals(64 * 1024, PacketPayloadLimits.maxPayloadBytes(0x04u)) + assertEquals(64 * 1024, PacketPayloadLimits.maxPayloadBytes(0x28u)) + assertEquals(4 * 1024, PacketPayloadLimits.maxPayloadBytes(0x26u)) + assertEquals(128 * 1024, PacketPayloadLimits.maxPayloadBytes(0x7Fu)) + } + + @Test + fun `media types keep the framed-file ceiling`() { + val max = AppConstants.Protocol.MAX_PAYLOAD_LENGTH + assertEquals(max, PacketPayloadLimits.maxPayloadBytes(MessageType.FILE_TRANSFER.value)) + assertEquals(max, PacketPayloadLimits.maxPayloadBytes(MessageType.NOISE_ENCRYPTED.value)) + } + + @Test + fun `compressed payload inflates to its type cap and no further`() { + for (type in cappedTypes) { + val cap = PacketPayloadLimits.maxPayloadBytes(type) + assertTrue(cap < AppConstants.Protocol.MAX_PAYLOAD_LENGTH) + + assertEquals( + "type $type at cap", + cap, + decodeCompressed(type, originalSize = cap)?.payload?.size + ) + var calls = 0 + val pastCap = BinaryProtocol.decodeForTesting(compressedFrame(type, cap + 1)) { _, size -> + calls += 1 + ByteArray(size) + } + assertNull("type $type past cap", pastCap) + assertEquals("type $type past cap must not reach the decompressor", 0, calls) + } + } + + @Test + fun `uncompressed payload reaches its type cap and no further`() { + for (type in cappedTypes) { + val cap = PacketPayloadLimits.maxPayloadBytes(type) + + val atCap = BinaryProtocol.decode(uncompressedFrame(type, cap)) + assertNotNull("type $type at cap", atCap) + assertEquals(cap, atCap!!.payload.size) + + assertNull("type $type past cap", BinaryProtocol.decode(uncompressedFrame(type, cap + 1))) + } + } + + @Test + fun `media payload above every control cap still decodes`() { + val size = 256 * 1024 + for (type in listOf(MessageType.FILE_TRANSFER.value, MessageType.NOISE_ENCRYPTED.value)) { + assertEquals(size, BinaryProtocol.decode(uncompressedFrame(type, size))?.payload?.size) + assertEquals(size, decodeCompressed(type, originalSize = size)?.payload?.size) + } + } + + @Test + fun `declared expansion beyond the DEFLATE bound never reaches the decompressor`() { + val compressedSize = 16 + val type = MessageType.FILE_TRANSFER.value + val atBound = compressedSize * PacketPayloadLimits.MAX_DEFLATE_RATIO + + assertEquals( + atBound, + decodeCompressed(type, originalSize = atBound, compressedSize = compressedSize)?.payload?.size + ) + + var calls = 0 + val pastBound = BinaryProtocol.decodeForTesting( + compressedFrame(type, atBound + 1, compressedSize) + ) { _, size -> + calls += 1 + ByteArray(size) + } + assertNull(pastBound) + assertEquals(0, calls) + } + + private fun decodeCompressed( + type: UByte, + originalSize: Int, + compressedSize: Int = minimumCompressedSize(originalSize) + ): BitchatPacket? = + BinaryProtocol.decodeForTesting(compressedFrame(type, originalSize, compressedSize)) { _, size -> + ByteArray(size) + } + + private fun minimumCompressedSize(originalSize: Int): Int = + (originalSize + PacketPayloadLimits.MAX_DEFLATE_RATIO - 1) / PacketPayloadLimits.MAX_DEFLATE_RATIO + + private fun compressedFrame( + type: UByte, + originalSize: Int, + compressedSize: Int = minimumCompressedSize(originalSize) + ): ByteArray { + val payloadLength = 4 + compressedSize + return ByteBuffer.allocate(16 + 8 + payloadLength).apply { + order(ByteOrder.BIG_ENDIAN) + put(2.toByte()) + put(type.toByte()) + put(5.toByte()) + putLong(fixedTimestamp.toLong()) + put(BinaryProtocol.Flags.IS_COMPRESSED.toByte()) + putInt(payloadLength) + put(senderID) + putInt(originalSize) + put(ByteArray(compressedSize) { 0x55 }) + }.array() + } + + private fun uncompressedFrame(type: UByte, payloadSize: Int): ByteArray = + ByteBuffer.allocate(16 + 8 + payloadSize).apply { + order(ByteOrder.BIG_ENDIAN) + put(2.toByte()) + put(type.toByte()) + put(5.toByte()) + putLong(fixedTimestamp.toLong()) + put(0.toByte()) + putInt(payloadSize) + put(senderID) + put(ByteArray(payloadSize) { 0x2A }) + }.array() +} diff --git a/app/src/test/kotlin/com/bitchat/android/contracts/PacketPayloadLimitsWireContractTest.kt b/app/src/test/kotlin/com/bitchat/android/contracts/PacketPayloadLimitsWireContractTest.kt new file mode 100644 index 000000000..f07b42f65 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/contracts/PacketPayloadLimitsWireContractTest.kt @@ -0,0 +1,107 @@ +package com.bitchat.android.contracts + +import com.bitchat.android.protocol.BinaryProtocol +import com.bitchat.android.protocol.CompressionUtil +import com.bitchat.android.protocol.MessageType +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Golden vectors for the per-type payload admission rules in + * docs/PACKET_PAYLOAD_LIMITS.md. + * + * Every frame is literal wire bytes with a real raw-DEFLATE body (zlib level 9, + * window bits -15, over an all-zero payload), decoded through the production + * decompressor. Sender 1011121314151617, timestamp 0x0102030405060708, TTL 7, + * flags IS_COMPRESSED, no padding. + */ +class PacketPayloadLimitsWireContractTest { + + @Test + fun `v1 announce inflating to exactly 4 KiB is accepted`() { + val packet = BinaryProtocol.decode(hex(ANNOUNCE_4096)) + + assertNotNull(packet) + assertEquals(MessageType.ANNOUNCE.value, packet!!.type) + assertArrayEquals(ByteArray(4096), packet.payload) + } + + @Test + fun `v1 announce inflating one byte past 4 KiB is rejected`() { + assertNull(BinaryProtocol.decode(hex(ANNOUNCE_4097))) + } + + @Test + fun `v2 public message inflating to exactly 128 KiB is accepted`() { + // 143 deflate bytes for 131,072 bytes (~917:1): a dense real stream stays + // inside DEFLATE's 1032:1 bound. + val packet = BinaryProtocol.decode(hex(MESSAGE_131072)) + + assertNotNull(packet) + assertEquals(MessageType.MESSAGE.value, packet!!.type) + assertArrayEquals(ByteArray(131_072), packet.payload) + } + + @Test + fun `v2 public message inflating one byte past 128 KiB is rejected`() { + assertNull(BinaryProtocol.decode(hex(MESSAGE_131073))) + } + + @Test + fun `file transfer keeps the framed-file ceiling for the same body`() { + // Byte-identical to MESSAGE_131073 except the type byte (0x22). + val packet = BinaryProtocol.decode(hex(FILE_TRANSFER_131073)) + + assertNotNull(packet) + assertEquals(MessageType.FILE_TRANSFER.value, packet!!.type) + assertArrayEquals(ByteArray(131_073), packet.payload) + } + + @Test + fun `declaration beyond 1032 to 1 is rejected before inflating`() { + // Two deflate bytes (an empty final fixed-Huffman block) declaring 2,065 bytes: + // one past 2 x 1032. No stream can produce that, so the decoder must not try. + var calls = 0 + val packet = BinaryProtocol.decodeForTesting(hex(FILE_TRANSFER_RATIO_2065)) { data, size -> + calls += 1 + CompressionUtil.decompress(data, size) + } + + assertNull(packet) + assertEquals(0, calls) + } + + private fun hex(value: String): ByteArray = + value.chunked(2).map { it.toInt(16).toByte() }.toByteArray() + + private companion object { + const val ANNOUNCE_4096 = + "0101070102030405060708040016101112131415161710" + + "00edc1010d000000c2a0f74f6d0f0714000000f06e" + const val ANNOUNCE_4097 = + "0101070102030405060708040016101112131415161710" + + "01edc1010d000000c2a0f74f6d0f0714000000706f" + + // Header, sender, 4-byte original size, then the 143-byte deflate body. + val MESSAGE_131072 = + "020207010203040506070804000000931011121314151617" + + "00020000" + + "edc13101000000c2a0f54fed610da0" + "00".repeat(127) + "6e" + val MESSAGE_131073 = + "020207010203040506070804000000931011121314151617" + + "00020001" + + "edc13101000000c2a0f54fed630ca0" + "00".repeat(127) + "6e" + val FILE_TRANSFER_131073 = + "022207010203040506070804000000931011121314151617" + + "00020001" + + "edc13101000000c2a0f54fed630ca0" + "00".repeat(127) + "6e" + + const val FILE_TRANSFER_RATIO_2065 = + "022207010203040506070804000000061011121314151617" + + "00000811" + + "0300" + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/features/file/FileUtilsClearAllMediaTest.kt b/app/src/test/kotlin/com/bitchat/android/features/file/FileUtilsClearAllMediaTest.kt new file mode 100644 index 000000000..7ad7b8a7f --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/features/file/FileUtilsClearAllMediaTest.kt @@ -0,0 +1,50 @@ +package com.bitchat.android.features.file + +import android.os.Build +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +/** + * The panic wipe only reports success when every stage confirms; media deletion + * must therefore say when a directory survived. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class FileUtilsClearAllMediaTest { + + private val context = RuntimeEnvironment.getApplication() + private val imagesDir = File(context.filesDir, "images") + + @After + fun tearDown() { + imagesDir.setWritable(true) + imagesDir.deleteRecursively() + } + + @Test + fun `reports success once media directories are gone`() { + val incoming = File(imagesDir, "incoming").apply { mkdirs() } + File(incoming, "synthetic.jpg").writeBytes(byteArrayOf(1, 2, 3)) + + assertTrue(FileUtils.clearAllMedia(context)) + assertFalse(incoming.exists()) + } + + @Test + fun `reports failure when a media directory survives`() { + val incoming = File(imagesDir, "incoming").apply { mkdirs() } + File(incoming, "synthetic.jpg").writeBytes(byteArrayOf(1, 2, 3)) + // A read-only parent keeps its children from being unlinked. + assumeTrue(imagesDir.setWritable(false) && !imagesDir.canWrite()) + + assertFalse(FileUtils.clearAllMedia(context)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt index 81dda867a..4cb5e6461 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrDirectMessageHandlerTest.kt @@ -28,6 +28,8 @@ import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.any import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment @@ -106,8 +108,7 @@ class NostrDirectMessageHandlerTest { content = requireNotNull( NostrEmbeddedBitChat.encodePMForNostrNoRecipient( content = "first", - messageID = firstId, - senderPeerID = "0011223344556677" + messageID = firstId ) ), sender = sender, @@ -119,8 +120,7 @@ class NostrDirectMessageHandlerTest { content = requireNotNull( NostrEmbeddedBitChat.encodePMForNostrNoRecipient( content = "second", - messageID = secondId, - senderPeerID = "0011223344556677" + messageID = secondId ) ), sender = sender, @@ -140,6 +140,59 @@ class NostrDirectMessageHandlerTest { assertEquals(secondRumorTime * 1000L, messages[1].timestamp.time) } + @Test + fun `blocked geohash sender gets no delivery ack`() { + val application = RuntimeEnvironment.getApplication() + val state = ChatState(scope).apply { setNickname("recipient") } + val dataManager = DataManager(application) + val privateChatManager = PrivateChatManager( + state = state, + messageManager = MessageManager(state), + dataManager = dataManager, + noiseSessionDelegate = mock() + ) + val seenStore = mock() + val handler = NostrDirectMessageHandler( + application = application, + state = state, + privateChatManager = privateChatManager, + updateDeliveryStatus = { _, _ -> }, + scope = scope, + repo = GeohashRepository(application, state, dataManager, scope), + dataManager = dataManager, + seenStoreProvider = { seenStore } + ) + val sender = NostrIdentity.generate() + val recipient = NostrIdentity.generate() + dataManager.addGeohashBlockedUser(sender.publicKeyHex.lowercase()) + val now = (System.currentTimeMillis() / 1000).toInt() + + val giftWrap = privateMessageGiftWrap( + content = requireNotNull( + NostrEmbeddedBitChat.encodePMForNostrNoRecipient( + content = "hello", + messageID = "blocked-message" + ) + ), + sender = sender, + recipient = recipient, + rumorCreatedAt = now - 60, + giftWrapCreatedAt = now - 5 + ) + + try { + handler.onGiftWrap(giftWrap, "zzzzzz", recipient) + + // The handler runs on an unconfined scope and drops a blocked sender before + // its first suspension, so nothing can still be in flight here. + assertEquals(emptyList(), state.getPrivateChatsValue().values.flatten().map { it.id }) + verify(seenStore, never()).hasDelivered(any()) + verify(seenStore, never()).markDelivered(any()) + } finally { + dataManager.removeGeohashBlockedUser(sender.publicKeyHex.lowercase()) + } + } + private fun waitForMessage(state: ChatState, messageId: String) { kotlinx.coroutines.runBlocking { withTimeout(5_000) { diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrEmbeddedBitChatTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrEmbeddedBitChatTest.kt new file mode 100644 index 000000000..dd2c5da4d --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrEmbeddedBitChatTest.kt @@ -0,0 +1,78 @@ +package com.bitchat.android.nostr + +import android.os.Build +import com.bitchat.android.model.NoisePayload +import com.bitchat.android.model.NoisePayloadType +import com.bitchat.android.model.PrivateMessagePacket +import com.bitchat.android.protocol.BitchatPacket +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Base64 + +/** + * Geohash (recipient-less) envelopes must never carry the mesh peer ID: it is the + * stable Noise fingerprint prefix, so it would link a geohash persona to the device. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class NostrEmbeddedBitChatTest { + + @Test + fun `geohash private message carries a fresh random sender ID`() { + val first = decode(NostrEmbeddedBitChat.encodePMForNostrNoRecipient("hi", "m1")) + val second = decode(NostrEmbeddedBitChat.encodePMForNostrNoRecipient("hi", "m1")) + + assertEquals(8, first.senderID.size) + assertFalse( + "Each envelope must use its own sender ID", + first.senderID.contentEquals(second.senderID) + ) + assertNull(first.recipientID) + + val payload = NoisePayload.decode(first.payload)!! + assertEquals(NoisePayloadType.PRIVATE_MESSAGE, payload.type) + val pm = PrivateMessagePacket.decode(payload.data)!! + assertEquals("m1", pm.messageID) + assertEquals("hi", pm.content) + } + + @Test + fun `geohash acks carry a fresh random sender ID`() { + for (type in listOf(NoisePayloadType.DELIVERED, NoisePayloadType.READ_RECEIPT)) { + val first = decode(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type, "m1")) + val second = decode(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type, "m1")) + + assertEquals(8, first.senderID.size) + assertFalse(first.senderID.contentEquals(second.senderID)) + assertNull(first.recipientID) + val payload = NoisePayload.decode(first.payload)!! + assertEquals(type, payload.type) + assertEquals("m1", String(payload.data, Charsets.UTF_8)) + } + } + + @Test + fun `favorites envelope still carries the given sender ID`() { + val senderPeerID = "0011223344556677" + val packet = decode( + NostrEmbeddedBitChat.encodePMForNostr( + content = "hi", + messageID = "m1", + recipientPeerID = "8899aabbccddeeff", + senderPeerID = senderPeerID + ) + ) + + assertEquals(senderPeerID, packet.senderID.joinToString("") { "%02x".format(it) }) + } + + private fun decode(embedded: String?): BitchatPacket { + val content = requireNotNull(embedded).removePrefix("bitchat1:") + return requireNotNull(BitchatPacket.fromBinaryData(Base64.getUrlDecoder().decode(content))) + } +} diff --git a/docs/PACKET_PAYLOAD_LIMITS.md b/docs/PACKET_PAYLOAD_LIMITS.md new file mode 100644 index 000000000..f1f1200bc --- /dev/null +++ b/docs/PACKET_PAYLOAD_LIMITS.md @@ -0,0 +1,65 @@ +# Packet payload limits + +Receive-side admission rules for the decoded payload of a mesh packet. They +match iOS `PacketPayloadLimits` (bitchat iOS #1719). Senders and the wire +format are unchanged; these rules only decide which frames a receiver decodes. + +Implementation: `app/src/main/java/com/bitchat/android/protocol/PacketPayloadLimits.kt`, +enforced in `BinaryProtocol.decodeCore`. + +## Why per type + +A compressed frame declares its original size, and raw DEFLATE lets about 1 KB +on air stand for about 1 MB decoded. Fragment reassembly lets a large +uncompressed frame arrive as a stream of small fragments. Accepted packets are +held at their decoded size, relayed, and re-served by gossip sync, so a single +frame-wide ceiling turns airtime into memory on every node. + +## Rules + +1. The decoded payload of a packet of type `T` may be at most `cap(T)` bytes, + whether it arrived compressed or not. +2. For a compressed payload, the declared original size is checked against + `cap(T)` before any output buffer is allocated. +3. For a compressed payload, a declared original size greater than + `1032 × compressedBytes` is rejected before inflating. 1032:1 is raw + DEFLATE's hard expansion ceiling (every 258-byte match costs at least two + bits), so no real stream can exceed it. +4. A frame that breaks a rule fails to decode and is dropped like any other + malformed frame: it is not relayed or stored for sync. + +## Caps + +Caps are at least twice the largest payload any shipping iOS or Android encoder +produces for the type. Only media-carrying types keep the framed-file ceiling. + +| Type | Value | Cap | +|---|---|---| +| `FILE_TRANSFER`, `NOISE_ENCRYPTED` | `0x22`, `0x11` | `MAX_PAYLOAD_LENGTH` (10 MiB) | +| `MESSAGE`, group message | `0x02`, `0x25` | 128 KiB | +| courier envelope, Nostr carrier | `0x04`, `0x28` | 64 KiB | +| `REQUEST_SYNC` | `0x21` | 8 KiB | +| `FRAGMENT` | `0x20` | 1,280 B | +| `ANNOUNCE`, `LEAVE`, `NOISE_HANDSHAKE`, `VOICE_FRAME`, board post, prekey bundle, ping, pong | `0x01`, `0x03`, `0x10`, `0x29`, `0x23`, `0x24`, `0x26`, `0x27` | 4 KiB | +| Any other value | | 128 KiB (a v1 frame stays relayable) | + +Types marked by name only (group message, courier envelope, Nostr carrier, +board post, prekey bundle, ping, pong) are iOS-only today; Android caps them so +it can still relay them safely. + +A new type that needs more than a v1 frame (65,535 bytes) must be added to this +table, on both platforms, before it ships, or current receivers drop it at +decode. + +## Not covered here + +iOS also bounds fragment reassembly by the frame its claimed type can fill and +gives the gossip stores byte budgets. Android has not ported those yet. + +## Tests + +- `PacketPayloadLimitsWireContractTest`: literal frames with real DEFLATE + bodies at and one byte past the announce and message caps, the media + exemption, and the 1032:1 rejection. +- `PacketPayloadLimitsTest`: every capped type at and past its cap, compressed + and uncompressed. diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index f93cd1025..65f6c0087 100644 --- a/docs/client-rewrite-contracts.md +++ b/docs/client-rewrite-contracts.md @@ -12,7 +12,7 @@ The remaining implementation work and milestone progress are tracked in | Layer | Compatibility promise | Primary tests | |---|---|---| -| Outer mesh packet | v1/v2 header widths, big-endian fields, flags, section order, route placement, signature placement, padding, compression, signing bytes | `BinaryProtocolTest`, `ClientRewriteWireContractTest` | +| Outer mesh packet | v1/v2 header widths, big-endian fields, flags, section order, route placement, signature placement, padding, compression, signing bytes, per-type payload caps ([PACKET_PAYLOAD_LIMITS.md](PACKET_PAYLOAD_LIMITS.md)) | `BinaryProtocolTest`, `ClientRewriteWireContractTest`, `PacketPayloadLimitsWireContractTest` | | Chat payload | Flag bits, millisecond timestamp, UTF-8 byte lengths, encrypted-content substitution, optional-field order | `ClientRewriteWireContractTest` | | Inner payloads | Noise type bytes, private-message TLVs, peer-state TLVs, file-transfer TLVs, live-voice bursts, fragment header, sync request TLVs | `ClientRewriteWireContractTest`, `AuthenticatedPeerStateTest`, `PrivateMediaTransferPreparerTest`, `VoiceBurstPacketTest`, `FragmentManagerTest` | | Identity/security | Announcement extensions, capability bitfield endianness, Noise static-key binding, handshake identity binding, signatures | `IdentityAnnouncementTest`, `NoiseSessionManagerIdentityBindingTest`, `ClientRewritePrimitiveContractTest` | @@ -36,6 +36,12 @@ randomization used by senders. Android caps outbound seal and gift-wrap randomization at 22h, leaving 2 hours of slack inside iOS's 24-hour subscription window, while retaining its 48-hour receive lookback. +Geohash (recipient-less) `bitchat1:` envelopes and their acks carry 8 fresh +random bytes in the embedded packet's sender field, never the mesh peer ID, +which would link a geohash identity to the device. Receivers key these +conversations on the Nostr sender pubkey and must not read the field +(`NostrEmbeddedBitChatTest`). + Bech32 byte decoding must reject nonzero discarded bits and a residual whole five-bit symbol. A valid checksum alone does not make byte padding canonical. Human-readable prefixes contain 1–83 characters; Nostr's extended data payloads