What changed, and why it matters
This commit refactors how Krux handles animated QR codes in the 'UR' format. It swaps an older decoder API for a newer state-machine API. The visible change is that transient decoding errors are now ignored while scanning, and only terminal errors stop capture. The commit itself is described as a refactor, but the change in error handling could affect security if it hides or delays detection of malformed or malicious QR frames.
Review whether silently ignoring transient decoder errors is safe for all UR types and adversarial inputs. Confirm that the real MaixPy cUR decoder's terminal-state list matches the simulator's _TERMINAL_STATES. Consider adding tests for malformed multi-part URs and ensure checksum failures cannot be bypassed by later frames.
Security signals we found
Error-handling behavior change: transient UR decoder errors are now ignored instead of aborting
Terminal error set is narrow (NO_RESULT, INVALID_CHECKSUM); other decoder error states may be silently dropped
Firmware submodule MaixPy updated, indicating the actual C decoder API changed
No explicit security rationale or CVE reference in commit message or diff
Evidence from the diff
The patch updates simulator/kruxsim/mocks/uUR.py and src/krux/qr.py to use a state-machine-based UR decoder API (receive_part returns a state, replacing is_complete/is_success). Transient errors (DECODER_ERR_INVALID_PART) are now silently ignored during scanning; only DECODER_NO_RESULT and DECODER_ERR_INVALID_CHECKSUM are treated as terminal and raise ValueError. A new test verifies this behavior. The MaixPy submodule is also bumped, suggesting the real firmware decoder changed in tandem.
Changed components
src/krux/qr.pysimulator/kruxsim/mocks/uUR.pyfirmware/MaixPy (submodule)UR QR code decoding flowInspect captured patch +80 / −7
diff --git a/simulator/kruxsim/mocks/uUR.py b/simulator/kruxsim/mocks/uUR.py
index 9770964..e125b43 100644
--- a/simulator/kruxsim/mocks/uUR.py
+++ b/simulator/kruxsim/mocks/uUR.py
@@ -46,10 +46,54 @@ class UREncoder(_UREncoder):
return super().next_part().upper()
+# Decoder states, mirroring uUR's DECODER_* constants (ur_decoder_state_t)
+DECODER_OK = 0
+DECODER_PROCESSING = 1
+DECODER_NO_RESULT = 2
+DECODER_ERR_INVALID_SCHEME = 16
+DECODER_ERR_INVALID_TYPE = 17
+DECODER_ERR_INVALID_PATH_LENGTH = 18
+DECODER_ERR_INVALID_SEQUENCE_COMPONENT = 19
+DECODER_ERR_INVALID_FRAGMENT = 20
+DECODER_ERR_INVALID_PART = 21
+DECODER_ERR_INVALID_CHECKSUM = 22
+DECODER_ERR_MEMORY = 23
+DECODER_ERR_NULL_POINTER = 24
+
+_TERMINAL_STATES = (DECODER_OK, DECODER_NO_RESULT, DECODER_ERR_INVALID_CHECKSUM)
+
+
class URDecoder(_URDecoder):
"""uUR exposes expected_part_count and processed_parts_count as plain
- int attributes (zero for single-part URs). Mirror that here so the same
- qr.py logic works against both decoders."""
+ int attributes (zero for single-part URs), and reports progress through a
+ state machine instead of is_complete()/is_success(). Mirror that here so
+ the same qr.py logic works against both decoders.
+
+ The Python decoder swallows the reason a part was rejected, so transient
+ errors are all reported as DECODER_ERR_INVALID_PART."""
+
+ def __init__(self):
+ super().__init__()
+ self._state = DECODER_PROCESSING
+
+ @property
+ def state(self):
+ return self._state
+
+ def receive_part(self, part):
+ # Terminal states are permanent: the part is not processed
+ if self._state in _TERMINAL_STATES:
+ return self._state
+ received = super().receive_part(part)
+ if isinstance(self.result, Exception):
+ self._state = DECODER_ERR_INVALID_CHECKSUM
+ elif self.result is not None:
+ self._state = DECODER_OK
+ elif not received:
+ self._state = DECODER_ERR_INVALID_PART
+ else:
+ self._state = DECODER_PROCESSING
+ return self._state
@property
def expected_part_count(self):
diff --git a/src/krux/qr.py b/src/krux/qr.py
index 29d7b04..ba1b64c 100644
--- a/src/krux/qr.py
+++ b/src/krux/qr.py
@@ -177,12 +177,16 @@ class QRPartParser:
self.total = total
return index - 1
elif self.format == FORMAT_UR:
- if not self.decoder:
- from uUR import URDecoder
+ from uUR import URDecoder, DECODER_NO_RESULT, DECODER_ERR_INVALID_CHECKSUM
+ if not self.decoder:
self.decoder = URDecoder()
data = data.decode() if isinstance(data, bytes) else data
- self.decoder.receive_part(data)
+ if self.decoder.receive_part(data) in (
+ DECODER_NO_RESULT,
+ DECODER_ERR_INVALID_CHECKSUM,
+ ):
+ raise ValueError("Failed to decode UR")
elif self.format == FORMAT_BBQR:
from .bbqr import parse_bbqr
@@ -195,7 +199,9 @@ class QRPartParser:
def is_complete(self):
"""Returns a boolean indicating whether or not enough parts have been parsed"""
if self.format == FORMAT_UR:
- return self.decoder.is_complete()
+ from uUR import DECODER_OK
+
+ return self.decoder.state == DECODER_OK
keys_check = (
sum(range(1, self.total + 1))
if self.format in (FORMAT_PMOFN, FORMAT_NONE)
diff --git a/tests/test_qr.py b/tests/test_qr.py
index 7572316..17f0cde 100644
--- a/tests/test_qr.py
+++ b/tests/test_qr.py
@@ -156,6 +156,29 @@ def test_parser(mocker, m5stickv, tdata):
assert res == tdata.TEST_DATA_B58
+def test_parser_ur_decoder_states(mocker, m5stickv, tdata):
+ """Transient UR decoder errors are ignored, terminal ones abort the parsing"""
+
+ import uUR
+ from krux.qr import QRPartParser
+
+ first_part = tdata.TEST_PARTS_FORMAT_MULTIPART_UR[0]
+
+ # Transient errors are expected while scanning, parsing goes on
+ parser = QRPartParser()
+ mocker.patch.object(
+ uUR.URDecoder, "receive_part", return_value=uUR.DECODER_ERR_INVALID_PART
+ )
+ parser.parse(first_part)
+ assert not parser.is_complete()
+
+ for terminal_state in (uUR.DECODER_NO_RESULT, uUR.DECODER_ERR_INVALID_CHECKSUM):
+ parser = QRPartParser()
+ mocker.patch.object(uUR.URDecoder, "receive_part", return_value=terminal_state)
+ with pytest.raises(ValueError):
+ parser.parse(first_part)
+
+
def test_to_qr_codes(mocker, m5stickv, tdata):
from krux.qr import to_qr_codes, FORMAT_NONE, FORMAT_PMOFN, FORMAT_UR, FORMAT_BBQR
from krux.display import Display
Why this scored 29/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.