diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt index 4125d5ca..08825adf 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrDirectMessageHandler.kt @@ -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 - } - } } diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedPacketDecoder.kt b/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedPacketDecoder.kt new file mode 100644 index 00000000..0fabe52b --- /dev/null +++ b/app/src/main/java/com/bitchat/android/nostr/NostrEmbeddedPacketDecoder.kt @@ -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 + } + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrEmbeddedPacketDecoderTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrEmbeddedPacketDecoderTest.kt new file mode 100644 index 00000000..d524ae41 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrEmbeddedPacketDecoderTest.kt @@ -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)) + } +}