wire: refactor handle_message, enable all message tests for libjade
What changed, and why it matters
This commit refactors the code that reads incoming messages on a Blockstream Jade hardware wallet. The main change is that the device now checks up-front whether incoming bytes form a valid CBOR 'map' (a structured data format) before treating them as a real RPC command. It also changes how stale or oversized data is rejected. The commit message and diff do not claim to fix a specific security bug, but the refactor tightens input validation and removes some timeout-driven rejection logic. The test changes enable previously skipped message tests for the 'libjade' build.
Treat this as a hardening/refactor commit rather than a confirmed vulnerability fix. Review the new stale-data logic to ensure an attacker cannot use the timeout behavior to slip malformed bytes past validation, and verify that ignoring reject_incomplete does not break any security-critical callers. Run the newly enabled libjade negative tests and fuzz the message boundary cases (truncated, oversized, stale-prefix followed by valid message).
Security signals we found
Input validation tightened: candidate messages must now be valid CBOR maps before RPC processing
Stale-data handling changed: timeout now triggers rejection of only the pre-existing stale bytes, not necessarily the new data
Maximum-size buffer behavior changed: full buffer that still cannot be parsed is rejected entirely
reject_incomplete parameter is now ignored, altering caller-intended incomplete-message behavior
Tests for malformed/random inputs are enabled for libjade, suggesting the refactor is meant to make that path safer
Evidence from the diff
In main/wire.c, handle_data() is rewritten. A new helper get_msg_len() uses cbor_parser_init with CborValidateCompleteData and requires the parsed value to be a map (cbor_value_is_map) after advancing past it. The loop now parses *read_ptr + new_data_len each iteration, rejects the whole buffer when it reaches MAX_INPUT_MSG_SIZE and still cannot parse, discards only the stale prefix when a timeout has expired, and otherwise waits for more data. The reject_incomplete parameter is ignored per the commit message. rpc_request_valid() is still called after a CBOR map is found. test_jade.py changes wait() calls to force=True and enables test_random_bytes and test_very_bad_message for libjade (removing the args.libjade exclusion).
Changed components
main/wire.ctest_jade.pyIncoming RPC message parsing pathCBOR deserialization logicInspect captured patch +66 / −74
diff --git a/main/wire.c b/main/wire.c
index 9634bf6..d48a2ce 100644
--- a/main/wire.c
+++ b/main/wire.c
@@ -29,6 +29,22 @@ static const TickType_t TIMEOUT_TICKS = 3000 / portTICK_PERIOD_MS;
static const TickType_t TIMEOUT_TICKS = 2000 / portTICK_PERIOD_MS;
#endif
+// Attempt to parse a valid CBOR message from data_in.
+// Returns its length if valid, 0 if invalid.
+static size_t get_msg_len(cbor_msg_t* ctx, const uint8_t* const data_in, const size_t read_len)
+{
+ const int flags = CborValidateCompleteData;
+ const CborError cberr = cbor_parser_init(data_in, read_len, flags, &ctx->parser, &ctx->value);
+ if (cberr == CborNoError) {
+ // If we can parse the value, and it may be an RPC message, return its length
+ CborValue tmp_value = ctx->value;
+ if (cbor_value_advance(&tmp_value) == CborNoError && cbor_value_is_map(&ctx->value)) {
+ return tmp_value.source.ptr - data_in;
+ }
+ }
+ return 0;
+}
+
static void reject_data(const cbor_msg_t* const ctx, const char* msg, size_t rejected_len)
{
uint8_t len_str[16], out[112]; // sufficient
@@ -81,96 +97,72 @@ void handle_data(uint8_t* full_data_in, size_t* read_ptr, const size_t new_data_
bool reject_incomplete)
{
JADE_ASSERT(full_data_in);
- JADE_ASSERT(read_ptr);
- JADE_ASSERT(*read_ptr <= MAX_INPUT_MSG_SIZE && *read_ptr + new_data_len <= MAX_INPUT_MSG_SIZE);
+ JADE_ASSERT(read_ptr && *read_ptr <= MAX_INPUT_MSG_SIZE && *read_ptr + new_data_len <= MAX_INPUT_MSG_SIZE);
JADE_ASSERT(last_processing_time);
// Get current message processing time
- const TickType_t time_now = xTaskGetTickCount();
- JADE_ASSERT(time_now >= *last_processing_time);
-
- JADE_LOGI("Received %u new bytes, total in buffer is now %u, time is %lu ticks (time since last %lu)", new_data_len,
- *read_ptr + new_data_len, time_now, time_now - *last_processing_time);
-
- if (*read_ptr > 0 && time_now > *last_processing_time + TIMEOUT_TICKS) {
- // Have stale bytes resting in buffer - reject if no complete message found
- JADE_LOGW("Timing out %u bytes in buffer (time_now: %lu, last_processing_time: %lu, TIMEOUT_TICKS: %lu)",
- *read_ptr, time_now, *last_processing_time, TIMEOUT_TICKS);
- reject_incomplete = true;
- }
+ const TickType_t now = xTaskGetTickCount();
+ JADE_ASSERT(now >= *last_processing_time);
- // Append new bytes, and try to parse
- *read_ptr += new_data_len;
- JADE_LOGD("Passing %u bytes to common handler", *read_ptr);
- reject_incomplete |= (*read_ptr == MAX_INPUT_MSG_SIZE);
+ // Buffer is stale if we had bytes already and the timeout has expired
+ bool have_stale = *read_ptr && now > *last_processing_time + TIMEOUT_TICKS;
+ JADE_LOGI("%u new of %u total %sbytes at tick %lu (prev tick %lu)", new_data_len, *read_ptr + new_data_len,
+ have_stale ? "stale " : "", now, *last_processing_time);
- const jade_msg_source_t source = full_data_in[0];
uint8_t* const data_in = full_data_in + 1;
+ cbor_msg_t ctx = { .source = full_data_in[0] };
while (true) {
+ // Try parsing an RPC message from the buffer plus the new data
+ const size_t parse_len = *read_ptr + new_data_len;
+ size_t msg_len = get_msg_len(&ctx, data_in, parse_len);
- cbor_msg_t ctx = { .source = source, .cbor = NULL, .cbor_len = 0 };
- const size_t read = *read_ptr;
- size_t msg_len = 0;
-
- const CborError cberr = cbor_parser_init(data_in, read, CborValidateCompleteData, &ctx.parser, &ctx.value);
- if (cberr == CborNoError) {
- // Attempt to fetch the next single cbor object from the stream and store the relevant message length
- // Will carry out basic structure validation - see: cbor_value_validate_basic()
- CborValue tmp_value = ctx.value;
- if (cbor_value_advance(&tmp_value) == CborNoError) {
- msg_len = tmp_value.source.ptr - data_in;
- }
- }
-
- // If we could not fetch a message from the buffer..
if (msg_len == 0) {
- if (!reject_incomplete) {
- // Not a complete cbor message, but we are allowed to await more data to complete the message
- JADE_LOGD("Got incomplete CBOR message, length %u - awaiting more data...", read);
- goto done;
+ // We could not parse a message from the buffer
+ if (parse_len == MAX_INPUT_MSG_SIZE) {
+ // Can't possibly be a valid message since there
+ // is no more room to complete it: Reject the whole buffer
+ msg_len = parse_len; // Whole buffer
+ reject_data(&ctx, "Invalid RPC Request message", msg_len);
+ } else if (have_stale) {
+ // We have stale data - Throw it away and try any new data
+ msg_len = *read_ptr; // Just the existing stale bytes
+ reject_data(&ctx, "Invalid RPC Request message", msg_len);
+ } else {
+ // Continue to wait for more data to complete the message
+ JADE_LOGD("Incomplete RPC Request of length %u - awaiting more data...", parse_len);
+ *read_ptr = parse_len; // Include the new data in the buffer
+ *last_processing_time = now; // New data received
+ return;
}
-
- // Not a complete/valid cbor message, and we are not allowed to await more, so reject what we have.
- // Break to reset the read-ptr to the start and lose all the data.
- reject_data(&ctx, "Invalid RPC Request message", read);
- *read_ptr = 0; // Discard entire buffer by resetting the read-ptr
- goto done;
- }
-
- if (!rpc_request_valid(&ctx.value)) {
- // bad message - expect all inputs to be cbor with a root map with an id and a method strings keys values
- reject_data(&ctx, "Invalid RPC Request message (malformed)", msg_len);
- } else if (handle_immediate_message(&ctx)) {
- JADE_LOGI("Message handled, not passing to main task");
- idletimer_register_activity(false);
+ } else if (!rpc_request_valid(&ctx.value)) {
+ // We have a valid CBOR map, but it is not a valid RPC message:
+ // reject it and try any following bytes in the buffer.
+ reject_data(&ctx, "Invalid RPC Request message", msg_len);
} else {
- // Push to task queue for main task to handle
- if (jade_process_push_in_message(full_data_in, msg_len + 1)) {
- // Valid message arrival counts as 'activity' against idle timeout
- // (but not as 'UI' activity - ie. keep jade on but do not stop the screen from turning off)
- idletimer_register_activity(false);
+ // We have a valid looking RPC message in ctx.value:
+ // Handle it immediately, or give it to the main task queue to handle
+ if (handle_immediate_message(&ctx) || jade_process_push_in_message(full_data_in, msg_len + 1)) {
+ const bool is_ui = false; // Not UI activity
+ idletimer_register_activity(is_ui); // Message handled
} else {
+ // Rejected by the main task queue: only happens if too large
reject_data(&ctx, "Input message too large", msg_len);
}
}
- if (msg_len == read) {
- // We have consumed all the data
- *read_ptr = 0; // Discard entire buffer by resetting the read-ptr
- goto done;
+ if (msg_len == parse_len) {
+ // We have consumed all the provided data
+ *read_ptr = 0; // Discard entire buffer contents
+ *last_processing_time = now; // Update caller's 'last processing time'
+ return;
}
- // Otherwise we have some data left in the buffer - move the unhandled data down to the start of the buffer
- // (overwriting what we've handled)
- // Also set 'reject_incomplete' to false, as we have now read a message.
- memmove(data_in, data_in + msg_len, read - msg_len);
+ // We have unprocessed data left in the buffer:
+ // Move it to the start of the buffer and loop to process it
+ memmove(data_in, data_in + msg_len, parse_len - msg_len);
*read_ptr -= msg_len;
- reject_incomplete = false;
+ have_stale = false;
}
-
-done:
- // Update caller's 'last processing time'
- *last_processing_time = time_now;
}
#endif // AMALGAMATED_BUILD
diff --git a/test_jade.py b/test_jade.py
index 8467227..754b06d 100644
--- a/test_jade.py
+++ b/test_jade.py
@@ -759,7 +759,7 @@ def test_very_bad_message(jade):
for badmsg in [empty, text, truncated]:
# Send the bad message, and after a pause a good message
jade.write(badmsg)
- wait(3)
+ wait(3, force=True)
jade.write_request(goodmsg)
# We should receive a bag of errors
@@ -799,7 +799,7 @@ def test_random_bytes(jade):
jade.write(noise)
nsent += len(noise)
- wait(5)
+ wait(5, force=True)
goodmsg = jade.build_request('goodmsg', 'add_entropy', {'entropy': 'somebytes'.encode()})
jade.write_request(goodmsg)
@@ -4005,8 +4005,8 @@ def run_interface_tests(jadeapi,
# Negative tests
if negative:
logger.info('Negative tests')
- if not args.libjade and not args.spts:
- # TODO: enable these tests at least for args.spts=true
+ if not args.spts:
+ # TODO: enable these tests for args.spts=true
test_random_bytes(jadeapi.jade)
test_very_bad_message(jadeapi.jade)
test_bad_message(jadeapi.jade)
Why this scored 37/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.