1use std::path::{Path, PathBuf};
11use std::sync::{Arc, Weak};
12
13use async_trait::async_trait;
14use forge_connectors::vault::VaultError;
15use forge_connectors::vault::crypto::Unlocked;
16use forge_connectors::vault::models::{Profile, SyncData};
17use forge_primitives::vault::{
18 VaultAskDecision, VaultAskEnding, VaultAttachmentDraft, VaultDraft, VaultTool,
19};
20use serde::Deserialize;
21use serde_json::{Value, json};
22use zeroize::Zeroize;
23
24use crate::SessionSlot;
25use crate::vault::VaultSession;
26use crate::workspace::Workspace;
27
28const MAX_ATTACHMENT_BYTES: u64 = 525 * 1024 * 1024;
31
32const SESSION_CHANGED: &str =
35 "the vault configuration changed while the call was parked; nothing ran";
36
37#[derive(Debug, thiserror::Error)]
39pub enum VaultToolError {
40 #[error("{0}")]
41 Message(String),
42 #[error("the vault call was declined, so nothing ran")]
43 Rejected,
44 #[error("the vault call was sent back: {note}")]
45 SentBack { note: String },
46 #[error("the vault session is locked; the next call asks for the master password")]
47 Locked,
48 #[error("the vault is not configured: add a [vault] section to forge.toml")]
49 NotConfigured,
50 #[error("no client is attached to answer the vault call")]
51 NoClient,
52 #[error("the answerer went away before the vault call was settled")]
53 Abandoned,
54 #[error("vault request failed: {0}")]
55 Vault(#[from] VaultError),
56 #[error("vault crypto failed: {0}")]
57 Crypto(#[from] anyhow::Error),
58 #[error("{0}")]
59 Io(String),
60}
61
62impl From<std::io::Error> for VaultToolError {
63 fn from(error: std::io::Error) -> Self {
64 Self::Io(error.to_string())
65 }
66}
67
68#[derive(Clone, Deserialize)]
70pub struct CustomFieldInput {
71 pub name: String,
72 pub value: String,
73 #[serde(default, rename = "type")]
74 pub kind: Option<String>,
75}
76
77impl std::fmt::Debug for CustomFieldInput {
79 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80 f.debug_struct("CustomFieldInput")
81 .field("name", &self.name)
82 .field("value", &"[redacted]")
83 .field("kind", &self.kind)
84 .finish()
85 }
86}
87
88#[derive(Clone, Default, Deserialize)]
90pub struct CreateInput {
91 #[serde(default, rename = "type")]
92 pub kind: Option<String>,
93 pub name: String,
94 #[serde(default)]
95 pub username: Option<String>,
96 #[serde(default)]
97 pub password: Option<String>,
98 #[serde(default)]
99 pub uri: Option<String>,
100 #[serde(default)]
101 pub totp: Option<String>,
102 #[serde(default)]
103 pub notes: Option<String>,
104 #[serde(default)]
105 pub cardholder_name: Option<String>,
106 #[serde(default)]
107 pub number: Option<String>,
108 #[serde(default)]
109 pub exp_month: Option<String>,
110 #[serde(default)]
111 pub exp_year: Option<String>,
112 #[serde(default)]
113 pub code: Option<String>,
114 #[serde(default)]
115 pub brand: Option<String>,
116 #[serde(default)]
117 pub folder_id: Option<String>,
118 #[serde(default)]
119 pub custom_fields: Option<Vec<CustomFieldInput>>,
120}
121
122impl std::fmt::Debug for CreateInput {
125 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126 f.debug_struct("CreateInput")
127 .field("kind", &self.kind)
128 .field("name", &self.name)
129 .field("username", &self.username)
130 .field("password", &self.password.as_ref().map(|_| "[redacted]"))
131 .field("uri", &self.uri)
132 .field("totp", &self.totp.as_ref().map(|_| "[redacted]"))
133 .field("notes", &self.notes.as_ref().map(|_| "[redacted]"))
134 .field("cardholder_name", &self.cardholder_name)
135 .field("number", &self.number.as_ref().map(|_| "[redacted]"))
136 .field("exp_month", &self.exp_month)
137 .field("exp_year", &self.exp_year)
138 .field("code", &self.code.as_ref().map(|_| "[redacted]"))
139 .field("brand", &self.brand)
140 .field("folder_id", &self.folder_id)
141 .field("custom_fields", &self.custom_fields)
142 .finish()
143 }
144}
145
146#[derive(Clone, Default, Deserialize)]
148pub struct PatchInput {
149 #[serde(default)]
150 pub name: Option<String>,
151 #[serde(default)]
152 pub username: Option<String>,
153 #[serde(default)]
154 pub password: Option<String>,
155 #[serde(default)]
156 pub uri: Option<String>,
157 #[serde(default)]
158 pub totp: Option<String>,
159 #[serde(default)]
160 pub notes: Option<String>,
161 #[serde(default)]
162 pub cardholder_name: Option<String>,
163 #[serde(default)]
164 pub number: Option<String>,
165 #[serde(default)]
166 pub exp_month: Option<String>,
167 #[serde(default)]
168 pub exp_year: Option<String>,
169 #[serde(default)]
170 pub code: Option<String>,
171 #[serde(default)]
172 pub brand: Option<String>,
173 #[serde(default)]
174 pub folder_id: Option<String>,
175 #[serde(default)]
176 pub custom_fields: Option<Vec<CustomFieldInput>>,
177}
178
179impl std::fmt::Debug for PatchInput {
182 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183 f.debug_struct("PatchInput")
184 .field("name", &self.name)
185 .field("username", &self.username)
186 .field("password", &self.password.as_ref().map(|_| "[redacted]"))
187 .field("uri", &self.uri)
188 .field("totp", &self.totp.as_ref().map(|_| "[redacted]"))
189 .field("notes", &self.notes.as_ref().map(|_| "[redacted]"))
190 .field("cardholder_name", &self.cardholder_name)
191 .field("number", &self.number.as_ref().map(|_| "[redacted]"))
192 .field("exp_month", &self.exp_month)
193 .field("exp_year", &self.exp_year)
194 .field("code", &self.code.as_ref().map(|_| "[redacted]"))
195 .field("brand", &self.brand)
196 .field("folder_id", &self.folder_id)
197 .field("custom_fields", &self.custom_fields)
198 .finish()
199 }
200}
201
202#[async_trait]
205pub trait VaultFacade: Send + Sync {
206 async fn search(
207 &self,
208 caller: &SessionSlot,
209 query: &str,
210 limit: Option<usize>,
211 ) -> Result<Value, VaultToolError>;
212 async fn get(
213 &self,
214 caller: &SessionSlot,
215 id: &str,
216 reveal: Option<&str>,
217 ) -> Result<Value, VaultToolError>;
218 async fn create(
219 &self,
220 caller: &SessionSlot,
221 input: &CreateInput,
222 ) -> Result<Value, VaultToolError>;
223 async fn update(
224 &self,
225 caller: &SessionSlot,
226 id: &str,
227 patch: &PatchInput,
228 ) -> Result<Value, VaultToolError>;
229 async fn trash(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError>;
230 async fn restore(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError>;
231 async fn totp_code(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError>;
232 async fn attachment_get(
233 &self,
234 caller: &SessionSlot,
235 item_id: &str,
236 attachment_id: &str,
237 destination: &str,
238 ) -> Result<Value, VaultToolError>;
239 async fn attachment_put(
240 &self,
241 caller: &SessionSlot,
242 item_id: &str,
243 path: &str,
244 name: Option<&str>,
245 replace_attachment_id: Option<&str>,
246 ) -> Result<Value, VaultToolError>;
247 async fn folder_list(&self, caller: &SessionSlot) -> Result<Value, VaultToolError>;
248 async fn folder_create(
249 &self,
250 caller: &SessionSlot,
251 name: &str,
252 ) -> Result<Value, VaultToolError>;
253 async fn folder_update(
254 &self,
255 caller: &SessionSlot,
256 id: &str,
257 name: &str,
258 ) -> Result<Value, VaultToolError>;
259 async fn folder_delete(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError>;
260}
261
262pub struct ProdVaultFacade {
265 workspace: Weak<Workspace>,
266}
267
268impl ProdVaultFacade {
269 pub fn from_arc(workspace: &Arc<Workspace>) -> Self {
270 Self { workspace: Arc::downgrade(workspace) }
271 }
272
273 pub fn into_arc(self) -> Arc<dyn VaultFacade> {
274 Arc::new(self)
275 }
276
277 fn workspace(&self) -> Result<Arc<Workspace>, VaultToolError> {
278 self.workspace.upgrade().ok_or(VaultToolError::NotConfigured)
279 }
280
281 fn session(&self) -> Result<Arc<VaultSession>, VaultToolError> {
282 self.workspace()?.vault.read().clone().ok_or(VaultToolError::NotConfigured)
283 }
284
285 fn decrypt_failed(
289 &self,
290 session: &VaultSession,
291 caller: &SessionSlot,
292 item: Option<&str>,
293 error: VaultToolError,
294 ) -> VaultToolError {
295 if matches!(
296 &error,
297 VaultToolError::Vault(VaultError::Crypto(_)) | VaultToolError::Crypto(_)
298 ) {
299 session.note_decrypt_failure(caller, item);
300 if let Ok(workspace) = self.workspace() {
301 workspace.rebase_vault_asks(true);
302 }
303 }
304 error
305 }
306
307 fn fallback_or_lock<T>(
310 &self,
311 session: &VaultSession,
312 caller: &SessionSlot,
313 item: Option<&str>,
314 result: Result<T, VaultToolError>,
315 fallback: T,
316 ) -> Result<T, VaultToolError> {
317 match result {
318 Ok(value) => Ok(value),
319 Err(VaultToolError::Vault(VaultError::MissingKey(_))) => Ok(fallback),
320 Err(error) => Err(self.decrypt_failed(session, caller, item, error)),
321 }
322 }
323
324 async fn ensure_keys_current(
328 &self,
329 approved: &Approved,
330 item: Option<&str>,
331 ) -> Result<SyncData, VaultToolError> {
332 match approved.session.ensure_keys_current(&approved.caller, item).await {
333 Ok(sync) => Ok(sync),
334 Err(error) => approved.fail(VaultToolError::Vault(error)),
338 }
339 }
340
341 fn guard_reload(
344 approved: &Approved,
345 session: &Arc<VaultSession>,
346 ) -> Result<(), VaultToolError> {
347 if Arc::ptr_eq(&approved.session, session) {
348 return Ok(());
349 }
350 approved.fail(VaultToolError::Message(SESSION_CHANGED.to_owned()))
351 }
352
353 async fn gate(
358 &self,
359 caller: &SessionSlot,
360 draft: VaultDraft,
361 tool: VaultTool,
362 field: Option<&str>,
363 ) -> Result<Approved, VaultToolError> {
364 let workspace = self.workspace()?;
365 let session = self.session()?;
366 let (id, receiver, answerable) =
367 workspace.register_vault_ask(caller, draft.clone(), session.is_locked());
368 if !answerable {
369 return Err(VaultToolError::NoClient);
370 }
371 let mut guard = SettleOnDrop {
372 workspace: Arc::clone(&workspace),
373 id,
374 caller: caller.clone(),
375 armed: true,
376 };
377 let Ok(mut answer) = receiver.await else {
378 return Err(VaultToolError::Abandoned);
379 };
380 let ending = match &answer.decision {
381 VaultAskDecision::Approve => VaultAskEnding::Approved,
382 VaultAskDecision::SendBack { .. } => VaultAskEnding::SentBack,
383 VaultAskDecision::Decline => VaultAskEnding::Declined,
384 };
385 guard.armed = false;
386 workspace.settle_vault_ask(id, caller, ending);
387
388 let item_id = draft.item_id.as_deref();
389 let item_name = draft.item_name.as_deref();
390 let field = field.or(draft.field.as_deref());
393 match &answer.decision {
394 VaultAskDecision::Decline => {
395 session.audit(caller, tool, item_id, item_name, field, "decline", "not run");
396 return Err(VaultToolError::Rejected);
397 }
398 VaultAskDecision::SendBack { note } => {
399 session.audit(caller, tool, item_id, item_name, field, "send_back", "not run");
400 return Err(VaultToolError::SentBack { note: note.clone() });
401 }
402 VaultAskDecision::Approve => {}
403 }
404
405 let current = self.session();
409 let reloaded = match current {
410 Ok(current) => !Arc::ptr_eq(¤t, &session),
411 Err(_) => true,
412 };
413 if reloaded {
414 if let Some(mut password) = answer.master_password.take() {
415 password.zeroize();
416 }
417 if let Some(mut code) = answer.two_factor.take() {
418 code.zeroize();
419 }
420 session.audit(
421 caller,
422 tool,
423 item_id,
424 item_name,
425 field,
426 "approve",
427 "failed: the vault configuration changed",
428 );
429 return Err(VaultToolError::Message(SESSION_CHANGED.to_owned()));
430 }
431
432 if session.is_locked() {
433 let unlocked = if let Some(released) = answer.released.as_ref() {
438 session.unlock_released(&released.refresh_token, &released.user_key).await
439 } else {
440 let Some(password) =
441 answer.master_password.as_deref().filter(|password| !password.is_empty())
442 else {
443 session.audit(
444 caller,
445 tool,
446 item_id,
447 item_name,
448 field,
449 "approve",
450 "failed: a locked session was approved without the master password",
451 );
452 return Err(VaultToolError::Locked);
453 };
454 let two_factor = answer.two_factor.as_deref().filter(|code| !code.is_empty());
455 session.unlock(password, two_factor).await
456 };
457 if let Some(mut password) = answer.master_password.take() {
458 password.zeroize();
459 }
460 if let Some(mut code) = answer.two_factor.take() {
461 code.zeroize();
462 }
463 if let Some(mut released) = answer.released.take() {
464 released.refresh_token.zeroize();
465 released.user_key.zeroize();
466 }
467 if let Err(error) = unlocked {
468 session.audit(
469 caller,
470 tool,
471 item_id,
472 item_name,
473 field,
474 "approve",
475 &format!("failed: {error}"),
476 );
477 return Err(VaultToolError::Vault(error));
478 }
479 workspace.rebase_vault_asks(false);
483 } else {
484 if let Some(mut password) = answer.master_password.take() {
485 password.zeroize();
486 }
487 if let Some(mut code) = answer.two_factor.take() {
488 code.zeroize();
489 }
490 }
491
492 let keys = session.keys().map_err(|error| {
493 session.audit(
494 caller,
495 tool,
496 item_id,
497 item_name,
498 field,
499 "approve",
500 &format!("failed: {error}"),
501 );
502 VaultToolError::Vault(error)
503 })?;
504 Ok(Approved {
505 workspace,
506 session,
507 keys,
508 caller: caller.clone(),
509 tool,
510 item_id: item_id.map(str::to_owned),
511 item_name: item_name.map(str::to_owned),
512 field: field.map(str::to_owned),
513 })
514 }
515
516 async fn item_gate<F>(
522 &self,
523 caller: &SessionSlot,
524 tool: VaultTool,
525 id: &str,
526 summary: F,
527 ) -> Result<(Approved, Value, VaultDraft), VaultToolError>
528 where
529 F: FnOnce(Option<&str>, &mut VaultDraft) -> String,
530 {
531 let session = self.session()?;
532 let (cipher, name) = if session.is_locked() {
533 (None, None)
534 } else {
535 let cipher = session.get_cipher(id).await?;
536 let keys = session.keys()?;
537 let name = self.fallback_or_lock(
538 &session,
539 caller,
540 Some(id),
541 decrypt_name(&keys, &cipher).map(Some),
542 None,
543 )?;
544 (Some(cipher), name)
545 };
546 let mut draft = VaultDraft::new(tool, String::new());
547 draft.item_id = Some(id.to_owned());
548 draft.item_name = name;
549 let item_name = draft.item_name.clone();
550 draft.summary = summary(item_name.as_deref(), &mut draft);
551 let approved = self.gate(caller, draft.clone(), tool, None).await?;
552 Self::guard_reload(&approved, &session)?;
556 let cipher = match cipher {
557 Some(cipher) => cipher,
558 None => match approved.session.get_cipher(id).await {
559 Ok(cipher) => cipher,
560 Err(error) => return approved.fail(VaultToolError::Vault(error)),
561 },
562 };
563 Ok((approved, cipher, draft))
564 }
565}
566
567struct SettleOnDrop {
570 workspace: Arc<Workspace>,
571 id: uuid::Uuid,
572 caller: SessionSlot,
573 armed: bool,
574}
575
576impl Drop for SettleOnDrop {
577 fn drop(&mut self) {
578 if self.armed {
579 self.workspace.settle_vault_ask(self.id, &self.caller, VaultAskEnding::Abandoned);
580 }
581 }
582}
583
584struct Approved {
588 workspace: Arc<Workspace>,
589 session: Arc<VaultSession>,
590 keys: Arc<Unlocked>,
591 caller: SessionSlot,
592 tool: VaultTool,
593 item_id: Option<String>,
594 item_name: Option<String>,
595 field: Option<String>,
596}
597
598impl Approved {
599 fn done<T>(&self, result: Result<T, VaultToolError>) -> Result<T, VaultToolError> {
600 if matches!(
604 &result,
605 Err(VaultToolError::Vault(VaultError::Crypto(_)) | VaultToolError::Crypto(_))
606 ) {
607 self.session.note_decrypt_failure(&self.caller, self.item_id.as_deref());
608 }
609 if self.session.is_locked() {
612 self.workspace.rebase_vault_asks(true);
613 }
614 let outcome = match &result {
615 Ok(_) => "ok".to_owned(),
616 Err(error) => format!("failed: {error}"),
617 };
618 self.session.audit(
619 &self.caller,
620 self.tool,
621 self.item_id.as_deref(),
622 self.item_name.as_deref(),
623 self.field.as_deref(),
624 "approve",
625 &outcome,
626 );
627 result
628 }
629
630 fn fail<T>(&self, error: VaultToolError) -> Result<T, VaultToolError> {
633 self.done(Err(error))
634 }
635}
636
637#[async_trait]
638impl VaultFacade for ProdVaultFacade {
639 async fn search(
640 &self,
641 caller: &SessionSlot,
642 query: &str,
643 limit: Option<usize>,
644 ) -> Result<Value, VaultToolError> {
645 let session = self.session()?;
646 let limit = limit.unwrap_or(20).clamp(1, 100);
647 let (items, hits) = if session.is_locked() {
648 (None, None)
649 } else {
650 let sync = session.sync().await?;
651 let keys = session.keys()?;
652 let items = self.search_items(&session, caller, &keys, &sync, query, limit)?;
653 let hits = items.len();
654 (Some(items), Some(hits))
655 };
656 let draft = VaultDraft::new(
657 VaultTool::Search,
658 match hits {
659 Some(hits) => {
660 format!("search for '{query}' ({hits} hit{})", if hits == 1 { "" } else { "s" })
661 }
662 None => format!("search for '{query}'"),
663 },
664 );
665 let approved = self.gate(caller, draft.clone(), VaultTool::Search, None).await?;
666 Self::guard_reload(&approved, &session)?;
667 let session = Arc::clone(&approved.session);
668 let result = async move {
669 let items = if let Some(items) = items {
670 items
671 } else {
672 let sync = session.sync().await?;
673 let keys = session.keys()?;
674 self.search_items(&session, caller, &keys, &sync, query, limit)?
675 };
676 Ok(json!({ "items": items }))
677 }
678 .await;
679 approved.done(result)
680 }
681
682 async fn get(
683 &self,
684 caller: &SessionSlot,
685 id: &str,
686 reveal: Option<&str>,
687 ) -> Result<Value, VaultToolError> {
688 validate_reveal(reveal)?;
689 let (approved, cipher, _draft) = self
690 .item_gate(caller, VaultTool::Get, id, |name, draft| {
691 draft.field = reveal.map(str::to_owned);
692 match (name, reveal) {
693 (Some(name), Some(field)) => format!("read {field} for '{name}'"),
694 (Some(name), None) => format!("read metadata for '{name}'"),
695 (None, Some(field)) => format!("read {field} for item {id}"),
696 (None, None) => format!("read metadata for item {id}"),
697 }
698 })
699 .await?;
700 let keys = Arc::clone(&approved.keys);
701 let result = async {
702 let mut result = self.item_metadata(&approved, &keys, &cipher)?;
703 if let Some(reveal) = reveal {
704 let value = reveal_value(&keys, &cipher, reveal)?;
705 result["revealed"] = json!({ "field": reveal, "value": value });
706 }
707 Ok(result)
708 }
709 .await;
710 approved.done(result)
711 }
712
713 async fn create(
714 &self,
715 caller: &SessionSlot,
716 input: &CreateInput,
717 ) -> Result<Value, VaultToolError> {
718 let kind = input.kind.as_deref().unwrap_or("login");
719 if !matches!(kind, "login" | "note" | "card") {
720 return Err(VaultToolError::Message(format!(
721 "unknown item type '{kind}'; the types are login, note, card"
722 )));
723 }
724 let mut draft = VaultDraft::new(
725 VaultTool::Create,
726 match kind {
727 "card" => "create a new card".to_owned(),
728 "note" => format!("create a secure note '{}'", input.name),
729 _ => format!("create a login '{}'", input.name),
730 },
731 );
732 draft.item_name = Some(input.name.clone());
733 draft.masked_diff = Some(masked_create_diff(input, kind));
734 let approved = self.gate(caller, draft.clone(), VaultTool::Create, None).await?;
735 let sync = self.ensure_keys_current(&approved, None).await?;
736 let user_id = match account_user_id(&sync.profile) {
737 Ok(user_id) => user_id,
738 Err(error) => return approved.done(Err(error)),
739 };
740 let body = match build_new_cipher(&approved.keys, &user_id, input, kind) {
741 Ok(body) => body,
742 Err(error) => return approved.done(Err(error)),
743 };
744 let created = match approved.session.create_cipher(&body).await {
745 Ok(created) => created,
746 Err(error) => return approved.done(Err(VaultToolError::Vault(error))),
747 };
748 let Some(id) = created.get("id").and_then(Value::as_str) else {
749 return approved.done(Err(VaultToolError::Message(
750 "the vault did not return the new item's id".to_owned(),
751 )));
752 };
753 let name = match created.get("name").and_then(Value::as_str) {
757 Some(enc) => match approved.keys.decrypt_item_str(&created, enc) {
758 Ok(name) => name,
759 Err(error) => {
760 let mut approved = approved;
761 approved.item_id = Some(id.to_owned());
762 return approved.done(Err(VaultToolError::Vault(error)));
763 }
764 },
765 None => input.name.clone(),
766 };
767 approved.done(Ok(json!({ "id": id, "name": name, "type": kind })))
768 }
769
770 async fn update(
771 &self,
772 caller: &SessionSlot,
773 id: &str,
774 patch: &PatchInput,
775 ) -> Result<Value, VaultToolError> {
776 let changed = patch_fields(patch);
777 if changed.is_empty() {
778 return Err(VaultToolError::Message("the patch changes no fields".to_owned()));
779 }
780 let (approved, cipher, draft) = self
781 .item_gate(caller, VaultTool::Update, id, |name, draft| {
782 draft.masked_diff = Some(masked_update_diff(&changed));
783 format!("change {} on '{}'", changed.join(", "), name.unwrap_or(id))
784 })
785 .await?;
786 let sync = self.ensure_keys_current(&approved, Some(id)).await?;
787 let user_id = match account_user_id(&sync.profile) {
788 Ok(user_id) => user_id,
789 Err(error) => return approved.done(Err(error)),
790 };
791 let session = Arc::clone(&approved.session);
792 let keys = Arc::clone(&approved.keys);
793 let result = async move {
794 let name = draft.item_name.clone().unwrap_or_else(|| id.to_owned());
795 let body = apply_patch(&keys, &user_id, cipher, patch)?;
796 session.update_cipher(id, &body).await?;
797 Ok(json!({ "id": id, "name": name, "updated": true }))
798 }
799 .await;
800 approved.done(result)
801 }
802
803 async fn trash(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError> {
804 let (approved, _cipher, draft) = self
805 .item_gate(caller, VaultTool::Trash, id, |name, _draft| {
806 format!("move '{}' to the trash", name.unwrap_or(id))
807 })
808 .await?;
809 let session = Arc::clone(&approved.session);
810 let result = async move {
811 session.trash_cipher(id).await?;
812 let name = draft.item_name.clone().unwrap_or_else(|| id.to_owned());
813 Ok(json!({ "id": id, "name": name, "trashed": true }))
814 }
815 .await;
816 approved.done(result)
817 }
818
819 async fn restore(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError> {
820 let (approved, _cipher, draft) = self
821 .item_gate(caller, VaultTool::Restore, id, |name, _draft| {
822 format!("restore '{}' from the trash", name.unwrap_or(id))
823 })
824 .await?;
825 let session = Arc::clone(&approved.session);
826 let result = async move {
827 session.restore_cipher(id).await?;
828 let name = draft.item_name.clone().unwrap_or_else(|| id.to_owned());
829 Ok(json!({ "id": id, "name": name, "restored": true }))
830 }
831 .await;
832 approved.done(result)
833 }
834
835 async fn totp_code(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError> {
836 let (approved, cipher, draft) = self
837 .item_gate(caller, VaultTool::TotpCode, id, |name, _draft| {
838 format!("compute a TOTP code for '{}'", name.unwrap_or(id))
839 })
840 .await?;
841 let keys = Arc::clone(&approved.keys);
842 let result = async move {
843 let name = draft.item_name.clone().unwrap_or_else(|| id.to_owned());
844 let seed = cipher
845 .pointer("/login/totp")
846 .and_then(Value::as_str)
847 .ok_or_else(|| VaultToolError::Message(format!("'{name}' has no TOTP seed")))?;
848 let seed = keys.decrypt_item_str(&cipher, seed)?;
849 let totp = forge_connectors::vault::crypto::totp_from_uri(
850 &seed,
851 std::time::SystemTime::now(),
852 )?;
853 Ok(json!({ "code": totp.code, "period": totp.period, "remaining": totp.remaining }))
854 }
855 .await;
856 approved.done(result)
857 }
858
859 async fn attachment_get(
860 &self,
861 caller: &SessionSlot,
862 item_id: &str,
863 attachment_id: &str,
864 destination: &str,
865 ) -> Result<Value, VaultToolError> {
866 let session = self.session()?;
867 let workspace = self.workspace()?;
868 let path = resolve_destination(&workspace, caller, destination)?;
872 let (cipher, name, file_name, size) = if session.is_locked() {
873 (None, None, None, None)
874 } else {
875 let cipher = session.get_cipher(item_id).await?;
876 let keys = session.keys()?;
877 let name = decrypt_name(&keys, &cipher)
878 .map_err(|error| self.decrypt_failed(&session, caller, Some(item_id), error))?;
879 let (file_name, size) = attachment_named(&keys, &cipher, attachment_id)
880 .map_err(|error| self.decrypt_failed(&session, caller, Some(item_id), error))?;
881 (Some(cipher), Some(name), Some(file_name), Some(size))
882 };
883 let destination_shown = path.display().to_string();
884 let mut draft = VaultDraft::new(
885 VaultTool::AttachmentGet,
886 match (&name, &file_name) {
887 (Some(name), Some(file_name)) => {
888 format!("download '{file_name}' from '{name}' to {destination_shown}")
889 }
890 _ => format!(
891 "download attachment {attachment_id} from item {item_id} to {destination_shown}"
892 ),
893 },
894 );
895 draft.item_id = Some(item_id.to_owned());
896 draft.item_name = name;
897 if let (Some(file_name), Some(size)) = (&file_name, size) {
898 draft.attachment =
899 Some(VaultAttachmentDraft { name: file_name.clone(), size, replaces: None });
900 }
901 let approved = self.gate(caller, draft.clone(), VaultTool::AttachmentGet, None).await?;
902 Self::guard_reload(&approved, &session)?;
903 let result = async {
904 let cipher = match cipher {
905 Some(cipher) => cipher,
906 None => approved.session.get_cipher(item_id).await?,
907 };
908 let meta = approved.session.get_attachment_meta(item_id, attachment_id).await?;
909 let url = meta.url.clone().ok_or_else(|| {
910 VaultToolError::Message("the vault did not return a download URL".to_owned())
911 })?;
912 let wrapped = meta.key.clone().ok_or_else(|| {
913 VaultToolError::Message("the vault did not return the attachment key".to_owned())
914 })?;
915 let blob = approved.session.download_attachment(&url, MAX_ATTACHMENT_BYTES).await?;
916 let plain = approved.keys.open_attachment(&cipher, &wrapped, &blob)?;
917 let size = plain.len();
918 let sha256 = forge_connectors::vault::crypto::sha256_hex(&plain);
919 let write_workspace = Arc::clone(&workspace);
924 let write_caller = caller.clone();
925 let expected = path.clone();
926 let destination = destination.to_owned();
927 tokio::task::spawn_blocking(move || {
928 let write_path =
929 recheck_destination(&write_workspace, &write_caller, &destination, &expected)?;
930 write_private(&write_path, &plain)
931 })
932 .await
933 .map_err(|error| VaultToolError::Io(format!("the attachment write: {error}")))??;
934 Ok(json!({
935 "path": path.display().to_string(),
936 "size": size,
937 "sha256": sha256,
938 }))
939 }
940 .await;
941 approved.done(result)
942 }
943
944 async fn attachment_put(
945 &self,
946 caller: &SessionSlot,
947 item_id: &str,
948 path: &str,
949 name: Option<&str>,
950 replace_attachment_id: Option<&str>,
951 ) -> Result<Value, VaultToolError> {
952 let workspace = self.workspace()?;
953 let session = self.session()?;
954 let source = resolve_source(&workspace, caller, path)?;
955 let read_path = source.clone();
958 let data =
959 tokio::task::spawn_blocking(move || read_attachment(&read_path, MAX_ATTACHMENT_BYTES))
960 .await
961 .map_err(|error| VaultToolError::Io(format!("the attachment read: {error}")))??;
962 let file_name = name
963 .map(str::to_owned)
964 .or_else(|| source.file_name().map(|name| name.to_string_lossy().into_owned()))
965 .ok_or_else(|| VaultToolError::Message("the path has no file name".to_owned()))?;
966 let size = data.len() as u64;
967 let replaced_name = if let Some(old) = replace_attachment_id {
970 if session.is_locked() {
971 None
972 } else {
973 let cipher = session.get_cipher(item_id).await?;
974 let keys = session.keys()?;
975 let (name, _) = attachment_named(&keys, &cipher, old)
976 .map_err(|error| self.decrypt_failed(&session, caller, Some(item_id), error))?;
977 Some(name)
978 }
979 } else {
980 None
981 };
982 let (approved, cipher, _draft) = self
983 .item_gate(caller, VaultTool::AttachmentPut, item_id, |name, draft| {
984 let replaced = replacement_label(replace_attachment_id, replaced_name);
985 draft.attachment = Some(VaultAttachmentDraft {
986 name: file_name.clone(),
987 size,
988 replaces: replaced.clone(),
989 });
990 match &replaced {
991 Some(old) => format!(
992 "replace '{old}' on '{}' with '{file_name}'",
993 name.unwrap_or(item_id)
994 ),
995 None => {
996 format!("upload '{file_name}' to '{}'", name.unwrap_or(item_id))
997 }
998 }
999 })
1000 .await?;
1001 Self::guard_reload(&approved, &session)?;
1002 self.ensure_keys_current(&approved, Some(item_id)).await?;
1003 let result = async {
1004 let (wrapped, encrypted_name, blob) =
1005 approved.keys.prepare_attachment(&cipher, &file_name, &data)?;
1006 if blob.len() as u64 > MAX_ATTACHMENT_BYTES {
1007 return Err(VaultToolError::Message(format!(
1008 "the encrypted attachment is {} bytes, over the {} byte cap",
1009 blob.len(),
1010 MAX_ATTACHMENT_BYTES
1011 )));
1012 }
1013 let slot_body = json!({
1014 "key": wrapped,
1015 "fileName": encrypted_name,
1016 "fileSize": blob.len() as u64,
1017 "adminRequest": false,
1018 });
1019 let slot = approved.session.create_attachment_slot(item_id, &slot_body).await?;
1020 let attachment_id =
1021 slot.get("attachmentId").and_then(Value::as_str).ok_or_else(|| {
1022 VaultToolError::Message(
1023 "the vault did not return the new attachment id".to_owned(),
1024 )
1025 })?;
1026 let blob_len = blob.len();
1027 approved.session.upload_attachment(item_id, attachment_id, &blob).await?;
1028 let meta = approved.session.get_attachment_meta(item_id, attachment_id).await?;
1029 check_stored_size(meta.size, blob_len)?;
1030 if let Some(old) = replace_attachment_id {
1034 approved.session.delete_attachment(item_id, old).await?;
1035 }
1036 Ok(json!({
1037 "id": attachment_id,
1038 "name": file_name,
1039 "size": blob_len,
1040 "sha256": forge_connectors::vault::crypto::sha256_hex(&data),
1041 "replaced": replace_attachment_id,
1042 }))
1043 }
1044 .await;
1045 approved.done(result)
1046 }
1047
1048 async fn folder_list(&self, caller: &SessionSlot) -> Result<Value, VaultToolError> {
1049 let draft = VaultDraft::new(VaultTool::FolderList, "list folders");
1050 let approved = self.gate(caller, draft.clone(), VaultTool::FolderList, None).await?;
1051 self.ensure_keys_current(&approved, None).await?;
1052 let result = async {
1053 let folders = approved.session.list_folders().await?;
1054 let mut rows = Vec::new();
1055 for folder in &folders {
1056 let name = self.fallback_or_lock(
1057 &approved.session,
1058 &approved.caller,
1059 Some(&folder.id),
1060 approved.keys.decrypt_user_str(&folder.name).map_err(VaultToolError::Crypto),
1061 "(unreadable)".to_owned(),
1062 )?;
1063 rows.push(json!({ "id": folder.id, "name": name }));
1064 }
1065 Ok(json!({ "folders": rows }))
1066 }
1067 .await;
1068 approved.done(result)
1069 }
1070
1071 async fn folder_create(
1072 &self,
1073 caller: &SessionSlot,
1074 name: &str,
1075 ) -> Result<Value, VaultToolError> {
1076 let mut draft = VaultDraft::new(VaultTool::FolderCreate, format!("create folder '{name}'"));
1077 draft.item_name = Some(name.to_owned());
1078 let approved = self.gate(caller, draft.clone(), VaultTool::FolderCreate, None).await?;
1079 self.ensure_keys_current(&approved, None).await?;
1080 let result = async {
1081 let encrypted = approved.keys.encrypt_user_str(name)?;
1082 let created = approved.session.create_folder(&encrypted).await?;
1083 let id = created.get("id").and_then(Value::as_str).ok_or_else(|| {
1084 VaultToolError::Message("the vault did not return the new folder's id".to_owned())
1085 })?;
1086 Ok(json!({ "id": id, "name": name }))
1087 }
1088 .await;
1089 approved.done(result)
1090 }
1091
1092 async fn folder_update(
1093 &self,
1094 caller: &SessionSlot,
1095 id: &str,
1096 name: &str,
1097 ) -> Result<Value, VaultToolError> {
1098 let mut draft =
1099 VaultDraft::new(VaultTool::FolderUpdate, format!("rename a folder to '{name}'"));
1100 draft.item_id = Some(id.to_owned());
1101 let approved = self.gate(caller, draft.clone(), VaultTool::FolderUpdate, None).await?;
1102 self.ensure_keys_current(&approved, Some(id)).await?;
1103 let result = async {
1104 let encrypted = approved.keys.encrypt_user_str(name)?;
1105 approved.session.update_folder(id, &encrypted).await?;
1106 Ok(json!({ "id": id, "name": name }))
1107 }
1108 .await;
1109 approved.done(result)
1110 }
1111
1112 async fn folder_delete(&self, caller: &SessionSlot, id: &str) -> Result<Value, VaultToolError> {
1113 let session = self.session()?;
1114 let name = if session.is_locked() {
1115 None
1116 } else {
1117 let keys = session.keys()?;
1118 let folders = session.list_folders().await?;
1119 match folders.iter().find(|folder| folder.id == id) {
1120 Some(folder) => Some(self.fallback_or_lock(
1121 &session,
1122 caller,
1123 Some(id),
1124 keys.decrypt_user_str(&folder.name).map_err(VaultToolError::Crypto),
1125 "(unreadable)".to_owned(),
1126 )?),
1127 None => None,
1128 }
1129 };
1130 let mut draft = VaultDraft::new(
1131 VaultTool::FolderDelete,
1132 match &name {
1133 Some(name) => format!("delete folder '{name}' (its items move to No Folder)"),
1134 None => format!("delete folder {id} (its items move to No Folder)"),
1135 },
1136 );
1137 draft.item_id = Some(id.to_owned());
1138 draft.item_name = name;
1139 let approved = self.gate(caller, draft.clone(), VaultTool::FolderDelete, None).await?;
1140 Self::guard_reload(&approved, &session)?;
1141 let result = async {
1142 approved.session.delete_folder(id).await?;
1143 let name = draft.item_name.clone().unwrap_or_else(|| id.to_owned());
1144 Ok(json!({ "id": id, "name": name, "deleted": true }))
1145 }
1146 .await;
1147 approved.done(result)
1148 }
1149}
1150
1151impl ProdVaultFacade {
1152 fn search_items(
1157 &self,
1158 session: &VaultSession,
1159 caller: &SessionSlot,
1160 keys: &Unlocked,
1161 sync: &SyncData,
1162 query: &str,
1163 limit: usize,
1164 ) -> Result<Vec<Value>, VaultToolError> {
1165 let needle = query.trim().to_lowercase();
1166 let mut items = Vec::new();
1167 for cipher in &sync.ciphers {
1168 if cipher.get("deletedDate").and_then(Value::as_str).is_some() {
1169 continue;
1170 }
1171 let item = cipher.get("id").and_then(Value::as_str);
1172 let name = self.fallback_or_lock(
1173 session,
1174 caller,
1175 item,
1176 decrypt_name(keys, cipher),
1177 "(unreadable)".to_owned(),
1178 )?;
1179 let username = self.decrypt_username(session, caller, item, keys, cipher)?;
1180 let uris = self.decrypt_uris(session, caller, item, keys, cipher)?;
1181 let hit = needle.is_empty()
1182 || name.to_lowercase().contains(&needle)
1183 || username.as_deref().is_some_and(|u| u.to_lowercase().contains(&needle))
1184 || uris.iter().any(|uri| uri.to_lowercase().contains(&needle));
1185 if !hit {
1186 continue;
1187 }
1188 let folder = self.folder_name(session, caller, item, keys, sync, cipher)?;
1189 items.push(json!({
1190 "id": cipher.get("id").and_then(Value::as_str),
1191 "name": name,
1192 "username": username,
1193 "uris": uris,
1194 "folder": folder,
1195 "collection": Value::Null,
1196 "updated_at": cipher.get("revisionDate").and_then(Value::as_str),
1197 }));
1198 if items.len() >= limit {
1199 break;
1200 }
1201 }
1202 Ok(items)
1203 }
1204
1205 fn decrypt_username(
1206 &self,
1207 session: &VaultSession,
1208 caller: &SessionSlot,
1209 item: Option<&str>,
1210 keys: &Unlocked,
1211 cipher: &Value,
1212 ) -> Result<Option<String>, VaultToolError> {
1213 let Some(enc) = cipher.pointer("/login/username").and_then(Value::as_str) else {
1214 return Ok(None);
1215 };
1216 let value = self.fallback_or_lock(
1217 session,
1218 caller,
1219 item,
1220 keys.decrypt_item_str(cipher, enc).map_err(VaultToolError::Vault),
1221 String::new(),
1222 )?;
1223 Ok((!value.is_empty()).then_some(value))
1224 }
1225
1226 fn decrypt_uris(
1227 &self,
1228 session: &VaultSession,
1229 caller: &SessionSlot,
1230 item: Option<&str>,
1231 keys: &Unlocked,
1232 cipher: &Value,
1233 ) -> Result<Vec<String>, VaultToolError> {
1234 let mut uris = Vec::new();
1235 if let Some(list) = cipher.pointer("/login/uris").and_then(Value::as_array) {
1236 for entry in list {
1237 if let Some(enc) = entry.get("uri").and_then(Value::as_str) {
1238 let uri = self.fallback_or_lock(
1239 session,
1240 caller,
1241 item,
1242 keys.decrypt_item_str(cipher, enc).map_err(VaultToolError::Vault),
1243 String::new(),
1244 )?;
1245 if !uri.is_empty() {
1246 uris.push(uri);
1247 }
1248 }
1249 }
1250 }
1251 if uris.is_empty()
1252 && let Some(enc) = cipher.pointer("/login/uri").and_then(Value::as_str)
1253 {
1254 let uri = self.fallback_or_lock(
1255 session,
1256 caller,
1257 item,
1258 keys.decrypt_item_str(cipher, enc).map_err(VaultToolError::Vault),
1259 String::new(),
1260 )?;
1261 if !uri.is_empty() {
1262 uris.push(uri);
1263 }
1264 }
1265 Ok(uris)
1266 }
1267
1268 fn folder_name(
1269 &self,
1270 session: &VaultSession,
1271 caller: &SessionSlot,
1272 item: Option<&str>,
1273 keys: &Unlocked,
1274 sync: &SyncData,
1275 cipher: &Value,
1276 ) -> Result<Option<String>, VaultToolError> {
1277 let Some(id) = cipher.get("folderId").and_then(Value::as_str) else {
1278 return Ok(None);
1279 };
1280 let Some(folder) = sync.folders.iter().find(|folder| folder.id == id) else {
1281 return Ok(None);
1282 };
1283 let name = self.fallback_or_lock(
1284 session,
1285 caller,
1286 item,
1287 keys.decrypt_user_str(&folder.name).map_err(VaultToolError::Crypto),
1288 String::new(),
1289 )?;
1290 Ok((!name.is_empty()).then_some(name))
1291 }
1292
1293 fn item_metadata(
1294 &self,
1295 approved: &Approved,
1296 keys: &Unlocked,
1297 cipher: &Value,
1298 ) -> Result<Value, VaultToolError> {
1299 let session = approved.session.as_ref();
1300 let caller = &approved.caller;
1301 let item = cipher.get("id").and_then(Value::as_str);
1302 let mut metadata = json!({
1303 "id": item,
1304 "name": decrypt_name(keys, cipher)?,
1305 "type": cipher.get("type").and_then(Value::as_u64),
1306 "folder_id": cipher.get("folderId").and_then(Value::as_str),
1307 "collection_ids": cipher.get("collectionIds").cloned().unwrap_or(Value::Null),
1308 "favorite": cipher.get("favorite").and_then(Value::as_bool).unwrap_or(false),
1309 "revision_date": cipher.get("revisionDate").and_then(Value::as_str),
1310 "deleted_date": cipher.get("deletedDate").and_then(Value::as_str),
1311 "attachments": self.attachment_rows(session, caller, item, keys, cipher)?,
1312 });
1313 if let Some(username) = self.decrypt_username(session, caller, item, keys, cipher)? {
1314 metadata["username"] = json!(username);
1315 }
1316 let uris = self.decrypt_uris(session, caller, item, keys, cipher)?;
1317 if !uris.is_empty() {
1318 metadata["uris"] = json!(uris);
1319 }
1320 if let Some(brand) = cipher.pointer("/card/brand").and_then(Value::as_str) {
1321 let brand = self.fallback_or_lock(
1322 session,
1323 caller,
1324 item,
1325 keys.decrypt_item_str(cipher, brand).map_err(VaultToolError::Vault),
1326 String::new(),
1327 )?;
1328 if !brand.is_empty() {
1329 metadata["card_brand"] = json!(brand);
1330 }
1331 }
1332 Ok(metadata)
1333 }
1334
1335 fn attachment_rows(
1336 &self,
1337 session: &VaultSession,
1338 caller: &SessionSlot,
1339 item: Option<&str>,
1340 keys: &Unlocked,
1341 cipher: &Value,
1342 ) -> Result<Value, VaultToolError> {
1343 let Some(list) = cipher.get("attachments").and_then(Value::as_array) else {
1344 return Ok(Value::Array(Vec::new()));
1345 };
1346 let mut rows = Vec::new();
1347 for attachment in list {
1348 let name = match attachment.get("fileName").and_then(Value::as_str) {
1349 Some(enc) => self.fallback_or_lock(
1350 session,
1351 caller,
1352 item,
1353 keys.decrypt_item_str(cipher, enc).map_err(VaultToolError::Vault),
1354 "(unreadable)".to_owned(),
1355 )?,
1356 None => "(unreadable)".to_owned(),
1357 };
1358 rows.push(json!({
1359 "id": attachment.get("id").and_then(Value::as_str),
1360 "file_name": name,
1361 "size": attachment.get("size").and_then(Value::as_u64),
1362 }));
1363 }
1364 Ok(Value::Array(rows))
1365 }
1366}
1367
1368fn decrypt_name(keys: &Unlocked, cipher: &Value) -> Result<String, VaultToolError> {
1369 let enc = cipher
1370 .get("name")
1371 .and_then(Value::as_str)
1372 .ok_or_else(|| VaultToolError::Message("the item has no name".to_owned()))?;
1373 Ok(keys.decrypt_item_str(cipher, enc)?)
1374}
1375
1376fn attachment_named(
1377 keys: &Unlocked,
1378 cipher: &Value,
1379 attachment_id: &str,
1380) -> Result<(String, u64), VaultToolError> {
1381 let list = cipher
1382 .get("attachments")
1383 .and_then(Value::as_array)
1384 .ok_or_else(|| VaultToolError::Message("the item has no attachments".to_owned()))?;
1385 let attachment = list
1386 .iter()
1387 .find(|attachment| attachment.get("id").and_then(Value::as_str) == Some(attachment_id))
1388 .ok_or_else(|| {
1389 VaultToolError::Message(format!("the item has no attachment '{attachment_id}'"))
1390 })?;
1391 let name = match attachment.get("fileName").and_then(Value::as_str) {
1392 Some(enc) => match keys.decrypt_item_str(cipher, enc) {
1393 Ok(name) => name,
1394 Err(VaultError::MissingKey(_)) => "(unreadable)".to_owned(),
1397 Err(error) => return Err(VaultToolError::Vault(error)),
1398 },
1399 None => "(unreadable)".to_owned(),
1400 };
1401 let size = attachment.get("size").and_then(Value::as_u64).unwrap_or(0);
1402 Ok((name, size))
1403}
1404
1405fn validate_reveal(reveal: Option<&str>) -> Result<(), VaultToolError> {
1406 let Some(reveal) = reveal else { return Ok(()) };
1407 let accepted = matches!(
1408 reveal,
1409 "password" | "notes" | "card.cardholder" | "card.number" | "card.expiry" | "card.code"
1410 ) || reveal.starts_with("custom:");
1411 if accepted {
1412 Ok(())
1413 } else {
1414 Err(VaultToolError::Message(format!(
1415 "unknown reveal '{reveal}'; use password, notes, custom:<name>, card.cardholder, \
1416 card.number, card.expiry or card.code"
1417 )))
1418 }
1419}
1420
1421fn reveal_value(keys: &Unlocked, cipher: &Value, reveal: &str) -> Result<Value, VaultToolError> {
1422 match reveal {
1423 "password" => {
1424 let enc = cipher
1425 .pointer("/login/password")
1426 .and_then(Value::as_str)
1427 .ok_or_else(|| VaultToolError::Message("the item has no password".to_owned()))?;
1428 Ok(json!(keys.decrypt_item_str(cipher, enc)?))
1429 }
1430 "notes" => {
1431 let enc = cipher
1432 .get("notes")
1433 .and_then(Value::as_str)
1434 .ok_or_else(|| VaultToolError::Message("the item has no notes".to_owned()))?;
1435 Ok(json!(keys.decrypt_item_str(cipher, enc)?))
1436 }
1437 "card.cardholder" => card_field(keys, cipher, "cardholderName"),
1438 "card.number" => card_field(keys, cipher, "number"),
1439 "card.code" => card_field(keys, cipher, "code"),
1440 "card.expiry" => {
1441 let month = card_field(keys, cipher, "expMonth")?;
1442 let year = card_field(keys, cipher, "expYear")?;
1443 Ok(json!({ "expMonth": month, "expYear": year }))
1444 }
1445 custom if custom.starts_with("custom:") => {
1446 let wanted = &custom["custom:".len()..];
1447 let fields = cipher.get("fields").and_then(Value::as_array).ok_or_else(|| {
1448 VaultToolError::Message("the item has no custom fields".to_owned())
1449 })?;
1450 for field in fields {
1451 let name = field
1452 .get("name")
1453 .and_then(Value::as_str)
1454 .map(|enc| keys.decrypt_item_str(cipher, enc))
1455 .transpose()?;
1456 if name.as_deref() == Some(wanted)
1457 && let Some(enc) = field.get("value").and_then(Value::as_str)
1458 {
1459 return Ok(json!(keys.decrypt_item_str(cipher, enc)?));
1460 }
1461 }
1462 Err(VaultToolError::Message(format!("the item has no custom field '{wanted}'")))
1463 }
1464 other => Err(VaultToolError::Message(format!("unknown reveal '{other}'"))),
1465 }
1466}
1467
1468fn card_field(keys: &Unlocked, cipher: &Value, field: &str) -> Result<Value, VaultToolError> {
1469 let enc = cipher
1470 .pointer(&format!("/card/{field}"))
1471 .and_then(Value::as_str)
1472 .ok_or_else(|| VaultToolError::Message(format!("the item has no card {field}")))?;
1473 Ok(json!(keys.decrypt_item_str(cipher, enc)?))
1474}
1475
1476fn masked_create_diff(input: &CreateInput, kind: &str) -> String {
1477 match kind {
1478 "card" => "new card: number, expiry and CVV are set and never drawn".to_owned(),
1479 "note" => "secure note".to_owned(),
1480 _ => {
1481 let mut fields = vec!["name"];
1482 if input.username.is_some() {
1483 fields.push("username");
1484 }
1485 if input.password.is_some() {
1486 fields.push("password");
1487 }
1488 if input.uri.is_some() {
1489 fields.push("uri");
1490 }
1491 if input.totp.is_some() {
1492 fields.push("totp");
1493 }
1494 if input.notes.is_some() {
1495 fields.push("notes");
1496 }
1497 if input.custom_fields.is_some() {
1498 fields.push("custom fields");
1499 }
1500 format!("new login: {}", fields.join(", "))
1501 }
1502 }
1503}
1504
1505fn patch_fields(patch: &PatchInput) -> Vec<&'static str> {
1506 let mut fields = Vec::new();
1507 if patch.name.is_some() {
1508 fields.push("name");
1509 }
1510 if patch.username.is_some() {
1511 fields.push("username");
1512 }
1513 if patch.password.is_some() {
1514 fields.push("password");
1515 }
1516 if patch.uri.is_some() {
1517 fields.push("uri");
1518 }
1519 if patch.totp.is_some() {
1520 fields.push("totp");
1521 }
1522 if patch.notes.is_some() {
1523 fields.push("notes");
1524 }
1525 if patch.cardholder_name.is_some()
1526 || patch.number.is_some()
1527 || patch.exp_month.is_some()
1528 || patch.exp_year.is_some()
1529 || patch.code.is_some()
1530 || patch.brand.is_some()
1531 {
1532 fields.push("card details");
1533 }
1534 if patch.folder_id.is_some() {
1535 fields.push("folder");
1536 }
1537 if patch.custom_fields.is_some() {
1538 fields.push("custom fields");
1539 }
1540 fields
1541}
1542
1543fn masked_update_diff(changed: &[&str]) -> String {
1544 if changed.contains(&"password") {
1545 let mut parts: Vec<&str> =
1546 changed.iter().copied().filter(|field| *field != "password").collect();
1547 parts.push("password changed");
1548 return parts.join(", ");
1549 }
1550 if changed.contains(&"card details") {
1551 let mut parts: Vec<&str> =
1552 changed.iter().copied().filter(|field| *field != "card details").collect();
1553 parts.push("card details changed");
1554 return parts.join(", ");
1555 }
1556 changed.join(", ")
1557}
1558
1559fn account_user_id(profile: &Profile) -> Result<String, VaultToolError> {
1563 profile.id.as_deref().filter(|id| !id.is_empty()).map(str::to_owned).ok_or_else(|| {
1564 VaultToolError::Message(
1565 "the vault sync carried no account id, so the item cannot be stamped with encryptedFor"
1566 .to_owned(),
1567 )
1568 })
1569}
1570
1571fn check_totp_seed(seed: &str) -> Result<(), VaultToolError> {
1574 if seed.trim().is_empty() {
1575 return Err(VaultToolError::Message("the TOTP seed is empty".to_owned()));
1576 }
1577 forge_connectors::vault::crypto::totp_from_uri(seed, std::time::SystemTime::now())
1578 .map(|_| ())
1579 .map_err(|error| {
1580 VaultToolError::Message(format!("the TOTP seed cannot produce a code: {error}"))
1581 })
1582}
1583
1584fn build_new_cipher(
1585 keys: &Unlocked,
1586 user_id: &str,
1587 input: &CreateInput,
1588 kind: &str,
1589) -> Result<Value, VaultToolError> {
1590 if input.totp.is_some() && kind != "login" {
1591 return Err(VaultToolError::Message(format!(
1592 "a TOTP seed applies to a login item, not a {kind}"
1593 )));
1594 }
1595 let base = Value::Null;
1596 let mut cipher = json!({
1597 "type": match kind { "card" => 3, "note" => 2, _ => 1 },
1598 "name": keys.encrypt_item_str(&base, &input.name)?,
1599 "favorite": false,
1600 "reprompt": 0,
1601 "encryptedFor": user_id,
1602 });
1603 if let Some(notes) = &input.notes {
1604 cipher["notes"] = json!(keys.encrypt_item_str(&base, notes)?);
1605 }
1606 if let Some(folder) = &input.folder_id {
1607 cipher["folderId"] = json!(folder);
1608 }
1609 match kind {
1610 "card" => {
1611 let mut card = serde_json::Map::new();
1612 for (field, value) in [
1613 ("cardholderName", input.cardholder_name.as_ref()),
1614 ("brand", input.brand.as_ref()),
1615 ("number", input.number.as_ref()),
1616 ("expMonth", input.exp_month.as_ref()),
1617 ("expYear", input.exp_year.as_ref()),
1618 ("code", input.code.as_ref()),
1619 ] {
1620 if let Some(value) = value {
1621 card.insert(field.to_owned(), json!(keys.encrypt_item_str(&base, value)?));
1622 }
1623 }
1624 cipher["card"] = Value::Object(card);
1625 }
1626 "note" => {
1627 cipher["secureNote"] = json!({ "type": 0 });
1628 }
1629 _ => {
1630 let mut login = serde_json::Map::new();
1631 if let Some(username) = &input.username {
1632 login.insert("username".to_owned(), json!(keys.encrypt_item_str(&base, username)?));
1633 }
1634 if let Some(password) = &input.password {
1635 login.insert("password".to_owned(), json!(keys.encrypt_item_str(&base, password)?));
1636 }
1637 if let Some(uri) = &input.uri {
1638 let encrypted = keys.encrypt_item_str(&base, uri)?;
1639 login.insert("uri".to_owned(), json!(encrypted));
1640 login
1641 .insert("uris".to_owned(), json!([{ "uri": encrypted, "match": Value::Null }]));
1642 }
1643 if let Some(totp) = &input.totp {
1644 check_totp_seed(totp)?;
1645 login.insert("totp".to_owned(), json!(keys.encrypt_item_str(&base, totp)?));
1646 }
1647 cipher["login"] = Value::Object(login);
1648 }
1649 }
1650 if let Some(fields) = &input.custom_fields {
1651 cipher["fields"] = custom_fields(keys, &base, fields)?;
1652 }
1653 Ok(cipher)
1654}
1655
1656fn custom_fields(
1657 keys: &Unlocked,
1658 cipher: &Value,
1659 fields: &[CustomFieldInput],
1660) -> Result<Value, VaultToolError> {
1661 let mut rows = Vec::new();
1662 for field in fields {
1663 let kind = match field.kind.as_deref() {
1664 Some("hidden") => 1,
1665 Some("boolean") => 2,
1666 _ => 0,
1667 };
1668 rows.push(json!({
1669 "name": keys.encrypt_item_str(cipher, &field.name)?,
1670 "value": keys.encrypt_item_str(cipher, &field.value)?,
1671 "type": kind,
1672 }));
1673 }
1674 Ok(Value::Array(rows))
1675}
1676
1677fn apply_patch(
1678 keys: &Unlocked,
1679 user_id: &str,
1680 mut cipher: Value,
1681 patch: &PatchInput,
1682) -> Result<Value, VaultToolError> {
1683 let carried =
1689 cipher.get("encryptedFor").and_then(Value::as_str).is_some_and(|owner| !owner.is_empty());
1690 if !carried {
1691 cipher["encryptedFor"] = json!(user_id);
1692 }
1693 if let Some(totp) = &patch.totp {
1694 if cipher.get("type").and_then(Value::as_u64) != Some(1) {
1695 return Err(VaultToolError::Message("a TOTP seed applies to a login item".to_owned()));
1696 }
1697 check_totp_seed(totp)?;
1698 }
1699 if let Some(name) = &patch.name {
1700 cipher["name"] = json!(keys.encrypt_item_str(&cipher, name)?);
1701 }
1702 if let Some(notes) = &patch.notes {
1703 cipher["notes"] = json!(keys.encrypt_item_str(&cipher, notes)?);
1704 }
1705 if let Some(folder) = &patch.folder_id {
1706 cipher["folderId"] = json!(folder);
1707 }
1708 if patch.username.is_some()
1709 || patch.password.is_some()
1710 || patch.uri.is_some()
1711 || patch.totp.is_some()
1712 {
1713 if cipher.get("login").is_none() {
1714 cipher["login"] = json!({});
1715 }
1716 if let Some(username) = &patch.username {
1717 cipher["login"]["username"] = json!(keys.encrypt_item_str(&cipher, username)?);
1718 }
1719 if let Some(password) = &patch.password {
1720 cipher["login"]["password"] = json!(keys.encrypt_item_str(&cipher, password)?);
1721 }
1722 if let Some(uri) = &patch.uri {
1723 let encrypted = keys.encrypt_item_str(&cipher, uri)?;
1724 cipher["login"]["uri"] = json!(encrypted);
1725 cipher["login"]["uris"] = json!([{ "uri": encrypted, "match": Value::Null }]);
1726 }
1727 if let Some(totp) = &patch.totp {
1728 cipher["login"]["totp"] = json!(keys.encrypt_item_str(&cipher, totp)?);
1729 }
1730 }
1731 let card_fields = [
1732 ("cardholderName", patch.cardholder_name.as_ref()),
1733 ("brand", patch.brand.as_ref()),
1734 ("number", patch.number.as_ref()),
1735 ("expMonth", patch.exp_month.as_ref()),
1736 ("expYear", patch.exp_year.as_ref()),
1737 ("code", patch.code.as_ref()),
1738 ];
1739 if card_fields.iter().any(|(_, value)| value.is_some()) {
1740 if cipher.get("card").is_none() {
1741 cipher["card"] = json!({});
1742 }
1743 for (field, value) in card_fields {
1744 if let Some(value) = value {
1745 cipher["card"][field] = json!(keys.encrypt_item_str(&cipher, value)?);
1746 }
1747 }
1748 }
1749 if let Some(fields) = &patch.custom_fields {
1750 cipher["fields"] = custom_fields(keys, &cipher, fields)?;
1751 }
1752 Ok(cipher)
1753}
1754
1755fn resolve_destination(
1756 workspace: &Workspace,
1757 caller: &SessionSlot,
1758 destination: &str,
1759) -> Result<PathBuf, VaultToolError> {
1760 let cwd = workspace.cwd_for_session(caller).ok_or_else(|| {
1761 VaultToolError::Message("the session has no working directory".to_owned())
1762 })?;
1763 let cwd = std::fs::canonicalize(cwd)
1764 .map_err(|error| VaultToolError::Io(format!("the working directory: {error}")))?;
1765 let target = Path::new(destination);
1766 let absolute = if target.is_absolute() { target.to_path_buf() } else { cwd.join(target) };
1767 let parent = absolute.parent().ok_or_else(|| {
1768 VaultToolError::Message("the destination has no parent directory".to_owned())
1769 })?;
1770 let parent = std::fs::canonicalize(parent)
1771 .map_err(|error| VaultToolError::Io(format!("the destination directory: {error}")))?;
1772 if !parent.starts_with(&cwd) {
1773 return Err(VaultToolError::Message(
1774 "the destination is outside this session's working directory".to_owned(),
1775 ));
1776 }
1777 let name = absolute
1778 .file_name()
1779 .ok_or_else(|| VaultToolError::Message("the destination has no file name".to_owned()))?;
1780 Ok(parent.join(name))
1781}
1782
1783fn resolve_source(
1784 workspace: &Workspace,
1785 caller: &SessionSlot,
1786 path: &str,
1787) -> Result<PathBuf, VaultToolError> {
1788 let cwd = workspace.cwd_for_session(caller).ok_or_else(|| {
1789 VaultToolError::Message("the session has no working directory".to_owned())
1790 })?;
1791 let cwd = std::fs::canonicalize(cwd)
1792 .map_err(|error| VaultToolError::Io(format!("the working directory: {error}")))?;
1793 let target = Path::new(path);
1794 let absolute = if target.is_absolute() { target.to_path_buf() } else { cwd.join(target) };
1795 let source = std::fs::canonicalize(&absolute)
1796 .map_err(|error| VaultToolError::Io(format!("the attachment file: {error}")))?;
1797 if !source.starts_with(&cwd) {
1798 return Err(VaultToolError::Message(
1799 "the attachment file is outside this session's working directory".to_owned(),
1800 ));
1801 }
1802 Ok(source)
1803}
1804
1805fn check_stored_size(stored: Option<u64>, blob_len: usize) -> Result<(), VaultToolError> {
1808 let Some(stored) = stored else {
1809 return Err(VaultToolError::Message(
1810 "the server did not report the stored attachment size".to_owned(),
1811 ));
1812 };
1813 if stored != blob_len as u64 {
1814 return Err(VaultToolError::Message(format!(
1815 "the server stored {stored} bytes for a {blob_len} byte blob"
1816 )));
1817 }
1818 Ok(())
1819}
1820
1821fn recheck_destination(
1824 workspace: &Workspace,
1825 caller: &SessionSlot,
1826 destination: &str,
1827 expected: &Path,
1828) -> Result<PathBuf, VaultToolError> {
1829 let current = resolve_destination(workspace, caller, destination)?;
1830 if current != expected {
1831 return Err(VaultToolError::Message(
1832 "the destination changed while the call was parked; nothing was written".to_owned(),
1833 ));
1834 }
1835 Ok(current)
1836}
1837
1838fn replacement_label(id: Option<&str>, name: Option<String>) -> Option<String> {
1841 id.map(|id| name.unwrap_or_else(|| id.to_owned()))
1842}
1843
1844fn read_attachment(path: &Path, cap: u64) -> Result<Vec<u8>, VaultToolError> {
1847 let declared = std::fs::metadata(path)?.len();
1848 if declared > cap {
1849 return Err(VaultToolError::Message(format!(
1850 "the file is {declared} bytes, over the {cap} byte cap"
1851 )));
1852 }
1853 let data = std::fs::read(path)?;
1854 if data.len() as u64 > cap {
1855 return Err(VaultToolError::Message(format!(
1856 "the file is {} bytes, over the {cap} byte cap",
1857 data.len()
1858 )));
1859 }
1860 Ok(data)
1861}
1862
1863fn write_private(path: &Path, bytes: &[u8]) -> Result<(), VaultToolError> {
1864 use rustix::fs::{Mode, OFlags};
1865 use std::io::Write;
1866 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1867
1868 let parent = path
1869 .parent()
1870 .ok_or_else(|| VaultToolError::Message("the destination has no parent".to_owned()))?;
1871 let name = path
1872 .file_name()
1873 .ok_or_else(|| VaultToolError::Message("the destination has no file name".to_owned()))?;
1874 let expected = std::fs::canonicalize(parent)?;
1880 let dir = rustix::fs::open(&expected, OFlags::RDONLY | OFlags::CLOEXEC, Mode::empty())
1881 .map_err(errno_io)?;
1882 let dir = std::fs::File::from(dir);
1883 verify_dir_path(&dir, &expected)?;
1884 let file = rustix::fs::openat(
1885 &dir,
1886 name,
1887 OFlags::WRONLY | OFlags::CREATE | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC,
1888 Mode::from_bits_truncate(0o600),
1889 )
1890 .map_err(errno_io)?;
1891 let mut file = std::fs::File::from(file);
1892 let metadata = file.metadata()?;
1893 if !metadata.file_type().is_file() {
1896 return Err(VaultToolError::Message(
1897 "the destination is not a regular file; refusing to write".to_owned(),
1898 ));
1899 }
1900 if metadata.nlink() > 1 {
1904 return Err(VaultToolError::Message(
1905 "the destination is a hard link; refusing to write through it".to_owned(),
1906 ));
1907 }
1908 file.set_len(0)?;
1909 file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
1910 file.write_all(bytes)?;
1911 file.flush()?;
1912 Ok(())
1913}
1914
1915fn errno_io(error: rustix::io::Errno) -> std::io::Error {
1916 std::io::Error::from_raw_os_error(error.raw_os_error())
1917}
1918
1919fn dir_path(dir: &std::fs::File) -> std::io::Result<PathBuf> {
1922 #[cfg(target_os = "linux")]
1923 use std::os::fd::AsRawFd;
1924 #[cfg(target_os = "macos")]
1925 use std::os::unix::ffi::OsStrExt;
1926
1927 #[cfg(target_os = "linux")]
1928 {
1929 std::fs::read_link(format!("/proc/self/fd/{}", dir.as_raw_fd()))
1930 }
1931 #[cfg(target_os = "macos")]
1932 {
1933 let path = rustix::fs::getpath(dir).map_err(errno_io)?;
1934 Ok(PathBuf::from(std::ffi::OsStr::from_bytes(path.as_bytes())))
1935 }
1936 #[cfg(not(any(target_os = "linux", target_os = "macos")))]
1937 {
1938 let _ = dir;
1939 Err(std::io::Error::new(
1940 std::io::ErrorKind::Unsupported,
1941 "this platform cannot read a directory handle's path",
1942 ))
1943 }
1944}
1945
1946fn verify_dir_path(dir: &std::fs::File, expected: &Path) -> Result<(), VaultToolError> {
1949 if dir_path(dir)? != expected {
1950 return Err(VaultToolError::Message(
1951 "the destination directory changed while the call was parked; nothing was written"
1952 .to_owned(),
1953 ));
1954 }
1955 Ok(())
1956}
1957
1958#[cfg(test)]
1961#[derive(Default)]
1962pub(crate) struct MockVaultFacade {
1963 calls: parking_lot::Mutex<Vec<String>>,
1964 failure: parking_lot::Mutex<Option<String>>,
1965}
1966
1967#[cfg(test)]
1968impl MockVaultFacade {
1969 pub(crate) fn new() -> Self {
1970 Self::default()
1971 }
1972
1973 pub(crate) fn into_arc(self) -> Arc<dyn VaultFacade> {
1974 Arc::new(self)
1975 }
1976
1977 pub(crate) fn fail_next(&self, message: impl Into<String>) {
1978 *self.failure.lock() = Some(message.into());
1979 }
1980
1981 pub(crate) fn calls(&self) -> Vec<String> {
1982 self.calls.lock().clone()
1983 }
1984
1985 fn record(&self, tool: VaultTool) -> Result<Value, VaultToolError> {
1986 self.calls.lock().push(tool.as_str().to_owned());
1987 if let Some(message) = self.failure.lock().take() {
1988 return Err(VaultToolError::Message(message));
1989 }
1990 Ok(json!({ "tool": tool.as_str() }))
1991 }
1992}
1993
1994#[cfg(test)]
1995#[async_trait]
1996impl VaultFacade for MockVaultFacade {
1997 async fn search(
1998 &self,
1999 _caller: &SessionSlot,
2000 _query: &str,
2001 _limit: Option<usize>,
2002 ) -> Result<Value, VaultToolError> {
2003 self.record(VaultTool::Search)
2004 }
2005 async fn get(
2006 &self,
2007 _caller: &SessionSlot,
2008 _id: &str,
2009 _reveal: Option<&str>,
2010 ) -> Result<Value, VaultToolError> {
2011 self.record(VaultTool::Get)
2012 }
2013 async fn create(
2014 &self,
2015 _caller: &SessionSlot,
2016 _input: &CreateInput,
2017 ) -> Result<Value, VaultToolError> {
2018 self.record(VaultTool::Create)
2019 }
2020 async fn update(
2021 &self,
2022 _caller: &SessionSlot,
2023 _id: &str,
2024 _patch: &PatchInput,
2025 ) -> Result<Value, VaultToolError> {
2026 self.record(VaultTool::Update)
2027 }
2028 async fn trash(&self, _caller: &SessionSlot, _id: &str) -> Result<Value, VaultToolError> {
2029 self.record(VaultTool::Trash)
2030 }
2031 async fn restore(&self, _caller: &SessionSlot, _id: &str) -> Result<Value, VaultToolError> {
2032 self.record(VaultTool::Restore)
2033 }
2034 async fn totp_code(&self, _caller: &SessionSlot, _id: &str) -> Result<Value, VaultToolError> {
2035 self.record(VaultTool::TotpCode)
2036 }
2037 async fn attachment_get(
2038 &self,
2039 _caller: &SessionSlot,
2040 _item_id: &str,
2041 _attachment_id: &str,
2042 _destination: &str,
2043 ) -> Result<Value, VaultToolError> {
2044 self.record(VaultTool::AttachmentGet)
2045 }
2046 async fn attachment_put(
2047 &self,
2048 _caller: &SessionSlot,
2049 _item_id: &str,
2050 _path: &str,
2051 _name: Option<&str>,
2052 _replace_attachment_id: Option<&str>,
2053 ) -> Result<Value, VaultToolError> {
2054 self.record(VaultTool::AttachmentPut)
2055 }
2056 async fn folder_list(&self, _caller: &SessionSlot) -> Result<Value, VaultToolError> {
2057 self.record(VaultTool::FolderList)
2058 }
2059 async fn folder_create(
2060 &self,
2061 _caller: &SessionSlot,
2062 _name: &str,
2063 ) -> Result<Value, VaultToolError> {
2064 self.record(VaultTool::FolderCreate)
2065 }
2066 async fn folder_update(
2067 &self,
2068 _caller: &SessionSlot,
2069 _id: &str,
2070 _name: &str,
2071 ) -> Result<Value, VaultToolError> {
2072 self.record(VaultTool::FolderUpdate)
2073 }
2074 async fn folder_delete(
2075 &self,
2076 _caller: &SessionSlot,
2077 _id: &str,
2078 ) -> Result<Value, VaultToolError> {
2079 self.record(VaultTool::FolderDelete)
2080 }
2081}
2082
2083#[cfg(test)]
2084mod tests {
2085 use super::*;
2086 use crate::config::VaultConfig;
2087 use crate::protocol::Command;
2088 use crate::vault::{test_install_unlocked, test_session};
2089 use forge_primitives::vault::{VaultAskAnswer, VaultAskDecision, VaultReleased};
2090
2091 fn audit_text(dir: &tempfile::TempDir) -> String {
2092 std::fs::read_to_string(
2093 dir.path()
2094 .join("vault")
2095 .join(format!("audit-{}.jsonl", forge_sdk::config_dir_store_key(dir.path()))),
2096 )
2097 .unwrap_or_default()
2098 }
2099
2100 async fn read_test_request(socket: &mut tokio::net::TcpStream) -> String {
2101 use tokio::io::AsyncReadExt;
2102 let mut buf = Vec::new();
2103 let mut chunk = [0u8; 8192];
2104 loop {
2105 let read = socket.read(&mut chunk).await.unwrap_or(0);
2106 if read == 0 {
2107 break;
2108 }
2109 buf.extend_from_slice(&chunk[..read]);
2110 if let Some(end) = buf.windows(4).position(|window| window == b"\r\n\r\n") {
2111 let headers = String::from_utf8_lossy(&buf[..end]).to_lowercase();
2112 let length: usize = headers
2113 .lines()
2114 .find_map(|line| line.strip_prefix("content-length:"))
2115 .and_then(|value| value.trim().parse().ok())
2116 .unwrap_or(0);
2117 if buf.len() >= end + 4 + length {
2118 break;
2119 }
2120 }
2121 }
2122 String::from_utf8_lossy(&buf).into_owned()
2123 }
2124
2125 async fn serve_paused_cipher(
2128 name: String,
2129 ) -> (std::net::SocketAddr, tokio::sync::oneshot::Receiver<()>, tokio::sync::oneshot::Sender<()>)
2130 {
2131 use tokio::io::AsyncWriteExt;
2132 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
2133 let address = listener.local_addr().expect("addr");
2134 let (seen_tx, seen_rx) = tokio::sync::oneshot::channel();
2135 let (release_tx, release_rx) = tokio::sync::oneshot::channel();
2136 tokio::spawn(async move {
2137 let (mut socket, _) = listener.accept().await.expect("accept");
2138 let request = read_test_request(&mut socket).await;
2139 assert!(
2140 request.split_whitespace().nth(1) == Some("/api/ciphers/item-1"),
2141 "unexpected request: {}",
2142 request.lines().next().unwrap_or_default(),
2143 );
2144 let _ = seen_tx.send(());
2145 let _ = release_rx.await;
2146 let body = serde_json::json!({
2147 "id": "item-1",
2148 "type": 1,
2149 "name": name,
2150 "login": {},
2151 })
2152 .to_string();
2153 let response = format!(
2154 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
2155 body.len()
2156 );
2157 let _ = socket.write_all(response.as_bytes()).await;
2158 });
2159 (address, seen_rx, release_tx)
2160 }
2161
2162 enum Reply {
2163 Json(String),
2164 Bytes(Vec<u8>),
2165 Status { code: u16, body: String },
2166 }
2167
2168 struct Step {
2169 path: &'static str,
2170 reply: Reply,
2171 pause: Option<(tokio::sync::oneshot::Sender<()>, tokio::sync::oneshot::Receiver<()>)>,
2172 }
2173
2174 async fn serve_steps(steps: Vec<Step>) -> std::net::SocketAddr {
2177 use tokio::io::AsyncWriteExt;
2178 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
2179 let address = listener.local_addr().expect("addr");
2180 tokio::spawn(async move {
2181 for step in steps {
2182 let (mut socket, _) = listener.accept().await.expect("accept");
2183 let request = read_test_request(&mut socket).await;
2184 assert_eq!(
2185 request.split_whitespace().nth(1),
2186 Some(step.path),
2187 "unexpected request: {}",
2188 request.lines().next().unwrap_or_default(),
2189 );
2190 if let Some((seen, release)) = step.pause {
2191 let _ = seen.send(());
2192 let _ = release.await;
2193 }
2194 let (status, kind, body) = match step.reply {
2195 Reply::Json(body) => {
2196 ("200 OK".to_owned(), "application/json", body.into_bytes())
2197 }
2198 Reply::Bytes(body) => ("200 OK".to_owned(), "application/octet-stream", body),
2199 Reply::Status { code, body } => {
2200 (format!("{code} Error"), "application/json", body.into_bytes())
2201 }
2202 };
2203 let head = format!(
2204 "HTTP/1.1 {status}\r\nContent-Type: {kind}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
2205 body.len()
2206 );
2207 let _ = socket.write_all(head.as_bytes()).await;
2208 let _ = socket.write_all(&body).await;
2209 }
2210 });
2211 address
2212 }
2213
2214 fn unlocked_worker_workspace(
2216 address: std::net::SocketAddr,
2217 user_key: forge_connectors::vault::crypto::SymmetricCryptoKey,
2218 user_key_fingerprint: String,
2219 ) -> (
2220 Arc<Workspace>,
2221 Arc<ProdVaultFacade>,
2222 SessionSlot,
2223 tempfile::TempDir,
2224 tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
2225 Arc<VaultSession>,
2226 ) {
2227 use crate::mcp::workers::types::WorkerEntry;
2228 use forge_primitives::WorkerLiveness;
2229 use forge_primitives::project_key::ProjectKey;
2230
2231 let dir = tempfile::tempdir().expect("tempdir");
2232 let (workspace, rx) = Workspace::testing_stub();
2233 let caller = SessionSlot::worker("TestOrg", "proj", "vault-test");
2234 workspace.insert_live_worker(
2235 &ProjectKey::new("TestOrg/proj"),
2236 WorkerEntry {
2237 label: "vault-test".to_owned(),
2238 charter: "test".to_owned(),
2239 slot: caller.clone(),
2240 session_id: None,
2241 status: WorkerLiveness::Running,
2242 spawned_at: std::time::SystemTime::UNIX_EPOCH,
2243 spawned_by: SessionSlot::lead("TestOrg", "proj"),
2244 needs_tag: false,
2245 is_git_repo_at_spawn: false,
2246 cwd: Some(dir.path().to_path_buf()),
2247 diagnostic: None,
2248 kick: None,
2249 },
2250 );
2251 let config = VaultConfig {
2252 url: format!("http://{address}"),
2253 email: "hi@vedhavyas.dev".to_owned(),
2254 idle_minutes: None,
2255 };
2256 let session =
2257 Arc::new(test_session(&config, Some(dir.path()), dir.path()).expect("a session"));
2258 test_install_unlocked(
2259 &session,
2260 Unlocked {
2261 user_key,
2262 private_key: None,
2263 org_keys: std::collections::HashMap::new(),
2264 failed_orgs: Vec::new(),
2265 user_key_fingerprint,
2266 },
2267 );
2268 *workspace.vault.write() = Some(Arc::clone(&session));
2269 let facade = Arc::new(ProdVaultFacade::from_arc(&workspace));
2270 (workspace, facade, caller, dir, rx, session)
2271 }
2272
2273 fn approve(workspace: &Arc<Workspace>, caller: &SessionSlot, id: uuid::Uuid) {
2274 workspace
2275 .dispatch(Command::RespondVaultAsk {
2276 key: caller.clone(),
2277 id,
2278 answer: VaultAskAnswer {
2279 decision: VaultAskDecision::Approve,
2280 master_password: None,
2281 two_factor: None,
2282 released: None,
2283 },
2284 })
2285 .expect("the answer routes");
2286 }
2287
2288 async fn serve_vault(responses: Vec<(&'static str, String)>) -> std::net::SocketAddr {
2291 use tokio::io::AsyncWriteExt;
2292 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
2293 let address = listener.local_addr().expect("addr");
2294 tokio::spawn(async move {
2295 for (path, body) in responses {
2296 let (mut socket, _) = listener.accept().await.expect("accept");
2297 let request = read_test_request(&mut socket).await;
2298 assert!(
2299 request.split_whitespace().nth(1) == Some(path),
2300 "the server expected {path}, got {}",
2301 request.lines().next().unwrap_or_default(),
2302 );
2303 let response = format!(
2304 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2305 body.len()
2306 );
2307 let _ = socket.write_all(response.as_bytes()).await;
2308 }
2309 });
2310 address
2311 }
2312
2313 fn locked_workspace() -> (
2314 Arc<Workspace>,
2315 Arc<ProdVaultFacade>,
2316 SessionSlot,
2317 tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
2318 ) {
2319 let (workspace, rx) = Workspace::testing_stub();
2320 let config = VaultConfig {
2321 url: "http://127.0.0.1:1".to_owned(),
2322 email: "hi@vedhavyas.dev".to_owned(),
2323 idle_minutes: None,
2324 };
2325 let session = Arc::new(test_session(&config, None, Path::new("/tmp")).expect("a session"));
2326 *workspace.vault.write() = Some(session);
2327 let facade = Arc::new(ProdVaultFacade::from_arc(&workspace));
2328 (workspace, facade, SessionSlot::lead("Default", "forge"), rx)
2329 }
2330
2331 async fn parked_draft(
2332 rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
2333 ) -> VaultDraft {
2334 loop {
2335 let frame = tokio::time::timeout(std::time::Duration::from_secs(30), rx.recv())
2338 .await
2339 .expect("the locked call parks a card")
2340 .expect("the seat stream stays open");
2341 if let crate::protocol::SessionUpdate::VaultAskPending { draft, .. } =
2342 std::sync::Arc::unwrap_or_clone(frame.update)
2343 {
2344 return draft;
2345 }
2346 }
2347 }
2348
2349 fn decline(workspace: &Arc<Workspace>, caller: &SessionSlot, id: uuid::Uuid) {
2350 workspace
2351 .dispatch(Command::RespondVaultAsk {
2352 key: caller.clone(),
2353 id,
2354 answer: VaultAskAnswer {
2355 decision: VaultAskDecision::Decline,
2356 master_password: None,
2357 two_factor: None,
2358 released: None,
2359 },
2360 })
2361 .expect("the answer routes");
2362 }
2363
2364 fn locked_worker_workspace() -> (
2365 Arc<Workspace>,
2366 Arc<ProdVaultFacade>,
2367 SessionSlot,
2368 tempfile::TempDir,
2369 tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
2370 ) {
2371 use crate::mcp::workers::types::WorkerEntry;
2372 use forge_primitives::WorkerLiveness;
2373 use forge_primitives::project_key::ProjectKey;
2374
2375 let dir = tempfile::tempdir().expect("tempdir");
2376 let (workspace, rx) = Workspace::testing_stub();
2377 let caller = SessionSlot::worker("TestOrg", "proj", "vault-test");
2378 workspace.insert_live_worker(
2379 &ProjectKey::new("TestOrg/proj"),
2380 WorkerEntry {
2381 label: "vault-test".to_owned(),
2382 charter: "test".to_owned(),
2383 slot: caller.clone(),
2384 session_id: None,
2385 status: WorkerLiveness::Running,
2386 spawned_at: std::time::SystemTime::UNIX_EPOCH,
2387 spawned_by: SessionSlot::lead("TestOrg", "proj"),
2388 needs_tag: false,
2389 is_git_repo_at_spawn: false,
2390 cwd: Some(dir.path().to_path_buf()),
2391 diagnostic: None,
2392 kick: None,
2393 },
2394 );
2395 let config = VaultConfig {
2396 url: "http://127.0.0.1:1".to_owned(),
2397 email: "hi@vedhavyas.dev".to_owned(),
2398 idle_minutes: None,
2399 };
2400 let session =
2401 Arc::new(test_session(&config, Some(dir.path()), dir.path()).expect("a session"));
2402 *workspace.vault.write() = Some(session);
2403 let facade = Arc::new(ProdVaultFacade::from_arc(&workspace));
2404 (workspace, facade, caller, dir, rx)
2405 }
2406
2407 async fn held<T, F>(
2408 workspace: &Arc<Workspace>,
2409 caller: &SessionSlot,
2410 rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
2411 tool: VaultTool,
2412 call: F,
2413 ) where
2414 T: Send + 'static,
2415 F: std::future::Future<Output = Result<T, VaultToolError>> + Send + 'static,
2416 {
2417 let task = tokio::spawn(call);
2418 let draft = parked_draft(rx).await;
2419 assert_eq!(draft.tool, tool, "the card names the tool that parked");
2420 decline(workspace, caller, draft.id);
2421 assert!(
2422 matches!(task.await.expect("the call finishes"), Err(VaultToolError::Rejected)),
2423 "{} must not run",
2424 tool.as_str(),
2425 );
2426 }
2427
2428 #[tokio::test]
2431 async fn every_tool_parks_its_own_card_on_a_locked_session() {
2432 let (workspace, facade, caller, dir, mut rx) = locked_worker_workspace();
2433 std::fs::write(dir.path().join("note.txt"), b"hello").expect("the file");
2434
2435 {
2436 let f = Arc::clone(&facade);
2437 let c = caller.clone();
2438 held(&workspace, &caller, &mut rx, VaultTool::Search, async move {
2439 f.search(&c, "bank", None).await
2440 })
2441 .await;
2442 }
2443 {
2444 let f = Arc::clone(&facade);
2445 let c = caller.clone();
2446 held(&workspace, &caller, &mut rx, VaultTool::Get, async move {
2447 f.get(&c, "item-1", Some("password")).await
2448 })
2449 .await;
2450 }
2451 {
2452 let f = Arc::clone(&facade);
2453 let c = caller.clone();
2454 let input = CreateInput { name: "bank".to_owned(), ..Default::default() };
2455 held(&workspace, &caller, &mut rx, VaultTool::Create, async move {
2456 f.create(&c, &input).await
2457 })
2458 .await;
2459 }
2460 {
2461 let f = Arc::clone(&facade);
2462 let c = caller.clone();
2463 let patch = PatchInput { password: Some("x".to_owned()), ..Default::default() };
2464 held(&workspace, &caller, &mut rx, VaultTool::Update, async move {
2465 f.update(&c, "item-1", &patch).await
2466 })
2467 .await;
2468 }
2469 for (tool, id) in [
2470 (VaultTool::Trash, "item-1"),
2471 (VaultTool::Restore, "item-1"),
2472 (VaultTool::TotpCode, "item-1"),
2473 ] {
2474 let f = Arc::clone(&facade);
2475 let c = caller.clone();
2476 held(&workspace, &caller, &mut rx, tool, async move {
2477 match tool {
2478 VaultTool::Trash => f.trash(&c, id).await,
2479 VaultTool::Restore => f.restore(&c, id).await,
2480 _ => f.totp_code(&c, id).await,
2481 }
2482 })
2483 .await;
2484 }
2485 {
2486 let f = Arc::clone(&facade);
2487 let c = caller.clone();
2488 held(&workspace, &caller, &mut rx, VaultTool::AttachmentGet, async move {
2489 f.attachment_get(&c, "item-1", "att-1", "out.bin").await
2490 })
2491 .await;
2492 }
2493 {
2494 let f = Arc::clone(&facade);
2495 let c = caller.clone();
2496 held(&workspace, &caller, &mut rx, VaultTool::AttachmentPut, async move {
2497 f.attachment_put(&c, "item-1", "note.txt", None, None).await
2498 })
2499 .await;
2500 }
2501 {
2502 let f = Arc::clone(&facade);
2503 let c = caller.clone();
2504 held(&workspace, &caller, &mut rx, VaultTool::FolderList, async move {
2505 f.folder_list(&c).await
2506 })
2507 .await;
2508 }
2509 {
2510 let f = Arc::clone(&facade);
2511 let c = caller.clone();
2512 held(&workspace, &caller, &mut rx, VaultTool::FolderCreate, async move {
2513 f.folder_create(&c, "folder").await
2514 })
2515 .await;
2516 }
2517 {
2518 let f = Arc::clone(&facade);
2519 let c = caller.clone();
2520 held(&workspace, &caller, &mut rx, VaultTool::FolderUpdate, async move {
2521 f.folder_update(&c, "folder-id", "renamed").await
2522 })
2523 .await;
2524 }
2525 {
2526 let f = Arc::clone(&facade);
2527 let c = caller.clone();
2528 held(&workspace, &caller, &mut rx, VaultTool::FolderDelete, async move {
2529 f.folder_delete(&c, "folder-id").await
2530 })
2531 .await;
2532 }
2533
2534 let audit = std::fs::read_to_string(
2535 dir.path()
2536 .join("vault")
2537 .join(format!("audit-{}.jsonl", forge_sdk::config_dir_store_key(dir.path()))),
2538 )
2539 .expect("every decline wrote an audit line");
2540 assert_eq!(audit.lines().count(), 13, "one line per decision");
2541 assert!(
2542 audit.contains("\"field\":\"password\""),
2543 "the get's reveal field reaches the audit through the gate",
2544 );
2545 }
2546
2547 #[test]
2550 fn an_approved_call_audits_its_own_outcome() {
2551 let dir = tempfile::tempdir().expect("tempdir");
2552 let config = VaultConfig {
2553 url: "http://127.0.0.1:1".to_owned(),
2554 email: "hi@vedhavyas.dev".to_owned(),
2555 idle_minutes: None,
2556 };
2557 let session =
2558 Arc::new(test_session(&config, Some(dir.path()), dir.path()).expect("a session"));
2559 let approved = Approved {
2560 workspace: Arc::clone(&Workspace::testing_stub().0),
2561 session,
2562 keys: Arc::new(Unlocked {
2563 user_key: forge_connectors::vault::crypto::make_symmetric_key(),
2564 private_key: None,
2565 org_keys: std::collections::HashMap::new(),
2566 failed_orgs: Vec::new(),
2567 user_key_fingerprint: String::new(),
2568 }),
2569 caller: SessionSlot::lead("Default", "forge"),
2570 tool: VaultTool::Get,
2571 item_id: Some("cipher-id".to_owned()),
2572 item_name: Some("bank".to_owned()),
2573 field: Some("password".to_owned()),
2574 };
2575
2576 let failed: Result<(), VaultToolError> = Err(VaultToolError::Message("boom".to_owned()));
2577 let _ = approved.done(failed);
2578 let _ = approved.done(Ok(()));
2579
2580 let path = dir
2581 .path()
2582 .join("vault")
2583 .join(format!("audit-{}.jsonl", forge_sdk::config_dir_store_key(dir.path())));
2584 let audit = std::fs::read_to_string(path).expect("the outcome lines");
2585 let lines: Vec<serde_json::Value> =
2586 audit.lines().map(|line| serde_json::from_str(line).expect("json")).collect();
2587 assert_eq!(lines.len(), 2);
2588 assert_eq!(lines[0]["tool"], "vault__get");
2589 assert_eq!(lines[0]["item_id"], "cipher-id");
2590 assert_eq!(lines[0]["item_name"], "bank");
2591 assert_eq!(lines[0]["field"], "password");
2592 assert_eq!(lines[0]["decision"], "approve");
2593 assert_eq!(lines[0]["result"], "failed: boom");
2594 assert_eq!(lines[1]["result"], "ok", "a success is recorded after the call runs");
2595 }
2596
2597 #[tokio::test]
2602 async fn a_locked_get_parks_the_unlock_card_before_any_token_call() {
2603 let (workspace, facade, caller, mut rx) = locked_workspace();
2604 let asked = caller.clone();
2605 let task = tokio::spawn(async move { facade.get(&asked, "item-1", None).await });
2606
2607 let draft = parked_draft(&mut rx).await;
2608 assert!(draft.needs_unlock, "the card asks for the master password");
2609 assert_eq!(draft.item_id.as_deref(), Some("item-1"));
2610 assert_eq!(draft.summary, "read metadata for item item-1");
2611
2612 decline(&workspace, &caller, draft.id);
2613 assert!(
2614 matches!(task.await.expect("the call finishes"), Err(VaultToolError::Rejected)),
2615 "a decline runs nothing"
2616 );
2617 }
2618
2619 #[tokio::test]
2622 async fn a_send_back_returns_the_note_and_runs_nothing() {
2623 let (workspace, facade, caller, mut rx) = locked_workspace();
2624 let asked = caller.clone();
2625 let task = tokio::spawn(async move { facade.search(&asked, "bank", None).await });
2626
2627 let draft = parked_draft(&mut rx).await;
2628 workspace
2629 .dispatch(Command::RespondVaultAsk {
2630 key: caller.clone(),
2631 id: draft.id,
2632 answer: VaultAskAnswer {
2633 decision: VaultAskDecision::SendBack { note: "use the bank folder".to_owned() },
2634 master_password: None,
2635 two_factor: None,
2636 released: None,
2637 },
2638 })
2639 .expect("the answer routes");
2640
2641 match task.await.expect("the call finishes") {
2642 Err(VaultToolError::SentBack { note }) => assert_eq!(note, "use the bank folder"),
2643 other => panic!("a send-back must not run the search: {other:?}"),
2644 }
2645 }
2646
2647 #[test]
2650 fn write_private_creates_and_repairs_0600() {
2651 use std::os::unix::fs::PermissionsExt;
2652 let dir = tempfile::tempdir().expect("tempdir");
2653 let path = dir.path().join("note");
2654 write_private(&path, b"first").expect("write");
2655 assert_eq!(
2656 std::fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
2657 0o600,
2658 "a new attachment is private",
2659 );
2660 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod");
2661 write_private(&path, b"second").expect("write");
2662 assert_eq!(
2663 std::fs::metadata(&path).expect("metadata").permissions().mode() & 0o777,
2664 0o600,
2665 "an existing attachment is repaired to private",
2666 );
2667 }
2668
2669 #[test]
2672 fn write_private_refuses_a_final_symlink() {
2673 use std::os::unix::fs::symlink;
2674 let dir = tempfile::tempdir().expect("tempdir");
2675 let target = dir.path().join("target");
2676 std::fs::write(&target, b"x").expect("target");
2677 let link = dir.path().join("link");
2678 symlink(&target, &link).expect("symlink");
2679
2680 assert!(write_private(&link, b"y").is_err(), "the symlink is not followed");
2681 assert_eq!(std::fs::read(&target).expect("target read"), b"x", "the target is untouched");
2682 }
2683
2684 #[test]
2687 fn a_missing_upload_size_fails_closed() {
2688 assert!(check_stored_size(Some(97), 97).is_ok());
2689 assert!(check_stored_size(Some(96), 97).is_err());
2690 assert!(check_stored_size(None, 97).is_err(), "a missing size is not a match");
2691 }
2692
2693 #[test]
2696 fn a_name_patch_keeps_the_unmodelled_fields() {
2697 let unlocked = Unlocked {
2698 user_key: forge_connectors::vault::crypto::make_symmetric_key(),
2699 private_key: None,
2700 org_keys: std::collections::HashMap::new(),
2701 failed_orgs: Vec::new(),
2702 user_key_fingerprint: String::new(),
2703 };
2704 let cipher = serde_json::json!({
2705 "id": "c",
2706 "type": 1,
2707 "name": "old",
2708 "login": { "totp": "seed", "password": "pw" },
2709 "fields": [{ "name": "n", "value": "v" }],
2710 "passwordHistory": [{ "password": "old" }],
2711 "key": null,
2712 });
2713 let patch = PatchInput { name: Some("renamed".to_owned()), ..Default::default() };
2714
2715 let out = apply_patch(&unlocked, "user-1", cipher.clone(), &patch).expect("the patch");
2716 assert_eq!(out["encryptedFor"], "user-1", "a PUT deserializes the same CipherData");
2717
2718 let org = serde_json::json!({
2721 "id": "c",
2722 "type": 1,
2723 "organizationId": "org-1",
2724 "encryptedFor": "org-1",
2725 "name": "old",
2726 "login": {},
2727 });
2728 let move_patch =
2731 PatchInput { folder_id: Some("folder-1".to_owned()), ..Default::default() };
2732 let org_out = apply_patch(&unlocked, "user-1", org, &move_patch).expect("the patch");
2733 assert_eq!(org_out["encryptedFor"], "org-1", "the fetched owner survives");
2734
2735 assert_eq!(out["login"]["totp"], cipher["login"]["totp"]);
2736 assert_eq!(out["login"]["password"], cipher["login"]["password"]);
2737 assert_eq!(out["fields"], cipher["fields"]);
2738 assert_eq!(out["passwordHistory"], cipher["passwordHistory"]);
2739 assert_eq!(out["key"], cipher["key"]);
2740 let name = out["name"].as_str().expect("the name is an EncString");
2741 assert_eq!(unlocked.decrypt_item_str(&out, name).expect("decrypt"), "renamed");
2742 }
2743
2744 #[test]
2747 fn a_new_login_encrypts_its_fields() {
2748 let unlocked = Unlocked {
2749 user_key: forge_connectors::vault::crypto::make_symmetric_key(),
2750 private_key: None,
2751 org_keys: std::collections::HashMap::new(),
2752 failed_orgs: Vec::new(),
2753 user_key_fingerprint: String::new(),
2754 };
2755 let input = CreateInput {
2756 name: "bank".to_owned(),
2757 username: Some("u".to_owned()),
2758 password: Some("p".to_owned()),
2759 ..Default::default()
2760 };
2761
2762 let body = build_new_cipher(&unlocked, "user-1", &input, "login").expect("the body");
2763
2764 assert_eq!(body["type"], 1);
2765 assert_eq!(body["encryptedFor"], "user-1");
2766 let name = body["name"].as_str().expect("the name is an EncString");
2767 assert_eq!(unlocked.decrypt_item_str(&body, name).expect("decrypt"), "bank");
2768 let username = body.pointer("/login/username").and_then(Value::as_str).expect("username");
2769 assert_eq!(unlocked.decrypt_item_str(&body, username).expect("decrypt"), "u");
2770 }
2771
2772 #[test]
2776 fn a_totp_seed_is_stored_and_patched_on_a_login() {
2777 let unlocked = Unlocked {
2778 user_key: forge_connectors::vault::crypto::make_symmetric_key(),
2779 private_key: None,
2780 org_keys: std::collections::HashMap::new(),
2781 failed_orgs: Vec::new(),
2782 user_key_fingerprint: String::new(),
2783 };
2784 let seed = "otpauth://totp/bank?secret=JBSWY3DPEHPK3PXP";
2785 let input = CreateInput {
2786 name: "bank".to_owned(),
2787 totp: Some(seed.to_owned()),
2788 ..Default::default()
2789 };
2790
2791 let body = build_new_cipher(&unlocked, "user-1", &input, "login").expect("the body");
2792 let stored = body.pointer("/login/totp").and_then(Value::as_str).expect("the seed");
2793 assert_eq!(unlocked.decrypt_item_str(&body, stored).expect("decrypt"), seed);
2794
2795 let cipher = serde_json::json!({ "id": "c", "type": 1, "name": "old", "login": {} });
2796 let patch = PatchInput { totp: Some("JBSWY3DPEHPK3PXP".to_owned()), ..Default::default() };
2797 assert_eq!(patch_fields(&patch), vec!["totp"], "a totp-only patch changes a field");
2798 let out = apply_patch(&unlocked, "user-1", cipher, &patch).expect("the patch");
2799 let stored = out.pointer("/login/totp").and_then(Value::as_str).expect("the seed");
2800 assert_eq!(unlocked.decrypt_item_str(&out, stored).expect("decrypt"), "JBSWY3DPEHPK3PXP");
2801 }
2802
2803 #[test]
2807 fn a_totp_seed_that_cannot_produce_a_code_is_refused() {
2808 let unlocked = Unlocked {
2809 user_key: forge_connectors::vault::crypto::make_symmetric_key(),
2810 private_key: None,
2811 org_keys: std::collections::HashMap::new(),
2812 failed_orgs: Vec::new(),
2813 user_key_fingerprint: String::new(),
2814 };
2815
2816 for seed in ["", "not!base32"] {
2817 let input = CreateInput {
2818 name: "bank".to_owned(),
2819 totp: Some(seed.to_owned()),
2820 ..Default::default()
2821 };
2822 let error = build_new_cipher(&unlocked, "user-1", &input, "login")
2823 .map(|_| ())
2824 .expect_err(&format!("the create must refuse '{seed}'"));
2825 assert!(error.to_string().contains("TOTP"), "'{seed}': {error}");
2826
2827 let cipher = serde_json::json!({ "id": "c", "type": 1, "name": "old", "login": {} });
2828 let patch = PatchInput { totp: Some(seed.to_owned()), ..Default::default() };
2829 let error = apply_patch(&unlocked, "user-1", cipher, &patch)
2830 .map(|_| ())
2831 .expect_err(&format!("the patch must refuse '{seed}'"));
2832 assert!(error.to_string().contains("TOTP"), "'{seed}': {error}");
2833 }
2834 }
2835
2836 #[test]
2839 fn a_totp_seed_on_a_note_or_card_is_refused() {
2840 let unlocked = Unlocked {
2841 user_key: forge_connectors::vault::crypto::make_symmetric_key(),
2842 private_key: None,
2843 org_keys: std::collections::HashMap::new(),
2844 failed_orgs: Vec::new(),
2845 user_key_fingerprint: String::new(),
2846 };
2847 let seed = "JBSWY3DPEHPK3PXP";
2848
2849 for kind in ["note", "card"] {
2850 let input = CreateInput {
2851 name: "x".to_owned(),
2852 totp: Some(seed.to_owned()),
2853 ..Default::default()
2854 };
2855 assert!(
2856 build_new_cipher(&unlocked, "user-1", &input, kind).is_err(),
2857 "a {kind} does not take a TOTP seed"
2858 );
2859 }
2860 let card = serde_json::json!({ "id": "c", "type": 3, "name": "old" });
2861 let patch = PatchInput { totp: Some(seed.to_owned()), ..Default::default() };
2862 assert!(
2863 apply_patch(&unlocked, "user-1", card, &patch).is_err(),
2864 "a card does not take a TOTP seed",
2865 );
2866 }
2867
2868 #[test]
2871 fn the_attachment_paths_must_stay_inside_the_session_tree() {
2872 let (workspace, _facade, caller, dir, _rx) = locked_worker_workspace();
2873 std::fs::write(dir.path().join("ok.txt"), b"x").expect("the file");
2874 let outside = tempfile::tempdir().expect("outside");
2875 std::fs::write(outside.path().join("secret"), b"x").expect("the outside file");
2876 std::os::unix::fs::symlink(outside.path(), dir.path().join("link")).expect("symlink");
2877
2878 assert!(resolve_source(&workspace, &caller, "ok.txt").is_ok());
2879 assert!(resolve_destination(&workspace, &caller, "out.txt").is_ok());
2880
2881 for escape in ["../escape.txt", "/etc/hosts", "link/secret"] {
2882 assert!(
2883 resolve_source(&workspace, &caller, escape).is_err(),
2884 "the source {escape} escapes the tree",
2885 );
2886 }
2887 for escape in ["../escape.txt", "/tmp/escape.txt", "link/out.txt"] {
2888 assert!(
2889 resolve_destination(&workspace, &caller, escape).is_err(),
2890 "the destination {escape} escapes the tree",
2891 );
2892 }
2893 assert!(!outside.path().join("out.txt").exists(), "nothing was written outside");
2894 }
2895
2896 #[test]
2899 fn a_replace_card_names_the_file_it_removes() {
2900 assert_eq!(
2901 replacement_label(Some("att-1"), Some("old.txt".to_owned())).as_deref(),
2902 Some("old.txt"),
2903 );
2904 assert_eq!(replacement_label(Some("att-1"), None).as_deref(), Some("att-1"));
2905 assert_eq!(replacement_label(None, Some("old.txt".to_owned())), None);
2906 }
2907
2908 #[test]
2911 fn an_attachment_source_over_the_cap_is_refused() {
2912 let dir = tempfile::tempdir().expect("tempdir");
2913 let path = dir.path().join("file");
2914 std::fs::write(&path, b"five!").expect("the file");
2915
2916 let error = read_attachment(&path, 4).expect_err("the cap refuses the file");
2917 assert!(error.to_string().contains("over the 4 byte cap"), "{error}");
2918 assert_eq!(read_attachment(&path, 5).expect("the exact size passes"), b"five!");
2919 }
2920
2921 #[tokio::test]
2924 async fn a_locked_attachment_get_card_names_its_destination() {
2925 let (workspace, facade, caller, _dir, mut rx) = locked_worker_workspace();
2926 let asked = caller.clone();
2927 let task = tokio::spawn(async move {
2928 facade.attachment_get(&asked, "item-1", "att-1", "out.bin").await
2929 });
2930
2931 let draft = parked_draft(&mut rx).await;
2932 assert!(draft.needs_unlock);
2933 assert!(draft.item_name.is_none(), "a locked card cannot name the item");
2934 assert!(
2935 draft.summary.contains("out.bin"),
2936 "the card names the destination: {}",
2937 draft.summary,
2938 );
2939
2940 decline(&workspace, &caller, draft.id);
2941 assert!(matches!(task.await.expect("the call finishes"), Err(VaultToolError::Rejected)));
2942 }
2943
2944 #[tokio::test]
2947 async fn an_approval_does_not_run_after_a_config_reload() {
2948 let (workspace, facade, caller, dir, mut rx) = locked_worker_workspace();
2949 let asked = caller.clone();
2950 let task = tokio::spawn(async move { facade.search(&asked, "bank", None).await });
2951 let draft = parked_draft(&mut rx).await;
2952
2953 let next = VaultConfig {
2954 url: "http://127.0.0.1:1".to_owned(),
2955 email: "other@example.com".to_owned(),
2956 idle_minutes: None,
2957 };
2958 *workspace.vault.write() = Some(Arc::new(
2959 test_session(&next, Some(dir.path()), dir.path()).expect("the new session"),
2960 ));
2961 workspace
2962 .dispatch(Command::RespondVaultAsk {
2963 key: caller.clone(),
2964 id: draft.id,
2965 answer: VaultAskAnswer {
2966 decision: VaultAskDecision::Approve,
2967 master_password: Some("old password".to_owned()),
2968 two_factor: None,
2969 released: None,
2970 },
2971 })
2972 .expect("the answer routes");
2973
2974 let result = task.await.expect("the call finishes");
2975 assert!(
2976 matches!(&result, Err(VaultToolError::Message(message)) if message.contains("configuration changed")),
2977 "{result:?}",
2978 );
2979 let audit = audit_text(&dir);
2980 assert!(
2981 audit.contains("failed: the vault configuration changed"),
2982 "the mismatch is audited: {audit}",
2983 );
2984 }
2985
2986 #[tokio::test]
2989 async fn a_locked_approval_without_a_password_is_audited() {
2990 let (workspace, facade, caller, dir, mut rx) = locked_worker_workspace();
2991 let asked = caller.clone();
2992 let task = tokio::spawn(async move { facade.search(&asked, "bank", None).await });
2993 let draft = parked_draft(&mut rx).await;
2994
2995 workspace
2996 .dispatch(Command::RespondVaultAsk {
2997 key: caller.clone(),
2998 id: draft.id,
2999 answer: VaultAskAnswer {
3000 decision: VaultAskDecision::Approve,
3001 master_password: None,
3002 two_factor: None,
3003 released: None,
3004 },
3005 })
3006 .expect("the answer routes");
3007
3008 assert!(matches!(task.await.expect("the call finishes"), Err(VaultToolError::Locked)));
3009 let audit = audit_text(&dir);
3010 assert!(
3011 audit.contains("failed: a locked session was approved without the master password"),
3012 "{audit}",
3013 );
3014 }
3015
3016 #[tokio::test]
3019 async fn an_unlock_rebases_the_other_parked_card_end_to_end() {
3020 use forge_connectors::vault::crypto::{derive_master_key, encrypt_str};
3021
3022 let pre = forge_connectors::vault::models::Prelogin {
3023 kdf: 0,
3024 kdf_iterations: 100_000,
3025 kdf_memory: None,
3026 kdf_parallelism: None,
3027 };
3028 let master_key =
3029 derive_master_key("open sesame", "hi@vedhavyas.dev", &pre).expect("the master key");
3030 let (user_key, wrapped_user_key) = master_key.make_user_key().expect("the user key");
3031 let cipher = serde_json::json!({
3032 "id": "item-1",
3033 "type": 1,
3034 "name": encrypt_str("bank", &user_key.0).expect("encrypting the name"),
3035 "login": {},
3036 });
3037 let prelogin = serde_json::json!({
3038 "kdf": 0,
3039 "kdfIterations": 100_000,
3040 })
3041 .to_string();
3042 let token = serde_json::json!({
3043 "access_token": "access",
3044 "refresh_token": "refresh",
3045 "Key": wrapped_user_key.to_string(),
3046 })
3047 .to_string();
3048 let sync = r#"{"profile":{},"folders":[],"ciphers":[]}"#.to_owned();
3049 let address = serve_vault(vec![
3050 ("/identity/accounts/prelogin", prelogin),
3051 ("/identity/connect/token", token),
3052 ("/api/sync", sync),
3053 ("/api/ciphers/item-1", cipher.to_string()),
3054 ("/api/ciphers/item-1", cipher.to_string()),
3055 ])
3056 .await;
3057 let (workspace, mut rx) = Workspace::testing_stub();
3058 let config = VaultConfig {
3059 url: format!("http://{address}"),
3060 email: "hi@vedhavyas.dev".to_owned(),
3061 idle_minutes: None,
3062 };
3063 let session = Arc::new(test_session(&config, None, Path::new("/tmp")).expect("session"));
3064 *workspace.vault.write() = Some(session);
3065 let facade = Arc::new(ProdVaultFacade::from_arc(&workspace));
3066 let caller = SessionSlot::lead("Default", "forge");
3067
3068 let first_facade = Arc::clone(&facade);
3069 let first_caller = caller.clone();
3070 let first =
3071 tokio::spawn(async move { first_facade.get(&first_caller, "item-1", None).await });
3072 let first_draft = parked_draft(&mut rx).await;
3073 assert!(first_draft.needs_unlock, "the first card asks for the password");
3074
3075 let second_facade = Arc::clone(&facade);
3076 let second_caller = caller.clone();
3077 let second =
3078 tokio::spawn(async move { second_facade.get(&second_caller, "item-1", None).await });
3079 let second_draft = parked_draft(&mut rx).await;
3080 assert!(second_draft.needs_unlock, "the second card parks while locked too");
3081
3082 workspace
3083 .dispatch(Command::RespondVaultAsk {
3084 key: caller.clone(),
3085 id: first_draft.id,
3086 answer: VaultAskAnswer {
3087 decision: VaultAskDecision::Approve,
3088 master_password: Some("open sesame".to_owned()),
3089 two_factor: None,
3090 released: None,
3091 },
3092 })
3093 .expect("the answer routes");
3094
3095 let mut restamped = None;
3096 while restamped.is_none() {
3097 let frame = tokio::time::timeout(std::time::Duration::from_secs(30), rx.recv())
3098 .await
3099 .expect("the unlock pushes a restamp")
3100 .expect("the seat stream stays open");
3101 if let crate::protocol::SessionUpdate::VaultAskPending { draft, .. } =
3102 std::sync::Arc::unwrap_or_clone(frame.update)
3103 && draft.id == second_draft.id
3104 {
3105 restamped = Some(draft);
3106 }
3107 }
3108 let restamped = restamped.expect("the second card was restamped");
3109 assert!(!restamped.needs_unlock, "the unlock restamps the other card");
3110
3111 let decoded = first.await.expect("the first call finishes").expect("the first read");
3112 assert_eq!(decoded["name"], "bank");
3113
3114 workspace
3115 .dispatch(Command::RespondVaultAsk {
3116 key: caller.clone(),
3117 id: second_draft.id,
3118 answer: VaultAskAnswer {
3119 decision: VaultAskDecision::Approve,
3120 master_password: None,
3121 two_factor: None,
3122 released: None,
3123 },
3124 })
3125 .expect("the answer routes");
3126 let decoded = second.await.expect("the second call finishes").expect("the second read");
3127 assert_eq!(decoded["name"], "bank");
3128 }
3129
3130 #[tokio::test]
3134 async fn an_unlock_from_released_material_reaches_the_same_session() {
3135 use forge_connectors::vault::crypto::{derive_master_key, encrypt_str};
3136
3137 let pre = forge_connectors::vault::models::Prelogin {
3138 kdf: 0,
3139 kdf_iterations: 100_000,
3140 kdf_memory: None,
3141 kdf_parallelism: None,
3142 };
3143 let master_key =
3144 derive_master_key("open sesame", "hi@vedhavyas.dev", &pre).expect("the master key");
3145 let (user_key, wrapped_user_key) = master_key.make_user_key().expect("the user key");
3146 let cipher = serde_json::json!({
3147 "id": "item-1",
3148 "type": 1,
3149 "name": encrypt_str("bank", &user_key.0).expect("encrypting the name"),
3150 "login": {},
3151 });
3152 let token = serde_json::json!({
3155 "access_token": "access",
3156 "refresh_token": "rotated",
3157 "Key": wrapped_user_key.to_string(),
3158 })
3159 .to_string();
3160 let sync = r#"{"profile":{},"folders":[],"ciphers":[]}"#.to_owned();
3161 let address = serve_vault(vec![
3162 ("/identity/connect/token", token),
3163 ("/api/sync", sync),
3164 ("/api/ciphers/item-1", cipher.to_string()),
3165 ])
3166 .await;
3167 let (workspace, mut rx) = Workspace::testing_stub();
3168 let config = VaultConfig {
3169 url: format!("http://{address}"),
3170 email: "hi@vedhavyas.dev".to_owned(),
3171 idle_minutes: None,
3172 };
3173 let session = Arc::new(test_session(&config, None, Path::new("/tmp")).expect("session"));
3174 *workspace.vault.write() = Some(Arc::clone(&session));
3175 let facade = Arc::new(ProdVaultFacade::from_arc(&workspace));
3176 let caller = SessionSlot::lead("Default", "forge");
3177
3178 let reader = Arc::clone(&facade);
3179 let reader_caller = caller.clone();
3180 let call = tokio::spawn(async move { reader.get(&reader_caller, "item-1", None).await });
3181 let draft = parked_draft(&mut rx).await;
3182 assert!(draft.needs_unlock, "the locked session asks for an unlock");
3183
3184 workspace
3185 .dispatch(Command::RespondVaultAsk {
3186 key: caller.clone(),
3187 id: draft.id,
3188 answer: VaultAskAnswer {
3189 decision: VaultAskDecision::Approve,
3190 master_password: None,
3191 two_factor: None,
3192 released: Some(VaultReleased {
3193 refresh_token: "refresh".to_owned(),
3194 user_key: user_key.0.to_base64().to_string(),
3195 }),
3196 },
3197 })
3198 .expect("the answer routes");
3199
3200 let decoded = call.await.expect("the call finishes").expect("the item is read");
3201 assert_eq!(decoded["name"], "bank");
3202 assert!(!session.is_locked(), "the session unlocked from the released material");
3203 }
3204
3205 #[tokio::test]
3208 async fn a_refused_release_leaves_the_session_locked() {
3209 let address = serve_vault(vec![("/identity/connect/token", "{}".to_owned())]).await;
3211 let (workspace, mut rx) = Workspace::testing_stub();
3212 let config = VaultConfig {
3213 url: format!("http://{address}"),
3214 email: "hi@vedhavyas.dev".to_owned(),
3215 idle_minutes: None,
3216 };
3217 let session = Arc::new(test_session(&config, None, Path::new("/tmp")).expect("session"));
3218 *workspace.vault.write() = Some(Arc::clone(&session));
3219 let facade = Arc::new(ProdVaultFacade::from_arc(&workspace));
3220 let caller = SessionSlot::lead("Default", "forge");
3221
3222 let reader = Arc::clone(&facade);
3223 let reader_caller = caller.clone();
3224 let call = tokio::spawn(async move { reader.get(&reader_caller, "item-1", None).await });
3225 let draft = parked_draft(&mut rx).await;
3226
3227 workspace
3228 .dispatch(Command::RespondVaultAsk {
3229 key: caller.clone(),
3230 id: draft.id,
3231 answer: VaultAskAnswer {
3232 decision: VaultAskDecision::Approve,
3233 master_password: None,
3234 two_factor: None,
3235 released: Some(VaultReleased {
3236 refresh_token: "spent".to_owned(),
3237 user_key: "not-a-key".to_owned(),
3238 }),
3239 },
3240 })
3241 .expect("the answer routes");
3242
3243 assert!(call.await.expect("the call finishes").is_err(), "the call is refused");
3244 assert!(session.is_locked(), "a refused release leaves the session locked");
3245 }
3246
3247 #[test]
3249 fn the_vault_inputs_redact_their_secret_values() {
3250 let field = CustomFieldInput {
3251 name: "pin".to_owned(),
3252 value: "1234-secret".to_owned(),
3253 kind: None,
3254 };
3255 let create = CreateInput {
3256 name: "bank".to_owned(),
3257 password: Some("password-secret".to_owned()),
3258 number: Some("4111111111111111".to_owned()),
3259 code: Some("999".to_owned()),
3260 totp: Some("totp-create-seed".to_owned()),
3261 custom_fields: Some(vec![field.clone()]),
3262 ..Default::default()
3263 };
3264 let patch = PatchInput {
3265 password: Some("patch-secret".to_owned()),
3266 totp: Some("totp-patch-seed".to_owned()),
3267 ..Default::default()
3268 };
3269
3270 for text in [format!("{field:?}"), format!("{create:?}"), format!("{patch:?}")] {
3271 for secret in [
3272 "1234-secret",
3273 "password-secret",
3274 "4111111111111111",
3275 "patch-secret",
3276 "totp-create-seed",
3277 "totp-patch-seed",
3278 ] {
3279 assert!(!text.contains(secret), "a secret reached Debug: {text}");
3280 }
3281 }
3282 }
3283
3284 #[test]
3287 fn an_attachment_name_without_its_org_key_is_unreadable() {
3288 use forge_connectors::vault::crypto::make_symmetric_key;
3289
3290 let unlocked = Unlocked {
3291 user_key: make_symmetric_key(),
3292 private_key: None,
3293 org_keys: std::collections::HashMap::new(),
3294 failed_orgs: vec!["org-1".to_owned()],
3295 user_key_fingerprint: String::new(),
3296 };
3297 let cipher = serde_json::json!({
3298 "organizationId": "org-1",
3299 "attachments": [{ "id": "att-1", "fileName": "2.x|y|z", "size": 7 }],
3300 });
3301
3302 let (name, size) = attachment_named(&unlocked, &cipher, "att-1").expect("the fallback");
3303
3304 assert_eq!(name, "(unreadable)");
3305 assert_eq!(size, 7);
3306 }
3307
3308 #[test]
3311 fn write_private_refuses_a_fifo() {
3312 use std::os::unix::fs::OpenOptionsExt;
3313
3314 let dir = tempfile::tempdir().expect("tempdir");
3315 let fifo = dir.path().join("fifo");
3316 let created = std::process::Command::new("mkfifo")
3317 .arg(fifo.as_os_str())
3318 .status()
3319 .expect("mkfifo runs");
3320 assert!(created.success(), "mkfifo failed");
3321 let _reader = std::fs::OpenOptions::new()
3322 .read(true)
3323 .custom_flags(libc::O_NONBLOCK)
3324 .open(&fifo)
3325 .expect("the FIFO reader");
3326
3327 let error = write_private(&fifo, b"x").expect_err("the FIFO is refused");
3328
3329 assert!(error.to_string().contains("not a regular file"), "{error}");
3330 }
3331
3332 #[test]
3335 fn write_private_refuses_a_directory_whose_fd_path_differs() {
3336 let first = tempfile::tempdir().expect("first");
3337 let second = tempfile::tempdir().expect("second");
3338 let expected = std::fs::canonicalize(first.path()).expect("canonical first");
3339 let dir = std::fs::File::open(first.path()).expect("the directory handle");
3340
3341 verify_dir_path(&dir, &expected).expect("the same directory passes");
3342
3343 let other = std::fs::canonicalize(second.path()).expect("canonical second");
3344 let error = verify_dir_path(&dir, &other).expect_err("a different directory is refused");
3345 assert!(error.to_string().contains("changed"), "{error}");
3346 }
3347
3348 #[test]
3351 fn write_private_refuses_a_hard_link() {
3352 let dir = tempfile::tempdir().expect("tempdir");
3353 let target = dir.path().join("target");
3354 std::fs::write(&target, b"keep").expect("target");
3355 let link = dir.path().join("link");
3356 std::fs::hard_link(&target, &link).expect("hard link");
3357
3358 assert!(write_private(&link, b"overwrite").is_err(), "the hard link is refused");
3359 assert_eq!(std::fs::read(&target).expect("target read"), b"keep");
3360 }
3361
3362 #[test]
3365 fn a_swapped_destination_parent_is_refused_at_write_time() {
3366 let (workspace, _facade, caller, dir, _rx) = locked_worker_workspace();
3367 std::fs::create_dir(dir.path().join("sub")).expect("sub");
3368 let expected = resolve_destination(&workspace, &caller, "sub/out.txt").expect("the path");
3369 let outside = tempfile::tempdir().expect("outside");
3370 std::fs::remove_dir(dir.path().join("sub")).expect("remove sub");
3371 std::os::unix::fs::symlink(outside.path(), dir.path().join("sub")).expect("symlink");
3372
3373 let error = recheck_destination(&workspace, &caller, "sub/out.txt", &expected)
3374 .expect_err("the swapped parent is refused");
3375
3376 assert!(error.to_string().contains("outside"), "{error}");
3377 assert!(!outside.path().join("out.txt").exists());
3378 }
3379
3380 #[test]
3383 fn an_attachment_that_grows_past_the_cap_is_refused_after_the_read() {
3384 use std::io::Write;
3385
3386 let dir = tempfile::tempdir().expect("tempdir");
3387 let fifo = dir.path().join("fifo");
3388 let created = std::process::Command::new("mkfifo")
3389 .arg(fifo.as_os_str())
3390 .status()
3391 .expect("mkfifo runs");
3392 assert!(created.success(), "mkfifo failed");
3393 let writer_path = fifo.clone();
3394 let writer = std::thread::spawn(move || {
3395 let mut file =
3396 std::fs::OpenOptions::new().write(true).open(&writer_path).expect("fifo write");
3397 file.write_all(&[b'x'; 8]).expect("write");
3398 });
3399
3400 let error = read_attachment(&fifo, 4).expect_err("the post-read cap refuses it");
3401 writer.join().expect("writer");
3402
3403 assert!(error.to_string().contains("over the 4 byte cap"), "{error}");
3404 }
3405
3406 #[tokio::test]
3409 async fn a_reload_during_the_pre_gate_read_fails_the_call() {
3410 use forge_connectors::vault::crypto::{encrypt_str, make_symmetric_key};
3411
3412 let user_key = make_symmetric_key();
3413 let name = encrypt_str("bank", &user_key).expect("encrypt the name");
3414 let (address, seen, release) = serve_paused_cipher(name).await;
3415 let dir = tempfile::tempdir().expect("tempdir");
3416 let config = VaultConfig {
3417 url: format!("http://{address}"),
3418 email: "hi@vedhavyas.dev".to_owned(),
3419 idle_minutes: None,
3420 };
3421 let old =
3422 Arc::new(test_session(&config, Some(dir.path()), dir.path()).expect("the old session"));
3423 test_install_unlocked(
3424 &old,
3425 Unlocked {
3426 user_key,
3427 private_key: None,
3428 org_keys: std::collections::HashMap::new(),
3429 failed_orgs: Vec::new(),
3430 user_key_fingerprint: String::new(),
3431 },
3432 );
3433 let (workspace, mut rx) = Workspace::testing_stub();
3434 *workspace.vault.write() = Some(Arc::clone(&old));
3435 let facade = Arc::new(ProdVaultFacade::from_arc(&workspace));
3436 let caller = SessionSlot::lead("Default", "forge");
3437
3438 let asked = caller.clone();
3439 let call = {
3440 let facade = Arc::clone(&facade);
3441 tokio::spawn(async move { facade.get(&asked, "item-1", None).await })
3442 };
3443 seen.await.expect("the pre-gate read reached the server");
3444
3445 let next =
3446 Arc::new(test_session(&config, Some(dir.path()), dir.path()).expect("the new session"));
3447 test_install_unlocked(
3448 &next,
3449 Unlocked {
3450 user_key: make_symmetric_key(),
3451 private_key: None,
3452 org_keys: std::collections::HashMap::new(),
3453 failed_orgs: Vec::new(),
3454 user_key_fingerprint: String::new(),
3455 },
3456 );
3457 *workspace.vault.write() = Some(Arc::clone(&next));
3458 release.send(()).expect("the server responds");
3459
3460 let draft = parked_draft(&mut rx).await;
3461 workspace
3462 .dispatch(Command::RespondVaultAsk {
3463 key: caller.clone(),
3464 id: draft.id,
3465 answer: VaultAskAnswer {
3466 decision: VaultAskDecision::Approve,
3467 master_password: None,
3468 two_factor: None,
3469 released: None,
3470 },
3471 })
3472 .expect("the answer routes");
3473
3474 let result = call.await.expect("the call finishes");
3475 assert!(
3476 matches!(&result, Err(VaultToolError::Message(message)) if message.contains("configuration changed")),
3477 "{result:?}",
3478 );
3479 assert!(audit_text(&dir).contains("failed: the vault configuration changed"));
3480 }
3481
3482 #[tokio::test]
3485 async fn a_reload_during_the_pre_gate_sync_fails_the_search() {
3486 use forge_connectors::vault::crypto::make_symmetric_key;
3487
3488 let (seen, seen_rx) = tokio::sync::oneshot::channel();
3489 let (release, release_rx) = tokio::sync::oneshot::channel();
3490 let address = serve_steps(vec![Step {
3491 path: "/api/sync",
3492 reply: Reply::Json(r#"{"profile":{},"folders":[],"ciphers":[]}"#.to_owned()),
3493 pause: Some((seen, release_rx)),
3494 }])
3495 .await;
3496 let (workspace, facade, caller, dir, mut rx, old) =
3497 unlocked_worker_workspace(address, make_symmetric_key(), String::new());
3498 let asked = caller.clone();
3499 let call = {
3500 let facade = Arc::clone(&facade);
3501 tokio::spawn(async move { facade.search(&asked, "bank", None).await })
3502 };
3503 seen_rx.await.expect("the pre-gate sync reached the server");
3504 let user_key = old.keys().expect("keys").user_key.clone();
3505 let next = Arc::new(
3506 test_session(
3507 &VaultConfig {
3508 url: format!("http://{address}"),
3509 email: "hi@vedhavyas.dev".to_owned(),
3510 idle_minutes: None,
3511 },
3512 Some(dir.path()),
3513 dir.path(),
3514 )
3515 .expect("the new session"),
3516 );
3517 test_install_unlocked(
3518 &next,
3519 Unlocked {
3520 user_key,
3521 private_key: None,
3522 org_keys: std::collections::HashMap::new(),
3523 failed_orgs: Vec::new(),
3524 user_key_fingerprint: String::new(),
3525 },
3526 );
3527 *workspace.vault.write() = Some(Arc::clone(&next));
3528 release.send(()).expect("the server responds");
3529
3530 let draft = parked_draft(&mut rx).await;
3531 approve(&workspace, &caller, draft.id);
3532 let result = call.await.expect("the call finishes");
3533 assert!(
3534 matches!(&result, Err(VaultToolError::Message(message)) if message.contains("configuration changed")),
3535 "{result:?}",
3536 );
3537 }
3538
3539 #[tokio::test]
3541 async fn a_reload_during_the_pre_gate_cipher_read_fails_attachment_get() {
3542 use forge_connectors::vault::crypto::{encrypt_str, make_symmetric_key};
3543
3544 let user_key = make_symmetric_key();
3545 let name = encrypt_str("bank", &user_key).expect("the name");
3546 let file = encrypt_str("file", &user_key).expect("the file name");
3547 let cipher = serde_json::json!({
3548 "id": "item-1",
3549 "type": 1,
3550 "name": name,
3551 "login": {},
3552 "attachments": [{ "id": "att-1", "fileName": file, "size": 4 }],
3553 })
3554 .to_string();
3555 let (seen, seen_rx) = tokio::sync::oneshot::channel();
3556 let (release, release_rx) = tokio::sync::oneshot::channel();
3557 let address = serve_steps(vec![Step {
3558 path: "/api/ciphers/item-1",
3559 reply: Reply::Json(cipher),
3560 pause: Some((seen, release_rx)),
3561 }])
3562 .await;
3563 let (workspace, facade, caller, dir, mut rx, old) =
3564 unlocked_worker_workspace(address, user_key.clone(), String::new());
3565 let asked = caller.clone();
3566 let call = {
3567 let facade = Arc::clone(&facade);
3568 tokio::spawn(async move {
3569 facade.attachment_get(&asked, "item-1", "att-1", "out.bin").await
3570 })
3571 };
3572 seen_rx.await.expect("the pre-gate read reached the server");
3573 let next = Arc::new(
3574 test_session(
3575 &VaultConfig {
3576 url: format!("http://{address}"),
3577 email: "hi@vedhavyas.dev".to_owned(),
3578 idle_minutes: None,
3579 },
3580 Some(dir.path()),
3581 dir.path(),
3582 )
3583 .expect("the new session"),
3584 );
3585 test_install_unlocked(
3586 &next,
3587 Unlocked {
3588 user_key: old.keys().expect("keys").user_key.clone(),
3589 private_key: None,
3590 org_keys: std::collections::HashMap::new(),
3591 failed_orgs: Vec::new(),
3592 user_key_fingerprint: String::new(),
3593 },
3594 );
3595 *workspace.vault.write() = Some(Arc::clone(&next));
3596 release.send(()).expect("the server responds");
3597
3598 let draft = parked_draft(&mut rx).await;
3599 approve(&workspace, &caller, draft.id);
3600 let result = call.await.expect("the call finishes");
3601 assert!(
3602 matches!(&result, Err(VaultToolError::Message(message)) if message.contains("configuration changed")),
3603 "{result:?}",
3604 );
3605 }
3606
3607 #[tokio::test]
3609 async fn a_reload_during_the_pre_gate_replace_read_fails_attachment_put() {
3610 use forge_connectors::vault::crypto::{encrypt_str, make_symmetric_key};
3611
3612 let user_key = make_symmetric_key();
3613 let name = encrypt_str("bank", &user_key).expect("the name");
3614 let file = encrypt_str("old", &user_key).expect("the file name");
3615 let cipher = serde_json::json!({
3616 "id": "item-1",
3617 "type": 1,
3618 "name": name,
3619 "login": {},
3620 "attachments": [{ "id": "att-1", "fileName": file, "size": 4 }],
3621 })
3622 .to_string();
3623 let (seen, seen_rx) = tokio::sync::oneshot::channel();
3624 let (release, release_rx) = tokio::sync::oneshot::channel();
3625 let address = serve_steps(vec![
3626 Step {
3627 path: "/api/ciphers/item-1",
3628 reply: Reply::Json(cipher.clone()),
3629 pause: Some((seen, release_rx)),
3630 },
3631 Step { path: "/api/ciphers/item-1", reply: Reply::Json(cipher), pause: None },
3632 ])
3633 .await;
3634 let (workspace, facade, caller, dir, mut rx, old) =
3635 unlocked_worker_workspace(address, user_key.clone(), String::new());
3636 std::fs::write(dir.path().join("upload.txt"), b"content").expect("the upload file");
3637 let asked = caller.clone();
3638 let call = {
3639 let facade = Arc::clone(&facade);
3640 tokio::spawn(async move {
3641 facade.attachment_put(&asked, "item-1", "upload.txt", None, Some("att-1")).await
3642 })
3643 };
3644 seen_rx.await.expect("the pre-gate read reached the server");
3645 let next = Arc::new(
3646 test_session(
3647 &VaultConfig {
3648 url: format!("http://{address}"),
3649 email: "hi@vedhavyas.dev".to_owned(),
3650 idle_minutes: None,
3651 },
3652 Some(dir.path()),
3653 dir.path(),
3654 )
3655 .expect("the new session"),
3656 );
3657 test_install_unlocked(
3658 &next,
3659 Unlocked {
3660 user_key: old.keys().expect("keys").user_key.clone(),
3661 private_key: None,
3662 org_keys: std::collections::HashMap::new(),
3663 failed_orgs: Vec::new(),
3664 user_key_fingerprint: String::new(),
3665 },
3666 );
3667 *workspace.vault.write() = Some(Arc::clone(&next));
3668 release.send(()).expect("the server responds");
3669
3670 let draft = parked_draft(&mut rx).await;
3671 approve(&workspace, &caller, draft.id);
3672 let result = call.await.expect("the call finishes");
3673 assert!(
3674 matches!(&result, Err(VaultToolError::Message(message)) if message.contains("configuration changed")),
3675 "{result:?}",
3676 );
3677 }
3678
3679 #[tokio::test]
3681 async fn a_reload_during_the_pre_gate_folder_read_fails_folder_delete() {
3682 use forge_connectors::vault::crypto::{encrypt_str, make_symmetric_key};
3683
3684 let user_key = make_symmetric_key();
3685 let name = encrypt_str("bank", &user_key).expect("the folder name");
3686 let folders = serde_json::json!({
3687 "data": [{ "id": "folder-1", "name": name }],
3688 "object": "list",
3689 "continuationToken": null,
3690 })
3691 .to_string();
3692 let (seen, seen_rx) = tokio::sync::oneshot::channel();
3693 let (release, release_rx) = tokio::sync::oneshot::channel();
3694 let address = serve_steps(vec![Step {
3695 path: "/api/folders",
3696 reply: Reply::Json(folders),
3697 pause: Some((seen, release_rx)),
3698 }])
3699 .await;
3700 let (workspace, facade, caller, dir, mut rx, old) =
3701 unlocked_worker_workspace(address, user_key, String::new());
3702 let asked = caller.clone();
3703 let call = {
3704 let facade = Arc::clone(&facade);
3705 tokio::spawn(async move { facade.folder_delete(&asked, "folder-1").await })
3706 };
3707 seen_rx.await.expect("the pre-gate read reached the server");
3708 let next = Arc::new(
3709 test_session(
3710 &VaultConfig {
3711 url: format!("http://{address}"),
3712 email: "hi@vedhavyas.dev".to_owned(),
3713 idle_minutes: None,
3714 },
3715 Some(dir.path()),
3716 dir.path(),
3717 )
3718 .expect("the new session"),
3719 );
3720 test_install_unlocked(
3721 &next,
3722 Unlocked {
3723 user_key: old.keys().expect("keys").user_key.clone(),
3724 private_key: None,
3725 org_keys: std::collections::HashMap::new(),
3726 failed_orgs: Vec::new(),
3727 user_key_fingerprint: String::new(),
3728 },
3729 );
3730 *workspace.vault.write() = Some(Arc::clone(&next));
3731 release.send(()).expect("the server responds");
3732
3733 let draft = parked_draft(&mut rx).await;
3734 approve(&workspace, &caller, draft.id);
3735 let result = call.await.expect("the call finishes");
3736 assert!(
3737 matches!(&result, Err(VaultToolError::Message(message)) if message.contains("configuration changed")),
3738 "{result:?}",
3739 );
3740 }
3741
3742 #[tokio::test]
3745 async fn a_swap_during_the_download_is_refused_at_the_write() {
3746 use forge_connectors::vault::crypto::{
3747 SymmetricCryptoKey, encrypt_str, make_symmetric_key,
3748 };
3749
3750 let user_key: SymmetricCryptoKey = make_symmetric_key();
3751 let preparer = Unlocked {
3752 user_key: user_key.clone(),
3753 private_key: None,
3754 org_keys: std::collections::HashMap::new(),
3755 failed_orgs: Vec::new(),
3756 user_key_fingerprint: String::new(),
3757 };
3758 let base = serde_json::json!({ "id": "item-1", "type": 1, "login": {} });
3759 let (wrapped, encrypted_name, blob) =
3760 preparer.prepare_attachment(&base, "file", b"contents").expect("the attachment");
3761 let name = encrypt_str("bank", &user_key).expect("the name");
3762 let cipher = serde_json::json!({
3763 "id": "item-1",
3764 "type": 1,
3765 "name": name,
3766 "login": {},
3767 "attachments": [{
3768 "id": "att-1",
3769 "fileName": encrypted_name.clone(),
3770 "size": blob.len(),
3771 }],
3772 })
3773 .to_string();
3774 let meta = serde_json::json!({
3775 "id": "att-1",
3776 "fileName": encrypted_name,
3777 "size": blob.len(),
3778 "url": "/dl",
3779 "key": wrapped,
3780 })
3781 .to_string();
3782 let (seen, seen_rx) = tokio::sync::oneshot::channel();
3783 let (release, release_rx) = tokio::sync::oneshot::channel();
3784 let address = serve_steps(vec![
3785 Step { path: "/api/ciphers/item-1", reply: Reply::Json(cipher), pause: None },
3786 Step {
3787 path: "/api/ciphers/item-1/attachment/att-1",
3788 reply: Reply::Json(meta),
3789 pause: Some((seen, release_rx)),
3790 },
3791 Step { path: "/dl", reply: Reply::Bytes(blob), pause: None },
3792 ])
3793 .await;
3794 let (workspace, facade, caller, dir, mut rx, _session) =
3795 unlocked_worker_workspace(address, user_key, String::new());
3796 std::fs::create_dir(dir.path().join("sub")).expect("sub");
3797 let outside = tempfile::tempdir().expect("outside");
3798 let asked = caller.clone();
3799 let call = {
3800 let facade = Arc::clone(&facade);
3801 tokio::spawn(async move {
3802 facade.attachment_get(&asked, "item-1", "att-1", "sub/out.bin").await
3803 })
3804 };
3805 let draft = parked_draft(&mut rx).await;
3806 approve(&workspace, &caller, draft.id);
3807 seen_rx.await.expect("the metadata read reached the server");
3808 std::fs::remove_dir(dir.path().join("sub")).expect("remove sub");
3809 std::os::unix::fs::symlink(outside.path(), dir.path().join("sub")).expect("symlink");
3810 release.send(()).expect("the server responds");
3811
3812 let result = call.await.expect("the call finishes");
3813 assert!(result.is_err(), "the swapped destination is refused: {result:?}");
3814 assert!(!outside.path().join("out.bin").exists(), "nothing was written outside");
3815 }
3816
3817 #[tokio::test]
3820 async fn a_rotated_user_key_locks_the_session_on_a_write() {
3821 use forge_connectors::vault::crypto::{make_symmetric_key, sha256_hex};
3822
3823 let sync = serde_json::json!({ "profile": { "key": "wrapped-key-two" } }).to_string();
3824 let address =
3825 serve_steps(vec![Step { path: "/api/sync", reply: Reply::Json(sync), pause: None }])
3826 .await;
3827 let (_workspace, _facade, caller, _dir, _rx, session) = unlocked_worker_workspace(
3828 address,
3829 make_symmetric_key(),
3830 sha256_hex(b"wrapped-key-one"),
3831 );
3832
3833 let error = session.ensure_keys_current(&caller, None).await.expect_err("rotation");
3834
3835 assert!(matches!(error, VaultError::KeyRotated), "{error}");
3836 assert!(session.is_locked(), "the rotated keys are dropped");
3837 }
3838
3839 #[tokio::test]
3841 async fn an_unchanged_user_key_passes_the_write_check() {
3842 use forge_connectors::vault::crypto::{make_symmetric_key, sha256_hex};
3843
3844 let sync = serde_json::json!({ "profile": { "key": "wrapped-key-one" } }).to_string();
3845 let address =
3846 serve_steps(vec![Step { path: "/api/sync", reply: Reply::Json(sync), pause: None }])
3847 .await;
3848 let (_workspace, _facade, caller, _dir, _rx, session) = unlocked_worker_workspace(
3849 address,
3850 make_symmetric_key(),
3851 sha256_hex(b"wrapped-key-one"),
3852 );
3853
3854 session.ensure_keys_current(&caller, None).await.expect("the key is unchanged");
3855
3856 assert!(!session.is_locked());
3857 }
3858
3859 fn rotated_worker_workspace(
3861 address: std::net::SocketAddr,
3862 user_key: forge_connectors::vault::crypto::SymmetricCryptoKey,
3863 ) -> (
3864 Arc<Workspace>,
3865 Arc<ProdVaultFacade>,
3866 SessionSlot,
3867 tempfile::TempDir,
3868 tokio::sync::mpsc::UnboundedReceiver<crate::SeatUpdate>,
3869 Arc<VaultSession>,
3870 ) {
3871 use forge_connectors::vault::crypto::sha256_hex;
3872 unlocked_worker_workspace(address, user_key, sha256_hex(b"unlocked-key"))
3873 }
3874
3875 fn synced_steps(extra: Vec<Step>) -> Vec<Step> {
3877 let sync =
3878 serde_json::json!({ "profile": { "key": "unlocked-key", "id": "user-1" } }).to_string();
3879 let mut steps = vec![Step { path: "/api/sync", reply: Reply::Json(sync), pause: None }];
3880 steps.extend(extra);
3881 steps
3882 }
3883
3884 fn rotated_steps(extra: Vec<Step>) -> Vec<Step> {
3886 let sync = serde_json::json!({ "profile": { "key": "sync-key" } }).to_string();
3887 let mut steps = vec![Step { path: "/api/sync", reply: Reply::Json(sync), pause: None }];
3888 steps.extend(extra);
3889 steps
3890 }
3891
3892 fn rotated_steps_after(first: Vec<Step>) -> Vec<Step> {
3894 let mut steps = first;
3895 steps.extend(rotated_steps(Vec::new()));
3896 steps
3897 }
3898
3899 async fn serve_write_capture(
3902 sync: String,
3903 path: &'static str,
3904 ) -> (std::net::SocketAddr, tokio::sync::oneshot::Receiver<String>) {
3905 serve_capture(vec![("/api/sync", sync), (path, r#"{"id":"new-item"}"#.to_owned())]).await
3906 }
3907
3908 async fn serve_capture(
3911 steps: Vec<(&'static str, String)>,
3912 ) -> (std::net::SocketAddr, tokio::sync::oneshot::Receiver<String>) {
3913 use tokio::io::AsyncWriteExt;
3914 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
3915 let address = listener.local_addr().expect("addr");
3916 let (body_tx, body_rx) = tokio::sync::oneshot::channel();
3917 tokio::spawn(async move {
3918 let mut body_tx = Some(body_tx);
3919 let last = steps.len().saturating_sub(1);
3920 for (index, (expected, reply)) in steps.into_iter().enumerate() {
3921 let (mut socket, _) = listener.accept().await.expect("accept");
3922 let request = read_test_request(&mut socket).await;
3923 assert_eq!(
3924 request.split_whitespace().nth(1),
3925 Some(expected),
3926 "unexpected request: {}",
3927 request.lines().next().unwrap_or_default(),
3928 );
3929 if index == last {
3930 let body = request.split("\r\n\r\n").nth(1).unwrap_or_default().to_owned();
3931 if let Some(body_tx) = body_tx.take() {
3932 let _ = body_tx.send(body);
3933 }
3934 }
3935 let response = format!(
3936 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}",
3937 reply.len()
3938 );
3939 let _ = socket.write_all(response.as_bytes()).await;
3940 }
3941 });
3942 (address, body_rx)
3943 }
3944
3945 #[tokio::test]
3947 async fn a_sync_without_the_user_key_locks_the_session() {
3948 use forge_connectors::vault::crypto::{make_symmetric_key, sha256_hex};
3949
3950 let sync = serde_json::json!({ "profile": {} }).to_string();
3951 let address =
3952 serve_steps(vec![Step { path: "/api/sync", reply: Reply::Json(sync), pause: None }])
3953 .await;
3954 let (_workspace, _facade, caller, _dir, _rx, session) =
3955 unlocked_worker_workspace(address, make_symmetric_key(), sha256_hex(b"unlocked-key"));
3956
3957 let error = session.ensure_keys_current(&caller, None).await.expect_err("no key");
3958
3959 assert!(matches!(error, VaultError::KeyUnverifiable), "{error}");
3960 assert!(session.is_locked(), "an unverifiable key locks the session");
3961 }
3962
3963 #[tokio::test]
3966 async fn a_transient_sync_error_does_not_restamp_parked_cards() {
3967 use forge_connectors::vault::crypto::make_symmetric_key;
3968
3969 let address = serve_steps(vec![Step {
3970 path: "/api/sync",
3971 reply: Reply::Status { code: 500, body: "{}".to_owned() },
3972 pause: None,
3973 }])
3974 .await;
3975 let (workspace, facade, caller, _dir, mut rx, session) =
3976 unlocked_worker_workspace(address, make_symmetric_key(), String::new());
3977 let (other, _held, _) = workspace.register_vault_ask(
3978 &caller,
3979 VaultDraft::new(VaultTool::Search, "another call"),
3980 false,
3981 );
3982 while rx.try_recv().is_ok() {}
3983
3984 let asked = caller.clone();
3985 let call = {
3986 let facade = Arc::clone(&facade);
3987 tokio::spawn(async move {
3988 facade
3989 .create(&asked, &CreateInput { name: "bank".to_owned(), ..Default::default() })
3990 .await
3991 })
3992 };
3993 let draft = parked_draft(&mut rx).await;
3994 approve(&workspace, &caller, draft.id);
3995 let result = call.await.expect("the call finishes");
3996
3997 assert!(
3998 matches!(result, Err(VaultToolError::Vault(VaultError::Api { status: 500, .. }))),
3999 "{result:?}",
4000 );
4001 assert!(!session.is_locked(), "a transient error does not lock");
4002 let mut restamped = Vec::new();
4003 while let Ok(frame) = rx.try_recv() {
4004 if let crate::protocol::SessionUpdate::VaultAskPending { draft, .. } =
4005 std::sync::Arc::unwrap_or_clone(frame.update)
4006 {
4007 restamped.push(draft);
4008 }
4009 }
4010 assert!(
4011 !restamped.iter().any(|draft| draft.id == other),
4012 "the other card is not restamped: {restamped:?}",
4013 );
4014 }
4015
4016 #[tokio::test]
4018 async fn a_rotated_key_refuses_create() {
4019 use forge_connectors::vault::crypto::make_symmetric_key;
4020
4021 let address = serve_steps(rotated_steps(Vec::new())).await;
4022 let (workspace, facade, caller, _dir, mut rx, session) =
4023 rotated_worker_workspace(address, make_symmetric_key());
4024 let asked = caller.clone();
4025 let call = {
4026 let facade = Arc::clone(&facade);
4027 tokio::spawn(async move {
4028 facade
4029 .create(&asked, &CreateInput { name: "bank".to_owned(), ..Default::default() })
4030 .await
4031 })
4032 };
4033 let draft = parked_draft(&mut rx).await;
4034 approve(&workspace, &caller, draft.id);
4035 let result = call.await.expect("the call finishes");
4036 assert!(matches!(result, Err(VaultToolError::Vault(VaultError::KeyRotated))), "{result:?}");
4037 assert!(session.is_locked());
4038 }
4039
4040 #[tokio::test]
4041 async fn a_rotated_key_refuses_folder_list() {
4042 use forge_connectors::vault::crypto::make_symmetric_key;
4043
4044 let address = serve_steps(rotated_steps(Vec::new())).await;
4045 let (workspace, facade, caller, _dir, mut rx, session) =
4046 rotated_worker_workspace(address, make_symmetric_key());
4047 let asked = caller.clone();
4048 let call = {
4049 let facade = Arc::clone(&facade);
4050 tokio::spawn(async move { facade.folder_list(&asked).await })
4051 };
4052 let draft = parked_draft(&mut rx).await;
4053 approve(&workspace, &caller, draft.id);
4054 let result = call.await.expect("the call finishes");
4055 assert!(matches!(result, Err(VaultToolError::Vault(VaultError::KeyRotated))), "{result:?}");
4056 assert!(session.is_locked());
4057 }
4058
4059 #[tokio::test]
4060 async fn a_rotated_key_refuses_folder_create() {
4061 use forge_connectors::vault::crypto::make_symmetric_key;
4062
4063 let address = serve_steps(rotated_steps(Vec::new())).await;
4064 let (workspace, facade, caller, _dir, mut rx, session) =
4065 rotated_worker_workspace(address, make_symmetric_key());
4066 let asked = caller.clone();
4067 let call = {
4068 let facade = Arc::clone(&facade);
4069 tokio::spawn(async move { facade.folder_create(&asked, "bank").await })
4070 };
4071 let draft = parked_draft(&mut rx).await;
4072 approve(&workspace, &caller, draft.id);
4073 let result = call.await.expect("the call finishes");
4074 assert!(matches!(result, Err(VaultToolError::Vault(VaultError::KeyRotated))), "{result:?}");
4075 assert!(session.is_locked());
4076 }
4077
4078 #[tokio::test]
4079 async fn a_rotated_key_refuses_folder_update() {
4080 use forge_connectors::vault::crypto::make_symmetric_key;
4081
4082 let address = serve_steps(rotated_steps(Vec::new())).await;
4083 let (workspace, facade, caller, _dir, mut rx, session) =
4084 rotated_worker_workspace(address, make_symmetric_key());
4085 let asked = caller.clone();
4086 let call = {
4087 let facade = Arc::clone(&facade);
4088 tokio::spawn(async move { facade.folder_update(&asked, "folder-1", "renamed").await })
4089 };
4090 let draft = parked_draft(&mut rx).await;
4091 approve(&workspace, &caller, draft.id);
4092 let result = call.await.expect("the call finishes");
4093 assert!(matches!(result, Err(VaultToolError::Vault(VaultError::KeyRotated))), "{result:?}");
4094 assert!(session.is_locked());
4095 }
4096
4097 #[tokio::test]
4098 async fn a_rotated_key_refuses_update() {
4099 use forge_connectors::vault::crypto::{encrypt_str, make_symmetric_key};
4100
4101 let user_key = make_symmetric_key();
4102 let name = encrypt_str("bank", &user_key).expect("the name");
4103 let cipher = serde_json::json!({
4104 "id": "item-1",
4105 "type": 1,
4106 "name": name,
4107 "login": {},
4108 })
4109 .to_string();
4110 let address = serve_steps(rotated_steps_after(vec![Step {
4111 path: "/api/ciphers/item-1",
4112 reply: Reply::Json(cipher),
4113 pause: None,
4114 }]))
4115 .await;
4116 let (workspace, facade, caller, _dir, mut rx, session) =
4117 rotated_worker_workspace(address, user_key);
4118 let asked = caller.clone();
4119 let call = {
4120 let facade = Arc::clone(&facade);
4121 tokio::spawn(async move {
4122 let patch = PatchInput { password: Some("new".to_owned()), ..Default::default() };
4123 facade.update(&asked, "item-1", &patch).await
4124 })
4125 };
4126 let draft = parked_draft(&mut rx).await;
4127 approve(&workspace, &caller, draft.id);
4128 let result = call.await.expect("the call finishes");
4129 assert!(matches!(result, Err(VaultToolError::Vault(VaultError::KeyRotated))), "{result:?}");
4130 assert!(session.is_locked());
4131 }
4132
4133 #[tokio::test]
4134 async fn a_rotated_key_refuses_attachment_put() {
4135 use forge_connectors::vault::crypto::{encrypt_str, make_symmetric_key};
4136
4137 let user_key = make_symmetric_key();
4138 let name = encrypt_str("bank", &user_key).expect("the name");
4139 let cipher = serde_json::json!({
4140 "id": "item-1",
4141 "type": 1,
4142 "name": name,
4143 "login": {},
4144 "attachments": [],
4145 })
4146 .to_string();
4147 let address = serve_steps(rotated_steps_after(vec![Step {
4148 path: "/api/ciphers/item-1",
4149 reply: Reply::Json(cipher),
4150 pause: None,
4151 }]))
4152 .await;
4153 let (workspace, facade, caller, dir, mut rx, session) =
4154 rotated_worker_workspace(address, user_key);
4155 std::fs::write(dir.path().join("upload.txt"), b"content").expect("the upload file");
4156 let asked = caller.clone();
4157 let call = {
4158 let facade = Arc::clone(&facade);
4159 tokio::spawn(async move {
4160 facade.attachment_put(&asked, "item-1", "upload.txt", None, None).await
4161 })
4162 };
4163 let draft = parked_draft(&mut rx).await;
4164 approve(&workspace, &caller, draft.id);
4165 let result = call.await.expect("the call finishes");
4166 assert!(matches!(result, Err(VaultToolError::Vault(VaultError::KeyRotated))), "{result:?}");
4167 assert!(session.is_locked());
4168 }
4169
4170 #[tokio::test]
4173 async fn create_propagates_a_name_decrypt_failure_and_names_the_id() {
4174 use forge_connectors::vault::crypto::{make_symmetric_key, sha256_hex};
4175
4176 let created = serde_json::json!({
4177 "id": "new-item",
4178 "name": "2.bad|bad|bad",
4179 })
4180 .to_string();
4181 let address = serve_steps(synced_steps(vec![Step {
4182 path: "/api/ciphers",
4183 reply: Reply::Json(created),
4184 pause: None,
4185 }]))
4186 .await;
4187 let (workspace, facade, caller, dir, mut rx, session) =
4188 unlocked_worker_workspace(address, make_symmetric_key(), sha256_hex(b"unlocked-key"));
4189 let asked = caller.clone();
4190 let call = {
4191 let facade = Arc::clone(&facade);
4192 tokio::spawn(async move {
4193 facade
4194 .create(&asked, &CreateInput { name: "bank".to_owned(), ..Default::default() })
4195 .await
4196 })
4197 };
4198 let draft = parked_draft(&mut rx).await;
4199 approve(&workspace, &caller, draft.id);
4200 let result = call.await.expect("the call finishes");
4201
4202 assert!(matches!(result, Err(VaultToolError::Vault(VaultError::Crypto(_)))), "{result:?}");
4203 assert!(session.is_locked(), "the crypto failure drops the keys");
4204 let audit = audit_text(&dir);
4205 assert!(
4206 audit.contains("\"item_id\":\"new-item\""),
4207 "the one audit line names the id that landed: {audit}",
4208 );
4209 assert_eq!(audit.lines().count(), 1, "one audit line, not two: {audit}");
4210 }
4211
4212 #[tokio::test]
4215 async fn a_create_sends_the_synced_user_id_in_encrypted_for() {
4216 use forge_connectors::vault::crypto::{make_symmetric_key, sha256_hex};
4217
4218 let sync =
4219 serde_json::json!({ "profile": { "key": "unlocked-key", "id": "user-1" } }).to_string();
4220 let (address, body) = serve_write_capture(sync, "/api/ciphers").await;
4221 let (workspace, facade, caller, _dir, mut rx, _session) =
4222 unlocked_worker_workspace(address, make_symmetric_key(), sha256_hex(b"unlocked-key"));
4223 let asked = caller.clone();
4224 let call = {
4225 let facade = Arc::clone(&facade);
4226 tokio::spawn(async move {
4227 facade
4228 .create(&asked, &CreateInput { name: "bank".to_owned(), ..Default::default() })
4229 .await
4230 })
4231 };
4232 let draft = parked_draft(&mut rx).await;
4233 approve(&workspace, &caller, draft.id);
4234 let created = call.await.expect("the call finishes").expect("the create");
4235
4236 assert_eq!(created["id"], "new-item");
4237 let body = body.await.expect("the create body");
4238 let body: Value = serde_json::from_str(&body).expect("the body is JSON");
4239 assert_eq!(body["encryptedFor"], "user-1", "{body}");
4240 }
4241
4242 #[tokio::test]
4245 async fn an_update_sends_the_synced_user_id_in_encrypted_for() {
4246 use forge_connectors::vault::crypto::{encrypt_str, make_symmetric_key, sha256_hex};
4247
4248 let user_key = make_symmetric_key();
4249 let name = encrypt_str("bank", &user_key).expect("the name");
4250 let cipher =
4251 serde_json::json!({ "id": "item-1", "type": 1, "name": name, "login": {} }).to_string();
4252 let sync =
4253 serde_json::json!({ "profile": { "key": "unlocked-key", "id": "user-1" } }).to_string();
4254 let (address, body) = serve_capture(vec![
4255 ("/api/ciphers/item-1", cipher),
4256 ("/api/sync", sync),
4257 ("/api/ciphers/item-1", "{}".to_owned()),
4258 ])
4259 .await;
4260 let (workspace, facade, caller, _dir, mut rx, _session) =
4261 unlocked_worker_workspace(address, user_key, sha256_hex(b"unlocked-key"));
4262 let asked = caller.clone();
4263 let call = {
4264 let facade = Arc::clone(&facade);
4265 tokio::spawn(async move {
4266 let patch = PatchInput { password: Some("new".to_owned()), ..Default::default() };
4267 facade.update(&asked, "item-1", &patch).await
4268 })
4269 };
4270 let draft = parked_draft(&mut rx).await;
4271 approve(&workspace, &caller, draft.id);
4272 let updated = call.await.expect("the call finishes").expect("the update");
4273
4274 assert_eq!(updated["updated"], true);
4275 let body = body.await.expect("the update body");
4276 let body: Value = serde_json::from_str(&body).expect("the body is JSON");
4277 assert_eq!(body["encryptedFor"], "user-1", "{body}");
4278 }
4279
4280 #[tokio::test]
4284 async fn a_create_without_a_synced_user_id_is_refused() {
4285 use forge_connectors::vault::crypto::{make_symmetric_key, sha256_hex};
4286
4287 for profile in [
4288 serde_json::json!({ "key": "unlocked-key" }),
4289 serde_json::json!({ "key": "unlocked-key", "id": "" }),
4290 ] {
4291 let sync = serde_json::json!({ "profile": profile }).to_string();
4292 let address = serve_steps(vec![Step {
4293 path: "/api/sync",
4294 reply: Reply::Json(sync),
4295 pause: None,
4296 }])
4297 .await;
4298 let (workspace, facade, caller, _dir, mut rx, _session) = unlocked_worker_workspace(
4299 address,
4300 make_symmetric_key(),
4301 sha256_hex(b"unlocked-key"),
4302 );
4303 let asked = caller.clone();
4304 let call = {
4305 let facade = Arc::clone(&facade);
4306 tokio::spawn(async move {
4307 facade
4308 .create(
4309 &asked,
4310 &CreateInput { name: "bank".to_owned(), ..Default::default() },
4311 )
4312 .await
4313 })
4314 };
4315 let draft = parked_draft(&mut rx).await;
4316 approve(&workspace, &caller, draft.id);
4317 let result = call.await.expect("the call finishes");
4318
4319 assert!(
4320 matches!(&result, Err(VaultToolError::Message(message)) if message.contains("account id")),
4321 "{result:?}",
4322 );
4323 }
4324 }
4325
4326 #[tokio::test]
4329 async fn missing_key_falls_back_and_a_crypto_failure_locks_and_restamps() {
4330 use forge_connectors::vault::crypto::make_symmetric_key;
4331
4332 let address = serve_steps(Vec::new()).await;
4333 let (workspace, facade, caller, _dir, mut rx, session) =
4334 unlocked_worker_workspace(address, make_symmetric_key(), String::new());
4335 let (other, _held, _) = workspace.register_vault_ask(
4336 &caller,
4337 VaultDraft::new(VaultTool::Search, "another call"),
4338 false,
4339 );
4340 while rx.try_recv().is_ok() {}
4341
4342 let missing = facade
4343 .fallback_or_lock(
4344 &session,
4345 &caller,
4346 Some("item-1"),
4347 Err(VaultToolError::Vault(VaultError::MissingKey("org-1".to_owned()))),
4348 "fallback".to_owned(),
4349 )
4350 .expect("MissingKey falls back");
4351 assert_eq!(missing, "fallback");
4352 assert!(!session.is_locked(), "a missing org key does not lock the account");
4353
4354 let error = facade
4355 .fallback_or_lock(
4356 &session,
4357 &caller,
4358 Some("item-1"),
4359 Err(VaultToolError::Vault(VaultError::Crypto(anyhow::anyhow!("rotated")))),
4360 "fallback".to_owned(),
4361 )
4362 .expect_err("a crypto failure surfaces");
4363 assert!(matches!(error, VaultToolError::Vault(VaultError::Crypto(_))), "{error}");
4364 assert!(session.is_locked(), "the stale keys are dropped");
4365
4366 let mut restamped = None;
4367 while let Ok(frame) = rx.try_recv() {
4368 if let crate::protocol::SessionUpdate::VaultAskPending { draft, .. } =
4369 std::sync::Arc::unwrap_or_clone(frame.update)
4370 && draft.id == other
4371 {
4372 restamped = Some(draft);
4373 }
4374 }
4375 assert!(
4376 restamped.is_some_and(|draft| draft.needs_unlock),
4377 "the other card asks for the password again",
4378 );
4379 }
4380
4381 #[tokio::test]
4384 async fn a_decryption_failure_locks_and_restamps_the_parked_cards() {
4385 use forge_connectors::vault::crypto::make_symmetric_key;
4386
4387 let dir = tempfile::tempdir().expect("tempdir");
4388 let config = VaultConfig {
4389 url: "http://127.0.0.1:1".to_owned(),
4390 email: "hi@vedhavyas.dev".to_owned(),
4391 idle_minutes: None,
4392 };
4393 let session =
4394 Arc::new(test_session(&config, Some(dir.path()), dir.path()).expect("a session"));
4395 test_install_unlocked(
4396 &session,
4397 Unlocked {
4398 user_key: make_symmetric_key(),
4399 private_key: None,
4400 org_keys: std::collections::HashMap::new(),
4401 failed_orgs: Vec::new(),
4402 user_key_fingerprint: String::new(),
4403 },
4404 );
4405 let (workspace, mut rx) = Workspace::testing_stub();
4406 *workspace.vault.write() = Some(Arc::clone(&session));
4407 let caller = SessionSlot::lead("Default", "forge");
4408 let (other, _held, _) = workspace.register_vault_ask(
4409 &caller,
4410 VaultDraft::new(VaultTool::Search, "another call"),
4411 false,
4412 );
4413 while rx.try_recv().is_ok() {}
4414
4415 let approved = Approved {
4416 workspace: Arc::clone(&workspace),
4417 session: Arc::clone(&session),
4418 keys: session.keys().expect("the installed keys"),
4419 caller: caller.clone(),
4420 tool: VaultTool::Get,
4421 item_id: Some("item-1".to_owned()),
4422 item_name: Some("bank".to_owned()),
4423 field: None,
4424 };
4425 let error = approved
4426 .done::<()>(Err(VaultToolError::Vault(VaultError::Crypto(anyhow::anyhow!(
4427 "keys rotated"
4428 )))))
4429 .expect_err("the crypto failure is returned");
4430
4431 assert!(session.is_locked(), "the stale keys are dropped: {error}");
4432 let mut restamped = None;
4433 while let Ok(frame) = rx.try_recv() {
4434 if let crate::protocol::SessionUpdate::VaultAskPending { draft, .. } =
4435 std::sync::Arc::unwrap_or_clone(frame.update)
4436 && draft.id == other
4437 {
4438 restamped = Some(draft);
4439 }
4440 }
4441 let restamped = restamped.expect("the other card is restamped");
4442 assert!(restamped.needs_unlock, "the other card asks for the password again");
4443 assert!(
4444 audit_text(&dir).contains("failed: vault request failed: crypto error: keys rotated"),
4445 "{}",
4446 audit_text(&dir),
4447 );
4448 }
4449
4450 #[tokio::test]
4452 async fn a_locked_search_parks_the_query() {
4453 let (workspace, facade, caller, mut rx) = locked_workspace();
4454 let asked = caller.clone();
4455 let task = tokio::spawn(async move { facade.search(&asked, "bank", None).await });
4456
4457 let draft = parked_draft(&mut rx).await;
4458 assert!(draft.needs_unlock);
4459 assert!(draft.summary.contains("search for 'bank'"), "{}", draft.summary);
4460
4461 decline(&workspace, &caller, draft.id);
4462 assert!(matches!(task.await.expect("the call finishes"), Err(VaultToolError::Rejected)));
4463 }
4464}