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