workflow: allow displayable UTF-8 messages
What changed, and why it matters
This commit changes how the BitBox02 hardware wallet decides whether to show a message to the user as readable text or as hex. Previously, only plain ASCII was shown as text; accented characters and other UTF-8 were always shown as hex. Now the device checks whether each character exists in its display font. If every character can be drawn, the message is shown normally; otherwise it falls back to hex. The same font check is also applied to Ethereum typed-message strings and payment-request memos, which now reject unsupported characters instead of accepting them. The change is a user-experience improvement with a small security angle: it reduces the chance that a user is confused by hex-encoded text that they cannot read, and it prevents attackers from sneaking in invisible or misleading characters in contexts where they were previously accepted.
Treat as a routine defensive-hardening commit. Review the `has_glyph` implementation and font coverage to ensure the Regular11 font truly contains all intended safe glyphs and that the mock used in tests matches the real device font. Verify that callers of `verify_message` and the Ethereum/payment-request APIs handle `Error::InvalidInput` gracefully and do not leak state after partial validation.
Security signals we found
Replaces ASCII-only display logic with font-glyph coverage validation
Prevents display of unreadable hex for supported UTF-8 characters
Rejects unsupported/invisible characters in Ethereum typed strings and payment memos
Removes unsafe `from_utf8_unchecked` in message verification
Adds tests for invisible/disruptive Unicode characters (tab, non-breaking space, soft hyphen, CJK, malformed bytes)
Evidence from the diff
The patch replaces util::ascii::is_printable_ascii(..., Charset::AllNewline) with a UTF-8-aware font-glyph coverage check using ui.has_glyph(Font::Regular11, c). In verify_message.rs, messages that are valid UTF-8 and whose codepoints are all present in the Regular11 font (or newline) are displayed as text; all other messages fall back to hex. In sign_typed_msg.rs and payment_request.rs, the same check is used to reject string/memo inputs that contain unsupported characters with Error::InvalidInput. Tests are added for accented characters, CJK characters, control characters, non-breaking space, soft hyphen, malformed UTF-8, and per-character glyph availability. The change removes an unsafe from_utf8_unchecked call in favor of a checked core::str::from_utf8.
Changed components
src/rust/bitbox02-rust/src/workflow/verify_message.rssrc/rust/bitbox02-rust/src/hww/api/ethereum/sign_typed_msg.rssrc/rust/bitbox02-rust/src/hww/api/payment_request.rsInspect captured patch +269 / −32
### src/rust/bitbox02-rust/src/hww/api/ethereum/sign_typed_msg.rs
@@ -13,7 +13,7 @@ use super::sighash::DataProducer;
use super::truncating_hex_preview_byte_cap;
use crate::hal::Ui;
-use crate::hal::ui::ConfirmParams;
+use crate::hal::ui::{ConfirmParams, Font};
use crate::keystore;
use crate::workflow::confirm;
@@ -297,7 +297,11 @@ async fn get_value_from_host(
///
/// Returns the 32 byte encoded value as well as a human readable representation that can be used
/// for user verification.
-fn encode_value(typ: &MemberType, value: Vec<u8>) -> Result<(Vec<u8>, String), Error> {
+fn encode_value(
+ ui: &impl Ui,
+ typ: &MemberType,
+ value: Vec<u8>,
+) -> Result<(Vec<u8>, String), Error> {
let result = match DataType::try_from(typ.r#type)? {
DataType::Unknown => return Err(Error::InvalidInput),
DataType::Bytes => {
@@ -358,12 +362,18 @@ fn encode_value(typ: &MemberType, value: Vec<u8>) -> Result<(Vec<u8>, String), E
(encoded, super::address::format_display_address(&value_str))
}
DataType::String => {
- if !util::ascii::is_printable_ascii(&value, util::ascii::Charset::AllNewline) {
+ let value_str = String::from_utf8(value).or(Err(Error::InvalidInput))?;
+ if !value_str
+ .chars()
+ .all(|c| c == '\n' || ui.has_glyph(Font::Regular11, c))
+ {
return Err(Error::InvalidInput);
}
(
- sha3::Keccak256::digest(&value).as_slice().to_vec(),
- String::from_utf8(value).or(Err(Error::InvalidInput))?,
+ sha3::Keccak256::digest(value_str.as_bytes())
+ .as_slice()
+ .to_vec(),
+ value_str,
)
}
DataType::Array | DataType::Struct => panic!("encode_value"),
@@ -473,7 +483,7 @@ async fn encode_member<U: sha3::digest::Update>(
format!("0x{}", hex::encode(producer.preview()))
} else {
let value_len = req.value.len();
- let (value_encoded, fmt) = encode_value(member_type, req.value)?;
+ let (value_encoded, fmt) = encode_value(&*hal.ui(), member_type, req.value)?;
hasher.update(&value_encoded);
if data_type == DataType::Bytes {
display_size = value_len;
@@ -1233,16 +1243,79 @@ mod tests {
#[test]
fn test_encode_value_address() {
+ let mock_hal = TestingHal::new();
let value = |len: usize| {
let mut value = b"0x".to_vec();
value.resize(2 + len * 2, b'1');
value
};
let typ = mk_type(DataType::Address);
- assert_eq!(encode_value(&typ, value(19)), Err(Error::InvalidInput));
- assert!(encode_value(&typ, value(20)).is_ok());
- assert_eq!(encode_value(&typ, value(21)), Err(Error::InvalidInput));
+ assert_eq!(
+ encode_value(&mock_hal.ui, &typ, value(19)),
+ Err(Error::InvalidInput)
+ );
+ assert!(encode_value(&mock_hal.ui, &typ, value(20)).is_ok());
+ assert_eq!(
+ encode_value(&mock_hal.ui, &typ, value(21)),
+ Err(Error::InvalidInput)
+ );
+ }
+
+ #[test]
+ fn test_encode_value_string() {
+ let mut mock_hal = TestingHal::new();
+ mock_hal
+ .ui
+ .set_has_glyph(Box::new(|_, c| matches!(c, ' '..='~' | 'ü' | '€')));
+ let typ = mk_type(DataType::String);
+ let value = "Zürich\n10 €";
+ let (encoded, formatted) =
+ encode_value(&mock_hal.ui, &typ, value.as_bytes().to_vec()).unwrap();
+ assert_eq!(
+ encoded,
+ sha3::Keccak256::digest(value.as_bytes()).as_slice()
+ );
+ assert_eq!(formatted, value);
+
+ assert_eq!(
+ encode_value(&mock_hal.ui, &typ, "tab\t".as_bytes().to_vec()),
+ Err(Error::InvalidInput)
+ );
+ assert_eq!(
+ encode_value(&mock_hal.ui, &typ, "東京".as_bytes().to_vec()),
+ Err(Error::InvalidInput)
+ );
+ assert_eq!(
+ encode_value(&mock_hal.ui, &typ, "µ".as_bytes().to_vec()),
+ Err(Error::InvalidInput)
+ );
+ for value in ["non\u{a0}breaking space", "soft\u{ad}hyphen"] {
+ assert_eq!(
+ encode_value(&mock_hal.ui, &typ, value.as_bytes().to_vec()),
+ Err(Error::InvalidInput)
+ );
+ }
+ assert_eq!(
+ encode_value(&mock_hal.ui, &typ, vec![0xff]),
+ Err(Error::InvalidInput)
+ );
+
+ mock_hal.ui.set_has_glyph(Box::new(|_, _| true));
+ let value = "東京 µ";
+ let (encoded, formatted) =
+ encode_value(&mock_hal.ui, &typ, value.as_bytes().to_vec()).unwrap();
+ assert_eq!(
+ encoded,
+ sha3::Keccak256::digest(value.as_bytes()).as_slice()
+ );
+ assert_eq!(formatted, value);
+
+ mock_hal.ui.set_has_glyph(Box::new(|_, c| c != 'ü'));
+ assert_eq!(
+ encode_value(&mock_hal.ui, &typ, "Zürich".as_bytes().to_vec()),
+ Err(Error::InvalidInput)
+ );
}
#[test]
### src/rust/bitbox02-rust/src/hww/api/payment_request.rs
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
use super::Error;
-use crate::hal::ui::ConfirmParams;
+use crate::hal::ui::{ConfirmParams, Font};
use crate::pb;
use alloc::vec::Vec;
@@ -148,10 +148,14 @@ pub async fn user_verify(
Memo {
memo: Some(memo::Memo::TextMemo(text_memo)),
} => {
- if !util::ascii::is_printable_ascii(
- &text_memo.note,
- util::ascii::Charset::AllNewline,
- ) {
+ let has_all_glyphs = {
+ let ui = hal.ui();
+ text_memo
+ .note
+ .chars()
+ .all(|c| c == '\n' || ui.has_glyph(Font::Regular11, c))
+ };
+ if !has_all_glyphs {
return Err(Error::InvalidInput);
}
hal.ui()
@@ -1573,7 +1577,7 @@ mod tests {
&mut mock_hal,
&pb::BtcPaymentRequestRequest {
recipient_name: "POCKET".into(),
- memos: vec![make_text_memo("Pocket memo")],
+ memos: vec![make_text_memo("Pöcket memo")],
nonce: vec![],
total_amount: 1234567890,
signature: vec![],
@@ -1597,14 +1601,46 @@ mod tests {
},
Screen::Confirm {
title: "Memo".into(),
- body: "Pocket memo".into(),
+ body: "Pöcket memo".into(),
longtouch: false,
},
]
);
assert_eq!(mock_hal.ui.confirm_scrollable, vec![true, true]);
}
+ #[async_test::test]
+ async fn test_user_verify_text_memo_glyphs() {
+ for (note, valid) in [("Pöcket memo", false), ("東京\nµ", true)] {
+ let mut mock_hal = TestingHal::new();
+ mock_hal.ui.set_has_glyph(alloc::boxed::Box::new(|font, c| {
+ assert!(matches!(font, Font::Regular11));
+ c != 'ö' && !c.is_control()
+ }));
+ let result = user_verify(
+ &mut mock_hal,
+ &pb::BtcPaymentRequestRequest {
+ recipient_name: "POCKET".into(),
+ memos: vec![make_text_memo(note)],
+ ..Default::default()
+ },
+ "0.25 BTC",
+ )
+ .await;
+ if valid {
+ result.unwrap();
+ assert_eq!(mock_hal.ui.screens.len(), 4);
+ assert!(matches!(
+ &mock_hal.ui.screens[2],
+ Screen::Confirm { title, body, .. } if title == "Memo 1/2" && body == "東京"
+ ));
+ } else {
+ assert_eq!(result, Err(Error::InvalidInput));
+ assert_eq!(mock_hal.ui.screens.len(), 1);
+ }
+ }
+ }
+
#[async_test::test]
async fn test_user_verify_long_recipient_name_scrollable() {
let recipient_name = format!("SWAPKIT ({})", "a".repeat(200));
### src/rust/bitbox02-rust/src/workflow/verify_message.rs
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: Apache-2.0
-use crate::hal::ui::ConfirmParams;
+use crate::hal::ui::{ConfirmParams, Font};
use alloc::vec::Vec;
-use util::ascii;
+use crate::hal::Ui;
pub enum Error {
InvalidInput,
@@ -16,10 +16,18 @@ impl core::convert::From<crate::hal::ui::UserAbort> for Error {
}
}
+fn is_displayable_with_regular11_font(ui: &impl Ui, bytes: &[u8]) -> bool {
+ let Ok(msg) = core::str::from_utf8(bytes) else {
+ return false;
+ };
+ msg.chars()
+ .all(|c| c == '\n' || ui.has_glyph(Font::Regular11, c))
+}
+
/// Verify a message.
///
-/// If the bytes are all printable ascii chars, the message is
-/// confirmed one line at a time (the str is split into lines).
+/// If the bytes are valid UTF-8 and all codepoints are newlines or covered by the Regular11 display
+/// font, the message is confirmed one line at a time (the str is split into lines).
///
/// Otherwise, it is displayed as hex.
///
@@ -34,10 +42,13 @@ pub async fn verify(
msg: &[u8],
is_final: bool,
) -> Result<(), Error> {
- if ascii::is_printable_ascii(msg, ascii::Charset::AllNewline) {
- // The message is all ascii and printable.
- // SAFETY: `is_printable_ascii()` accepted every byte above.
- let msg = unsafe { core::str::from_utf8_unchecked(msg) };
+ let is_displayable = {
+ let ui = hal.ui();
+ is_displayable_with_regular11_font(&*ui, msg)
+ };
+
+ if is_displayable {
+ let msg = core::str::from_utf8(msg).unwrap();
if msg.is_empty() {
return Err(Error::InvalidInput);
}
@@ -84,6 +95,8 @@ pub async fn verify(
mod tests {
use super::*;
+ use alloc::boxed::Box;
+
use crate::hal::testing::TestingHal;
use crate::hal::testing::ui::Screen;
use crate::workflow::confirm::{MAX_CONFIRM_BODY_SIZE, TRUNCATION_WARNING_BODY};
@@ -297,21 +310,136 @@ mod tests {
}
}
+ #[test]
+ fn test_is_displayable_with_regular11_font() {
+ let mut mock_hal = TestingHal::new();
+ mock_hal
+ .ui
+ .set_has_glyph(Box::new(|_, c| matches!(c, ' '..='~' | 'ü' | '€')));
+ assert!(is_displayable_with_regular11_font(
+ &mock_hal.ui,
+ "Zürich".as_bytes()
+ ));
+ assert!(is_displayable_with_regular11_font(
+ &mock_hal.ui,
+ "€\nA".as_bytes()
+ ));
+ assert!(!is_displayable_with_regular11_font(
+ &mock_hal.ui,
+ "Aȑ".as_bytes()
+ ));
+ assert!(!is_displayable_with_regular11_font(
+ &mock_hal.ui,
+ "東京".as_bytes()
+ ));
+ assert!(!is_displayable_with_regular11_font(
+ &mock_hal.ui,
+ "tab\t".as_bytes()
+ ));
+ assert!(!is_displayable_with_regular11_font(
+ &mock_hal.ui,
+ "non\u{a0}breaking space".as_bytes()
+ ));
+ assert!(!is_displayable_with_regular11_font(
+ &mock_hal.ui,
+ "µ".as_bytes()
+ ));
+ assert!(!is_displayable_with_regular11_font(
+ &mock_hal.ui,
+ "soft\u{ad}hyphen".as_bytes()
+ ));
+ assert!(!is_displayable_with_regular11_font(&mock_hal.ui, &[0xff]));
+
+ mock_hal.ui.set_has_glyph(Box::new(|_, _| true));
+ assert!(is_displayable_with_regular11_font(
+ &mock_hal.ui,
+ "東京 µ".as_bytes()
+ ));
+
+ mock_hal.ui.set_has_glyph(Box::new(|_, c| c != 'ü'));
+ assert!(!is_displayable_with_regular11_font(
+ &mock_hal.ui,
+ "Zürich".as_bytes()
+ ));
+ }
+
#[async_test::test]
- async fn test_verify_non_ascii_utf8_as_hex() {
- let mut hal = TestingHal::new();
- assert!(
- verify(&mut hal, "Sign message", "Sign", "tä".as_bytes(), true)
- .await
- .is_ok()
+ async fn test_verify_displayable_with_regular11_font() {
+ let mut mock_hal = TestingHal::new();
+ let result = verify(
+ &mut mock_hal,
+ "Sign message",
+ "Sign",
+ "Zürich\n§".as_bytes(),
+ true,
+ )
+ .await;
+ assert!(matches!(result, Ok(())));
+
+ assert_eq!(
+ mock_hal.ui.screens,
+ vec![
+ Screen::Confirm {
+ title: "Sign 1/2".into(),
+ body: "Zürich".into(),
+ longtouch: false,
+ },
+ Screen::Confirm {
+ title: "Sign 2/2".into(),
+ body: "§".into(),
+ longtouch: true,
+ },
+ ]
);
+ assert_eq!(mock_hal.ui.confirm_display_sizes, vec![0, 0]);
+ }
+
+ #[async_test::test]
+ async fn test_verify_hex_if_not_displayable_with_regular11_font() {
+ let mut mock_hal = TestingHal::new();
+ let result = verify(
+ &mut mock_hal,
+ "Sign message",
+ "Sign",
+ "東京".as_bytes(),
+ true,
+ )
+ .await;
+ assert!(matches!(result, Ok(())));
+
assert_eq!(
- hal.ui.screens,
+ mock_hal.ui.screens,
+ vec![Screen::Confirm {
+ title: "Sign message\ndata (hex)".into(),
+ body: "e69db1e4baac".into(),
+ longtouch: true,
+ }]
+ );
+ assert_eq!(mock_hal.ui.confirm_display_sizes, vec![6]);
+ }
+
+ #[async_test::test]
+ async fn test_verify_hex_if_glyph_missing_from_regular11_font() {
+ let checked_glyphs = core::cell::RefCell::new(Vec::new());
+ let mut mock_hal = TestingHal::new();
+ mock_hal.ui.set_has_glyph(Box::new(|font, c| {
+ assert!(matches!(font, Font::Regular11));
+ checked_glyphs.borrow_mut().push(c);
+ c != 'ü'
+ }));
+
+ let result = verify(&mut mock_hal, "Sign message", "Sign", "Aü".as_bytes(), true).await;
+ assert!(matches!(result, Ok(())));
+ assert_eq!(checked_glyphs.borrow().as_slice(), &['A', 'ü']);
+
+ assert_eq!(
+ mock_hal.ui.screens,
vec![Screen::Confirm {
title: "Sign message\ndata (hex)".into(),
- body: "74c3a4".into(),
+ body: "41c3bc".into(),
longtouch: true,
}]
);
+ assert_eq!(mock_hal.ui.confirm_display_sizes, vec![3]);
}
}Why this scored 36/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.