Skip to main content

bitwarden_core/key_management/
webauthn_prf.rs

1//! WebAuthn PRF unlock data. Each registered passkey that supports the PRF extension carries a
2//! rotateable key set: a private key wrapped by the key derived from the credential's PRF output,
3//! and the user key encapsulated to the matching public key. Unlocking derives the PRF key from the
4//! authenticator, unwraps the private key, and decapsulates the user key with it.
5
6use bitwarden_api_api::models::WebAuthnPrfDecryptionOption;
7use bitwarden_crypto::{EncString, UnsignedSharedKey};
8use serde::{Deserialize, Serialize};
9use thiserror::Error;
10
11use crate::{MissingFieldError, require};
12
13/// The unlock data for a single WebAuthn PRF credential.
14#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
15#[serde(rename_all = "camelCase", deny_unknown_fields)]
16#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
17#[bitwarden_ffi::wasm_record]
18pub struct WebAuthnPrfUnlockOption {
19    /// The private key of the unlock option, wrapped by the key derived from the credential's PRF
20    /// output
21    pub encrypted_private_key: EncString,
22    /// The user key, encapsulated with the public key of the unlock option
23    pub encrypted_user_key: UnsignedSharedKey,
24    /// Credential ID for this WebAuthn PRF credential.
25    pub credential_id: Option<String>,
26    /// Transport methods available for this credential (e.g., "usb", "nfc", "ble", "internal",
27    /// "hybrid").
28    pub transports: Option<Vec<String>>,
29}
30
31/// Every WebAuthn PRF credential the account can unlock with.
32#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
33#[serde(rename_all = "camelCase", deny_unknown_fields)]
34#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
35#[bitwarden_ffi::wasm_record]
36pub struct WebAuthnPrfUnlockData {
37    /// The credentials, in the order the server reported them.
38    pub options: Vec<WebAuthnPrfUnlockOption>,
39}
40
41#[cfg(feature = "wasm")]
42impl TryFrom<wasm_bindgen::JsValue> for WebAuthnPrfUnlockData {
43    type Error = serde_wasm_bindgen::Error;
44
45    fn try_from(value: wasm_bindgen::JsValue) -> Result<Self, Self::Error> {
46        serde_wasm_bindgen::from_value(value)
47    }
48}
49
50impl TryFrom<&WebAuthnPrfDecryptionOption> for WebAuthnPrfUnlockOption {
51    type Error = WebAuthnPrfError;
52
53    fn try_from(response: &WebAuthnPrfDecryptionOption) -> Result<Self, Self::Error> {
54        let encrypted_private_key = require!(&response.encrypted_private_key)
55            .parse()
56            .map_err(|_| WebAuthnPrfError::ResponseModelMalformed)?;
57        let encrypted_user_key = require!(&response.encrypted_user_key)
58            .parse()
59            .map_err(|_| WebAuthnPrfError::ResponseModelMalformed)?;
60
61        Ok(WebAuthnPrfUnlockOption {
62            encrypted_private_key,
63            encrypted_user_key,
64            credential_id: response.credential_id.clone(),
65            transports: response.transports.clone(),
66        })
67    }
68}
69
70/// Errors that can occur when working with WebAuthn PRF unlock data
71#[derive(Debug, Error)]
72pub enum WebAuthnPrfError {
73    /// A key field is present but could not be parsed
74    #[error("Response model malformed")]
75    ResponseModelMalformed,
76    /// A required key field is missing
77    #[error(transparent)]
78    MissingField(#[from] MissingFieldError),
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    const ENCRYPTED_PRIVATE_KEY: &str = "2.fkvl0+sL1lwtiOn1eewsvQ==|dT0TynLl8YERZ8x7dxC+DQ==|cWhiRSYHOi/AA2LiV/JBJWbO9C7pbUpOM6TMAcV47hE=";
86    const ENCRYPTED_USER_KEY: &str = "4.DMD1D5r6BsDDd7C/FE1eZbMCKrmryvAsCKj6+bO54gJNUxisOI7SDcpPLRXf+JdhqY15pT+wimQ5cD9C+6OQ6s71LFQHewXPU29l9Pa1JxGeiKqp37KLYf+1IS6UB2K3ANN35C52ZUHh2TlzIS5RuntxnpCw7APbcfpcnmIdLPJBtuj/xbFd6eBwnI3GSe5qdS6/Ixdd0dgsZcpz3gHJBKmIlSo0YN60SweDq3kTJwox9xSqdCueIDg5U4khc7RhjYx8b33HXaNJj3DwgIH8iLj+lqpDekogr630OhHG3XRpvl4QzYO45bmHb8wAh67Dj70nsZcVg6bAEFHdSFohww==";
87
88    fn build_response_model() -> WebAuthnPrfDecryptionOption {
89        WebAuthnPrfDecryptionOption {
90            encrypted_private_key: Some(ENCRYPTED_PRIVATE_KEY.to_string()),
91            encrypted_user_key: Some(ENCRYPTED_USER_KEY.to_string()),
92            credential_id: None,
93            transports: None,
94        }
95    }
96
97    #[test]
98    fn test_from_response_model() {
99        let response = build_response_model();
100
101        let option = WebAuthnPrfUnlockOption::try_from(&response).unwrap();
102
103        assert_eq!(
104            option.encrypted_private_key,
105            ENCRYPTED_PRIVATE_KEY.parse().unwrap()
106        );
107        assert_eq!(
108            option.encrypted_user_key.to_string(),
109            ENCRYPTED_USER_KEY.to_string()
110        );
111        assert_eq!(option.credential_id, None);
112        assert_eq!(option.transports, None);
113    }
114
115    #[test]
116    fn test_from_response_model_with_optional_fields() {
117        let mut response = build_response_model();
118        response.credential_id = Some("test-credential-id".to_string());
119        response.transports = Some(vec!["usb".to_string(), "nfc".to_string()]);
120
121        let option = WebAuthnPrfUnlockOption::try_from(&response).unwrap();
122
123        assert_eq!(option.credential_id, Some("test-credential-id".to_string()));
124        assert_eq!(
125            option.transports,
126            Some(vec!["usb".to_string(), "nfc".to_string()])
127        );
128    }
129
130    #[test]
131    fn test_from_response_model_missing_encrypted_private_key() {
132        let mut response = build_response_model();
133        response.encrypted_private_key = None;
134
135        assert!(matches!(
136            WebAuthnPrfUnlockOption::try_from(&response),
137            Err(WebAuthnPrfError::MissingField(_))
138        ));
139    }
140
141    #[test]
142    fn test_from_response_model_missing_encrypted_user_key() {
143        let mut response = build_response_model();
144        response.encrypted_user_key = None;
145
146        assert!(matches!(
147            WebAuthnPrfUnlockOption::try_from(&response),
148            Err(WebAuthnPrfError::MissingField(_))
149        ));
150    }
151
152    #[test]
153    fn test_from_response_model_unparseable_encrypted_user_key() {
154        let mut response = build_response_model();
155        response.encrypted_user_key = Some("not an unsigned shared key".to_string());
156
157        assert!(matches!(
158            WebAuthnPrfUnlockOption::try_from(&response),
159            Err(WebAuthnPrfError::ResponseModelMalformed)
160        ));
161    }
162
163    #[test]
164    fn test_unlock_data_serde_round_trip() {
165        let data = WebAuthnPrfUnlockData {
166            options: vec![WebAuthnPrfUnlockOption::try_from(&build_response_model()).unwrap()],
167        };
168
169        let serialized = serde_json::to_string(&data).unwrap();
170        let deserialized: WebAuthnPrfUnlockData = serde_json::from_str(&serialized).unwrap();
171
172        assert_eq!(data, deserialized);
173    }
174}