Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions host/origo_sdl.c
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ void seedtool_display_nav_text(
present();
}

void seedtool_display_nav_grouped(const seedtool_nav_t* nav, const char* title, const char* const* lines,
const size_t* first_group, const size_t count)
{
seedtool_render_nav_grouped(nav, title, lines, first_group, count);
present();
}

void seedtool_display_nav_rows(const seedtool_nav_t* nav, const char* title, const char* const* rows, const size_t count)
{
seedtool_render_nav_rows(nav, title, rows, count);
Expand Down Expand Up @@ -162,6 +169,15 @@ bool seedtool_display_qr(const char* title, const char* text)
return ok;
}

bool seedtool_display_qr_address(const char* title, const char* text)
{
const bool ok = seedtool_render_qr_address(title, text);
if (ok) {
present();
}
return ok;
}

bool seedtool_display_qr_bytes(const char* title, const uint8_t* data, const size_t len)
{
const bool ok = seedtool_render_qr_bytes(title, data, len);
Expand Down
261 changes: 248 additions & 13 deletions host/origo_simulator.c
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ static bool wire_order_is_big_endian(void)
.confirm = NULL,
.confirm_enabled = false,
.back = false,
.confirm_as_tick = true };
.confirm_style = SEEDTOOL_CONFIRM_TICK };

seedtool_render_nav_list(&nav, "Origo", items, 3, 0);
const uint16_t* const pixels = seedtool_render_pixels();
Expand Down Expand Up @@ -1149,7 +1149,7 @@ static seedtool_nav_t stackbit_test_nav(void)
.confirm = NULL,
.confirm_enabled = false,
.back = true,
.confirm_as_tick = true };
.confirm_style = SEEDTOOL_CONFIRM_TICK };
return nav;
}

Expand Down Expand Up @@ -1610,6 +1610,225 @@ static bool nav_left_slot_is_drawn(void)
return false;
}

/* Grouping an address is a drawing decision, so the one thing it must never
* do is change the address. Walking a value with seedtool_render_fit_grouped
* the way page_text_impl does has to reproduce it character for character -
* a group dropped, doubled or reordered at a line break would hand the reader
* an address that is not theirs, and they would have no way to tell.
*
* Also checks the cut lands on a group boundary. That is what keeps the
* alternating ink meaningful across a break: a line ending mid-group would
* put two same-coloured groups against each other with only the gap between
* them, and the gap is the signal the colour is there to back up. */
/* A truncated row has to say so, and has to pay for saying it out of the same
* width. Two things are checked, because either alone passes while the other
* is broken: that the dots stay inside the row's own column rather than
* running under the scrollbar, and that they are drawn at all.
*
* The second is the one worth explaining. A row drawn without the ellipsis
* would be exactly its own fit_row prefix, so rendering that prefix as a row
* in its own right and comparing the two framebuffers says whether anything
* extra reached the screen. Without it this test would pass on a draw_row
* that silently cut, which is the bug it exists for. */
static bool truncated_rows_show_an_ellipsis(void)
{
static uint16_t truncated[SEEDTOOL_DISPLAY_WIDTH * SEEDTOOL_DISPLAY_HEIGHT];
/* Longer than any row can hold, which is what every address row is: an
* index, two spaces and a full address. */
static const char* const row = " 0 bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4";
const seedtool_nav_t nav = { .selected = 0, .back = true };
const char* const items[] = { row };
seedtool_render_nav_list(&nav, "m/84'/0'/0'/0", items, 1, 0);
memcpy(truncated, seedtool_render_pixels(), sizeof(truncated));

int rightmost = -1;
for (int y = 21; y < 55; ++y) {
for (int x = 0; x < SEEDTOOL_DISPLAY_WIDTH; ++x) {
if (truncated[y * SEEDTOOL_DISPLAY_WIDTH + x] != 0x0000 && x > rightmost) {
rightmost = x;
}
}
}
if (rightmost < 0) {
return false; /* the row never reached the screen */
}
/* LIST_TEXT_X + LIST_TEXT_WIDTH in seedtool_render.c, kept in sync with
* the 6 + 222 below: the row's own column, which the ellipsis has to stay
* inside rather than run past into the scrollbar's strip. */
if (rightmost > 6 + 222) {
return false;
}

/* What a silent cut would have drawn. */
char prefix[64] = { 0 };
const size_t fit = seedtool_render_fit_row(row);
if (fit >= sizeof(prefix) || fit >= strlen(row)) {
return false; /* the row was meant to be too long for one */
}
memcpy(prefix, row, fit);
const char* const cut[] = { prefix };
seedtool_render_nav_list(&nav, "m/84'/0'/0'/0", cut, 1, 0);
return memcmp(truncated, seedtool_render_pixels(), sizeof(truncated)) != 0;
}

