1use 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
29struct 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#[derive(Default)]
46struct SessionState {
47 tokens: Option<Tokens>,
48 keys: Option<std::sync::Arc<Unlocked>>,
49}
50
51pub(crate) struct VaultSession {
53 client: VaultClient,
54 email: String,
55 state: parking_lot::RwLock<SessionState>,
56 unlock_gate: tokio::sync::Mutex<()>,
59 refresh_gate: tokio::sync::Mutex<()>,
63 updates: UpdateFanout,
66 idle_minutes: Option<u64>,
68 last_used: parking_lot::Mutex<Option<std::time::SystemTime>>,
72 lock_generation: std::sync::atomic::AtomicU64,
75 audit: Option<VaultAudit>,
76}
77
78impl VaultSession {
79 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 pub(crate) fn idle_minutes(&self) -> Option<u64> {
119 self.idle_minutes
120 }
121
122 pub(crate) fn is_locked(&self) -> bool {
126 self.lock_if_idle();
127 self.state.read().keys.is_none()
128 }
129
130 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 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 fn touch(&self) {
161 *self.last_used.lock() = Some(std::time::SystemTime::now());
162 }
163
164 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 pub(crate) fn enrolment_material(&self) -> Option<VaultReleased> {
178 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 fn announce_rotation(&self, refresh_token: String) {
202 let _ = self.updates.send(SessionUpdate::VaultRotated { refresh_token });
203 }
204
205 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 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 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 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 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 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 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 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 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 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 pub(crate) fn lock(&self) {
396 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 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 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 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 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 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 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 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#[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 pub(crate) fn vault_enrolment_material(&self) -> Option<VaultReleased> {
804 self.vault.read().as_ref()?.enrolment_material()
805 }
806
807 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#[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#[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#[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
848pub(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
875struct 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 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 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 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 #[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 #[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 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 #[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 #[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 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 #[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 #[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 #[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 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 #[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 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 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 #[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 #[test]
1414 fn the_idle_bound_locks_the_session_on_its_next_read() {
1415 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 *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 *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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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}