1use std::fs;
9use std::path::{Path, PathBuf};
10
11use argon2::{Algorithm, Argon2, Params, Version};
12use crate::encoding::STANDARD as B64;
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 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 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}