Merge e5d9ccf2f9992e8fdadd79f09fa3df9cc9c0f2df into 1f24973b06ee77492742ba522002e028f38f4381

This commit is contained in:
Taksh Kothari 2026-11-08 11:45:00 +05:30 committed by GitHub
commit b3e2fb9999
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 89 additions and 13 deletions

View File

@ -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<Pair<CborValue, CborValue>>(argument.coerceAtMost(64).toInt())
val textKeys = HashSet<String>()
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)
}

View File

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