Port iOS geohash DM, payload cap, and panic confirm fixes

- Geohash DMs and their acks embed a random sender ID per envelope
  instead of the mesh peer ID, which linked geohash personas to the
  device (iOS #1718). Favorites envelopes are unchanged.
- Decode enforces per-type payload caps on compressed and uncompressed
  payloads, matching iOS PacketPayloadLimits; media types keep the
  existing ceiling. The compression-ratio guard becomes DEFLATE's
  1032:1 bound (iOS #1719, decoder part only).
- Triple-tapping the logo asks for confirmation before a panic wipe,
  and the outcome (wiped, or incomplete) is posted to the chat
  (iOS #1588).
- Pin that a blocked geohash sender gets no delivery ack.
This commit is contained in:
callebtc 2026-10-25 00:53:53 +02:00
parent d1da97160a
commit 30f911f947
11 changed files with 479 additions and 34 deletions

View File

@ -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)
*/

View File

@ -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

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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) },

View File

@ -1519,6 +1519,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
}
@ -1527,6 +1532,12 @@ 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.
messageManager.addSystemMessage(
getApplication<Application>().getString(com.bitchat.android.R.string.system_panic_completed)
)
Log.w(TAG, "🚨 PANIC MODE COMPLETED - New identity: ${mesh.myPeerID}")
}

View File

@ -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>

View File

@ -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)
}
/**

View File

@ -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()
}

View File

@ -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) {

View File

@ -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)))
}
}