diff --git a/Cargo.toml b/Cargo.toml index 438e311..3aaf0ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,19 +7,39 @@ edition = "2021" license = "Apache-2.0 OR MIT" description = "Administrative Trussed app for SoloKeys Solo 2 security keys" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] -apdu-dispatch = "0.1" -ctaphid-dispatch = "0.1" +apdu-app = "0.2" +cbor-smol = { version = "0.5.0", features = ["heapless-v0-9", "heapless-bytes-v0-5"] } +ctaphid-app = "0.2" delog = "0.1" -iso7816 = "0.1" -trussed = "0.1" +heapless = "0.9" +heapless-bytes = { version = "0.5", features = ["heapless-0.9"] } +iso7816 = "0.2" +littlefs2 = { version = "0.7", optional = true } +littlefs2-core = { version = "0.1", features = ["heapless-bytes05"] } +serde = { version = "1.0.180", default-features = false } +strum_macros = "0.25.2" +trussed = { version = "=0.2.0-rc.1", default-features = false } +trussed-core = { version = "0.2", features = ["crypto-client", "filesystem-client", "management-client", "ui-client"] } + +embedded-hal = { version = "0.2.7", optional = true } +hex-literal = "0.4.1" +rand_chacha = { version = "0.3.1", optional = true, default-features = false } +trussed-manage = { version = "0.3" } +trussed-se050-manage = { version = "0.3.0", optional = true } [features] +default = [] +se050 = ["trussed-se050-manage"] + +factory-reset = [] log-all = [] +log-trace = [] log-none = [] log-info = [] log-debug = [] log-warn = [] log-error = [] + +# Utils to test migration +migration-tests = ["dep:littlefs2"] diff --git a/src/admin.rs b/src/admin.rs index 56cf138..f2ee6f7 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -1,15 +1,36 @@ -use core::{convert::TryInto, marker::PhantomData}; -use ctaphid_dispatch::app::{self as hid, Command as HidCommand, Message}; -use ctaphid_dispatch::command::VendorCommand; -use apdu_dispatch::{Command, command, response, app as apdu}; -use apdu_dispatch::iso7816::Status; -use trussed::{ - syscall, - Client as TrussedClient, -}; +use super::Client as TrussedClient; +use apdu_app::{CommandView, Interface}; +use cbor_smol::{cbor_deserialize, cbor_serialize_to}; +use core::{convert::TryInto, marker::PhantomData, time::Duration}; +use ctaphid_app::{self as hid, Command as HidCommand, VendorCommand}; +use heapless::VecView; +use heapless_bytes::BytesView; +use iso7816::Status; +#[cfg(feature = "factory-reset")] +use littlefs2_core::PathBuf; +use serde::Deserialize; +use trussed::store::{Filestore, Store}; +use trussed_core::{syscall, try_syscall, InterruptFlag}; + +use crate::config::{self, Config, ConfigError}; +use crate::migrations::Migrator; pub const USER_PRESENCE_TIMEOUT_SECS: u32 = 15; +// New commands are only available over this vendor command (acting as a namespace for this +// application). The actual application command is stored in the first byte of the packet data. +const ADMIN: VendorCommand = VendorCommand::H72; +const STATUS: u8 = 0x80; +const TEST_SE050: u8 = 0x81; +const GET_CONFIG: u8 = 0x82; +const SET_CONFIG: u8 = 0x83; +#[cfg(feature = "factory-reset")] +const FACTORY_RESET: u8 = 0x84; +#[cfg(feature = "factory-reset")] +const FACTORY_RESET_APP: u8 = 0x85; +const LIST_AVAILABLE_FIELDS: u8 = 0x86; + +// For compatibility, old commands are also available directly as separate vendor commands. const UPDATE: VendorCommand = VendorCommand::H51; const REBOOT: VendorCommand = VendorCommand::H53; const RNG: VendorCommand = VendorCommand::H60; @@ -17,6 +38,129 @@ const VERSION: VendorCommand = VendorCommand::H61; const UUID: VendorCommand = VendorCommand::H62; const LOCKED: VendorCommand = VendorCommand::H63; +// We also handle the standard wink command. +const WINK: HidCommand = HidCommand::Wink; // 0x08 + +const RNG_DATA_LEN: usize = 57; + +const CONFIG_OK: u8 = 0x00; +#[cfg(feature = "factory-reset")] +const FACTORY_RESET_OK: u8 = 0x00; +#[cfg(feature = "factory-reset")] +const FACTORY_RESET_NOT_CONFIRMED: u8 = 0x01; +#[cfg(feature = "factory-reset")] +const FACTORY_RESET_APP_NOT_ALLOWED: u8 = 0x02; +#[cfg(feature = "factory-reset")] +const FACTORY_RESET_APP_FAILED_PARSE: u8 = 0x03; + +#[derive(PartialEq, Debug)] +enum Command { + Update, + Reboot, + Rng, + Version, + Uuid, + Locked, + Wink, + Status, + TestSe05X, + GetConfig, + SetConfig, + #[cfg(feature = "factory-reset")] + FactoryReset, + #[cfg(feature = "factory-reset")] + FactoryResetApp, + ListAvailableFields, +} + +impl TryFrom for Command { + type Error = Error; + + fn try_from(command: u8) -> Result { + // First, check the old commands. + if let Ok(command) = HidCommand::try_from(command) { + if let Ok(command) = command.try_into() { + return Ok(command); + } + } + + // Now check the new commands. + match command { + STATUS => Ok(Command::Status), + TEST_SE050 => Ok(Command::TestSe05X), + GET_CONFIG => Ok(Command::GetConfig), + SET_CONFIG => Ok(Command::SetConfig), + #[cfg(feature = "factory-reset")] + FACTORY_RESET => Ok(Command::FactoryReset), + #[cfg(feature = "factory-reset")] + FACTORY_RESET_APP => Ok(Command::FactoryResetApp), + LIST_AVAILABLE_FIELDS => Ok(Command::ListAvailableFields), + _ => Err(Error::UnsupportedCommand), + } + } +} + +impl TryFrom for Command { + type Error = Error; + + fn try_from(command: HidCommand) -> Result { + match command { + WINK => Ok(Command::Wink), + HidCommand::Vendor(command) => command.try_into(), + _ => Err(Error::UnsupportedCommand), + } + } +} + +impl TryFrom for Command { + type Error = Error; + + fn try_from(command: VendorCommand) -> Result { + match command { + UPDATE => Ok(Command::Update), + REBOOT => Ok(Command::Reboot), + RNG => Ok(Command::Rng), + VERSION => Ok(Command::Version), + UUID => Ok(Command::Uuid), + LOCKED => Ok(Command::Locked), + _ => Err(Error::UnsupportedCommand), + } + } +} + +enum Error { + InvalidLength, + NotAvailable, + UnsupportedCommand, +} + +impl From for hid::Error { + fn from(error: Error) -> Self { + match error { + Error::InvalidLength => Self::InvalidLength, + // TODO: use more appropriate error code + Error::NotAvailable => Self::InvalidLength, + Error::UnsupportedCommand => Self::InvalidCommand, + } + } +} + +impl From for Status { + fn from(error: Error) -> Self { + match error { + Error::InvalidLength => Self::WrongLength, + Error::NotAvailable => Self::ConditionsOfUseNotSatisfied, + Error::UnsupportedCommand => Self::InstructionNotSupportedOrInvalid, + } + } +} + +#[derive(Debug, Deserialize)] +struct SetConfigRequest<'a> { + key: &'a str, + value: &'a str, +} + pub trait Reboot { /// Reboots the device. fn reboot() -> !; @@ -40,160 +184,450 @@ pub trait Reboot { fn locked() -> bool; } -pub struct App -where T: TrussedClient, - R: Reboot, -{ +/// Trait indicating that a value can be used as a status +pub trait StatusBytes { + type Serialized: AsRef<[u8]>; + /// Set the flag indicating that the random generator could properly be created (`false`) or not (`true`) + fn set_random_error(&mut self, value: bool); + /// Get the flag indicating that the random generator could properly be created (`false`) or not (`true`) + fn get_random_error(&self) -> bool; + /// Serialize the StatusBytes to raw bytes + fn serialize(&self) -> Self::Serialized; +} + +pub struct App { trussed: T, uuid: [u8; 16], version: u32, + full_version: &'static str, + status: S, boot_interface: PhantomData, + config: C, + migrations: &'static [Migrator], } -impl App -where T: TrussedClient, - R: Reboot, +impl App +where + T: TrussedClient, + R: Reboot, + S: StatusBytes, + C: Config, { - pub fn new(client: T, uuid: [u8; 16], version: u32) -> Self { - Self { trussed: client, uuid, version, boot_interface: PhantomData } + /// Create an admin app instance, loading the configuration from the filesystem. + pub fn load_config( + client: T, + filestore: &mut F, + uuid: [u8; 16], + version: u32, + full_version: &'static str, + status: S, + migrations: &'static [Migrator], + ) -> Result { + match config::load(filestore) { + Ok(config) => Ok(Self::new( + client, + uuid, + version, + full_version, + status, + config, + migrations, + )), + Err(err) => { + error!("failed to load configuration: {:?}", err); + Err((client, err)) + } + } } - fn user_present(&mut self) -> bool { - let user_present = syscall!(self.trussed.confirm_user_present(USER_PRESENCE_TIMEOUT_SECS * 1000)).result; - user_present.is_ok() + pub fn migrate( + &mut self, + to_version: u32, + store: impl Store, + filestore: &mut F, + ) -> Result<(), ConfigError> { + let Some(current_version) = self.config.migration_version() else { + // Migrate cannot be done for configurations that don't provide storage of the filesystem version + return Err(ConfigError::InvalidValue); + }; + + if current_version == to_version { + return Ok(()); + } + + if to_version < current_version { + return Err(ConfigError::InvalidValue); + } + + let internal = store.ifs(); + let external = store.efs(); + + for migration in self.migrations { + if migration.version > current_version && migration.version <= to_version { + (migration.migrate)(internal, external).map_err(|_err| { + error_now!("Migration failed: {_err:?}"); + ConfigError::WriteFailed + })?; + } + } + + if !self.config.set_migration_version(to_version) { + return Err(ConfigError::InvalidValue); + } + config::save_filestore(filestore, &self.config) } + /// Create an admin app instance without the configuration mechanism, using the default config + /// values. + /// + /// This is only intended for debugging, testing and example code. In production, + /// [`App::load_config`][] should be used. + pub fn with_default_config( + client: T, + uuid: [u8; 16], + version: u32, + full_version: &'static str, + status: S, + migrations: &'static [Migrator], + ) -> Self { + Self::new( + client, + uuid, + version, + full_version, + status, + Default::default(), + migrations, + ) + } -} + fn new( + client: T, + uuid: [u8; 16], + version: u32, + full_version: &'static str, + status: S, + config: C, + migrations: &'static [Migrator], + ) -> Self { + Self { + trussed: client, + uuid, + version, + full_version, + status, + boot_interface: PhantomData, + config, + migrations, + } + } -impl hid::App for App -where T: TrussedClient, - R: Reboot -{ - fn commands(&self) -> &'static [HidCommand] { - &[ - HidCommand::Wink, - HidCommand::Vendor(UPDATE), - HidCommand::Vendor(REBOOT), - HidCommand::Vendor(RNG), - HidCommand::Vendor(VERSION), - HidCommand::Vendor(UUID), - HidCommand::Vendor(LOCKED), - ] + pub fn config(&self) -> &C { + &self.config } - fn call(&mut self, command: HidCommand, input_data: &Message, response: &mut Message) -> hid::AppResult { + pub fn config_mut(&mut self) -> &mut C { + &mut self.config + } + + pub fn save_config_filestore( + &mut self, + filestore: &mut F, + ) -> Result<(), ConfigError> { + config::save_filestore(filestore, &self.config) + } + + fn user_present(&mut self) -> bool { + let user_present = syscall!(self + .trussed + .confirm_user_present(USER_PRESENCE_TIMEOUT_SECS * 1000)) + .result; + user_present.is_ok() + } + + fn exec( + &mut self, + command: Command, + input: &[u8], + response: &mut VecView, + ) -> Result<(), Error> { + debug_now!("Executing command: {command:?}"); match command { - HidCommand::Vendor(REBOOT) => R::reboot(), - HidCommand::Vendor(LOCKED) => { - response.extend_from_slice( - &[R::locked() as u8] - ).ok(); + Command::Reboot => R::reboot(), + Command::Locked => { + response.push(R::locked().into()).ok(); } - HidCommand::Vendor(RNG) => { + Command::Rng => { // Fill the HID packet (57 bytes) - response.extend_from_slice( - &syscall!(self.trussed.random_bytes(57)).bytes.as_slice() - ).ok(); + response + .extend_from_slice(&syscall!(self.trussed.random_bytes(RNG_DATA_LEN)).bytes) + .ok(); } - HidCommand::Vendor(UPDATE) => { + Command::Update => { if self.user_present() { - if input_data.len() > 0 && input_data[0] == 0x01 { + if input.first().copied() == Some(0x01) { R::reboot_to_firmware_update_destructive(); } else { R::reboot_to_firmware_update(); } } else { - return Err(hid::Error::InvalidLength); + return Err(Error::NotAvailable); } } - HidCommand::Vendor(UUID) => { + Command::Uuid => { // Get UUID response.extend_from_slice(&self.uuid).ok(); } - HidCommand::Vendor(VERSION) => { + Command::Version => { // GET VERSION - response.extend_from_slice(&self.version.to_be_bytes()).ok(); + if input.first().copied() == Some(0x01) { + response + .extend_from_slice(self.full_version.as_bytes()) + .ok(); + } else { + response.extend_from_slice(&self.version.to_be_bytes()).ok(); + } } - HidCommand::Wink => { + Command::Wink => { debug_now!("winking"); - syscall!(self.trussed.wink(core::time::Duration::from_secs(10))); + syscall!(self.trussed.wink(Duration::from_secs(10))); + } + Command::Status => { + if !self.status.get_random_error() { + let is_random_working = try_syscall!(self.trussed.random_bytes(1)).is_ok(); + self.status.set_random_error(!is_random_working); + } + response + .extend_from_slice(self.status.serialize().as_ref()) + .ok(); + } + Command::TestSe05X => { + #[cfg(feature = "se050")] + { + let rep = syscall!(self.trussed.test_se050()); + response.extend_from_slice(&rep.reply).ok(); + return Ok(()); + } + #[cfg(not(feature = "se050"))] + { + return Err(Error::UnsupportedCommand); + } + } + Command::GetConfig => { + // Response: 1 status byte, then data if status == 0 + response.push(CONFIG_OK).ok(); + if let Err(error) = self.get_config(input, response) { + response.clear(); + response.push(error.into()).ok(); + } + } + Command::SetConfig => { + // Response: 1 status byte + let status = match self.set_config(input) { + Ok(()) => CONFIG_OK, + Err(error) => error.into(), + }; + response.push(status).ok(); } - _ => { - return Err(hid::Error::InvalidCommand); + Command::ListAvailableFields => { + cbor_serialize_to::<_, &mut VecView>( + &self.config.list_available_fields(), + response, + ) + .ok(); + return Ok(()); + } + #[cfg(feature = "factory-reset")] + Command::FactoryReset => { + debug_now!("Factory resetting the device"); + if let Err(_err) = syscall!(self.trussed.confirm_user_present(15 * 1000)).result { + debug_now!("Failed to verify user presence: {_err:?}"); + response.push(FACTORY_RESET_NOT_CONFIRMED).ok(); + return Ok(()); + } + syscall!(self.trussed.factory_reset_device()); + R::reboot(); + } + #[cfg(feature = "factory-reset")] + Command::FactoryResetApp => { + let Ok(client) = core::str::from_utf8(input) else { + response.push(FACTORY_RESET_APP_FAILED_PARSE).ok(); + return Ok(()); + }; + let Ok(path) = PathBuf::try_from(client) else { + response.push(FACTORY_RESET_APP_FAILED_PARSE).ok(); + return Ok(()); + }; + + let Some((_, flag)) = self.config().reset_client_id(client) else { + response.push(FACTORY_RESET_APP_NOT_ALLOWED).ok(); + return Ok(()); + }; + + if let Err(_err) = syscall!(self.trussed.confirm_user_present(15 * 1000)).result { + debug_now!("Failed to verify user presence: {_err:?}"); + response.push(FACTORY_RESET_NOT_CONFIRMED).ok(); + return Ok(()); + } + + match self.config.reset_client_config(client) { + crate::config::ResetConfigResult::Changed => { + flag.set_config_changed(); + config::save(&mut self.trussed, &self.config).map_err(|_err| { + error_now!("Failed to save config: {_err:?}"); + Error::InvalidLength + })?; + syscall!(self.trussed.factory_reset_client(&path)); + } + crate::config::ResetConfigResult::Unchanged => { + // No need to factory reset if already factory reset + if flag.set_factory_reset() { + syscall!(self.trussed.factory_reset_client(&path)); + } + } + crate::config::ResetConfigResult::WrongKey => { + response.push(FACTORY_RESET_APP_NOT_ALLOWED).ok(); + return Ok(()); + } + } + + response.push(FACTORY_RESET_OK).ok(); } } Ok(()) } + + fn get_config(&mut self, input: &[u8], response: &mut VecView) -> Result<(), ConfigError> { + let key = core::str::from_utf8(input).map_err(|_| ConfigError::InvalidKey)?; + config::get(&mut self.config, key, response) + } + + fn set_config(&mut self, input: &[u8]) -> Result<(), ConfigError> { + let request: SetConfigRequest<'_> = + cbor_deserialize(input).map_err(|_| ConfigError::DeserializationFailed)?; + let reset_client_id = self.config.reset_client_id(request.key); + + if reset_client_id.is_some() { + if let Err(_err) = syscall!(self.trussed.confirm_user_present(15 * 1000)).result { + debug_now!("Failed to verify user presence: {_err:?}"); + return Err(ConfigError::NotConfirmed); + } + } + + config::set(&mut self.config, request.key, request.value)?; + if let Some((client, signal)) = reset_client_id { + signal.set_config_changed(); + syscall!(self.trussed.factory_reset_client(client)); + } + + config::save(&mut self.trussed, &self.config) + } + + pub fn status(&self) -> &S { + &self.status + } + + pub fn status_mut(&mut self) -> &mut S { + &mut self.status + } +} + +impl hid::App<'static> for App +where + T: TrussedClient, + R: Reboot, + S: StatusBytes, + C: Config, +{ + fn commands(&self) -> &'static [HidCommand] { + &[ + HidCommand::Wink, + HidCommand::Vendor(ADMIN), + HidCommand::Vendor(UPDATE), + HidCommand::Vendor(REBOOT), + HidCommand::Vendor(RNG), + HidCommand::Vendor(VERSION), + HidCommand::Vendor(UUID), + HidCommand::Vendor(LOCKED), + ] + } + + fn call( + &mut self, + command: HidCommand, + input_data: &[u8], + response: &mut BytesView, + ) -> Result<(), hid::Error> { + let (command, input) = if command == HidCommand::Vendor(ADMIN) { + // new mode: first input byte specifies the actual command + let (command, input) = input_data.split_first().ok_or(Error::InvalidLength)?; + let command = Command::try_from(*command)?; + (command, input) + } else { + // old mode: directly use vendor commands + wink + (Command::try_from(command)?, input_data) + }; + self.exec(command, input, response.as_mut()) + .map_err(From::from) + } + + fn interrupt(&self) -> Option<&'static InterruptFlag> { + self.trussed.interrupt() + } } -impl iso7816::App for App -where T: TrussedClient, - R: Reboot +impl iso7816::App for App +where + T: TrussedClient, + R: Reboot, + S: StatusBytes, { // Solo management app fn aid(&self) -> iso7816::Aid { - iso7816::Aid::new(&[ 0xA0, 0x00, 0x00, 0x08, 0x47, 0x00, 0x00, 0x00, 0x01]) + iso7816::Aid::new(&[0xA0, 0x00, 0x00, 0x08, 0x47, 0x00, 0x00, 0x00, 0x01]) } } -impl apdu::App<{command::SIZE}, {response::SIZE}> for App -where T: TrussedClient, - R: Reboot +impl apdu_app::App for App +where + T: TrussedClient, + R: Reboot, + S: StatusBytes, + C: Config, { - - fn select(&mut self, _apdu: &Command, _reply: &mut response::Data) -> apdu::Result { + fn select( + &mut self, + _interface: Interface, + _apdu: CommandView<'_>, + _reply: &mut heapless::VecView, + ) -> apdu_app::Result { Ok(()) } fn deselect(&mut self) {} - fn call(&mut self, interface: apdu::Interface, apdu: &Command, reply: &mut response::Data) -> apdu::Result { + fn call( + &mut self, + interface: Interface, + apdu: CommandView<'_>, + reply: &mut heapless::VecView, + ) -> apdu_app::Result { let instruction: u8 = apdu.instruction().into(); + let command = Command::try_from(instruction)?; - if instruction == 0x08 { - syscall!(self.trussed.wink(core::time::Duration::from_secs(10))); - return Ok(()); + // Reboot may only be called over USB + if command == Command::Reboot && interface != Interface::Contact { + return Err(Status::ConditionsOfUseNotSatisfied); } - let command: VendorCommand = instruction.try_into().map_err(|_e| Status::InstructionNotSupportedOrInvalid)?; - - match command { - REBOOT => R::reboot(), - LOCKED => { - // Random bytes - reply.extend_from_slice(&[R::locked() as u8]).ok(); - } - RNG => { - // Random bytes - reply.extend_from_slice(&syscall!(self.trussed.random_bytes(57)).bytes.as_slice()).ok(); - } - UPDATE => { - // Boot to mcuboot (only when contact interface) - if interface == apdu::Interface::Contact && self.user_present() - { - if apdu.p1 == 0x01 { - R::reboot_to_firmware_update_destructive(); - } else { - R::reboot_to_firmware_update(); - } - } - return Err(Status::ConditionsOfUseNotSatisfied); - } - UUID => { - // Get UUID - reply.extend_from_slice(&self.uuid).ok(); - } - VERSION => { - // Get version - reply.extend_from_slice(&self.version.to_be_bytes()[..]).ok(); - } - - _ => return Err(Status::InstructionNotSupportedOrInvalid), - + // The Update and Version commands use the P1 value to select an operation mode. As we + // cannot model this in the CTAPHID application, we pretend that we received the flag as + // the command payload. + if command == Command::Update || command == Command::Version { + self.exec(command, &[apdu.p1], reply) + } else { + self.exec(command, apdu.data(), reply) } - Ok(()) - + .map_err(From::from) } } - diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..7031d2d --- /dev/null +++ b/src/config.rs @@ -0,0 +1,383 @@ +use core::{ + fmt::{self, Display, Formatter, Write as _}, + str::FromStr, + sync::atomic::{AtomicU8, Ordering}, +}; + +use cbor_smol::{cbor_deserialize, cbor_serialize_to}; +use heapless::VecView; +use littlefs2_core::{path, Path}; +use serde::{de::DeserializeOwned, Serialize}; +use strum_macros::FromRepr; +use trussed::store::Filestore; +use trussed_core::{ + try_syscall, + types::{Location, Message}, + FilesystemClient, +}; + +#[derive(Debug)] +/// Structure meant to be stored in a `static` to signal applications that they have been factory-resetted by the admin app +/// +/// It is expected to have one such structure for each application supporting factory-reset by the admin-app +/// +/// ```rust,ignore +///# use admin_app::{ResetSignalAllocation, ConfigValueMut}; +///# use littlefs2::{path::Path, path}; +/// #[derive(Default, PartialEq, serde::Deserialize, serde::Serialize)] +/// struct Config { +/// use_new_backend: bool, +///}; +/// static OPCARD_RESET: ResetSignalAllocation = ResetSignalAllocation::new(); +/// impl admin_app::Config for Config { +/// fn field(&mut self, key: &str) -> Option> { +/// match key { +/// "opcard.use_new_backend" => Some(ConfigValueMut::Bool(&mut self.use_new_backend)), +/// _ => None, +/// } +/// } +/// /// Client ID to factory-reset if the associated configuration option is changed +/// fn reset_client_id(&self, key: &str) -> Option<(&'static Path, &'static ResetSignalAllocation)> { +/// match key { +/// "opcard" => Some((path!("opcard"), &OPCARD_RESET)), +/// "opcard.use_new_backend" =>Some((path!("opcard"), &OPCARD_RESET)), +/// _ => None, +/// } +/// } +/// } +/// ``` +pub struct ResetSignalAllocation(AtomicU8); + +impl Default for ResetSignalAllocation { + fn default() -> Self { + Self::new() + } +} + +impl ResetSignalAllocation { + pub const fn new() -> Self { + Self(AtomicU8::new(ResetSignal::None as u8)) + } + + pub fn load(&self) -> ResetSignal { + let v = self.0.load(Ordering::Relaxed); + ResetSignal::from_repr(v).expect("A reset signal value") + } + + pub fn set_factory_reset(&self) -> bool { + self.0 + .compare_exchange( + ResetSignal::None as u8, + ResetSignal::FactoryReset as u8, + Ordering::Relaxed, + Ordering::Relaxed, + ) + .is_ok() + } + + pub fn set_config_changed(&self) { + self.0 + .store(ResetSignal::ConfigChanged as u8, Ordering::Relaxed) + } + + /// Factory reset can be acknowledged so that the application can restart working + /// + /// A configuration change cannot be acknowledged as it requires a power cycle to be taken into account. + pub fn ack_factory_reset(&self) -> bool { + self.0 + .compare_exchange( + ResetSignal::FactoryReset as u8, + ResetSignal::None as u8, + Ordering::Relaxed, + Ordering::Relaxed, + ) + .is_ok() + } +} + +#[derive(Debug, FromRepr, Default)] +#[repr(u8)] +pub enum ResetSignal { + #[default] + /// The App can continue operating + None, + /// The app has had it state factory reseted by the admin app + /// + /// It should delete any runtime state it is currently holding, then [`acknowledge`](ResetSignalAllocation::ack_factory_reset) the reset and continue working. + FactoryReset, + /// A configuration relevant to the application has been changed. + /// + /// The application must reject all incoming request and store no persistent state until a power cycle. + ConfigChanged, +} + +const LOCATION: Location = Location::Internal; +const FILENAME: &Path = path!("config"); + +#[derive(Debug, Clone, Copy)] +pub enum ResetConfigResult { + /// The config was changed as a result of the reset to default + Changed, + /// The config was at the default value + Unchanged, + /// The key does not correspond to any application that can be reset + WrongKey, +} + +impl ResetConfigResult { + pub fn is_changed(&self) -> bool { + matches!(self, Self::Changed) + } + pub fn is_unchanged(&self) -> bool { + matches!(self, Self::Unchanged) + } + pub fn is_error(&self) -> bool { + matches!(self, Self::WrongKey) + } +} + +pub trait Config: Default + PartialEq + DeserializeOwned + Serialize { + fn field(&mut self, key: &str) -> Option>; + + /// Client ID to factory-reset if the associated configuration option is changed + /// + /// # If the Request is for a `client_id`: + /// + /// - MUST return `Some` to indicate that the client can be factory reset by the admin app, + /// In that case, the path is the clientid that must be reset, and the allocation must point to a + /// signal that id checked by the application. + /// - MUST return None otherwise. + fn reset_client_id( + &self, + _key: &str, + ) -> Option<(&'static Path, &'static ResetSignalAllocation)> { + None + } + + /// Reset the config of a client to its default value + /// + /// Returns `true` if the config has been changed as a result + fn reset_client_config(&mut self, _key: &str) -> ResetConfigResult { + ResetConfigResult::WrongKey + } + + /// The migration version + /// + /// Return None if the configuration does not support storing the migration version + fn migration_version(&self) -> Option; + + /// Set the migration version + /// + /// Return false if the configuration does not support storing the migration version + fn set_migration_version(&mut self, _version: u32) -> bool; + + fn list_available_fields(&self) -> &'static [ConfigField]; +} + +// No need to rename, cbor-smol already packs enum using ids +#[derive(Serialize)] +#[non_exhaustive] +pub enum FieldType { + Bool, + U8, +} + +#[derive(Serialize)] +pub struct ConfigField { + #[serde(rename = "n")] + pub name: &'static str, + /// Changing the config field requires a touch + #[serde(rename = "c")] + pub requires_touch_confirmation: bool, + /// Changing the config field requires a power cycle + #[serde(rename = "r")] + pub requires_reboot: bool, + /// Changing the config field deletes data + #[serde(rename = "d")] + pub destructive: bool, + /// The type of data stored in this field + #[serde(rename = "t")] + pub ty: FieldType, +} + +impl Config for () { + fn field(&mut self, _key: &str) -> Option> { + None + } + + fn reset_client_config(&mut self, _key: &str) -> ResetConfigResult { + ResetConfigResult::WrongKey + } + + fn migration_version(&self) -> Option { + None + } + + fn set_migration_version(&mut self, _version: u32) -> bool { + false + } + + fn list_available_fields(&self) -> &'static [ConfigField] { + &[] + } +} + +#[derive(Debug, Serialize)] +pub enum ConfigValueMut<'a> { + Bool(&'a mut bool), + U8(&'a mut u8), +} + +impl<'a> ConfigValueMut<'a> { + fn set(&mut self, value: &str) -> Result<(), ConfigError> { + fn set_value(target: &mut T, s: &str) -> Result<(), ConfigError> { + *target = s.parse().map_err(|_| ConfigError::InvalidValue)?; + Ok(()) + } + + match self { + Self::Bool(r) => set_value(*r, value), + Self::U8(r) => set_value(*r, value), + } + } +} + +impl<'a> Display for ConfigValueMut<'a> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Bool(value) => write!(f, "{}", value), + Self::U8(value) => write!(f, "{}", value), + } + } +} + +#[derive(Debug, FromRepr)] +#[repr(u8)] +pub enum ConfigError { + ReadFailed = 1, + WriteFailed = 2, + DeserializationFailed = 3, + SerializationFailed = 4, + InvalidKey = 5, + InvalidValue = 6, + DataTooLong = 7, + NotConfirmed = 8, +} + +const _: () = assert!( + ConfigError::from_repr(0).is_none(), + "ConfigError may not have a variant with discriminant zero as zero indicates success.", +); + +impl From for u8 { + fn from(error: ConfigError) -> u8 { + error as _ + } +} + +pub fn get( + config: &mut C, + key: &str, + response: &mut VecView, +) -> Result<(), ConfigError> { + let field = config.field(key).ok_or(ConfigError::InvalidKey)?; + write!(response, "{}", field).map_err(|_| ConfigError::DataTooLong) +} + +pub fn set(config: &mut C, key: &str, value: &str) -> Result<(), ConfigError> { + config + .field(key) + .ok_or(ConfigError::InvalidKey)? + .set(value)?; + Ok(()) +} + +pub fn load(store: &mut F) -> Result { + let Some(data) = load_if_exists(store, LOCATION, FILENAME)? else { + return Ok(Default::default()); + }; + cbor_deserialize(&data).map_err(|_| ConfigError::DeserializationFailed) +} + +pub fn save_filestore( + store: &mut F, + config: &C, +) -> Result<(), ConfigError> { + if config == &C::default() { + if store.exists(FILENAME, LOCATION) { + store + .remove_file(FILENAME, LOCATION) + .map_err(|_| ConfigError::WriteFailed)?; + } + } else { + let mut data = Message::new(); + cbor_serialize_to(config, &mut data).map_err(|_| ConfigError::SerializationFailed)?; + store + .write(FILENAME, LOCATION, &data) + .map_err(|_| ConfigError::SerializationFailed)?; + } + Ok(()) +} + +pub fn save(client: &mut T, config: &C) -> Result<(), ConfigError> { + if config == &Default::default() { + if exists(client, LOCATION, FILENAME)? { + try_syscall!(client.remove_file(LOCATION, FILENAME.into())) + .map_err(|_| ConfigError::WriteFailed)?; + } + } else { + let mut data = Message::new(); + cbor_serialize_to(config, &mut data).map_err(|_| ConfigError::SerializationFailed)?; + try_syscall!(client.write_file(LOCATION, FILENAME.into(), data, None)) + .map_err(|_| ConfigError::WriteFailed)?; + } + Ok(()) +} + +fn exists( + client: &mut T, + location: Location, + path: &Path, +) -> Result { + try_syscall!(client.entry_metadata(location, path.into())) + .map(|r| r.metadata.is_some()) + .map_err(|_| ConfigError::ReadFailed) +} + +fn load_if_exists( + store: &mut F, + location: Location, + path: &Path, +) -> Result, ConfigError> { + store.read(path, location).map(Some).or_else(|_| { + if store.exists(path, location) { + Err(ConfigError::ReadFailed) + } else { + Ok(None) + } + }) +} + +#[cfg(test)] +mod tests { + use hex_literal::hex; + + use super::*; + + #[test] + fn config_field() { + let fields = &[ConfigField { + name: "test_name", + requires_touch_confirmation: true, + requires_reboot: false, + destructive: true, + ty: FieldType::Bool, + }]; + let mut bytes: heapless::Vec = Default::default(); + cbor_smol::cbor_serialize_to(fields, &mut bytes).unwrap(); + assert_eq!( + &bytes, + &hex!("81A5616E69746573745F6E616D656163F56172F46164F5617400") + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index ec8a355..04d897e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,9 +6,46 @@ //! It directly implements the APDU and CTAPHID dispatch App interfaces. #![no_std] +use trussed_core::{CryptoClient, FilesystemClient, ManagementClient, UiClient}; + #[macro_use] extern crate delog; generate_macros!(); mod admin; -pub use admin::{App, Reboot}; +mod config; +pub mod migrations; + +pub use admin::{App, Reboot, StatusBytes}; +pub use config::{ + Config, ConfigError, ConfigField, ConfigValueMut, FieldType, ResetConfigResult, ResetSignal, + ResetSignalAllocation, +}; +use trussed_manage::ManageClient; +#[cfg(feature = "se050")] +use trussed_se050_manage::Se050ManageClient; + +#[cfg(not(feature = "se050"))] +pub trait Client: + CryptoClient + FilesystemClient + ManagementClient + UiClient + ManageClient +{ +} +#[cfg(not(feature = "se050"))] +impl Client for C {} + +#[cfg(feature = "se050")] +pub trait Client: + CryptoClient + FilesystemClient + ManagementClient + UiClient + Se050ManageClient + ManageClient +{ +} +#[cfg(feature = "se050")] +impl< + C: CryptoClient + + FilesystemClient + + ManagementClient + + UiClient + + Se050ManageClient + + ManageClient, + > Client for C +{ +} diff --git a/src/migrations.rs b/src/migrations.rs new file mode 100644 index 0000000..3dc03da --- /dev/null +++ b/src/migrations.rs @@ -0,0 +1,146 @@ +use littlefs2_core::DynFilesystem; + +#[derive(Debug)] +pub struct Migrator { + /// The function performing the migration + /// + /// First argument is the Internal Filesystem, second argument is the External + pub migrate: fn(&dyn DynFilesystem, &dyn DynFilesystem) -> Result<(), littlefs2_core::Error>, + + /// The version of the storage for which the migration needs to be run + pub version: u32, +} + +#[cfg(feature = "migration-tests")] +pub mod test_utils { + + // Copyright (C) Nitrokey GmbH + // SPDX-License-Identifier: Apache-2.0 or MIT + + use littlefs2::{ + fs::Filesystem, io::Error, object_safe::DynFilesystem, path, path::Path, ram_storage, + }; + + /// Represent a directory of data + pub enum FsValues { + Dir(&'static [(&'static Path, FsValues)]), + File(usize), + } + + type Result = core::result::Result; + + /// Prepare the filesystem for a given tests values + pub fn prepare_fs(fs: &dyn DynFilesystem, value: &FsValues, path: &Path) { + match value { + FsValues::File(f_data_len) => { + fs.create_file_and_then(path, &mut |f| { + f.set_len(*f_data_len).unwrap(); + Ok(()) + }) + .unwrap(); + } + FsValues::Dir(d) => { + if path != path!("/") { + fs.create_dir(path).unwrap(); + } + for (p, v) in *d { + prepare_fs(fs, v, &path.join(p)); + } + } + } + } + + /// Test equality between the filesystem and the expected values + pub fn test_fs_equality(fs: &dyn DynFilesystem, value: &FsValues, path: &Path) { + match value { + FsValues::Dir(d) => { + let mut expected_iter = d.iter(); + fs.read_dir_and_then(path, &mut |dir| { + // skip . and .. + dir.next().unwrap().unwrap(); + dir.next().unwrap().unwrap(); + for (expected_path, expected_values) in expected_iter.by_ref() { + let entry = dir.next().unwrap().unwrap(); + assert_eq!(entry.file_name(), *expected_path); + test_fs_equality(fs, expected_values, &path.join(expected_path)); + } + assert!(dir.next().is_none()); + Ok(()) + }) + .unwrap(); + } + FsValues::File(f_data_len) => { + fs.open_file_and_then(path, &mut |f| { + let mut buf = [0; 512]; + let data = f.read(&mut buf).unwrap(); + assert_eq!(data, *f_data_len); + Ok(()) + }) + .unwrap(); + } + } + } + + ram_storage!( + name = NoBackendStorage, + backend = RamDirect, + erase_value = 0xff, + read_size = 16, + write_size = 16, + cache_size_ty = littlefs2::consts::U512, + block_size = 512, + block_count = 128, + lookahead_size_ty = littlefs2::consts::U8, + filename_max_plus_one_ty = littlefs2::consts::U256, + path_max_plus_one_ty = littlefs2::consts::U256, + ); + + pub fn test_migration_one( + before: &FsValues, + after: &FsValues, + migrate: impl Fn(&dyn DynFilesystem) -> Result<(), Error>, + ) { + test_migration( + before, + after, + &FsValues::Dir(&[]), + &FsValues::Dir(&[]), + |ifs, _efs| migrate(ifs), + ); + } + + pub fn test_migration( + before_ifs: &FsValues, + after_ifs: &FsValues, + before_efs: &FsValues, + after_efs: &FsValues, + migrate: impl Fn(&dyn DynFilesystem, &dyn DynFilesystem) -> Result<(), Error>, + ) { + let mut storage_ifs = RamDirect::default(); + let mut storage_efs = RamDirect::default(); + + let backend_efs = &mut NoBackendStorage::new(&mut storage_efs); + let backend_ifs = &mut NoBackendStorage::new(&mut storage_ifs); + + Filesystem::format(backend_ifs).unwrap(); + Filesystem::format(backend_efs).unwrap(); + + Filesystem::mount_and_then(backend_ifs, |ifs| { + Filesystem::mount_and_then(backend_efs, |efs| { + prepare_fs(ifs, before_ifs, path!("/")); + prepare_fs(efs, before_efs, path!("/")); + + test_fs_equality(ifs, before_ifs, path!("/")); + test_fs_equality(efs, before_efs, path!("/")); + + migrate(ifs, efs).unwrap(); + test_fs_equality(efs, after_efs, path!("/")); + test_fs_equality(ifs, after_ifs, path!("/")); + Ok(()) + }) + .unwrap(); + Ok(()) + }) + .unwrap(); + } +}