Skip to main content

forge_connectors/vault/
models.rs

1//! The Vaultwarden wire shapes the connector reads and writes.
2//!
3//! Ciphers cross as `serde_json::Value`, not as a lossy typed struct: an update
4//! must send the whole object back, and a typed shape that dropped the TOTP
5//! seed, a passkey or a field this version does not model would delete it on
6//! the server. The facade decrypts the fields it needs from the value; the
7//! connector only moves it.
8
9use serde::de::{Deserializer, IgnoredAny, MapAccess, Visitor};
10use serde::{Deserialize, Serialize};
11use zeroize::{Zeroize, Zeroizing};
12
13/// The prelogin answer: which KDF the account uses and its parameters.
14#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct Prelogin {
17    /// `0` is PBKDF2-SHA256, `1` is Argon2id.
18    pub kdf: i32,
19    pub kdf_iterations: u32,
20    #[serde(default)]
21    pub kdf_memory: Option<u32>,
22    #[serde(default)]
23    pub kdf_parallelism: Option<u32>,
24}
25
26/// The token endpoint's answer for both the password and refresh grants.
27#[derive(Clone, Serialize)]
28pub struct TokenResponse {
29    pub access_token: String,
30    pub refresh_token: Option<String>,
31    /// The wrapped user key. Vaultwarden capitalises the field; some clients
32    /// lower-case it, so both spellings parse.
33    pub key: Option<String>,
34    /// The wrapped account private key, in the same two spellings.
35    pub private_key: Option<String>,
36}
37
38/// Hand-written so a malformed response drops every field it had already
39/// read through `Zeroizing`: the access and refresh tokens are credentials.
40impl<'de> Deserialize<'de> for TokenResponse {
41    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
42    where
43        D: Deserializer<'de>,
44    {
45        struct TokenVisitor;
46
47        impl<'de> Visitor<'de> for TokenVisitor {
48            type Value = TokenResponse;
49
50            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51                formatter.write_str("a token response")
52            }
53
54            fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
55            where
56                M: MapAccess<'de>,
57            {
58                let mut access: Option<Zeroizing<String>> = None;
59                let mut refresh: Option<Zeroizing<String>> = None;
60                let mut key: Option<Zeroizing<String>> = None;
61                let mut private_key: Option<Zeroizing<String>> = None;
62                while let Some(field) = map.next_key::<String>()? {
63                    match field.as_str() {
64                        "access_token" => access = Some(Zeroizing::new(map.next_value()?)),
65                        "refresh_token" => {
66                            refresh = map.next_value::<Option<String>>()?.map(Zeroizing::new);
67                        }
68                        "Key" | "key" => {
69                            key = map.next_value::<Option<String>>()?.map(Zeroizing::new);
70                        }
71                        "PrivateKey" | "privateKey" => {
72                            private_key = map.next_value::<Option<String>>()?.map(Zeroizing::new);
73                        }
74                        _ => {
75                            let _: IgnoredAny = map.next_value()?;
76                        }
77                    }
78                }
79                let mut access = access
80                    .ok_or_else(|| <M::Error as serde::de::Error>::missing_field("access_token"))?;
81                Ok(TokenResponse {
82                    access_token: std::mem::take(&mut *access),
83                    refresh_token: refresh.map(|value| (*value).clone()),
84                    key: key.map(|value| (*value).clone()),
85                    private_key: private_key.map(|value| (*value).clone()),
86                })
87            }
88        }
89
90        deserializer.deserialize_map(TokenVisitor)
91    }
92}
93
94/// Every field is a credential, so dropping the token wipes it even on an
95/// error path that never stored it.
96impl Drop for TokenResponse {
97    fn drop(&mut self) {
98        self.access_token.zeroize();
99        if let Some(refresh) = &mut self.refresh_token {
100            refresh.zeroize();
101        }
102        if let Some(key) = &mut self.key {
103            key.zeroize();
104        }
105        if let Some(private_key) = &mut self.private_key {
106            private_key.zeroize();
107        }
108    }
109}
110
111/// Hand-written because every field is a credential: the access and refresh
112/// tokens, the wrapped user key and the wrapped private key.
113impl std::fmt::Debug for TokenResponse {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        f.debug_struct("TokenResponse")
116            .field("access_token", &"[redacted]")
117            .field("refresh_token", &self.refresh_token.as_ref().map(|_| "[redacted]"))
118            .field("key", &self.key.as_ref().map(|_| "[redacted]"))
119            .field("private_key", &self.private_key.as_ref().map(|_| "[redacted]"))
120            .finish()
121    }
122}
123
124/// The account profile inside `GET /api/sync`.
125#[derive(Debug, Clone, Default, Deserialize, Serialize)]
126#[serde(rename_all = "camelCase")]
127pub struct Profile {
128    /// The account's user id. A write stamps it on `encryptedFor`, which
129    /// Vaultwarden 1.37 requires and compares against the token's own user.
130    #[serde(default)]
131    pub id: Option<String>,
132    #[serde(default, rename = "key", alias = "Key")]
133    pub key: Option<String>,
134    #[serde(default, rename = "privateKey", alias = "PrivateKey")]
135    pub private_key: Option<String>,
136    #[serde(default)]
137    pub organizations: Vec<Organization>,
138    #[serde(default)]
139    pub email: Option<String>,
140}
141
142/// One organization's wrapped key, as the profile carries it.
143#[derive(Debug, Clone, Deserialize, Serialize)]
144#[serde(rename_all = "camelCase")]
145pub struct Organization {
146    pub id: String,
147    #[serde(default)]
148    pub key: Option<String>,
149    #[serde(default)]
150    pub name: Option<String>,
151}
152
153/// One folder. Folders are simple enough to type: an update sends only the name.
154#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
155#[serde(rename_all = "camelCase")]
156pub struct Folder {
157    pub id: String,
158    pub name: String,
159}
160
161/// The `GET /api/folders` list envelope. Vaultwarden sends every folder in
162/// `data` with a null `continuationToken`, and no server this family talks to
163/// pages, so the token is not modelled.
164#[derive(Debug, Clone, Default, Deserialize, Serialize)]
165#[serde(rename_all = "camelCase")]
166pub struct FolderList {
167    #[serde(default)]
168    pub data: Vec<Folder>,
169}
170
171/// One attachment's metadata, as a cipher and the download route carry it.
172#[derive(Clone, Deserialize, Serialize)]
173#[serde(rename_all = "camelCase")]
174pub struct AttachmentMeta {
175    pub id: String,
176    pub file_name: String,
177    /// Vaultwarden sends the size as a string on some routes and a number on
178    /// others; both spellings mean the same byte count.
179    #[serde(default, deserialize_with = "size_from_number_or_string")]
180    pub size: Option<u64>,
181    #[serde(default)]
182    pub size_name: Option<String>,
183    #[serde(default)]
184    pub url: Option<String>,
185    /// The wrapped attachment key. Never returned past this module.
186    #[serde(default)]
187    pub key: Option<String>,
188}
189
190/// Hand-written because the signed URL carries its own token and the key is a
191/// wrapped credential.
192impl std::fmt::Debug for AttachmentMeta {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        f.debug_struct("AttachmentMeta")
195            .field("id", &self.id)
196            .field("file_name", &self.file_name)
197            .field("size", &self.size)
198            .field("size_name", &self.size_name)
199            .field("url", &self.url.as_ref().map(|_| "[redacted]"))
200            .field("key", &self.key.as_ref().map(|_| "[redacted]"))
201            .finish()
202    }
203}
204
205/// The parts of `GET /api/sync` this family reads.
206#[derive(Debug, Clone, Default, Deserialize, Serialize)]
207#[serde(rename_all = "camelCase")]
208pub struct SyncData {
209    #[serde(default)]
210    pub profile: Profile,
211    #[serde(default)]
212    pub folders: Vec<Folder>,
213    /// The ciphers stay raw so an update can send the whole object back
214    /// without dropping a field this version does not model.
215    #[serde(default)]
216    pub ciphers: Vec<serde_json::Value>,
217}
218
219/// Read an attachment size that arrives as either a JSON number or a string.
220fn size_from_number_or_string<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
221where
222    D: serde::Deserializer<'de>,
223{
224    let value = Option::<serde_json::Value>::deserialize(deserializer)?;
225    Ok(match value {
226        Some(serde_json::Value::Number(number)) => number.as_u64(),
227        Some(serde_json::Value::String(size)) => size.trim().parse().ok(),
228        _ => None,
229    })
230}
231
232/// The second factor a password grant carries, when the account asks for one.
233#[derive(Clone, PartialEq, Eq, Deserialize, Serialize)]
234pub struct TwoFactorCode {
235    pub token: String,
236    /// The provider id Vaultwarden expects: `0` is an authenticator app.
237    pub provider: i32,
238}
239
240/// Hand-written because the token is a credential.
241impl std::fmt::Debug for TwoFactorCode {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        f.debug_struct("TwoFactorCode")
244            .field("token", &"[redacted]")
245            .field("provider", &self.provider)
246            .finish()
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    /// Both spellings of the wrapped keys parse, and an unknown field is
255    /// skipped: lower-casing clients must still log in.
256    #[test]
257    fn token_response_parses_both_key_spellings_and_skips_unknown_fields() {
258        let upper: TokenResponse = serde_json::from_str(
259            r#"{"access_token":"a","Key":"wrapped","PrivateKey":"private","unknown":1}"#,
260        )
261        .expect("the upper-case spells parse");
262        assert_eq!(upper.key.as_deref(), Some("wrapped"));
263        assert_eq!(upper.private_key.as_deref(), Some("private"));
264
265        let lower: TokenResponse =
266            serde_json::from_str(r#"{"access_token":"a","key":"wrapped","privateKey":"private"}"#)
267                .expect("the lower-case spells parse");
268        assert_eq!(lower.key.as_deref(), Some("wrapped"));
269        assert_eq!(lower.private_key.as_deref(), Some("private"));
270    }
271
272    /// The access token is the one required field; its absence names it.
273    #[test]
274    fn a_token_response_without_an_access_token_is_refused() {
275        let error = serde_json::from_str::<TokenResponse>(r#"{"key":"wrapped"}"#)
276            .expect_err("the access token is required");
277
278        assert!(error.to_string().contains("access_token"), "{error}");
279    }
280
281    /// A create stamps `encryptedFor` from this id, and Vaultwarden refuses a
282    /// cipher whose value is not the account's own user id.
283    #[test]
284    fn the_profile_carries_the_account_user_id() {
285        let profile: Profile =
286            serde_json::from_str(r#"{"id":"user-1","key":"wrapped"}"#).expect("the profile");
287
288        assert_eq!(profile.id.as_deref(), Some("user-1"));
289    }
290
291    /// The signed URL carries its own token and the key is a wrapped
292    /// credential, so neither may appear in a Debug line.
293    #[test]
294    fn attachment_meta_debug_redacts_the_url_and_key() {
295        let meta = AttachmentMeta {
296            id: "attachment-id".to_owned(),
297            file_name: "file".to_owned(),
298            size: Some(97),
299            size_name: None,
300            url: Some("https://vault.example.com/signed?token=secret".to_owned()),
301            key: Some("2.c2VjcmV0".to_owned()),
302        };
303
304        let text = format!("{meta:?}");
305
306        assert!(!text.contains("secret"), "{text}");
307        assert!(!text.contains("2.c2VjcmV0"), "{text}");
308    }
309}