Skip to main content

forge_primitives/
vault.rs

1//! The vault family's ask shape: the draft one vault call parks for the
2//! reader's approval, the answer a person gives it, and the tools those
3//! calls name.
4//!
5//! **No secret value is a field here.** The draft carries what a card draws:
6//! the tool, the item, the field being revealed or changed, a masked diff, and
7//! an attachment's name and size. Nothing here is a value a person would learn
8//! by reading it. The value itself never leaves the process except through the
9//! tool's own result after an approval.
10//!
11//! The shared session's lock state is not frozen into the draft: the core
12//! fills [`VaultDraft::needs_unlock`] when it builds the card from the live
13//! session, so a second parked call re-bases to an ordinary approve card the
14//! moment another call unlocks the session.
15
16use serde::{Deserialize, Serialize};
17use uuid::Uuid;
18use zeroize::Zeroize;
19
20/// One vault tool a held call can name. The wire spelling is the MCP tool
21/// name, so the card and the agent's request cannot disagree about which
22/// operation is being approved.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum VaultTool {
26    #[serde(rename = "vault__search")]
27    Search,
28    #[serde(rename = "vault__get")]
29    Get,
30    #[serde(rename = "vault__create")]
31    Create,
32    #[serde(rename = "vault__update")]
33    Update,
34    #[serde(rename = "vault__trash")]
35    Trash,
36    #[serde(rename = "vault__restore")]
37    Restore,
38    #[serde(rename = "vault__totp_code")]
39    TotpCode,
40    #[serde(rename = "vault__attachment_get")]
41    AttachmentGet,
42    #[serde(rename = "vault__attachment_put")]
43    AttachmentPut,
44    #[serde(rename = "vault__folder_list")]
45    FolderList,
46    #[serde(rename = "vault__folder_create")]
47    FolderCreate,
48    #[serde(rename = "vault__folder_update")]
49    FolderUpdate,
50    #[serde(rename = "vault__folder_delete")]
51    FolderDelete,
52}
53
54impl VaultTool {
55    /// Every tool the family exposes, in the order the plan lists them.
56    pub const ALL: [Self; 13] = [
57        Self::Search,
58        Self::Get,
59        Self::Create,
60        Self::Update,
61        Self::Trash,
62        Self::Restore,
63        Self::TotpCode,
64        Self::AttachmentGet,
65        Self::AttachmentPut,
66        Self::FolderList,
67        Self::FolderCreate,
68        Self::FolderUpdate,
69        Self::FolderDelete,
70    ];
71
72    /// The MCP tool name this operation answers to.
73    #[must_use]
74    pub const fn as_str(self) -> &'static str {
75        match self {
76            Self::Search => "vault__search",
77            Self::Get => "vault__get",
78            Self::Create => "vault__create",
79            Self::Update => "vault__update",
80            Self::Trash => "vault__trash",
81            Self::Restore => "vault__restore",
82            Self::TotpCode => "vault__totp_code",
83            Self::AttachmentGet => "vault__attachment_get",
84            Self::AttachmentPut => "vault__attachment_put",
85            Self::FolderList => "vault__folder_list",
86            Self::FolderCreate => "vault__folder_create",
87            Self::FolderUpdate => "vault__folder_update",
88            Self::FolderDelete => "vault__folder_delete",
89        }
90    }
91
92    /// The operation a tool name names, or `None` when it is not one of the
93    /// family's thirteen.
94    #[must_use]
95    pub fn parse(name: &str) -> Option<Self> {
96        Self::ALL.into_iter().find(|tool| tool.as_str() == name)
97    }
98}
99
100/// One attachment a held call names, as the card draws it: the file name and
101/// size, and the attachment a replace would remove. The bytes never ride the
102/// draft.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub struct VaultAttachmentDraft {
105    /// The file's name, as the card draws it.
106    pub name: String,
107    /// The file's size in bytes, as the card draws it.
108    pub size: u64,
109    /// The attachment id a replace would remove, when the call replaces one.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub replaces: Option<String>,
112}
113
114/// One vault call held for the reader's approval.
115///
116/// The blocked MCP handler awaits the answer; no operation runs in the vault
117/// unless the answer approved it. Every field here is safe to draw: no
118/// secret value, no key, no TOTP seed, no card number.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub struct VaultDraft {
121    /// The call's own id, which is what an answer is addressed by.
122    pub id: Uuid,
123    /// The tool the call names.
124    pub tool: VaultTool,
125    /// The one-line summary the card draws. Written by the facade; never a
126    /// secret value.
127    pub summary: String,
128    /// The item the call reads or changes, when it names one.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub item_id: Option<String>,
131    /// The item's name, so the card names what it is about without a second
132    /// read.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub item_name: Option<String>,
135    /// The field a reveal names, or the field set a change touches.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub field: Option<String>,
138    /// A diff with every secret value masked, when the call changes fields.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub masked_diff: Option<String>,
141    /// The attachment a call gets or puts, when it names one.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub attachment: Option<VaultAttachmentDraft>,
144    /// Whether the shared session is locked as this card is drawn. The core
145    /// fills this from the live session on every read, never at park time,
146    /// so a card whose sibling unlocked first re-bases to an ordinary
147    /// approve card.
148    #[serde(default)]
149    pub needs_unlock: bool,
150}
151
152impl VaultDraft {
153    /// A draft for `tool` with the summary a card draws and no item, field,
154    /// diff or attachment named yet.
155    #[must_use]
156    pub fn new(tool: VaultTool, summary: impl Into<String>) -> Self {
157        Self {
158            id: Uuid::new_v4(),
159            tool,
160            summary: summary.into(),
161            item_id: None,
162            item_name: None,
163            field: None,
164            masked_diff: None,
165            attachment: None,
166            needs_unlock: false,
167        }
168    }
169
170    /// The draft with the live session's lock state stamped on it.
171    #[must_use]
172    pub fn with_unlock_state(mut self, needs_unlock: bool) -> Self {
173        self.needs_unlock = needs_unlock;
174        self
175    }
176}
177
178/// What the reader decided about a held vault call.
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(tag = "decision", rename_all = "snake_case")]
181pub enum VaultAskDecision {
182    /// Run the call as the card described it.
183    Approve,
184    /// Send the call back with a note; the agent may revise and ask again.
185    SendBack { note: String },
186    /// Run nothing. The blocked call returns a refusal.
187    Decline,
188}
189
190/// The material a client released through its platform authenticator, which
191/// is what a start sends instead of the master password: the refresh token
192/// the enrolment wrapped and the user key itself.
193///
194/// **It is a secret and rides the same local channel the password does.** The
195/// manual `Debug` redacts both fields, and `Drop` wipes them.
196#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
197pub struct VaultReleased {
198    /// The refresh token, which the core redeems for an access token.
199    pub refresh_token: String,
200    /// The account's user key, base64.
201    pub user_key: String,
202}
203
204impl Drop for VaultReleased {
205    fn drop(&mut self) {
206        self.refresh_token.zeroize();
207        self.user_key.zeroize();
208    }
209}
210
211impl std::fmt::Debug for VaultReleased {
212    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213        f.debug_struct("VaultReleased")
214            .field("refresh_token", &"[redacted]")
215            .field("user_key", &"[redacted]")
216            .finish()
217    }
218}
219
220/// The reader's answer to a held vault call, including the unlock material
221/// when the card asked for it.
222///
223/// **The master password rides only this answer.** It never enters the
224/// model's arguments and never reaches the transcript: the dock's masked
225/// field sends it over the local answer channel, and the core hands it to
226/// the unlock path and zeroizes it. The manual `Debug` redacts both it and
227/// the 2FA code.
228#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
229pub struct VaultAskAnswer {
230    /// What the reader decided.
231    ///
232    /// **Flattened, because the decision IS the answer's tag.** The client
233    /// writes `{"decision":"approve", ...}`; without this the internally
234    /// tagged enum nests one level down and no answer decodes.
235    #[serde(flatten)]
236    pub decision: VaultAskDecision,
237    /// The master password, when this card asked for it and the reader
238    /// entered one. Absent on every card the session was already unlocked
239    /// for, and ignored when the session unlocked first.
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub master_password: Option<String>,
242    /// The 2FA code, when the account asks for one.
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub two_factor: Option<String>,
245    /// The material a platform authenticator released, when the card asked
246    /// for the gesture instead of the password. The two are alternatives; a
247    /// card carries one or the other, and a present release is the one tried.
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub released: Option<VaultReleased>,
250}
251
252impl Drop for VaultAskAnswer {
253    fn drop(&mut self) {
254        if let Some(password) = &mut self.master_password {
255            password.zeroize();
256        }
257        if let Some(code) = &mut self.two_factor {
258            code.zeroize();
259        }
260    }
261}
262
263impl std::fmt::Debug for VaultAskAnswer {
264    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
265        f.debug_struct("VaultAskAnswer")
266            .field("decision", &self.decision)
267            .field("master_password", &self.master_password.as_ref().map(|_| "[redacted]"))
268            .field("two_factor", &self.two_factor.as_ref().map(|_| "[redacted]"))
269            .field("released", &self.released)
270            .finish()
271    }
272}
273
274/// What ended a held vault call, which a view that did not answer it has no
275/// other way to learn. The tool result carries the operation's own outcome;
276/// this says only how the card was settled.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum VaultAskEnding {
280    /// The reader approved the call. The tool result reports whether the
281    /// operation itself succeeded.
282    Approved,
283    /// The reader sent the call back with a note; nothing ran.
284    SentBack,
285    /// The reader declined; nothing ran.
286    Declined,
287    /// The asking session went away before the call was settled.
288    Abandoned,
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    /// Every tool's wire name parses back to itself, so the card and the
296    /// agent's request cannot disagree about which operation is approved.
297    #[test]
298    fn every_tool_name_round_trips() {
299        for tool in VaultTool::ALL {
300            assert_eq!(VaultTool::parse(tool.as_str()), Some(tool), "{}", tool.as_str());
301        }
302        assert_eq!(VaultTool::parse("vault__hard_delete"), None);
303    }
304
305    /// The draft's serialised keys are exactly the safe set: adding a secret
306    /// field would fail this test rather than reach a card.
307    #[test]
308    fn a_draft_serialises_only_what_a_card_draws() {
309        let mut draft = VaultDraft::new(VaultTool::Get, "read the password for 'bank'");
310        draft.item_id = Some("cipher-id".to_owned());
311        draft.item_name = Some("bank".to_owned());
312        draft.field = Some("password".to_owned());
313        draft.masked_diff = Some("password changed".to_owned());
314        draft.attachment =
315            Some(VaultAttachmentDraft { name: "notes.txt".to_owned(), size: 97, replaces: None });
316        let value = serde_json::to_value(&draft).expect("a draft serialises");
317        let object = value.as_object().expect("a draft serialises as an object");
318        let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
319        keys.sort_unstable();
320        assert_eq!(
321            keys,
322            [
323                "attachment",
324                "field",
325                "id",
326                "item_id",
327                "item_name",
328                "masked_diff",
329                "needs_unlock",
330                "summary",
331                "tool"
332            ],
333        );
334    }
335
336    /// The serde spelling and `as_str` are one name, not two that happen to
337    /// round-trip through `parse`: the wire name is what the card and the
338    /// audit line carry.
339    #[test]
340    fn every_tool_serde_name_is_its_as_str() {
341        for tool in VaultTool::ALL {
342            let value = serde_json::to_value(tool).expect("a tool encodes");
343            assert_eq!(value.as_str(), Some(tool.as_str()), "{}", tool.as_str());
344        }
345    }
346
347    /// A draft and an answer round-trip through serde, which is what the
348    /// socket does with both.
349    #[test]
350    fn the_ask_shapes_round_trip() {
351        let mut draft = VaultDraft::new(VaultTool::AttachmentPut, "upload 'notes.txt' (97 bytes)");
352        draft.item_id = Some("cipher-id".to_owned());
353        draft.item_name = Some("bank".to_owned());
354        draft.attachment = Some(VaultAttachmentDraft {
355            name: "notes.txt".to_owned(),
356            size: 97,
357            replaces: Some("old-attachment".to_owned()),
358        });
359        let json = serde_json::to_string(&draft).expect("serialise");
360        let back: VaultDraft = serde_json::from_str(&json).expect("deserialise");
361        assert_eq!(draft, back);
362
363        let answer = VaultAskAnswer {
364            decision: VaultAskDecision::SendBack { note: "trim the summary".to_owned() },
365            master_password: Some("never printed".to_owned()),
366            two_factor: Some("123456".to_owned()),
367            released: Some(VaultReleased {
368                refresh_token: "never printed either".to_owned(),
369                user_key: "nor this".to_owned(),
370            }),
371        };
372        let json = serde_json::to_string(&answer).expect("serialise");
373        let back: VaultAskAnswer = serde_json::from_str(&json).expect("deserialise");
374        assert_eq!(answer, back);
375        assert!(!format!("{answer:?}").contains("never printed"));
376        assert!(!format!("{answer:?}").contains("123456"));
377        assert!(!format!("{answer:?}").contains("nor this"), "the released user key is redacted");
378    }
379
380    /// The lock state is stamped per read, so a draft parked while locked
381    /// can be re-based by the core without being rebuilt.
382    #[test]
383    fn the_unlock_state_is_stamped_not_frozen() {
384        let draft = VaultDraft::new(VaultTool::Search, "find 'bank'").with_unlock_state(true);
385        assert!(draft.needs_unlock);
386        let rebased = draft.with_unlock_state(false);
387        assert!(!rebased.needs_unlock);
388    }
389}