Enforce the sorted-keys precondition for by-key map reads
What changed, and why it matters
This commit hardens a Ledger Bitcoin app security check. Previously, reading a value from a special data structure (a 'merkleized map') by key required the caller to first verify that all keys were sorted and unique; that was only a documented rule, not enforced. A malicious host could potentially trick the app into reading the wrong value for a key if the rule was skipped. The patch now tracks a 'keys are sorted' flag inside the map commitment itself and makes all by-key readers fail (via an internal assertion) if that flag is not set. It also adds a helper to validate maps that come directly from the APDU command (like the PSBT global map).
Review all call sites that populate `merkleized_map_commitment_t` outside the two validated paths (`call_get_merkleized_map*` and `call_check_merkleized_map_sorted`) to ensure `_keys_are_sorted` is never set directly. Confirm that `LEDGER_ASSERT` behavior on the target Ledger OS is acceptable for a security precondition (ideally replace or supplement with a runtime error return in production). Run unit tests and fuzz the dispatcher mock to verify by-key readers fail when the flag is unset.
Security signals we found
Defense-in-depth invariant enforcement
Precondition for by-key merkleized map reads now asserted
Potential equivocation / wrong-value-read mitigated if callers skipped sortedness check
New API helper `call_check_merkleized_map_sorted` for APDU-direct map commitments
Use of `LEDGER_ASSERT` (debug/fault behavior) rather than runtime error return
Evidence from the diff
The patch adds a private _keys_are_sorted boolean to merkleized_map_commitment_t. call_get_merkleized_map[_with_callback] initializes it to false and sets it to true only after call_check_merkle_tree_sorted_with_callback succeeds. A new inline helper call_check_merkleized_map_sorted validates an already-populated map commitment and sets the flag. All by-key readers (call_get_merkleized_map_value, call_get_merkleized_map_value_hash, call_stream_merkleized_map_value) now LEDGER_ASSERT(map->_keys_are_sorted, ...). The PSBT global map validation in init_global_state.c is switched to the new helper so the flag is set. Unit-test mocks also set the flag for pre-sorted trees. This converts a caller-enforced convention into an invariant enforced by the map-reading API.
Changed components
src/common/merkle.hsrc/handler/lib/check_merkle_tree_sorted.hsrc/handler/lib/get_merkleized_map.csrc/handler/lib/get_merkleized_map_value.csrc/handler/lib/get_merkleized_map_value.hsrc/handler/lib/get_merkleized_map_value_hash.csrc/handler/lib/get_merkleized_map_value_hash.hsrc/handler/lib/stream_merkleized_map_value.csrc/handler/lib/stream_merkleized_map_value.hsrc/handler/sign_psbt/init_global_state.cunit-tests/libs/mock_dispatcher.cInspect captured patch +82 / −16
### src/common/merkle.h
@@ -1,6 +1,7 @@
#pragma once
+#include <stdbool.h>
#include <stdint.h>
// TODO: RFC6962 defines the empty list hash as sha256(b''); while we're using 0 here. Should we
@@ -86,4 +87,11 @@ typedef struct {
uint64_t size;
uint8_t keys_root[32];
uint8_t values_root[32];
+
+ // PRIVATE - managed only by the merkleized-map API (call_get_merkleized_map* /
+ // call_check_merkleized_map_sorted). Set to true once the keys tree has been verified to be
+ // lexicographically sorted (and therefore the keys are unique), which is the precondition for
+ // reading a value by key. The by-key readers assert this is set.
+ // Callers must not read or set it directly.
+ bool _keys_are_sorted;
} merkleized_map_commitment_t;
### src/handler/lib/check_merkle_tree_sorted.h
@@ -44,4 +44,23 @@ static inline int call_check_merkle_tree_sorted(dispatcher_context_t *dispatcher
size,
NULL,
NULL);
-}
\ No newline at end of file
+}
+
+/**
+ * Validates a merkleized map commitment whose fields were populated directly (rather than obtained
+ * from call_get_merkleized_map): checks that its keys tree is lexicographically sorted, and on
+ * success marks the commitment as validated so that its values can be read by key.
+ *
+ * This is the counterpart of call_get_merkleized_map for maps that are not fetched from an outer
+ * Merkle tree of maps (e.g. the PSBT global map, whose commitment comes straight from the APDU).
+ *
+ * Returns 0 on success, or a negative number on failure.
+ */
+static inline int call_check_merkleized_map_sorted(dispatcher_context_t *dispatcher_context,
+ merkleized_map_commitment_t *map) {
+ int ret = call_check_merkle_tree_sorted(dispatcher_context, map->keys_root, (size_t) map->size);
+ if (ret >= 0) {
+ map->_keys_are_sorted = true;
+ }
+ return ret;
+}
### src/handler/lib/get_merkleized_map.c
@@ -21,6 +21,9 @@ int call_get_merkleized_map_with_callback(dispatcher_context_t *dispatcher_conte
uint8_t raw_output[9 + 2 * 32]; // maximum size of serialized result (9 bytes for the varint,
// and the 2 Merkle roots)
+ // The map is not yet validated; explicitly mark it as such
+ out_ptr->_keys_are_sorted = false;
+
int el_len = call_get_merkle_leaf_element(dispatcher_context,
root,
size,
@@ -38,10 +41,15 @@ int call_get_merkleized_map_with_callback(dispatcher_context_t *dispatcher_conte
return -1;
}
- return call_check_merkle_tree_sorted_with_callback(dispatcher_context,
- callback_state,
- out_ptr->keys_root,
- out_ptr->size,
- callback,
- out_ptr);
+ int ret = call_check_merkle_tree_sorted_with_callback(dispatcher_context,
+ callback_state,
+ out_ptr->keys_root,
+ out_ptr->size,
+ callback,
+ out_ptr);
+ if (ret >= 0) {
+ // keys were verified to be lexicographically sorted: the map is now safe for by-key reads
+ out_ptr->_keys_are_sorted = true;
+ }
+ return ret;
}
### src/handler/lib/get_merkleized_map_value.c
@@ -2,6 +2,9 @@
#include "get_merkleized_map_value.h"
+/* SDK headers */
+#include "ledger_assert.h"
+
/* Local headers */
#include "get_merkle_leaf_element.h"
#include "get_merkle_leaf_index.h"
@@ -14,6 +17,10 @@ int call_get_merkleized_map_value(dispatcher_context_t *dispatcher_context,
size_t out_len) {
// LOG_PROCESSOR(__FILE__, __LINE__, __func__);
+ // Reading a value by key is only sound once the map's keys have been verified sorted (hence
+ // unique); otherwise a malicious client could equivocate. This must hold by construction.
+ LEDGER_ASSERT(map->_keys_are_sorted, "map keys not validated as sorted");
+
uint8_t key_merkle_hash[32];
merkle_compute_element_hash(key, key_len, key_merkle_hash);
### src/handler/lib/get_merkleized_map_value.h
@@ -16,8 +16,15 @@
* Returns a negative number if the response is too long to fit into the output buffer, or if the
* key is not found, or if any of the proofs failed. Returns the length of the preimage on success.
*
- * NOTE: this does _not_ check that the keys are lexicographically sorted; the sanity check needs to
- * be done before.
+ * PRECONDITION: the map's keys must have already been verified to be lexicographically sorted (and
+ * therefore unique); this is what makes a by-key lookup unambiguous. A map is validated either by
+ * `call_get_merkleized_map[_with_callback]` (which validates before returning) or by
+ * `call_check_merkleized_map_sorted`. This function asserts that precondition (LEDGER_ASSERT on
+ * `map->_keys_are_sorted`); it does NOT re-check the ordering itself.
+ *
+ * NOTE for callbacks fired during validation (via call_get_merkleized_map_with_callback): at that
+ * point the map is not yet validated, so values must be read by index (on `values_root`) and never
+ * by key through this function or its siblings.
*/
int call_get_merkleized_map_value(dispatcher_context_t *dispatcher_context,
const merkleized_map_commitment_t *map,
### src/handler/lib/get_merkleized_map_value_hash.c
@@ -2,6 +2,9 @@
#include "get_merkleized_map_value_hash.h"
+/* SDK headers */
+#include "ledger_assert.h"
+
/* Local headers */
#include "get_merkle_leaf_hash.h"
#include "get_merkle_leaf_index.h"
@@ -13,6 +16,10 @@ int call_get_merkleized_map_value_hash(dispatcher_context_t *dispatcher_context,
uint8_t out[static 32]) {
// LOG_PROCESSOR(__FILE__, __LINE__, __func__);
+ // Reading a value by key is only sound once the map's keys have been verified sorted (hence
+ // unique); otherwise a malicious client could equivocate. This must hold by construction.
+ LEDGER_ASSERT(map->_keys_are_sorted, "map keys not validated as sorted");
+
uint8_t key_merkle_hash[32];
merkle_compute_element_hash(key, key_len, key_merkle_hash);
### src/handler/lib/get_merkleized_map_value_hash.h
@@ -13,8 +13,8 @@
* Returns a negative number if the key is not found, or any of the proofs failed. Returns 0 on
* success.
*
- * NOTE: this does _not_ check that the keys are lexicographically sorted; the sanity check needs to
- * be done before.
+ * PRECONDITION: the map's keys must have already been verified to be lexicographically sorted;
+ * this function asserts it (LEDGER_ASSERT on `map->_keys_are_sorted`).
*/
int call_get_merkleized_map_value_hash(dispatcher_context_t *dispatcher_context,
const merkleized_map_commitment_t *map,
### src/handler/lib/stream_merkleized_map_value.c
@@ -1,5 +1,8 @@
#include "stream_merkleized_map_value.h"
+/* SDK headers */
+#include "ledger_assert.h"
+
/* Local headers */
#include "get_merkle_leaf_index.h"
#include "stream_merkle_leaf_element.h"
@@ -13,6 +16,10 @@ int call_stream_merkleized_map_value(dispatcher_context_t *dispatcher_context,
void *callback_state) {
LOG_PROCESSOR(__FILE__, __LINE__, __func__);
+ // Reading a value by key is only sound once the map's keys have been verified sorted (hence
+ // unique); otherwise a malicious client could equivocate. This must hold by construction.
+ LEDGER_ASSERT(map->_keys_are_sorted, "map keys not validated as sorted");
+
uint8_t key_merkle_hash[32];
merkle_compute_element_hash(key, key_len, key_merkle_hash);
### src/handler/lib/stream_merkleized_map_value.h
@@ -11,8 +11,8 @@
*
* Returns a negative number on failure, or the preimage length on success.
*
- * NOTE: this does _not_ check that the keys are lexicographically sorted; the sanity check needs to
- * be done before.
+ * PRECONDITION: the map's keys must have already been verified to be lexicographically sorted;
+ * this function asserts it (LEDGER_ASSERT on `map->_keys_are_sorted`).
*/
int call_stream_merkleized_map_value(dispatcher_context_t *dispatcher_context,
const merkleized_map_commitment_t *map,
### src/handler/sign_psbt/init_global_state.c
@@ -123,9 +123,9 @@ static bool __attribute__((noinline)) parse_sign_psbt_apdu(dispatcher_context_t
*/
static bool __attribute__((noinline)) process_global_map(dispatcher_context_t *dc,
sign_psbt_state_t *st) {
- // Check integrity of the global map
- if (call_check_merkle_tree_sorted(dc, st->global_map.keys_root, (size_t) st->global_map.size) <
- 0) {
+ // Check integrity of the global map (this also marks it as validated, so that its values may
+ // be read by key below).
+ if (call_check_merkleized_map_sorted(dc, &st->global_map) < 0) {
SEND_SW(dc, SW_INCORRECT_DATA);
return false;
}
### unit-tests/libs/mock_dispatcher.c
@@ -553,6 +553,9 @@ void mock_dispatcher_add_map(mock_dispatcher_t *mock,
out_commitment->size = (uint64_t) n;
memcpy(out_commitment->keys_root, mock->trees[keys_tree_idx].root, 32);
memcpy(out_commitment->values_root, mock->trees[values_tree_idx].root, 32);
+ /* The mock builds the keys tree already sorted, so the commitment satisfies the invariant that
+ * the by-key value readers assert on (matching what call_get_merkleized_map guarantees). */
+ out_commitment->_keys_are_sorted = true;
}
/* ---- Helper: register a psbt_map_t with the mock ---- */Why this scored 76/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.