Bound Nostr embedded BitChat packet decode before allocating

iOS rejects oversized bitchat1 payloads before Base64 decode. Android
decoded first and could be forced into an arbitrarily large allocation
by a malicious gift wrap. Cap encoding length and decoded size at the
protocol payload ceiling.
This commit is contained in:
Taksh 2026-09-04 22:12:22 +05:30
parent 81ba775063
commit cc17c00282
3 changed files with 102 additions and 16 deletions

View File

@ -78,7 +78,7 @@ class NostrDirectMessageHandler(
if (!content.startsWith("bitchat1:")) return@launch
val base64Content = content.removePrefix("bitchat1:")
val packetData = base64URLDecode(base64Content) ?: return@launch
val packetData = NostrEmbeddedPacketDecoder.decodeBounded(base64Content) ?: return@launch
val packet = BitchatPacket.fromBinaryData(packetData) ?: return@launch
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
@ -304,19 +304,4 @@ class NostrDirectMessageHandler(
false
}
}
private fun base64URLDecode(input: String): ByteArray? {
return try {
val padded = input.replace("-", "+")
.replace("_", "/")
.let { str ->
val padding = (4 - str.length % 4) % 4
str + "=".repeat(padding)
}
android.util.Base64.decode(padded, android.util.Base64.DEFAULT)
} catch (e: Exception) {
Log.e(TAG, "Failed to decode base64url: ${e.message}")
null
}
}
}

View File

@ -0,0 +1,50 @@
package com.bitchat.android.nostr
import android.util.Base64
import android.util.Log
import com.bitchat.android.util.AppConstants
/**
* Decode a `bitchat1:` base64url payload with an explicit size ceiling.
*
* iOS rejects oversized embedded packets before allocating
* (`NostrInboundPipeline.decodeEmbeddedBitChatPacket`). Without a bound,
* a malicious gift wrap can force an arbitrarily large Base64 decode.
*/
object NostrEmbeddedPacketDecoder {
private const val TAG = "NostrEmbeddedPacketDecoder"
fun decodeBounded(
base64Url: String,
maxBytes: Int = AppConstants.Protocol.MAX_PAYLOAD_LENGTH
): ByteArray? {
if (maxBytes <= 0) return null
// Base64 expands 3 bytes -> 4 chars; reject oversized encodings first.
val maxEncoded = ((maxBytes.toLong() + 2L) / 3L) * 4L
if (base64Url.length.toLong() > maxEncoded) {
Log.w(TAG, "Rejecting embedded packet encoding longer than $maxEncoded chars")
return null
}
val decoded = base64URLDecode(base64Url) ?: return null
if (decoded.size > maxBytes) {
Log.w(TAG, "Rejecting embedded packet of ${decoded.size} bytes (max $maxBytes)")
return null
}
return decoded
}
internal fun base64URLDecode(input: String): ByteArray? {
return try {
val padded = input.replace("-", "+")
.replace("_", "/")
.let { str ->
val padding = (4 - str.length % 4) % 4
str + "=".repeat(padding)
}
Base64.decode(padded, Base64.DEFAULT)
} catch (e: Exception) {
Log.e(TAG, "Failed to decode base64url: ${e.message}")
null
}
}
}

View File

@ -0,0 +1,51 @@
package com.bitchat.android.nostr
import android.util.Base64
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import android.os.Build
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
class NostrEmbeddedPacketDecoderTest {
@Test
fun `decodes a small base64url payload`() {
val raw = byteArrayOf(1, 2, 3, 4)
val encoded = Base64.encodeToString(raw, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
.replace("=", "")
assertArrayEquals(raw, NostrEmbeddedPacketDecoder.decodeBounded(encoded, maxBytes = 64))
}
@Test
fun `rejects an encoding that cannot fit under the byte ceiling`() {
val maxBytes = 16
// 4 chars encode at most 3 bytes; 24 chars would decode to ~18 bytes.
val oversizedEncoding = "A".repeat(24)
assertNull(NostrEmbeddedPacketDecoder.decodeBounded(oversizedEncoding, maxBytes = maxBytes))
}
@Test
fun `rejects a decode that expands past the byte ceiling`() {
val maxBytes = 8
// 12 chars of valid base64url decode to 9 bytes.
val nineBytes = ByteArray(9) { 0x41 }
val encoded = Base64.encodeToString(nineBytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
.replace("=", "")
assertNull(NostrEmbeddedPacketDecoder.decodeBounded(encoded, maxBytes = maxBytes))
}
@Test
fun `accepts a payload that fills the ceiling exactly`() {
val maxBytes = 9
val nineBytes = ByteArray(9) { 0x42 }
val encoded = Base64.encodeToString(nineBytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
.replace("=", "")
assertNotNull(NostrEmbeddedPacketDecoder.decodeBounded(encoded, maxBytes = maxBytes))
}
}