Add unit tests for get_merkle_preimage
What changed, and why it matters
This commit only adds new unit tests for existing code. It does not change any production behavior, fix a bug, or introduce new functionality. The tests verify that certain security checks (proof length and domain separator checks) already reject malformed inputs. There is no direct security risk in this commit itself.
No action required. Review the tests for correctness and consider whether the existing production checks they exercise are sufficient. If any test fails, investigate the implementation, not this commit.
Security signals we found
Test-only commit with no production code changes
Tests verify existing Merkle proof length validation rejects internal-node-as-leaf and overlong proofs
Tests verify existing leaf preimage domain-separator check rejects internal-node preimages and non-zero prefixes
No patch to actual cryptographic or parsing logic
Evidence from the diff
The commit adds unit tests in three files: test_get_merkle_leaf_hash.c, test_get_merkle_preimage.c, and test_stream_preimage.c. The tests exercise existing functions (call_get_merkle_leaf_hash, call_get_merkle_preimage, call_stream_preimage) against adversarial inputs such as internal-node hashes presented as leaves, overlong/short proofs, and non-zero domain-separator prefixes. All added code is test-only; no source files under src/ are modified. The tests assert that the existing implementation returns negative error codes for these cases, confirming defensive checks are already in place.
Changed components
unit-tests/test_get_merkle_leaf_hash.cunit-tests/test_get_merkle_preimage.cunit-tests/test_stream_preimage.cInspect captured patch +334 / −0
diff --git a/unit-tests/test_get_merkle_leaf_hash.c b/unit-tests/test_get_merkle_leaf_hash.c
index 0127680..d07fd75 100644
--- a/unit-tests/test_get_merkle_leaf_hash.c
+++ b/unit-tests/test_get_merkle_leaf_hash.c
@@ -48,6 +48,18 @@ static void compute_leaf_hash(const uint8_t *elem, size_t len, uint8_t out[32])
compute_sha256(buf, 1 + len, out);
}
+/**
+ * Depth of a leaf in the tree (= the number of steps of its Merkle proof), computed from
+ * merkle_get_ith_direction.
+ */
+static uint8_t leaf_depth(uint32_t tree_size, uint32_t leaf_index) {
+ uint8_t depth = 0;
+ while (merkle_get_ith_direction(tree_size, leaf_index, depth) >= 0) {
+ ++depth;
+ }
+ return depth;
+}
+
/* ---------- Test cases ---------- */
/**
@@ -688,6 +700,182 @@ static void test_get_leaf_hash_more_proof_overflow(void **state) {
assert_true(result < 0);
}
+/**
+ * The proof length must be exactly the depth of the leaf: an internal node must never be
+ * accepted as a leaf.
+ *
+ * In a 4-element tree, root = H(H01, H23) with H01 = H(h0, h1) and H23 = H(h2, h3). A client
+ * that answers with leaf_hash = H01, proof = [H23] and proof_size = 1 produces a proof that
+ * recomputes the root exactly (the first direction for leaf 0 is "left"), so the only thing
+ * that rejects it is the requirement that proof_size equals the leaf depth (2 here).
+ */
+static int tamper_internal_node_as_leaf(uint8_t *response_buf,
+ size_t *response_len,
+ uint8_t cmd,
+ int call_count,
+ void *user_data) {
+ (void) call_count;
+
+ /* forged[0] = the internal node presented as a leaf, forged[1] = its sibling */
+ const uint8_t (*forged)[32] = (const uint8_t (*)[32]) user_data;
+
+ if (cmd == CCMD_GET_MERKLE_LEAF_PROOF) {
+ memcpy(response_buf, forged[0], 32);
+ response_buf[32] = 1; /* proof_size: one step short of the real leaf depth */
+ response_buf[33] = 1; /* n_proof_elements */
+ memcpy(response_buf + 34, forged[1], 32);
+ *response_len = 34 + 32;
+ }
+ return 0;
+}
+
+static void test_get_leaf_hash_internal_node_as_leaf(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ uint8_t e0[] = {0x00, 0x01};
+ uint8_t e1[] = {0x10, 0x11};
+ uint8_t e2[] = {0x20, 0x21};
+ uint8_t e3[] = {0x30, 0x31};
+
+ const uint8_t *elems[] = {e0, e1, e2, e3};
+ size_t lens[] = {sizeof(e0), sizeof(e1), sizeof(e2), sizeof(e3)};
+
+ uint8_t root[32];
+ build_tree(mock, elems, lens, 4, root);
+
+ /* Recompute the two internal nodes at depth 1 */
+ uint8_t h[4][32];
+ for (size_t i = 0; i < 4; i++) {
+ compute_leaf_hash(elems[i], lens[i], h[i]);
+ }
+
+ uint8_t forged[2][32];
+ merkle_combine_hashes(h[0], h[1], forged[0]); /* H01, claimed as a leaf */
+ merkle_combine_hashes(h[2], h[3], forged[1]); /* H23, its sibling */
+
+ /* Sanity check: the forged 1-step proof does recompute the real root, so rejecting it can
+ * only come from the proof length check. */
+ uint8_t recomputed_root[32];
+ merkle_combine_hashes(forged[0], forged[1], recomputed_root);
+ assert_memory_equal(recomputed_root, root, 32);
+
+ mock_dispatcher_set_tamper_hook(mock, tamper_internal_node_as_leaf, forged);
+
+ uint8_t out[32];
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(mock);
+ int result = call_get_merkle_leaf_hash(dc, root, 4, 0, out);
+
+ assert_true(result < 0);
+}
+
+/**
+ * Positive counterpart of the length check: an honest client sends a proof of exactly the leaf
+ * depth, and it is accepted. Uses a 5-element tree, where leaves 0..3 have depth 3 while leaf
+ * 4 has depth 1, so the accepted length really does track the individual leaf.
+ *
+ * (test_get_leaf_hash_zero_proof_size does not cover this: proof_size = 0 there is rejected
+ * even without the length check, because the leaf hash alone doesn't match the root.)
+ */
+static int tamper_record_proof_size(uint8_t *response_buf,
+ size_t *response_len,
+ uint8_t cmd,
+ int call_count,
+ void *user_data) {
+ (void) response_len;
+ (void) call_count;
+
+ if (cmd == CCMD_GET_MERKLE_LEAF_PROOF) {
+ *(uint8_t *) user_data = response_buf[32];
+ }
+ return 0;
+}
+
+static void test_get_leaf_hash_proof_size_equals_depth(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ uint8_t e0[] = {0xAA};
+ uint8_t e1[] = {0xBB, 0xCC};
+ uint8_t e2[] = {0xDD, 0xEE, 0xFF};
+ uint8_t e3[] = {0x11, 0x22};
+ uint8_t e4[] = {0x33};
+
+ const uint8_t *elems[] = {e0, e1, e2, e3, e4};
+ size_t lens[] = {sizeof(e0), sizeof(e1), sizeof(e2), sizeof(e3), sizeof(e4)};
+
+ uint8_t root[32];
+ build_tree(mock, elems, lens, 5, root);
+
+ uint8_t observed_proof_size = 0xFF;
+ mock_dispatcher_set_tamper_hook(mock, tamper_record_proof_size, &observed_proof_size);
+
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(mock);
+
+ /* The tree is unbalanced, so not all leaves have the same depth. */
+ assert_int_not_equal(leaf_depth(5, 0), leaf_depth(5, 4));
+
+ for (size_t i = 0; i < 5; i++) {
+ uint8_t expected_hash[32];
+ compute_leaf_hash(elems[i], lens[i], expected_hash);
+
+ uint8_t out[32];
+ memset(out, 0, sizeof(out));
+
+ observed_proof_size = 0xFF;
+ int result = call_get_merkle_leaf_hash(dc, root, 5, (uint32_t) i, out);
+
+ assert_int_equal(result, 0);
+ assert_int_equal(observed_proof_size, leaf_depth(5, (uint32_t) i));
+ assert_memory_equal(out, expected_hash, 32);
+ }
+}
+
+/**
+ * Adversarial: a proof longer than the leaf depth must be rejected. Leaf 4 of a 5-element
+ * tree has depth 1; the client claims a proof of 2 steps (padding the extra sibling hash).
+ */
+static int tamper_overlong_proof(uint8_t *response_buf,
+ size_t *response_len,
+ uint8_t cmd,
+ int call_count,
+ void *user_data) {
+ (void) user_data;
+ (void) call_count;
+
+ if (cmd == CCMD_GET_MERKLE_LEAF_PROOF && *response_len == 34 + 32) {
+ response_buf[32] = 2; /* proof_size: one more than the real leaf depth */
+ response_buf[33] = 2; /* n_proof_elements */
+ memset(response_buf + 34 + 32, 0x00, 32);
+ *response_len = 34 + 64;
+ }
+ return 0;
+}
+
+static void test_get_leaf_hash_overlong_proof(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ uint8_t e0[] = {0xAA};
+ uint8_t e1[] = {0xBB, 0xCC};
+ uint8_t e2[] = {0xDD, 0xEE, 0xFF};
+ uint8_t e3[] = {0x11, 0x22};
+ uint8_t e4[] = {0x33};
+
+ const uint8_t *elems[] = {e0, e1, e2, e3, e4};
+ size_t lens[] = {sizeof(e0), sizeof(e1), sizeof(e2), sizeof(e3), sizeof(e4)};
+
+ uint8_t root[32];
+ build_tree(mock, elems, lens, 5, root);
+
+ assert_int_equal(leaf_depth(5, 4), 1);
+
+ mock_dispatcher_set_tamper_hook(mock, tamper_overlong_proof, NULL);
+
+ uint8_t out[32];
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(mock);
+ int result = call_get_merkle_leaf_hash(dc, root, 5, 4, out);
+
+ assert_true(result < 0);
+}
+
/* ---------- Main ---------- */
int main(void) {
@@ -710,6 +898,9 @@ int main(void) {
T(test_get_leaf_hash_more_comm_failure),
T(test_get_leaf_hash_truncated_more),
T(test_get_leaf_hash_more_proof_overflow),
+ T(test_get_leaf_hash_internal_node_as_leaf),
+ T(test_get_leaf_hash_proof_size_equals_depth),
+ T(test_get_leaf_hash_overlong_proof),
};
#undef T
diff --git a/unit-tests/test_get_merkle_preimage.c b/unit-tests/test_get_merkle_preimage.c
index 8af8580..e3057ed 100644
--- a/unit-tests/test_get_merkle_preimage.c
+++ b/unit-tests/test_get_merkle_preimage.c
@@ -596,6 +596,75 @@ static void test_get_merkle_preimage_more_bytes(void **state) {
assert_int_equal(result, -9);
}
+/**
+ * Adversarial: the client serves a preimage that is not tagged with the 0x00 leaf domain
+ * separator. Here it is a well-formed internal node preimage (0x01 || left || right), whose
+ * hash is a genuine internal node hash of a Merkle tree, so the SHA-256 check passes and only
+ * the domain separator check can reject it.
+ */
+static void test_get_merkle_preimage_internal_node_preimage(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ /* Build a 2-element tree so that the internal node is the root of a real tree. */
+ const uint8_t e0[] = {0xAA, 0xBB};
+ const uint8_t e1[] = {0xCC, 0xDD};
+ const uint8_t *elems[] = {e0, e1};
+ size_t lens[] = {sizeof(e0), sizeof(e1)};
+ mock_dispatcher_add_list(mock, elems, lens, 2);
+
+ /* Internal node preimage: 0x01 || h(leaf 0) || h(leaf 1) */
+ uint8_t node_preimage[1 + 32 + 32];
+ node_preimage[0] = 0x01;
+ merkle_compute_element_hash(e0, sizeof(e0), node_preimage + 1);
+ merkle_compute_element_hash(e1, sizeof(e1), node_preimage + 33);
+
+ mock_dispatcher_add_preimage(mock, node_preimage, sizeof(node_preimage));
+
+ uint8_t hash[32];
+ compute_sha256(node_preimage, sizeof(node_preimage), hash);
+
+ /* Sanity check: this really is the root of the tree we registered. */
+ assert_memory_equal(hash, mock->trees[0].root, 32);
+
+ uint8_t out[256];
+ memset(out, 0xAA, sizeof(out));
+
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(mock);
+ int result = call_get_merkle_preimage(dc, hash, out, sizeof(out));
+
+ assert_int_equal(result, -12);
+}
+
+/**
+ * Adversarial: same rejection for an arbitrary non-zero prefix byte, and nothing is written to
+ * the output buffer.
+ */
+static void test_get_merkle_preimage_bad_prefix(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ uint8_t preimage[20];
+ preimage[0] = 0x01;
+ for (size_t i = 1; i < sizeof(preimage); i++) {
+ preimage[i] = (uint8_t) i;
+ }
+
+ mock_dispatcher_add_preimage(mock, preimage, sizeof(preimage));
+
+ uint8_t hash[32];
+ compute_sha256(preimage, sizeof(preimage), hash);
+
+ uint8_t out[64];
+ memset(out, 0xAA, sizeof(out));
+
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(mock);
+ int result = call_get_merkle_preimage(dc, hash, out, sizeof(out));
+
+ assert_int_equal(result, -12);
+ for (size_t i = 0; i < sizeof(out); i++) {
+ assert_int_equal(out[i], 0xAA);
+ }
+}
+
/* ---------- Main ---------- */
int main(void) {
@@ -618,6 +687,8 @@ int main(void) {
T(test_get_merkle_preimage_truncated_more),
T(test_get_merkle_preimage_bad_element_size),
T(test_get_merkle_preimage_more_bytes),
+ T(test_get_merkle_preimage_internal_node_preimage),
+ T(test_get_merkle_preimage_bad_prefix),
};
#undef T
diff --git a/unit-tests/test_stream_preimage.c b/unit-tests/test_stream_preimage.c
index 1a78c06..18bb4cd 100644
--- a/unit-tests/test_stream_preimage.c
+++ b/unit-tests/test_stream_preimage.c
@@ -671,6 +671,76 @@ static void test_stream_preimage_communication_failure(void **state) {
assert_true(result < 0);
}
+/**
+ * Adversarial: the client serves a preimage that is not tagged with the 0x00 leaf domain
+ * separator. Here it is a well-formed internal node preimage (0x01 || left || right), whose
+ * hash is a genuine internal node hash of a Merkle tree, so the SHA-256 check passes and only
+ * the domain separator check can reject it. Nothing must be streamed to the callbacks.
+ */
+static void test_stream_preimage_internal_node_preimage(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ /* Build a 2-element tree so that the internal node is the root of a real tree. */
+ const uint8_t e0[] = {0xAA, 0xBB};
+ const uint8_t e1[] = {0xCC, 0xDD};
+ const uint8_t *elems[] = {e0, e1};
+ size_t lens[] = {sizeof(e0), sizeof(e1)};
+ mock_dispatcher_add_list(mock, elems, lens, 2);
+
+ /* Internal node preimage: 0x01 || h(leaf 0) || h(leaf 1) */
+ uint8_t node_preimage[1 + 32 + 32];
+ node_preimage[0] = 0x01;
+ merkle_compute_element_hash(e0, sizeof(e0), node_preimage + 1);
+ merkle_compute_element_hash(e1, sizeof(e1), node_preimage + 33);
+
+ mock_dispatcher_add_preimage(mock, node_preimage, sizeof(node_preimage));
+
+ uint8_t hash[32];
+ compute_sha256(node_preimage, sizeof(node_preimage), hash);
+
+ /* Sanity check: this really is the root of the tree we registered. */
+ assert_memory_equal(hash, mock->trees[0].root, 32);
+
+ stream_accumulator_t acc;
+ memset(&acc, 0, sizeof(acc));
+
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(mock);
+ int result = call_stream_preimage(dc, hash, acc_len_callback, acc_data_callback, &acc);
+
+ assert_int_equal(result, -11);
+ assert_false(acc.len_called);
+ assert_int_equal(acc.offset, 0);
+}
+
+/**
+ * Adversarial: same rejection for an arbitrary non-zero prefix byte, including for a preimage
+ * long enough to need GET_MORE_ELEMENTS (the rejection happens on the first chunk).
+ */
+static void test_stream_preimage_bad_prefix(void **state) {
+ mock_dispatcher_t *mock = *state;
+
+ uint8_t preimage[300];
+ preimage[0] = 0x01;
+ for (size_t i = 1; i < sizeof(preimage); i++) {
+ preimage[i] = (uint8_t) (i * 7);
+ }
+
+ mock_dispatcher_add_preimage(mock, preimage, sizeof(preimage));
+
+ uint8_t hash[32];
+ compute_sha256(preimage, sizeof(preimage), hash);
+
+ stream_accumulator_t acc;
+ memset(&acc, 0, sizeof(acc));
+
+ dispatcher_context_t *dc = mock_dispatcher_get_dc(mock);
+ int result = call_stream_preimage(dc, hash, acc_len_callback, acc_data_callback, &acc);
+
+ assert_int_equal(result, -11);
+ assert_false(acc.len_called);
+ assert_int_equal(acc.offset, 0);
+}
+
/* ---------- Main ---------- */
int main(void) {
@@ -693,6 +763,8 @@ int main(void) {
T(test_stream_preimage_bad_element_size),
T(test_stream_preimage_more_bytes_than_remaining),
T(test_stream_preimage_communication_failure),
+ T(test_stream_preimage_internal_node_preimage),
+ T(test_stream_preimage_bad_prefix),
};
#undef T
Why this scored 15/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.