diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c372d0..56e098f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,9 @@ jobs: toolchain: ${{ matrix.rust }} components: clippy + - name: Install libudev + run: sudo apt-get update && sudo apt-get install -y libudev-dev + - name: Run Clippy run: rustup run ${{ matrix.rust }} cargo clippy --all --all-targets --all-features -- -D warnings @@ -119,6 +122,9 @@ jobs: profile: minimal toolchain: ${{ matrix.rust }} + - name: Install libudev + run: sudo apt-get update && sudo apt-get install -y libudev-dev + - name: Run tests run: rustup run ${{ matrix.rust }} cargo test diff --git a/passkey-authenticator/Cargo.toml b/passkey-authenticator/Cargo.toml index 6bda8b1..7f72022 100644 --- a/passkey-authenticator/Cargo.toml +++ b/passkey-authenticator/Cargo.toml @@ -19,22 +19,30 @@ workspace = true default = [] testable = ["dep:mockall", "passkey-types/testable"] tokio = ["dep:tokio"] +linux = ["dep:ciborium", "dep:tokio", "passkey-transports/linux"] [dependencies] async-trait = "0.1" +ciborium = { version = "0.2", optional = true } coset = { workspace = true } log = "0.4" mockall = { version = "0.11", optional = true } p256 = { version = "0.13", features = ["arithmetic", "jwk", "pem"] } +passkey-transports = { path = "../passkey-transports", version = "0.1" } passkey-types = { path = "../passkey-types", version = "0.5" } rand = "0.8" tokio = { version = "1", features = ["sync"], optional = true } [dev-dependencies] +authenticator = { version = "0.4", default-features = false, features = [ + "crypto_dummy", +] } +ciborium = "0.2" generic-array = { version = "=0.14.7", default-features = false } mockall = { version = "0.11" } passkey-types = { path = "../passkey-types", version = "0.5", features = [ "testable", ] } +serde_cbor = "0.11" signature = { version = "2", features = ["rand_core"] } tokio = { version = "1", features = ["macros", "rt", "sync"] } diff --git a/passkey-authenticator/src/authenticator.rs b/passkey-authenticator/src/authenticator.rs index c071449..3be6ee2 100644 --- a/passkey-authenticator/src/authenticator.rs +++ b/passkey-authenticator/src/authenticator.rs @@ -66,6 +66,31 @@ impl From for usize { } } +trait ValidationOptions { + fn uv(&self) -> bool; + fn up(&self) -> bool; +} + +impl ValidationOptions for passkey_types::ctap2::make_credential::Options { + fn uv(&self) -> bool { + self.uv + } + + fn up(&self) -> bool { + self.up + } +} + +impl ValidationOptions for passkey_types::ctap2::get_assertion::Options { + fn uv(&self) -> bool { + self.uv + } + + fn up(&self) -> bool { + self.up + } +} + /// A virtual authenticator with all the necessary state and information. pub struct Authenticator { /// The authenticator's AAGUID @@ -202,22 +227,22 @@ where async fn check_user( &self, hint: UiHint<'_, ::PasskeyItem>, - options: &passkey_types::ctap2::make_credential::Options, + options: &impl ValidationOptions, ) -> Result { - if options.uv && self.user_validation.is_verification_enabled() != Some(true) { + if options.uv() && self.user_validation.is_verification_enabled() != Some(true) { return Err(Ctap2Error::UnsupportedOption); }; let check_result = self .user_validation - .check_user(hint, options.up, options.uv) + .check_user(hint, options.up(), options.uv()) .await?; - if options.up && !check_result.presence { + if options.up() && !check_result.presence { return Err(Ctap2Error::OperationDenied); } - if options.uv && !check_result.verification { + if options.uv() && !check_result.verification { return Err(Ctap2Error::OperationDenied); } diff --git a/passkey-authenticator/src/authenticator/get_assertion/tests.rs b/passkey-authenticator/src/authenticator/get_assertion/tests.rs index 4386e6c..5779fd7 100644 --- a/passkey-authenticator/src/authenticator/get_assertion/tests.rs +++ b/passkey-authenticator/src/authenticator/get_assertion/tests.rs @@ -35,11 +35,7 @@ fn good_request() -> Request { extensions: None, pin_auth: None, pin_protocol: None, - options: Options { - up: true, - uv: true, - rk: false, - }, + options: Options { up: true, uv: true }, } } diff --git a/passkey-authenticator/src/credential_store.rs b/passkey-authenticator/src/credential_store.rs index 506d9f1..2ed0a37 100644 --- a/passkey-authenticator/src/credential_store.rs +++ b/passkey-authenticator/src/credential_store.rs @@ -5,8 +5,7 @@ use passkey_types::{ Passkey, ctap2::{ Ctap2Error, StatusCode, - get_assertion::Options, - make_credential::{PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity}, + make_credential::{Options, PublicKeyCredentialRpEntity, PublicKeyCredentialUserEntity}, }, webauthn::PublicKeyCredentialDescriptor, }; diff --git a/passkey-authenticator/src/ctap2.rs b/passkey-authenticator/src/ctap2.rs index 0016490..7751003 100644 --- a/passkey-authenticator/src/ctap2.rs +++ b/passkey-authenticator/src/ctap2.rs @@ -15,6 +15,9 @@ mod sealed { pub trait Sealed {} impl Sealed for Authenticator {} + + #[cfg(all(feature = "linux", target_os = "linux"))] + impl Sealed for crate::linux::LinuxAuthenticator {} } /// Methods defined as being required for a [CTAP 2.0] compliant authenticator to implement. diff --git a/passkey-authenticator/src/lib.rs b/passkey-authenticator/src/lib.rs index a9381e6..2f06bc8 100644 --- a/passkey-authenticator/src/lib.rs +++ b/passkey-authenticator/src/lib.rs @@ -30,6 +30,9 @@ mod passkey; mod u2f; mod user_validation; +#[cfg(all(feature = "linux", target_os = "linux"))] +pub mod linux; + use coset::{ CoseKey, CoseKeyBuilder, iana::{self, Algorithm, EnumI64}, diff --git a/passkey-authenticator/src/linux.rs b/passkey-authenticator/src/linux.rs new file mode 100644 index 0000000..09f91e8 --- /dev/null +++ b/passkey-authenticator/src/linux.rs @@ -0,0 +1,506 @@ +//! Linux USB security key authenticator. +//! +//! [`LinuxAuthenticator`] adapts the CTAPHID transport in +//! [`passkey_transports::hidraw`] to the [`Ctap2Api`](crate::Ctap2Api) trait, so a +//! USB hardware key can be plugged into anything that today drives the in-process +//! [`Authenticator`](crate::Authenticator). +//! +//! ## Usage +//! +//! ```ignore +//! use passkey_authenticator::linux::LinuxAuthenticator; +//! +//! let devices = LinuxAuthenticator::list_devices()?; +//! let mut auth = LinuxAuthenticator::open(&devices[0].path).await?; +//! let info = auth.get_info().await; +//! ``` + +use std::io; +use std::path::Path; + +use passkey_transports::hid::{Command, Message}; +use passkey_transports::hidraw::{DeviceInfo, HidDevice, HidrawError, enumerate_fido_devices}; +use passkey_types::Bytes; +use passkey_types::ctap2::{ + Ctap2Code, Ctap2Error, StatusCode, U2FError, client_pin, get_assertion, get_info, + make_credential, +}; +use tokio::sync::Mutex; + +use crate::Ctap2Api; + +// Re-export so callers don't need a direct dep on passkey-transports. +pub use passkey_transports::hidraw::{Capabilities, DeviceInfo as HidDeviceInfo, InitResponse}; + +/// CTAP2 command byte for `authenticatorMakeCredential`. +const CTAP_CMD_MAKE_CREDENTIAL: u8 = 0x01; +/// CTAP2 command byte for `authenticatorGetAssertion`. +const CTAP_CMD_GET_ASSERTION: u8 = 0x02; +/// CTAP2 command byte for `authenticatorGetInfo`. +const CTAP_CMD_GET_INFO: u8 = 0x04; +/// CTAP2 command byte for `authenticatorClientPin` +const CTAP_CMD_CLIENT_PIN: u8 = 0x06; +/// CTAP2 subcommand byte for `getPinRetries`. +const CTAP_GET_PIN_RETRIES: u8 = 0x01; +/// CTAP2 subcommand byte for `getKeyAgreement`. +const CTAP_GET_KEY_AGREEMENT: u8 = 0x02; +/// CTAP2 subcommand byte for `getPinToken`. +const CTAP_GET_PIN_TOKEN: u8 = 0x05; +/// CTAP2 subcommand byte for +/// `getPinUvAuthTokenUsingUvWithPermissions`. +const CTAP_GET_PIN_UV_AUTH_TOKEN_USING_UV_WITH_PERMISSIONS: u8 = 0x06; +/// CTAP2 subcommand byte for +/// `getPinUvAuthTokenUsingPinWithPermissions`. +const CTAP_GET_PIN_UV_AUTH_TOKEN_USING_PIN_WITH_PERMISSIONS: u8 = 0x09; +/// CTAP2 command byte for `authenticatorSelection` +const CTAP_CMD_AUTHENTICATOR_SELECTION: u8 = 0x0B; + +/// Errors that can occur while constructing a [`LinuxAuthenticator`]. +#[derive(Debug)] +#[non_exhaustive] +pub enum OpenError { + /// The underlying HIDRAW transport returned an error (open / init / I/O). + Transport(HidrawError), + /// `CTAPHID_INIT` succeeded but the device does not advertise CTAP2 (CBOR) support. + NotCtap2, + /// The device responded to `authenticatorGetInfo` with a CTAP2 status code. + GetInfo(StatusCode), + /// The device's `authenticatorGetInfo` response could not be parsed as CBOR. + InvalidGetInfo, +} + +impl From for OpenError { + fn from(e: HidrawError) -> Self { + Self::Transport(e) + } +} + +impl From for OpenError { + fn from(e: io::Error) -> Self { + Self::Transport(HidrawError::from(e)) + } +} + +impl std::fmt::Display for OpenError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + OpenError::Transport(e) => write!(f, "HID transport error: {e}"), + OpenError::NotCtap2 => f.write_str("device does not advertise CTAP2 support"), + OpenError::GetInfo(s) => write!(f, "authenticatorGetInfo failed: {s:?}"), + OpenError::InvalidGetInfo => { + f.write_str("could not parse authenticatorGetInfo response") + } + } + } +} + +impl std::error::Error for OpenError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + OpenError::Transport(e) => Some(e), + _ => None, + } + } +} + +/// A CTAP2 authenticator backed by a USB security key reached over Linux HIDRAW. +/// +/// Construct with [`LinuxAuthenticator::open`]; enumerate candidate devices with +/// [`LinuxAuthenticator::list_devices`]. +pub struct LinuxAuthenticator { + device: HidDevice, + channel: u32, + capabilities: Capabilities, + /// Cached `authenticatorGetInfo` response, stored as raw CBOR. + /// + /// The `Ctap2Api::get_info` trait method returns an owned `Box` and + /// takes `&self`, so we can't lazily call the device on every invocation + /// without interior mutability. Caching the bytes lets us hand out fresh + /// `Response` values cheaply, and avoids requiring `Clone` on the response + /// type, which lives in `passkey-types`. + get_info_cbor: Vec, + /// A caller performing a multi-packet CBOR transaction on this device must acquire this lock so + /// concurrent callers don't interleave packets on the wire. + txn_lock: Mutex<()>, +} + +impl LinuxAuthenticator { + /// Enumerate FIDO-capable USB HID devices visible on the system. + pub fn list_devices() -> io::Result> { + enumerate_fido_devices() + } + + /// Whether builtin UV is configured for this device. + pub fn uv_configured(&self) -> bool { + self.info().options.and_then(|o| o.uv).unwrap_or(false) + } + + /// Whether a PIN is configured for this device. + pub fn pin_configured(&self) -> bool { + self.info() + .options + .and_then(|o| o.client_pin) + .unwrap_or(false) + } + + /// Whether this device supports storing resident keys. + pub fn rk_supported(&self) -> bool { + self.info().options.is_some_and(|o| o.rk) + } + + /// Whether the authenticator supports authenticatorClientPIN's + /// getPinUvAuthTokenUsingUvWithPermissions subcommand. + pub fn pin_uv_auth_token_supported(&self) -> bool { + self.info() + .options + .map(|o| o.pin_uv_auth_token == Some(true)) + .unwrap_or(false) + } + + /// Open a specific `/dev/hidrawN` path, run `CTAPHID_INIT` to obtain a private + /// channel, and prime the cached `authenticatorGetInfo` response. + pub async fn open(path: &Path) -> Result { + let device = HidDevice::open(path)?; + let init = device.init().await?; + if !init.capabilities.supports_cbor() { + return Err(OpenError::NotCtap2); + } + + // Fetch authenticatorGetInfo so we can cache it and surface any obvious + // device-side errors before returning to the caller. + let raw = send_cbor(&device, init.channel, CTAP_CMD_GET_INFO, &[]).await?; + // Validate that it parses. + let _: get_info::Response = + ciborium::de::from_reader(raw.as_slice()).map_err(|_| OpenError::InvalidGetInfo)?; + + Ok(Self { + device, + channel: init.channel, + capabilities: init.capabilities, + get_info_cbor: raw, + txn_lock: Mutex::new(()), + }) + } + + /// Capabilities reported by the device in its `CTAPHID_INIT` response. + pub fn capabilities(&self) -> Capabilities { + self.capabilities + } + + /// Issue an `authenticatorSelection` command against the device. Returns when the user + /// provides UP on the device. + pub async fn authenticator_selection(&self) -> Result<(), StatusCode> { + let _guard = self.txn_lock.lock().await; + let response = send_cbor( + &self.device, + self.channel, + CTAP_CMD_AUTHENTICATOR_SELECTION, + &[], + ) + .await; + if let Err(TransactionError::Status(StatusCode::Ctap2(Ctap2Code::Known(Ctap2Error::Ok)))) = + response + { + Ok(()) + } else { + response.map(|_| ()).map_err(StatusCode::from) + } + } + + /// Issue `authenticatorMakeCredential` against the device. Holds an internal mutex for the + /// duration of the transaction to keep wire packets in order. + pub async fn make_credential( + &self, + request: make_credential::Request, + ) -> Result { + let mut body = Vec::new(); + ciborium::ser::into_writer(&request, &mut body) + .map_err(|_| StatusCode::from(U2FError::Other))?; + let _guard = self.txn_lock.lock().await; + let response = send_cbor(&self.device, self.channel, CTAP_CMD_MAKE_CREDENTIAL, &body) + .await + .map_err(StatusCode::from)?; + ciborium::de::from_reader(response.as_slice()) + .map_err(|_| StatusCode::from(Ctap2Error::InvalidCbor)) + } + + /// Issue `authenticatorGetAssertion` against the device. See [`Self::make_credential`]. + pub async fn get_assertion( + &self, + request: get_assertion::Request, + ) -> Result { + let mut body = Vec::new(); + ciborium::ser::into_writer(&request, &mut body) + .map_err(|_| StatusCode::from(U2FError::Other))?; + let _guard = self.txn_lock.lock().await; + let response = send_cbor(&self.device, self.channel, CTAP_CMD_GET_ASSERTION, &body) + .await + .map_err(StatusCode::from)?; + ciborium::de::from_reader(response.as_slice()) + .map_err(|_| StatusCode::from(Ctap2Error::InvalidCbor)) + } + + /// Send `CTAPHID_CANCEL` on this authenticator's channel without taking the + /// transaction mutex. + /// + /// This causes any in-flight `CTAPHID_CBOR` request on the same channel to be aborted; the + /// awaiting `make_credential` / `get_assertion` future will return + /// `Ctap2Error::KeepAliveCancel`. Calling this on a channel with no in-flight request is a + /// no-op (the device ignores it). + pub async fn cancel(&self) -> Result<(), HidrawError> { + let msg = Message::new(self.channel, Command::Cancel, &[]) + .map_err(|_| HidrawError::MessageTooLarge)?; + self.device.send(&msg).await + } + + /// Read and decode the cached `authenticatorGetInfo` response. + pub fn info(&self) -> get_info::Response { + ciborium::de::from_reader(self.get_info_cbor.as_slice()).unwrap_or_default() + } + + /// Fetch public key from device using the given protocol. + pub async fn get_public_key(&self, protocol: u8) -> Result { + let request = client_pin::Request { + pin_uv_auth_protocol: Some(protocol), + sub_command: CTAP_GET_KEY_AGREEMENT, + key_agreement: None, + pin_uv_auth_param: None, + new_pin_enc: None, + pin_hash_enc: None, + permissions: None, + rp_id: None, + }; + let mut body = Vec::new(); + ciborium::ser::into_writer(&request, &mut body) + .map_err(|_| StatusCode::from(U2FError::Other))?; + let _guard = self.txn_lock.lock().await; + let response = send_cbor(&self.device, self.channel, CTAP_CMD_CLIENT_PIN, &body) + .await + .map_err(StatusCode::from)?; + let response: client_pin::Response = + ciborium::de::from_reader(response.as_slice()).unwrap(); + // TODO: remove this expect + Ok(response.key_agreement.expect("should have a key agreement")) + } + + /// `getPinToken` subcommand of `authenticatorClientPin`. Superseded by + /// `getPinUvAuthTokenUsingPinWithPermissions`. + pub async fn get_pin_token( + &self, + protocol: u8, + key_agreement: coset::CoseKey, + pin_hash_enc: Bytes, + ) -> Result { + let request = client_pin::Request { + pin_uv_auth_protocol: Some(protocol), + sub_command: CTAP_GET_PIN_TOKEN, + key_agreement: Some(key_agreement), + pin_uv_auth_param: None, + new_pin_enc: None, + pin_hash_enc: Some(pin_hash_enc), + permissions: None, + rp_id: None, + }; + let mut body = Vec::new(); + ciborium::ser::into_writer(&request, &mut body) + .map_err(|_| StatusCode::from(U2FError::Other))?; + let _guard = self.txn_lock.lock().await; + let response = send_cbor(&self.device, self.channel, CTAP_CMD_CLIENT_PIN, &body) + .await + .map_err(StatusCode::from)?; + let response: client_pin::Response = + ciborium::de::from_reader(response.as_slice()).unwrap_or_default(); + // TODO: remove this expect + Ok(response + .pin_uv_auth_token + .expect("should have a pinUvAuthToken")) + } + + /// `getPinUvAuthTokenUsingUvWithPermissions` subcommand of `authenticatorClientPin`. + /// Uses UV method built in to the authenticator to obtain token. + pub async fn get_pin_uv_auth_token_using_uv( + &self, + protocol: u8, + key_agreement: coset::CoseKey, + permissions: client_pin::Permissions, + // rp_id is required for both make_credential and get_assertion, but we leave + // it as an Option here in case we need to add support for other permissions and don't + // want to break backwards compatibility. + rp_id: Option, + ) -> Result { + let request = client_pin::Request { + pin_uv_auth_protocol: Some(protocol), + sub_command: CTAP_GET_PIN_UV_AUTH_TOKEN_USING_UV_WITH_PERMISSIONS, + key_agreement: Some(key_agreement), + pin_uv_auth_param: None, + new_pin_enc: None, + pin_hash_enc: None, + permissions: Some(permissions), + rp_id, + }; + let mut body = Vec::new(); + ciborium::ser::into_writer(&request, &mut body) + .map_err(|_| StatusCode::from(U2FError::Other))?; + let _guard = self.txn_lock.lock().await; + let response = send_cbor(&self.device, self.channel, CTAP_CMD_CLIENT_PIN, &body) + .await + .map_err(StatusCode::from)?; + let response: client_pin::Response = + ciborium::de::from_reader(response.as_slice()).unwrap_or_default(); + // TODO: remove this expect + Ok(response + .pin_uv_auth_token + .expect("should have a pinUvAuthToken")) + } + + /// `getPinUvAuthTokenUsingPinWithPermissions` subcommand of `authenticatorClientPin`. + /// Uses PIN configured on the authenticator to obtain token. + pub async fn get_pin_uv_auth_token_using_pin( + &self, + protocol: u8, + key_agreement: coset::CoseKey, + pin_hash_enc: Bytes, + permissions: client_pin::Permissions, + // rp_id is required for both make_credential and get_assertion, but we leave + // it as an Option here in case we need to add support for other permissions and don't + // want to break backwards compatibility. + rp_id: Option, + ) -> Result { + let request = client_pin::Request { + pin_uv_auth_protocol: Some(protocol), + sub_command: CTAP_GET_PIN_UV_AUTH_TOKEN_USING_PIN_WITH_PERMISSIONS, + key_agreement: Some(key_agreement), + pin_uv_auth_param: None, + new_pin_enc: None, + pin_hash_enc: Some(pin_hash_enc), + permissions: Some(permissions), + rp_id, + }; + let mut body = Vec::new(); + ciborium::ser::into_writer(&request, &mut body) + .map_err(|_| StatusCode::from(U2FError::Other))?; + let _guard = self.txn_lock.lock().await; + let response = send_cbor(&self.device, self.channel, CTAP_CMD_CLIENT_PIN, &body) + .await + .map_err(StatusCode::from)?; + let response: client_pin::Response = + ciborium::de::from_reader(response.as_slice()).unwrap_or_default(); + // TODO: remove this expect + Ok(response + .pin_uv_auth_token + .expect("should have a pinUvAuthToken")) + } + + /// `getPinRetries` subcommand of `authenticatorClientPin`. + /// Returns the number of times PIN authentication can fail before the authenticator's data is + /// wiped and it is fully reset. + pub async fn get_pin_retries(&self, protocol: u8) -> Result { + let request = client_pin::Request { + pin_uv_auth_protocol: Some(protocol), + sub_command: CTAP_GET_PIN_RETRIES, + key_agreement: None, + pin_uv_auth_param: None, + new_pin_enc: None, + pin_hash_enc: None, + permissions: None, + rp_id: None, + }; + let mut body = Vec::new(); + ciborium::ser::into_writer(&request, &mut body) + .map_err(|_| StatusCode::from(U2FError::Other))?; + let _guard = self.txn_lock.lock().await; + let response = send_cbor(&self.device, self.channel, CTAP_CMD_CLIENT_PIN, &body) + .await + .map_err(StatusCode::from)?; + let response: client_pin::Response = + ciborium::de::from_reader(response.as_slice()).unwrap_or_default(); + // TODO: remove this expect + Ok(response.pin_retries.expect("Should have pin retries")) + } +} + +/// Internal error type for CBOR transactions. Maps cleanly to both [`StatusCode`] +/// (for the [`Ctap2Api`] surface) and [`OpenError`] (for the constructor). +#[derive(Debug)] +enum TransactionError { + Hid(HidrawError), + Status(StatusCode), +} + +impl From for OpenError { + fn from(e: TransactionError) -> Self { + match e { + TransactionError::Hid(e) => OpenError::Transport(e), + TransactionError::Status(s) => OpenError::GetInfo(s), + } + } +} + +impl From for StatusCode { + fn from(e: TransactionError) -> Self { + match e { + TransactionError::Status(s) => s, + // CTAP doesn't have a dedicated "transport failed" status code; surface + // it as the catch-all CTAP1 `U2FError::Other` (0x7F). + TransactionError::Hid(_) => StatusCode::from(U2FError::Other), + } + } +} + +/// Run one CTAPHID_CBOR transaction and return the CBOR body of the response. +/// +/// Lifted out of [`LinuxAuthenticator`] so it can also be used during construction +/// before `self` exists. +async fn send_cbor( + device: &HidDevice, + channel: u32, + command: u8, + body: &[u8], +) -> Result, TransactionError> { + let mut payload = Vec::with_capacity(1 + body.len()); + payload.push(command); + payload.extend_from_slice(body); + + let msg = Message::new(channel, Command::Cbor, &payload) + .map_err(|_| TransactionError::Hid(HidrawError::MessageTooLarge))?; + device.send(&msg).await.map_err(TransactionError::Hid)?; + + let response = device.recv(channel).await.map_err(TransactionError::Hid)?; + if !matches!(response.command, Command::Cbor) { + return Err(TransactionError::Hid(HidrawError::Protocol( + "unexpected CTAPHID command in response", + ))); + } + let mut bytes = response.payload; + if bytes.is_empty() { + return Err(TransactionError::Hid(HidrawError::Protocol( + "empty CTAPHID_CBOR response", + ))); + } + let status = bytes.remove(0); + if status != 0 { + return Err(TransactionError::Status(StatusCode::from(status))); + } + Ok(bytes) +} + +#[async_trait::async_trait] +impl Ctap2Api for LinuxAuthenticator { + async fn get_info(&self) -> Box { + Box::new(self.info()) + } + + async fn make_credential( + &mut self, + request: make_credential::Request, + ) -> Result { + LinuxAuthenticator::make_credential(self, request).await + } + + async fn get_assertion( + &mut self, + request: get_assertion::Request, + ) -> Result { + LinuxAuthenticator::get_assertion(self, request).await + } +} diff --git a/passkey-authenticator/src/u2f.rs b/passkey-authenticator/src/u2f.rs index 9a728b7..2a5f847 100644 --- a/passkey-authenticator/src/u2f.rs +++ b/passkey-authenticator/src/u2f.rs @@ -96,7 +96,7 @@ impl U2 Passkey::wrap_u2f_registration_request(&request, &response, handle, &private); // U2F registration does not use rk, uv, or up - let options = passkey_types::ctap2::get_assertion::Options { + let options = passkey_types::ctap2::make_credential::Options { rk: false, uv: false, up: false, diff --git a/passkey-authenticator/tests/authenticator-rs-compat.rs b/passkey-authenticator/tests/authenticator-rs-compat.rs new file mode 100644 index 0000000..d5262db --- /dev/null +++ b/passkey-authenticator/tests/authenticator-rs-compat.rs @@ -0,0 +1,87 @@ +//! Tests for compatibility sanity checks with Authenticator-rs + +use authenticator::MakeCredentialsResult; +use coset::iana; +use passkey_authenticator::{Authenticator, UiHint, UserCheck, UserValidationMethod}; +use passkey_types::{ + Passkey, + ctap2::{Ctap2Error, make_credential}, + rand, webauthn, +}; + +struct MockUV; + +#[async_trait::async_trait] +impl UserValidationMethod for MockUV { + type PasskeyItem = Passkey; + + async fn check_user<'a>( + &self, + _hint: UiHint<'a, Self::PasskeyItem>, + presence: bool, + verification: bool, + ) -> Result { + Ok(UserCheck { + presence, + verification, + }) + } + + fn is_presence_enabled(&self) -> bool { + true + } + + fn is_verification_enabled(&self) -> Option { + Some(true) + } +} + +#[tokio::test] +async fn ensure_attestation_object_compatibility() { + let mut auth = Authenticator::new([0; 16].into(), None::, MockUV); + let cred_response = auth + .make_credential(make_credential::Request { + client_data_hash: rand::random_vec(32).into(), + rp: make_credential::PublicKeyCredentialRpEntity { + id: "webauth.io".to_string(), + name: Some("webauthn.io".to_string()), + }, + user: webauthn::PublicKeyCredentialUserEntity { + id: rand::random_vec(16).into(), + name: "wendy".to_string(), + display_name: "wendy".to_string(), + }, + pub_key_cred_params: vec![webauthn::PublicKeyCredentialParameters { + ty: webauthn::PublicKeyCredentialType::PublicKey, + alg: iana::Algorithm::ES256, + }], + exclude_list: None, + extensions: None, + options: make_credential::Options { + rk: true, + up: true, + uv: false, + }, + pin_auth: None, + pin_protocol: None, + }) + .await + .expect("Creation failed"); + + // Encode response as we do + let mut attestation_obj = Vec::with_capacity(256); + ciborium::into_writer(&cred_response, &mut attestation_obj).expect("Failed to serialize"); + + // Decode response as Authenticator-rs does + let make_cred_response: MakeCredentialsResult = + serde_cbor::from_slice(attestation_obj.as_slice()) + .expect("Failed to deserialize for authenticator-rs"); + + // Ensure that our public keys are correctly parseable as x.509 format. + let auth_data = make_cred_response.att_obj.auth_data; + let cose_key = auth_data.credential_data.unwrap().credential_public_key; + let public_key = cose_key + .der_spki() + .expect("ensure our public key parses correctly"); + assert!(!public_key.is_empty()); +} diff --git a/passkey-client/Cargo.toml b/passkey-client/Cargo.toml index eda348b..a84a5f5 100644 --- a/passkey-client/Cargo.toml +++ b/passkey-client/Cargo.toml @@ -20,10 +20,12 @@ android-asset-validation = ["dep:nom"] testable = ["dep:mockall"] tokio = ["dep:tokio"] typeshare = ["passkey-types/typeshare", "dep:typeshare"] +linux = ["dep:tokio", "passkey-authenticator/linux"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +async-trait = "0.1" ciborium = "0.2" coset = { workspace = true } idna = "1" @@ -36,9 +38,10 @@ public-suffix = { path = "../public-suffix", version = "0.1" } reqwest = { version = "0.12", default-features = false, optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["sync", "time"], optional = true } +tokio = { version = "1", features = ["sync", "time", "rt"], optional = true } typeshare = { version = "1", optional = true } url = "2" +zeroize = { version = "1", features = ["zeroize_derive"] } [dev-dependencies] coset = { workspace = true } diff --git a/passkey-client/src/lib.rs b/passkey-client/src/lib.rs index 7230421..200a3c9 100644 --- a/passkey-client/src/lib.rs +++ b/passkey-client/src/lib.rs @@ -37,6 +37,9 @@ use url::Url; mod extensions; mod rp_id_verifier; +#[cfg(all(feature = "linux", target_os = "linux"))] +pub mod linux; + pub use self::rp_id_verifier::{Fetcher, RelatedOriginResponse, RpIdVerifier}; #[cfg(feature = "android-asset-validation")] @@ -171,6 +174,8 @@ where { authenticator: Authenticator, rp_id_verifier: RpIdVerifier, + /// When the RP sends [`UserVerificationRequirement::Preferred`], whether to set CTAP `uv` to true. + uv_when_preferred: bool, } impl Client @@ -185,6 +190,7 @@ where Self { authenticator, rp_id_verifier: RpIdVerifier::new(public_suffix::DEFAULT_PROVIDER, None), + uv_when_preferred: true, } } } @@ -206,6 +212,7 @@ where Self { authenticator, rp_id_verifier: RpIdVerifier::new(custom_provider, fetcher), + uv_when_preferred: true, } } @@ -225,6 +232,13 @@ where &mut self.authenticator } + /// Set the client's preferred user verification setting. + /// This setting is used when the RP requirement is [`UserVerificationRequirement::Preferred`] to determine if uv should be set to true. + pub fn user_verification_when_preferred(mut self, enabled: bool) -> Self { + self.uv_when_preferred = enabled; + self + } + /// Register a webauthn `request` from the given `origin`. /// /// Returns either a [`webauthn::CreatedPublicKeyCredential`] on success or some [`WebauthnError`] @@ -285,8 +299,12 @@ where )?; let rk = self.map_rk(&request.authenticator_selection, &auth_info); - let uv = request.authenticator_selection.map(|s| s.user_verification) - != Some(UserVerificationRequirement::Discouraged); + let uv_requirement = request + .authenticator_selection + .as_ref() + .map(|s| s.user_verification) + .unwrap_or_default(); + let uv = self.ctap_uv_option(uv_requirement); let make_credential_request = ctap2::make_credential::Request { client_data_hash: client_data_json_hash.into(), @@ -416,15 +434,14 @@ where &request, auth_info.extensions.unwrap_or_default().as_slice(), )?; - let rk = false; - let uv = request.user_verification != UserVerificationRequirement::Discouraged; + let uv = self.ctap_uv_option(request.user_verification); let get_assertion_request = ctap2::get_assertion::Request { rp_id: rp_id.to_owned(), client_data_hash: client_data_json_hash.into(), allow_list: request.allow_credentials, extensions: ctap_extensions, - options: ctap2::get_assertion::Options { rk, up: true, uv }, + options: ctap2::get_assertion::Options { up: true, uv }, pin_auth: None, pin_protocol: None, }; @@ -511,4 +528,14 @@ where } => *require_resident_key, } } + + /// Determine if user verification is required based on the given requirement and the client's preferred user verification setting. + /// If the requirement is [`UserVerificationRequirement::Preferred`], use the client's preferred user verification setting. + fn ctap_uv_option(&self, requirement: UserVerificationRequirement) -> bool { + match requirement { + UserVerificationRequirement::Discouraged => false, + UserVerificationRequirement::Required => true, + UserVerificationRequirement::Preferred => self.uv_when_preferred, + } + } } diff --git a/passkey-client/src/linux.rs b/passkey-client/src/linux.rs new file mode 100644 index 0000000..aa6e6a7 --- /dev/null +++ b/passkey-client/src/linux.rs @@ -0,0 +1,797 @@ +//! Multi-device USB HID WebAuthn client for Linux. +//! +//! [`LinuxClient`] wraps a set of [`LinuxAuthenticator`]s, one for each detected FIDO-compatible +//! HID device, and races a WebAuthn ceremony across all of them. Whichever security key the user +//! touches first wins; the rest receive a `CTAPHID_CANCEL` command so their in-flight transactions +//! are cancelled cleanly. +//! +//! Each per-device CTAP request is tailored to that device's capabilities (advertised in its cached +//! `authenticatorGetInfo` response). Algorithms that the device doesn't support are filtered out, +//! resident-key-incapable devices are skipped when an RP requires `rk`, and so on. Only the +//! response from the winning authenticator is returned. +//! +//! ## Usage +//! +//! ```ignore +//! use passkey_client::linux::LinuxClient; +//! +//! let mut client = LinuxClient::open_all().await?; +//! let created = client.register(origin, request, DefaultClientData).await?; +//! ``` + +use std::sync::{Arc, Mutex}; + +use coset::{Algorithm, iana::EnumI64}; +use passkey_authenticator::linux::{LinuxAuthenticator, OpenError}; +use passkey_authenticator::public_key_der_from_cose_key; +use passkey_types::Bytes; +use passkey_types::crypto::{aes_256_cbc, hmac_sha256}; +use passkey_types::ctap2::StatusCode; +use passkey_types::ctap2::client_pin::Permissions; +use passkey_types::ctap2::pin_uv_auth_protocol::PinUvAuthProtocolOne; +use passkey_types::{ + crypto::sha256, + ctap2, encoding, + webauthn::{ + self, AuthenticatedPublicKeyCredential, AuthenticatorAssertionResponse, + AuthenticatorAttachment, AuthenticatorAttestationResponse, ClientDataType, + CollectedClientData, CreatedPublicKeyCredential, CredentialCreationOptions, + CredentialRequestOptions, PublicKeyCredentialParameters, PublicKeyCredentialType, + ResidentKeyRequirement, UserVerificationRequirement, + }, +}; +use serde::Serialize; +use tokio::sync::mpsc; + +use crate::{ClientData, Fetcher, Origin, RpIdVerifier, WebauthnError}; + +/// Trait to be implemented by the consumer that allows custom logic for displaying a PIN +/// prompt/authenticator selection request. +#[async_trait::async_trait] +pub trait PinPrompt: Send + Sync { + /// Prompt user to provide User Presence to a particular authenticator to select it for use. + /// Returning an error aborts the in-flight authenticator-selection race. + async fn prompt_authenticator_selection( + &self, + num_authenticators: usize, + ) -> Result<(), PinPromptError>; + + /// Prompt user to enter a PIN. + async fn request_pin( + &self, + attempts_remaining: u32, + ) -> Result, PinPromptError>; +} + +/// Error resulting from a prompt. +pub enum PinPromptError { + /// PIN request was cancelled by the user. + Cancelled, + + /// Any other error. + Other(Box), +} + +// Allow "redundant" prefixes - these are the names of the actual authenticatorClientPin +// subcommands +#[allow(clippy::enum_variant_names)] +enum GetPinUvAuthTokenOp { + GetPinUvAuthTokenUsingUvWithPermissions, + GetPinUvAuthTokenUsingPinWithPermissions, + GetPinToken, +} + +/// A WebAuthn client backed by zero or more USB security keys serving as authenticators. +pub struct LinuxClient +where + P: public_suffix::EffectiveTLDProvider + Sync + 'static, + F: Fetcher + Sync, +{ + devices: Vec>, + rp_id_verifier: RpIdVerifier, + uv_when_preferred: bool, + pin_prompt: Box, + // TODO: support multiple pin protocols + pin_uv_auth_protocol_state: PinUvAuthProtocolOne, +} + +#[derive(Clone)] +struct TokenWithProtocol { + token: Bytes, + protocol: u8, +} + +struct LinuxAuthenticatorWithToken { + authenticator: LinuxAuthenticator, + token_with_protocol: Mutex>, +} + +impl From for LinuxAuthenticatorWithToken { + fn from(value: LinuxAuthenticator) -> Self { + Self { + authenticator: value, + token_with_protocol: Mutex::new(None), + } + } +} + +impl LinuxClient { + /// Build a `LinuxClient` over the supplied authenticators using the default public-suffix list + /// TLD provider. + pub fn new(authenticators: Vec, pin_prompt: Box) -> Self { + Self { + devices: authenticators + .into_iter() + .map(LinuxAuthenticatorWithToken::from) + .map(Arc::new) + .collect(), + rp_id_verifier: RpIdVerifier::new(public_suffix::DEFAULT_PROVIDER, None), + uv_when_preferred: true, + pin_prompt, + pin_uv_auth_protocol_state: PinUvAuthProtocolOne::new(), + } + } + + /// Enumerate every FIDO-capable USB HID device on the system and open each + /// one. Devices that fail to open are skipped silently. + pub async fn open_all(pin_prompt: Box) -> Result { + let infos = LinuxAuthenticator::list_devices()?; + let mut authenticators = Vec::new(); + for info in infos { + if let Ok(auth) = LinuxAuthenticator::open(&info.path).await { + authenticators.push(auth); + } + } + Ok(Self::new(authenticators, pin_prompt)) + } +} + +impl LinuxClient +where + P: public_suffix::EffectiveTLDProvider + Sync + 'static, + F: Fetcher + Sync, +{ + /// Build a `LinuxClient` with a custom TLD provider and optional fetcher. + pub fn new_with_custom_tld_provider( + authenticators: Vec, + custom_provider: P, + fetcher: Option, + pin_prompt: Box, + ) -> Self { + Self { + devices: authenticators + .into_iter() + .map(LinuxAuthenticatorWithToken::from) + .map(Arc::new) + .collect(), + rp_id_verifier: RpIdVerifier::new(custom_provider, fetcher), + uv_when_preferred: true, + pin_prompt, + pin_uv_auth_protocol_state: PinUvAuthProtocolOne::new(), + } + } +} + +impl LinuxClient +where + P: public_suffix::EffectiveTLDProvider + Sync + 'static, + F: Fetcher + Sync, +{ + /// Mirror of `Client::user_verification_when_preferred`. + pub fn user_verification_when_preferred(mut self, enabled: bool) -> Self { + self.uv_when_preferred = enabled; + self + } + + /// How many authenticators this client will dispatch to. + pub fn device_count(&self) -> usize { + self.devices.len() + } + + async fn get_valid_devices( + &self, + uv: bool, + rp_id: &str, + ) -> Result<&[Arc], WebauthnError> { + // Determine if any device has been configured to use built-in UV or a PIN. + let any_device_requires_uv = self.devices.iter().any(|device| { + device.authenticator.uv_configured() || device.authenticator.pin_configured() + }); + + // We need to prompt the user to provide UV if the request requires user verification OR if + // any device requires UV. In order to do this, we first need them to provide UP to an authenticator + // to select it. + let selected_devices = if uv || any_device_requires_uv { + let idx = if self.device_count() == 1 { + 0 + } else { + self.race_authenticator_selection(self.devices.clone()) + .await? + }; + let selected_device = self.devices[idx].clone(); + + if selected_device + .token_with_protocol + .lock() + .unwrap() + .is_none() + { + let selected_operation = if selected_device.authenticator.uv_configured() { + if selected_device.authenticator.pin_uv_auth_token_supported() { + // Use UV + Some(GetPinUvAuthTokenOp::GetPinUvAuthTokenUsingUvWithPermissions) + } else { + // Just set uv = true in request + // TODO: retry with PIN if we get error code 0x36 + None + } + } else if selected_device.authenticator.pin_configured() { + if selected_device.authenticator.pin_uv_auth_token_supported() { + // Use PIN + Some(GetPinUvAuthTokenOp::GetPinUvAuthTokenUsingPinWithPermissions) + } else { + Some(GetPinUvAuthTokenOp::GetPinToken) + } + } else { + // Dispatch request with no pinUvAuthToken to selected authenticator + None + }; + + self.get_auth_token_for_device(&selected_device, selected_operation, rp_id) + .await?; + } + + &self.devices[idx..idx + 1] + } else { + // The request doesn't require UV, and no authenticator device requires UV. + // Therefore, do the regular authentication flow (race all authenticators). + &self.devices + }; + Ok(selected_devices) + } + + async fn get_auth_token_for_device( + &self, + selected_device: &LinuxAuthenticatorWithToken, + selected_operation: Option, + rp_id: &str, + ) -> Result<(), WebauthnError> { + let token_and_protocol = match selected_operation { + None => None, + Some(op) => { + // Obtain platform key-agreement key and shared secret. + let pin_uv_auth_protocol = 1; + let public_key = selected_device + .authenticator + .get_public_key(pin_uv_auth_protocol) + .await?; + let (platform_key_agreement_key, shared_secret) = self + .pin_uv_auth_protocol_state + .encapsulate(&public_key) + .map_err(StatusCode::from)?; + let permissions = Permissions::MC | Permissions::GA; + let encrypted_token = match op { + GetPinUvAuthTokenOp::GetPinUvAuthTokenUsingPinWithPermissions => { + let attempts_remaining = selected_device + .authenticator + .get_pin_retries(pin_uv_auth_protocol) + .await?; + let pin = self + .pin_prompt + .request_pin(attempts_remaining) + .await + .map_err(|_| WebauthnError::NotSupportedError)?; + let encrypted_pin = + aes_256_cbc(shared_secret, [0; 16], &sha256(pin.as_bytes())[0..16]) + .into(); + selected_device + .authenticator + .get_pin_uv_auth_token_using_pin( + pin_uv_auth_protocol, + platform_key_agreement_key, + encrypted_pin, + permissions, + Some(rp_id.to_owned()), + ) + .await? + } + GetPinUvAuthTokenOp::GetPinUvAuthTokenUsingUvWithPermissions => { + selected_device + .authenticator + .get_pin_uv_auth_token_using_uv( + pin_uv_auth_protocol, + platform_key_agreement_key, + permissions, + Some(rp_id.to_owned()), + ) + .await? + } + GetPinUvAuthTokenOp::GetPinToken => { + let attempts_remaining = selected_device + .authenticator + .get_pin_retries(pin_uv_auth_protocol) + .await?; + let pin = self + .pin_prompt + .request_pin(attempts_remaining) + .await + .map_err(|_| WebauthnError::NotSupportedError)?; + let encrypted_pin = + aes_256_cbc(shared_secret, [0; 16], &sha256(pin.as_bytes())[0..16]) + .into(); + selected_device + .authenticator + .get_pin_token( + pin_uv_auth_protocol, + platform_key_agreement_key, + encrypted_pin, + ) + .await? + } + }; + + // Decrypt the token using the shared secret before returning it. + let pin_uv_auth_token = Bytes::from(PinUvAuthProtocolOne::decrypt( + &shared_secret, + encrypted_token.as_slice(), + )); + Some((pin_uv_auth_token, pin_uv_auth_protocol)) + } + }; + *selected_device.token_with_protocol.lock().unwrap() = + token_and_protocol.map(|e| TokenWithProtocol { + token: e.0, + protocol: e.1, + }); + Ok(()) + } + + fn get_pin_auth_and_protocol( + &self, + device: &Arc, + client_data_hash: &[u8], + ) -> (Option, Option) { + let guard = device.token_with_protocol.lock().unwrap(); + let pin_auth = guard + .as_ref() + .map(|e| Bytes::from(&hmac_sha256(e.token.as_slice(), client_data_hash)[0..16])); + let pin_protocol = guard.as_ref().map(|e| e.protocol); + (pin_auth, pin_protocol) + } + + /// Register a credential. + pub async fn register, E: Serialize + Clone>( + &self, + origin: impl Into>, + request: CredentialCreationOptions, + client_data: D, + ) -> Result { + let origin = origin.into(); + let opts = request.public_key; + + let rp_id = self + .rp_id_verifier + .assert_domain(&origin, opts.rp.id.as_deref()) + .await?; + let rp_id = rp_id.to_owned(); + + let collected_client_data = CollectedClientData:: { + ty: ClientDataType::Create, + challenge: encoding::base64url(&opts.challenge), + origin: origin.to_string(), + cross_origin: None, + extra_data: client_data.extra_client_data(), + unknown_keys: Default::default(), + }; + let client_data_json = serde_json::to_string(&collected_client_data) + .map_err(|_| WebauthnError::SerializationError)?; + let client_data_hash = client_data + .client_data_hash() + .unwrap_or_else(|| sha256(client_data_json.as_bytes()).to_vec()); + + let pub_key_cred_params = if opts.pub_key_cred_params.is_empty() { + PublicKeyCredentialParameters::default_algorithms() + } else { + opts.pub_key_cred_params + }; + + let uv_requirement = opts + .authenticator_selection + .as_ref() + .map(|s| s.user_verification) + .unwrap_or_default(); + let uv = self.ctap_uv_option(uv_requirement); + + let selected_devices = self.get_valid_devices(uv, &rp_id).await?; + + let mut candidates: Vec<( + Arc, + ctap2::make_credential::Request, + )> = Vec::with_capacity(self.devices.len()); + + for device in selected_devices { + let info = device.authenticator.info(); + + let rk = map_rk(opts.authenticator_selection.as_ref(), &info); + if rk && !device.authenticator.rk_supported() { + // RP requires a resident key but this device can't store one. + continue; + } + if uv + && !(device.authenticator.uv_configured() || device.authenticator.pin_configured()) + { + // UV is required, but no UV method is configured for this device. + continue; + } + + let matched_params = filter_algorithms(&pub_key_cred_params, &info); + if matched_params.is_empty() { + // None of the algorithms requested by the RP are supported by this authenticator. + continue; + } + + let (pin_auth, pin_protocol) = + self.get_pin_auth_and_protocol(device, client_data_hash.as_slice()); + let request = ctap2::make_credential::Request { + client_data_hash: client_data_hash.clone().into(), + rp: ctap2::make_credential::PublicKeyCredentialRpEntity { + id: rp_id.clone(), + name: Some(opts.rp.name.clone()), + }, + user: opts.user.clone(), + pub_key_cred_params: matched_params, + exclude_list: opts.exclude_credentials.clone(), + extensions: None, + options: ctap2::make_credential::Options { rk, up: false, uv }, + pin_auth, + pin_protocol, + }; + *device.token_with_protocol.lock().unwrap() = None; + candidates.push((Arc::clone(device), request)); + } + + if candidates.is_empty() { + return Err(WebauthnError::NotSupportedError); + } + + let ctap2_response = race_make_credential(candidates).await?; + + let credential_id = ctap2_response + .auth_data + .attested_credential_data + .as_ref() + .ok_or(WebauthnError::AuthenticatorError(0x7F))?; + let alg = match credential_id.key.alg.as_ref() { + Some(Algorithm::PrivateUse(val)) => *val, + Some(Algorithm::Assigned(a)) => EnumI64::to_i64(a), + // In the case that the algorithm is unknown, default to 0 (Reserved) + _ => 0, + }; + let public_key = Some( + public_key_der_from_cose_key(&credential_id.key) + .map_err(|e| WebauthnError::AuthenticatorError(e.into()))?, + ); + let attestation_object = ctap2_response.as_webauthn_bytes(); + + Ok(CreatedPublicKeyCredential { + id: encoding::base64url(credential_id.credential_id()), + raw_id: credential_id.credential_id().to_vec().into(), + ty: PublicKeyCredentialType::PublicKey, + response: AuthenticatorAttestationResponse { + client_data_json: Vec::from(client_data_json).into(), + authenticator_data: ctap2_response.auth_data.to_vec().into(), + public_key, + public_key_algorithm: alg, + attestation_object, + // Every `LinuxAuthenticator` uses the Usb transport. + transports: Some(vec![webauthn::AuthenticatorTransport::Usb]), + }, + authenticator_attachment: Some(AuthenticatorAttachment::CrossPlatform), + client_extension_results: Default::default(), + }) + } + + /// Get assertion for a credential by racing every connected security key. + pub async fn authenticate, E: Serialize + Clone>( + &self, + origin: impl Into>, + request: CredentialRequestOptions, + client_data: D, + ) -> Result { + let origin = origin.into(); + let opts = request.public_key; + + let rp_id = self + .rp_id_verifier + .assert_domain(&origin, opts.rp_id.as_deref()) + .await?; + let rp_id = rp_id.to_owned(); + + let collected_client_data = CollectedClientData:: { + ty: ClientDataType::Get, + challenge: encoding::base64url(&opts.challenge), + origin: origin.to_string(), + cross_origin: None, + extra_data: client_data.extra_client_data(), + unknown_keys: Default::default(), + }; + let client_data_json = serde_json::to_string(&collected_client_data) + .map_err(|_| WebauthnError::SerializationError)?; + let client_data_hash = client_data + .client_data_hash() + .unwrap_or_else(|| sha256(client_data_json.as_bytes()).to_vec()); + + let uv = self.ctap_uv_option(opts.user_verification); + + let selected_devices = self.get_valid_devices(uv, &rp_id).await?; + + let mut candidates: Vec<( + Arc, + ctap2::get_assertion::Request, + )> = Vec::with_capacity(self.devices.len()); + + for device in selected_devices { + if uv + && !(device.authenticator.uv_configured() || device.authenticator.pin_configured()) + { + continue; + } + + let (pin_auth, pin_protocol) = + self.get_pin_auth_and_protocol(device, client_data_hash.as_slice()); + let request = ctap2::get_assertion::Request { + rp_id: rp_id.clone(), + client_data_hash: client_data_hash.clone().into(), + allow_list: opts.allow_credentials.clone(), + extensions: None, + options: ctap2::get_assertion::Options { up: true, uv }, + pin_auth, + pin_protocol, + }; + *device.token_with_protocol.lock().unwrap() = None; + candidates.push((Arc::clone(device), request)); + } + + if candidates.is_empty() { + return Err(WebauthnError::NotSupportedError); + } + + let ctap2_response = race_get_assertion(candidates).await?; + + let credential_id_bytes = match ctap2_response.credential { + Some(c) => c.id.to_vec(), + None => return Err(WebauthnError::CredentialNotFound), + }; + Ok(AuthenticatedPublicKeyCredential { + id: encoding::base64url(&credential_id_bytes), + raw_id: credential_id_bytes.into(), + ty: PublicKeyCredentialType::PublicKey, + response: AuthenticatorAssertionResponse { + client_data_json: Vec::from(client_data_json).into(), + authenticator_data: ctap2_response.auth_data.to_vec().into(), + signature: ctap2_response.signature, + user_handle: ctap2_response.user.map(|user| user.id), + attestation_object: None, + }, + authenticator_attachment: Some(AuthenticatorAttachment::CrossPlatform), + client_extension_results: Default::default(), + }) + } + + fn ctap_uv_option(&self, requirement: UserVerificationRequirement) -> bool { + match requirement { + UserVerificationRequirement::Discouraged => false, + UserVerificationRequirement::Required => true, + UserVerificationRequirement::Preferred => self.uv_when_preferred, + } + } + + /// Race `authenticator_selection` across all candidates while running the platform's + /// selection prompt concurrently. Returns `NotSupportedError` if the prompt is cancelled. + async fn race_authenticator_selection( + &self, + candidates: Vec>, + ) -> Result { + let (tx, mut rx) = mpsc::channel(candidates.len().max(1)); + + for (idx, auth) in candidates.clone().into_iter().enumerate() { + let tx = tx.clone(); + tokio::spawn(async move { + let result = auth.authenticator.authenticator_selection().await; + let _ = tx.send((idx, result)).await; + }); + } + drop(tx); + + let mut prompt_future = self + .pin_prompt + .prompt_authenticator_selection(candidates.len()); + let mut prompt_done = false; + + let mut winner_idx: Option = None; + let mut last_error: Option = None; + + loop { + tokio::select! { + maybe_msg = rx.recv() => { + match maybe_msg { + Some((idx, Ok(()))) => { + winner_idx = Some(idx); + break; + } + Some((_, Err(sc))) => { + last_error = Some(WebauthnError::from(sc)); + } + None => break, + } + } + // The `if !prompt_done` guard prevents the branch from being polled after it has + // returned. + result = &mut prompt_future, if !prompt_done => { + prompt_done = true; + if result.is_err() { + cancel_losers(&candidates, None).await; + return Err(WebauthnError::NotSupportedError); + } + } + } + } + + cancel_losers(&candidates, winner_idx).await; + + if let Some(idx) = winner_idx { + Ok(idx) + } else { + Err(last_error.unwrap_or(WebauthnError::NotSupportedError)) + } + } +} + +/// Copy of `Client::map_rk`. +// TODO: Should that method be moved out of `Client`? It's pretty much an exact duplicate. +fn map_rk( + sel: Option<&webauthn::AuthenticatorSelectionCriteria>, + info: &ctap2::get_info::Response, +) -> bool { + let supports_rk = info.options.as_ref().is_some_and(|o| o.rk); + match sel.unwrap_or(&Default::default()) { + webauthn::AuthenticatorSelectionCriteria { + resident_key: Some(ResidentKeyRequirement::Required), + .. + } => true, + webauthn::AuthenticatorSelectionCriteria { + resident_key: Some(ResidentKeyRequirement::Preferred), + .. + } => supports_rk, + webauthn::AuthenticatorSelectionCriteria { + resident_key: Some(ResidentKeyRequirement::Discouraged), + .. + } => false, + webauthn::AuthenticatorSelectionCriteria { + resident_key: None, + require_resident_key, + .. + } => *require_resident_key, + } +} + +/// Keep only the algorithms the device's `get_info` advertises (if it advertises any). The +/// algorithms field is optional, so if no algorithms are advertised then just return the request +/// unmodified. +fn filter_algorithms( + requested: &[PublicKeyCredentialParameters], + info: &ctap2::get_info::Response, +) -> Vec { + let Some(device_algs) = info.algorithms.as_ref() else { + return requested.to_vec(); + }; + + requested + .iter() + .filter(|p| device_algs.iter().any(|d| d.alg == p.alg)) + .copied() + .collect() +} + +/// Race `make_credential` across all candidates and cancel the losers once a +/// winner has been found. +async fn race_make_credential( + candidates: Vec<( + Arc, + ctap2::make_credential::Request, + )>, +) -> Result { + let (tx, mut rx) = mpsc::channel(candidates.len().max(1)); + let auths: Vec> = + candidates.iter().map(|(a, _)| Arc::clone(a)).collect(); + + for (idx, (auth, request)) in candidates.into_iter().enumerate() { + let tx = tx.clone(); + tokio::spawn(async move { + let result = auth.authenticator.make_credential(request).await; + // Channel may be closed if a winner already emerged — ignore. + let _ = tx.send((idx, result)).await; + }); + } + drop(tx); + + let mut winner_idx: Option = None; + let mut winner_response: Option = None; + let mut last_error: Option = None; + while let Some((idx, result)) = rx.recv().await { + match result { + Ok(resp) => { + winner_idx = Some(idx); + winner_response = Some(resp); + break; + } + Err(sc) => last_error = Some(WebauthnError::from(sc)), + } + } + + cancel_losers(&auths, winner_idx).await; + + winner_response.ok_or(last_error.unwrap_or(WebauthnError::NotSupportedError)) +} + +/// Race `get_assertion` across all candidates, similar to `race_make_credential`. +async fn race_get_assertion( + candidates: Vec<( + Arc, + ctap2::get_assertion::Request, + )>, +) -> Result { + let (tx, mut rx) = mpsc::channel(candidates.len().max(1)); + let auths: Vec> = + candidates.iter().map(|(a, _)| Arc::clone(a)).collect(); + + for (idx, (auth, request)) in candidates.into_iter().enumerate() { + let tx = tx.clone(); + tokio::spawn(async move { + let result = auth.authenticator.get_assertion(request).await; + let _ = tx.send((idx, result)).await; + }); + } + drop(tx); + + let mut winner_idx: Option = None; + let mut winner_response: Option = None; + let mut all_no_credentials = true; + let mut last_other_error: Option = None; + while let Some((idx, result)) = rx.recv().await { + match result { + Ok(resp) => { + winner_idx = Some(idx); + winner_response = Some(resp); + break; + } + Err(sc) => { + let err = WebauthnError::from(sc); + if !matches!(err, WebauthnError::CredentialNotFound) { + all_no_credentials = false; + last_other_error = Some(err); + } + } + } + } + + cancel_losers(&auths, winner_idx).await; + + if let Some(resp) = winner_response { + Ok(resp) + } else if all_no_credentials { + Err(WebauthnError::CredentialNotFound) + } else { + Err(last_other_error.unwrap_or(WebauthnError::NotSupportedError)) + } +} + +/// Send `CTAPHID_CANCEL` to every authenticator except the one at `winner_idx`. Passing `None` +/// cancels all of them — used when nobody won (timeout/prompt-cancel/all-errors). +async fn cancel_losers(auths: &[Arc], winner_idx: Option) { + for (i, auth) in auths.iter().enumerate() { + if Some(i) != winner_idx { + let _ = auth.authenticator.cancel().await; + } + } +} diff --git a/passkey-client/src/test.rs b/passkey-client/src/test.rs new file mode 100644 index 0000000..b986085 --- /dev/null +++ b/passkey-client/src/test.rs @@ -0,0 +1,90 @@ +use passkey_authenticator::{Authenticator, MemoryStore, MockUserValidationMethod}; +use passkey_types::{ + ctap2, + webauthn::{ + AuthenticatorSelectionCriteria, ResidentKeyRequirement, UserVerificationRequirement, + }, +}; + +use crate::Client; + +#[test] +fn map_rk_maps_criteria_to_rk_bool() { + #[derive(Debug)] + struct TestCase { + resident_key: Option, + require_resident_key: bool, + expected_rk: bool, + } + + let test_cases = vec![ + // require_resident_key fallbacks + TestCase { + resident_key: None, + require_resident_key: false, + expected_rk: false, + }, + TestCase { + resident_key: None, + require_resident_key: true, + expected_rk: true, + }, + // resident_key values + TestCase { + resident_key: Some(ResidentKeyRequirement::Discouraged), + require_resident_key: false, + expected_rk: false, + }, + TestCase { + resident_key: Some(ResidentKeyRequirement::Preferred), + require_resident_key: false, + expected_rk: true, + }, + TestCase { + resident_key: Some(ResidentKeyRequirement::Required), + require_resident_key: false, + expected_rk: true, + }, + // resident_key overrides require_resident_key + TestCase { + resident_key: Some(ResidentKeyRequirement::Discouraged), + require_resident_key: true, + expected_rk: false, + }, + ]; + + for test_case in test_cases { + let criteria = AuthenticatorSelectionCriteria { + resident_key: test_case.resident_key, + require_resident_key: test_case.require_resident_key, + user_verification: UserVerificationRequirement::Discouraged, + authenticator_attachment: None, + }; + let auth_info = ctap2::get_info::Response { + versions: vec![], + extensions: None, + aaguid: ctap2::Aaguid::new_empty(), + options: Some(ctap2::get_info::Options { + rk: true, + uv: Some(true), + up: true, + plat: true, + client_pin: None, + ..Default::default() + }), + max_msg_size: None, + pin_protocols: None, + transports: None, + ..Default::default() + }; + let client = Client::new(Authenticator::new( + ctap2::Aaguid::new_empty(), + MemoryStore::new(), + MockUserValidationMethod::verified_user(0), + )); + + let result = client.map_rk(&Some(criteria), &auth_info); + + assert_eq!(result, test_case.expected_rk, "{test_case:?}"); + } +} diff --git a/passkey-transports/Cargo.toml b/passkey-transports/Cargo.toml index c7bbdea..74233f7 100644 --- a/passkey-transports/Cargo.toml +++ b/passkey-transports/Cargo.toml @@ -16,6 +16,18 @@ version = "0.1.0" [lints] workspace = true +[features] +default = [] +linux = [] + [dependencies] +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" +nix = { version = "0.29", features = ["fs", "ioctl"] } +tokio = { version = "1", features = ["net", "rt", "sync", "macros", "time"] } +udev = "0.9" +hidparser = "1.0" +rand = { version = "0.10", features = ["chacha"] } + [dev-dependencies] diff --git a/passkey-transports/src/hid.rs b/passkey-transports/src/hid.rs index 0d24aea..7600cc9 100644 --- a/passkey-transports/src/hid.rs +++ b/passkey-transports/src/hid.rs @@ -121,7 +121,7 @@ impl Command { } } /// a CTAP2 HID packet can be at most 64 bytes, bigger messages are broken up using Continuation Packets -const MAX_PACKET_SIZE: usize = 64; +pub const MAX_PACKET_SIZE: usize = 64; /// Initialization Packet header /// @@ -275,14 +275,6 @@ impl PacketHeader { } } } - - /// Get the length of the header size - const fn len(&self) -> usize { - match self { - PacketHeader::Initialization(_) => InitHeader::HEADER_SIZE, - PacketHeader::Continuation(_) => ContHeader::HEADER_SIZE, - } - } } /// A complete CTAP2 message, which is built from one or many packets. @@ -343,23 +335,30 @@ impl Message { /// Send a message to the client by breaking it up into CTAP2 HID packets and sending them in sequence. pub fn send(self, writer: &mut W) -> Result<(), std::io::Error> { - let packets = self.to_packets(); - let mut buf = [0; MAX_PACKET_SIZE]; - let num_packets = packets.len() - 1; - for (i, (header, data)) in packets.into_iter().enumerate() { - // if last packet zero bytes that will not be written to - if i == num_packets { - let data_len = header.len() + data.len(); - buf[data_len..].iter_mut().for_each(|b| *b = 0); - } - header.encode(data, &mut buf); - + for buf in self.encode_packets() { let _ = writer.write(&buf)?; writer.flush()?; } Ok(()) } + /// Encode this message as a sequence of fully-padded 64-byte CTAPHID packets. + /// + /// Each returned packet is exactly [`MAX_PACKET_SIZE`] bytes and ready to be written to + /// the transport. Use this when you need to drive the wire encoding yourself (for example + /// from an async writer that cannot use [`Message::send`]). + pub fn encode_packets(&self) -> Vec<[u8; MAX_PACKET_SIZE]> { + let packets = self.to_packets(); + packets + .into_iter() + .map(|(header, data)| { + let mut buf = [0u8; MAX_PACKET_SIZE]; + header.encode(data, &mut buf); + buf + }) + .collect() + } + /// Break up a [Message] into packets which are a tuple of the packet's header and its associated /// payload of appropriate length. /// diff --git a/passkey-transports/src/hidraw.rs b/passkey-transports/src/hidraw.rs new file mode 100644 index 0000000..e6240b1 --- /dev/null +++ b/passkey-transports/src/hidraw.rs @@ -0,0 +1,572 @@ +//! Linux HIDRAW transport for talking CTAPHID to USB security keys. +//! +//! This module enumerates FIDO-capable HID devices via `udev`, then exposes an async wrapper around +//! `/dev/hidrawN` built on `tokio::io::unix::AsyncFd`. The wrapper handles framing CTAPHID +//! [`Message`]s into 64-byte packets, and provides a convenience method for performing a fresh +//! `CTAPHID_INIT` handshake. +//! +//! Callers are expected to drive the CTAP conversation that follows using [`HidDevice::send`] and +//! [`HidDevice::recv`]. + +use std::fs::{File, OpenOptions}; +use std::io; +use std::os::fd::{AsRawFd, OwnedFd}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use hidparser::ReportField; +use rand::Rng; +use rand::rngs::ChaCha20Rng; +use tokio::io::Interest; +use tokio::io::unix::AsyncFd; +use tokio::time::timeout; + +use crate::hid::{ChannelHandler, Command, CreationError, MAX_PACKET_SIZE, Message}; + +/// HID usage page assigned to the FIDO Alliance for U2F / CTAP authenticators. +pub const FIDO_USAGE_PAGE: u16 = 0xF1D0; + +/// CTAPHID broadcast channel identifier used during `CTAPHID_INIT`. +pub const BROADCAST_CID: u32 = 0xFFFF_FFFF; + +/// Maximum size of an HID report descriptor as defined by the Linux kernel +/// (`HID_MAX_DESCRIPTOR_SIZE` in `linux/hid.h`). +const HID_MAX_DESCRIPTOR_SIZE: usize = 4096; + +/// Default timeout for a single packet read while waiting for an authenticator +/// response. The CTAPHID spec mandates `KEEPALIVE` packets every 100 ms while a +/// transaction is in progress, so anything longer than a couple seconds without +/// activity indicates the device has stopped responding. +const PACKET_READ_TIMEOUT: Duration = Duration::from_secs(2); + +/// Information about a discovered FIDO-capable HID device. +#[derive(Debug, Clone)] +pub struct DeviceInfo { + /// Path to the `/dev/hidrawN` device file. + pub path: PathBuf, + /// USB vendor identifier, if available. + pub vendor_id: Option, + /// USB product identifier, if available. + pub product_id: Option, + /// Human-readable device name reported by the HID descriptor, if available. + pub name: Option, +} + +/// Errors that may occur while talking to a HIDRAW device. +#[derive(Debug)] +#[non_exhaustive] +pub enum HidrawError { + /// An I/O error occurred while reading from or writing to the device. + Io(io::Error), + /// A device responded with an unexpected packet. + Protocol(&'static str), + /// A message we tried to send was too large to fit in 128 continuation packets. + MessageTooLarge, + /// The device stopped responding within the read timeout. + Timeout, +} + +impl From for HidrawError { + fn from(e: io::Error) -> Self { + Self::Io(e) + } +} + +impl From for HidrawError { + fn from(_: CreationError) -> Self { + Self::MessageTooLarge + } +} + +impl std::fmt::Display for HidrawError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HidrawError::Io(e) => write!(f, "I/O error: {e}"), + HidrawError::Protocol(s) => write!(f, "protocol error: {s}"), + HidrawError::MessageTooLarge => { + f.write_str("message too large to fit in CTAPHID frame") + } + HidrawError::Timeout => f.write_str("timed out waiting for response"), + } + } +} + +impl std::error::Error for HidrawError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + HidrawError::Io(e) => Some(e), + _ => None, + } + } +} + +/// Enumerate `/dev/hidrawN` devices and return the subset that advertise the FIDO usage +/// page in their HID report descriptor. Devices that cannot be opened (e.g. due to udev +/// permissions) are skipped. +pub fn enumerate_fido_devices() -> io::Result> { + let mut enumerator = udev::Enumerator::new()?; + enumerator.match_subsystem("hidraw")?; + + let mut devices = Vec::new(); + for device in enumerator.scan_devices()? { + let Some(devnode) = device.devnode() else { + continue; + }; + let path = devnode.to_path_buf(); + + let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) else { + // Most likely a permissions issue. Skip rather than surface to the caller. + continue; + }; + + if !device_has_fido_usage(&file).unwrap_or(false) { + continue; + } + + let (vendor_id, product_id, name) = parent_hid_info(&device); + devices.push(DeviceInfo { + path, + vendor_id, + product_id, + name, + }); + } + + Ok(devices) +} + +/// Pull VID/PID/name out of the parent `hid` device for a `hidraw` udev entry. +/// +/// The `HID_ID` property is formatted as `BUS:VID:PID` in hex; the `HID_NAME` property +/// is a free-form human-readable string set by the kernel HID driver. +fn parent_hid_info(device: &udev::Device) -> (Option, Option, Option) { + let Some(parent) = device.parent_with_subsystem("hid").ok().flatten() else { + return (None, None, None); + }; + + let hid_id = parent + .property_value("HID_ID") + .and_then(|s| s.to_str()) + .map(str::to_owned); + let hid_name = parent + .property_value("HID_NAME") + .and_then(|s| s.to_str()) + .map(str::to_owned); + + let (vid, pid) = match hid_id.as_deref() { + Some(s) => { + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() == 3 { + ( + u16::from_str_radix(parts[1], 16).ok(), + u16::from_str_radix(parts[2], 16).ok(), + ) + } else { + (None, None) + } + } + None => (None, None), + }; + + (vid, pid, hid_name) +} + +/// Run the `HIDIOCGRDESC` ioctl against an open HIDRAW fd and look for the FIDO +/// usage page in the returned report descriptor. +fn device_has_fido_usage(file: &File) -> io::Result { + let fd = file.as_raw_fd(); + + let mut size: libc::c_int = 0; + // SAFETY: `HIDIOCGRDESCSIZE` writes a single `int` through the supplied pointer. + // `size` is a `c_int` that outlives the call. + unsafe { ioctls::hidiocgrdescsize(fd, &mut size) }.map_err(io::Error::from)?; + if size <= 0 { + return Ok(false); + } + let Ok(size) = u32::try_from(size) else { + return Ok(false); + }; + let Ok(size_usize) = usize::try_from(size) else { + return Ok(false); + }; + if size_usize > HID_MAX_DESCRIPTOR_SIZE { + return Ok(false); + } + + let mut desc = ioctls::HidrawReportDescriptor { + size, + value: [0u8; HID_MAX_DESCRIPTOR_SIZE], + }; + // SAFETY: `HIDIOCGRDESC` reads `desc.size` bytes into `desc.value`. We initialised + // `desc.size` to the value returned by HIDIOCGRDESCSIZE above and bounded it + // by HID_MAX_DESCRIPTOR_SIZE, so the kernel will not write past the buffer. + unsafe { ioctls::hidiocgrdesc(fd, &mut desc) }.map_err(io::Error::from)?; + + Ok(report_descriptor_has_fido_usage(&desc.value[..size_usize])) +} + +/// Walk an HID report descriptor and return whether it includes a `Usage Page (0xF1D0)` item. +fn report_descriptor_has_fido_usage(desc: &[u8]) -> bool { + let Ok(descriptor) = hidparser::parse_report_descriptor(desc) else { + // TODO: Should this return an error instead? + return false; + }; + if descriptor.input_reports.is_empty() { + return false; + } + for report in descriptor.input_reports { + for field in report.fields { + let ReportField::Variable(v) = field else { + continue; + }; + if v.usage.page() == 0xF1D0 { + return true; + } + } + } + false +} + +mod ioctls { + //! Linux HIDRAW ioctl bindings. See `include/uapi/linux/hidraw.h`. + + use nix::ioctl_read; + + /// `struct hidraw_report_descriptor` from `linux/hidraw.h`. + #[repr(C)] + pub struct HidrawReportDescriptor { + pub size: u32, + pub value: [u8; super::HID_MAX_DESCRIPTOR_SIZE], + } + + // HIDIOCGRDESCSIZE: _IOR('H', 0x01, int) + ioctl_read!(hidiocgrdescsize, b'H', 0x01, libc::c_int); + // HIDIOCGRDESC: _IOR('H', 0x02, struct hidraw_report_descriptor) + ioctl_read!(hidiocgrdesc, b'H', 0x02, HidrawReportDescriptor); +} + +/// An async wrapper around an open `/dev/hidrawN` file descriptor. +/// +/// Use [`HidDevice::open`] to obtain one, then either drive the CTAPHID protocol +/// directly with [`HidDevice::send`] / [`HidDevice::recv`], or call +/// [`HidDevice::init`] once after opening to perform the `CTAPHID_INIT` handshake +/// and obtain a per-application channel identifier. +pub struct HidDevice { + fd: AsyncFd, +} + +impl HidDevice { + /// Open the given `/dev/hidrawN` path with `O_NONBLOCK | O_CLOEXEC` and wrap it + /// in a [`tokio::io::unix::AsyncFd`] so subsequent reads/writes can be awaited. + /// + /// Must be called from within a Tokio runtime so the registration with the I/O + /// driver can succeed. + pub fn open(path: &Path) -> io::Result { + let file = OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NONBLOCK | libc::O_CLOEXEC) + .open(path)?; + let fd: OwnedFd = file.into(); + let fd = AsyncFd::with_interest(fd, Interest::READABLE | Interest::WRITABLE)?; + Ok(Self { fd }) + } + + /// Write a single 64-byte CTAPHID packet to the device. + /// + /// The HIDRAW write interface requires a report-ID prefix byte. FIDO devices do not use + /// numbered reports, so we always prepend `0x00` (as per [the HIDRAW + /// docs](https://docs.kernel.org/hid/hidraw.html)), giving a 65-byte write. + async fn write_packet(&self, packet: &[u8; MAX_PACKET_SIZE]) -> io::Result<()> { + let mut framed = [0u8; MAX_PACKET_SIZE + 1]; + framed[1..].copy_from_slice(packet); + + let mut written = 0; + while written < framed.len() { + let mut guard = self.fd.writable().await?; + let buf = &framed[written..]; + match guard.try_io(|inner| { + // SAFETY: `inner.get_ref()` returns a reference to an owned fd that + // outlives this call. `buf.as_ptr()` and `buf.len()` describe an + // in-bounds region of `framed`. + let n = unsafe { + libc::write( + inner.get_ref().as_raw_fd(), + buf.as_ptr().cast::(), + buf.len(), + ) + }; + if n < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(usize::try_from(n).expect("non-negative isize should fit in usize")) + } + }) { + Ok(Ok(n)) => { + if n == 0 { + return Err(io::Error::new(io::ErrorKind::WriteZero, "write returned 0")); + } + written += n; + } + Ok(Err(e)) => return Err(e), + Err(_would_block) => { + // Re-await the fd. + } + } + } + Ok(()) + } + + /// Read a single 64-byte CTAPHID packet from the device. + /// + /// Returns an error if the device returns fewer than 64 bytes; CTAPHID requires + /// every packet to be exactly that size. + async fn read_packet(&self) -> io::Result<[u8; MAX_PACKET_SIZE]> { + let mut buf = [0u8; MAX_PACKET_SIZE]; + // This loop is necessary in the case that try_io fails with a WouldBlock erorr. + // try_io should succeed only once, and return the full 64 bytes. + loop { + let mut guard = self.fd.readable().await?; + match guard.try_io(|inner| { + // SAFETY: `inner.get_ref()` returns a reference to an owned fd that + // outlives this call. `buf.as_mut_ptr()` and `buf.len()` describe an + // in-bounds region of `buf`. + let n = unsafe { + libc::read( + inner.get_ref().as_raw_fd(), + buf.as_mut_ptr().cast::(), + buf.len(), + ) + }; + if n < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(usize::try_from(n).unwrap_or(0)) + } + }) { + Ok(Ok(n)) => { + if n != MAX_PACKET_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "HID report had unexpected size: got {n} bytes, expected {MAX_PACKET_SIZE}" + ), + )); + } + return Ok(buf); + } + Ok(Err(e)) => return Err(e), + Err(_would_block) => { + // Re-await fd. + } + } + } + } + + /// Send a CTAPHID [`Message`], breaking it up into wire packets and writing them + /// in sequence. + pub async fn send(&self, message: &Message) -> Result<(), HidrawError> { + // We use `message.encode_packets()` here instead of the `Message::send` implementation + // because (1) `self.fd` doesn't implement `std::io::Write`, and (2) we need to add the + // zero byte at the beginning of each packet for HIDRAW. + for packet in message.encode_packets() { + self.write_packet(&packet).await?; + } + Ok(()) + } + + /// Receive a single CTAPHID [`Message`] on the given channel. + /// + /// `KEEPALIVE` packets are dropped: per CTAPHID, they do not constitute a + /// response and may arrive at least every 100 ms while the authenticator + /// processes a long-running request such as user verification. + pub async fn recv(&self, channel: u32) -> Result { + // Here we use `hid::ChannelHandler`, which uses a hashmap internally to support messages + // being sent on multiple channels. Since we really only care about one channel, we could + // replace this with a simpler implementation that just ignores all packets sent to a + // different channel (which is effectively what we do here). + let mut handler = ChannelHandler::default(); + loop { + let packet = match timeout(PACKET_READ_TIMEOUT, self.read_packet()).await { + Ok(res) => res?, + Err(_) => return Err(HidrawError::Timeout), + }; + + let Some(message) = handler.handle_packet(&packet) else { + continue; + }; + + if message.channel != channel { + // Stale packet for another channel — drop and keep waiting. + continue; + } + if matches!(message.command, Command::KeepAlive) { + continue; + } + return Ok(message); + } + } + + /// Perform the `CTAPHID_INIT` handshake against the device and return the + /// allocated channel identifier. + pub async fn init(&self) -> Result { + let nonce = { + let mut buf = [0u8; 8]; + let mut rng: ChaCha20Rng = rand::make_rng(); + rng.fill_bytes(&mut buf); + buf + }; + + let request = Message::new(BROADCAST_CID, Command::Init, &nonce)?; + self.send(&request).await?; + + let response = self.recv(BROADCAST_CID).await?; + if !matches!(response.command, Command::Init) { + return Err(HidrawError::Protocol( + "unexpected command in CTAPHID_INIT response", + )); + } + // Payload layout: + // 8 bytes nonce + // 4 bytes channel ID + // 1 byte protocol version, + // 1 byte major device version number + // 1 byte minor device version number + // 1 byte build device version number + // 1 byte capabilities flags. + if response.payload.len() < 17 { + return Err(HidrawError::Protocol("short CTAPHID_INIT response payload")); + } + if response.payload[..8] != nonce { + return Err(HidrawError::Protocol( + "CTAPHID_INIT response nonce mismatch", + )); + } + // Multi-byte fields must be specified in little endian order, per the HID specification. + let cid = u32::from_le_bytes([ + response.payload[8], + response.payload[9], + response.payload[10], + response.payload[11], + ]); + Ok(InitResponse { + channel: cid, + protocol_version: response.payload[12], + device_version_major: response.payload[13], + device_version_minor: response.payload[14], + device_version_build: response.payload[15], + capabilities: Capabilities::from_bits(response.payload[16]), + }) + } +} + +/// Successful response payload from `CTAPHID_INIT`. +#[derive(Debug, Clone)] +pub struct InitResponse { + /// The freshly allocated 4-byte channel identifier to use for subsequent transactions. + pub channel: u32, + /// CTAPHID protocol version implemented by the authenticator. + pub protocol_version: u8, + /// Vendor-defined major device version. + pub device_version_major: u8, + /// Vendor-defined minor device version. + pub device_version_minor: u8, + /// Vendor-defined build version. + pub device_version_build: u8, + /// Reported capabilities bit-field; see [`Capabilities`]. + pub capabilities: Capabilities, +} + +/// Capabilities reported in the `CTAPHID_INIT` response. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Capabilities(u8); + +impl Capabilities { + /// Implements the `CTAPHID_WINK` command. + pub const WINK: u8 = 0x01; + /// Implements the `CTAPHID_CBOR` command. + pub const CBOR: u8 = 0x04; + /// Does NOT implement `CTAPHID_MSG` (i.e. no U2F/CTAP1 fallback). + pub const NMSG: u8 = 0x08; + + /// Build a `Capabilities` value from the raw byte in the `CTAPHID_INIT` response. + pub const fn from_bits(bits: u8) -> Self { + Self(bits) + } + + /// Whether the device implements `CTAPHID_CBOR`. + pub const fn supports_cbor(self) -> bool { + self.0 & Self::CBOR != 0 + } + + /// Whether the device implements `CTAPHID_WINK`. + pub const fn supports_wink(self) -> bool { + self.0 & Self::WINK != 0 + } + + /// Whether the device explicitly does NOT implement `CTAPHID_MSG`. + pub const fn no_msg(self) -> bool { + self.0 & Self::NMSG != 0 + } + + /// Raw capability bits, as reported by the authenticator. + pub const fn bits(self) -> u8 { + self.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_fido_usage_in_yubikey_style_descriptor() { + // A minimal HID report descriptor that declares a top-level FIDO collection, + // similar to what a YubiKey reports. + let desc: [u8; 34] = [ + 0x06, 0xD0, 0xF1, // Usage Page (0xF1D0) + 0x09, 0x01, // Usage (0x01) + 0xA1, 0x01, // Collection (Application) + 0x09, 0x20, // Usage (FIDO_USAGE_DATA_IN) + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x00, // Logical Maximum (255) + 0x75, 0x08, // Report Size (8) + 0x95, 0x40, // Report Count (64) + 0x81, 0x02, // Input (Data,Var,Abs) + 0x09, 0x21, // Usage (FIDO_USAGE_DATA_OUT) + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x00, // Logical Maximum (255) + 0x75, 0x08, // Report Size (8) + 0x95, 0x40, // Report Count (64) + 0x91, 0x02, // Output (Data,Var,Abs) + 0xC0, // End Collection + ]; + assert!(report_descriptor_has_fido_usage(&desc)); + } + + #[test] + fn rejects_keyboard_descriptor() { + // A trimmed-down keyboard report descriptor with Usage Page (Generic Desktop). + let desc: [u8; 8] = [ + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x06, // Usage (Keyboard) + 0xA1, 0x01, // Collection (Application) + 0xC0, 0x00, // End collection + padding + ]; + assert!(!report_descriptor_has_fido_usage(&desc)); + } + + #[test] + fn handles_empty_descriptor() { + assert!(!report_descriptor_has_fido_usage(&[])); + } + + #[test] + fn handles_truncated_usage_page_item() { + // 0x06 declares a 2-byte usage page value but only 1 byte follows. + // The parser must not panic. + let desc: [u8; 2] = [0x06, 0xD0]; + assert!(!report_descriptor_has_fido_usage(&desc)); + } +} diff --git a/passkey-transports/src/lib.rs b/passkey-transports/src/lib.rs index beae6ad..7c436ed 100644 --- a/passkey-transports/src/lib.rs +++ b/passkey-transports/src/lib.rs @@ -17,3 +17,7 @@ //! [documentation]: https://img.shields.io/docsrs/passkey-transports/latest?logo=docs.rs&style=flat pub mod hid; + +/// Linux-only HIDRAW transport for talking to USB CTAP2 authenticators. +#[cfg(all(feature = "linux", target_os = "linux"))] +pub mod hidraw; diff --git a/passkey-types/Cargo.toml b/passkey-types/Cargo.toml index ef669d2..811ed1e 100644 --- a/passkey-types/Cargo.toml +++ b/passkey-types/Cargo.toml @@ -18,11 +18,13 @@ workspace = true [features] default = [] serialize_bytes_as_base64_string = [] -testable = ["dep:p256"] +testable = [] typeshare = ["dep:typeshare"] [dependencies] -bitflags = "2" +aes = "0.8" +bitflags = { version = "2", features = ["serde"] } +cbc = "0.1" ciborium = "0.2" data-encoding = "2" hmac = "0.12" @@ -37,11 +39,7 @@ url = { version = "2", features = ["serde"] } zeroize = { version = "1", features = ["zeroize_derive"] } # TODO: investigate rolling our own IANA listings and COSE keys coset = { workspace = true } -p256 = { version = "0.13", features = [ - "arithmetic", - "jwk", - "pem", -], optional = true } +p256 = { version = "0.13", features = ["arithmetic", "ecdh", "jwk", "pem"] } [target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = { version = "0.2", features = ["js"] } diff --git a/passkey-types/src/ctap2.rs b/passkey-types/src/ctap2.rs index 7b7eb55..009b123 100644 --- a/passkey-types/src/ctap2.rs +++ b/passkey-types/src/ctap2.rs @@ -9,9 +9,11 @@ mod attestation_fmt; mod error; mod flags; +pub mod client_pin; pub mod extensions; pub mod get_assertion; pub mod get_info; pub mod make_credential; +pub mod pin_uv_auth_protocol; pub use self::{aaguid::*, attestation_fmt::*, error::*, flags::*}; diff --git a/passkey-types/src/ctap2/client_pin.rs b/passkey-types/src/ctap2/client_pin.rs new file mode 100644 index 0000000..e05333f --- /dev/null +++ b/passkey-types/src/ctap2/client_pin.rs @@ -0,0 +1,107 @@ +//! +use bitflags::bitflags; +use serde::{Deserialize, Serialize}; + +use crate::Bytes; + +serde_workaround! { + /// Request type for the authenticatorClientPin command. + pub struct Request { + /// PIN/UV protocol chosen by the Client. + #[serde(rename = 0x01; default, skip_serializing_if = Option::is_none)] + pub pin_uv_auth_protocol: Option, + + /// The specific action being requested. + #[serde(rename = 0x02)] + pub sub_command: u8, + + /// The platform key-agreement key. + #[serde( + rename = 0x03; + default, + skip_serializing_if = Option::is_none, + serialize_with = crate::utils::serde::cose_key::option::serialize, + deserialize_with = crate::utils::serde::cose_key::option::deserialize + )] + pub key_agreement: Option, + + /// The output of calling PinProtocol::authenticate on some context specific to the subcommand. + #[serde(rename = 0x04; default, skip_serializing_if = Option::is_none)] + pub pin_uv_auth_param: Option, + + /// An encrypted PIN. + #[serde(rename = 0x05; default, skip_serializing_if = Option::is_none)] + pub new_pin_enc: Option, + + /// An encrypted proof-of-knowledge of a PIN. + #[serde(rename = 0x06; default, skip_serializing_if = Option::is_none)] + pub pin_hash_enc: Option, + + /// Bitfield of permissions for the requested auth token. If present, MUST NOT be 0. + #[serde(rename = 0x09; default, skip_serializing_if = Option::is_none)] + pub permissions: Option, + + /// The RP ID to assign as the permissions RP ID. Required for [`Permissions::MC`] and + /// [`Permissions::GA`]. + #[serde(rename = 0x0A; default, skip_serializing_if = Option::is_none)] + pub rp_id: Option, + } +} + +serde_workaround! { + /// authenticatorClientPin response. + #[derive(Default)] + pub struct Response { + /// Result of getPublicKey. + #[serde( + rename = 0x01; + default, + serialize_with = crate::utils::serde::cose_key::option::serialize, + deserialize_with = crate::utils::serde::cose_key::option::deserialize + )] + pub key_agreement: Option, + + /// The pinUvAuthToken, encrypted by calling aes-256-cbc with the shared secret as the key. + #[serde(rename = 0x02; default, skip_serializing_if = Option::is_none)] + pub pin_uv_auth_token: Option, + + /// Number of PIN attempts remaining before lockout. + #[serde(rename = 0x03; default, skip_serializing_if = Option::is_none)] + pub pin_retries: Option, + + /// Present and true if the authenticator requires a power cycle before any future PIN operation, false if no power cycle needed. + #[serde(rename = 0x04; default, skip_serializing_if = Option::is_none)] + pub power_cycle_state: Option, + + /// Number of uv attempts remaining before lockout. + #[serde(rename = 0x05; default, skip_serializing_if = Option::is_none)] + pub uv_retries: Option, + } +} + +bitflags! { + /// Permissions field of `authenticatorClientPin` request. + #[derive(Serialize, Deserialize)] + pub struct Permissions: u8 { + /// MakeCredential + const MC = 0x01; + + /// GetAssertion + const GA = 0x02; + + /// Credential Management + const CM = 0x04; + + /// Bio Enrollment + const BE = 0x08; + + /// Large Blob Write + const LBW = 0x10; + + /// Authenticator Configuration + const ACFG = 0x20; + + /// Persistent Credential Management Read Only + const PCMR = 0x40; + } +} diff --git a/passkey-types/src/ctap2/extensions/hmac_secret/tests.rs b/passkey-types/src/ctap2/extensions/hmac_secret/tests.rs index 839d61c..f316261 100644 --- a/passkey-types/src/ctap2/extensions/hmac_secret/tests.rs +++ b/passkey-types/src/ctap2/extensions/hmac_secret/tests.rs @@ -171,6 +171,7 @@ fn from_correct_cbor() { assert_eq!(salts.as_slice(), &GOOD_SALT1_AND_2); } +#[cfg(not(feature = "serialize_bytes_as_base64_string"))] #[test] fn cbor_round_trip_one_salt() { let key = coset::CoseKeyBuilder::new_ec2_pub_key( @@ -210,6 +211,7 @@ fn cbor_round_trip_one_salt() { one_salt.pin_uv_auth_protocol ); } +#[cfg(not(feature = "serialize_bytes_as_base64_string"))] #[test] fn cbor_round_trip_both_salts() { let key = coset::CoseKeyBuilder::new_ec2_pub_key( diff --git a/passkey-types/src/ctap2/get_assertion.rs b/passkey-types/src/ctap2/get_assertion.rs index fa35dab..9f53d86 100644 --- a/passkey-types/src/ctap2/get_assertion.rs +++ b/passkey-types/src/ctap2/get_assertion.rs @@ -8,8 +8,6 @@ use crate::{ webauthn::{PublicKeyCredentialDescriptor, PublicKeyCredentialUserEntity}, }; -pub use crate::ctap2::make_credential::Options; - #[cfg(doc)] use { crate::webauthn::{CollectedClientData, PublicKeyCredentialRequestOptions}, @@ -68,6 +66,32 @@ serde_workaround! { } } +/// The options that control how an authenticator will behave during authenticatorMakeCredential. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Options { + /// Instructs the authenticator to require a gesture that verifies the user to complete the request. Examples of such gestures are fingerprint scan or a PIN. + #[serde(default = "default_true")] + pub up: bool, + /// User Verification: + /// + /// If the "uv" option is absent, let the "uv" option be treated as being present with the value false. + #[serde(default)] + pub uv: bool, +} + +impl Default for Options { + fn default() -> Self { + Self { + up: true, + uv: false, + } + } +} + +const fn default_true() -> bool { + true +} + /// All supported Authenticator extensions inputs during credential assertion #[derive(Debug, Serialize, Deserialize, Default)] pub struct ExtensionInputs { diff --git a/passkey-types/src/ctap2/make_credential.rs b/passkey-types/src/ctap2/make_credential.rs index fc1a8ba..24b5e51 100644 --- a/passkey-types/src/ctap2/make_credential.rs +++ b/passkey-types/src/ctap2/make_credential.rs @@ -185,14 +185,17 @@ impl TryFrom for PublicKeyCredentialRpEnt } } -/// The options that control how an authenticator will behave. +/// The options that control how an authenticator will behave during authenticatorMakeCredential. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Options { /// Specifies whether this credential is to be discoverable or not. #[serde(default)] pub rk: bool, /// Instructs the authenticator to require a gesture that verifies the user to complete the request. Examples of such gestures are fingerprint scan or a PIN. - #[serde(default = "default_true")] + /// This is marked with `skip_serializing` because CTAP 2.0 does not specify a `up` field in + /// `Options`, while CTAP 2.1 allows the `up` field to be present but requires that its value + /// be set to true if it is present. + #[serde(default = "default_true", skip_serializing)] pub up: bool, /// User Verification: /// diff --git a/passkey-types/src/ctap2/make_credential/tests.rs b/passkey-types/src/ctap2/make_credential/tests.rs index 423ef9a..d983ca3 100644 --- a/passkey-types/src/ctap2/make_credential/tests.rs +++ b/passkey-types/src/ctap2/make_credential/tests.rs @@ -1,6 +1,6 @@ use ciborium::{Value, cbor}; -use crate::{ctap2::get_assertion::Options, webauthn}; +use crate::{ctap2::make_credential::Options, webauthn}; #[test] fn windows_sanity_test() { diff --git a/passkey-types/src/ctap2/pin_uv_auth_protocol.rs b/passkey-types/src/ctap2/pin_uv_auth_protocol.rs new file mode 100644 index 0000000..8ce5569 --- /dev/null +++ b/passkey-types/src/ctap2/pin_uv_auth_protocol.rs @@ -0,0 +1,156 @@ +//! Client-side implementation of the PIN/UV Auth Protocols defined in CTAP2. +//! Currently only implements Protocol One. + +use coset::{ + CoseKey, CoseKeyBuilder, Label, RegisteredLabel, + iana::{Algorithm, Ec2KeyParameter, EllipticCurve, EnumI64, KeyType}, +}; +use p256::{ + EncodedPoint, PublicKey, SecretKey, + ecdh::diffie_hellman, + elliptic_curve::{ + generic_array::GenericArray, + sec1::{FromEncodedPoint, ToEncodedPoint}, + }, +}; +use rand::rngs::OsRng; + +use crate::{ + ctap2::Ctap2Error, + utils::crypto::{aes_256_cbc_decrypt, sha256}, +}; + +/// Client-side state for PIN/UV Auth Protocol One. Holds the client's ephemeral P-256 key agreement +/// key. +pub struct PinUvAuthProtocolOne { + key_agreement: SecretKey, +} + +impl PinUvAuthProtocolOne { + /// Create a new protocol instance with a freshly generated key agreement keypair. + pub fn new() -> Self { + Self { + key_agreement: SecretKey::random(&mut OsRng), + } + } + + /// Replace the key agreement keypair with a fresh one. + pub fn regenerate(&mut self) { + self.key_agreement = SecretKey::random(&mut OsRng); + } + + /// COSE_Key encoding of `xB`. + /// + /// Headers per spec: `kty=EC2(2)`, `alg=-25` ("not the algorithm actually used"), + /// `crv=P-256(1)`, `x` and `y` as 32-byte big-endian coordinates. + pub fn get_public_key(&self) -> CoseKey { + let point = self.key_agreement.public_key().to_encoded_point(false); + // SAFETY: an uncompressed P-256 point always carries both coordinates. + let x = point.x().unwrap().as_slice().to_vec(); + let y = point.y().unwrap().as_slice().to_vec(); + CoseKeyBuilder::new_ec2_pub_key(EllipticCurve::P_256, x, y) + .algorithm(Algorithm::ECDH_ES_HKDF_256) + .build() + } + + /// Returns the client's own public key plus the derived shared secret to + /// send to/use with the authenticator whose public key is `peer`. + pub fn encapsulate(&self, peer: &CoseKey) -> Result<(CoseKey, [u8; 32]), Ctap2Error> { + let shared_secret = self.ecdh(peer)?; + Ok((self.get_public_key(), shared_secret)) + } + + /// AES-256-CBC-Decrypt with a zero IV and no padding. + pub fn decrypt(key: &[u8; 32], ciphertext: &[u8]) -> Vec { + aes_256_cbc_decrypt(*key, [0; 16], ciphertext) + } + + /// 1. Parse `peer` as a P-256 point `Y`; reject if not on the curve. + /// 2. Compute `xY` with the local private key. + /// 3. `Z` = 32-byte big-endian encoding of the x-coordinate of `xY`. + /// 4. Return `kdf(Z) = SHA-256(Z)`. + fn ecdh(&self, peer: &CoseKey) -> Result<[u8; 32], Ctap2Error> { + let peer_public = cose_to_p256_public(peer)?; + // `SharedSecret::raw_secret_bytes()` is exactly Z - the BE x-coord of xY. + let shared = diffie_hellman( + self.key_agreement.to_nonzero_scalar(), + peer_public.as_affine(), + ); + Ok(sha256(shared.raw_secret_bytes().as_slice())) + } +} + +impl Default for PinUvAuthProtocolOne { + fn default() -> Self { + Self::new() + } +} + +/// Parse the COSE_Key shape required by get_public_key into a `p256::PublicKey`. The `alg` header +/// is intentionally ignored: the spec mandates writing `-25` there but explicitly notes it is not +/// the algorithm actually in use. +fn cose_to_p256_public(key: &CoseKey) -> Result { + if !matches!(key.kty, RegisteredLabel::Assigned(KeyType::EC2)) { + return Err(Ctap2Error::InvalidCredential); + } + + let (mut crv, mut x, mut y) = (None, None, None); + for (label, value) in &key.params { + let Label::Int(i) = label else { continue }; + match Ec2KeyParameter::from_i64(*i) { + Some(Ec2KeyParameter::Crv) => { + crv = value + .as_integer() + .and_then(|n| i128::from(n).try_into().ok()); + } + Some(Ec2KeyParameter::X) => x = value.as_bytes(), + Some(Ec2KeyParameter::Y) => y = value.as_bytes(), + _ => {} + } + } + + if crv != Some(EllipticCurve::P_256.to_i64()) { + return Err(Ctap2Error::InvalidCredential); + } + let (Some(x), Some(y)) = (x, y) else { + return Err(Ctap2Error::CborUnexpectedType); + }; + + let point = EncodedPoint::from_affine_coordinates( + GenericArray::from_slice(x), + GenericArray::from_slice(y), + false, + ); + Option::::from(PublicKey::from_encoded_point(&point)) + .ok_or(Ctap2Error::InvalidCredential) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encapsulate_and_decapsulate_agree() { + // Two independent instances stand in for client and authenticator. + let client = PinUvAuthProtocolOne::new(); + let authenticator = PinUvAuthProtocolOne::new(); + + let (client_public, client_shared) = client + .encapsulate(&authenticator.get_public_key()) + .expect("encapsulate succeeds for a valid peer"); + + // Authenticator-side derivation of the same secret via ecdh(clientPublic). + let auth_shared = authenticator + .ecdh(&client_public) + .expect("ecdh succeeds for a valid peer"); + + assert_eq!(client_shared, auth_shared); + } + + #[test] + fn rejects_non_ec2_key() { + let proto = PinUvAuthProtocolOne::new(); + let bogus = CoseKeyBuilder::new_symmetric_key(vec![0; 32]).build(); + assert!(proto.encapsulate(&bogus).is_err()); + } +} diff --git a/passkey-types/src/passkey.rs b/passkey-types/src/passkey.rs index e50aa11..139b5ed 100644 --- a/passkey-types/src/passkey.rs +++ b/passkey-types/src/passkey.rs @@ -221,7 +221,7 @@ impl Debug for Passkey { } /// Supported extensions on a [`Passkey`] -#[derive(Default, Clone, Zeroize, ZeroizeOnDrop)] +#[derive(Debug, Default, Clone, Zeroize, ZeroizeOnDrop)] #[cfg_attr(any(test, feature = "testable"), derive(PartialEq))] pub struct CredentialExtensions { /// Whether the passkey has hmac-secret credentials associated to it diff --git a/passkey-types/src/utils/crypto.rs b/passkey-types/src/utils/crypto.rs index 1e8a0e9..a8b8e15 100644 --- a/passkey-types/src/utils/crypto.rs +++ b/passkey-types/src/utils/crypto.rs @@ -1,5 +1,7 @@ //! Collection of common cryptography primitives used in serialization of types. +use aes::Aes256; +use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit, generic_array::GenericArray}; use hmac::{Hmac, Mac}; use sha2::{Digest, Sha256}; @@ -16,3 +18,97 @@ pub fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] { mac.finalize().into_bytes().into() } + +const AES_BLOCK_SIZE: usize = 16; + +/// AES-256-CBC encryption of `data` under `key` with the given `iv`, with no padding. +pub fn aes_256_cbc(key: [u8; 32], iv: [u8; 16], data: &[u8]) -> Vec { + assert!( + data.len() % AES_BLOCK_SIZE == 0, + "plaintext length must be a multiple of the AES block size", + ); + let mut cipher = cbc::Encryptor::::new( + GenericArray::from_slice(&key), + GenericArray::from_slice(&iv), + ); + let mut out = vec![0u8; data.len()]; + for (in_chunk, out_chunk) in data + .chunks_exact(AES_BLOCK_SIZE) + .zip(out.chunks_exact_mut(AES_BLOCK_SIZE)) + { + cipher.encrypt_block_b2b_mut( + GenericArray::from_slice(in_chunk), + GenericArray::from_mut_slice(out_chunk), + ); + } + out +} + +/// AES-256-CBC decryption of `data` under `key` with the given `iv`, with no padding. +/// Mirror of [`aes_256_cbc`]. +pub fn aes_256_cbc_decrypt(key: [u8; 32], iv: [u8; 16], data: &[u8]) -> Vec { + assert!( + data.len() % AES_BLOCK_SIZE == 0, + "ciphertext length must be a multiple of the AES block size", + ); + let mut cipher = cbc::Decryptor::::new( + GenericArray::from_slice(&key), + GenericArray::from_slice(&iv), + ); + let mut out = vec![0u8; data.len()]; + for (in_chunk, out_chunk) in data + .chunks_exact(AES_BLOCK_SIZE) + .zip(out.chunks_exact_mut(AES_BLOCK_SIZE)) + { + cipher.decrypt_block_b2b_mut( + GenericArray::from_slice(in_chunk), + GenericArray::from_mut_slice(out_chunk), + ); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aes_256_cbc_zero_iv_roundtrip_via_decrypt() { + use aes::cipher::BlockDecryptMut; + + let key = [0x42u8; 32]; + let iv = [0u8; AES_BLOCK_SIZE]; + let plaintext = b"plaintext_b1_!!!plaintext_b2_!!!"; // 32 bytes, two blocks + assert_eq!(plaintext.len(), 32); + + let ct = aes_256_cbc(key, iv, plaintext); + assert_eq!(ct.len(), plaintext.len()); + assert_ne!(ct.as_slice(), plaintext.as_slice()); + // CBC chains — identical plaintext blocks must NOT yield identical + // ciphertext blocks under a non-zero IV-chained encryption. + assert_ne!(&ct[..AES_BLOCK_SIZE], &ct[AES_BLOCK_SIZE..]); + + // Decrypt with the symmetric primitive to confirm correctness. + let mut cipher = cbc::Decryptor::::new( + GenericArray::from_slice(&key), + GenericArray::from_slice(&iv), + ); + let mut pt = vec![0u8; ct.len()]; + for (i, o) in ct + .chunks_exact(AES_BLOCK_SIZE) + .zip(pt.chunks_exact_mut(AES_BLOCK_SIZE)) + { + cipher.decrypt_block_b2b_mut( + GenericArray::from_slice(i), + GenericArray::from_mut_slice(o), + ); + } + assert_eq!(pt.as_slice(), plaintext.as_slice()); + } + + #[test] + fn aes_256_cbc_empty_input() { + let ct = aes_256_cbc([0u8; 32], [0u8; AES_BLOCK_SIZE], &[]); + assert!(ct.is_empty()); + } +} diff --git a/passkey-types/src/utils/serde.rs b/passkey-types/src/utils/serde.rs index 6b40f84..4fa0fb3 100644 --- a/passkey-types/src/utils/serde.rs +++ b/passkey-types/src/utils/serde.rs @@ -95,9 +95,6 @@ where } pub mod i64_to_iana { - use super::StringOrNum; - use std::marker::PhantomData; - use coset::iana::EnumI64; pub fn serialize(value: &T, ser: S) -> Result @@ -107,20 +104,61 @@ pub mod i64_to_iana { { ser.serialize_i64(value.to_i64()) } +} + +/// Adapters for `serde_workaround` to (de)serialize a `coset::CoseKey` as an inline CBOR map, +/// the wire form used by CTAP2 (e.g. authenticatorClientPIN `keyAgreement`). +pub mod cose_key { + use ciborium::value::Value; + use coset::AsCborValue; + + pub fn serialize(value: &coset::CoseKey, ser: S) -> Result + where + S: serde::Serializer, + { + let cbor = value + .clone() + .to_cbor_value() + .map_err(serde::ser::Error::custom)?; + serde::Serialize::serialize(&cbor, ser) + } - pub fn deserialize<'de, D, T>(de: D) -> Result + #[allow(dead_code)] + pub fn deserialize<'de, D>(de: D) -> Result where D: serde::Deserializer<'de>, - T: EnumI64, { - let value: i64 = de.deserialize_any(StringOrNum(PhantomData))?; + let value = ::deserialize(de)?; + coset::CoseKey::from_cbor_value(value).map_err(serde::de::Error::custom) + } - T::from_i64(value).ok_or_else(|| { - ::invalid_value( - serde::de::Unexpected::Signed(value), - &"An iana::Algorithm value", - ) - }) + pub mod option { + use ciborium::value::Value; + use coset::AsCborValue; + + pub fn serialize(value: &Option, ser: S) -> Result + where + S: serde::Serializer, + { + match value { + Some(v) => super::serialize(v, ser), + None => ser.serialize_none(), + } + } + + pub fn deserialize<'de, D>(de: D) -> Result, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = ::deserialize(de)?; + if matches!(value, Value::Null) { + Ok(None) + } else { + coset::CoseKey::from_cbor_value(value) + .map(Some) + .map_err(serde::de::Error::custom) + } + } } } @@ -238,6 +276,17 @@ where .map(Some) } +/// Deserializes an i64 that may be stringified (e.g., "-7" instead of -7). +/// Returns `Ok(None)` if parsing fails, allowing the caller to handle unknown values gracefully. +pub(crate) fn maybe_stringified_i64<'de, D>(de: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Ok(de + .deserialize_any(StringOrNum(std::marker::PhantomData)) + .ok()) +} + struct StringOrBool; impl Visitor<'_> for StringOrBool { diff --git a/passkey-types/src/utils/serde_workaround.rs b/passkey-types/src/utils/serde_workaround.rs index 1794e26..0a4640a 100644 --- a/passkey-types/src/utils/serde_workaround.rs +++ b/passkey-types/src/utils/serde_workaround.rs @@ -11,7 +11,7 @@ macro_rules! serde_workaround { $(#[$attr:meta])* pub struct $name:ident {$( $(#[doc=$doc:literal])* - #[serde(rename = $discriminant:literal$(;$default:ident)?$(,skip_serializing_if = $method:path)?$(,deserialize_with = $de:path)?)] + #[serde(rename = $discriminant:literal$(;$default:ident)?$(,skip_serializing_if = $method:path)?$(,serialize_with = $ser:path)?$(,deserialize_with = $de:path)?)] $vis:vis $field:ident: $ty:ty, )*} ) => { @@ -59,7 +59,7 @@ macro_rules! serde_workaround { { let mut serde_state = serde::Serializer::serialize_map(serializer, Some(struct_len(&self)))?; $( - serde_serialize_entry!{serde_state; self.$field $(;$method)?} + serde_serialize_entry!{serde_state; self.$field $(;skip = $method)? $(;ser = $ser, $ty)?} )* serde_state.end() } @@ -171,20 +171,39 @@ macro_rules! serde_workaround { } macro_rules! serde_workaround_struct_len { - ($field:expr; $skip_if:path) => { + ($field:expr_2021; $skip_if:path) => { if $skip_if(&$field) { 0 } else { 1 } }; - ($field:expr ) => { + ($field:expr_2021 ) => { 1 }; } macro_rules! serde_serialize_entry { - ($state:ident; $self:ident.$field:ident; $skip_if:path) => { + ($state:ident; $self:ident.$field:ident; skip = $skip_if:path; ser = $ser_with:path, $ty:ty) => { + if !$skip_if(&$self.$field) { + serde_serialize_entry!($state; $self.$field; ser = $ser_with, $ty) + } + }; + ($state:ident; $self:ident.$field:ident; skip = $skip_if:path) => { if !$skip_if(&$self.$field) { serde_serialize_entry!($state; $self.$field) } }; + ($state:ident; $self:ident.$field:ident; ser = $ser_with:path, $ty:ty) => {{ + struct __SerializeWith<'__a> { + value: &'__a $ty, + } + impl<'__a> ::serde::Serialize for __SerializeWith<'__a> { + fn serialize(&self, serializer: S) -> Result + where + S: ::serde::Serializer, + { + $ser_with(self.value, serializer) + } + } + $state.serialize_entry(&Ident::$field, &__SerializeWith { value: &$self.$field })? + }}; ($state:ident; $self:ident.$field:ident) => { $state.serialize_entry(&Ident::$field, &$self.$field)? }; diff --git a/passkey-types/src/webauthn.rs b/passkey-types/src/webauthn.rs index 047fba2..a3785a7 100644 --- a/passkey-types/src/webauthn.rs +++ b/passkey-types/src/webauthn.rs @@ -55,7 +55,7 @@ pub struct PublicKeyCredential { /// authenticators. /// /// > NOTE: This API does not constrain the format or length of this identifier, except that it - /// MUST be sufficient for the authenticator to uniquely select a key. + /// > MUST be sufficient for the authenticator to uniquely select a key. pub id: String, /// The raw byte containing the credential ID, see [Self::id] for more information. diff --git a/passkey-types/src/webauthn/attestation.rs b/passkey-types/src/webauthn/attestation.rs index e853bb4..2e7a2d3 100644 --- a/passkey-types/src/webauthn/attestation.rs +++ b/passkey-types/src/webauthn/attestation.rs @@ -1,6 +1,7 @@ //! Types specific to public key credential creation -use coset::iana; +use coset::iana::{self, EnumI64}; use indexmap::IndexMap; +use serde::de::Error; use serde::{Deserialize, Serialize, Serializer}; use std::fmt; #[cfg(feature = "typeshare")] @@ -10,7 +11,7 @@ use crate::{ Bytes, utils::serde::{ i64_to_iana, ignore_unknown, ignore_unknown_opt_vec, ignore_unknown_vec, - maybe_stringified_bool, maybe_stringified_num, + maybe_stringified_bool, maybe_stringified_i64, maybe_stringified_num, }, webauthn::{ AuthenticationExtensionsClientInputs, AuthenticatorAttachment, AuthenticatorTransport, @@ -275,13 +276,13 @@ pub struct PublicKeyCredentialUserEntity { /// This type is used to supply additional parameters when creating a new credential. /// /// -#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Serialize, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "typeshare", typeshare)] pub struct PublicKeyCredentialParameters { /// This member specifies the type of credential to be created. The value SHOULD be a member of /// [`PublicKeyCredentialType`] but client platforms MUST ignore unknown values, ignoring any /// [`PublicKeyCredentialParameters`] with an [`PublicKeyCredentialType::Unknown`] type. - #[serde(rename = "type", deserialize_with = "ignore_unknown")] + #[serde(rename = "type")] pub ty: PublicKeyCredentialType, /// This member specifies the cryptographic signature algorithm with which the newly generated @@ -297,6 +298,36 @@ pub struct PublicKeyCredentialParameters { pub alg: iana::Algorithm, } +/// Intermediate type for deserializing [`PublicKeyCredentialParameters`]. +/// +/// This ensures the entire JSON object is consumed before validation fails, +/// which is necessary for `ignore_unknown_vec` to work correctly with streaming +/// JSON parsers when unknown algorithm values are encountered. +#[derive(Deserialize)] +struct PublicKeyCredentialParametersRaw { + #[serde(rename = "type", deserialize_with = "ignore_unknown")] + ty: PublicKeyCredentialType, + #[serde(deserialize_with = "maybe_stringified_i64")] + alg: Option, +} + +impl<'de> Deserialize<'de> for PublicKeyCredentialParameters { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = PublicKeyCredentialParametersRaw::deserialize(deserializer)?; + let alg_value = raw.alg.ok_or_else(|| D::Error::missing_field("alg"))?; + let alg = iana::Algorithm::from_i64(alg_value).ok_or_else(|| { + D::Error::invalid_value( + serde::de::Unexpected::Signed(alg_value), + &"a known IANA COSE algorithm identifier", + ) + })?; + Ok(PublicKeyCredentialParameters { ty: raw.ty, alg }) + } +} + impl PublicKeyCredentialParameters { /// Create an array with the default algorithms in case /// [`PublicKeyCredentialCreationOptions::pub_key_cred_params`] comes in empty. diff --git a/passkey-types/src/webauthn/attestation/tests.rs b/passkey-types/src/webauthn/attestation/tests.rs index 8637301..b52a833 100644 --- a/passkey-types/src/webauthn/attestation/tests.rs +++ b/passkey-types/src/webauthn/attestation/tests.rs @@ -365,6 +365,75 @@ fn float_as_timeout() { assert_eq!(deserialized.timeout, Some(1800000)); } +#[test] +fn ebay_registration_stringified_algs_with_unknown() { + // Ebay on Android sends stringified algorithm values AND an unknown -1 algorithm + let request = r#"{ + "publicKey": { + "attestation": "direct", + "authenticatorSelection": { + "authenticatorAttachment": "platform", + "requireResidentKey": true, + "userVerification": "required" + }, + "challenge": "dGVzdA==", + "pubKeyCredParams": [ + { "alg": "-7", "type": "public-key" }, + { "alg": "-35", "type": "public-key" }, + { "alg": "-36", "type": "public-key" }, + { "alg": "-257", "type": "public-key" }, + { "alg": "-258", "type": "public-key" }, + { "alg": "-259", "type": "public-key" }, + { "alg": "-37", "type": "public-key" }, + { "alg": "-38", "type": "public-key" }, + { "alg": "-39", "type": "public-key" }, + { "alg": "-1", "type": "public-key" } + ], + "rp": { + "id": "ebay.com", + "name": "ebay.com" + }, + "user": { + "displayName": "test@test.com", + "id": "dGVzdA==", + "name": "test@test.com" + } + } + }"#; + + let deserialized = + serde_json::from_str::(request).expect("Failed to deserialize"); + // There are 10 in the JSON but we should be ignoring the `alg: "-1"` (unknown, stringified) + assert_eq!(deserialized.public_key.pub_key_cred_params.len(), 9); +} + +#[test] +fn stringified_known_algs_only() { + let request = r#"{ + "publicKey": { + "attestation": "direct", + "challenge": "dGVzdA==", + "pubKeyCredParams": [ + { "alg": "-7", "type": "public-key" }, + { "alg": "-257", "type": "public-key" } + ], + "rp": { + "id": "test.com", + "name": "test.com" + }, + "user": { + "displayName": "test@test.com", + "id": "dGVzdA==", + "name": "test@test.com" + } + } + }"#; + + let deserialized = + serde_json::from_str::(request).expect("Failed to deserialize"); + assert_eq!(deserialized.public_key.pub_key_cred_params.len(), 2); +} + #[test] fn wells_fargo() { let json = r#"{ @@ -416,3 +485,177 @@ fn wells_fargo() { // was correctly deserialized to a true boolean value assert!(authenticator_selection.require_resident_key); } + +#[test] +fn pub_key_cred_params_serialization() { + use super::PublicKeyCredentialParameters; + use crate::webauthn::PublicKeyCredentialType; + use coset::iana::Algorithm; + + // Test that serialization produces numeric alg values (not stringified) + let params = PublicKeyCredentialParameters { + ty: PublicKeyCredentialType::PublicKey, + alg: Algorithm::ES256, + }; + + let serialized = serde_json::to_string(¶ms).expect("Failed to serialize"); + assert!( + serialized.contains("\"alg\":-7"), + "Algorithm should serialize as numeric -7, got: {}", + serialized + ); + assert!( + serialized.contains("\"type\":\"public-key\""), + "Type should serialize as public-key, got: {}", + serialized + ); + + // Test round-trip: deserialize stringified, serialize to numeric + let stringified_json = r#"{"alg": "-257", "type": "public-key"}"#; + let deserialized: PublicKeyCredentialParameters = + serde_json::from_str(stringified_json).expect("Failed to deserialize"); + assert_eq!(deserialized.alg, Algorithm::RS256); + + let re_serialized = serde_json::to_string(&deserialized).expect("Failed to re-serialize"); + assert!( + re_serialized.contains("\"alg\":-257"), + "Re-serialized should have numeric alg, got: {}", + re_serialized + ); +} + +#[test] +fn pub_key_cred_params_float_alg() { + // Float algorithm value (e.g., from JavaScript) + let json = r#"{ + "publicKey": { + "challenge": "dGVzdA==", + "pubKeyCredParams": [ + { "alg": -7.0, "type": "public-key" } + ], + "rp": { "id": "test.com", "name": "test" }, + "user": { "displayName": "test", "id": "dGVzdA==", "name": "test" } + } + }"#; + let result: CredentialCreationOptions = serde_json::from_str(json).unwrap(); + assert_eq!(result.public_key.pub_key_cred_params.len(), 1); +} + +#[test] +fn pub_key_cred_params_stringified_float_alg() { + let json = r#"{ + "publicKey": { + "challenge": "dGVzdA==", + "pubKeyCredParams": [ + { "alg": "-7.0", "type": "public-key" } + ], + "rp": { "id": "test.com", "name": "test" }, + "user": { "displayName": "test", "id": "dGVzdA==", "name": "test" } + } + }"#; + let result: CredentialCreationOptions = serde_json::from_str(json).unwrap(); + assert_eq!(result.public_key.pub_key_cred_params.len(), 1); +} + +#[test] +fn pub_key_cred_params_null_alg_skipped() { + let json = r#"{ + "publicKey": { + "challenge": "dGVzdA==", + "pubKeyCredParams": [ + { "alg": null, "type": "public-key" }, + { "alg": -7, "type": "public-key" } + ], + "rp": { "id": "test.com", "name": "test" }, + "user": { "displayName": "test", "id": "dGVzdA==", "name": "test" } + } + }"#; + let result: CredentialCreationOptions = serde_json::from_str(json).unwrap(); + assert_eq!(result.public_key.pub_key_cred_params.len(), 1); +} + +#[test] +fn pub_key_cred_params_non_numeric_string_alg_skipped() { + let json = r#"{ + "publicKey": { + "challenge": "dGVzdA==", + "pubKeyCredParams": [ + { "alg": "ES256", "type": "public-key" }, + { "alg": -7, "type": "public-key" } + ], + "rp": { "id": "test.com", "name": "test" }, + "user": { "displayName": "test", "id": "dGVzdA==", "name": "test" } + } + }"#; + let result: CredentialCreationOptions = serde_json::from_str(json).unwrap(); + assert_eq!(result.public_key.pub_key_cred_params.len(), 1); +} + +#[test] +fn pub_key_cred_params_missing_alg_skipped() { + let json = r#"{ + "publicKey": { + "challenge": "dGVzdA==", + "pubKeyCredParams": [ + { "type": "public-key" }, + { "alg": -7, "type": "public-key" } + ], + "rp": { "id": "test.com", "name": "test" }, + "user": { "displayName": "test", "id": "dGVzdA==", "name": "test" } + } + }"#; + let result: CredentialCreationOptions = serde_json::from_str(json).unwrap(); + assert_eq!(result.public_key.pub_key_cred_params.len(), 1); +} + +#[test] +fn pub_key_cred_params_missing_type_skipped() { + let json = r#"{ + "publicKey": { + "challenge": "dGVzdA==", + "pubKeyCredParams": [ + { "alg": -7 }, + { "alg": -257, "type": "public-key" } + ], + "rp": { "id": "test.com", "name": "test" }, + "user": { "displayName": "test", "id": "dGVzdA==", "name": "test" } + } + }"#; + let result: CredentialCreationOptions = serde_json::from_str(json).unwrap(); + assert_eq!(result.public_key.pub_key_cred_params.len(), 1); +} + +#[test] +fn pub_key_cred_params_unknown_type_not_filtered() { + // Per spec, unknown types SHOULD be ignored, but ignore_unknown_vec doesn't filter by type. + // Downstream code is responsible for filtering elements with Unknown type. + let json = r#"{ + "publicKey": { + "challenge": "dGVzdA==", + "pubKeyCredParams": [ + { "alg": -7, "type": "unknown-type" }, + { "alg": -257, "type": "public-key" } + ], + "rp": { "id": "test.com", "name": "test" }, + "user": { "displayName": "test", "id": "dGVzdA==", "name": "test" } + } + }"#; + let result: CredentialCreationOptions = serde_json::from_str(json).unwrap(); + assert_eq!(result.public_key.pub_key_cred_params.len(), 2); +} + +#[test] +fn pub_key_cred_params_extra_fields_ignored() { + let json = r#"{ + "publicKey": { + "challenge": "dGVzdA==", + "pubKeyCredParams": [ + { "alg": -7, "type": "public-key", "extra": "ignored" } + ], + "rp": { "id": "test.com", "name": "test" }, + "user": { "displayName": "test", "id": "dGVzdA==", "name": "test" } + } + }"#; + let result: CredentialCreationOptions = serde_json::from_str(json).unwrap(); + assert_eq!(result.public_key.pub_key_cred_params.len(), 1); +} diff --git a/passkey-types/src/webauthn/common.rs b/passkey-types/src/webauthn/common.rs index d3a710a..ef927ae 100644 --- a/passkey-types/src/webauthn/common.rs +++ b/passkey-types/src/webauthn/common.rs @@ -41,7 +41,7 @@ pub enum PublicKeyCredentialType { /// It is recommended to ignore any credential whose type is [`PublicKeyCredentialType::Unknown`] /// /// -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typeshare", typeshare)] pub struct PublicKeyCredentialDescriptor { /// This member contains the type of the public key credential the caller is referring to. The diff --git a/passkey/Cargo.toml b/passkey/Cargo.toml index 6456184..7eca9d0 100644 --- a/passkey/Cargo.toml +++ b/passkey/Cargo.toml @@ -42,3 +42,4 @@ passkey-client = { path = "../passkey-client", version = "0.6", features = [ tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] } tokio-test = "0.4" url = "2" +zeroize = "1.9" diff --git a/passkey/examples/linux.rs b/passkey/examples/linux.rs new file mode 100644 index 0000000..adde513 --- /dev/null +++ b/passkey/examples/linux.rs @@ -0,0 +1,141 @@ +//! Sample App for Linux Client +use std::io::{Write, stdin, stdout}; + +use passkey::{ + client::{WebauthnError, linux::LinuxClient}, + types::{Bytes, rand::random_vec, webauthn::*}, +}; + +use coset::iana; +use passkey_client::{ + DefaultClientData, + linux::{PinPrompt, PinPromptError}, +}; +use url::Url; +use zeroize::Zeroizing; + +struct MyPinPrompt; + +#[async_trait::async_trait] +impl PinPrompt for MyPinPrompt { + async fn prompt_authenticator_selection( + &self, + num_authenticators: usize, + ) -> Result<(), PinPromptError> { + println!( + "Touch one of the {} attached authenticator devices to select it.", + num_authenticators + ); + Ok(()) + } + async fn request_pin( + &self, + attempts_remaining: u32, + ) -> Result, PinPromptError> { + print!("Enter PIN ({} attempts remaining): ", attempts_remaining); + stdout() + .flush() + .map_err(|e| PinPromptError::Other(Box::new(e)))?; + let mut buf = String::new(); + stdin() + .read_line(&mut buf) + .map_err(|e| PinPromptError::Other(Box::new(e)))?; + Ok(Zeroizing::new(buf.trim_ascii().into())) + } +} + +// Example of how to set up, register and authenticate with a `Client`. +async fn client_setup( + challenge_bytes_from_rp: Bytes, + parameters_from_rp: PublicKeyCredentialParameters, + origin: &Url, + user_entity: PublicKeyCredentialUserEntity, +) -> Result<(CreatedPublicKeyCredential, AuthenticatedPublicKeyCredential), WebauthnError> { + // Create the Client + let my_client = LinuxClient::open_all(Box::new(MyPinPrompt)) + .await + .unwrap() + .user_verification_when_preferred(false); + + // The following values, provided as parameters to this function would usually be + // retrieved from a Relying Party according to the context of the application. + let request = CredentialCreationOptions { + public_key: PublicKeyCredentialCreationOptions { + rp: PublicKeyCredentialRpEntity { + id: None, // Leaving the ID as None means use the effective domain + name: origin.domain().unwrap().into(), + }, + user: user_entity, + challenge: challenge_bytes_from_rp, + pub_key_cred_params: vec![parameters_from_rp], + timeout: None, + exclude_credentials: None, + authenticator_selection: Some(AuthenticatorSelectionCriteria { + resident_key: Some(ResidentKeyRequirement::Required), + ..Default::default() + }), + hints: None, + attestation: AttestationConveyancePreference::None, + attestation_formats: None, + extensions: None, + }, + }; + + // Now create the credential. + println!("Attempting credential creation"); + let my_webauthn_credential = my_client + .register(origin, request, DefaultClientData) + .await?; + + // Let's try and authenticate. + // Create a challenge that would usually come from the RP. + let challenge_bytes_from_rp: Bytes = random_vec(32).into(); + // Now try and authenticate + let credential_request = CredentialRequestOptions { + public_key: PublicKeyCredentialRequestOptions { + challenge: challenge_bytes_from_rp, + timeout: None, + rp_id: Some(String::from(origin.domain().unwrap())), + allow_credentials: None, + user_verification: UserVerificationRequirement::default(), + hints: None, + attestation: AttestationConveyancePreference::None, + attestation_formats: None, + extensions: None, + }, + }; + + println!("Attempting authentication"); + let authenticated_cred = my_client + .authenticate(origin, credential_request, DefaultClientData) + .await?; + + Ok((my_webauthn_credential, authenticated_cred)) +} + +#[tokio::main] +async fn main() -> Result<(), WebauthnError> { + let rp_url = Url::parse("https://future.1password.com").expect("Should Parse"); + let user_entity = PublicKeyCredentialUserEntity { + id: random_vec(32).into(), + display_name: "Johnny Passkey".into(), + name: "jpasskey@example.org".into(), + }; + + // Set up a client, create and authenticate a credential, then report results. + let (created_cred, authed_cred) = client_setup( + random_vec(32).into(), // challenge_bytes_from_rp + PublicKeyCredentialParameters { + ty: PublicKeyCredentialType::PublicKey, + alg: iana::Algorithm::ES256, + }, + &rp_url, // origin + user_entity.clone(), + ) + .await?; + + println!("Webauthn credential created:\n\n{created_cred:?}\n\n"); + println!("Webauthn credential auth'ed:\n\n{authed_cred:?}\n\n"); + + Ok(()) +} diff --git a/passkey/examples/usage.rs b/passkey/examples/usage.rs index 7d14ba0..3d94d73 100644 --- a/passkey/examples/usage.rs +++ b/passkey/examples/usage.rs @@ -143,7 +143,7 @@ async fn authenticator_setup( client_data_hash, allow_list: None, extensions: None, - options: make_credential::Options::default(), + options: get_assertion::Options::default(), pin_auth: None, pin_protocol: None, }; diff --git a/passkey/src/lib.rs b/passkey/src/lib.rs index 7ec1cfe..fed7f35 100644 --- a/passkey/src/lib.rs +++ b/passkey/src/lib.rs @@ -254,7 +254,7 @@ //! client_data_hash, //! allow_list: None, //! extensions: None, -//! options: make_credential::Options::default(), +//! options: get_assertion::Options::default(), //! pin_auth: None, //! pin_protocol: None, //! }; diff --git a/rust-toolchain b/rust-toolchain index f6ea87d..f634271 100644 --- a/rust-toolchain +++ b/rust-toolchain @@ -1 +1 @@ -1.87.0 \ No newline at end of file +1.87.0