static bool grouped_paging_preserves_the_value(void)
{
static const char* const values[] = {
/* Both address shapes this firmware derives, plus lengths that land
* exactly on a group boundary and one past it. */
"bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4",
"bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr",
"1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2",
"3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy",
"abcd",
"abcde",
"abcdefgh",
};
for (size_t v = 0; v < sizeof(values) / sizeof(values[0]); ++v) {
const char* const value = values[v];
const size_t total = strlen(value);
char rebuilt[128] = { 0 };
size_t used = 0, offset = 0, lines = 0;
while (offset < total && lines < 32) {
const size_t fit = seedtool_render_fit_grouped(value + offset, 48);
if (!fit) {
return false; /* no progress: the walk would never end */
}
/* A whole number of groups unless this is the value's own tail. */
if (offset + fit < total && fit % SEEDTOOL_GROUP_LEN) {
return false;
}
if (used + fit >= sizeof(rebuilt)) {
return false;
}
memcpy(rebuilt + used, value + offset, fit);
used += fit;
offset += fit;
++lines;
}
rebuilt[used] = '\0';
if (offset != total || strcmp(rebuilt, value) != 0) {
return false;
}
}
return true;
}

/* The QR screen draws the address beside the code, and the margin it has is
* not always enough: a taproot address is 62 characters against the 48 two
* columns of four hold there. seedtool_render_qr_address must refuse those
* outright rather than draw as far as it reaches, because an address cut off
* mid-value looks exactly like one that ended there and nothing on screen
* says otherwise. This is the check grouped_paging_preserves_the_value does
* not make: that one walks seedtool_render_fit_grouped, the paged path at the
* body face, and says nothing about groups drawn by any other route. The first
* version of this screen truncated a taproot address to 48 of its characters
* with every other check still passing.
*
* Proving the tail is drawn without restating the renderer's own geometry:
* change only the value's last group and the margin must change with it. The
* margin is everything left of the code, found by looking for the first column
* carrying a run of white tall enough to be the code itself rather than a line
* of text. */
static int qr_code_left_edge(void)
{
const uint16_t* const pixels = seedtool_render_pixels();
for (int x = 0; x < SEEDTOOL_DISPLAY_WIDTH; ++x) {
int run = 0;
for (int y = 0; y < SEEDTOOL_DISPLAY_HEIGHT; ++y) {
run = pixels[y * SEEDTOOL_DISPLAY_WIDTH + x] == 0xffff ? run + 1 : 0;
if (run > 40) {
return x;
}
}
}
return -1;
}

static bool qr_address_draws_every_group(void)
{
static const char* const fits[] = {
"bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4",
"1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2",
"3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy",
};
static uint16_t before[SEEDTOOL_DISPLAY_WIDTH * SEEDTOOL_DISPLAY_HEIGHT];

for (size_t v = 0; v < sizeof(fits) / sizeof(fits[0]); ++v) {
const char* const value = fits[v];
if (!seedtool_render_qr_address("m/84'/0'/0'/0/0", value)) {
return false; /* these do fit; refusing them is its own failure */
}
const int edge = qr_code_left_edge();
if (edge <= 0) {
return false;
}
memcpy(before, seedtool_render_pixels(), sizeof(before));

/* The same value with its last group altered, same length so the code
* keeps its version and the margin keeps its width. */
char altered[80] = { 0 };
const size_t total = strlen(value);
if (total + 1 > sizeof(altered) || total < SEEDTOOL_GROUP_LEN) {
return false;
}
memcpy(altered, value, total);
for (size_t i = total - SEEDTOOL_GROUP_LEN; i < total; ++i) {
altered[i] = altered[i] == 'q' ? 'p' : 'q';
}
if (!seedtool_render_qr_address("m/84'/0'/0'/0/0", altered)) {
return false;
}
const uint16_t* const after = seedtool_render_pixels();

bool margin_changed = false;
for (int y = 0; y < SEEDTOOL_DISPLAY_HEIGHT && !margin_changed; ++y) {
for (int x = 0; x < edge; ++x) {
if (before[y * SEEDTOOL_DISPLAY_WIDTH + x] != after[y * SEEDTOOL_DISPLAY_WIDTH + x]) {
margin_changed = true;
break;
}
}
}
if (!margin_changed) {
return false; /* the tail was never drawn */
}
}

/* And the one the margin cannot hold, which must say so rather than clip. */
return !seedtool_render_qr_address(
"m/86'/0'/0'/0/0", "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr");
}

