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