diff --git a/core/embed/rust/src/ui/model_tr/component/mod.rs b/core/embed/rust/src/ui/model_tr/component/mod.rs index 9e9c49a25..a54a6d08d 100644 --- a/core/embed/rust/src/ui/model_tr/component/mod.rs +++ b/core/embed/rust/src/ui/model_tr/component/mod.rs @@ -18,6 +18,7 @@ mod pin; mod result_anim; mod result_popup; mod scrollbar; +mod share_words; mod simple_choice; use super::theme; @@ -45,4 +46,5 @@ pub use pin::{PinEntry, PinEntryMsg}; pub use result_anim::{ResultAnim, ResultAnimMsg}; pub use result_popup::{ResultPopup, ResultPopupMsg}; pub use scrollbar::ScrollBar; +pub use share_words::ShareWords; pub use simple_choice::{SimpleChoice, SimpleChoiceMsg}; diff --git a/core/embed/rust/src/ui/model_tr/component/page.rs b/core/embed/rust/src/ui/model_tr/component/page.rs index 87a5c16c6..5b561b209 100644 --- a/core/embed/rust/src/ui/model_tr/component/page.rs +++ b/core/embed/rust/src/ui/model_tr/component/page.rs @@ -20,6 +20,8 @@ pub struct ButtonPage { back_btn_details: Option>, next_btn_details: Option>, buttons: Child>, + /// Scrollbar may or may not be shown (but will be counting pages anyway). + show_scrollbar: bool, } impl ButtonPage<&'static str, T> @@ -32,7 +34,7 @@ where Self { content: Child::new(content), scrollbar: Child::new(ScrollBar::vertical_to_be_filled_later()), - pad: Pad::with_background(background), + pad: Pad::with_background(background).with_clear(), cancel_btn_details: Some(ButtonDetails::cancel_icon()), confirm_btn_details: Some(ButtonDetails::text("CONFIRM")), back_btn_details: Some(ButtonDetails::up_arrow_icon_wide()), @@ -41,6 +43,7 @@ where // Initial button layout will be set in `place()` after we can call // `content.page_count()`. buttons: Child::new(ButtonController::new(ButtonLayout::empty())), + show_scrollbar: true, } } } @@ -55,7 +58,7 @@ where Self { content: Child::new(content), scrollbar: Child::new(ScrollBar::vertical_to_be_filled_later()), - pad: Pad::with_background(background), + pad: Pad::with_background(background).with_clear(), cancel_btn_details: Some(ButtonDetails::cancel_icon()), confirm_btn_details: Some(ButtonDetails::text("CONFIRM".into())), back_btn_details: Some(ButtonDetails::up_arrow_icon_wide()), @@ -64,6 +67,7 @@ where // Initial button layout will be set in `place()` after we can call // `content.page_count()`. buttons: Child::new(ButtonController::new(ButtonLayout::empty())), + show_scrollbar: true, } } } @@ -95,6 +99,11 @@ where self } + pub fn with_scrollbar(mut self, show: bool) -> Self { + self.show_scrollbar = show; + self + } + /// Basically just determining whether the right button for /// initial page should be "NEXT" or "CONFIRM". /// Can only be called when we know the final page_count. @@ -160,8 +169,13 @@ where fn place(&mut self, bounds: Rect) -> Rect { let (content_and_scrollbar_area, button_area) = bounds.split_bottom(theme::BUTTON_HEIGHT); - let (content_area, scrollbar_area) = - content_and_scrollbar_area.split_right(ScrollBar::WIDTH); + let (content_area, scrollbar_area) = { + if self.show_scrollbar { + content_and_scrollbar_area.split_right(ScrollBar::WIDTH) + } else { + (content_and_scrollbar_area, Rect::zero()) + } + }; let content_area = content_area.inset(Insets::top(1)); // Do not pad the button area nor the scrollbar, leave it to them self.pad.place(content_area); @@ -170,7 +184,9 @@ where // and we can calculate the page count let page_count = self.content.inner_mut().page_count(); self.scrollbar.inner_mut().set_page_count(page_count); - self.scrollbar.place(scrollbar_area); + if self.show_scrollbar { + self.scrollbar.place(scrollbar_area); + } self.set_buttons_for_initial_page(page_count); self.buttons.place(button_area); bounds @@ -215,7 +231,9 @@ where fn paint(&mut self) { self.pad.paint(); self.content.paint(); - self.scrollbar.paint(); + if self.show_scrollbar { + self.scrollbar.paint(); + } self.buttons.paint(); } } diff --git a/core/embed/rust/src/ui/model_tr/component/share_words.rs b/core/embed/rust/src/ui/model_tr/component/share_words.rs new file mode 100644 index 000000000..b14a3cae7 --- /dev/null +++ b/core/embed/rust/src/ui/model_tr/component/share_words.rs @@ -0,0 +1,96 @@ +use crate::{ + micropython::buffer::StrBuffer, + ui::{ + component::{Component, Event, EventCtx, Never, Paginate}, + display::Font, + geometry::{Offset, Rect}, + }, +}; + +use heapless::{String, Vec}; + +use super::common::display_inverse; + +const WORDS_PER_PAGE: usize = 3; +const EXTRA_LINE_HEIGHT: i16 = 3; +const NUMBER_X_OFFSET: i16 = 5; +const NUMBER_WORD_OFFSET: i16 = 20; +const NUMBER_FONT: Font = Font::DEMIBOLD; +const WORD_FONT: Font = Font::NORMAL; + +/// Showing the given share words. +/// +/// Displays them in inverse colors - black text on white background. +/// It is that because of the OLED side attack - lot of white noise makes +/// the attack much harder. +pub struct ShareWords { + area: Rect, + share_words: Vec, + word_index: usize, +} + +impl ShareWords { + pub fn new(share_words: Vec) -> Self { + Self { + area: Rect::zero(), + share_words, + word_index: 0, + } + } +} + +impl Component for ShareWords { + type Msg = Never; + + fn place(&mut self, bounds: Rect) -> Rect { + self.area = bounds; + self.area + } + + fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option { + None + } + + fn paint(&mut self) { + let mut y_offset = 0; + // Showing the word index and the words itself + for i in 0..WORDS_PER_PAGE { + y_offset += NUMBER_FONT.line_height() + EXTRA_LINE_HEIGHT; + let index = self.word_index + i; + let word = self.share_words[index].clone(); + let baseline = self.area.top_left() + Offset::new(NUMBER_X_OFFSET, y_offset); + display_inverse(baseline, &inttostr!(index as u8 + 1), NUMBER_FONT); + display_inverse(baseline + Offset::x(NUMBER_WORD_OFFSET), &word, WORD_FONT); + } + } +} + +impl Paginate for ShareWords { + fn page_count(&mut self) -> usize { + if self.share_words.len() % WORDS_PER_PAGE == 0 { + self.share_words.len() / WORDS_PER_PAGE + } else { + self.share_words.len() / WORDS_PER_PAGE + 1 + } + } + + fn change_page(&mut self, active_page: usize) { + self.word_index = active_page * WORDS_PER_PAGE; + } +} + +#[cfg(feature = "ui_debug")] +impl crate::trace::Trace for ShareWords { + fn trace(&self, t: &mut dyn crate::trace::Tracer) { + t.open("ShareWords"); + t.content_flag(); + for i in 0..WORDS_PER_PAGE { + let index = self.word_index + i; + let word = self.share_words[index].clone(); + let content = build_string!(20, inttostr!(index as u8 + 1), " ", &word, "\n"); + t.string(&content); + } + t.content_flag(); + t.close(); + } +} diff --git a/core/embed/rust/src/ui/model_tr/layout.rs b/core/embed/rust/src/ui/model_tr/layout.rs index 556ea57ac..f5cfb0477 100644 --- a/core/embed/rust/src/ui/model_tr/layout.rs +++ b/core/embed/rust/src/ui/model_tr/layout.rs @@ -27,7 +27,7 @@ use super::{ component::{ Bip39Entry, Bip39EntryMsg, ButtonActions, ButtonDetails, ButtonLayout, ButtonPage, Flow, FlowMsg, FlowPages, Frame, Page, PassphraseEntry, PassphraseEntryMsg, PinEntry, - PinEntryMsg, SimpleChoice, SimpleChoiceMsg, + PinEntryMsg, ShareWords, SimpleChoice, SimpleChoiceMsg, }, theme, }; @@ -463,37 +463,18 @@ extern "C" fn request_pin(n_args: usize, args: *const Obj, kwargs: *mut Map) -> extern "C" fn show_share_words(n_args: usize, args: *const Obj, kwargs: *mut Map) -> Obj { let block = |_args: &[Obj], kwargs: &Map| { - let share_words: StrBuffer = kwargs.get(Qstr::MP_QSTR_share_words)?.try_into()?; + let share_words_obj: Obj = kwargs.get(Qstr::MP_QSTR_share_words)?; + let share_words: Vec = iter_into_vec(share_words_obj)?; - let get_page = move |page_index| match page_index { - 0 => Page::<10>::new( - ButtonLayout::cancel_and_arrow_down(), - ButtonActions::cancel_next(), - Font::BOLD, - ) - .text_bold("Write all words in order on recovery seed card.".into()) - .newline() - .newline_half() - .text_mono("Do NOT make digital copies.".into()), - 1 => Page::<10>::new( - ButtonLayout::only_arrow_down(), - ButtonActions::only_next(), - Font::NORMAL, - ) - .text_normal(share_words.clone()), - 2 => Page::<10>::new( - ButtonLayout::back_and_htc_text("HOLD TO CONFIRM", Duration::from_millis(1000)), - ButtonActions::prev_confirm(), - Font::MONO, - ) - .newline() - .newline() - .text_mono("I wrote down all words in order.".into()), - _ => unreachable!(), - }; - let pages = FlowPages::new(get_page, 1); + let confirm_btn = + Some(ButtonDetails::text("CONFIRM").with_duration(Duration::from_secs(1))); - let obj = LayoutObj::new(Flow::new(pages).into_child())?; + let obj = LayoutObj::new( + ButtonPage::new_str(ShareWords::new(share_words), theme::FG) + .with_cancel_btn(None) + .with_confirm_btn(confirm_btn) + .with_scrollbar(false), + )?; Ok(obj.into()) }; unsafe { util::try_with_args_and_kwargs(n_args, args, kwargs, block) } @@ -519,7 +500,7 @@ extern "C" fn request_word_count(n_args: usize, args: *const Obj, kwargs: *mut M let block = |_args: &[Obj], kwargs: &Map| { let title: StrBuffer = kwargs.get(Qstr::MP_QSTR_title)?.try_into()?; - let choices: Vec<&str, 5> = ["12", "18", "20", "24", "33"].into_iter().collect(); + let choices: Vec<&str, 3> = ["12", "18", "24"].into_iter().collect(); let obj = LayoutObj::new(Frame::new( title, diff --git a/core/src/trezor/ui/layouts/tr/reset.py b/core/src/trezor/ui/layouts/tr/reset.py index 5c6ab9c4c..7b292c88d 100644 --- a/core/src/trezor/ui/layouts/tr/reset.py +++ b/core/src/trezor/ui/layouts/tr/reset.py @@ -5,7 +5,7 @@ from trezor.enums import ButtonRequestType import trezorui2 from ..common import interact -from . import RustLayout, confirm_action +from . import RustLayout, _placeholder_confirm, confirm_action, get_bool if TYPE_CHECKING: from trezor import wire @@ -19,20 +19,56 @@ async def show_share_words( share_index: int | None = None, group_index: int | None = None, ) -> None: - share_word_str = "" - for i, word in enumerate(share_words): - share_word_str += f"{i + 1} {word}\n" - await interact( - ctx, - RustLayout( - trezorui2.show_share_words( - share_words=share_word_str.rstrip(), - ) - ), + await _placeholder_confirm( + ctx=ctx, br_type="backup_words", + title="SHARE WORDS", + description=f"Write all {len(share_words)} words in order on recovery seed card.", + data="Do NOT make digital copies.", br_code=ButtonRequestType.ResetDevice, ) + # Showing words, asking for write down confirmation and preparing for check + # until user accepts everything. + while True: + await interact( + ctx, + RustLayout( + trezorui2.show_share_words( + share_words=share_words, + ) + ), + br_type="backup_words", + br_code=ButtonRequestType.ResetDevice, + ) + + wrote_down = await get_bool( + ctx=ctx, + title="SHARE WORDS", + data=f"I wrote down all {len(share_words)} words in order.", + verb="I WROTE DOWN", + hold=True, + br_type="backup_words", + br_code=ButtonRequestType.ResetDevice, + ) + if not wrote_down: + continue + + ready_to_check = await get_bool( + ctx=ctx, + title="CHECK PHRASE", + data="Select correct words in correct positions.", + verb_cancel="SEE AGAIN", + verb="BEGIN", + br_type="backup_words", + br_code=ButtonRequestType.ResetDevice, + ) + if not ready_to_check: + continue + + # All went well, we can break the loop. + break + async def select_word( ctx: wire.GenericContext, @@ -49,7 +85,7 @@ async def select_word( result = await ctx.wait( RustLayout( trezorui2.select_word( - title=f"SELECT WORD {checked_index + 1}", + title=f"SELECT WORD {checked_index + 1}/{count}", words=(words[0].upper(), words[1].upper(), words[2].upper()), ) )