diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a5649d..4a00803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Allow three instead of two PIN retries per boot ([#35][]) - Reduce ID length for new credentials ([#37][]) - Update apdu-dispatch and reject calls to `select` ([#40][]) +- Implement the `largeBlobKey` extension and the `largeBlobs` command ([#38][]) [#26]: https://github.com/solokeys/fido-authenticator/issues/26 [#28]: https://github.com/solokeys/fido-authenticator/issues/28 @@ -23,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [#35]: https://github.com/solokeys/fido-authenticator/issues/35 [#37]: https://github.com/solokeys/fido-authenticator/issues/37 [#40]: https://github.com/nitrokey/fido-authenticator/pull/40 +[#38]: https://github.com/Nitrokey/fido-authenticator/issues/38 ## [0.1.1] - 2022-08-22 - Fix bug that treated U2F payloads as APDU over APDU in NFC transport @conorpp diff --git a/Cargo.toml b/Cargo.toml index 9e82879..96b6516 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,7 @@ trussed = { version = "0.1", features = ["virt"] } features = ["dispatch"] [patch.crates-io] -ctap-types = { git = "https://github.com/nitrokey/ctap-types.git", tag = "v0.1.2-nitrokey.4" } +ctap-types = { git = "https://github.com/trussed-dev/ctap-types.git", rev = "785bcc52720ce2e2054ae32034a2a24c500e1043" } 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" } diff --git a/src/credential.rs b/src/credential.rs index 32cd94a..9b17cdd 100644 --- a/src/credential.rs +++ b/src/credential.rs @@ -226,6 +226,8 @@ pub struct CredentialData { pub hmac_secret: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cred_protect: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub large_blob_key: Option>, // TODO: add `sig_counter: Option`, // and grant RKs a per-credential sig-counter. @@ -327,6 +329,7 @@ impl FullCredential { timestamp: u32, hmac_secret: Option, cred_protect: Option, + large_blob_key: Option>, nonce: [u8; 12], ) -> Self { info!("credential for algorithm {}", algorithm); @@ -341,6 +344,7 @@ impl FullCredential { hmac_secret, cred_protect, + large_blob_key, use_short_id: Some(true), }; @@ -446,6 +450,9 @@ pub struct StrippedCredential { pub hmac_secret: Option, #[serde(skip_serializing_if = "Option::is_none")] pub cred_protect: Option, + // TODO: HACK -- remove + #[serde(skip_serializing_if = "Option::is_none")] + pub large_blob_key: Option>, } impl StrippedCredential { @@ -480,6 +487,7 @@ impl From<&FullCredential> for StrippedCredential { nonce: credential.nonce.clone(), hmac_secret: credential.data.hmac_secret, cred_protect: credential.data.cred_protect, + large_blob_key: credential.data.large_blob_key.clone(), } } } diff --git a/src/ctap1.rs b/src/ctap1.rs index f3df339..4e5623e 100644 --- a/src/ctap1.rs +++ b/src/ctap1.rs @@ -90,6 +90,7 @@ impl Authenticator for crate::Authenti nonce, hmac_secret: None, cred_protect: None, + large_blob_key: None, }; // info!("made credential {:?}", &credential); diff --git a/src/ctap2.rs b/src/ctap2.rs index 0967846..7713b2d 100644 --- a/src/ctap2.rs +++ b/src/ctap2.rs @@ -31,6 +31,7 @@ use crate::{ use crate::msp; pub mod credential_management; +pub mod large_blobs; // pub mod pin; /// Implement `ctap2::Authenticator` for our Authenticator. @@ -50,7 +51,7 @@ impl Authenticator for crate::Authenti .push(String::from_str("FIDO_2_1_PRE").unwrap()) .unwrap(); - let mut extensions = Vec::, 4>::new(); + let mut extensions = Vec::, 4>::new(); // extensions.push(String::from_str("credProtect").unwrap()).unwrap(); extensions .push(String::from_str("credProtect").unwrap()) @@ -58,6 +59,11 @@ impl Authenticator for crate::Authenti extensions .push(String::from_str("hmac-secret").unwrap()) .unwrap(); + if self.config.supports_large_blobs() { + extensions + .push(String::from_str("largeBlobKey").unwrap()) + .unwrap(); + } let mut pin_protocols = Vec::::new(); pin_protocols.push(1).unwrap(); @@ -74,6 +80,7 @@ impl Authenticator for crate::Authenti false => Some(false), }, credential_mgmt_preview: Some(true), + large_blobs: Some(self.config.supports_large_blobs()), ..Default::default() }; // options.rk = true; @@ -228,6 +235,7 @@ impl Authenticator for crate::Authenti let mut hmac_secret_requested = None; // let mut cred_protect_requested = CredentialProtectionPolicy::Optional; let mut cred_protect_requested = None; + let mut large_blob_key_requested = false; if let Some(extensions) = ¶meters.extensions { hmac_secret_requested = extensions.hmac_secret; @@ -235,6 +243,21 @@ impl Authenticator for crate::Authenti cred_protect_requested = Some(credential::CredentialProtectionPolicy::try_from(*policy)?); } + + if self.config.supports_large_blobs() { + if let Some(large_blob_key) = extensions.large_blob_key { + if large_blob_key { + if !rk_requested { + // the largeBlobKey extension is only available for resident keys + return Err(Error::InvalidOption); + } + large_blob_key_requested = true; + } else { + // large_blob_key must be Some(true) or omitted, Some(false) is invalid + return Err(Error::InvalidOption); + } + } + } } // debug_now!("hmac-secret = {:?}, credProtect = {:?}", hmac_secret_requested, cred_protect_requested); @@ -337,6 +360,12 @@ impl Authenticator for crate::Authenti // store it. // TODO: overwrite, error handling with KeyStoreFull + let large_blob_key = if large_blob_key_requested { + Some(Bytes::from_slice(&syscall!(self.trussed.random_bytes(32)).bytes).unwrap()) + } else { + None + }; + let credential = FullCredential::new( credential::CtapVersion::Fido21Pre, ¶meters.rp, @@ -346,6 +375,7 @@ impl Authenticator for crate::Authenti self.state.persistent.timestamp(&mut self.trussed)?, hmac_secret_requested, cred_protect_requested, + large_blob_key.clone(), nonce, ); @@ -444,6 +474,7 @@ impl Authenticator for crate::Authenti Some(ctap2::make_credential::Extensions { cred_protect: parameters.extensions.as_ref().unwrap().cred_protect, hmac_secret: parameters.extensions.as_ref().unwrap().hmac_secret, + large_blob_key: None, }) } else { None @@ -551,6 +582,8 @@ impl Authenticator for crate::Authenti fmt, auth_data: serialized_auth_data, att_stmt, + ep_att: None, + large_blob_key, }; Ok(attestation_object) @@ -577,6 +610,9 @@ impl Authenticator for crate::Authenti .trussed .remove_dir_all(Location::Internal, PathBuf::from("rk"),)); + // Delete large-blob array + large_blobs::reset(&mut self.trussed); + // b. delete persistent state self.state.persistent.reset(&mut self.trussed)?; @@ -991,6 +1027,27 @@ impl Authenticator for crate::Authenti self.assert_with_credential(num_credentials, credential) } + + #[inline(never)] + fn large_blobs( + &mut self, + request: &ctap2::large_blobs::Request, + ) -> Result { + let Some(config) = self.config.large_blobs else { + return Err(Error::InvalidCommand); + }; + + // 1. offset is validated by serde + + // 2.-3. Exactly one of get or set must be present + match (request.get, request.set) { + (None, None) | (Some(_), Some(_)) => Err(Error::InvalidParameter), + // 4. Implement get subcommand + (Some(get), None) => self.large_blobs_get(request, config, get), + // 5. Implement set subcommand + (None, Some(set)) => self.large_blobs_set(request, config, set), + } + } } // impl Authenticator for crate::Authenticator @@ -1503,7 +1560,15 @@ impl crate::Authenticator { }; // 8. process any extensions present + let mut large_blob_key_requested = false; let extensions_output = if let Some(extensions) = &data.extensions { + if self.config.supports_large_blobs() { + if extensions.large_blob_key == Some(false) { + // large_blob_key must be Some(true) or omitted + return Err(Error::InvalidOption); + } + large_blob_key_requested = extensions.large_blob_key == Some(true); + } self.process_assertion_extensions(&data, extensions, &credential, key)? } else { None @@ -1526,7 +1591,7 @@ impl crate::Authenticator { rp_id_hash, flags: { - let mut flags = Flags::EMPTY; + let mut flags = Flags::empty(); if data.up_performed { flags |= Flags::USER_PRESENCE; } @@ -1581,11 +1646,13 @@ impl crate::Authenticator { signature, user: None, number_of_credentials: num_credentials, + user_selected: None, + large_blob_key: None, }; // User with empty IDs are ignored for compatibility if is_rk { - if let Credential::Full(credential) = credential { + if let Credential::Full(credential) = &credential { if !credential.user.id.is_empty() { let mut user = credential.user.clone(); // User identifiable information (name, DisplayName, icon) MUST not @@ -1599,6 +1666,14 @@ impl crate::Authenticator { response.user = Some(user); } } + + if large_blob_key_requested { + debug!("Sending largeBlobKey in getAssertion"); + response.large_blob_key = match credential { + Credential::Stripped(stripped) => stripped.large_blob_key, + Credential::Full(full) => full.data.large_blob_key, + }; + } } Ok(response) @@ -1708,6 +1783,152 @@ impl crate::Authenticator { ); } } + + fn large_blobs_get( + &mut self, + request: &ctap2::large_blobs::Request, + config: large_blobs::Config, + length: u32, + ) -> Result { + debug!( + "large_blobs_get: length = {length}, offset = {}", + request.offset + ); + // 1.-2. Validate parameters + if request.length.is_some() + || request.pin_uv_auth_param.is_some() + || request.pin_uv_auth_protocol.is_some() + { + 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 { + return Err(Error::InvalidParameter); + }; + let stored_length = large_blobs::size(&mut self.trussed, config.location)?; + if offset > stored_length { + return Err(Error::InvalidParameter); + }; + // 5. Return requested data + info!("Reading large-blob array from offset {offset}"); + large_blobs::read_chunk(&mut self.trussed, config.location, offset, length) + .map(|data| ctap2::large_blobs::Response { config: Some(data) }) + } + + fn large_blobs_set( + &mut self, + request: &ctap2::large_blobs::Request, + config: large_blobs::Config, + data: &[u8], + ) -> Result { + debug!( + "large_blobs_set: |data| = {}, offset = {}, length = {:?}", + data.len(), + request.offset, + request.length + ); + // 1. Validate data + if data.len() > sizes::LARGE_BLOB_MAX_FRAGMENT_LENGTH { + return Err(Error::InvalidLength); + } + if request.offset == 0 { + // 2. Calculate expected length and offset + // 2.1. Require length + let Some(length) = request.length else { + return Err(Error::InvalidParameter); + }; + // 2.2. Check that length is not too big + let Ok(length) = usize::try_from(length) else { + return Err(Error::LargeBlobStorageFull); + }; + if length > config.max_size { + return Err(Error::LargeBlobStorageFull); + } + // 2.3. Check that length is not too small + if length < large_blobs::MIN_SIZE { + return Err(Error::InvalidParameter); + } + // 2.4-5. Set expected length and offset + self.state.runtime.large_blobs.expected_length = length; + self.state.runtime.large_blobs.expected_next_offset = 0; + } else { + // 3. Validate parameters + if request.length.is_some() { + return Err(Error::InvalidParameter); + } + } + + // 4. Validate offset + let Ok(offset) = usize::try_from(request.offset) else { + return Err(Error::InvalidSeq); + }; + if offset != self.state.runtime.large_blobs.expected_next_offset { + return Err(Error::InvalidSeq); + } + + // 5. Perform uv + // TODO: support alwaysUv + if self.state.persistent.pin_is_set() { + let Some(pin_uv_auth_param) = request.pin_uv_auth_param else { + return Err(Error::PinRequired); + }; + let Some(pin_uv_auth_protocol) = request.pin_uv_auth_protocol else { + return Err(Error::PinRequired); + }; + if pin_uv_auth_protocol != 1 { + return Err(Error::PinAuthInvalid); + } + // TODO: check pinUvAuthToken + let pin_auth: [u8; 16] = pin_uv_auth_param + .as_ref() + .try_into() + .map_err(|_| Error::PinAuthInvalid)?; + + let mut auth_data: Bytes<70> = Bytes::new(); + // 32x 0xff + auth_data.resize(32, 0xff).unwrap(); + // h'0c00' + auth_data.push(0x0c).unwrap(); + auth_data.push(0x00).unwrap(); + // uint32LittleEndian(offset) + auth_data + .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(); + + self.verify_pin(&pin_auth, &auth_data)?; + } + + // 6. Validate data length + if offset + data.len() > self.state.runtime.large_blobs.expected_length { + return Err(Error::InvalidParameter); + } + + // 7.-11. Write the buffer + info!("Writing large-blob array to offset {offset}"); + large_blobs::write_chunk( + &mut self.trussed, + &mut self.state.runtime.large_blobs, + config.location, + data, + )?; + + Ok(ctap2::large_blobs::Response::default()) + } } fn rp_rk_dir(rp_id_hash: &Bytes<32>) -> PathBuf { diff --git a/src/ctap2/credential_management.rs b/src/ctap2/credential_management.rs index 627858a..0aba519 100644 --- a/src/ctap2/credential_management.rs +++ b/src/ctap2/credential_management.rs @@ -453,6 +453,7 @@ where credential_id: Some(credential_id.into()), public_key: Some(cose_public_key), cred_protect, + large_blob_key: credential.data.large_blob_key, ..Default::default() }; diff --git a/src/ctap2/large_blobs.rs b/src/ctap2/large_blobs.rs new file mode 100644 index 0000000..5047f71 --- /dev/null +++ b/src/ctap2/large_blobs.rs @@ -0,0 +1,193 @@ +use ctap_types::{sizes::LARGE_BLOB_MAX_FRAGMENT_LENGTH, Error}; +use trussed::{ + client::Client, + syscall, try_syscall, + types::{Bytes, Location, Mechanism, Message, PathBuf}, +}; + +use crate::Result; + +const HASH_SIZE: usize = 16; +pub const MIN_SIZE: usize = HASH_SIZE + 1; +// empty CBOR array (0x80) + hash +const EMPTY_ARRAY: &[u8; MIN_SIZE] = &[ + 0x80, 0x76, 0xbe, 0x8b, 0x52, 0x8d, 0x00, 0x75, 0xf7, 0xaa, 0xe9, 0x8d, 0x6f, 0xa5, 0x7a, 0x6d, + 0x3c, +]; +const FILENAME: &[u8] = b"large-blob-array"; +const FILENAME_TMP: &[u8] = b".large-blob-array"; + +pub type Chunk = Bytes; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub struct Config { + /// The location for storing the large-blob array. + 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. + pub max_size: usize, +} + +pub fn size(client: &mut C, location: Location) -> Result { + Ok( + try_syscall!(client.entry_metadata(location, PathBuf::from(FILENAME))) + .map_err(|_| Error::Other)? + .metadata + .map(|metadata| metadata.len()) + .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), + ) +} + +pub fn read_chunk( + client: &mut C, + location: Location, + offset: usize, + length: usize, +) -> Result { + SelectedStorage::read(client, location, offset, length) +} + +pub fn write_chunk( + client: &mut C, + state: &mut State, + location: Location, + data: &[u8], +) -> Result<()> { + write_impl::<_, SelectedStorage>(client, state, location, data) +} + +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(); + } + try_syscall!(client.remove_file(Location::Volatile, PathBuf::from(FILENAME_TMP))).ok(); +} + +fn write_impl>( + client: &mut C, + state: &mut State, + location: Location, + data: &[u8], +) -> Result<()> { + // sanity checks + if state.expected_next_offset + data.len() > state.expected_length { + return Err(Error::InvalidParameter); + } + + let mut writer = S::start_write(client, 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) + } else { + Err(Error::IntegrityFailure) + } + } else { + Ok(()) + } +} + +#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)] +pub struct State { + pub expected_length: usize, + pub expected_next_offset: usize, +} + +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 extend_buffer(&mut self, client: &mut C, data: &[u8]) -> Result; + + fn validate_checksum(&mut self, client: &mut C) -> bool; + + fn commit(&mut self, client: &mut C, location: Location) -> Result<()>; +} + +type SelectedStorage = SimpleStorage; + +// 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 { + buffer: Message, +} + +impl Storage for SimpleStorage { + fn read(client: &mut C, location: Location, offset: usize, length: usize) -> Result { + let result = try_syscall!(client.read_file(location, PathBuf::from(FILENAME))); + let data = if let Ok(reply) = &result { + reply.data.as_slice() + } else { + EMPTY_ARRAY.as_slice() + }; + let Some(max_length) = data.len().checked_sub(offset) else { + return Err(Error::InvalidParameter); + }; + let length = length.min(max_length); + let mut buffer = Chunk::new(); + buffer.extend_from_slice(&data[offset..][..length]).unwrap(); + Ok(buffer) + } + + fn start_write(client: &mut C, offset: usize, expected_length: usize) -> Result { + let buffer = if offset == 0 { + Message::new() + } else { + try_syscall!(client.read_file(Location::Volatile, PathBuf::from(FILENAME_TMP))) + .map_err(|_| Error::Other)? + .data + }; + + // sanity checks + if expected_length > buffer.capacity() { + return Err(Error::InvalidLength); + } + if buffer.len() != offset { + return Err(Error::Other); + } + + Ok(Self { buffer }) + } + + fn extend_buffer(&mut self, client: &mut C, data: &[u8]) -> Result { + self.buffer + .extend_from_slice(data) + .map_err(|_| Error::InvalidParameter)?; + try_syscall!(client.write_file( + Location::Volatile, + PathBuf::from(FILENAME_TMP), + self.buffer.clone(), + None + )) + .map_err(|_| Error::Other)?; + Ok(self.buffer.len()) + } + + fn validate_checksum(&mut self, client: &mut C) -> bool { + let Some(n) = self.buffer.len().checked_sub(HASH_SIZE) else { + return 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..] + } + + fn commit(&mut self, client: &mut C, location: Location) -> Result<()> { + try_syscall!(client.write_file( + location, + PathBuf::from(FILENAME), + self.buffer.clone(), + None + )) + .map_err(|_| Error::Other)?; + try_syscall!(client.remove_file(Location::Volatile, PathBuf::from(FILENAME_TMP))).ok(); + Ok(()) + } +} diff --git a/src/dispatch.rs b/src/dispatch.rs index 7aaf841..50849e6 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -211,6 +211,7 @@ fn request_operation(request: &ctap2::Request) -> ctap2::Operation { ctap2::Request::Reset => ctap2::Operation::Reset, ctap2::Request::CredentialManagement(_) => ctap2::Operation::CredentialManagement, ctap2::Request::Selection => ctap2::Operation::Selection, + ctap2::Request::LargeBlobs(_) => ctap2::Operation::LargeBlobs, ctap2::Request::Vendor(operation) => ctap2::Operation::Vendor(*operation), } } @@ -226,6 +227,7 @@ fn response_operation(request: &ctap2::Response) -> Option { ctap2::Response::Reset => Some(ctap2::Operation::Reset), ctap2::Response::CredentialManagement(_) => Some(ctap2::Operation::CredentialManagement), ctap2::Response::Selection => Some(ctap2::Operation::Selection), + ctap2::Response::LargeBlobs(_) => Some(ctap2::Operation::LargeBlobs), ctap2::Response::Vendor => None, } } diff --git a/src/lib.rs b/src/lib.rs index 5cd444a..30cac68 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,8 @@ pub mod constants; pub mod credential; pub mod state; +pub use ctap2::large_blobs::Config as LargeBlobsConfig; + /// Results with our [`Error`]. pub type Result = core::result::Result; @@ -78,6 +80,16 @@ pub struct Config { pub skip_up_timeout: Option, /// The maximum number of resident credentials. pub max_resident_credential_count: Option, + /// Configuration for the largeBlobKey extension and the largeBlobs command. + /// + /// If this is `None`, the extension and the command are disabled. + pub large_blobs: Option, +} + +impl Config { + pub fn supports_large_blobs(&self) -> bool { + self.large_blobs.is_some() + } } // impl Default for Config { diff --git a/src/state.rs b/src/state.rs index 0e7e43c..4b6d5a3 100644 --- a/src/state.rs +++ b/src/state.rs @@ -18,7 +18,7 @@ use trussed::{ use heapless::binary_heap::{BinaryHeap, Max}; -use crate::{cbor_serialize_message, credential::FullCredential, Result}; +use crate::{cbor_serialize_message, credential::FullCredential, ctap2, Result}; #[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] pub struct CachedCredential { @@ -234,6 +234,9 @@ pub struct RuntimeState { channel: Option, pub cached_rp: Option, pub cached_rk: Option, + + // largeBlob command + pub large_blobs: ctap2::large_blobs::State, } // TODO: Plan towards future extensibility