From b16dc858433845bd55c9f61327923e5b650dcee8 Mon Sep 17 00:00:00 2001 From: Alexis Grojean Date: Fri, 10 Nov 2023 17:03:05 +0100 Subject: [PATCH] Add transaction signing APDU handling and display. --- Cargo.toml | 1 + src/app_ui/sign.rs | 120 ++++++++++++++++++ src/handlers/get_public_key.rs | 31 +---- src/handlers/sign_tx.rs | 220 +++++++++++++++++++++++++++++++++ src/main.rs | 85 ++++++------- src/utils.rs | 51 +++++++- 6 files changed, 430 insertions(+), 78 deletions(-) create mode 100644 src/app_ui/sign.rs create mode 100644 src/handlers/sign_tx.rs diff --git a/Cargo.toml b/Cargo.toml index 4338d38..dbdf0d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ edition = "2021" nanos_sdk = { git = "https://github.com/LedgerHQ/ledger-nanos-sdk.git" } nanos_ui = { git = "https://github.com/LedgerHQ/ledger-nanos-ui.git" } include_gif = { git = "https://github.com/LedgerHQ/sdk_include_gif" } +numtoa = "0.2.4" [patch."https://github.com/LedgerHQ/ledger-nanos-ui"] nanos_ui = { path = "ledger-nanos-ui" } diff --git a/src/app_ui/sign.rs b/src/app_ui/sign.rs new file mode 100644 index 0000000..bf47ad4 --- /dev/null +++ b/src/app_ui/sign.rs @@ -0,0 +1,120 @@ +/***************************************************************************** + * Ledger App Boilerplate Rust. + * (c) 2023 Ledger SAS. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + *****************************************************************************/ + +use nanos_sdk::io; +use nanos_ui::bitmaps::{EYE, VALIDATE_14, CROSSMARK}; +use nanos_ui::ui::{MultiFieldReview, Field}; +use crate::handlers::sign_tx::Tx; +use crate::utils::{concatenate, to_hex}; +use numtoa::NumToA; + +use nanos_sdk::testing; + +pub fn ui_display_tx(tx: &Tx) -> Result { + // Todo add error handling + + testing::debug_print("Display Tx 1\n"); + + // Format amount value + let mut amount_buf = [0u8; 20]; + let mut amount_with_denom_buf = [0u8; 25]; + concatenate(&["CRAB", " ", tx.value.numtoa_str(10, &mut amount_buf)], &mut amount_with_denom_buf); + + testing::debug_print("Display Tx 2\n"); + + let amount_str_with_denom = core::str::from_utf8(&amount_with_denom_buf).unwrap(); + + testing::debug_print("Display Tx 3\n"); + + // Format destination address + let hex_address = to_hex(&tx.to).unwrap(); + let hex_address_str = core::str::from_utf8(&hex_address).unwrap(); + + testing::debug_print("Display Tx 4\n"); + + // Format memo + let memo_str = core::str::from_utf8(&tx.memo).unwrap(); + + testing::debug_print("Display Tx 5\n"); + + // Define transaction review fields + let my_fields = [ + Field { + name: "Amount", + value: amount_str_with_denom, + }, + Field { + name: "Destination", + value: hex_address_str, + }, + Field { + name: "Memo", + value: memo_str, + }, + ]; + + testing::debug_print("Display Tx 6\n"); + + // Create transaction review + let my_review = MultiFieldReview::new( + &my_fields, + &["Review ", "Transaction"], + Some(&EYE), + "Approve", + Some(&VALIDATE_14), + "Reject", + Some(&CROSSMARK), + ); + + testing::debug_print("Display Tx 7\n"); + + Ok(my_review.show()) +} + +// This is the UI flow for signing, composed of a scroller +// to read the incoming message, a panel that requests user +// validation, and an exit message. +// fn sign_ui(message: &[u8]) -> Result, SyscallError> { +// let hex = utils::to_hex(message).map_err(|_| SyscallError::Overflow)?; +// let m = from_utf8(&hex).map_err(|_| SyscallError::InvalidParameter)?; +// let my_field = [ui::Field { +// name: "Data", +// value: m, +// }]; + +// let my_review = ui::MultiFieldReview::new( +// &my_field, +// &["Review ", "Transaction"], +// Some(&EYE), +// "Approve", +// Some(&VALIDATE_14), +// "Reject", +// Some(&CROSSMARK), +// ); + +// if my_review.show() { +// let signature = Secp256k1::derive_from_path(&BIP32_PATH) +// .deterministic_sign(message) +// .map_err(|_| SyscallError::Unspecified)?; +// ui::popup("Done !"); +// Ok(Some(signature)) +// } else { +// ui::popup("Cancelled"); +// Ok(None) +// } +// } + diff --git a/src/handlers/get_public_key.rs b/src/handlers/get_public_key.rs index 9216305..5c377be 100644 --- a/src/handlers/get_public_key.rs +++ b/src/handlers/get_public_key.rs @@ -16,14 +16,11 @@ *****************************************************************************/ use crate::app_ui::address::ui_display_pk; +use crate::utils::{read_bip32_path, MAX_ALLOWED_PATH_LEN}; use crate::SW_DENY; use nanos_sdk::ecc::{Secp256k1, SeedDerive}; use nanos_sdk::{io, testing}; -const MAX_ALLOWED_PATH_LEN: usize = 10; - -// const SW_DENY: u16 = 0x6985; - pub fn handler_get_public_key(comm: &mut io::Comm, display: bool) -> Result<(), io::Reply> { let mut path = [0u32; MAX_ALLOWED_PATH_LEN]; let data = comm.get_data()?; @@ -53,29 +50,3 @@ pub fn handler_get_public_key(comm: &mut io::Comm, display: bool) -> Result<(), Ok(()) } - -fn read_bip32_path(data: &[u8], path: &mut [u32]) -> Result { - // Check input length and path buffer capacity - if data.len() < 1 || path.len() < data.len() / 4 { - return Err(io::StatusWords::BadLen.into()); - } - - let path_len = data[0] as usize; // First byte is the length of the path - let path_data = &data[1..]; - - // Check path data length and alignment - if path_data.len() != path_len * 4 - || path_data.len() > MAX_ALLOWED_PATH_LEN * 4 - || path_data.len() % 4 != 0 - { - return Err(io::StatusWords::BadLen.into()); - } - - let mut idx = 0; - for (i, chunk) in path_data.chunks(4).enumerate() { - path[idx] = u32::from_be_bytes(chunk.try_into().unwrap()); - idx = i + 1; - } - - Ok(idx) -} diff --git a/src/handlers/sign_tx.rs b/src/handlers/sign_tx.rs new file mode 100644 index 0000000..c5013b6 --- /dev/null +++ b/src/handlers/sign_tx.rs @@ -0,0 +1,220 @@ +/***************************************************************************** + * Ledger App Boilerplate Rust. + * (c) 2023 Ledger SAS. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + *****************************************************************************/ +use crate::app_ui::sign::ui_display_tx; +use crate::utils::{read_bip32_path, MAX_ALLOWED_PATH_LEN}; +use crate::{SW_DENY, SW_TX_PARSING_FAIL, SW_WRONG_TX_LENGTH}; +// use nanos_sdk::ecc::{Secp256k1, SeedDerive}; +use nanos_sdk::{io, testing}; + +const MAX_TRANSACTION_LEN: usize = 510; +const MAX_DER_SIG_LEN: usize = 72; + +pub struct Tx { + nonce: u64, + pub value: u64, + pub to: [u8; 20], + pub memo: [u8; 255], + pub memo_len: usize, +} + +// Implement deserialize for Tx with TryFrom u8 array +impl TryFrom<&[u8]> for Tx { + type Error = (); + fn try_from(raw_tx: &[u8]) -> Result { + if raw_tx.len() > MAX_TRANSACTION_LEN { + return Err(()); + } + + testing::debug_print("Parsing 1\n"); + + // Try to parse the transaction fields : + // Nonce + let nonce_bytes = match raw_tx.get(..8) { + Some(value) => value, + None => return Err(()), + }; + let nonce = u64::from_be_bytes(nonce_bytes.try_into().unwrap()); + + testing::debug_print("Parsing 2\n"); + + // Amount value + let value_bytes = match raw_tx.get(8..16) { + Some(value) => value, + None => return Err(()), + }; + let value = u64::from_be_bytes(value_bytes.try_into().unwrap()); + + testing::debug_print("Parsing 3\n"); + + // To address + let to: [u8;20] = match raw_tx.get(16..36) { + Some(value) => value.try_into().unwrap(), + None => return Err(()), + }; + + testing::debug_print("Parsing 4\n"); + + // Memo length + let memo_len: usize = match raw_tx.get(36) { + Some(value) => *value as usize, + None => return Err(()), + }; + + testing::debug_print("Parsing 5\n"); + + // Memo + let mut memo: [u8; 255] = [0u8; 255]; + match raw_tx.get(37..(37 + memo_len) as usize) { + Some(value) => memo[0..memo_len].copy_from_slice(value), + None => return Err(()), + }; + + testing::debug_print("Parsing 6\n"); + + // Check memo ASCII encoding + if !memo.iter().all(|&byte| byte.is_ascii()) { + return Err(()); + } + + testing::debug_print("Parsing 7\n"); + + Ok(Tx { + nonce, + value, + to, + memo, + memo_len, + }) + } +} + +// Implement constructor for Tx with default values +impl Tx { + fn new() -> Tx { + Tx { + nonce: 0, + value: 0, + to: [0u8; 20], + memo: [0u8; 255], + memo_len: 0, + } + } +} + +// #[derive(Copy, Clone)] +pub struct TxInfo { + raw_tx: [u8; MAX_TRANSACTION_LEN], // raw transaction serialized + raw_tx_len: usize, // length of raw transaction + transaction: Tx, // structured transaction + m_hash: [u8; 32], // message hash digest + signature: [u8; MAX_DER_SIG_LEN], // transaction signature encoded in DER + signature_len: usize, // length of transaction signature + v: u8, // parity of y-coordinate of R in ECDSA signature +} + +// Implement constructor for TxInfo with default values +impl TxInfo { + pub fn new() -> TxInfo { + TxInfo { + raw_tx: [0u8; MAX_TRANSACTION_LEN], + raw_tx_len: 0, + transaction: Tx::new(), + m_hash: [0u8; 32], + signature: [0u8; MAX_DER_SIG_LEN], + signature_len: 0, + v: 0, + } + } + // Implement reset for TxInfo + fn reset(&mut self) { + self.raw_tx_len = 0; + self.transaction = Tx::new(); + self.m_hash = [0u8; 32]; + self.signature = [0u8; MAX_DER_SIG_LEN]; + self.signature_len = 0; + self.v = 0; + } +} + +pub fn handler_sign_tx( + comm: &mut io::Comm, + chunk: u8, + more: bool, + txinfo: &mut TxInfo, +) -> Result<(), io::Reply> { + // Try to get data from comm. If there is no data, + // the '?' operator will propagate the error. + let data = comm.get_data()?; + // First chunk, try to parse the path + if chunk == 0 { + testing::debug_print("First transaction chunk\n"); + // Reset txinfo + txinfo.reset(); + let mut path = [0u32; MAX_ALLOWED_PATH_LEN]; + // This will propagate the error if the path is invalid + read_bip32_path(data, &mut path)?; + // Next chunks, append data to raw_tx and return or parse + // the transaction if it is the last chunk. + } else { + if txinfo.raw_tx_len + data.len() > MAX_TRANSACTION_LEN { + return Err(io::Reply(SW_WRONG_TX_LENGTH)); + } + + testing::debug_print("Next transaction chunks\n"); + + // Append data to raw_tx + txinfo.raw_tx[txinfo.raw_tx_len..txinfo.raw_tx_len + data.len()].copy_from_slice(data); + txinfo.raw_tx_len += data.len(); + + // If we expect more chunks, return + if more { + testing::debug_print("More chunks expected : return\n"); + return Ok(()); + // Otherwise, try to parse the transaction + } else { + testing::debug_print("Last chunk : parse transaction\n"); + match Tx::try_from(&txinfo.raw_tx[..txinfo.raw_tx_len]) { + Ok(tx) => { + testing::debug_print("Transaction parsed\n"); + txinfo.transaction = tx; + } + Err(_) => { + testing::debug_print("Transaction parsing failed\n"); + return Err(io::Reply(SW_TX_PARSING_FAIL)); + } + } + + // Display transaction. If user approves + // the transaction, sign it. Otherwise, + // return an error. + if ui_display_tx(&txinfo.transaction)? + { + return Ok(()) + } + else { + return Err(io::Reply(SW_DENY)); + } + + // let out = sign_ui(comm.get_data()?)?; + // if let Some((signature_buf, length, _)) = out + // { + // comm.append(&signature_buf[..length as usize]) + // } + } + } + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 7060d53..1d066d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,65 +5,33 @@ mod utils; mod app_ui { pub mod address; pub mod menu; + pub mod sign; } mod handlers { pub mod get_public_key; pub mod get_version; + pub mod sign_tx; } -use core::str::from_utf8; use nanos_sdk::buttons::ButtonEvent; -use nanos_sdk::ecc::{Secp256k1, SeedDerive}; use nanos_sdk::io; -use nanos_sdk::io::SyscallError; use nanos_ui::ui; -use nanos_ui::bitmaps::{CROSSMARK, EYE, VALIDATE_14}; - use app_ui::menu::ui_menu_main; -use handlers::{get_public_key::handler_get_public_key, get_version::handler_get_version}; +use handlers::{ + get_public_key::handler_get_public_key, + get_version::handler_get_version, + sign_tx::{handler_sign_tx, TxInfo}, +}; nanos_sdk::set_panic!(nanos_sdk::exiting_panic); -pub const BIP32_PATH: [u32; 5] = nanos_sdk::ecc::make_bip32_path(b"m/44'/535348'/0'/0/0"); - pub const SW_INS_NOT_SUPPORTED: u16 = 0x6D00; pub const SW_DENY: u16 = 0x6985; pub const SW_WRONG_P1P2: u16 = 0x6A86; pub const SW_WRONG_DATA_LENGTH: u16 = 0x6A87; - -/// This is the UI flow for signing, composed of a scroller -/// to read the incoming message, a panel that requests user -/// validation, and an exit message. -fn sign_ui(message: &[u8]) -> Result, SyscallError> { - let hex = utils::to_hex(message).map_err(|_| SyscallError::Overflow)?; - let m = from_utf8(&hex).map_err(|_| SyscallError::InvalidParameter)?; - let my_field = [ui::Field { - name: "Data", - value: m, - }]; - - let my_review = ui::MultiFieldReview::new( - &my_field, - &["Review ", "Transaction"], - Some(&EYE), - "Approve", - Some(&VALIDATE_14), - "Reject", - Some(&CROSSMARK), - ); - - if my_review.show() { - let signature = Secp256k1::derive_from_path(&BIP32_PATH) - .deterministic_sign(message) - .map_err(|_| SyscallError::Unspecified)?; - ui::popup("Done !"); - Ok(Some(signature)) - } else { - ui::popup("Cancelled"); - Ok(None) - } -} +pub const SW_WRONG_TX_LENGTH: u16 = 0xB004; +pub const SW_TX_PARSING_FAIL: u16 = 0xB005; #[no_mangle] extern "C" fn sample_pending() { @@ -88,12 +56,13 @@ extern "C" fn sample_pending() { #[no_mangle] extern "C" fn sample_main() { let mut comm = io::Comm::new(); + let mut tx_info = TxInfo::new(); loop { // Wait for either a specific button push to exit the app // or an APDU command match ui_menu_main(&mut comm) { - io::Event::Command(ins) => match handle_apdu(&mut comm, ins.into()) { + io::Event::Command(ins) => match handle_apdu(&mut comm, ins.into(), &mut tx_info) { Ok(()) => comm.reply_ok(), Err(sw) => comm.reply(sw), }, @@ -104,6 +73,7 @@ extern "C" fn sample_main() { #[repr(u8)] +// Instruction set for the app. enum Ins { GetVersion, GetAppName, @@ -111,8 +81,16 @@ enum Ins { SignTx, UnknownIns, } - +// CLA (APDU class byte) for all APDUs. const CLA: u8 = 0xe0; +// P2 for last APDU to receive. +const P2_SIGN_TX_LAST: u8 = 0x00; +// P2 for more APDU to receive. +const P2_SIGN_TX_MORE: u8 = 0x80; +// P1 for first APDU number. +const P1_SIGN_TX_START: u8 = 0x00; +// P1 for maximum APDU number. +const P1_SIGN_TX_MAX: u8 = 0x03; impl From for Ins { fn from(header: io::ApduHeader) -> Ins { @@ -128,7 +106,7 @@ impl From for Ins { use nanos_sdk::io::Reply; -fn handle_apdu(comm: &mut io::Comm, ins: Ins) -> Result<(), Reply> { +fn handle_apdu(comm: &mut io::Comm, ins: Ins, txinfo: &mut TxInfo) -> Result<(), Reply> { if comm.rx == 0 { return Err(io::StatusWords::NothingReceived.into()); } @@ -164,10 +142,23 @@ fn handle_apdu(comm: &mut io::Comm, ins: Ins) -> Result<(), Reply> { return handler_get_public_key(comm, apdu_metadata.p1 == 1); } Ins::SignTx => { - let out = sign_ui(comm.get_data()?)?; - if let Some((signature_buf, length, _)) = out { - comm.append(&signature_buf[..length as usize]) + if (apdu_metadata.p1 == P1_SIGN_TX_START && apdu_metadata.p2 != P2_SIGN_TX_MORE) + || apdu_metadata.p1 > P1_SIGN_TX_MAX + || (apdu_metadata.p2 != P2_SIGN_TX_LAST && apdu_metadata.p2 != P2_SIGN_TX_MORE) + { + return Err(io::Reply(SW_WRONG_P1P2)); + } + + if (comm.get_data()?.len()) == 0 { + return Err(io::Reply(SW_WRONG_DATA_LENGTH)); } + + return handler_sign_tx( + comm, + apdu_metadata.p1, + apdu_metadata.p2 == P2_SIGN_TX_MORE, + txinfo, + ); } Ins::UnknownIns => { return Err(io::Reply(SW_INS_NOT_SUPPORTED)); diff --git a/src/utils.rs b/src/utils.rs index 9c0acfc..ef968b1 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,7 +1,11 @@ +use crate::SW_WRONG_DATA_LENGTH; use core::char; +use nanos_sdk::io::Reply; use nanos_sdk::testing; -/// Convert to hex. Returns a static buffer of 64 bytes +pub const MAX_ALLOWED_PATH_LEN: usize = 10; + +/// Convert to hex. Returns a static buffer of 255 bytes #[inline] pub fn to_hex(m: &[u8]) -> Result<[u8; 255], ()> { if 2 * m.len() > 255 { @@ -19,3 +23,48 @@ pub fn to_hex(m: &[u8]) -> Result<[u8; 255], ()> { } Ok(hex) } + +/// Convert serialized derivation path to u32 array elements +pub fn read_bip32_path(data: &[u8], path: &mut [u32]) -> Result { + // Check input length and path buffer capacity + if data.len() < 1 || path.len() < data.len() / 4 { + return Err(Reply(SW_WRONG_DATA_LENGTH)); + } + + let path_len = data[0] as usize; // First byte is the length of the path + let path_data = &data[1..]; + + // Check path data length and alignment + if path_data.len() != path_len * 4 + || path_data.len() > MAX_ALLOWED_PATH_LEN * 4 + || path_data.len() % 4 != 0 + { + return Err(Reply(SW_WRONG_DATA_LENGTH)); + } + + let mut idx = 0; + for (i, chunk) in path_data.chunks(4).enumerate() { + path[idx] = u32::from_be_bytes(chunk.try_into().unwrap()); + idx = i + 1; + } + + Ok(idx) +} + +/// Concatenate multiple strings into a fixed-size array +pub fn concatenate(strings: &[&str], output: &mut [u8]) { + let mut offset = 0; + + for s in strings { + let s_len = s.len(); + let copy_len = core::cmp::min(s_len, output.len() - offset); + + if copy_len > 0 { + output[offset..offset + copy_len].copy_from_slice(&s.as_bytes()[..copy_len]); + offset += copy_len; + } else { + // If the output buffer is full, stop concatenating. + break; + } + } +}