Skip to main content

tf_types/
vault.rs

1//! File-backed passphrase vault. Mirrors
2//! `tools/tf-types-ts/src/core/vault.ts`.
3//!
4//! On-disk layout is schemas/vault-file.schema.json. Wrap key = Argon2id
5//! over the passphrase; each entry is sealed with ChaCha20-Poly1305 using
6//! AAD = JSON([id, purpose, algorithm]).
7
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use argon2::{Algorithm, Argon2, Params, Version};
12use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
13use rand::RngCore;
14use serde::{Deserialize, Serialize};
15use serde_json;
16
17use crate::crypto::{chacha20poly1305_decrypt, chacha20poly1305_encrypt};
18
19#[derive(Debug, thiserror::Error)]
20pub enum VaultError {
21    #[error("vault I/O error: {0}")]
22    Io(String),
23    #[error("vault parse error: {0}")]
24    Parse(String),
25    #[error("unsupported vault version: {0}")]
26    UnsupportedVersion(String),
27    #[error("unsupported vault algorithm: {0}")]
28    UnsupportedAlgorithm(String),
29    #[error("argon2 derivation failed: {0}")]
30    Argon2(String),
31    #[error("vault entry not found: {0}")]
32    EntryNotFound(String),
33    #[error("base64 decode failed: {0}")]
34    Base64(String),
35    #[error("aead decrypt failed")]
36    Aead,
37    #[error("invalid nonce length")]
38    BadNonce,
39}
40
41#[derive(Clone, Debug, Serialize, Deserialize)]
42struct OnDiskEntry {
43    id: String,
44    purpose: String,
45    algorithm: String,
46    nonce: String,
47    ciphertext: String,
48    created_at: String,
49}
50
51#[derive(Clone, Debug, Serialize, Deserialize)]
52struct OnDiskKdf {
53    algorithm: String,
54    salt: String,
55    m_cost: u32,
56    t_cost: u32,
57    p_cost: u32,
58}
59
60#[derive(Clone, Debug, Serialize, Deserialize)]
61struct OnDiskCipher {
62    algorithm: String,
63}
64
65#[derive(Clone, Debug, Serialize, Deserialize)]
66struct OnDiskVault {
67    vault_version: String,
68    kdf: OnDiskKdf,
69    cipher: OnDiskCipher,
70    entries: Vec<OnDiskEntry>,
71}
72
73#[derive(Clone, Debug, PartialEq, Eq)]
74pub struct VaultEntryPlain {
75    pub id: String,
76    pub purpose: String,
77    pub algorithm: String,
78    pub key_bytes: Vec<u8>,
79    pub created_at: String,
80}
81
82#[derive(Clone, Debug)]
83pub struct VaultEntrySummary {
84    pub id: String,
85    pub purpose: String,
86    pub algorithm: String,
87    pub created_at: String,
88}
89
90#[derive(Clone, Debug)]
91pub struct VaultCreateOptions {
92    pub m_cost: u32,
93    pub t_cost: u32,
94    pub p_cost: u32,
95    pub salt: Option<[u8; 16]>,
96}
97
98impl Default for VaultCreateOptions {
99    fn default() -> Self {
100        VaultCreateOptions {
101            m_cost: 19456,
102            t_cost: 2,
103            p_cost: 1,
104            salt: None,
105        }
106    }
107}
108
109pub struct Vault {
110    path: PathBuf,
111    wrap_key: [u8; 32],
112    data: OnDiskVault,
113}
114
115impl Vault {
116    pub fn create_at_path(
117        path: &Path,
118        passphrase: &str,
119        opts: &VaultCreateOptions,
120    ) -> Result<Self, VaultError> {
121        if path.exists() {
122            return Err(VaultError::Io(format!(
123                "vault already exists at {}",
124                path.display()
125            )));
126        }
127        let mut salt = [0u8; 16];
128        match opts.salt {
129            Some(s) => salt = s,
130            None => rand::thread_rng().fill_bytes(&mut salt),
131        }
132        let wrap_key = derive_key(
133            passphrase.as_bytes(),
134            &salt,
135            opts.m_cost,
136            opts.t_cost,
137            opts.p_cost,
138        )?;
139        let data = OnDiskVault {
140            vault_version: "1".to_string(),
141            kdf: OnDiskKdf {
142                algorithm: "argon2id".to_string(),
143                salt: B64.encode(salt),
144                m_cost: opts.m_cost,
145                t_cost: opts.t_cost,
146                p_cost: opts.p_cost,
147            },
148            cipher: OnDiskCipher {
149                algorithm: "chacha20poly1305".to_string(),
150            },
151            entries: Vec::new(),
152        };
153        persist(path, &data)?;
154        Ok(Vault {
155            path: path.to_path_buf(),
156            wrap_key,
157            data,
158        })
159    }
160
161    pub fn open_at_path(path: &Path, passphrase: &str) -> Result<Self, VaultError> {
162        let raw = fs::read_to_string(path).map_err(|e| VaultError::Io(e.to_string()))?;
163        let data: OnDiskVault =
164            serde_json::from_str(&raw).map_err(|e| VaultError::Parse(e.to_string()))?;
165        if data.vault_version != "1" {
166            return Err(VaultError::UnsupportedVersion(data.vault_version));
167        }
168        if data.kdf.algorithm != "argon2id" || data.cipher.algorithm != "chacha20poly1305" {
169            return Err(VaultError::UnsupportedAlgorithm(format!(
170                "kdf={}, cipher={}",
171                data.kdf.algorithm, data.cipher.algorithm
172            )));
173        }
174        let salt_bytes = B64
175            .decode(&data.kdf.salt)
176            .map_err(|e| VaultError::Base64(e.to_string()))?;
177        let mut salt = [0u8; 16];
178        if salt_bytes.len() < 8 {
179            return Err(VaultError::Parse(format!(
180                "salt too short: {} bytes",
181                salt_bytes.len()
182            )));
183        }
184        let copy_len = salt_bytes.len().min(16);
185        salt[..copy_len].copy_from_slice(&salt_bytes[..copy_len]);
186        let wrap_key = derive_key(
187            passphrase.as_bytes(),
188            &salt_bytes,
189            data.kdf.m_cost,
190            data.kdf.t_cost,
191            data.kdf.p_cost,
192        )?;
193        Ok(Vault {
194            path: path.to_path_buf(),
195            wrap_key,
196            data,
197        })
198    }
199
200    pub fn list(&self) -> Vec<VaultEntrySummary> {
201        self.data
202            .entries
203            .iter()
204            .map(|e| VaultEntrySummary {
205                id: e.id.clone(),
206                purpose: e.purpose.clone(),
207                algorithm: e.algorithm.clone(),
208                created_at: e.created_at.clone(),
209            })
210            .collect()
211    }
212
213    pub fn store(&mut self, entry: VaultEntryPlain) -> Result<(), VaultError> {
214        let mut nonce = [0u8; 12];
215        rand::thread_rng().fill_bytes(&mut nonce);
216        let aad = aad_for(&entry.id, &entry.purpose, &entry.algorithm);
217        let ciphertext = chacha20poly1305_encrypt(&self.wrap_key, &nonce, &aad, &entry.key_bytes);
218        let disk_entry = OnDiskEntry {
219            id: entry.id.clone(),
220            purpose: entry.purpose.clone(),
221            algorithm: entry.algorithm.clone(),
222            nonce: B64.encode(nonce),
223            ciphertext: B64.encode(&ciphertext),
224            created_at: entry.created_at.clone(),
225        };
226        if let Some(existing) = self.data.entries.iter_mut().find(|e| e.id == entry.id) {
227            *existing = disk_entry;
228        } else {
229            self.data.entries.push(disk_entry);
230        }
231        persist(&self.path, &self.data)?;
232        Ok(())
233    }
234
235    pub fn read(&self, id: &str) -> Result<VaultEntryPlain, VaultError> {
236        let entry = self
237            .data
238            .entries
239            .iter()
240            .find(|e| e.id == id)
241            .ok_or_else(|| VaultError::EntryNotFound(id.to_string()))?;
242        let nonce_bytes = B64
243            .decode(&entry.nonce)
244            .map_err(|e| VaultError::Base64(e.to_string()))?;
245        if nonce_bytes.len() != 12 {
246            return Err(VaultError::BadNonce);
247        }
248        let mut nonce = [0u8; 12];
249        nonce.copy_from_slice(&nonce_bytes);
250        let ct = B64
251            .decode(&entry.ciphertext)
252            .map_err(|e| VaultError::Base64(e.to_string()))?;
253        let aad = aad_for(&entry.id, &entry.purpose, &entry.algorithm);
254        let plaintext = chacha20poly1305_decrypt(&self.wrap_key, &nonce, &aad, &ct)
255            .map_err(|_| VaultError::Aead)?;
256        Ok(VaultEntryPlain {
257            id: entry.id.clone(),
258            purpose: entry.purpose.clone(),
259            algorithm: entry.algorithm.clone(),
260            key_bytes: plaintext,
261            created_at: entry.created_at.clone(),
262        })
263    }
264
265    pub fn remove(&mut self, id: &str) -> Result<bool, VaultError> {
266        let before = self.data.entries.len();
267        self.data.entries.retain(|e| e.id != id);
268        let changed = self.data.entries.len() != before;
269        if changed {
270            persist(&self.path, &self.data)?;
271        }
272        Ok(changed)
273    }
274}
275
276fn aad_for(id: &str, purpose: &str, algorithm: &str) -> Vec<u8> {
277    // Canonical-JSON AAD so TS and Rust produce byte-identical
278    // associated data even when id/purpose/algorithm contain non-ASCII
279    // (NFC normalization, sorted-by-bytes for any object form).
280    let value = serde_json::Value::Array(vec![
281        serde_json::Value::String(id.to_string()),
282        serde_json::Value::String(purpose.to_string()),
283        serde_json::Value::String(algorithm.to_string()),
284    ]);
285    crate::canonical::canonicalize(&value)
286        .expect("canonicalize aad triple")
287        .into_bytes()
288}
289
290fn derive_key(
291    password: &[u8],
292    salt: &[u8],
293    m_cost: u32,
294    t_cost: u32,
295    p_cost: u32,
296) -> Result<[u8; 32], VaultError> {
297    let params = Params::new(m_cost, t_cost, p_cost, Some(32))
298        .map_err(|e| VaultError::Argon2(e.to_string()))?;
299    let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
300    let mut out = [0u8; 32];
301    argon
302        .hash_password_into(password, salt, &mut out)
303        .map_err(|e| VaultError::Argon2(e.to_string()))?;
304    Ok(out)
305}
306
307fn persist(path: &Path, data: &OnDiskVault) -> Result<(), VaultError> {
308    let text = serde_json::to_string_pretty(data).map_err(|e| VaultError::Parse(e.to_string()))?;
309    let final_text = format!("{}\n", text);
310    use std::io::Write;
311    use std::time::{SystemTime, UNIX_EPOCH};
312    // Write to a temp sibling, fsync, then rename. Crash-safe: a partial
313    // write never replaces the original vault.
314    let nanos = SystemTime::now()
315        .duration_since(UNIX_EPOCH)
316        .map(|d| d.as_nanos())
317        .unwrap_or(0);
318    let tmp = path.with_extension(format!("tmp.{}", nanos));
319    {
320        #[cfg(unix)]
321        let mut file = {
322            use std::os::unix::fs::OpenOptionsExt;
323            std::fs::OpenOptions::new()
324                .write(true)
325                .create(true)
326                .truncate(true)
327                .mode(0o600)
328                .open(&tmp)
329                .map_err(|e| VaultError::Io(e.to_string()))?
330        };
331        #[cfg(not(unix))]
332        let mut file = std::fs::OpenOptions::new()
333            .write(true)
334            .create(true)
335            .truncate(true)
336            .open(&tmp)
337            .map_err(|e| VaultError::Io(e.to_string()))?;
338        file.write_all(final_text.as_bytes())
339            .map_err(|e| VaultError::Io(e.to_string()))?;
340        file.sync_all().map_err(|e| VaultError::Io(e.to_string()))?;
341    }
342    fs::rename(&tmp, path).map_err(|e| VaultError::Io(e.to_string()))?;
343    Ok(())
344}