bitbox03/ui: add confirm screen demo impl
What changed, and why it matters
This commit adds a new on-screen confirmation dialog for the BitBox03 hardware wallet and the low-level Rust-to-LVGL event-callback plumbing needed to make it work. It is a feature implementation, not a security patch. The code introduces memory-management patterns (reference-counted callbacks and a screen guard that pops the UI when a future is dropped) that are intended to make the code safer, but the commit itself does not fix any disclosed vulnerability.
Treat as a normal feature commit. Review the unsafe callback trampoline and Rc lifecycle for soundness during code review, but no incident response or patch deployment is warranted based on this commit alone.
Security signals we found
New unsafe FFI callback trampoline bridges Rust closures to C LVGL events
Reference-counted callback state to survive object deletion during callback execution
ScreenGuard Drop impl pops UI screen if async confirm future is abandoned
Tests explicitly verify safety of deleting an LVGL object from within its own click callback
No vendor statement of security relevance, no CVE, no attribution, no advisory references supplied
Evidence from the diff
The change implements Ui::confirm() in bitbox03/src/ui.rs, replacing a todo!() placeholder with an LVGL-based Yes/No screen. It also adds add_event_cb/add_click_cb wrappers in bitbox-lvgl that register Rust closures as LVGL event callbacks using an Rc<RefCell<Box<dyn FnMut()>>> trampoline, plus a DELETE-event cleanup callback to release the Rc. A ScreenGuard ensures the screen is popped if the async future is dropped (e.g., client disconnect). Tests cover callback invocation, delete-event cleanup, and deleting the object inside its own callback. No security bug or CVE is mentioned, and the commit message frames the Rc and ScreenGuard as defensive safety measures, not fixes for known issues.
Changed components
src/rust/bitbox-lvgl/src/widgets/obj.rssrc/rust/bitbox-lvgl/src/widgets/util.rssrc/rust/bitbox03/src/ui.rsInspect captured patch +336 / −9
diff --git a/src/rust/bitbox-lvgl/src/lib.rs b/src/rust/bitbox-lvgl/src/lib.rs
index 7a68c32..330494d 100644
--- a/src/rust/bitbox-lvgl/src/lib.rs
+++ b/src/rust/bitbox-lvgl/src/lib.rs
@@ -83,4 +83,4 @@ pub use widgets::slider::{LvSlider, SliderExt};
pub use widgets::span::{LvSpan, LvSpanTextError, LvSpangroup, SpangroupExt};
pub use widgets::spinner::{LvSpinner, SpinnerExt};
pub use widgets::textarea::{LvTextarea, LvTextareaTextError, TextareaExt};
-pub use widgets::{LvMapError, LvTextError};
+pub use widgets::{LvEventRegistrationError, LvMapError, LvTextError};
diff --git a/src/rust/bitbox-lvgl/src/widgets/mod.rs b/src/rust/bitbox-lvgl/src/widgets/mod.rs
index 1d66a79..a37cd6c 100644
--- a/src/rust/bitbox-lvgl/src/widgets/mod.rs
+++ b/src/rust/bitbox-lvgl/src/widgets/mod.rs
@@ -16,7 +16,7 @@ pub mod spinner;
pub mod textarea;
mod util;
-pub use util::{LvMapError, LvTextError};
+pub use util::{LvEventRegistrationError, LvMapError, LvTextError};
#[cfg(test)]
mod tests {
diff --git a/src/rust/bitbox-lvgl/src/widgets/obj.rs b/src/rust/bitbox-lvgl/src/widgets/obj.rs
index 1d2e04a..a2cad17 100644
--- a/src/rust/bitbox-lvgl/src/widgets/obj.rs
+++ b/src/rust/bitbox-lvgl/src/widgets/obj.rs
@@ -1,12 +1,14 @@
// SPDX-License-Identifier: Apache-2.0
+use super::util;
+use super::util::LvEventRegistrationError;
use core::ffi::c_void;
use core::marker::PhantomData;
use core::ptr::NonNull;
use crate::{
- LvAlign, LvBaseDir, LvBlendMode, LvBorderSide, LvColor, LvFlexAlign, LvFlexFlow, LvFont,
- LvGradDir, LvGridAlign, LvOpa, LvStyleSelector, LvTextAlign, LvTextDecor, class, ffi,
+ LvAlign, LvBaseDir, LvBlendMode, LvBorderSide, LvColor, LvEventCode, LvFlexAlign, LvFlexFlow,
+ LvFont, LvGradDir, LvGridAlign, LvOpa, LvStyleSelector, LvTextAlign, LvTextDecor, class, ffi,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -131,6 +133,20 @@ pub trait ObjExt {
unsafe { ffi::lv_obj_set_height(self.as_ptr(), height) }
}
+ fn add_event_cb<F>(&self, filter: LvEventCode, cb: F) -> Result<(), LvEventRegistrationError>
+ where
+ F: FnMut() + 'static,
+ {
+ util::add_event_cb(self.as_ptr(), filter, cb)
+ }
+
+ fn add_click_cb<F>(&self, cb: F) -> Result<(), LvEventRegistrationError>
+ where
+ F: FnMut() + 'static,
+ {
+ self.add_event_cb(crate::LvEventCode::LV_EVENT_CLICKED, cb)
+ }
+
/// # Safety
///
/// After deletion, LVGL frees the object and may recursively free its children. Callers must
@@ -331,8 +347,22 @@ impl<C: class::LvClass> ObjExt for LvHandle<C> {
#[cfg(test)]
mod tests {
+ extern crate std;
+
+ use alloc::rc::Rc;
+ use core::cell::Cell;
+ use core::ptr;
+ use std::sync::{Mutex, Once};
+
use super::*;
+ static LVGL_TEST_LOCK: Mutex<()> = Mutex::new(());
+
+ fn init_lvgl() {
+ static INIT: Once = Once::new();
+ INIT.call_once(crate::system::init);
+ }
+
#[test]
fn test_style_methods_exist() {
let _: fn(&LvObj, LvColor, LvStyleSelector) = <LvObj as ObjExt>::set_style_text_color;
@@ -340,5 +370,85 @@ mod tests {
let _: fn(&LvObj, LvStyleSelector) -> bool = <LvObj as ObjExt>::remove_style_text_font;
let _: unsafe fn(&LvObj, Option<&'static u8>, LvStyleSelector) =
<LvObj as ObjExt>::set_style_bg_image_src::<u8>;
+ let _: fn(&LvObj, crate::LvEventCode, fn()) -> Result<(), crate::LvEventRegistrationError> =
+ <LvObj as ObjExt>::add_event_cb::<fn()>;
+ let _: fn(&LvObj, fn()) -> Result<(), crate::LvEventRegistrationError> =
+ <LvObj as ObjExt>::add_click_cb::<fn()>;
+ }
+
+ #[test]
+ fn test_add_event_cb_invokes_callback() {
+ let _lock = LVGL_TEST_LOCK.lock().unwrap();
+ init_lvgl();
+
+ let display = crate::LvDisplay::new(16, 16).unwrap();
+ let screen = display.screen_active().unwrap();
+ let obj = LvObj::with_parent(&screen).unwrap();
+ let called = Rc::new(Cell::new(0));
+ let called_cb = Rc::clone(&called);
+ obj.add_event_cb(crate::LvEventCode::LV_EVENT_CLICKED, move || {
+ called_cb.set(called_cb.get() + 1);
+ })
+ .unwrap();
+
+ let result = unsafe {
+ ffi::lv_obj_send_event(
+ obj.as_ptr(),
+ crate::LvEventCode::LV_EVENT_CLICKED,
+ ptr::null_mut(),
+ )
+ };
+ assert_eq!(result, ffi::lv_result_t::LV_RESULT_OK);
+ assert_eq!(called.get(), 1);
+
+ unsafe { obj.delete() };
+ }
+
+ #[test]
+ fn test_add_event_cb_delete_event_invokes_callback() {
+ let _lock = LVGL_TEST_LOCK.lock().unwrap();
+ init_lvgl();
+
+ let display = crate::LvDisplay::new(16, 16).unwrap();
+ let screen = display.screen_active().unwrap();
+ let obj = LvObj::with_parent(&screen).unwrap();
+ let called = Rc::new(Cell::new(false));
+ let called_cb = Rc::clone(&called);
+ obj.add_event_cb(crate::LvEventCode::LV_EVENT_DELETE, move || {
+ called_cb.set(true);
+ })
+ .unwrap();
+
+ unsafe { obj.delete() };
+
+ assert!(called.get());
+ }
+
+ #[test]
+ fn test_add_event_cb_delete_during_callback_is_safe() {
+ let _lock = LVGL_TEST_LOCK.lock().unwrap();
+ init_lvgl();
+
+ let display = crate::LvDisplay::new(16, 16).unwrap();
+ let screen = display.screen_active().unwrap();
+ let obj = LvObj::with_parent(&screen).unwrap();
+ let obj_ptr = obj.as_ptr();
+ let called = Rc::new(Cell::new(false));
+ let called_cb = Rc::clone(&called);
+ obj.add_event_cb(crate::LvEventCode::LV_EVENT_CLICKED, move || {
+ called_cb.set(true);
+ unsafe { ffi::lv_obj_delete(obj_ptr) };
+ })
+ .unwrap();
+
+ let result = unsafe {
+ ffi::lv_obj_send_event(
+ obj.as_ptr(),
+ crate::LvEventCode::LV_EVENT_CLICKED,
+ ptr::null_mut(),
+ )
+ };
+ assert_eq!(result, ffi::lv_result_t::LV_RESULT_INVALID);
+ assert!(called.get());
}
}
diff --git a/src/rust/bitbox-lvgl/src/widgets/util.rs b/src/rust/bitbox-lvgl/src/widgets/util.rs
index 56170d2..e46010e 100644
--- a/src/rust/bitbox-lvgl/src/widgets/util.rs
+++ b/src/rust/bitbox-lvgl/src/widgets/util.rs
@@ -1,6 +1,8 @@
// SPDX-License-Identifier: Apache-2.0
use alloc::boxed::Box;
+use alloc::rc::Rc;
+use core::cell::RefCell;
use core::ffi::c_void;
use core::ptr::NonNull;
@@ -17,6 +19,13 @@ pub enum LvMapError {
EventRegistrationFailed,
}
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum LvEventRegistrationError {
+ RegistrationFailed,
+}
+
+type EventCallback = RefCell<Box<dyn FnMut() + 'static>>;
+
unsafe extern "C" fn on_delete_drop_attachment<T>(e: *mut ffi::lv_event_t) {
if unsafe { ffi::lv_event_get_code(e) } != ffi::lv_event_code_t::LV_EVENT_DELETE {
return;
@@ -30,6 +39,34 @@ unsafe extern "C" fn on_delete_drop_attachment<T>(e: *mut ffi::lv_event_t) {
drop(unsafe { Box::from_raw(user_data as *mut T) });
}
+unsafe extern "C" fn event_callback_trampoline(e: *mut ffi::lv_event_t) {
+ let user_data = unsafe { ffi::lv_event_get_user_data(e) };
+ if user_data.is_null() {
+ return;
+ }
+
+ let callback_ptr = user_data.cast::<EventCallback>().cast_const();
+ unsafe {
+ Rc::increment_strong_count(callback_ptr);
+ }
+ let callback = unsafe { Rc::from_raw(callback_ptr) };
+ let mut callback = callback.borrow_mut();
+ callback.as_mut()();
+}
+
+unsafe extern "C" fn on_delete_drop_event_callback(e: *mut ffi::lv_event_t) {
+ if unsafe { ffi::lv_event_get_code(e) } != ffi::lv_event_code_t::LV_EVENT_DELETE {
+ return;
+ }
+
+ let user_data = unsafe { ffi::lv_event_get_user_data(e) };
+ if user_data.is_null() {
+ return;
+ }
+
+ drop(unsafe { Rc::from_raw(user_data.cast::<EventCallback>().cast_const()) });
+}
+
pub(crate) fn attach_to_object<T, C: class::LvClass>(
obj: &LvHandle<C>,
attachment: T,
@@ -50,3 +87,41 @@ pub(crate) fn attach_to_object<T, C: class::LvClass>(
}
Ok(unsafe { NonNull::new_unchecked(attachment_ptr) })
}
+
+pub(crate) fn add_event_cb<F>(
+ obj: *mut ffi::lv_obj_t,
+ filter: ffi::lv_event_code_t,
+ cb: F,
+) -> Result<(), LvEventRegistrationError>
+where
+ F: FnMut() + 'static,
+{
+ let callback: Rc<EventCallback> = Rc::new(RefCell::new(Box::new(cb)));
+ let callback_ptr = Rc::into_raw(callback);
+ let user_data = callback_ptr.cast_mut().cast::<c_void>();
+
+ let event_dsc = unsafe {
+ ffi::lv_obj_add_event_cb(obj, Some(event_callback_trampoline), filter, user_data)
+ };
+ if event_dsc.is_null() {
+ drop(unsafe { Rc::from_raw(callback_ptr) });
+ return Err(LvEventRegistrationError::RegistrationFailed);
+ }
+
+ let cleanup_dsc = unsafe {
+ ffi::lv_obj_add_event_cb(
+ obj,
+ Some(on_delete_drop_event_callback),
+ ffi::lv_event_code_t::LV_EVENT_DELETE,
+ user_data,
+ )
+ };
+ if cleanup_dsc.is_null() {
+ let removed = unsafe { ffi::lv_obj_remove_event_dsc(obj, event_dsc) };
+ assert!(removed, "failed to roll back event registration");
+ drop(unsafe { Rc::from_raw(callback_ptr) });
+ return Err(LvEventRegistrationError::RegistrationFailed);
+ }
+
+ Ok(())
+}
diff --git a/src/rust/bitbox03/src/ui.rs b/src/rust/bitbox03/src/ui.rs
index cb3ddd3..dd0d717 100644
--- a/src/rust/bitbox03/src/ui.rs
+++ b/src/rust/bitbox03/src/ui.rs
@@ -1,9 +1,12 @@
+use alloc::rc::Rc;
use alloc::vec::Vec;
use bitbox_hal as hal;
use bitbox_lvgl::{
- self as lvgl, LabelExt, LvAlign, LvDisplay, LvHandle, LvLabel, LvObj, LvOpacityLevel, LvPart,
- LvSpangroup, ObjExt, SpangroupExt,
+ self as lvgl, LabelExt, LvAlign, LvButton, LvDisplay, LvHandle, LvLabel, LvLabelLongMode,
+ LvObj, LvOpacityLevel, LvPart, LvSpangroup, ObjExt, SpangroupExt,
};
+use core::cell::RefCell;
+use core::task::{Poll, Waker};
use tracing::info;
const LOGO: &[u8] = include_bytes!("../splash.png");
@@ -17,6 +20,16 @@ pub struct BitBox03UiProgress;
pub struct BitBox03UiEmpty;
+struct ScreenGuard<'a> {
+ ui: &'a mut BitBox03Ui,
+}
+
+impl Drop for ScreenGuard<'_> {
+ fn drop(&mut self) {
+ self.ui.pop();
+ }
+}
+
impl hal::ui::Progress for BitBox03UiProgress {
fn set(&mut self, _progress: f32) {
todo!()
@@ -32,9 +45,131 @@ impl hal::ui::Ui for BitBox03Ui {
async fn confirm(
&mut self,
- _params: &bitbox_hal::ui::ConfirmParams<'_>,
+ params: &bitbox_hal::ui::ConfirmParams<'_>,
) -> Result<(), bitbox_hal::ui::UserAbort> {
- todo!()
+ struct SharedState {
+ waker: Option<Waker>,
+ result: Option<Result<(), hal::ui::UserAbort>>,
+ }
+
+ let shared_state = Rc::new(RefCell::new(SharedState {
+ waker: None,
+ result: None,
+ }));
+
+ let screen = LvObj::new().unwrap();
+ screen.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
+ screen.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_COLUMN);
+ screen.set_style_bg_color(lvgl::color::black(), 0);
+ screen.set_style_text_color(lvgl::color::white(), 0);
+ screen.set_style_pad_top(40, 0);
+ screen.set_style_pad_right(50, 0);
+ screen.set_style_pad_bottom(40, 0);
+ screen.set_style_pad_left(50, 0);
+ screen.set_style_pad_row(24, 0);
+
+ let title = LvLabel::new(&screen).unwrap();
+ title.set_width(380);
+ title.set_long_mode(LvLabelLongMode::LV_LABEL_LONG_MODE_WRAP);
+ title.set_text(params.title).unwrap();
+ title.set_style_text_font(
+ lvgl::fonts::INTER_BOLD_48,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+
+ let body = LvLabel::new(&screen).unwrap();
+ body.set_width(380);
+ body.set_long_mode(LvLabelLongMode::LV_LABEL_LONG_MODE_WRAP);
+ body.set_text(params.body).unwrap();
+ body.set_style_text_font(
+ lvgl::fonts::INTER_REGULAR_32,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+ body.set_style_flex_grow(1, 0);
+
+ let actions = LvObj::with_parent(&screen).unwrap();
+ actions.set_width(380);
+ actions.set_height(72);
+ actions.set_layout(lvgl::LvLayout::LV_LAYOUT_FLEX);
+ actions.set_flex_flow(lvgl::LvFlexFlow::LV_FLEX_FLOW_ROW);
+ actions.set_style_pad_top(0, 0);
+ actions.set_style_pad_bottom(0, 0);
+ actions.set_style_pad_left(0, 0);
+ actions.set_style_pad_right(0, 0);
+ actions.set_style_pad_column(20, 0);
+ actions.set_style_margin_top(16, 0);
+ actions.set_style_border_width(0, 0);
+ actions.set_style_bg_opa(LvOpacityLevel::LV_OPA_TRANSP as u8, 0);
+
+ let reject = LvButton::new(&actions).unwrap();
+ reject.set_size(180, 72);
+ reject.set_style_bg_color(lvgl::color::hex(0x30333a), 0);
+ reject.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, 0);
+ reject.set_style_border_width(2, 0);
+ reject.set_style_border_color(lvgl::color::white(), 0);
+ let reject_state = Rc::clone(&shared_state);
+ reject
+ .add_click_cb(move || {
+ let mut shared_state = reject_state.borrow_mut();
+ if shared_state.result.is_none() {
+ shared_state.result = Some(Err(hal::ui::UserAbort));
+ if let Some(waker) = shared_state.waker.as_ref() {
+ waker.wake_by_ref();
+ }
+ }
+ })
+ .expect("failed to register reject callback");
+ let reject_label = LvLabel::new(&reject).unwrap();
+ reject_label.set_text("No").unwrap();
+ reject_label.set_style_text_font(
+ lvgl::fonts::INTER_BOLD_32,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+ reject_label.set_style_text_color(lvgl::color::white(), 0);
+ reject_label.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
+
+ let accept = LvButton::new(&actions).unwrap();
+ accept.set_size(180, 72);
+ accept.set_style_bg_color(lvgl::color::white(), 0);
+ accept.set_style_bg_opa(LvOpacityLevel::LV_OPA_COVER as u8, 0);
+ accept.set_style_border_width(2, 0);
+ accept.set_style_border_color(lvgl::color::black(), 0);
+ let accept_state = Rc::clone(&shared_state);
+ accept
+ .add_click_cb(move || {
+ let mut shared_state = accept_state.borrow_mut();
+ if shared_state.result.is_none() {
+ shared_state.result = Some(Ok(()));
+ if let Some(waker) = shared_state.waker.as_ref() {
+ waker.wake_by_ref();
+ }
+ }
+ })
+ .expect("failed to register accept callback");
+ let accept_label = LvLabel::new(&accept).unwrap();
+ accept_label.set_text("Yes").unwrap();
+ accept_label.set_style_text_font(
+ lvgl::fonts::INTER_BOLD_32,
+ lvgl::LvState::LV_STATE_DEFAULT as u32,
+ );
+ accept_label.set_style_text_color(lvgl::color::black(), 0);
+ accept_label.align(LvAlign::LV_ALIGN_CENTER, 0, 0);
+
+ let _screen = self.push_guard(screen);
+
+ core::future::poll_fn({
+ let shared_state = &shared_state;
+ move |cx| {
+ let mut shared_state = shared_state.borrow_mut();
+ if let Some(result) = shared_state.result.take() {
+ Poll::Ready(result)
+ } else {
+ shared_state.waker = Some(cx.waker().clone());
+ Poll::Pending
+ }
+ }
+ })
+ .await
}
async fn verify_recipient(
@@ -71,7 +206,9 @@ impl hal::ui::Ui for BitBox03Ui {
}
fn reset(&mut self) {
- todo!()
+ while !self.stack.is_empty() {
+ self.pop();
+ }
}
fn progress_create(&mut self, _title: &str) -> Self::Progress {
@@ -226,6 +363,11 @@ impl BitBox03Ui {
}
}
+ fn push_guard(&mut self, screen: LvObj) -> ScreenGuard<'_> {
+ self.push(screen);
+ ScreenGuard { ui: self }
+ }
+
pub fn push(&mut self, screen: LvObj) {
if let Some(display) = &self.display {
let current = display.screen_active().expect("No active screen?!");
Why this scored 15/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.