Show cleartext representation of wallet policy, when possible
What changed, and why it matters
This commit changes how Ledger devices display Bitcoin wallet policies during setup. Instead of always showing the raw technical descriptor, it tries to show a human-readable summary ("cleartext") of the spending rules. For simple multisig wallets, it hides the raw descriptor entirely because the plain-English summary is considered unambiguous. This is a UX improvement, not a fix for a known vulnerability, but it touches security-critical user confirmation code.
Review the cleartext engine (common/cleartext.h and its implementation) to confirm the confusion-score threshold and classification logic cannot be bypassed or misclassify ambiguous descriptors as simple multisig. Ensure that hiding the raw descriptor does not allow a malicious software wallet to register a policy whose cleartext summary is misleading. Consider whether descriptor_hidden should require explicit user opt-in for complex policies.
Security signals we found
UI confirmation flow modified for wallet policy registration
Raw descriptor template hidden for simple multisig policies
New dependency on cleartext classification/confusion-score engine
Bounds assertions added for cleartext line count
No cryptographic, parsing, or memory-safety bug visible in diff
Evidence from the diff
The register-wallet handler now invokes a cleartext engine: it computes a confusion score for the parsed wallet policy and, if below a threshold, encodes human-readable spending-path lines. Those lines are shown before the raw descriptor template in the NBGL UI flow. For policies classified as DC_MULTISIG, the raw descriptor template is omitted (descriptor_to_show set to NULL) because the cleartext lines are deemed sufficiently unambiguous. The change adds bounds checks (LEDGER_ASSERT on n_cleartext_lines <= CT_MAX_LINES), updates static assertions for UX pair capacity, and adapts unit/JS tests to expect descriptor_hidden for multisig cases.
Changed components
src/handler/register_wallet.csrc/ui/display.csrc/ui/display.hsrc/ui/display_nbgl.cunit-tests/test_register_wallet.ctest_vectors/register_wallet.tomlbitcoin_client_js test automationsInspect captured patch +146 / −14
diff --git a/bitcoin_client_js/src/__tests__/automations/register_wallet_accept.json b/bitcoin_client_js/src/__tests__/automations/register_wallet_accept.json
index 0091a7e..e312e30 100644
--- a/bitcoin_client_js/src/__tests__/automations/register_wallet_accept.json
+++ b/bitcoin_client_js/src/__tests__/automations/register_wallet_accept.json
@@ -2,7 +2,7 @@
"version": 1,
"rules": [
{
- "regexp": "Review account|Account name|Wallet policy|Review co-signer|Our|Their",
+ "regexp": "Review account|Account name|Spending policy|Wallet policy|Review co-signer|Our|Their",
"actions": [
["button", 2, true],
["button", 2, false]
diff --git a/src/handler/register_wallet.c b/src/handler/register_wallet.c
index eb6c61a..0e21c87 100644
--- a/src/handler/register_wallet.c
+++ b/src/handler/register_wallet.c
@@ -41,6 +41,7 @@
#include "policy.h"
#include "sw.h"
#include "wallet.h"
+#include "common/cleartext.h"
static bool is_policy_acceptable(const policy_node_t *policy);
static bool is_policy_name_acceptable(const char *name, size_t name_len);
@@ -223,10 +224,46 @@ void handler_register_wallet(dispatcher_context_t *dc, uint8_t protocol_version)
return;
}
+ // Try to compute the cleartext spending-path lines. If the descriptor
+ // doesn't classify (DC_OTHER), has non-canonical derivations, or its
+ // confusion score exceeds the threshold, the cleartext block is skipped
+ // and the UX falls back to the existing raw-descriptor-template screen.
+ char cleartext_lines[CT_MAX_LINES][CT_MAX_LINE_LEN + 1];
+ size_t n_cleartext_lines = 0;
+ bool has_cleartext = false;
+ descriptor_class_e cleartext_class = DC_OTHER;
+
+ if (cleartext_confusion_score(&policy_map.parsed) <= CLEARTEXT_MAX_CONFUSION_SCORE) {
+ int rc = cleartext_encode(&policy_map.parsed,
+ NULL,
+ cleartext_lines,
+ &n_cleartext_lines,
+ &has_cleartext,
+ &cleartext_class);
+ if (rc <= 0 || !has_cleartext) {
+ // Either the descriptor doesn't classify (rc == 0), an internal
+ // error occurred (rc == -1), or at least one part of the
+ // descriptor lacks a cleartext rendering — in all cases keep
+ // the current behaviour (raw descriptor template only).
+ n_cleartext_lines = 0;
+ }
+ }
+
+ // For multisig policies (all coalesced into DC_MULTISIG),
+ // the cleartext "Any K of <keys> must sign" captures the spending policy
+ // with little risk of ambiguity. Therefore, it is safe to hide the
+ // raw descriptor template, simplifying the UX.
+ const char *descriptor_to_show = (char *) policy_map_descriptor;
+ if (has_cleartext && n_cleartext_lines > 0 && cleartext_class == DC_MULTISIG) {
+ descriptor_to_show = NULL;
+ }
+
// show wallet policy
if (!ui_display_register_wallet_policy(dc,
&wallet_header,
- (char *) policy_map_descriptor,
+ descriptor_to_show,
+ &cleartext_lines,
+ n_cleartext_lines,
&keys_info,
&keys_type)) {
SEND_SW(dc, SW_DENY);
diff --git a/src/ui/display.c b/src/ui/display.c
index 1cee839..7918277 100644
--- a/src/ui/display.c
+++ b/src/ui/display.c
@@ -125,6 +125,8 @@ bool ui_display_register_wallet_policy(
dispatcher_context_t *context,
const policy_map_wallet_header_t *wallet_header,
const char *descriptor_template,
+ const char (*cleartext_lines)[CT_MAX_LINES][CT_MAX_LINE_LEN + 1],
+ size_t n_cleartext_lines,
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]) {
#ifdef HAVE_AUTOAPPROVE_FOR_PERF_TESTS
@@ -139,6 +141,15 @@ bool ui_display_register_wallet_policy(
state->n_keys = wallet_header->n_keys;
state->wallet_name = wallet_header->name;
state->descriptor_template = descriptor_template;
+
+ LEDGER_ASSERT(n_cleartext_lines <= CT_MAX_LINES, "Too many cleartext lines");
+ if (cleartext_lines == NULL || n_cleartext_lines == 0) {
+ state->cleartext_lines = NULL;
+ state->n_cleartext_lines = 0;
+ } else {
+ state->cleartext_lines = *cleartext_lines;
+ state->n_cleartext_lines = n_cleartext_lines;
+ }
for (size_t i = 0; i < wallet_header->n_keys; i++) {
state->keys_info[i] = (*keys_info)[i];
#ifdef SCREEN_SIZE_WALLET
diff --git a/src/ui/display.h b/src/ui/display.h
index 42b00dc..35facad 100644
--- a/src/ui/display.h
+++ b/src/ui/display.h
@@ -16,6 +16,7 @@
#include "script.h"
#include "sw.h"
#include "wallet.h"
+#include "common/cleartext.h"
#define MESSAGE_CHUNK_SIZE 64 // Protocol specific
// Displayed message length - if the message is too long we will not display it
@@ -91,7 +92,15 @@ typedef struct {
typedef struct {
const char *wallet_name;
+ // The raw descriptor template, displayed after the cleartext lines.
+ // `NULL` means "do not show the descriptor template" (used for very
+ // simple multisig wallet policies, that have little ambiguity).
const char *descriptor_template;
+ // Cleartext spending-path lines, displayed before the descriptor template.
+ // Pointer to caller-owned memory (the register-wallet handler's stack).
+ // `n_cleartext_lines == 0` means "no cleartext to display".
+ const char (*cleartext_lines)[CT_MAX_LINE_LEN + 1];
+ size_t n_cleartext_lines;
size_t n_keys;
char keys_label[MAX_N_KEYS_IN_WALLET_POLICY][MAX_KEY_LABEL_LENGTH];
const char *keys_info[MAX_N_KEYS_IN_WALLET_POLICY];
@@ -149,10 +158,15 @@ bool ui_display_message_and_confirm(dispatcher_context_t *context,
const char *message,
bool is_hash);
+// Reviews a wallet policy to register. Pass `descriptor_template == NULL` to
+// hide the raw descriptor template (when the cleartext lines already fully
+// capture the policy); otherwise it is shown after the cleartext block.
bool ui_display_register_wallet_policy(
dispatcher_context_t *context,
const policy_map_wallet_header_t *wallet_header,
const char *descriptor_template,
+ const char (*cleartext_lines)[CT_MAX_LINES][CT_MAX_LINE_LEN + 1],
+ size_t n_cleartext_lines,
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]);
diff --git a/src/ui/display_nbgl.c b/src/ui/display_nbgl.c
index 5e2352f..ef2cce0 100644
--- a/src/ui/display_nbgl.c
+++ b/src/ui/display_nbgl.c
@@ -321,7 +321,7 @@ void ui_display_receive_in_wallet_flow(void) {
}
void ui_display_register_wallet_policy_flow(void) {
- _Static_assert(N_UX_PAIRS >= 3 + MAX_N_KEYS_IN_WALLET_POLICY,
+ _Static_assert(N_UX_PAIRS >= 3 + MAX_N_KEYS_IN_WALLET_POLICY + CT_MAX_LINES,
"Insufficient pairs for this flow");
confirmed_status = "Account registered";
@@ -334,14 +334,42 @@ void ui_display_register_wallet_policy_flow(void) {
.value = g_ui_state.register_wallet_policy.wallet_name,
};
- pairs[n_pairs++] = (nbgl_layoutTagValue_t) {
+ // Cleartext spending-path block, when available.
+ if (g_ui_state.register_wallet_policy.n_cleartext_lines > 0 &&
+ g_ui_state.register_wallet_policy.cleartext_lines != NULL) {
+ static char path_labels[CT_MAX_LINES][sizeof("Spending path #99")];
+ size_t n = g_ui_state.register_wallet_policy.n_cleartext_lines;
+
+ LEDGER_ASSERT(n <= CT_MAX_LINES, "Too many cleartext lines");
+ for (size_t i = 0; i < n; i++) {
+ const char *label;
+ if (n == 1) {
+ label = "Spending policy";
+ } else if (i == 0) {
+ label = "Primary spending path";
+ } else {
+ snprintf(path_labels[i], sizeof(path_labels[i]), "Spending path #%u", (unsigned) i);
+ label = path_labels[i];
+ }
+ pairs[n_pairs++] = (nbgl_layoutTagValue_t) {
+ .item = label,
+ .value = g_ui_state.register_wallet_policy.cleartext_lines[i],
+ };
+ }
+ }
+
+ // The descriptor template is hidden (NULL) when the cleartext rendering
+ // already fully captures the policy (e.g. multisig); show it otherwise.
+ if (g_ui_state.register_wallet_policy.descriptor_template != NULL) {
+ pairs[n_pairs++] = (nbgl_layoutTagValue_t) {
#ifdef SCREEN_SIZE_WALLET
- .item = "Descriptor template",
+ .item = "Descriptor template",
#else
- .item = "Wallet policy",
+ .item = "Wallet policy",
#endif
- .value = g_ui_state.register_wallet_policy.descriptor_template,
- };
+ .value = g_ui_state.register_wallet_policy.descriptor_template,
+ };
+ }
pairs[n_pairs++] = (nbgl_contentTagValue_t) {.centeredInfo = true,
.item = "Review co-signer\npublic keys",
@@ -354,7 +382,7 @@ void ui_display_register_wallet_policy_flow(void) {
}
nbgl_useCaseReviewLight(TYPE_OPERATION,
- make_pair_list(n_pairs, false),
+ make_pair_list(n_pairs, true),
&ICON_APP_ACTION,
"Review account\nto register",
NULL,
diff --git a/test_vectors/register_wallet.toml b/test_vectors/register_wallet.toml
index bed7b0d..cb6da21 100644
--- a/test_vectors/register_wallet.toml
+++ b/test_vectors/register_wallet.toml
@@ -20,6 +20,7 @@ wallet_name = "Cold storage"
expected_wallet_id = "1d150ed425a871a5ca7e2c55db1b3295c1fd97147fd0a7b188d4327f4ba7402a"
expected_wallet_hmac = "fa73e36119324fbe4cc1ca94aa842c6261526d44112a22164bc57c3335102b04"
expected_key_types = ["external", "internal"]
+descriptor_hidden = true
[[case]]
name = "multisig_sh_wit_2of2"
@@ -33,6 +34,7 @@ wallet_name = "Cold storage"
expected_wallet_id = "763926f53be53ad89a9248dc15bc2f3ed577a59a87d81cd88f14279b263b31f6"
expected_wallet_hmac = "1f498e7444841b883c4a63e2b88a5cad297c289d235794f8e3e17cf559ed0654"
expected_key_types = ["external", "internal"]
+descriptor_hidden = true
[[case]]
name = "multisig_wit_2of2"
@@ -46,6 +48,7 @@ wallet_name = "Cold storage"
expected_wallet_id = "cd9474ae9e74403128477789789db43a215e996af80d60120f0d844f8404ac64"
expected_wallet_hmac = "d7c7a60b4ab4a14c1bf8901ba627d72140b2fb907f2b4e35d2e693bce9fbb371"
expected_key_types = ["external", "internal"]
+descriptor_hidden = true
[[case]]
name = "multisig_wit_2of2_long_name"
@@ -59,6 +62,7 @@ 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"]
+descriptor_hidden = true
[[case]]
name = "unusual_singlesig_legacy"
@@ -401,4 +405,5 @@ wallet_name = "Collision and internal"
expected_wallet_id = "25bbdfdbc7d7f184ddbea79d52bd1235d95cce799d06b4e7aced22b253d4e266"
expected_wallet_hmac = "f82f318bdd7d7db9f586b34c2f4985614ec9abf0d9c9ef48b3e57ba246ef0cf2"
expected_key_types = ["external", "internal"]
+descriptor_hidden = true
diff --git a/unit-tests/test_register_wallet.c b/unit-tests/test_register_wallet.c
index b23da6f..5472c24 100644
--- a/unit-tests/test_register_wallet.c
+++ b/unit-tests/test_register_wallet.c
@@ -77,6 +77,9 @@ typedef struct {
bool has_key_types;
key_type_e key_types[MAX_KEYS_PER_CASE];
size_t n_key_types;
+ /* When true, the handler is expected to hide the raw descriptor template and
+ * confirm via the cleartext block only (the case for multisig policies). */
+ bool descriptor_hidden;
} testcase_t;
static testcase_t *g_cases = NULL;
@@ -92,7 +95,11 @@ static struct {
bool called;
size_t n_keys;
key_type_e keys_type[MAX_N_KEYS_IN_WALLET_POLICY];
+ /* The handler passes a NULL descriptor template when it chooses to hide the
+ * raw descriptor in favour of the cleartext block (multisig policies). */
+ bool descriptor_shown;
char descriptor_template[MAX_TPL_LEN];
+ size_t n_cleartext_lines;
char name[MAX_NAME_LEN];
uint8_t version;
} g_ui_capture;
@@ -104,21 +111,32 @@ bool ui_display_register_wallet_policy(
dispatcher_context_t *context,
const policy_map_wallet_header_t *wallet_header,
const char *descriptor_template,
+ const char (*cleartext_lines)[CT_MAX_LINES][CT_MAX_LINE_LEN + 1],
+ size_t n_cleartext_lines,
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) keys_info;
+ (void) cleartext_lines;
g_ui_capture.called = true;
g_ui_capture.n_keys = wallet_header->n_keys;
g_ui_capture.version = wallet_header->version;
+ g_ui_capture.n_cleartext_lines = n_cleartext_lines;
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);
+ /* A NULL template means the handler hid the raw descriptor (cleartext-only
+ * multisig screen); record that rather than capturing the "(null)" string. */
+ g_ui_capture.descriptor_shown = (descriptor_template != NULL);
+ if (g_ui_capture.descriptor_shown) {
+ snprintf(g_ui_capture.descriptor_template,
+ sizeof(g_ui_capture.descriptor_template),
+ "%s",
+ descriptor_template);
+ } else {
+ g_ui_capture.descriptor_template[0] = '\0';
+ }
(void) context;
return true;
@@ -275,6 +293,18 @@ static void parse_vectors(const char *path) {
cur->keys_info[k][key_node.u.str.len] = '\0';
}
+ /* Optional: whether the handler should hide the raw descriptor template
+ * in favour of the cleartext-only screen. Defaults to false. */
+ toml_datum_t dh = toml_get(tc_node, "descriptor_hidden");
+ if (dh.type == TOML_BOOLEAN) {
+ cur->descriptor_hidden = dh.u.boolean;
+ } else if (dh.type != TOML_UNKNOWN) {
+ fprintf(stderr, "%s: descriptor_hidden must be a boolean\n", cur->name);
+ abort();
+ } else {
+ cur->descriptor_hidden = false;
+ }
+
/* 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);
@@ -426,7 +456,14 @@ static void test_one_case(void **state) {
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);
+ if (tc->descriptor_hidden) {
+ /* The raw descriptor is hidden; the cleartext block must stand in for it. */
+ assert_false(g_ui_capture.descriptor_shown);
+ assert_true(g_ui_capture.n_cleartext_lines > 0);
+ } else {
+ assert_true(g_ui_capture.descriptor_shown);
+ 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
Why this scored 22/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.