diff --git a/app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt b/app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt index e10948b1d..8d03397df 100644 --- a/app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt +++ b/app/src/main/java/com/bitchat/android/ui/CashuTokenDecoder.kt @@ -4,6 +4,8 @@ import com.google.gson.JsonParser import java.net.URI import java.net.URLEncoder import java.nio.charset.StandardCharsets +import java.nio.ByteBuffer +import java.nio.charset.CodingErrorAction import java.math.BigDecimal import java.util.Base64 import java.util.Currency @@ -68,8 +70,8 @@ object CashuTokenDecoder { val payload = decodeBase64Url(token.substring(6)) ?: return null if (payload.isEmpty()) return null val info = when (token[5]) { - 'A' -> decodeV3(payload) - 'B' -> decodeV4(payload) ?: if (strict) null else TokenInfo('B', null, null, null, null) + 'A' -> decodeV3(payload, strict) + 'B' -> decodeV4(payload, strict) ?: if (strict) null else TokenInfo('B', null, null, null, null) else -> null } ?: return null return if (!strict || (info.amount != null && info.amount > 0)) info else null @@ -117,13 +119,16 @@ object CashuTokenDecoder { private fun decodeBase64Url(input: String): ByteArray? { val normalized = input.replace('-', '+').replace('_', '/').trimEnd('=') + val paddingCount = input.length - normalized.length + if (paddingCount > 2) return null if (normalized.length % 4 == 1) return null + if (paddingCount != 0 && paddingCount != (4 - normalized.length % 4) % 4) return null val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4) return runCatching { Base64.getDecoder().decode(padded) }.getOrNull() } - private fun decodeV3(payload: ByteArray): TokenInfo? = runCatching { - val root = JsonParser.parseString(String(payload, StandardCharsets.UTF_8)).asJsonObject + private fun decodeV3(payload: ByteArray, strict: Boolean): TokenInfo? = runCatching { + val root = JsonParser.parseString(decodeTokenUtf8(payload) ?: return null).asJsonObject val entries = root.getAsJsonArray("token")?.takeIf { it.size() > 0 } ?: return null var total = 0L var sawAmount = false @@ -133,10 +138,14 @@ object CashuTokenDecoder { if (mintHost == null) mintHost = sanitizeHost(entry.get("mint")?.takeIf { it.isJsonPrimitive }?.asString) val proofs = entry.getAsJsonArray("proofs") ?: continue for (proofElement in proofs) { - val amountElement = proofElement.takeIf { it.isJsonObject }?.asJsonObject?.get("amount") ?: continue - if (!amountElement.isJsonPrimitive || !amountElement.asJsonPrimitive.isNumber) continue - val value = runCatching { amountElement.asBigDecimal.longValueExact() }.getOrNull() ?: continue - if (value <= 0 || value > MAX_AMOUNT) continue + val amountElement = proofElement.takeIf { it.isJsonObject }?.asJsonObject?.get("amount") + val value = amountElement?.takeIf { + it.isJsonPrimitive && it.asJsonPrimitive.isNumber + }?.let { runCatching { it.asBigDecimal.longValueExact() }.getOrNull() } + if (value == null || value <= 0 || value > MAX_AMOUNT) { + if (strict) return null + continue + } if (total > MAX_AMOUNT - value) return null total += value sawAmount = true @@ -151,7 +160,7 @@ object CashuTokenDecoder { ) }.getOrNull() - private fun decodeV4(payload: ByteArray): TokenInfo? { + private fun decodeV4(payload: ByteArray, strict: Boolean): TokenInfo? { val root = CborReader(payload).parseComplete() as? CborValue.MapValue ?: return null var total = 0L var sawAmount = false @@ -169,8 +178,11 @@ object CashuTokenDecoder { for (proof in (groupValue as? CborValue.ArrayValue)?.values.orEmpty()) { for ((proofKey, proofValue) in (proof as? CborValue.MapValue)?.pairs.orEmpty()) { if ((proofKey as? CborValue.Text)?.value != "a") continue - val amount = (proofValue as? CborValue.Unsigned)?.value ?: continue - if (amount == 0L || amount > MAX_AMOUNT) continue + val amount = (proofValue as? CborValue.Unsigned)?.value + if (amount == null || amount == 0L || amount > MAX_AMOUNT) { + if (strict) return null + continue + } if (total > MAX_AMOUNT - amount) return null total += amount sawAmount = true @@ -207,6 +219,13 @@ object CashuTokenDecoder { private val TOKEN_REGEX = Regex("""(?i:cashu:(?://)?)?cashu[AB][A-Za-z0-9_+/%=-]{6,}""") } +private fun decodeTokenUtf8(bytes: ByteArray): String? = runCatching { + StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)).toString() +}.getOrNull() + private sealed interface CborValue { data class Unsigned(val value: Long) : CborValue data class Text(val value: String) : CborValue @@ -231,13 +250,17 @@ private class CborReader(private val bytes: ByteArray) { 0 -> CborValue.Unsigned(argument.takeIf { it <= Long.MAX_VALUE }?.toLong() ?: return null) 1 -> CborValue.Opaque 2 -> if (readBytes(argument) != null) CborValue.Opaque else null - 3 -> readBytes(argument)?.toString(StandardCharsets.UTF_8)?.let(CborValue::Text) + 3 -> readBytes(argument)?.let(::decodeTokenUtf8)?.let(CborValue::Text) 4 -> parseContainer(argument, depth) { CborValue.ArrayValue(it) } 5 -> { if (argument > 10_000 || argument > itemBudget / 2) return null val pairs = ArrayList>(argument.coerceAtMost(64).toInt()) + val textKeys = HashSet() repeat(argument.toInt()) { - pairs += (parseValue(depth + 1) ?: return null) to (parseValue(depth + 1) ?: return null) + val key = parseValue(depth + 1) ?: return null + val value = parseValue(depth + 1) ?: return null + if (key is CborValue.Text && !textKeys.add(key.value)) return null + pairs += key to value } CborValue.MapValue(pairs) } diff --git a/app/src/test/java/com/bitchat/android/ui/CashuTokenDecoderTest.kt b/app/src/test/java/com/bitchat/android/ui/CashuTokenDecoderTest.kt index 94caf6244..c8f12ce7d 100644 --- a/app/src/test/java/com/bitchat/android/ui/CashuTokenDecoderTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/CashuTokenDecoderTest.kt @@ -122,6 +122,59 @@ class CashuTokenDecoderTest { private fun v3Token(json: String): String = "cashuA" + base64Url(json.toByteArray(StandardCharsets.UTF_8)) + @Test + fun `strict decoding does not hide invalid proofs behind a valid amount`() { + for (amount in listOf("true", "1.5", "-1", "0", "2100000000000001", "\"1\"")) { + val token = v3Token("""{"token":[{"proofs":[{"amount":1},{"amount":$amount}]}]}""") + assertNull(CashuTokenDecoder.decode(token, strict = true)) + assertEquals(1L, CashuTokenDecoder.decode(token)?.amount) + } + val proof = cborMap("a" to cborText("1")) + val validProof = cborMap("a" to cborUnsigned(1)) + val token = "cashuB" + base64Url(cborMap( + "t" to cborArray(cborMap("p" to cborArray(validProof, proof))) + )) + assertNull(CashuTokenDecoder.decode(token, strict = true)) + } + + @Test + fun `duplicate cbor fields cannot inflate the amount or replace metadata`() { + val validProof = cborMap("a" to cborUnsigned(1)) + val group = cborMap("p" to cborArray(validProof)) + val duplicateProof = cborMap("a" to cborUnsigned(1), "a" to cborUnsigned(2)) + val payloads = listOf( + cborMap("t" to cborArray(group), "t" to cborArray(group)), + cborMap("t" to cborArray(cborMap("p" to cborArray(duplicateProof)))), + cborMap("t" to cborArray(group), "u" to cborText("sat"), "u" to cborText("usd")) + ) + for (payload in payloads) { + val token = "cashuB" + base64Url(payload) + assertNull(CashuTokenDecoder.decode(token, strict = true)) + assertNull(CashuTokenDecoder.decode(token)?.amount) + } + } + + @Test + fun `malformed utf8 is rejected instead of replaced in json and cbor`() { + val json = """{"token":[{"proofs":[{"amount":1}]}],"memo":"""".toByteArray() + + byteArrayOf(0xff.toByte()) + "\"}".toByteArray() + val proof = cborMap("a" to cborUnsigned(1)) + val cbor = cborMap( + "t" to cborArray(cborMap("p" to cborArray(proof))), + "x" to byteArrayOf(0x61, 0xff.toByte()) + ) + assertNull(CashuTokenDecoder.decode("cashuA" + base64Url(json), strict = true)) + assertNull(CashuTokenDecoder.decode("cashuB" + base64Url(cbor), strict = true)) + } + + @Test + fun `base64 padding must have the correct length`() { + val token = v3Token("""{"token":[{"proofs":[{"amount":1}]}]}""") + assertNull(CashuTokenDecoder.decode(token + "===", strict = true)) + val padding = "=".repeat((4 - (token.length - 6) % 4) % 4) + assertEquals(1L, CashuTokenDecoder.decode(token + padding, strict = true)?.amount) + } + private fun validV4Token(): String { val proofs = listOf(1L, 4L, 16L).map { amount -> cborMap(