diff --git a/Cargo.toml b/Cargo.toml index 96b6516..c51b1e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,14 +11,16 @@ description = "FIDO authenticator Trussed app" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -ctap-types = "0.1.0" +ctap-types = { version = "0.1.0", features = ["large-blobs"] } delog = "0.1.0" heapless = "0.7" interchange = "0.2.0" serde = { version = "1.0", default-features = false } serde_cbor = { version = "0.11.0", default-features = false } serde-indexed = "0.1.0" +sha2 = { version = "0.10", default-features = false } trussed = "0.1" +trussed-staging = { version = "0.1.0", default-features = false, optional = true } apdu-dispatch = { version = "0.1", optional = true } ctaphid-dispatch = { version = "0.1", optional = true } @@ -29,6 +31,9 @@ dispatch = ["apdu-dispatch", "ctaphid-dispatch", "iso7816"] disable-reset-time-window = [] enable-fido-pre = [] +# enables support for a large-blob array longer than 1024 bytes +chunked = ["trussed-staging/chunked"] + log-all = [] log-none = [] log-info = [] @@ -45,8 +50,9 @@ trussed = { version = "0.1", features = ["virt"] } features = ["dispatch"] [patch.crates-io] -ctap-types = { git = "https://github.com/trussed-dev/ctap-types.git", rev = "785bcc52720ce2e2054ae32034a2a24c500e1043" } +ctap-types = { git = "https://github.com/Nitrokey/ctap-types.git", branch = "msg-size" } ctaphid-dispatch = { git = "https://github.com/trussed-dev/ctaphid-dispatch.git", rev = "57cb3317878a8593847595319aa03ef17c29ec5b" } apdu-dispatch = { git = "https://github.com/trussed-dev/apdu-dispatch.git", rev = "915fc237103fcecc29d0f0b73391f19abf6576de" } -trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "51e68500d7601d04f884f5e95567d14b9018a6cb" } +trussed = { git = "https://github.com/trussed-dev/trussed.git", rev = "b1781805a2e33615d2d00b8bec80c0b1f5870ca1" } +trussed-staging = { git = "https://github.com/trussed-dev/trussed-staging", rev = "3b9594d93f89a5e760fe78fa5a96f125dfdcd470" } serde-indexed = { git = "https://github.com/sosthene-nitrokey/serde-indexed.git", rev = "5005d23cb4ee8622e62188ea0f9466146f851f0d" } diff --git a/src/ctap2.rs b/src/ctap2.rs index 7713b2d..c8579a5 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -6,6 +6,7 @@ use ctap_types::{ heapless_bytes::Bytes, sizes, Error, }; +use sha2::{Digest as _, Sha256}; use trussed::{ syscall, try_syscall, @@ -1799,24 +1800,24 @@ impl crate::Authenticator { || request.pin_uv_auth_param.is_some() || request.pin_uv_auth_protocol.is_some() { + error!("length/pin set"); return Err(Error::InvalidParameter); } // 3. Validate length let Ok(length) = usize::try_from(length) else { return Err(Error::InvalidLength); }; - // TODO: *Actually*, the max size would be LARGE_BLOB_MAX_FRAGMENT_LENGTH, but as the - // maximum size for the large-blob array is currently 1024, the difference does not matter - // -- the table will always fit in one fragment. if length > self.config.max_msg_size.saturating_sub(64) { return Err(Error::InvalidLength); } // 4. Validate offset let Ok(offset) = usize::try_from(request.offset) else { + error!("offset too large"); return Err(Error::InvalidParameter); }; let stored_length = large_blobs::size(&mut self.trussed, config.location)?; if offset > stored_length { + error!("offset: {offset}, stored_length: {stored_length}"); return Err(Error::InvalidParameter); }; // 5. Return requested data @@ -1838,7 +1839,7 @@ impl crate::Authenticator { request.length ); // 1. Validate data - if data.len() > sizes::LARGE_BLOB_MAX_FRAGMENT_LENGTH { + if data.len() > self.config.max_msg_size.saturating_sub(64) { return Err(Error::InvalidLength); } if request.offset == 0 { @@ -1851,7 +1852,7 @@ impl crate::Authenticator { let Ok(length) = usize::try_from(length) else { return Err(Error::LargeBlobStorageFull); }; - if length > config.max_size { + if length > config.max_size() { return Err(Error::LargeBlobStorageFull); } // 2.3. Check that length is not too small @@ -1905,10 +1906,7 @@ impl crate::Authenticator { .extend_from_slice(&request.offset.to_le_bytes()) .unwrap(); // SHA-256(data) - let mut hash_input = Message::new(); - hash_input.extend_from_slice(&data).unwrap(); - let hash = syscall!(self.trussed.hash(Mechanism::Sha256, hash_input)).hash; - auth_data.extend_from_slice(&hash).unwrap(); + auth_data.extend_from_slice(&Sha256::digest(&data)).unwrap(); self.verify_pin(&pin_auth, &auth_data)?; } diff --git a/src/ctap2/large_blobs.rs b/src/ctap2/large_blobs.rs index 5047f71..094a06e 100644 --- a/src/ctap2/large_blobs.rs +++ b/src/ctap2/large_blobs.rs @@ -1,11 +1,12 @@ use ctap_types::{sizes::LARGE_BLOB_MAX_FRAGMENT_LENGTH, Error}; use trussed::{ client::Client, + config::MAX_MESSAGE_LENGTH, syscall, try_syscall, types::{Bytes, Location, Mechanism, Message, PathBuf}, }; -use crate::Result; +use crate::{Result, TrussedRequirements}; const HASH_SIZE: usize = 16; pub const MIN_SIZE: usize = HASH_SIZE + 1; @@ -25,12 +26,29 @@ pub struct Config { pub location: Location, /// The maximum size for the large-blob array including metadata. /// - /// This value must be at least 1024 according to the CTAP2.1 spec. Currently, it must not be - /// more than 1024 because the large-blob array must fit into a Trussed message. + /// This value must be at least 1024 according to the CTAP2.1 spec. Without the chunking + /// extension, it cannot be larger than 1024 because the large-blob array must fit into a + /// Trussed message. Therefore, this setting is only available if the chunked feature is + /// enabled. + #[cfg(feature = "chunked")] pub max_size: usize, } -pub fn size(client: &mut C, location: Location) -> Result { +impl Config { + pub fn max_size(&self) -> usize { + #[cfg(feature = "chunked")] + { + self.max_size + } + + #[cfg(not(feature = "chunked"))] + { + MAX_MESSAGE_LENGTH + } + } +} + +pub fn size(client: &mut C, location: Location) -> Result { Ok( try_syscall!(client.entry_metadata(location, PathBuf::from(FILENAME))) .map_err(|_| Error::Other)? @@ -39,11 +57,11 @@ pub fn size(client: &mut C, location: Location) -> Result { .unwrap_or_default() // If the data is shorter than MIN_SIZE, it is missing or corrupted and we fall back to // an empty array which has exactly MIN_SIZE - .min(MIN_SIZE), + .max(MIN_SIZE), ) } -pub fn read_chunk( +pub fn read_chunk( client: &mut C, location: Location, offset: usize, @@ -52,7 +70,7 @@ pub fn read_chunk( SelectedStorage::read(client, location, offset, length) } -pub fn write_chunk( +pub fn write_chunk( client: &mut C, state: &mut State, location: Location, @@ -61,7 +79,7 @@ pub fn write_chunk( write_impl::<_, SelectedStorage>(client, state, location, data) } -pub fn reset(client: &mut C) { +pub fn reset(client: &mut C) { for location in [Location::Internal, Location::External, Location::Volatile] { try_syscall!(client.remove_file(location, PathBuf::from(FILENAME))).ok(); } @@ -79,12 +97,18 @@ fn write_impl>( return Err(Error::InvalidParameter); } - let mut writer = S::start_write(client, state.expected_next_offset, state.expected_length)?; + let mut writer = S::start_write( + client, + location, + state.expected_next_offset, + state.expected_length, + )?; state.expected_next_offset = writer.extend_buffer(client, data)?; if state.expected_next_offset == state.expected_length { - if writer.validate_checksum(client) { - writer.commit(client, location) + if writer.validate_checksum(client)? { + writer.commit(client) } else { + writer.abort(client)?; Err(Error::IntegrityFailure) } } else { @@ -92,7 +116,7 @@ fn write_impl>( } } -#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)] +#[derive(Clone, Debug, Default)] pub struct State { pub expected_length: usize, pub expected_next_offset: usize, @@ -101,20 +125,34 @@ pub struct State { trait Storage: Sized { fn read(client: &mut C, location: Location, offset: usize, length: usize) -> Result; - fn start_write(client: &mut C, offset: usize, expected_length: usize) -> Result; + fn start_write( + client: &mut C, + location: Location, + offset: usize, + expected_length: usize, + ) -> Result; fn extend_buffer(&mut self, client: &mut C, data: &[u8]) -> Result; - fn validate_checksum(&mut self, client: &mut C) -> bool; + fn validate_checksum(&mut self, client: &mut C) -> Result; - fn commit(&mut self, client: &mut C, location: Location) -> Result<()>; + fn commit(&mut self, client: &mut C) -> Result<()>; + + fn abort(&mut self, client: &mut C) -> Result<()> { + let _ = client; + Ok(()) + } } +#[cfg(not(feature = "chunked"))] type SelectedStorage = SimpleStorage; +#[cfg(feature = "chunked")] +type SelectedStorage = ChunkedStorage; // Basic implementation using a file in the volatile storage as a buffer based on the core Trussed // API. Maximum size for the entire large blob array: 1024 bytes. struct SimpleStorage { + location: Location, buffer: Message, } @@ -135,7 +173,12 @@ impl Storage for SimpleStorage { Ok(buffer) } - fn start_write(client: &mut C, offset: usize, expected_length: usize) -> Result { + fn start_write( + client: &mut C, + location: Location, + offset: usize, + expected_length: usize, + ) -> Result { let buffer = if offset == 0 { Message::new() } else { @@ -152,7 +195,7 @@ impl Storage for SimpleStorage { return Err(Error::Other); } - Ok(Self { buffer }) + Ok(Self { buffer, location }) } fn extend_buffer(&mut self, client: &mut C, data: &[u8]) -> Result { @@ -169,19 +212,19 @@ impl Storage for SimpleStorage { Ok(self.buffer.len()) } - fn validate_checksum(&mut self, client: &mut C) -> bool { + fn validate_checksum(&mut self, client: &mut C) -> Result { let Some(n) = self.buffer.len().checked_sub(HASH_SIZE) else { - return false; + return Ok(false); }; let mut message = Message::new(); message.extend_from_slice(&self.buffer[..n]).unwrap(); let checksum = syscall!(client.hash(Mechanism::Sha256, message)).hash; - checksum[..HASH_SIZE] == self.buffer[n..] + Ok(checksum[..HASH_SIZE] == self.buffer[n..]) } - fn commit(&mut self, client: &mut C, location: Location) -> Result<()> { + fn commit(&mut self, client: &mut C) -> Result<()> { try_syscall!(client.write_file( - location, + self.location, PathBuf::from(FILENAME), self.buffer.clone(), None @@ -191,3 +234,165 @@ impl Storage for SimpleStorage { Ok(()) } } + +#[cfg(feature = "chunked")] +struct ChunkedStorage { + location: Location, + expected_length: usize, + create_file: bool, +} + +#[cfg(feature = "chunked")] +impl Storage for ChunkedStorage { + fn read(client: &mut C, location: Location, offset: usize, length: usize) -> Result { + debug!("ChunkedStorage::read: offset = {offset}, length = {length}"); + let mut chunk = Chunk::new(); + let file_size = try_syscall!(client.entry_metadata(location, PathBuf::from(FILENAME))) + .map_err(|_| Error::Other)? + .metadata + .map(|metadata| metadata.len()) + .unwrap_or_default(); + if file_size < MIN_SIZE { + // The stored file is missing or too short, so we fall back to an empty array. + trace!("Sending empty array instead of missing or corrupted file"); + let start = offset.min(MIN_SIZE); + let end = (offset + length).min(MIN_SIZE); + chunk.extend_from_slice(&EMPTY_ARRAY[start..end]).unwrap(); + return Ok(chunk); + } + + while offset + chunk.len() < offset + length { + let n = MAX_MESSAGE_LENGTH.min(length - chunk.len()); + let reply = try_syscall!(client.partial_read_file( + location, + PathBuf::from(FILENAME), + offset + chunk.len(), + n + )) + .map_err(|_| Error::Other)?; + chunk + .extend_from_slice(&reply.data) + .map_err(|_| Error::Other)?; + if offset + chunk.len() >= reply.file_length { + break; + } + } + + trace!("Read chunk with {} bytes", chunk.len()); + Ok(chunk) + } + + fn start_write( + _client: &mut C, + location: Location, + offset: usize, + expected_length: usize, + ) -> Result { + debug!( + "ChunkedStorage::start_write: offset = {offset}, expected_length = {expected_length}" + ); + let create_file = offset == 0; + Ok(ChunkedStorage { + location, + create_file, + expected_length, + }) + } + + fn extend_buffer(&mut self, client: &mut C, data: &[u8]) -> Result { + debug!("ChunkedStorage::extend_buffer: |data| = {}", data.len()); + let mut n = 0; + for chunk in data.chunks(trussed::config::MAX_MESSAGE_LENGTH) { + trace!("Writing {} bytes", chunk.len()); + let path = PathBuf::from(FILENAME_TMP); + let mut message = Message::new(); + message.extend_from_slice(chunk).unwrap(); + if self.create_file { + try_syscall!(client.write_file(self.location, path, message, None)).map_err( + |_err| { + error!("failed to write initial chunk: {_err:?}"); + Error::Other + }, + )?; + self.create_file = false; + n = data.len(); + } else { + n = try_syscall!(client.append_file(self.location, path, message)) + .map(|reply| reply.file_length) + .map_err(|_err| { + error!("failed to append chunk: {_err:?}"); + Error::Other + })?; + } + } + Ok(n) + } + + fn validate_checksum(&mut self, client: &mut C) -> Result { + use sha2::{digest::Digest as _, Sha256}; + + debug!("ChunkedStorage::validate_checksum"); + + let mut digest = Sha256::new(); + let mut received_hash: Bytes = Bytes::new(); + let mut bytes_read = 0; + + let (mut chunk, mut len) = + try_syscall!(client.start_chunked_read(self.location, PathBuf::from(FILENAME_TMP))) + .map(|reply| (reply.data, reply.len)) + .map_err(|_err| { + error!("Failed to read file: {:?}", _err); + Error::Other + })?; + loop { + trace!("read chunk: {}", chunk.len()); + + let remaining_data = self + .expected_length + .saturating_sub(bytes_read) + .saturating_sub(HASH_SIZE); + let data_end = remaining_data.min(chunk.len()); + digest.update(&chunk[..data_end]); + if received_hash + .extend_from_slice(&chunk[data_end..chunk.len()]) + .is_err() + { + return Ok(false); + } + + bytes_read += chunk.len(); + if bytes_read >= len { + break; + } + + (chunk, len) = try_syscall!(client.read_file_chunk()) + .map(|reply| (reply.data, reply.len)) + .map_err(|_err| { + error!("Failed to read chunk: {:?}", _err); + Error::Other + })?; + } + + let actual_hash = digest.finalize(); + Ok(bytes_read == self.expected_length + && received_hash.as_slice() == &actual_hash[..HASH_SIZE]) + } + + fn commit(&mut self, client: &mut C) -> Result<()> { + debug!("ChunkedStorage::commit"); + try_syscall!(client.rename( + self.location, + PathBuf::from(FILENAME_TMP), + PathBuf::from(FILENAME) + )) + .map_err(|_| Error::Other)?; + Ok(()) + } + + fn abort(&mut self, client: &mut C) -> Result<()> { + debug!("ChunkedStorage::abort"); + try_syscall!(client.remove_file(self.location, PathBuf::from(FILENAME_TMP))) + .map_err(|_| Error::Other)?; + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 30cac68..b252c02 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -45,6 +45,8 @@ pub type Result = core::result::Result; /// - Ed25519 and P-256 are the core signature algorithms. /// - AES-256, SHA-256 and its HMAC are used within the CTAP protocols. /// - ChaCha8Poly1305 is our AEAD of choice, used e.g. for the key handles. +/// - Some Trussed extensions might be required depending on the activated features, see +/// [`ExtensionRequirements`][]. pub trait TrussedRequirements: client::Client + client::P256 @@ -53,6 +55,7 @@ pub trait TrussedRequirements: + client::Sha256 + client::HmacSha256 + client::Ed255 // + client::Totp + + ExtensionRequirements { } @@ -64,9 +67,22 @@ impl TrussedRequirements for T where + client::Sha256 + client::HmacSha256 + client::Ed255 // + client::Totp + + ExtensionRequirements { } +#[cfg(not(feature = "chunked"))] +pub trait ExtensionRequirements {} + +#[cfg(not(feature = "chunked"))] +impl ExtensionRequirements for T {} + +#[cfg(feature = "chunked")] +pub trait ExtensionRequirements: trussed_staging::streaming::ChunkedClient {} + +#[cfg(feature = "chunked")] +impl ExtensionRequirements for T where T: trussed_staging::streaming::ChunkedClient {} + #[derive(Copy, Clone, Debug, Eq, PartialEq)] /// Externally defined configuration. pub struct Config { diff --git a/src/state.rs b/src/state.rs index 4b6d5a3..7b524aa 100644 --- a/src/state.rs +++ b/src/state.rs @@ -70,7 +70,7 @@ impl CredentialCacheGeneric { pub type CredentialCache = CredentialCacheGeneric; -#[derive(Clone, Debug, /*uDebug, Eq, PartialEq,*/ serde::Deserialize, serde::Serialize)] +#[derive(Clone, Debug)] pub struct State { /// Batch device identity (aaguid, certificate, key). pub identity: Identity, @@ -218,9 +218,7 @@ pub struct ActiveGetAssertionData { pub extensions: Option, } -#[derive( - Clone, Debug, /*uDebug,*/ Default, /*PartialEq,*/ serde::Deserialize, serde::Serialize, -)] +#[derive(Clone, Debug, Default)] pub struct RuntimeState { key_agreement_key: Option, pin_token: Option,