Skip to main content

bwx/
config.rs

1use crate::prelude::*;
2
3use std::io::{Read as _, Write as _};
4
5use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
6
7#[derive(serde::Serialize, serde::Deserialize, Debug)]
8pub struct Config {
9    pub email: Option<String>,
10    pub sso_id: Option<String>,
11    pub base_url: Option<String>,
12    pub identity_url: Option<String>,
13    pub ui_url: Option<String>,
14    pub notifications_url: Option<String>,
15    #[serde(default = "default_lock_timeout")]
16    pub lock_timeout: u64,
17    #[serde(default = "default_sync_interval")]
18    pub sync_interval: u64,
19    #[serde(default = "default_pinentry")]
20    pub pinentry: String,
21    pub client_cert_path: Option<std::path::PathBuf>,
22    #[serde(default)]
23    pub ssh_confirm_sign: bool,
24    /// On macOS, controls how the master-password prompt is shown at unlock
25    /// time. Default `true` renders a native modal (works from daemonized
26    /// contexts — ssh-sign, Finder-launched GUI git, etc.). Set `false` to
27    /// fall back to pinentry. No effect on other platforms.
28    #[serde(default = "default_macos_unlock_dialog")]
29    pub macos_unlock_dialog: bool,
30    #[serde(
31        default,
32        with = "touchid_gate_serde",
33        skip_serializing_if = "is_touchid_gate_off"
34    )]
35    pub touchid_gate: crate::touchid::Gate,
36    // backcompat, no longer generated in new configs
37    #[serde(skip_serializing)]
38    pub device_id: Option<String>,
39}
40
41impl Default for Config {
42    fn default() -> Self {
43        Self {
44            email: None,
45            sso_id: None,
46            base_url: None,
47            identity_url: None,
48            ui_url: None,
49            notifications_url: None,
50            lock_timeout: default_lock_timeout(),
51            sync_interval: default_sync_interval(),
52            pinentry: default_pinentry(),
53            client_cert_path: None,
54            ssh_confirm_sign: false,
55            macos_unlock_dialog: default_macos_unlock_dialog(),
56            touchid_gate: crate::touchid::Gate::Off,
57            device_id: None,
58        }
59    }
60}
61
62pub fn default_lock_timeout() -> u64 {
63    3600
64}
65
66pub fn default_sync_interval() -> u64 {
67    3600
68}
69
70pub fn default_pinentry() -> String {
71    "pinentry".to_string()
72}
73
74pub const fn default_macos_unlock_dialog() -> bool {
75    cfg!(target_os = "macos")
76}
77
78#[allow(clippy::trivially_copy_pass_by_ref)]
79fn is_touchid_gate_off(g: &crate::touchid::Gate) -> bool {
80    matches!(g, crate::touchid::Gate::Off)
81}
82
83mod touchid_gate_serde {
84    use std::str::FromStr as _;
85
86    use serde::{Deserialize as _, Deserializer, Serializer};
87
88    #[allow(clippy::trivially_copy_pass_by_ref)]
89    pub fn serialize<S: Serializer>(
90        g: &crate::touchid::Gate,
91        s: S,
92    ) -> Result<S::Ok, S::Error> {
93        s.serialize_str(&g.to_string())
94    }
95
96    pub fn deserialize<'de, D: Deserializer<'de>>(
97        d: D,
98    ) -> Result<crate::touchid::Gate, D::Error> {
99        let s = String::deserialize(d)?;
100        crate::touchid::Gate::from_str(&s).map_err(serde::de::Error::custom)
101    }
102}
103
104impl Config {
105    pub fn new() -> Self {
106        Self::default()
107    }
108
109    pub fn load() -> Result<Self> {
110        let file = crate::dirs::config_file();
111        let mut fh = std::fs::File::open(&file).map_err(|source| {
112            Error::LoadConfig {
113                source,
114                file: file.clone(),
115            }
116        })?;
117        let mut json = String::new();
118        fh.read_to_string(&mut json)
119            .map_err(|source| Error::LoadConfig {
120                source,
121                file: file.clone(),
122            })?;
123        let mut slf: Self = serde_json::from_str(&json)
124            .map_err(|source| Error::LoadConfigJson { source, file })?;
125        if slf.lock_timeout == 0 {
126            log::warn!("lock_timeout must be greater than 0");
127            slf.lock_timeout = default_lock_timeout();
128        }
129        Ok(slf)
130    }
131
132    pub async fn load_async() -> Result<Self> {
133        let file = crate::dirs::config_file();
134        let mut fh =
135            tokio::fs::File::open(&file).await.map_err(|source| {
136                Error::LoadConfigAsync {
137                    source,
138                    file: file.clone(),
139                }
140            })?;
141        let mut json = String::new();
142        fh.read_to_string(&mut json).await.map_err(|source| {
143            Error::LoadConfigAsync {
144                source,
145                file: file.clone(),
146            }
147        })?;
148        let mut slf: Self = serde_json::from_str(&json)
149            .map_err(|source| Error::LoadConfigJson { source, file })?;
150        if slf.lock_timeout == 0 {
151            log::warn!("lock_timeout must be greater than 0");
152            slf.lock_timeout = default_lock_timeout();
153        }
154        Ok(slf)
155    }
156
157    pub fn save(&self) -> Result<()> {
158        use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
159        let file = crate::dirs::config_file();
160        // unwrap is safe here because Self::filename is explicitly
161        // constructed as a filename in a directory
162        std::fs::create_dir_all(file.parent().unwrap()).map_err(
163            |source| Error::SaveConfig {
164                source,
165                file: file.clone(),
166            },
167        )?;
168        let mut fh = std::fs::OpenOptions::new()
169            .write(true)
170            .create(true)
171            .truncate(true)
172            .mode(0o600)
173            .open(&file)
174            .map_err(|source| Error::SaveConfig {
175                source,
176                file: file.clone(),
177            })?;
178        // `OpenOptions::mode` only applies on file creation; tighten
179        // unconditionally so a pre-existing loose-mode file is corrected
180        // on every write.
181        fh.set_permissions(std::fs::Permissions::from_mode(0o600))
182            .map_err(|source| Error::SaveConfig {
183                source,
184                file: file.clone(),
185            })?;
186        fh.write_all(
187            serde_json::to_string(self)
188                .map_err(|source| Error::SaveConfigJson {
189                    source,
190                    file: file.clone(),
191                })?
192                .as_bytes(),
193        )
194        .map_err(|source| Error::SaveConfig { source, file })?;
195        Ok(())
196    }
197
198    pub fn validate() -> Result<()> {
199        let config = Self::load()?;
200        if config.email.is_none() {
201            return Err(Error::ConfigMissingEmail);
202        }
203        Ok(())
204    }
205
206    pub fn base_url(&self) -> String {
207        self.base_url.clone().map_or_else(
208            || "https://api.bitwarden.com".to_string(),
209            |url| {
210                let clean_url = url.trim_end_matches('/');
211                if clean_url == "https://api.bitwarden.eu" {
212                    "https://api.bitwarden.eu".to_string()
213                } else {
214                    format!("{clean_url}/api")
215                }
216            },
217        )
218    }
219
220    pub fn identity_url(&self) -> String {
221        self.identity_url.clone().unwrap_or_else(|| {
222            self.base_url.clone().map_or_else(
223                || "https://identity.bitwarden.com".to_string(),
224                |url| {
225                    let clean_url = url.trim_end_matches('/');
226                    if clean_url == "https://api.bitwarden.eu" {
227                        "https://identity.bitwarden.eu".to_string()
228                    } else {
229                        format!("{clean_url}/identity")
230                    }
231                },
232            )
233        })
234    }
235
236    pub fn ui_url(&self) -> String {
237        self.ui_url.clone().unwrap_or_else(|| {
238            self.base_url.clone().map_or_else(
239                || "https://vault.bitwarden.com".to_string(),
240                |url| {
241                    let clean_url = url.trim_end_matches('/');
242                    if clean_url == "https://api.bitwarden.eu" {
243                        "https://vault.bitwarden.eu".to_string()
244                    } else {
245                        clean_url.to_string()
246                    }
247                },
248            )
249        })
250    }
251
252    pub fn notifications_url(&self) -> String {
253        self.notifications_url.clone().unwrap_or_else(|| {
254            self.base_url.clone().map_or_else(
255                || "https://notifications.bitwarden.com".to_string(),
256                |url| {
257                    let clean_url = url.trim_end_matches('/');
258                    if clean_url == "https://api.bitwarden.eu" {
259                        "https://notifications.bitwarden.eu".to_string()
260                    } else {
261                        format!("{clean_url}/notifications")
262                    }
263                },
264            )
265        })
266    }
267
268    pub fn client_cert_path(&self) -> Option<&std::path::Path> {
269        self.client_cert_path.as_deref()
270    }
271
272    pub fn server_name(&self) -> String {
273        self.base_url
274            .clone()
275            .unwrap_or_else(|| "default".to_string())
276    }
277}
278
279pub async fn device_id(config: &Config) -> Result<String> {
280    let file = crate::dirs::device_id_file();
281    if let Ok(mut fh) = tokio::fs::File::open(&file).await {
282        let mut s = String::new();
283        fh.read_to_string(&mut s)
284            .await
285            .map_err(|e| Error::LoadDeviceId {
286                source: e,
287                file: file.clone(),
288            })?;
289        Ok(s.trim().to_string())
290    } else {
291        use std::os::unix::fs::PermissionsExt as _;
292        let id = config.device_id.as_ref().map_or_else(
293            || crate::uuid::new_v4().to_string(),
294            String::to_string,
295        );
296        let mut fh = tokio::fs::OpenOptions::new()
297            .write(true)
298            .create(true)
299            .truncate(true)
300            .mode(0o600)
301            .open(&file)
302            .await
303            .map_err(|e| Error::LoadDeviceId {
304                source: e,
305                file: file.clone(),
306            })?;
307        // `OpenOptions::mode` only applies on create; tighten
308        // unconditionally so a pre-existing loose-mode file gets
309        // corrected on the next write.
310        fh.set_permissions(std::fs::Permissions::from_mode(0o600))
311            .await
312            .map_err(|e| Error::LoadDeviceId {
313                source: e,
314                file: file.clone(),
315            })?;
316        fh.write_all(id.as_bytes()).await.map_err(|e| {
317            Error::LoadDeviceId {
318                source: e,
319                file: file.clone(),
320            }
321        })?;
322        Ok(id)
323    }
324}