From 6bfe7c62cfd9e714548e3ebfd6e0ebffe60d870c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 23 Jul 2026 12:21:39 -0400 Subject: [PATCH] fix(scanner): guard against out-of-range currency byte in scan frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A foreign or corrupt Kik code can pass error-correction yet carry a currency byte outside CurrencyCode's range. PayloadKind.Payment.decode indexed CurrencyCode.entries with that untrusted 0-255 byte, so a frame with byte[1] = 232 threw IndexOutOfBoundsException against the 171-entry enum. Because the scanner decodes every camera frame, this stormed Bugsnag (~every 50ms) and silently broke scanning for affected users. PayloadKind.decode is now nullable: Payment.decode returns null when the currency index is out of range instead of throwing, and OpenCodePayload.fromList maps a null decode to Empty (Unknown), which the scanner ignores — no crash and no bogus grab attempt. Adds regression + boundary tests. Bugsnag: 6a5a4523e96556123eba0d68 Signed-off-by: Brandon McAnsh --- .../opencode/model/core/OpenCodePayload.kt | 8 +++++- .../opencode/model/core/PayloadKind.kt | 14 +++++++--- .../model/core/OpenCodePayloadTest.kt | 28 +++++++++++++++++++ 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/OpenCodePayload.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/OpenCodePayload.kt index 2e251ced6..7416e1d38 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/OpenCodePayload.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/OpenCodePayload.kt @@ -53,7 +53,13 @@ data class OpenCodePayload( } val kind = PayloadKind.from(frame[0].toInt()) - return OpenCodePayload(kind, kind.decode(frame), kind.decodeNonce(frame)) + + // `decode` returns null for a frame this kind can't parse — e.g. a foreign or corrupt + // Kik code whose currency byte is out of range (Bugsnag 6a5a4523: + // IndexOutOfBoundsException on CurrencyCode.entries[232], a 171-entry enum). Treat it + // as Empty so the scanner ignores it instead of crashing or attempting a bogus grab. + val value = kind.decode(frame) ?: return Empty + return OpenCodePayload(kind, value, kind.decodeNonce(frame)) } } } diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadKind.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadKind.kt index b81f1ca01..14725b9cd 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadKind.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/core/PayloadKind.kt @@ -19,8 +19,11 @@ sealed interface PayloadKind { /** Encodes [value] (and [nonce], where applicable) into the fixed-length scan frame. */ fun encode(value: PayloadValue, nonce: List): List - /** Reads this kind's [PayloadValue] out of a (zero-padded) scan [frame]. */ - fun decode(frame: List): PayloadValue + /** + * Reads this kind's [PayloadValue] out of a (zero-padded) scan [frame], or null if the frame + * can't be parsed as this kind (e.g. a cash frame whose currency byte is out of range). + */ + fun decode(frame: List): PayloadValue? /** Reads the nonce out of a scan [frame]; empty for kinds that carry none. */ fun decodeNonce(frame: List): List @@ -45,8 +48,11 @@ sealed interface PayloadKind { return data } - override fun decode(frame: List): PayloadValue { - val currency = CurrencyCode.entries[frame[1].byteToUnsignedInt()] + override fun decode(frame: List): PayloadValue? { + // Byte 1 is an index into CurrencyCode. A foreign or corrupt Kik code can carry a byte + // outside that range; such a frame isn't a valid cash code, so fail decoding rather + // than throw (Bugsnag 6a5a4523: IndexOutOfBoundsException on a 171-entry enum). + val currency = CurrencyCode.entries.getOrNull(frame[1].byteToUnsignedInt()) ?: return null val quarks = frame.subList(OpenCodePayload.OFFSET_QUARKS, OpenCodePayload.OFFSET_NONCE) .toByteArray().byteArrayToLong() return Fiat(currencyCode = currency, quarks = quarks) diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/OpenCodePayloadTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/OpenCodePayloadTest.kt index 75f3f2db0..50cc30427 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/OpenCodePayloadTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/core/OpenCodePayloadTest.kt @@ -92,6 +92,34 @@ class OpenCodePayloadTest { assertIs(decoded.value) } + @Test + fun `fromList does not throw on out-of-range currency byte`() { + // A foreign / corrupt Kik code can pass error-correction yet carry a currency byte + // outside CurrencyCode's range. Regression for IndexOutOfBoundsException (Bugsnag + // 6a5a4523): `CurrencyCode.entries[232]` on a 171-element enum. Such a frame is not a + // valid Flipcash cash code, so it must decode to Unknown (which the scanner ignores). + val frame = MutableList(OpenCodePayload.LENGTH) { 0 } + frame[0] = PayloadKind.Cash.value.toByte() + frame[1] = 232.toByte() // >= CurrencyCode.entries.size + + val decoded = OpenCodePayload.fromList(frame) + + assertEquals(PayloadKind.Unknown, decoded.kind) + } + + @Test + fun `fromList decodes highest valid currency index`() { + // Boundary: the last valid ordinal must still decode as Cash, not be rejected. + val lastCurrency = CurrencyCode.entries.last() + val fiat = Fiat(quarks = 7L, currencyCode = lastCurrency) + val encoded = encodePayload(PayloadKind.Cash, fiat, List(10) { 0.toByte() }) + + val decoded = OpenCodePayload.fromList(encoded) + + assertEquals(PayloadKind.Cash, decoded.kind) + assertEquals(lastCurrency, decoded.fiat!!.currencyCode) + } + @Test fun `fromList preserves nonce bytes`() { val nonce = List(10) { (it * 3).toByte() }