mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-11-08 08:01:09 +00:00
Merge pull request #996 from permissionlesstech/claude/ios-features-android-port-ebe483
Port iOS geohash DM, payload cap, and panic confirm fixes
This commit is contained in:
commit
97db392d2a
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
*/
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<BitchatMessage?>(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) },
|
||||
|
||||
@ -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<Application>().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<Application>().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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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?) {
|
||||
|
||||
@ -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) {}
|
||||
|
||||
@ -136,6 +136,13 @@
|
||||
<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>
|
||||
|
||||
<!-- Panic wipe confirmation and outcome -->
|
||||
<string name="panic_confirm_title" tools:ignore="MissingTranslation">Wipe all data?</string>
|
||||
<string name="panic_confirm_body" tools:ignore="MissingTranslation">This permanently deletes your identity, keys, messages, and media on this device and creates a new identity. It can\'t be undone.</string>
|
||||
<string name="panic_confirm_action" tools:ignore="MissingTranslation">Wipe</string>
|
||||
<string name="system_panic_completed" tools:ignore="MissingTranslation">all data wiped — new identity created</string>
|
||||
<string name="system_panic_incomplete" tools:ignore="MissingTranslation">wipe incomplete — some data may remain. triple-tap the logo to try again.</string>
|
||||
|
||||
<!-- File viewer dialog -->
|
||||
<string name="file_viewer_title">📎 File Received</string>
|
||||
<string name="file_viewer_name">📄 %1$s</string>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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<UByte> = 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()
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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<NoiseSessionDelegate>()
|
||||
)
|
||||
val seenStore = mock<SeenMessageStore>()
|
||||
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<String>(), 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) {
|
||||
|
||||
@ -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)))
|
||||
}
|
||||
}
|
||||
65
docs/PACKET_PAYLOAD_LIMITS.md
Normal file
65
docs/PACKET_PAYLOAD_LIMITS.md
Normal file
@ -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.
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user