/* The grouped line is centred with its gaps counted in, so it can run off an
* edge in a way the plain centred line cannot. Measured, not assumed. */
static bool grouped_lines_clear_the_edges(void)
{
static const char* const lines[] = { "bc1qw508d6qejxtdg4y5r3zarvary0", "bc1p5cyxnuxmeuwuvkwfem96lqzs" };
static const size_t first_group[] = { 0, 7, 14 };
for (size_t i = 0; i < sizeof(lines) / sizeof(lines[0]); ++i) {
/* The widest a line can be: fit_grouped's own answer for this value. */
char line[64] = { 0 };
const size_t fit = seedtool_render_fit_grouped(lines[i], 48);
memcpy(line, lines[i], fit);
const char* const shown[] = { line, line, line };
const seedtool_nav_t nav = { .selected = SEEDTOOL_NAV_BODY,
.confirm = "Continue",
.confirm_enabled = true,
.back = true,
.counter = "1/2" };
seedtool_render_nav_grouped(&nav, "m/84'/0'/0'/0/0", shown, first_group, 3);
const uint16_t* const pixels = seedtool_render_pixels();
for (int y = 30; y < 100; ++y) {
if (pixels[y * SEEDTOOL_DISPLAY_WIDTH] != 0x0000
|| pixels[y * SEEDTOOL_DISPLAY_WIDTH + SEEDTOOL_DISPLAY_WIDTH - 1] != 0x0000) {
return false;
}
}
}
return true;
}

