Skip to main content

forge_workspace/
vault.rs

1//! The process-wide vault session and its audit.
2//!
3//! One `VaultSession` per forge process, built from `[vault]` at boot and
4//! swapped on a reload that changes the section. It owns the tokens and the
5//! derived keys; the connector holds neither. A call the server rejects with
6//! 401 tries the refresh token, and only a failed refresh locks the session
7//! and makes the next held call ask for the master password. `[vault]
8//! idle_minutes`, when set, locks an unused session on its first read after
9//! the bound.
10//!
11//! The master password never enters this struct. It arrives with one answer,
12//! is used to derive the master key, and is zeroized before the answer is
13//! dropped.
14
15use std::path::{Path, PathBuf};
16
17use forge_connectors::vault::crypto::{self, Unlocked};
18use forge_connectors::vault::models::{Prelogin, SyncData, TokenResponse, TwoFactorCode};
19use forge_connectors::vault::{VaultClient, VaultError};
20use forge_primitives::vault::{VaultReleased, VaultTool};
21use serde_json::Value;
22use zeroize::{Zeroize, Zeroizing};
23
24use crate::SessionSlot;
25use crate::config::VaultConfig;
26use crate::protocol::SessionUpdate;
27use crate::update_fanout::UpdateFanout;
28
29/// The access and refresh tokens one unlock produced.
30struct Tokens {
31    access: String,
32    refresh: Option<String>,
33}
34
35impl Drop for Tokens {
36    fn drop(&mut self) {
37        self.access.zeroize();
38        if let Some(refresh) = &mut self.refresh {
39            refresh.zeroize();
40        }
41    }
42}
43
44/// The session's live half: the tokens and the derived keys, or neither.
45#[derive(Default)]
46struct SessionState {
47    tokens: Option<Tokens>,
48    keys: Option<std::sync::Arc<Unlocked>>,
49}
50
51/// One process-wide Vaultwarden session.
52pub(crate) struct VaultSession {
53    client: VaultClient,
54    email: String,
55    state: parking_lot::RwLock<SessionState>,
56    /// Serializes unlocks, so two parked cards that both carry a password
57    /// cannot race: the first wins and the second reads an unlocked session.
58    unlock_gate: tokio::sync::Mutex<()>,
59    /// Serializes refreshes. A rotating refresh token is spent once, so two
60    /// concurrent 401s must not both redeem it; the second reads the token
61    /// the first stored.
62    refresh_gate: tokio::sync::Mutex<()>,
63    /// Where a rotation is announced: the enrolment blobs hold the old token
64    /// and the client that owns one rewrites it from this.
65    updates: UpdateFanout,
66    /// The configured idle bound in minutes, or `None` for no idle lock.
67    idle_minutes: Option<u64>,
68    /// When the session was last used, for the idle clock. **Wall clock, not
69    /// `Instant`**: a monotonic clock stops while the machine sleeps, so an
70    /// overnight lid-close would never reach the bound.
71    last_used: parking_lot::Mutex<Option<std::time::SystemTime>>,
72    /// Bumped by every lock, so an unlock whose network work was overtaken by
73    /// a lock does not install its keys under it.
74    lock_generation: std::sync::atomic::AtomicU64,
75    audit: Option<VaultAudit>,
76}
77
78impl VaultSession {
79    /// Build the session's audit around a client the workspace built. The
80    /// audit path is `<app-support>/vault/audit-<config-dir-store-key>.jsonl`;
81    /// a missing app-support dir leaves the session usable and the audit off.
82    pub(crate) fn with_client(
83        config: &VaultConfig,
84        app_support: Option<&Path>,
85        config_dir: &Path,
86        client: VaultClient,
87        updates: UpdateFanout,
88    ) -> Result<Self, VaultError> {
89        let audit = app_support.and_then(|base| {
90            match VaultAudit::new(base, &forge_sdk::config_dir_store_key(config_dir)) {
91                Ok(audit) => Some(audit),
92                Err(error) => {
93                    tracing::warn!(
94                        event_name = "vault_audit_open_failed",
95                        path = %base.join("vault").display(),
96                        %error,
97                        "the vault audit could not be opened; operations will not be recorded"
98                    );
99                    None
100                }
101            }
102        });
103        Ok(Self {
104            client,
105            email: config.email.clone(),
106            state: parking_lot::RwLock::new(SessionState::default()),
107            unlock_gate: tokio::sync::Mutex::new(()),
108            refresh_gate: tokio::sync::Mutex::new(()),
109            updates,
110            idle_minutes: config.idle_minutes,
111            last_used: parking_lot::Mutex::new(None),
112            lock_generation: std::sync::atomic::AtomicU64::new(0),
113            audit,
114        })
115    }
116
117    /// The idle bound the config set, for the settings card.
118    pub(crate) fn idle_minutes(&self) -> Option<u64> {
119        self.idle_minutes
120    }
121
122    /// Whether the derived keys are absent. A session that has never
123    /// unlocked, one whose refresh failed and one past its idle bound all
124    /// read locked.
125    pub(crate) fn is_locked(&self) -> bool {
126        self.lock_if_idle();
127        self.state.read().keys.is_none()
128    }
129
130    /// Lock a session that has gone unused past its configured bound. The
131    /// clock is read here rather than kept by a timer, so the lock lands on
132    /// the first touch after the bound - and a session nothing touches is
133    /// never read unlocked by anyone.
134    fn lock_if_idle(&self) {
135        let Some(minutes) = self.idle_minutes else { return };
136        if self.state.read().keys.is_none() {
137            return;
138        }
139        // A clock that moved backwards (an NTP step, a manual change) reads as
140        // no time passed rather than as an instant lock.
141        let Some(elapsed) = self
142            .last_used
143            .lock()
144            .as_ref()
145            .and_then(|stamp| std::time::SystemTime::now().duration_since(*stamp).ok())
146        else {
147            return;
148        };
149        if elapsed >= std::time::Duration::from_secs(minutes.saturating_mul(60)) {
150            tracing::info!(
151                event_name = "vault_idle_locked",
152                minutes,
153                "the vault session passed its idle bound and locked itself"
154            );
155            self.lock();
156        }
157    }
158
159    /// Stamp a use of the session, for the idle clock.
160    fn touch(&self) {
161        *self.last_used.lock() = Some(std::time::SystemTime::now());
162    }
163
164    /// The unlocked keys. A caller that needs crypto reads these; a locked
165    /// session returns its own error rather than an option a caller forgets.
166    pub(crate) fn keys(&self) -> Result<std::sync::Arc<Unlocked>, VaultError> {
167        self.lock_if_idle();
168        let keys = self.state.read().keys.clone().ok_or(VaultError::Locked)?;
169        self.touch();
170        Ok(keys)
171    }
172
173    /// The material a client wraps to enrol this device: the current refresh
174    /// token and the account's user key, base64 - or `None` when the session
175    /// holds no tokens or keys, and when it holds no refresh token, because a
176    /// blob built on material that cannot refresh is a shortcut to nothing.
177    pub(crate) fn enrolment_material(&self) -> Option<VaultReleased> {
178        // A hand-out is a read like any other: it must not revive a session
179        // past its idle bound, and it stamps the clock when it answers.
180        self.lock_if_idle();
181        let state = self.state.read();
182        let keys = state.keys.as_ref()?;
183        let tokens = state.tokens.as_ref()?;
184        let refresh = tokens.refresh.as_deref().filter(|token| !token.is_empty())?;
185        let material = VaultReleased {
186            refresh_token: refresh.to_owned(),
187            user_key: keys.user_key.to_base64().to_string(),
188        };
189        drop(state);
190        self.touch();
191        tracing::info!(
192            event_name = "vault_enrolment_material_released",
193            "a client took the session's enrolment material"
194        );
195        Some(material)
196    }
197
198    /// Tell every view the refresh token rotated. The token is a secret: it
199    /// rides the live socket for the client that owns an enrolment to rewrite
200    /// its blob, and no store keeps it.
201    fn announce_rotation(&self, refresh_token: String) {
202        let _ = self.updates.send(SessionUpdate::VaultRotated { refresh_token });
203    }
204
205    /// Derive the master key and unlock the account, storing the tokens and
206    /// the user/private/organization keys. Idempotent: a session another
207    /// card already unlocked is left alone.
208    pub(crate) async fn unlock(
209        &self,
210        password: &str,
211        two_factor: Option<&str>,
212    ) -> Result<(), VaultError> {
213        let _gate = self.unlock_gate.lock().await;
214        if !self.is_locked() {
215            return Ok(());
216        }
217        let generation = self.lock_generation.load(std::sync::atomic::Ordering::SeqCst);
218
219        let prelogin: Prelogin = self.client.prelogin(&self.email).await?;
220        // `bitwarden-crypto`'s `MasterKey` has no public zeroize; it is
221        // dropped as soon as the user and private keys are unwrapped.
222        let master_key = crypto::derive_master_key(password, &self.email, &prelogin)
223            .map_err(VaultError::Crypto)?;
224        let hash = Zeroizing::new(crypto::master_password_hash(&master_key, password));
225        let mut two_factor =
226            two_factor.map(|token| TwoFactorCode { token: token.to_owned(), provider: 0 });
227        let login = self.client.login_password(&self.email, &hash, two_factor.as_ref()).await;
228        if let Some(code) = &mut two_factor {
229            code.token.zeroize();
230        }
231        // Every failure below drops the token through its `Drop`, which wipes
232        // the access and refresh tokens and the wrapped keys.
233        let mut token: TokenResponse = login?;
234        let sync = self.client.sync(&token.access_token).await?;
235        let unlocked =
236            crypto::unlock(&token, &master_key, &sync.profile).map_err(VaultError::Crypto)?;
237        if !unlocked.failed_orgs.is_empty() {
238            tracing::warn!(
239                event_name = "vault_org_keys_unavailable",
240                orgs = ?unlocked.failed_orgs,
241                "organization keys were not unlocked; their items are refused until the next unlock"
242            );
243        }
244
245        let mut state = self.state.write();
246        if self.lock_generation.load(std::sync::atomic::Ordering::SeqCst) != generation {
247            // **A lock overtook this unlock**: the person pressed lock, or the
248            // idle bound fired, while the network work was out. Installing
249            // the keys now would undo that lock.
250            return Err(VaultError::Locked);
251        }
252        state.tokens = Some(Tokens {
253            access: std::mem::take(&mut token.access_token),
254            refresh: token.refresh_token.take(),
255        });
256        state.keys = Some(std::sync::Arc::new(unlocked));
257        drop(state);
258        self.touch();
259        let _ = self.updates.send(SessionUpdate::VaultLockChanged { locked: false });
260        Ok(())
261    }
262
263    /// Unlock from material a client released through its platform
264    /// authenticator: the enrolment's refresh token and the account's user
265    /// key.
266    ///
267    /// The destination is the password unlock's - a token, a sync and the
268    /// held keys - without the master password crossing on this start. The
269    /// server cannot prove the released user key (client-side enforcement is
270    /// the design); a wrong one fails when the private key will not unwrap.
271    /// Idempotent like [`Self::unlock`]: a session another card unlocked is
272    /// left alone.
273    pub(crate) async fn unlock_released(
274        &self,
275        refresh_token: &str,
276        user_key: &str,
277    ) -> Result<(), VaultError> {
278        let _gate = self.unlock_gate.lock().await;
279        if !self.is_locked() {
280            return Ok(());
281        }
282        let generation = self.lock_generation.load(std::sync::atomic::Ordering::SeqCst);
283
284        // The token is spent once and the endpoint rotates it; the response's
285        // next refresh token is what this session keeps for its own refreshes.
286        let mut token: TokenResponse = self.client.refresh(refresh_token).await?;
287        let sync = self.client.sync(&token.access_token).await?;
288        let unlocked = crypto::unlock_with_user_key(&token, user_key, &sync.profile)
289            .map_err(VaultError::Crypto)?;
290        if !unlocked.failed_orgs.is_empty() {
291            tracing::warn!(
292                event_name = "vault_org_keys_unavailable",
293                orgs = ?unlocked.failed_orgs,
294                "organization keys were not unlocked; their items are refused until the next unlock"
295            );
296        }
297
298        let mut state = self.state.write();
299        if self.lock_generation.load(std::sync::atomic::Ordering::SeqCst) != generation {
300            return Err(VaultError::Locked);
301        }
302        // **News only when the token moved**: a redemption that answers the
303        // released token again leaves the client's blob correct, so there is
304        // nothing to rewrite.
305        let rotated = token.refresh_token.clone().filter(|next| next != refresh_token);
306        state.tokens = Some(Tokens {
307            access: std::mem::take(&mut token.access_token),
308            refresh: token.refresh_token.take(),
309        });
310        state.keys = Some(std::sync::Arc::new(unlocked));
311        drop(state);
312        self.touch();
313        let _ = self.updates.send(SessionUpdate::VaultLockChanged { locked: false });
314        if let Some(refresh_token) = rotated {
315            self.announce_rotation(refresh_token);
316        }
317        Ok(())
318    }
319
320    /// The current access token, or `Locked` when there is none. The clone is
321    /// zeroized when the caller drops it.
322    fn access_token(&self) -> Result<Zeroizing<String>, VaultError> {
323        self.lock_if_idle();
324        let token = self
325            .state
326            .read()
327            .tokens
328            .as_ref()
329            .map(|tokens| Zeroizing::new(tokens.access.clone()))
330            .ok_or(VaultError::Locked)?;
331        self.touch();
332        Ok(token)
333    }
334
335    /// Refresh the access token. A missing or rejected refresh token clears
336    /// the keys and tokens, which is what makes the next held call ask for
337    /// the master password; a session with no refresh token must lock too,
338    /// or the card would never ask and every call would repeat the 401.
339    ///
340    /// `spent` is the token that got the 401. Another call may have refreshed
341    /// while this one waited on the gate; its token is then current and the
342    /// refresh token was already spent, so that answer is handed back instead
343    /// of redeeming the same refresh token twice.
344    pub(crate) async fn refresh(&self, spent: &str) -> Result<Zeroizing<String>, VaultError> {
345        let _gate = self.refresh_gate.lock().await;
346        let current =
347            self.state.read().tokens.as_ref().map(|tokens| Zeroizing::new(tokens.access.clone()));
348        match current {
349            None => return Err(VaultError::Locked),
350            Some(access) if access.as_str() != spent => return Ok(access),
351            Some(access) => drop(access),
352        }
353        let Some(mut refresh) = ({
354            let state = self.state.read();
355            state.tokens.as_ref().and_then(|tokens| tokens.refresh.clone())
356        }) else {
357            self.lock();
358            return Err(VaultError::Locked);
359        };
360        match self.client.refresh(&refresh).await {
361            Ok(mut token) => {
362                let access = Zeroizing::new(token.access_token.clone());
363                let previous = refresh.clone();
364                let next_refresh =
365                    token.refresh_token.take().or_else(|| Some(std::mem::take(&mut refresh)));
366                refresh.zeroize();
367                // **A rotation is news only when the token moved.** An
368                // endpoint that answers the same refresh token again would
369                // otherwise make every client rewrite a blob nothing changed.
370                let rotated = next_refresh.clone().filter(|next| next != &previous);
371                if let Some(tokens) = self.state.write().tokens.as_mut() {
372                    tokens.access.zeroize();
373                    if let Some(mut old) = tokens.refresh.take() {
374                        old.zeroize();
375                    }
376                    tokens.access = std::mem::take(&mut token.access_token);
377                    tokens.refresh = next_refresh;
378                }
379                if let Some(refresh_token) = rotated {
380                    self.announce_rotation(refresh_token);
381                }
382                Ok(access)
383            }
384            Err(error) => {
385                refresh.zeroize();
386                self.lock();
387                Err(error)
388            }
389        }
390    }
391
392    /// Drop the tokens and the derived keys. The key types zeroize on drop.
393    /// A lock that found a session open announces it, so every view's vault
394    /// card redraws - the settings page's own among them.
395    pub(crate) fn lock(&self) {
396        // **Bumped before the state write**: an unlock that already read the
397        // generation will find it changed and refuse to install its keys, so
398        // this lock cannot be undone by network work that was already out.
399        self.lock_generation.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
400        let was_unlocked = {
401            let mut state = self.state.write();
402            let was_unlocked = state.keys.is_some();
403            state.tokens = None;
404            state.keys = None;
405            was_unlocked
406        };
407        *self.last_used.lock() = None;
408        if was_unlocked {
409            let _ = self.updates.send(SessionUpdate::VaultLockChanged { locked: true });
410        }
411    }
412
413    /// A decryption failure under a key that has not changed means the account
414    /// rotated its keys: drop them, so the next held call asks for the master
415    /// password rather than repeating a failure against stale keys.
416    pub(crate) fn note_decrypt_failure(&self, caller: &SessionSlot, item: Option<&str>) {
417        tracing::warn!(
418            event_name = "vault_decrypt_failed",
419            caller = %caller.display(),
420            item = item,
421            vault = %self.client.base(),
422            "a vault decryption failed; the session's keys are dropped and the next call will unlock"
423        );
424        self.lock();
425    }
426
427    /// A write must not run against keys the account rotated while the session
428    /// stayed open: its ciphertext would be unreadable after the next unlock.
429    /// The wrapped user key's fingerprint from unlock is compared with the
430    /// one a fresh sync carries. The sync comes back so a write can stamp the
431    /// account id from the same read that verified the key.
432    pub(crate) async fn ensure_keys_current(
433        &self,
434        caller: &SessionSlot,
435        item: Option<&str>,
436    ) -> Result<SyncData, VaultError> {
437        let keys = self.keys()?;
438        let sync = self.sync().await?;
439        let Some(profile_key) = sync.profile.key.as_deref().filter(|key| !key.is_empty()) else {
440            // A sync that omits the key cannot prove the unlock still holds;
441            // proceeding would be the corruption this check exists to stop.
442            self.note_key_unverifiable(caller, item);
443            return Err(VaultError::KeyUnverifiable);
444        };
445        if crypto::sha256_hex(profile_key.as_bytes()) != keys.user_key_fingerprint {
446            self.note_key_rotation(caller, item);
447            return Err(VaultError::KeyRotated);
448        }
449        Ok(sync)
450    }
451
452    /// Drop the keys when a sync cannot prove which user key the account
453    /// holds, so the next call asks for the master password instead of
454    /// writing under an unverified unlock.
455    pub(crate) fn note_key_unverifiable(&self, caller: &SessionSlot, item: Option<&str>) {
456        tracing::warn!(
457            event_name = "vault_key_unverifiable",
458            caller = %caller.display(),
459            item = item,
460            vault = %self.client.base(),
461            "the sync carried no wrapped user key; the session is locked"
462        );
463        self.lock();
464    }
465
466    /// Drop the keys after an account rotation; re-entering the master
467    /// password is the only thing that fixes it.
468    pub(crate) fn note_key_rotation(&self, caller: &SessionSlot, item: Option<&str>) {
469        tracing::warn!(
470            event_name = "vault_keys_rotated",
471            caller = %caller.display(),
472            item = item,
473            vault = %self.client.base(),
474            "the account's wrapped user key changed while unlocked; the session is locked"
475        );
476        self.lock();
477    }
478
479    /// `GET /api/sync`, through the refresh path.
480    pub(crate) async fn sync(&self) -> Result<SyncData, VaultError> {
481        let token = self.access_token()?;
482        match self.client.sync(&token).await {
483            Err(VaultError::Unauthorized) => {
484                let token = self.refresh(&token).await?;
485                match self.client.sync(&token).await {
486                    Err(VaultError::Unauthorized) => {
487                        self.lock();
488                        Err(VaultError::Locked)
489                    }
490                    other => other,
491                }
492            }
493            other => other,
494        }
495    }
496
497    pub(crate) async fn get_cipher(&self, id: &str) -> Result<Value, VaultError> {
498        let token = self.access_token()?;
499        match self.client.get_cipher(&token, id).await {
500            Err(VaultError::Unauthorized) => {
501                let token = self.refresh(&token).await?;
502                match self.client.get_cipher(&token, id).await {
503                    Err(VaultError::Unauthorized) => {
504                        self.lock();
505                        Err(VaultError::Locked)
506                    }
507                    other => other,
508                }
509            }
510            other => other,
511        }
512    }
513
514    pub(crate) async fn create_cipher(&self, body: &Value) -> Result<Value, VaultError> {
515        let token = self.access_token()?;
516        match self.client.create_cipher(&token, body).await {
517            Err(VaultError::Unauthorized) => {
518                let token = self.refresh(&token).await?;
519                match self.client.create_cipher(&token, body).await {
520                    Err(VaultError::Unauthorized) => {
521                        self.lock();
522                        Err(VaultError::Locked)
523                    }
524                    other => other,
525                }
526            }
527            other => other,
528        }
529    }
530
531    pub(crate) async fn update_cipher(&self, id: &str, body: &Value) -> Result<Value, VaultError> {
532        let token = self.access_token()?;
533        match self.client.update_cipher(&token, id, body).await {
534            Err(VaultError::Unauthorized) => {
535                let token = self.refresh(&token).await?;
536                match self.client.update_cipher(&token, id, body).await {
537                    Err(VaultError::Unauthorized) => {
538                        self.lock();
539                        Err(VaultError::Locked)
540                    }
541                    other => other,
542                }
543            }
544            other => other,
545        }
546    }
547
548    pub(crate) async fn trash_cipher(&self, id: &str) -> Result<Value, VaultError> {
549        let token = self.access_token()?;
550        match self.client.trash_cipher(&token, id).await {
551            Err(VaultError::Unauthorized) => {
552                let token = self.refresh(&token).await?;
553                match self.client.trash_cipher(&token, id).await {
554                    Err(VaultError::Unauthorized) => {
555                        self.lock();
556                        Err(VaultError::Locked)
557                    }
558                    other => other,
559                }
560            }
561            other => other,
562        }
563    }
564
565    pub(crate) async fn restore_cipher(&self, id: &str) -> Result<Value, VaultError> {
566        let token = self.access_token()?;
567        match self.client.restore_cipher(&token, id).await {
568            Err(VaultError::Unauthorized) => {
569                let token = self.refresh(&token).await?;
570                match self.client.restore_cipher(&token, id).await {
571                    Err(VaultError::Unauthorized) => {
572                        self.lock();
573                        Err(VaultError::Locked)
574                    }
575                    other => other,
576                }
577            }
578            other => other,
579        }
580    }
581
582    pub(crate) async fn list_folders(
583        &self,
584    ) -> Result<Vec<forge_connectors::vault::Folder>, VaultError> {
585        let token = self.access_token()?;
586        match self.client.list_folders(&token).await {
587            Err(VaultError::Unauthorized) => {
588                let token = self.refresh(&token).await?;
589                match self.client.list_folders(&token).await {
590                    Err(VaultError::Unauthorized) => {
591                        self.lock();
592                        Err(VaultError::Locked)
593                    }
594                    other => other,
595                }
596            }
597            other => other,
598        }
599    }
600
601    pub(crate) async fn create_folder(&self, name: &str) -> Result<Value, VaultError> {
602        let token = self.access_token()?;
603        match self.client.create_folder(&token, name).await {
604            Err(VaultError::Unauthorized) => {
605                let token = self.refresh(&token).await?;
606                match self.client.create_folder(&token, name).await {
607                    Err(VaultError::Unauthorized) => {
608                        self.lock();
609                        Err(VaultError::Locked)
610                    }
611                    other => other,
612                }
613            }
614            other => other,
615        }
616    }
617
618    pub(crate) async fn update_folder(&self, id: &str, name: &str) -> Result<Value, VaultError> {
619        let token = self.access_token()?;
620        match self.client.update_folder(&token, id, name).await {
621            Err(VaultError::Unauthorized) => {
622                let token = self.refresh(&token).await?;
623                match self.client.update_folder(&token, id, name).await {
624                    Err(VaultError::Unauthorized) => {
625                        self.lock();
626                        Err(VaultError::Locked)
627                    }
628                    other => other,
629                }
630            }
631            other => other,
632        }
633    }
634
635    pub(crate) async fn delete_folder(&self, id: &str) -> Result<Value, VaultError> {
636        let token = self.access_token()?;
637        match self.client.delete_folder(&token, id).await {
638            Err(VaultError::Unauthorized) => {
639                let token = self.refresh(&token).await?;
640                match self.client.delete_folder(&token, id).await {
641                    Err(VaultError::Unauthorized) => {
642                        self.lock();
643                        Err(VaultError::Locked)
644                    }
645                    other => other,
646                }
647            }
648            other => other,
649        }
650    }
651
652    pub(crate) async fn create_attachment_slot(
653        &self,
654        cipher_id: &str,
655        body: &Value,
656    ) -> Result<Value, VaultError> {
657        let token = self.access_token()?;
658        match self.client.create_attachment_slot(&token, cipher_id, body).await {
659            Err(VaultError::Unauthorized) => {
660                let token = self.refresh(&token).await?;
661                match self.client.create_attachment_slot(&token, cipher_id, body).await {
662                    Err(VaultError::Unauthorized) => {
663                        self.lock();
664                        Err(VaultError::Locked)
665                    }
666                    other => other,
667                }
668            }
669            other => other,
670        }
671    }
672
673    pub(crate) async fn upload_attachment(
674        &self,
675        cipher_id: &str,
676        attachment_id: &str,
677        data: &[u8],
678    ) -> Result<Value, VaultError> {
679        let token = self.access_token()?;
680        match self.client.upload_attachment(&token, cipher_id, attachment_id, data).await {
681            Err(VaultError::Unauthorized) => {
682                let token = self.refresh(&token).await?;
683                match self.client.upload_attachment(&token, cipher_id, attachment_id, data).await {
684                    Err(VaultError::Unauthorized) => {
685                        self.lock();
686                        Err(VaultError::Locked)
687                    }
688                    other => other,
689                }
690            }
691            other => other,
692        }
693    }
694
695    pub(crate) async fn get_attachment_meta(
696        &self,
697        cipher_id: &str,
698        attachment_id: &str,
699    ) -> Result<forge_connectors::vault::AttachmentMeta, VaultError> {
700        let token = self.access_token()?;
701        match self.client.get_attachment_meta(&token, cipher_id, attachment_id).await {
702            Err(VaultError::Unauthorized) => {
703                let token = self.refresh(&token).await?;
704                match self.client.get_attachment_meta(&token, cipher_id, attachment_id).await {
705                    Err(VaultError::Unauthorized) => {
706                        self.lock();
707                        Err(VaultError::Locked)
708                    }
709                    other => other,
710                }
711            }
712            other => other,
713        }
714    }
715
716    pub(crate) async fn download_attachment(
717        &self,
718        url: &str,
719        max_bytes: u64,
720    ) -> Result<Vec<u8>, VaultError> {
721        let token = self.access_token()?;
722        match self.client.download_attachment(&token, url, max_bytes).await {
723            Err(VaultError::Unauthorized) => {
724                let token = self.refresh(&token).await?;
725                match self.client.download_attachment(&token, url, max_bytes).await {
726                    Err(VaultError::Unauthorized) => {
727                        self.lock();
728                        Err(VaultError::Locked)
729                    }
730                    other => other,
731                }
732            }
733            other => other,
734        }
735    }
736
737    pub(crate) async fn delete_attachment(
738        &self,
739        cipher_id: &str,
740        attachment_id: &str,
741    ) -> Result<Value, VaultError> {
742        let token = self.access_token()?;
743        match self.client.delete_attachment(&token, cipher_id, attachment_id).await {
744            Err(VaultError::Unauthorized) => {
745                let token = self.refresh(&token).await?;
746                match self.client.delete_attachment(&token, cipher_id, attachment_id).await {
747                    Err(VaultError::Unauthorized) => {
748                        self.lock();
749                        Err(VaultError::Locked)
750                    }
751                    other => other,
752                }
753            }
754            other => other,
755        }
756    }
757
758    /// One audit line: who, which tool, which item and field, what was
759    /// decided, and what the operation did. Never a secret value.
760    pub(crate) fn audit(
761        &self,
762        caller: &SessionSlot,
763        tool: VaultTool,
764        item_id: Option<&str>,
765        item_name: Option<&str>,
766        field: Option<&str>,
767        decision: &str,
768        result: &str,
769    ) {
770        let Some(audit) = &self.audit else { return };
771        let line = serde_json::json!({
772            "at": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
773            "caller": caller.display().clone(),
774            "tool": tool.as_str(),
775            "item_id": item_id,
776            "item_name": item_name,
777            "field": field,
778            "decision": decision,
779            "result": result,
780        });
781        if let Err(error) = audit.append(&line.to_string()) {
782            tracing::error!(
783                target: "forge_workspace::vault",
784                event_name = "vault_audit_write_failed",
785                %error,
786                "a vault audit line was not written",
787            );
788        }
789    }
790}
791
792/// The shared vault session as a view draws it: whether it is locked and the
793/// idle bound the config set.
794#[derive(Debug, Clone, PartialEq, Eq)]
795pub struct VaultState {
796    pub locked: bool,
797    pub idle_minutes: Option<u64>,
798}
799
800impl crate::workspace::Workspace {
801    /// The material a client wraps to enrol this device, from the shared
802    /// session - or `None` when it is locked or no `[vault]` is configured.
803    pub(crate) fn vault_enrolment_material(&self) -> Option<VaultReleased> {
804        self.vault.read().as_ref()?.enrolment_material()
805    }
806
807    /// The shared vault session as a view draws it - `None` when no `[vault]`
808    /// is configured.
809    pub fn vault_state(&self) -> Option<VaultState> {
810        let session = self.vault.read().clone()?;
811        Some(VaultState { locked: session.is_locked(), idle_minutes: session.idle_minutes() })
812    }
813}
814
815/// Install an unlocked state on a test session without the login round-trip.
816#[cfg(test)]
817pub(crate) fn test_install_unlocked(session: &VaultSession, unlocked: Unlocked) {
818    let mut state = session.state.write();
819    state.tokens =
820        Some(Tokens { access: "test-access".to_owned(), refresh: Some("test-refresh".to_owned()) });
821    state.keys = Some(std::sync::Arc::new(unlocked));
822}
823
824/// A session over the connector's own client, for local test servers. The
825/// production path builds the clients through [`production_client`].
826#[cfg(test)]
827pub(crate) fn test_session(
828    config: &VaultConfig,
829    app_support: Option<&Path>,
830    config_dir: &Path,
831) -> Result<VaultSession, VaultError> {
832    test_session_with_updates(config, app_support, config_dir, UpdateFanout::default())
833}
834
835/// [`test_session`] over a fan-out the test holds, so it can read what the
836/// session announces.
837#[cfg(test)]
838pub(crate) fn test_session_with_updates(
839    config: &VaultConfig,
840    app_support: Option<&Path>,
841    config_dir: &Path,
842    updates: UpdateFanout,
843) -> Result<VaultSession, VaultError> {
844    let client = VaultClient::new(&config.url)?;
845    VaultSession::with_client(config, app_support, config_dir, client, updates)
846}
847
848/// The production vault clients: the machine's extra TLS roots, `https_only`,
849/// the account's own user agent, and the vault family's bounds. The download
850/// client takes a longer read and total bound because an attachment is opaque
851/// ciphertext up to the server's cap.
852pub(crate) fn production_client(base: &str) -> Result<VaultClient, VaultError> {
853    use forge_connectors::vault::client::{
854        CONNECT_TIMEOUT, DOWNLOAD_READ_TIMEOUT, DOWNLOAD_TIMEOUT, READ_TIMEOUT, REQUEST_TIMEOUT,
855    };
856    let build = |read_timeout: std::time::Duration, timeout: std::time::Duration| {
857        forge_agent::http_trust::with_extra_roots(
858            reqwest::Client::builder()
859                .user_agent(forge_connectors::vault::client::user_agent())
860                .https_only(true)
861                .connect_timeout(CONNECT_TIMEOUT)
862                .read_timeout(read_timeout)
863                .timeout(timeout),
864        )
865        .build()
866        .map_err(VaultError::Http)
867    };
868    VaultClient::with_clients(
869        base,
870        build(READ_TIMEOUT, REQUEST_TIMEOUT)?,
871        build(DOWNLOAD_READ_TIMEOUT, DOWNLOAD_TIMEOUT)?,
872    )
873}
874
875/// The append-only audit file, rotated 10 MB x 6.
876struct VaultAudit {
877    path: PathBuf,
878    file: parking_lot::Mutex<AuditFile>,
879}
880
881struct AuditFile {
882    file: std::fs::File,
883    size: u64,
884}
885
886const AUDIT_MAX_BYTES: u64 = 10 * 1024 * 1024;
887const AUDIT_MAX_FILES: usize = 6;
888
889impl VaultAudit {
890    fn new(app_support: &Path, store_key: &str) -> std::io::Result<Self> {
891        use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
892        let dir = app_support.join("vault");
893        std::fs::create_dir_all(&dir)?;
894        let path = dir.join(format!("audit-{store_key}.jsonl"));
895        // `O_NOFOLLOW` refuses a planted symlink, and the explicit
896        // set_permissions fixes an existing looser file's mode, which `mode`
897        // only applies at creation.
898        let file = std::fs::OpenOptions::new()
899            .create(true)
900            .append(true)
901            .mode(0o600)
902            .custom_flags(libc::O_NOFOLLOW)
903            .open(&path)?;
904        file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
905        let size = file.metadata()?.len();
906        Ok(Self { path, file: parking_lot::Mutex::new(AuditFile { file, size }) })
907    }
908
909    fn append(&self, line: &str) -> std::io::Result<()> {
910        use std::io::Write;
911        let mut audit = self.file.lock();
912        let bytes = line.len() as u64 + 1;
913        if audit.size + bytes > AUDIT_MAX_BYTES {
914            rotate(&self.path, &mut audit)?;
915        }
916        audit.file.write_all(line.as_bytes())?;
917        audit.file.write_all(b"\n")?;
918        audit.size += bytes;
919        audit.file.flush()
920    }
921}
922
923fn rotate(path: &Path, audit: &mut AuditFile) -> std::io::Result<()> {
924    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
925    match std::fs::remove_file(rotated_path(path, AUDIT_MAX_FILES)) {
926        Ok(()) => {}
927        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
928        Err(error) => return Err(error),
929    }
930    for index in (1..AUDIT_MAX_FILES).rev() {
931        let from = rotated_path(path, index);
932        if from.exists() {
933            std::fs::rename(&from, rotated_path(path, index + 1))?;
934        }
935    }
936    // A failed rename is returned rather than followed by a truncate of the
937    // file that was meant to move, which would destroy the audit.
938    std::fs::rename(path, rotated_path(path, 1))?;
939    let file = std::fs::OpenOptions::new()
940        .create(true)
941        .write(true)
942        .truncate(true)
943        .mode(0o600)
944        .custom_flags(libc::O_NOFOLLOW)
945        .open(path)?;
946    file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
947    audit.file = file;
948    audit.size = 0;
949    Ok(())
950}
951
952fn rotated_path(path: &Path, index: usize) -> PathBuf {
953    PathBuf::from(format!("{}.{index}", path.display()))
954}
955
956#[cfg(test)]
957mod tests {
958    use super::*;
959
960    fn config() -> VaultConfig {
961        VaultConfig {
962            url: "http://127.0.0.1:1".to_owned(),
963            email: "hi@vedhavyas.dev".to_owned(),
964            idle_minutes: None,
965        }
966    }
967
968    /// A wall-clock instant the given seconds in the past.
969    fn seconds_ago(seconds: u64) -> std::time::SystemTime {
970        std::time::SystemTime::now()
971            .checked_sub(std::time::Duration::from_secs(seconds))
972            .expect("the clock is older than the test's span")
973    }
974
975    /// A released-material unlock announces a moved token, so the client
976    /// rewrites its blob with the lineage the redemption returned.
977    #[tokio::test]
978    async fn a_released_unlock_announces_a_moved_token() {
979        assert!(released_rotation_announcement("next").await, "a moved token is news");
980    }
981
982    /// **A redemption that answers the released token again is not a
983    /// rotation**, so no client rewrites a blob nothing changed.
984    #[tokio::test]
985    async fn a_released_unlock_does_not_announce_the_same_token() {
986        assert!(!released_rotation_announcement("released").await, "the same token is not news");
987    }
988
989    /// Whether a released-material unlock whose redemption answers `next`
990    /// produced a `VaultRotated`.
991    async fn released_rotation_announcement(next: &str) -> bool {
992        use crate::update_fanout::SubscriberRole;
993
994        let pre =
995            Prelogin { kdf: 0, kdf_iterations: 100_000, kdf_memory: None, kdf_parallelism: None };
996        let master_key = crypto::derive_master_key("open sesame", "hi@vedhavyas.dev", &pre)
997            .expect("the master key");
998        let (user_key, wrapped_user_key) = master_key.make_user_key().expect("the user key");
999        let token = serde_json::json!({
1000            "access_token": "access",
1001            "refresh_token": next,
1002            "Key": wrapped_user_key.to_string(),
1003        })
1004        .to_string();
1005        let (address, _server) = serve(vec![
1006            response("200 OK", &token),
1007            response("200 OK", r#"{"profile":{},"folders":[],"ciphers":[]}"#),
1008        ])
1009        .await;
1010        let updates = UpdateFanout::default();
1011        let mut announced = updates.subscribe(SubscriberRole::Answering);
1012        let config = VaultConfig {
1013            url: format!("http://{address}"),
1014            email: "hi@vedhavyas.dev".to_owned(),
1015            idle_minutes: None,
1016        };
1017        let session = test_session_with_updates(&config, None, Path::new("/tmp"), updates)
1018            .expect("a session");
1019
1020        session
1021            .unlock_released("released", &user_key.0.to_base64().to_string())
1022            .await
1023            .expect("the unlock");
1024
1025        let mut rotated = false;
1026        while let Ok(frame) = announced.try_recv() {
1027            if matches!(&*frame.update, SessionUpdate::VaultRotated { .. }) {
1028                rotated = true;
1029            }
1030        }
1031        rotated
1032    }
1033
1034    /// **A refresh that answers the same token is not a rotation.** Announcing
1035    /// it would make every client rewrite a blob nothing changed.
1036    #[tokio::test]
1037    async fn an_unchanged_refresh_token_is_not_announced() {
1038        assert!(!rotation_announcement("same").await, "the same token is not news");
1039    }
1040
1041    /// A refresh that answers a new token is announced, and its value is what
1042    /// the client rewrites with.
1043    #[tokio::test]
1044    async fn a_changed_refresh_token_is_announced() {
1045        assert!(rotation_announcement("next").await, "a moved token is news");
1046    }
1047
1048    /// Whether a refresh answering `next` produced a `VaultRotated`.
1049    async fn rotation_announcement(next: &str) -> bool {
1050        use crate::update_fanout::SubscriberRole;
1051
1052        let body =
1053            serde_json::json!({ "access_token": "fresh", "refresh_token": next }).to_string();
1054        let (address, _server) = serve(vec![response("200 OK", &body)]).await;
1055        let updates = UpdateFanout::default();
1056        let mut announced = updates.subscribe(SubscriberRole::Answering);
1057        let config = VaultConfig {
1058            url: format!("http://{address}"),
1059            email: "hi@vedhavyas.dev".to_owned(),
1060            idle_minutes: None,
1061        };
1062        let session = test_session_with_updates(&config, None, Path::new("/tmp"), updates)
1063            .expect("a session");
1064        session.state.write().tokens =
1065            Some(Tokens { access: "stale".to_owned(), refresh: Some("same".to_owned()) });
1066
1067        session.refresh("stale").await.expect("the refresh");
1068
1069        let mut rotated = false;
1070        while let Ok(frame) = announced.try_recv() {
1071            if matches!(&*frame.update, SessionUpdate::VaultRotated { .. }) {
1072                rotated = true;
1073            }
1074        }
1075        rotated
1076    }
1077
1078    /// **The enrolment hand-out is a read like any other.** A session past its
1079    /// bound answers no material and announces the lock, so a device cannot
1080    /// enrol against a session the idle clock has already closed.
1081    #[test]
1082    fn the_enrolment_material_is_not_handed_out_past_the_idle_bound() {
1083        use crate::update_fanout::SubscriberRole;
1084
1085        let updates = UpdateFanout::default();
1086        let mut announced = updates.subscribe(SubscriberRole::Answering);
1087        let bound = VaultConfig {
1088            url: "http://127.0.0.1:1".to_owned(),
1089            email: "hi@vedhavyas.dev".to_owned(),
1090            idle_minutes: Some(30),
1091        };
1092        let session = test_session_with_updates(&bound, None, Path::new("/tmp"), updates.clone())
1093            .expect("a session");
1094        test_install_unlocked(
1095            &session,
1096            Unlocked {
1097                user_key: crypto::make_symmetric_key(),
1098                private_key: None,
1099                org_keys: std::collections::HashMap::new(),
1100                failed_orgs: Vec::new(),
1101                user_key_fingerprint: String::new(),
1102            },
1103        );
1104        session.state.write().tokens =
1105            Some(Tokens { access: "access".to_owned(), refresh: Some("refresh".to_owned()) });
1106
1107        assert!(session.enrolment_material().is_some(), "a fresh session hands the material out");
1108        *session.last_used.lock() = Some(seconds_ago(31 * 60));
1109
1110        assert!(
1111            session.enrolment_material().is_none(),
1112            "a session past its bound hands nothing out",
1113        );
1114        assert!(session.is_locked(), "and the hand-out locked it");
1115        match &*announced.try_recv().expect("the lock is announced").update {
1116            SessionUpdate::VaultLockChanged { locked } => assert!(locked),
1117            other => panic!("the hand-out announced something else: {other:?}"),
1118        }
1119    }
1120
1121    /// **A lock that lands while a released-material unlock is out wins**,
1122    /// exactly as it does for the password path.
1123    #[tokio::test]
1124    async fn a_lock_during_a_released_unlock_is_not_undone_by_it() {
1125        use tokio::io::AsyncWriteExt;
1126
1127        let pre =
1128            Prelogin { kdf: 0, kdf_iterations: 100_000, kdf_memory: None, kdf_parallelism: None };
1129        let master_key = crypto::derive_master_key("open sesame", "hi@vedhavyas.dev", &pre)
1130            .expect("the master key");
1131        let (user_key, wrapped_user_key) = master_key.make_user_key().expect("the user key");
1132        let token = serde_json::json!({
1133            "access_token": "access",
1134            "refresh_token": "refresh",
1135            "Key": wrapped_user_key.to_string(),
1136        })
1137        .to_string();
1138
1139        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
1140        let address = listener.local_addr().expect("addr");
1141        let (sync_seen, sync_seen_rx) = tokio::sync::oneshot::channel();
1142        let (release, released) = tokio::sync::oneshot::channel();
1143        tokio::spawn(async move {
1144            let mut sync_seen = Some(sync_seen);
1145            let mut released = Some(released);
1146            for index in 0..2 {
1147                let (mut socket, _) = listener.accept().await.expect("accept");
1148                let _ = read_request(&mut socket).await;
1149                if index == 0 {
1150                    let _ = socket.write_all(response("200 OK", &token).as_bytes()).await;
1151                } else {
1152                    if let Some(signal) = sync_seen.take() {
1153                        let _ = signal.send(());
1154                    }
1155                    if let Some(held) = released.take() {
1156                        let _ = held.await;
1157                    }
1158                    let body = r#"{"profile":{},"folders":[],"ciphers":[]}"#;
1159                    let _ = socket.write_all(response("200 OK", body).as_bytes()).await;
1160                }
1161            }
1162        });
1163
1164        let session = std::sync::Arc::new(
1165            test_session(
1166                &VaultConfig {
1167                    url: format!("http://{address}"),
1168                    email: "hi@vedhavyas.dev".to_owned(),
1169                    idle_minutes: None,
1170                },
1171                None,
1172                Path::new("/tmp"),
1173            )
1174            .expect("a session"),
1175        );
1176        let held = std::sync::Arc::clone(&session);
1177        let released_key = user_key.0.to_base64().to_string();
1178        let unlocking =
1179            tokio::spawn(async move { held.unlock_released("refresh", &released_key).await });
1180        sync_seen_rx.await.expect("the sync request is out");
1181
1182        session.lock();
1183        let _ = release.send(());
1184        let refused = unlocking.await.expect("the unlock finishes");
1185
1186        assert!(matches!(refused, Err(VaultError::Locked)), "the lock wins: {refused:?}");
1187        assert!(session.is_locked(), "the session stays locked");
1188    }
1189
1190    /// **A lock that lands while an unlock is out wins.** The generation bump
1191    /// is what stops network work from installing keys under a lock the
1192    /// person pressed (or the idle clock fired) while the request was away.
1193    #[tokio::test]
1194    async fn a_lock_during_an_unlock_is_not_undone_by_it() {
1195        use tokio::io::AsyncWriteExt;
1196
1197        let pre =
1198            Prelogin { kdf: 0, kdf_iterations: 100_000, kdf_memory: None, kdf_parallelism: None };
1199        let master_key = crypto::derive_master_key("open sesame", "hi@vedhavyas.dev", &pre)
1200            .expect("the master key");
1201        let (_user_key, wrapped_user_key) = master_key.make_user_key().expect("the user key");
1202        let prelogin = serde_json::json!({ "kdf": 0, "kdfIterations": 100_000 }).to_string();
1203        let token = serde_json::json!({
1204            "access_token": "access",
1205            "refresh_token": "refresh",
1206            "Key": wrapped_user_key.to_string(),
1207        })
1208        .to_string();
1209
1210        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
1211        let address = listener.local_addr().expect("addr");
1212        let (sync_seen, sync_seen_rx) = tokio::sync::oneshot::channel();
1213        let (release, released) = tokio::sync::oneshot::channel();
1214        tokio::spawn(async move {
1215            let answers = [prelogin, token];
1216            let mut sync_seen = Some(sync_seen);
1217            let mut released = Some(released);
1218            for index in 0..3 {
1219                let (mut socket, _) = listener.accept().await.expect("accept");
1220                let _ = read_request(&mut socket).await;
1221                if index == 2 {
1222                    // The sync is the last answer the unlock needs: hold it
1223                    // until the test's lock has landed.
1224                    if let Some(signal) = sync_seen.take() {
1225                        let _ = signal.send(());
1226                    }
1227                    if let Some(held) = released.take() {
1228                        let _ = held.await;
1229                    }
1230                    let body = r#"{"profile":{},"folders":[],"ciphers":[]}"#;
1231                    let _ = socket.write_all(response("200 OK", body).as_bytes()).await;
1232                } else if let Some(answer) = answers.get(index) {
1233                    let _ = socket.write_all(response("200 OK", answer).as_bytes()).await;
1234                }
1235            }
1236        });
1237
1238        let session = std::sync::Arc::new(
1239            test_session(
1240                &VaultConfig {
1241                    url: format!("http://{address}"),
1242                    email: "hi@vedhavyas.dev".to_owned(),
1243                    idle_minutes: None,
1244                },
1245                None,
1246                Path::new("/tmp"),
1247            )
1248            .expect("a session"),
1249        );
1250        let held = std::sync::Arc::clone(&session);
1251        let unlocking = tokio::spawn(async move { held.unlock("open sesame", None).await });
1252        sync_seen_rx.await.expect("the sync request is out");
1253
1254        session.lock();
1255        let _ = release.send(());
1256        let refused = unlocking.await.expect("the unlock finishes");
1257
1258        assert!(matches!(refused, Err(VaultError::Locked)), "the lock wins: {refused:?}");
1259        assert!(session.is_locked(), "the session stays locked");
1260    }
1261
1262    /// **The settings card's lock press is a command**, so its dispatch arm is
1263    /// pinned: `VaultLock` locks the shared session and announces the change
1264    /// every view redraws on.
1265    #[test]
1266    fn the_lock_command_locks_the_shared_session() {
1267        use crate::update_fanout::SubscriberRole;
1268
1269        let updates = UpdateFanout::default();
1270        let mut announced = updates.subscribe(SubscriberRole::Answering);
1271        let session =
1272            test_session_with_updates(&config(), None, Path::new("/tmp"), updates.clone())
1273                .expect("a session");
1274        test_install_unlocked(
1275            &session,
1276            Unlocked {
1277                user_key: crypto::make_symmetric_key(),
1278                private_key: None,
1279                org_keys: std::collections::HashMap::new(),
1280                failed_orgs: Vec::new(),
1281                user_key_fingerprint: String::new(),
1282            },
1283        );
1284        let (workspace, _rx) = crate::workspace::Workspace::testing_stub();
1285        *workspace.vault.write() = Some(std::sync::Arc::new(session));
1286
1287        assert!(!workspace.vault_state().expect("a vault").locked, "the session starts open");
1288        workspace.dispatch(crate::protocol::Command::VaultLock).expect("the lock routes");
1289        assert!(workspace.vault_state().expect("a vault").locked, "the shared session locked");
1290        match &*announced.try_recv().expect("the lock is announced").update {
1291            SessionUpdate::VaultLockChanged { locked } => assert!(locked),
1292            other => panic!("the lock announced something else: {other:?}"),
1293        }
1294    }
1295
1296    #[test]
1297    fn a_new_session_is_locked() {
1298        let session = test_session(&config(), None, Path::new("/tmp")).expect("a session");
1299        assert!(session.is_locked());
1300        assert!(matches!(session.keys(), Err(VaultError::Locked)));
1301    }
1302
1303    #[test]
1304    fn the_audit_writes_one_json_line_with_no_value() {
1305        let dir = tempfile::tempdir().expect("tempdir");
1306        let audit = VaultAudit::new(dir.path(), "store").expect("audit");
1307        audit.append(r#"{"tool":"vault_get","decision":"approved"}"#).expect("append");
1308        let path = dir.path().join("vault").join("audit-store.jsonl");
1309        let text = std::fs::read_to_string(path).expect("read");
1310        assert_eq!(text.lines().count(), 1);
1311        let value: serde_json::Value = serde_json::from_str(text.trim()).expect("json");
1312        assert_eq!(value["tool"], "vault_get");
1313    }
1314
1315    async fn read_request(socket: &mut tokio::net::TcpStream) -> Vec<u8> {
1316        use tokio::io::AsyncReadExt;
1317        let mut buf = Vec::new();
1318        let mut chunk = [0u8; 8192];
1319        loop {
1320            let read = socket.read(&mut chunk).await.unwrap_or(0);
1321            if read == 0 {
1322                break;
1323            }
1324            buf.extend_from_slice(&chunk[..read]);
1325            if let Some(end) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
1326                let headers = String::from_utf8_lossy(&buf[..end]).to_lowercase();
1327                let length: usize = headers
1328                    .lines()
1329                    .find_map(|line| line.strip_prefix("content-length:"))
1330                    .and_then(|value| value.trim().parse().ok())
1331                    .unwrap_or(0);
1332                if buf.len() >= end + 4 + length {
1333                    break;
1334                }
1335            }
1336        }
1337        buf
1338    }
1339
1340    /// Serve `responses` in order, one request per response.
1341    async fn serve(responses: Vec<String>) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
1342        use tokio::io::AsyncWriteExt;
1343        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
1344        let address = listener.local_addr().expect("addr");
1345        let handle = tokio::spawn(async move {
1346            for response in responses {
1347                let (mut socket, _) = listener.accept().await.expect("accept");
1348                let _ = read_request(&mut socket).await;
1349                let _ = socket.write_all(response.as_bytes()).await;
1350            }
1351        });
1352        (address, handle)
1353    }
1354
1355    fn response(status: &str, body: &str) -> String {
1356        // **One request per connection**: the server closes after answering,
1357        // so the client must be told not to reuse it.
1358        format!(
1359            "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1360            body.len()
1361        )
1362    }
1363
1364    fn session_for(address: std::net::SocketAddr) -> VaultSession {
1365        let config = VaultConfig {
1366            url: format!("http://{address}"),
1367            email: "hi@vedhavyas.dev".to_owned(),
1368            idle_minutes: None,
1369        };
1370        let session = test_session(&config, None, Path::new("/tmp")).expect("a session");
1371        session.state.write().tokens =
1372            Some(Tokens { access: "stale".to_owned(), refresh: Some("good".to_owned()) });
1373        session
1374    }
1375
1376    /// The material a device enrols with is the session's current refresh
1377    /// token and the account's user key, and nothing when either is missing.
1378    #[test]
1379    fn the_enrolment_material_is_the_refresh_token_and_user_key() {
1380        let session = test_session(&config(), None, Path::new("/tmp")).expect("a session");
1381        assert!(session.enrolment_material().is_none(), "a locked session enrols nothing");
1382
1383        let key = crypto::make_symmetric_key();
1384        test_install_unlocked(
1385            &session,
1386            Unlocked {
1387                user_key: key.clone(),
1388                private_key: None,
1389                org_keys: std::collections::HashMap::new(),
1390                failed_orgs: Vec::new(),
1391                user_key_fingerprint: String::new(),
1392            },
1393        );
1394        let material = session.enrolment_material().expect("the unlocked material");
1395        assert_eq!(material.refresh_token, "test-refresh");
1396        assert_eq!(
1397            material.user_key,
1398            key.to_base64().to_string(),
1399            "the user key is the one the unlock holds",
1400        );
1401
1402        if let Some(tokens) = session.state.write().tokens.as_mut() {
1403            tokens.refresh = None;
1404        }
1405        assert!(
1406            session.enrolment_material().is_none(),
1407            "a session with no refresh token enrols nothing: the blob could never refresh",
1408        );
1409    }
1410
1411    /// The idle bound locks the session on its next read, announces it, and
1412    /// leaves an unconfigured session alone.
1413    #[test]
1414    fn the_idle_bound_locks_the_session_on_its_next_read() {
1415        /// A wall-clock instant `seconds` in the past.
1416        fn ago(seconds: u64) -> std::time::SystemTime {
1417            std::time::SystemTime::now()
1418                .checked_sub(std::time::Duration::from_secs(seconds))
1419                .expect("the wall clock is older than the test's span")
1420        }
1421
1422        use crate::update_fanout::SubscriberRole;
1423
1424        let updates = UpdateFanout::default();
1425        let mut announced = updates.subscribe(SubscriberRole::Answering);
1426        let config = VaultConfig {
1427            url: "http://127.0.0.1:1".to_owned(),
1428            email: "hi@vedhavyas.dev".to_owned(),
1429            idle_minutes: Some(30),
1430        };
1431        let session = test_session_with_updates(&config, None, Path::new("/tmp"), updates.clone())
1432            .expect("a session");
1433        test_install_unlocked(
1434            &session,
1435            Unlocked {
1436                user_key: crypto::make_symmetric_key(),
1437                private_key: None,
1438                org_keys: std::collections::HashMap::new(),
1439                failed_orgs: Vec::new(),
1440                user_key_fingerprint: String::new(),
1441            },
1442        );
1443
1444        // A use inside the bound keeps it open and re-stamps the clock.
1445        *session.last_used.lock() = Some(ago(29 * 60));
1446        assert!(!session.is_locked(), "a read inside the bound leaves the session open");
1447        assert!(session.keys().is_ok());
1448
1449        // Past the bound, the next read locks it.
1450        *session.last_used.lock() = Some(ago(31 * 60));
1451        assert!(session.is_locked(), "a read past the bound locks the session");
1452        assert!(session.keys().is_err());
1453        match &*announced.try_recv().expect("the idle lock is announced").update {
1454            SessionUpdate::VaultLockChanged { locked } => assert!(locked),
1455            other => panic!("the idle lock announced something else: {other:?}"),
1456        }
1457
1458        // No bound means no idle lock, however old the stamp.
1459        let open = VaultConfig {
1460            url: "http://127.0.0.1:1".to_owned(),
1461            email: "hi@vedhavyas.dev".to_owned(),
1462            idle_minutes: None,
1463        };
1464        let session = test_session(&open, None, Path::new("/tmp")).expect("a session");
1465        test_install_unlocked(
1466            &session,
1467            Unlocked {
1468                user_key: crypto::make_symmetric_key(),
1469                private_key: None,
1470                org_keys: std::collections::HashMap::new(),
1471                failed_orgs: Vec::new(),
1472                user_key_fingerprint: String::new(),
1473            },
1474        );
1475        *session.last_used.lock() = Some(ago(365 * 24 * 60 * 60));
1476        assert!(!session.is_locked(), "an unconfigured session never locks itself");
1477    }
1478
1479    /// A rotation is announced with the new token, and the frame's Debug never
1480    /// prints it.
1481    #[tokio::test]
1482    async fn a_rotation_announces_the_new_token_and_redacts_it() {
1483        use crate::update_fanout::SubscriberRole;
1484
1485        let (address, server) = serve(vec![
1486            response("401 Unauthorized", ""),
1487            response("200 OK", r#"{"access_token":"fresh","refresh_token":"next"}"#),
1488            response("200 OK", r#"{"profile":{},"folders":[],"ciphers":[]}"#),
1489        ])
1490        .await;
1491        let updates = UpdateFanout::default();
1492        let mut announced = updates.subscribe(SubscriberRole::Answering);
1493        let config = VaultConfig {
1494            url: format!("http://{address}"),
1495            email: "hi@vedhavyas.dev".to_owned(),
1496            idle_minutes: None,
1497        };
1498        let session = test_session_with_updates(&config, None, Path::new("/tmp"), updates.clone())
1499            .expect("a session");
1500        session.state.write().tokens =
1501            Some(Tokens { access: "stale".to_owned(), refresh: Some("good".to_owned()) });
1502
1503        session.sync().await.expect("the refresh recovers the call");
1504
1505        let frame = announced.try_recv().expect("the rotation is announced");
1506        match &*frame.update {
1507            SessionUpdate::VaultRotated { refresh_token } => assert_eq!(refresh_token, "next"),
1508            other => panic!("the rotation announced something else: {other:?}"),
1509        }
1510        assert!(
1511            !format!("{:?}", *frame.update).contains("next"),
1512            "the rotated token is a secret and the Debug line redacts it",
1513        );
1514        server.await.expect("server");
1515    }
1516
1517    /// A 401 tries the refresh token, and the retry uses the refreshed token.
1518    #[tokio::test]
1519    async fn a_401_refreshes_and_retries() {
1520        let (address, server) = serve(vec![
1521            response("401 Unauthorized", ""),
1522            response("200 OK", r#"{"access_token":"fresh","refresh_token":"next"}"#),
1523            response("200 OK", r#"{"profile":{},"folders":[],"ciphers":[]}"#),
1524        ])
1525        .await;
1526        let session = session_for(address);
1527
1528        let sync = session.sync().await.expect("the refresh recovers the call");
1529
1530        assert!(sync.ciphers.is_empty());
1531        {
1532            let state = session.state.read();
1533            let tokens = state.tokens.as_ref().expect("tokens survive");
1534            assert_eq!(tokens.access, "fresh");
1535            assert_eq!(tokens.refresh.as_deref(), Some("next"));
1536        }
1537        server.await.expect("server");
1538    }
1539
1540    /// Two concurrent 401s redeem the rotating refresh token once: the second
1541    /// reads the access token the first stored instead of spending it again.
1542    /// The server answers exactly one request, so a second would fail.
1543    #[tokio::test]
1544    async fn concurrent_refreshes_spend_the_token_once() {
1545        let (address, server) =
1546            serve(vec![response("200 OK", r#"{"access_token":"fresh","refresh_token":"next"}"#)])
1547                .await;
1548        let session = session_for(address);
1549
1550        let (first, second) = tokio::join!(session.refresh("stale"), session.refresh("stale"));
1551
1552        assert_eq!(first.expect("the first refresh succeeds").as_str(), "fresh");
1553        assert_eq!(second.expect("the second reads the fresh token").as_str(), "fresh");
1554        {
1555            let state = session.state.read();
1556            let tokens = state.tokens.as_ref().expect("tokens survive");
1557            assert_eq!(tokens.access, "fresh");
1558            assert_eq!(tokens.refresh.as_deref(), Some("next"), "the rotated token is kept");
1559        }
1560        server.await.expect("the single response was taken");
1561    }
1562
1563    /// A rejected refresh locks the session, so the next held call asks for
1564    /// the master password rather than repeating the 401.
1565    #[tokio::test]
1566    async fn a_rejected_refresh_locks_the_session() {
1567        let (address, server) = serve(vec![
1568            response("401 Unauthorized", ""),
1569            response("400 Bad Request", r#"{"error":"invalid_grant"}"#),
1570        ])
1571        .await;
1572        let session = session_for(address);
1573
1574        let error = session.sync().await.expect_err("the refresh fails");
1575
1576        assert!(session.is_locked(), "the failed refresh locks the session: {error}");
1577        assert!(session.state.read().tokens.is_none(), "the tokens are dropped");
1578        server.await.expect("server");
1579    }
1580
1581    /// A session with tokens but no refresh token locks on the first 401
1582    /// rather than returning Locked while still reading unlocked.
1583    #[tokio::test]
1584    async fn a_session_without_a_refresh_token_locks_on_401() {
1585        let (address, server) = serve(vec![response("401 Unauthorized", "")]).await;
1586        let session = test_session(
1587            &VaultConfig {
1588                url: format!("http://{address}"),
1589                email: "hi@vedhavyas.dev".to_owned(),
1590                idle_minutes: None,
1591            },
1592            None,
1593            Path::new("/tmp"),
1594        )
1595        .expect("a session");
1596        session.state.write().tokens = Some(Tokens { access: "stale".to_owned(), refresh: None });
1597
1598        let error = session.sync().await.expect_err("no refresh token");
1599
1600        assert!(matches!(error, VaultError::Locked));
1601        assert!(session.is_locked(), "a missing refresh token locks the session");
1602        server.await.expect("server");
1603    }
1604
1605    #[test]
1606    fn an_audit_line_carries_the_decision_and_no_value() {
1607        let dir = tempfile::tempdir().expect("tempdir");
1608        let config = VaultConfig {
1609            url: "http://127.0.0.1:1".to_owned(),
1610            email: "hi@vedhavyas.dev".to_owned(),
1611            idle_minutes: None,
1612        };
1613        let config_dir = Path::new("/tmp");
1614        let session = test_session(&config, Some(dir.path()), config_dir).expect("session");
1615        session.audit(
1616            &SessionSlot::lead("Default", "forge"),
1617            VaultTool::Get,
1618            Some("item-id"),
1619            Some("bank"),
1620            Some("password"),
1621            "approve",
1622            "ok",
1623        );
1624        let path = dir
1625            .path()
1626            .join("vault")
1627            .join(format!("audit-{}.jsonl", forge_sdk::config_dir_store_key(config_dir)));
1628        let line = std::fs::read_to_string(path).expect("audit line");
1629        let value: serde_json::Value = serde_json::from_str(line.trim()).expect("audit json");
1630        let object = value.as_object().expect("an audit line is an object");
1631        let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
1632        keys.sort_unstable();
1633        assert_eq!(
1634            keys,
1635            ["at", "caller", "decision", "field", "item_id", "item_name", "result", "tool"],
1636            "the line carries the decision's facts and no value field",
1637        );
1638        assert_eq!(value["decision"], "approve");
1639        assert_eq!(value["tool"], "vault__get");
1640        assert_eq!(value["field"], "password");
1641        assert_eq!(value["item_id"], "item-id");
1642    }
1643
1644    /// An existing audit file with looser permissions is repaired on open,
1645    /// which `mode(0o600)` alone does not do.
1646    #[test]
1647    fn the_audit_file_is_private_even_when_it_existed_looser() {
1648        use std::os::unix::fs::PermissionsExt;
1649        let dir = tempfile::tempdir().expect("tempdir");
1650        let vault = dir.path().join("vault");
1651        std::fs::create_dir_all(&vault).expect("dir");
1652        let path = vault.join("audit-store.jsonl");
1653        std::fs::write(&path, "").expect("file");
1654        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod");
1655
1656        let _audit = VaultAudit::new(dir.path(), "store").expect("audit");
1657
1658        assert_eq!(
1659            std::fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
1660            0o600,
1661            "the audit file is private",
1662        );
1663    }
1664
1665    /// A symlink planted at the audit path is refused rather than followed, so
1666    /// a line cannot be appended through it.
1667    #[test]
1668    fn the_audit_refuses_a_symlink() {
1669        use std::os::unix::fs::symlink;
1670        let dir = tempfile::tempdir().expect("tempdir");
1671        let vault = dir.path().join("vault");
1672        std::fs::create_dir_all(&vault).expect("dir");
1673        let target = dir.path().join("target");
1674        std::fs::write(&target, "").expect("target");
1675        symlink(&target, vault.join("audit-store.jsonl")).expect("symlink");
1676
1677        assert!(VaultAudit::new(dir.path(), "store").is_err(), "the symlink is not followed");
1678        assert_eq!(std::fs::read(&target).expect("target read"), b"");
1679    }
1680
1681    /// A decryption failure under a known key means the account rotated them:
1682    /// the session drops them and the next held call asks for the password.
1683    #[test]
1684    fn a_decrypt_failure_drops_the_keys() {
1685        let session = test_session(&config(), None, Path::new("/tmp")).expect("a session");
1686        session.state.write().keys = Some(std::sync::Arc::new(Unlocked {
1687            user_key: crypto::make_symmetric_key(),
1688            private_key: None,
1689            org_keys: std::collections::HashMap::new(),
1690            failed_orgs: Vec::new(),
1691            user_key_fingerprint: String::new(),
1692        }));
1693        assert!(!session.is_locked());
1694
1695        session.note_decrypt_failure(&SessionSlot::lead("Default", "forge"), None);
1696
1697        assert!(session.is_locked(), "the stale keys are dropped");
1698        assert!(matches!(session.keys(), Err(VaultError::Locked)));
1699    }
1700
1701    #[test]
1702    fn the_audit_rotates_through_six_files() {
1703        use std::os::unix::fs::PermissionsExt;
1704        let dir = tempfile::tempdir().expect("tempdir");
1705        let audit = VaultAudit::new(dir.path(), "store").expect("audit");
1706        // Write enough lines to force one rotation.
1707        let line = "x".repeat(1024 * 1024);
1708        for _ in 0..11 {
1709            audit.append(&line).expect("append");
1710        }
1711        let base = dir.path().join("vault");
1712        assert!(base.join("audit-store.jsonl.1").exists(), "the first rotation made .1");
1713        for name in ["audit-store.jsonl", "audit-store.jsonl.1"] {
1714            assert_eq!(
1715                std::fs::metadata(base.join(name)).expect("metadata").permissions().mode() & 0o777,
1716                0o600,
1717                "{name} is private",
1718            );
1719        }
1720    }
1721
1722    /// The production clients are the ones the vault session uses, and they
1723    /// refuse a plaintext URL before touching the network.
1724    #[tokio::test]
1725    async fn production_client_is_https_only() {
1726        use std::time::Duration;
1727
1728        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
1729        let address = listener.local_addr().expect("addr");
1730        let client = production_client(&format!("http://{address}")).expect("clients build");
1731        let error = client.prelogin("hi@example.com").await.expect_err("https only");
1732        assert!(matches!(error, VaultError::Http(_)), "{error}");
1733        assert!(
1734            tokio::time::timeout(Duration::from_millis(100), listener.accept()).await.is_err(),
1735            "no plaintext request was made",
1736        );
1737    }
1738}