Validate key classification in register_wallet unit tests
What changed, and why it matters
This commit only adds and improves unit tests for the Ledger Bitcoin app. It records what the user-interface confirmation function receives during wallet registration tests, then checks that the app correctly labels each key as internal (ours), external (someone else's), or unspendable. It also adds a new test case for a fingerprint-collision scenario. There is no change to the actual app code that runs on the device, so this is a testing/quality improvement rather than a security fix.
No action required; treat as normal test-enhancement commit. Reviewers may optionally verify that the new test vectors align with the intended key-classification semantics in the production handler.
Security signals we found
Adds regression test for fingerprint collision classified as external
Adds test coverage for BIP-341 NUMS unspendable key classification
No production code change
Evidence from the diff
The diff modifies test infrastructure: test_vectors/README.md documents a new optional expected_key_types field; test_vectors/register_wallet.toml adds expected_key_types arrays to existing success cases and one new internal_key_with_collision case; unit-tests/test_register_wallet.c captures the arguments passed to the ui_display_register_wallet_policy stub and asserts them against the vectors. The production handler logic is not patched; only test coverage is expanded.
Changed components
unit-tests/test_register_wallet.ctest_vectors/register_wallet.tomltest_vectors/README.mdInspect captured patch +116 / −7
diff --git a/test_vectors/README.md b/test_vectors/README.md
index db61cab..48d1ea1 100644
--- a/test_vectors/README.md
+++ b/test_vectors/README.md
@@ -94,6 +94,11 @@ on navigation, not on the request, and stay as framework-specific code.
secret-free, checkable by any framework.
- `expected_wallet_hmac` (hex, 32 bytes, optional): reproducible only with the
Speculos registration key.
+- `expected_key_types` (array of strings, optional): the per-key classification the
+ device assigns, one entry per key in `@0,@1,…` order. Each is `"internal"` (our key,
+ re-derived and matched), `"external"` (a foreign key, including a fingerprint collision
+ whose re-derived pubkey doesn't match), or `"unspendable"` (the BIP-341 NUMS point).
+ Present only on success cases.
- *or* `error` (e.g. `INCORRECT_DATA` for a bad name, `NOT_SUPPORTED` for a
non-sane policy).
diff --git a/test_vectors/register_wallet.toml b/test_vectors/register_wallet.toml
index 68d55f6..bed7b0d 100644
--- a/test_vectors/register_wallet.toml
+++ b/test_vectors/register_wallet.toml
@@ -19,6 +19,7 @@ keys_info = [
wallet_name = "Cold storage"
expected_wallet_id = "1d150ed425a871a5ca7e2c55db1b3295c1fd97147fd0a7b188d4327f4ba7402a"
expected_wallet_hmac = "fa73e36119324fbe4cc1ca94aa842c6261526d44112a22164bc57c3335102b04"
+expected_key_types = ["external", "internal"]
[[case]]
name = "multisig_sh_wit_2of2"
@@ -31,6 +32,7 @@ keys_info = [
wallet_name = "Cold storage"
expected_wallet_id = "763926f53be53ad89a9248dc15bc2f3ed577a59a87d81cd88f14279b263b31f6"
expected_wallet_hmac = "1f498e7444841b883c4a63e2b88a5cad297c289d235794f8e3e17cf559ed0654"
+expected_key_types = ["external", "internal"]
[[case]]
name = "multisig_wit_2of2"
@@ -43,6 +45,7 @@ keys_info = [
wallet_name = "Cold storage"
expected_wallet_id = "cd9474ae9e74403128477789789db43a215e996af80d60120f0d844f8404ac64"
expected_wallet_hmac = "d7c7a60b4ab4a14c1bf8901ba627d72140b2fb907f2b4e35d2e693bce9fbb371"
+expected_key_types = ["external", "internal"]
[[case]]
name = "multisig_wit_2of2_long_name"
@@ -55,6 +58,7 @@ keys_info = [
wallet_name = "Cold storage with a pretty long name that requires 64 characters"
expected_wallet_id = "57f64b36153b819c624dedd0ba3ba491000c41652087b5b265b2460508b09620"
expected_wallet_hmac = "42ea7900175227ee3ea259a0a061dda232dce3e93707d0940f9dc63bab50d35a"
+expected_key_types = ["external", "internal"]
[[case]]
name = "unusual_singlesig_legacy"
@@ -66,6 +70,7 @@ keys_info = [
wallet_name = "Unusual legacy"
expected_wallet_id = "409d4fb1e165e94fb7a822f82ce648e3cc263f06311b790d7f6299ee7fcb5987"
expected_wallet_hmac = "5d4a77a905b1be578a638f5c037a9a646af5ad71c16f813913bb9aba7abac795"
+expected_key_types = ["internal"]
[[case]]
name = "unusual_singlesig_nested_segwit"
@@ -77,6 +82,7 @@ keys_info = [
wallet_name = "Unusual nested_segwit"
expected_wallet_id = "70e97d1e974a5cf46cd13b8c3358850c51873a1a1ab8b60647ed35520a76e870"
expected_wallet_hmac = "f7e49a379e71cfe6645a564c641f1b5232aeb6696c862f4f3df581236bd451c4"
+expected_key_types = ["internal"]
[[case]]
name = "unusual_singlesig_native_segwit"
@@ -88,6 +94,7 @@ keys_info = [
wallet_name = "Unusual native_segwit"
expected_wallet_id = "d5de0973b368d69f67166391d66b8ead31478a6e7dda5c9acbeff185523e1f64"
expected_wallet_hmac = "b9c66ce0adb1684c2f2cf51e280c3a95746393ddfe32ebfb6938922a37b438d9"
+expected_key_types = ["internal"]
[[case]]
name = "unusual_singlesig_taproot"
@@ -99,6 +106,7 @@ keys_info = [
wallet_name = "Unusual taproot"
expected_wallet_id = "4e9679b3f4aaf54a3ffdef160963bc5b4d6ac706499d2317e8514819a9777921"
expected_wallet_hmac = "9006e41fe8ba29dd93aeae79dc6b3513db42d2fbcf18dbda5df860342e137fec"
+expected_key_types = ["internal"]
[[case]]
name = "miniscript_long_policy"
@@ -112,6 +120,7 @@ keys_info = [
wallet_name = "Long policy"
expected_wallet_id = "71b806a6ef8102626f8c9126a0bac51efca19fadd295c42245be240138bb0b57"
expected_wallet_hmac = "c018966ba0416569d3510c93f5c6cd88c4338a9192e3de3a94db9d27f58615b3"
+expected_key_types = ["internal", "external", "external"]
[[case]]
name = "miniscript_minmax_relative_timelocks"
@@ -125,6 +134,7 @@ keys_info = [
wallet_name = "Relative timelocks"
expected_wallet_id = "3d9900113d87d80c72ca9d036d1f7aa163cea194629afa4c4946409e05b00fcb"
expected_wallet_hmac = "65fda496184f9f720c2d3eb47169b94d2390fe2a085e6d2c43eb6d12d7608af3"
+expected_key_types = ["internal", "external", "external"]
[[case]]
name = "tr_script_pk"
@@ -137,6 +147,7 @@ keys_info = [
wallet_name = "Taproot foreign internal key, and our script key"
expected_wallet_id = "ba1a89c470ab0c36c38beaebb577b754c33d90b51df53977e2b01018fbe25f33"
expected_wallet_hmac = "dae925660e20859ed8833025d46444483ce264fdb77e34569aabe9d590da8fb7"
+expected_key_types = ["external", "internal"]
[[case]]
name = "tr_nums_keypath"
@@ -149,6 +160,7 @@ keys_info = [
wallet_name = "Taproot unspendable keypath"
expected_wallet_id = "ba6c11196b604c062a27e195bcbdfe6c1fe1fa61e92256974b5c53c2fc2e2172"
expected_wallet_hmac = "7af8844fb3f176337acd66fb76113b5e250e1be75a0845964587219b297fda59"
+expected_key_types = ["unspendable", "internal"]
[[case]]
name = "tr_script_sortedmulti_a"
@@ -162,6 +174,7 @@ keys_info = [
wallet_name = "Taproot single-key or multisig 2-of-2"
expected_wallet_id = "46c498a049e164ec7feaf167c3ae0f4282217645a338f66f5377bfe67f6a8aa0"
expected_wallet_hmac = "a3f31e9d7b70d1d967413488bae136a8b6c7afd1de0524deb6cf74f5c509b9ab"
+expected_key_types = ["internal", "external", "internal"]
[[case]]
name = "max_derivation_steps"
@@ -173,6 +186,7 @@ keys_info = [
wallet_name = "Max derivation"
expected_wallet_id = "71b58446a17ebc98e654560f4f1fb9003bad49ddde1d3ee9384221f7f2d3dda2"
expected_wallet_hmac = "e3bb8a13e19d74079ee209a99438da0b47f3f430a88130ba18b1c05e626d22c8"
+expected_key_types = ["internal"]
# ---- Deterministic rejections ----
@@ -375,3 +389,16 @@ keys_info = [
wallet_name = "Fingerprint collision"
error = "INCORRECT_DATA"
+[[case]]
+name = "internal_key_with_collision"
+description = "1-of-2 with a fingerprint-collision key (our fp, foreign xpub) plus a real internal key; the collision key must still be classified external"
+descriptor_template = "wsh(multi(1,@0/**,@1/**))"
+keys_info = [
+ "[f5acc2fd/44'/1'/3']tpubDE7NQymr4AFtewpAsWtnreyq9ghkzQBXpCZjWLFVRAvnbf7vya2eMTvT2fPapNqL8SuVvLQdbUbMfWLVDCZKnsEBqp6UK93QEzL8Ck23AwF",
+ "[f5acc2fd/48'/1'/0'/2']tpubDFAqEGNyad35aBCKUAXbQGDjdVhNueno5ZZVEn3sQbW5ci457gLR7HyTmHBg93oourBssgUxuWz1jX5uhc1qaqFo9VsybY1J5FuedLfm4dK",
+]
+wallet_name = "Collision and internal"
+expected_wallet_id = "25bbdfdbc7d7f184ddbea79d52bd1235d95cce799d06b4e7aced22b253d4e266"
+expected_wallet_hmac = "f82f318bdd7d7db9f586b34c2f4985614ec9abf0d9c9ef48b3e57ba246ef0cf2"
+expected_key_types = ["external", "internal"]
+
diff --git a/unit-tests/test_register_wallet.c b/unit-tests/test_register_wallet.c
index 44d8922..b23da6f 100644
--- a/unit-tests/test_register_wallet.c
+++ b/unit-tests/test_register_wallet.c
@@ -74,28 +74,65 @@ typedef struct {
uint8_t expected_wallet_id[32];
bool has_wallet_hmac;
uint8_t expected_wallet_hmac[32];
+ bool has_key_types;
+ key_type_e key_types[MAX_KEYS_PER_CASE];
+ size_t n_key_types;
} testcase_t;
static testcase_t *g_cases = NULL;
static size_t g_n_cases = 0;
-/* The handler references the UI confirmation; we exercise the UI-free flow, so
- * this stub only needs to satisfy the linker (it always approves). Pure UI
- * DENY flows are out of scope for these vectors (see register_wallet.toml). */
+/* Records the arguments the handler passes to the UI confirmation, so the test
+ * can verify the per-key classification (internal/external/unspendable) the
+ * handler computed — not just the aggregate status word. Reset before each case
+ * in case_setup. A file-scope global is the channel: the stub's signature is
+ * fixed by the UI prototype, and the shared mock dispatcher shouldn't carry
+ * test-specific fields. */
+static struct {
+ bool called;
+ size_t n_keys;
+ key_type_e keys_type[MAX_N_KEYS_IN_WALLET_POLICY];
+ char descriptor_template[MAX_TPL_LEN];
+ char name[MAX_NAME_LEN];
+ uint8_t version;
+} g_ui_capture;
+
+/* The handler calls this once the policy is accepted, just before computing the
+ * HMAC. We capture its arguments and approve (return true); pure UI DENY flows
+ * are out of scope for these vectors (see register_wallet.toml). */
bool ui_display_register_wallet_policy(
dispatcher_context_t *context,
const policy_map_wallet_header_t *wallet_header,
const char *descriptor_template,
const char (*keys_info)[MAX_N_KEYS_IN_WALLET_POLICY][MAX_POLICY_KEY_INFO_LEN + 1],
const key_type_e (*keys_type)[MAX_N_KEYS_IN_WALLET_POLICY]) {
- (void) context;
- (void) wallet_header;
- (void) descriptor_template;
(void) keys_info;
- (void) keys_type;
+
+ g_ui_capture.called = true;
+ g_ui_capture.n_keys = wallet_header->n_keys;
+ g_ui_capture.version = wallet_header->version;
+ for (size_t i = 0; i < wallet_header->n_keys && i < MAX_N_KEYS_IN_WALLET_POLICY; i++) {
+ g_ui_capture.keys_type[i] = (*keys_type)[i];
+ }
+ snprintf(g_ui_capture.name, sizeof(g_ui_capture.name), "%s", wallet_header->name);
+ snprintf(g_ui_capture.descriptor_template,
+ sizeof(g_ui_capture.descriptor_template),
+ "%s",
+ descriptor_template);
+
+ (void) context;
return true;
}
+/* Map a key-type name from the vectors file to its key_type_e. */
+static key_type_e key_type_from_name(const char *name) {
+ if (strcmp(name, "internal") == 0) return PUBKEY_TYPE_INTERNAL;
+ if (strcmp(name, "external") == 0) return PUBKEY_TYPE_EXTERNAL;
+ if (strcmp(name, "unspendable") == 0) return PUBKEY_TYPE_UNSPENDABLE;
+ fprintf(stderr, "unknown key type: %s\n", name);
+ abort();
+}
+
/* ===========================================================================
* Helpers
* =========================================================================== */
@@ -237,6 +274,28 @@ static void parse_vectors(const char *path) {
memcpy(cur->keys_info[k], key_node.u.str.ptr, (size_t) key_node.u.str.len);
cur->keys_info[k][key_node.u.str.len] = '\0';
}
+
+ /* Optional per-key classification (one entry per key, @0,@1,... order). */
+ toml_datum_t kt = toml_get(tc_node, "expected_key_types");
+ cur->has_key_types = (kt.type != TOML_UNKNOWN);
+ if (cur->has_key_types) {
+ if (kt.type != TOML_ARRAY || (size_t) kt.u.arr.size != cur->n_keys) {
+ fprintf(stderr,
+ "%s: expected_key_types must be an array of length n_keys (%zu)\n",
+ cur->name,
+ cur->n_keys);
+ abort();
+ }
+ cur->n_key_types = cur->n_keys;
+ for (size_t k = 0; k < cur->n_keys; k++) {
+ toml_datum_t e = kt.u.arr.elem[k];
+ if (e.type != TOML_STRING) {
+ fprintf(stderr, "%s: expected_key_types[%zu] is not a string\n", cur->name, k);
+ abort();
+ }
+ cur->key_types[k] = key_type_from_name(e.u.str.ptr);
+ }
+ }
}
toml_free(r);
@@ -265,6 +324,7 @@ static int case_setup(void **state) {
}
cs->tc = tc;
*state = cs;
+ memset(&g_ui_capture, 0, sizeof(g_ui_capture));
return 0;
}
@@ -349,6 +409,8 @@ static void test_one_case(void **state) {
if (tc->has_error) {
assert_int_equal(mock->last_sw, error_name_to_sw(tc->error));
+ /* A deterministic rejection must return before the UI confirmation. */
+ assert_false(g_ui_capture.called);
return;
}
@@ -359,6 +421,21 @@ static void test_one_case(void **state) {
if (tc->has_wallet_hmac) {
assert_memory_equal(mock->request_buf + 32, tc->expected_wallet_hmac, 32);
}
+
+ /* The UI confirmation must have been shown, with the parsed header intact. */
+ assert_true(g_ui_capture.called);
+ assert_int_equal(g_ui_capture.version, WALLET_POLICY_VERSION_V2);
+ assert_int_equal(g_ui_capture.n_keys, tc->n_keys);
+ assert_string_equal(g_ui_capture.descriptor_template, tc->descriptor_template);
+ assert_string_equal(g_ui_capture.name, tc->wallet_name);
+
+ /* When pinned, verify the per-key classification (NUMS / internal-key
+ * re-derivation / fingerprint-collision guard) the handler computed. */
+ if (tc->has_key_types) {
+ for (size_t i = 0; i < tc->n_key_types; i++) {
+ assert_int_equal(g_ui_capture.keys_type[i], tc->key_types[i]);
+ }
+ }
}
/* Hand-written framing case: an empty command buffer makes the leading
Why this scored 12/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.