static bool nav_chrome_bands_do_not_collide(void)
{
/* Twelve rows of the widest label review_and_confirm can build: two
Expand All @@ -1633,7 +1852,7 @@ static bool nav_chrome_bands_do_not_collide(void)
.confirm = "Continue",
.confirm_enabled = confirm_enabled,
.back = true,
.confirm_as_tick = true };
.confirm_style = SEEDTOOL_CONFIRM_TICK };
seedtool_render_nav_list(&nav, titles[i], words, 12, 0);
/* Title bar ends at 20, rows run 21..116 (LIST_TOP + 3 *
* NAV_ROW_HEIGHT, less the 2px each row leaves under itself), the
Expand Down Expand Up @@ -1670,13 +1889,13 @@ static bool nav_chrome_bands_do_not_collide(void)
/* A menu wears the chrome with no confirm at all: its rows are its
* actions, so the arrow is the only control. Both places the confirm could
* appear must stay empty - the band along the bottom, and the title bar's
* right slot. The slot is the one that caught a real bug: confirm_as_tick
* right slot. The slot is the one that caught a real bug: the tick
* is set for every list, so the tick drew on the Origo and Wallet menus,
* offering an answer to a question those screens never ask. Nothing here
* noticed, because every check only ever asserted the tick was present. */
for (size_t s = 0; s < 2; ++s) {
const seedtool_nav_t nav
= { .selected = s ? SEEDTOOL_NAV_BACK : 0, .back = true, .confirm_as_tick = true };
= { .selected = s ? SEEDTOOL_NAV_BACK : 0, .back = true, .confirm_style = SEEDTOOL_CONFIRM_TICK };
seedtool_render_nav_list(&nav, "Word entry", words, 2, 0);
if (!nav_band_is_clear(NAV_BAR_BAND_Y, SEEDTOOL_DISPLAY_HEIGHT) || nav_right_slot_is_drawn()) {
return false;
Expand Down Expand Up @@ -1714,15 +1933,15 @@ static bool nav_chrome_bands_do_not_collide(void)
};
for (size_t i = 0; i < sizeof(screens) / sizeof(screens[0]); ++i) {
for (int on_back = 0; on_back < 2; ++on_back) {
/* confirm_as_tick, because that is what nav_screen sets and these
/* confirm_style, because that is what nav_screen sets and these
* are its screens: the confirm is a box in the title bar's right
* slot and no bar is drawn. Rendering them with a bar would have
* this table checking a layout the firmware stopped shipping. */
const seedtool_nav_t nav = { .selected = on_back ? SEEDTOOL_NAV_BACK : SEEDTOOL_NAV_CONFIRM,
.confirm = screens[i].label,
.confirm_enabled = true,
.back = true,
.confirm_as_tick = true };
.confirm_style = SEEDTOOL_CONFIRM_TICK };
seedtool_render_nav_text(&nav, screens[i].title, screens[i].line1, screens[i].line2, NULL);
/* Line 2 sits at 65 and the 16px face is that tall again, so its
* wraps land at 81, 97, 113 - and 113 is inside the bar's own
Expand Down Expand Up @@ -1781,7 +2000,7 @@ static bool nav_chrome_bands_do_not_collide(void)
.confirm = dice[i].label,
.confirm_enabled = true,
.back = true,
.confirm_as_tick = true };
.confirm_style = SEEDTOOL_CONFIRM_TICK };
seedtool_render_nav_dice(&nav, dice[i].title, dice[i].line1, dice[i].line2, &full);
if (!nav_band_is_clear(20, 21) || !nav_band_is_clear(104, 118)) {
return false;
Expand Down Expand Up @@ -1821,7 +2040,7 @@ static bool nav_chrome_bands_do_not_collide(void)
.confirm = NULL,
.confirm_enabled = true,
.back = true,
.confirm_as_tick = true };
.confirm_style = SEEDTOOL_CONFIRM_TICK };
seedtool_render_nav_text(&nav, notices[i].title, notices[i].line1, notices[i].line2, NULL);
/* A notice's only control is now the arrow - if that failed to draw the
* screen would be one the reader cannot leave at all, which is worth
Expand All @@ -1848,7 +2067,7 @@ static bool nav_chrome_bands_do_not_collide(void)
.confirm_enabled = true,
.back = true,
.counter = "8/8",
.confirm_as_tick = true };
.confirm_style = SEEDTOOL_CONFIRM_TICK };
seedtool_render_nav_text(&paged, "Canonical transcript", "20-1-2-1-4-1-1-1-1-1-1-1-1-",
"1-1-1-1-1-1-1-1-1-20-1-1-1-", "1-1-1-1-1-1-1-1-1-1");
if (!nav_band_is_clear(20, 21) || !nav_band_is_clear(116, 118)) {
Expand All @@ -1872,7 +2091,7 @@ static bool nav_chrome_bands_do_not_collide(void)
.confirm_enabled = true,
.back = true,
.counter = "6/6",
.confirm_as_tick = true };
.confirm_style = SEEDTOOL_CONFIRM_TICK };
seedtool_render_nav_rows(&listed, "BIP39 word numbers", rows, 4);
if (!nav_band_is_clear(20, 21) || !nav_band_is_clear(116, 118)) {
return false;
Expand Down Expand Up @@ -2202,6 +2421,22 @@ static int self_test(void)
fputs("Origo backup confirmation screen self-test failed\n", stderr);
return 1;
}
if (!truncated_rows_show_an_ellipsis()) {
fputs("Origo truncated row self-test failed\n", stderr);
return 1;
}
if (!grouped_paging_preserves_the_value()) {
fputs("Origo grouped address paging self-test failed\n", stderr);
return 1;
}
if (!grouped_lines_clear_the_edges()) {
fputs("Origo grouped address geometry self-test failed\n", stderr);
return 1;
}
if (!qr_address_draws_every_group()) {
fputs("Origo address-beside-QR self-test failed\n", stderr);
return 1;
}
if (!nav_chrome_bands_do_not_collide()) {
fputs("Origo nav chrome geometry self-test failed\n", stderr);
return 1;
Expand Down Expand Up @@ -2243,12 +2478,12 @@ static int self_test(void)
.confirm = NULL,
.confirm_enabled = false,
.back = true,
.confirm_as_tick = true };
.confirm_style = SEEDTOOL_CONFIRM_TICK };
const seedtool_nav_t at_start = { .selected = 0,
.confirm = NULL,
.confirm_enabled = false,
.back = true,
.confirm_as_tick = true };
.confirm_style = SEEDTOOL_CONFIRM_TICK };
seedtool_render_nav_list(&scrolled, "Wallet", menu_labels, 7, 4);
seedtool_render_nav_list(&at_start, "Word 3/12 aba", menu_labels, MENU_LABEL_COUNT, 0);
/* Draw the real layouts, each opened at its centre, so one that overflows a
Expand Down
Loading