bitwarden_fido/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use bitwarden_core::key_management::KeyIds;
4use bitwarden_crypto::KeyStoreContext;
5use bitwarden_encoding::{B64Url, NotB64UrlEncodedError};
6use bitwarden_vault::{
7    CipherError, CipherView, Fido2CredentialFullView, Fido2CredentialNewView, Fido2CredentialView,
8};
9use crypto::{CoseKeyToPkcs8Error, PrivateKeyFromSecretKeyError};
10use passkey::types::{Passkey, ctap2::Aaguid};
11
12#[cfg(feature = "uniffi")]
13uniffi::setup_scaffolding!();
14#[cfg(feature = "uniffi")]
15mod uniffi_support;
16
17mod authenticator;
18mod client;
19mod client_fido;
20mod crypto;
21mod traits;
22mod types;
23pub use authenticator::{
24    CredentialsForAutofillError, Fido2Authenticator, GetAssertionError, MakeCredentialError,
25    SilentlyDiscoverCredentialsError,
26};
27pub use client::{Fido2Client, Fido2ClientError};
28pub use client_fido::{ClientFido2, ClientFido2Ext, DecryptFido2AutofillCredentialsError};
29pub use passkey::authenticator::UIHint;
30use thiserror::Error;
31pub use traits::{
32    CheckUserOptions, CheckUserResult, Fido2CallbackError, Fido2CredentialStore,
33    Fido2UserInterface, Verification,
34};
35pub use types::{
36    AuthenticatorAssertionResponse, AuthenticatorAttestationResponse, ClientData,
37    Fido2CredentialAutofillView, Fido2CredentialAutofillViewError, GetAssertionRequest,
38    GetAssertionResult, MakeCredentialRequest, MakeCredentialResult, Options, Origin,
39    PublicKeyCredentialAuthenticatorAssertionResponse,
40    PublicKeyCredentialAuthenticatorAttestationResponse, PublicKeyCredentialRpEntity,
41    PublicKeyCredentialUserEntity, UnverifiedAssetLink,
42};
43
44use self::crypto::{cose_key_to_pkcs8, pkcs8_to_cose_key};
45
46// This is the AAGUID for the Bitwarden Passkey provider (d548826e-79b4-db40-a3d8-11116f7e8349)
47// It is used for the Relaying Parties to identify the authenticator during registration
48const AAGUID: Aaguid = Aaguid([
49    0xd5, 0x48, 0x82, 0x6e, 0x79, 0xb4, 0xdb, 0x40, 0xa3, 0xd8, 0x11, 0x11, 0x6f, 0x7e, 0x83, 0x49,
50]);
51
52#[allow(dead_code, missing_docs)]
53#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
54pub struct SelectedCredential {
55    cipher: CipherView,
56    credential: Fido2CredentialView,
57}
58
59// This container is needed so we can properly implement the TryFrom trait for Passkey
60// Otherwise we need to decrypt the Fido2 credentials every time we create a CipherView
61#[derive(Clone)]
62pub(crate) struct CipherViewContainer {
63    cipher: CipherView,
64    fido2_credentials: Vec<Fido2CredentialFullView>,
65}
66
67impl CipherViewContainer {
68    fn new(cipher: CipherView, ctx: &mut KeyStoreContext<KeyIds>) -> Result<Self, CipherError> {
69        let fido2_credentials = cipher.get_fido2_credentials(ctx)?;
70        Ok(Self {
71            cipher,
72            fido2_credentials,
73        })
74    }
75}
76
77#[allow(missing_docs)]
78#[derive(Debug, Error)]
79pub enum Fido2Error {
80    #[error(transparent)]
81    Decode(#[from] NotB64UrlEncodedError),
82
83    #[error(transparent)]
84    UnknownEnum(#[from] UnknownEnumError),
85
86    #[error(transparent)]
87    InvalidGuid(#[from] InvalidGuidError),
88
89    #[error(transparent)]
90    PrivateKeyFromSecretKey(#[from] PrivateKeyFromSecretKeyError),
91
92    #[error("No Fido2 credentials found")]
93    NoFido2CredentialsFound,
94
95    #[error("Invalid counter")]
96    InvalidCounter,
97}
98
99impl TryFrom<CipherViewContainer> for Passkey {
100    type Error = Fido2Error;
101
102    fn try_from(value: CipherViewContainer) -> Result<Self, Self::Error> {
103        let cred = value
104            .fido2_credentials
105            .first()
106            .ok_or(Fido2Error::NoFido2CredentialsFound)?;
107
108        try_from_credential_full_view(cred.clone())
109    }
110}
111
112fn try_from_credential_full_view(value: Fido2CredentialFullView) -> Result<Passkey, Fido2Error> {
113    let counter: u32 = value
114        .counter
115        .parse()
116        .map_err(|_| Fido2Error::InvalidCounter)?;
117    let counter = (counter != 0).then_some(counter);
118    let key_value = B64Url::try_from(value.key_value)?;
119    let user_handle = value.user_handle.map(B64Url::try_from).transpose()?;
120
121    let key = pkcs8_to_cose_key(key_value.as_bytes())?;
122
123    Ok(Passkey {
124        key,
125        credential_id: string_to_guid_bytes(&value.credential_id)?.into(),
126        rp_id: value.rp_id.clone(),
127        user_handle: user_handle.map(|u| u.into_bytes().into()),
128        counter,
129    })
130}
131
132#[allow(missing_docs)]
133#[derive(Debug, Error)]
134pub enum FillCredentialError {
135    #[error(transparent)]
136    InvalidInputLength(#[from] InvalidInputLengthError),
137    #[error(transparent)]
138    CoseKeyToPkcs8(#[from] CoseKeyToPkcs8Error),
139}
140
141#[allow(missing_docs)]
142pub fn fill_with_credential(
143    view: &Fido2CredentialView,
144    value: Passkey,
145) -> Result<Fido2CredentialFullView, FillCredentialError> {
146    let cred_id: Vec<u8> = value.credential_id.into();
147    let user_handle = value
148        .user_handle
149        .map(|u| B64Url::from(u.to_vec()).to_string());
150    let key_value = B64Url::from(cose_key_to_pkcs8(&value.key)?).to_string();
151
152    Ok(Fido2CredentialFullView {
153        credential_id: guid_bytes_to_string(&cred_id)?,
154        key_type: "public-key".to_owned(),
155        key_algorithm: "ECDSA".to_owned(),
156        key_curve: "P-256".to_owned(),
157        key_value,
158        rp_id: value.rp_id,
159        rp_name: view.rp_name.clone(),
160        user_handle,
161
162        counter: value.counter.unwrap_or(0).to_string(),
163        user_name: view.user_name.clone(),
164        user_display_name: view.user_display_name.clone(),
165        discoverable: "true".to_owned(),
166        creation_date: chrono::offset::Utc::now(),
167    })
168}
169
170pub(crate) fn try_from_credential_new_view(
171    user: &passkey::types::ctap2::make_credential::PublicKeyCredentialUserEntity,
172    rp: &passkey::types::ctap2::make_credential::PublicKeyCredentialRpEntity,
173) -> Result<Fido2CredentialNewView, InvalidInputLengthError> {
174    let cred_id: Vec<u8> = vec![0; 16];
175    let user_handle = B64Url::from(user.id.to_vec()).to_string();
176
177    Ok(Fido2CredentialNewView {
178        // TODO: Why do we have a credential id here?
179        credential_id: guid_bytes_to_string(&cred_id)?,
180        key_type: "public-key".to_owned(),
181        key_algorithm: "ECDSA".to_owned(),
182        key_curve: "P-256".to_owned(),
183        rp_id: rp.id.clone(),
184        rp_name: rp.name.clone(),
185        user_handle: Some(user_handle),
186
187        counter: 0.to_string(),
188        user_name: user.name.clone(),
189        user_display_name: user.display_name.clone(),
190        creation_date: chrono::offset::Utc::now(),
191    })
192}
193
194pub(crate) fn try_from_credential_full(
195    value: Passkey,
196    user: passkey::types::ctap2::make_credential::PublicKeyCredentialUserEntity,
197    rp: passkey::types::ctap2::make_credential::PublicKeyCredentialRpEntity,
198    options: passkey::types::ctap2::get_assertion::Options,
199) -> Result<Fido2CredentialFullView, FillCredentialError> {
200    let cred_id: Vec<u8> = value.credential_id.into();
201    let key_value = B64Url::from(cose_key_to_pkcs8(&value.key)?).to_string();
202    let user_handle = B64Url::from(user.id.to_vec()).to_string();
203
204    Ok(Fido2CredentialFullView {
205        credential_id: guid_bytes_to_string(&cred_id)?,
206        key_type: "public-key".to_owned(),
207        key_algorithm: "ECDSA".to_owned(),
208        key_curve: "P-256".to_owned(),
209        key_value,
210        rp_id: value.rp_id,
211        rp_name: rp.name,
212        user_handle: Some(user_handle),
213
214        counter: value.counter.unwrap_or(0).to_string(),
215        user_name: user.name,
216        user_display_name: user.display_name,
217        discoverable: options.rk.to_string(),
218        creation_date: chrono::offset::Utc::now(),
219    })
220}
221
222#[allow(missing_docs)]
223#[derive(Debug, Error)]
224#[error("Input should be a 16 byte array")]
225pub struct InvalidInputLengthError;
226
227#[allow(missing_docs)]
228pub fn guid_bytes_to_string(source: &[u8]) -> Result<String, InvalidInputLengthError> {
229    if source.len() != 16 {
230        return Err(InvalidInputLengthError);
231    }
232    Ok(uuid::Uuid::from_bytes(source.try_into().expect("Invalid length")).to_string())
233}
234
235#[allow(missing_docs)]
236#[derive(Debug, Error)]
237#[error("Invalid GUID")]
238pub struct InvalidGuidError;
239
240#[allow(missing_docs)]
241pub fn string_to_guid_bytes(source: &str) -> Result<Vec<u8>, InvalidGuidError> {
242    if source.starts_with("b64.") {
243        let bytes =
244            B64Url::try_from(source.trim_start_matches("b64.")).map_err(|_| InvalidGuidError)?;
245        Ok(bytes.as_bytes().to_vec())
246    } else {
247        let Ok(uuid) = uuid::Uuid::try_parse(source) else {
248            return Err(InvalidGuidError);
249        };
250        Ok(uuid.as_bytes().to_vec())
251    }
252}
253
254#[allow(missing_docs)]
255#[derive(Debug, Error)]
256#[error("Unknown enum value")]
257pub struct UnknownEnumError;
258
259// Some utilities to convert back and forth between enums and strings
260fn get_enum_from_string_name<T: serde::de::DeserializeOwned>(
261    s: &str,
262) -> Result<T, UnknownEnumError> {
263    let serialized = format!(r#""{s}""#);
264    let deserialized: T = serde_json::from_str(&serialized).map_err(|_| UnknownEnumError)?;
265    Ok(deserialized)
266}
267
268fn get_string_name_from_enum(s: impl serde::Serialize) -> Result<String, serde_json::Error> {
269    let serialized = serde_json::to_string(&s)?;
270    let deserialized: String = serde_json::from_str(&serialized)?;
271    Ok(deserialized)
272}
273
274#[cfg(test)]
275mod tests {
276    use passkey::types::webauthn::AuthenticatorAttachment;
277
278    use super::{get_enum_from_string_name, get_string_name_from_enum};
279
280    #[test]
281    fn test_enum_string_conversion_works_as_expected() {
282        assert_eq!(
283            get_string_name_from_enum(AuthenticatorAttachment::CrossPlatform).unwrap(),
284            "cross-platform"
285        );
286
287        assert_eq!(
288            get_enum_from_string_name::<AuthenticatorAttachment>("cross-platform").unwrap(),
289            AuthenticatorAttachment::CrossPlatform
290        );
291    }
292
293    #[test]
294    fn string_to_guid_with_uuid_works() {
295        let uuid = "d548826e-79b4-db40-a3d8-11116f7e8349";
296        let bytes = super::string_to_guid_bytes(uuid).unwrap();
297        assert_eq!(
298            bytes,
299            vec![
300                213, 72, 130, 110, 121, 180, 219, 64, 163, 216, 17, 17, 111, 126, 131, 73
301            ]
302        );
303    }
304
305    #[test]
306    fn string_to_guid_with_b64_works() {
307        let b64 = "b64.1UiCbnm020Cj2BERb36DSQ";
308        let bytes = super::string_to_guid_bytes(b64).unwrap();
309        assert_eq!(
310            bytes,
311            vec![
312                213, 72, 130, 110, 121, 180, 219, 64, 163, 216, 17, 17, 111, 126, 131, 73
313            ]
314        );
315    }
316}