Harden count_distinct_keys_info to correctly count the used keys
What changed, and why it matters
This commit fixes a bug in Ledger's Bitcoin app that could let a wallet policy slip through registration even when it contained unused or out-of-range public keys. The old code simply looked at the highest key index referenced in the wallet descriptor and assumed every key up to that number was used. That meant an attacker could craft a policy where the user's own key was listed as an unused extra key while the descriptor only required the attacker's key. The fix now explicitly counts which keys are actually referenced and rejects policies with gaps or unreferenced keys.
Treat this as a security hardening fix and include it in the next firmware release. Review related wallet policy validation paths for similar max-index assumptions, and consider whether the internal-key check can be bypassed through other descriptor constructions.
Security signals we found
Logic flaw in wallet policy validation
Potential bypass of internal-key ownership check via unused key slots
Bounds checking added for key indices
New negative test case for gap/unreferenced key attack
Evidence from the diff
The function count_distinct_keys_info previously returned max(key_index)+1, so a descriptor like wsh(sortedmulti(1,@1/**)) with two supplied keys (user’s key at @0, attacker’s key at @1) would report 2 distinct keys and pass the equality check against wallet_header.n_keys. The patched version takes n_keys as a bound, builds a bitvector of referenced indices, rejects out-of-range indices, and returns the true count of referenced keys. A new test vector ‘unreferenced_internal_key’ demonstrates that such policies must now return SW_INCORRECT_DATA.
Changed components
src/handler/lib/policy.csrc/handler/lib/policy.hsrc/handler/register_wallet.ctest_vectors/register_wallet.tomlInspect captured patch +52 / −12
### src/handler/lib/policy.c
@@ -1738,8 +1738,15 @@ int get_keyexpr_by_index(const policy_node_t *policy,
return -1;
}
-int count_distinct_keys_info(const policy_node_t *policy) {
- int ret = -1;
+int count_distinct_keys_info(const policy_node_t *policy, size_t n_keys) {
+ if (n_keys > MAX_N_KEYS_IN_WALLET_POLICY) {
+ return -1;
+ }
+
+ // bitvector of key indices referenced anywhere in the policy
+ uint8_t used[BITVECTOR_REAL_SIZE(MAX_N_KEYS_IN_WALLET_POLICY)];
+ memset(used, 0, sizeof(used));
+
policy_node_keyexpr_t *key_expression_ptr;
int n_key_expressions = get_keyexpr_by_index(policy, 0, NULL, NULL);
if (n_key_expressions < 0) {
@@ -1751,18 +1758,31 @@ int count_distinct_keys_info(const policy_node_t *policy) {
return -1;
}
if (key_expression_ptr->type == KEY_EXPRESSION_NORMAL) {
- ret = MAX(ret, key_expression_ptr->k.key_index + 1);
+ if (key_expression_ptr->k.key_index >= n_keys) {
+ return -1;
+ }
+ bitvector_set(used, key_expression_ptr->k.key_index, true);
} else if (key_expression_ptr->type == KEY_EXPRESSION_MUSIG) {
const musig_aggr_key_info_t *musig_info = key_expression_ptr->m.musig_info;
const uint16_t *key_indexes = musig_info->key_indexes;
for (int i = 0; i < musig_info->n; i++) {
- ret = MAX(ret, key_indexes[i] + 1);
+ if (key_indexes[i] >= n_keys) {
+ return -1;
+ }
+ bitvector_set(used, key_indexes[i], true);
}
} else {
LEDGER_ASSERT(false, "Unknown key expression type");
}
}
- return ret;
+
+ int n_distinct = 0;
+ for (size_t i = 0; i < n_keys; i++) {
+ if (bitvector_get(used, i)) {
+ ++n_distinct;
+ }
+ }
+ return n_distinct;
}
// Utility function to extract and decode the i-th xpub from the keys information vector
### src/handler/lib/policy.h
@@ -239,16 +239,24 @@ bool are_key_placeholders_identical(const policy_node_keyexpr_t *kp1,
const policy_node_keyexpr_t *kp2);
/**
- * Determines the expected number of unique keys in the provided policy's key information.
- * The function calculates this by finding the maximum key index from key expressions and increments
- * it by 1. For instance, if the maximum key index found in the key expressions is `n`, then the
- * result would be `n + 1`.
+ * Counts the number of distinct key indices in the range [0, n_keys) that are referenced by some
+ * key expression in the provided policy (including every member of any musig() aggregation).
+ * Key indices greater than or equal to n_keys are rejected as an error, as they do not correspond
+ * to any key info actually supplied by the caller.
+ *
+ * Comparing the result with n_keys tells the caller whether every supplied key info is referenced
+ * at least once by the descriptor, with no gaps and no out-of-range references.
*
* @param[in] policy
* Pointer to the root node of the policy
- * @return the expected number of items in the keys information vector; -1 in case of error.
+ * @param[in] n_keys
+ * The number of keys info supplied for this policy; also the exclusive upper bound for valid key
+ * indices.
+ * @return the number of distinct key indices in [0, n_keys) referenced by the policy; -1 in case of
+ * error, including if a key expression references an index outside of that range.
*/
-__attribute__((warn_unused_result)) int count_distinct_keys_info(const policy_node_t *policy);
+__attribute__((warn_unused_result)) int count_distinct_keys_info(const policy_node_t *policy,
+ size_t n_keys);
/**
* Checks if a wallet policy is sane, verifying that pubkeys are never repeated and (if miniscript)
### src/handler/register_wallet.c
@@ -272,7 +272,8 @@ void handler_register_wallet(dispatcher_context_t *dc, uint8_t protocol_version)
return;
}
- if (count_distinct_keys_info(&policy_map.parsed) != (int) wallet_header.n_keys) {
+ if (count_distinct_keys_info(&policy_map.parsed, wallet_header.n_keys) !=
+ (int) wallet_header.n_keys) {
PRINTF("The number of keys in descriptor template doesn't match the provided keys\n");
SEND_SW(dc, SW_INCORRECT_DATA);
return;
### test_vectors/register_wallet.toml
@@ -269,6 +269,17 @@ keys_info = [
wallet_name = "Missing a key"
error = "INCORRECT_DATA"
+[[case]]
+name = "unreferenced_internal_key"
+description = "our genuine key is supplied as unused @0 while the descriptor references only the attacker's @1; must be rejected instead of bypassing the internal-key check"
+descriptor_template = "wsh(sortedmulti(1,@1/**))"
+keys_info = [
+ "[f5acc2fd/48'/1'/0'/2']tpubDFAqEGNyad35aBCKUAXbQGDjdVhNueno5ZZVEn3sQbW5ci457gLR7HyTmHBg93oourBssgUxuWz1jX5uhc1qaqFo9VsybY1J5FuedLfm4dK",
+ "[76223a6e/48'/1'/0'/2']tpubDE7NQymr4AFtewpAsWtnreyq9ghkzQBXpCZjWLFVRAvnbf7vya2eMTvT2fPapNqL8SuVvLQdbUbMfWLVDCZKnsEBqp6UK93QEzL8Ck23AwF",
+]
+wallet_name = "Gap attack"
+error = "INCORRECT_DATA"
+
[[case]]
name = "unsupported_bare_pk"
description = "bare pk() top-level descriptor is not supported"Why this scored 67/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.