What changed, and why it matters
This commit fixes Krux's QR code scanner so that when it reads a series of animated BBQr codes, every later frame must match the encoding and file type announced by the first frame, must agree on the total number of frames, and cannot overwrite an already-scanned frame with different data. It also caps how much data the scanner will accumulate, preventing a malicious or malformed stream from making the device run out of memory. The change is defensive hardening against QR stream confusion and memory exhaustion.
Review whether the 160 KB cap is appropriate for all supported PSBT sizes and whether the parser should also reject parts whose index exceeds the declared total. Consider adding a changelog or security note describing the hardening, and verify the fix is included in the next release.
Security signals we found
Input validation added for multi-part BBQr streams
Memory exhaustion mitigation via accumulated payload cap
Anti-splicing: parts must agree with first part's encoding and file type
Anti-tampering: duplicate index must contain identical content
Total-count consistency enforced across parts
Evidence from the diff
The patch hardens QRPartParser for the BBQr format. Previously, individual BBQr parts were not bound to the first part’s header, so an attacker or mis-scan could splice parts from different streams, swap a part’s content at a given index, or announce an arbitrarily large total and keep the parser allocating payload chunks. The fix adds validation in qr.py: encoding/file-type bytes must match self.bbqr, the total must be consistent, duplicate indices must contain identical data, and a running payload_len is bounded by BBQR_MAX_PAYLOAD_LEN (160 KB, derived from 100 KB decompression limit times 8/5 base32 expansion). Tests cover header mismatch, total mismatch, conflicting part at the same index, and oversized payload.
Changed components
src/krux/qr.py:QRPartParser.parse()src/krux/bbqr.py:BBQR_MAX_PAYLOAD_LEN constantInspect captured patch +86 / −1
diff --git a/src/krux/bbqr.py b/src/krux/bbqr.py
index 71918ca..6f9fb7b 100644
--- a/src/krux/bbqr.py
+++ b/src/krux/bbqr.py
@@ -35,6 +35,15 @@ KNOWN_FILETYPES = {"P", "T", "J", "U"}
BBQR_ALWAYS_COMPRESS_THRESHOLD = 5000 # bytes
+# Upper bound for the accumulated payload of an animated BBQr, in chars.
+# The header encodes the part total as 2 base36 chars, so a crafted stream may
+# announce up to 1295 parts and keep the parser accumulating them. A part count
+# limit can't be used here: small screens generate many small parts, so a few KB
+# PSBT already takes more than a hundred of them. Bound the total instead, at the
+# base32 expansion (8/5) of the 100 KB decompression limit. Anything above it
+# could not be decoded anyway.
+BBQR_MAX_PAYLOAD_LEN = 160 * 1024
+
class BBQrCode:
"""A BBQr code, containing the data, encoding, and file type"""
diff --git a/src/krux/qr.py b/src/krux/qr.py
index ba1b64c..02a8133 100644
--- a/src/krux/qr.py
+++ b/src/krux/qr.py
@@ -131,6 +131,7 @@ class QRPartParser:
def __init__(self):
self.parts = {}
+ self.payload_len = 0
self.total = -1
self.format = None
self.decoder = None
@@ -188,9 +189,23 @@ class QRPartParser:
):
raise ValueError("Failed to decode UR")
elif self.format == FORMAT_BBQR:
- from .bbqr import parse_bbqr
+ from .bbqr import parse_bbqr, BBQR_MAX_PAYLOAD_LEN
part, index, total = parse_bbqr(data)
+ # Only the first part is passed to detect_format, and its encoding and
+ # file type are used to decode all of them. Parts of a BBQr aren't bound
+ # to each other by any checksum, so reject the ones that disagree with
+ # the first instead of splicing different streams into a corrupt result.
+ if data[2] != self.bbqr.encoding or data[3] != self.bbqr.file_type:
+ raise ValueError("BBQr header mismatch")
+ if self.total not in (-1, total):
+ raise ValueError("BBQr part total mismatch")
+ if self.parts.get(index, part) != part:
+ raise ValueError("Conflicting BBQr part")
+ if index not in self.parts:
+ self.payload_len += len(part)
+ if self.payload_len > BBQR_MAX_PAYLOAD_LEN:
+ raise ValueError("BBQr payload too big")
self.parts[index] = part
self.total = total
return index
diff --git a/tests/test_qr.py b/tests/test_qr.py
index ccff2fa..7371f31 100644
--- a/tests/test_qr.py
+++ b/tests/test_qr.py
@@ -279,6 +279,67 @@ def test_parse_pmofn_rejects_invalid_index(m5stickv):
parse_pmofn_qr_part("p4of3 data")
+def test_parser_rejects_bbqr_header_mismatch(m5stickv):
+ """Parts must agree with the encoding and file type of the first part,
+ which is the one detect_format used to set up decoding"""
+ from krux.qr import QRPartParser
+
+ parser = QRPartParser()
+ parser.parse("B$HP0200414243")
+
+ with pytest.raises(ValueError, match="BBQr header mismatch"):
+ parser.parse("B$ZU0201444546")
+
+ assert parser.parts == {0: "414243"}
+
+
+def test_parser_rejects_bbqr_total_mismatch(m5stickv):
+ """A part announcing a different total belongs to another stream"""
+ from krux.qr import QRPartParser
+
+ parser = QRPartParser()
+ parser.parse("B$2P0300AAAAAAAA")
+
+ with pytest.raises(ValueError, match="BBQr part total mismatch"):
+ parser.parse("B$2P0100MZXW6YTB")
+
+ assert parser.total == 3
+ assert not parser.is_complete()
+
+
+def test_parser_rejects_conflicting_bbqr_part(m5stickv):
+ """The same index can be re-scanned, but not with different content"""
+ from krux.qr import QRPartParser
+
+ parser = QRPartParser()
+ parser.parse("B$2P0200AAAAAAAA")
+ parser.parse("B$2P0200AAAAAAAA") # redundant scan of the same part is fine
+
+ with pytest.raises(ValueError, match="Conflicting BBQr part"):
+ parser.parse("B$2P0200MZXW6YTB")
+
+ assert parser.parts == {0: "AAAAAAAA"}
+
+
+def test_parser_rejects_oversized_bbqr_payload(m5stickv):
+ """Accumulated payload is bounded, a stream above it could not be decoded"""
+ from krux.qr import QRPartParser
+ from krux.bbqr import BBQR_MAX_PAYLOAD_LEN, int2base36
+
+ parser = QRPartParser()
+ part_size = 8192
+ parts = BBQR_MAX_PAYLOAD_LEN // part_size + 1
+ for index in range(parts - 1):
+ parser.parse(
+ "B$2P%s%s%s" % (int2base36(parts), int2base36(index), "A" * part_size)
+ )
+
+ with pytest.raises(ValueError, match="BBQr payload too big"):
+ parser.parse(
+ "B$2P%s%s%s" % (int2base36(parts), int2base36(parts - 1), "A" * part_size)
+ )
+
+
def test_detect_format_propagates_base_exceptions(mocker, m5stickv):
"""detect_format catches genuine parsing errors (returns FORMAT_NONE) but
must NOT swallow BaseException-level signals like KeyboardInterrupt.
Why this scored 66/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.