1use crate::auth::secret::{SensitiveString, sensitive_string};
7use crate::validation::validate_vault_entry_name;
8use argon2::{Algorithm, Argon2, Params, Version};
9use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
10use chacha20poly1305::aead::{Aead, Payload};
11use chacha20poly1305::{KeyInit, XChaCha20Poly1305, XNonce};
12use chrono::Utc;
13use getrandom::fill as random_fill;
14use serde::{Deserialize, Serialize};
15use std::fmt;
16use std::fs;
17use std::io;
18use std::path::{Path, PathBuf};
19use zeroize::{Zeroize, Zeroizing};
20
21const VAULT_VERSION: u8 = 1;
22const VAULT_METADATA_FILENAME: &str = "metadata.json";
23const VAULT_ENTRIES_DIRNAME: &str = "entries";
24const VAULT_DIRNAME: &str = "vault";
25const RUN_DIRNAME: &str = "run";
26const DATA_KEY_LEN: usize = 32;
27const KDF_SALT_LEN: usize = 16;
28const WRAPPED_KEY_NONCE_LEN: usize = 24;
29const ENTRY_NONCE_LEN: usize = 24;
30const KDF_MEMORY_KIB: u32 = 64 * 1024;
31const KDF_TIME_COST: u32 = 3;
32const KDF_PARALLELISM: u32 = 1;
33const WRAPPED_KEY_AAD: &[u8] = b"color-ssh/vault-metadata/v1";
34const ENTRY_AAD_PREFIX: &[u8] = b"color-ssh/vault-entry/v1:";
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37pub struct VaultMetadata {
39 pub version: u8,
40 pub kdf_salt: String,
41 pub kdf_memory_kib: u32,
42 pub kdf_time_cost: u32,
43 pub kdf_parallelism: u32,
44 pub wrapped_dek_nonce: String,
45 pub wrapped_dek_ciphertext: String,
46 pub created_at: String,
47 pub updated_at: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51pub struct VaultEntry {
53 pub version: u8,
54 pub name: String,
55 pub nonce: String,
56 pub ciphertext: String,
57 pub updated_at: String,
58}
59
60#[derive(Debug, Clone)]
61pub struct VaultPaths {
63 base_dir: PathBuf,
64}
65
66#[derive(Debug)]
67pub enum VaultError {
69 MissingHomeDirectory,
70 InvalidEntryName,
71 VaultAlreadyInitialized,
72 VaultNotInitialized,
73 EntryNotFound,
74 InvalidMasterPassword,
75 InvalidVaultFormat(String),
76 EncryptFailed(String),
77 Io(io::Error),
78}
79
80impl fmt::Display for VaultError {
81 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82 match self {
83 Self::MissingHomeDirectory => write!(f, "could not determine home directory"),
84 Self::InvalidEntryName => write!(f, "invalid pass entry name; use only letters, numbers, '.', '_' or '-'"),
85 Self::VaultAlreadyInitialized => write!(f, "password vault is already initialized"),
86 Self::VaultNotInitialized => write!(f, "password vault is not initialized"),
87 Self::EntryNotFound => write!(f, "password vault entry was not found"),
88 Self::InvalidMasterPassword => write!(f, "invalid master password"),
89 Self::InvalidVaultFormat(message) => write!(f, "invalid vault data: {message}"),
90 Self::EncryptFailed(message) => write!(f, "vault encryption failed: {message}"),
91 Self::Io(err) => write!(f, "{err}"),
92 }
93 }
94}
95
96impl std::error::Error for VaultError {}
97
98impl From<io::Error> for VaultError {
99 fn from(value: io::Error) -> Self {
100 Self::Io(value)
101 }
102}
103
104impl VaultPaths {
105 pub fn resolve_default() -> Result<Self, VaultError> {
107 let Some(home_dir) = dirs::home_dir() else {
108 return Err(VaultError::MissingHomeDirectory);
109 };
110 Ok(Self {
111 base_dir: home_dir.join(".color-ssh"),
112 })
113 }
114
115 #[cfg(test)]
116 pub(crate) fn new(base_dir: PathBuf) -> Self {
117 Self { base_dir }
118 }
119
120 pub fn base_dir(&self) -> &Path {
122 &self.base_dir
123 }
124
125 pub fn vault_dir(&self) -> PathBuf {
127 self.base_dir.join(VAULT_DIRNAME)
128 }
129
130 pub fn metadata_path(&self) -> PathBuf {
132 self.vault_dir().join(VAULT_METADATA_FILENAME)
133 }
134
135 pub fn entries_dir(&self) -> PathBuf {
137 self.vault_dir().join(VAULT_ENTRIES_DIRNAME)
138 }
139
140 pub fn entry_path(&self, name: &str) -> Result<PathBuf, VaultError> {
142 if !validate_vault_entry_name(name) {
143 return Err(VaultError::InvalidEntryName);
144 }
145 Ok(self.entries_dir().join(format!("{name}.json")))
146 }
147
148 pub fn run_dir(&self) -> PathBuf {
150 self.base_dir.join(RUN_DIRNAME)
151 }
152}
153
154#[derive(Debug)]
155pub struct UnlockedVault {
157 paths: VaultPaths,
158 data_key: Zeroizing<[u8; DATA_KEY_LEN]>,
159}
160
161impl UnlockedVault {
162 pub(crate) fn from_data_key(paths: VaultPaths, data_key: [u8; DATA_KEY_LEN]) -> Self {
163 Self {
164 paths,
165 data_key: Zeroizing::new(data_key),
166 }
167 }
168
169 pub fn store_secret(&self, name: &str, secret: &str) -> Result<(), VaultError> {
171 if !validate_vault_entry_name(name) {
172 return Err(VaultError::InvalidEntryName);
173 }
174
175 ensure_vault_layout(&self.paths)?;
176
177 let mut nonce = [0u8; ENTRY_NONCE_LEN];
178 random_fill(&mut nonce).map_err(|err| VaultError::EncryptFailed(format!("secure random generation failed: {err}")))?;
179
180 let cipher =
181 XChaCha20Poly1305::new_from_slice(&self.data_key[..]).map_err(|err| VaultError::EncryptFailed(format!("invalid cipher key material: {err}")))?;
182 let aad = entry_aad(name);
183 let ciphertext = cipher
184 .encrypt(
185 XNonce::from_slice(&nonce),
186 Payload {
187 msg: secret.as_bytes(),
188 aad: aad.as_bytes(),
189 },
190 )
191 .map_err(|_| VaultError::EncryptFailed("failed to encrypt vault entry".to_string()))?;
192
193 let entry = VaultEntry {
194 version: VAULT_VERSION,
195 name: name.to_string(),
196 nonce: BASE64.encode(nonce),
197 ciphertext: BASE64.encode(ciphertext),
198 updated_at: Utc::now().to_rfc3339(),
199 };
200 write_json_atomic(&self.paths.entry_path(name)?, &entry)?;
201 set_restrictive_file_permissions(&self.paths.entry_path(name)?)?;
202 Ok(())
203 }
204
205 pub fn get_secret(&self, name: &str) -> Result<SensitiveString, VaultError> {
207 if !validate_vault_entry_name(name) {
208 return Err(VaultError::InvalidEntryName);
209 }
210
211 let path = self.paths.entry_path(name)?;
212 if !path.is_file() {
213 return Err(VaultError::EntryNotFound);
214 }
215
216 let entry = read_json::<VaultEntry>(&path)?;
217 if entry.version != VAULT_VERSION {
218 return Err(VaultError::InvalidVaultFormat("unsupported entry version".to_string()));
219 }
220 if entry.name != name {
221 return Err(VaultError::InvalidVaultFormat("entry name did not match file name".to_string()));
222 }
223
224 let nonce = decode_fixed::<ENTRY_NONCE_LEN>(&entry.nonce, "entry nonce")?;
225 let ciphertext = decode_bytes(&entry.ciphertext, "entry ciphertext")?;
226 if ciphertext.is_empty() {
227 return Err(VaultError::InvalidVaultFormat("entry ciphertext was empty".to_string()));
228 }
229
230 let cipher =
231 XChaCha20Poly1305::new_from_slice(&self.data_key[..]).map_err(|err| VaultError::EncryptFailed(format!("invalid cipher key material: {err}")))?;
232 let aad = entry_aad(name);
233 let plaintext = cipher
234 .decrypt(
235 XNonce::from_slice(&nonce),
236 Payload {
237 msg: ciphertext.as_slice(),
238 aad: aad.as_bytes(),
239 },
240 )
241 .map_err(|_| VaultError::InvalidMasterPassword)?;
242 match String::from_utf8(plaintext) {
243 Ok(secret) => Ok(sensitive_string(secret)),
244 Err(err) => {
245 let mut invalid_bytes = err.into_bytes();
246 invalid_bytes.zeroize();
247 Err(VaultError::InvalidVaultFormat("entry plaintext was not valid UTF-8".to_string()))
248 }
249 }
250 }
251
252 pub fn remove_entry(&self, name: &str) -> Result<(), VaultError> {
254 let path = self.paths.entry_path(name)?;
255 if !path.exists() {
256 return Err(VaultError::EntryNotFound);
257 }
258 fs::remove_file(path)?;
259 Ok(())
260 }
261
262 pub fn paths(&self) -> &VaultPaths {
264 &self.paths
265 }
266
267 pub(crate) fn data_key_copy(&self) -> [u8; DATA_KEY_LEN] {
268 *self.data_key
269 }
270}
271
272pub fn vault_exists() -> Result<bool, VaultError> {
274 Ok(VaultPaths::resolve_default()?.metadata_path().is_file())
275}
276
277pub fn list_entries() -> Result<Vec<String>, VaultError> {
279 list_entries_with_paths(&VaultPaths::resolve_default()?)
280}
281
282pub fn entry_exists(name: &str) -> Result<bool, VaultError> {
284 entry_exists_with_paths(&VaultPaths::resolve_default()?, name)
285}
286
287pub fn initialize_vault(master_password: &str) -> Result<(), VaultError> {
289 initialize_vault_with_paths(&VaultPaths::resolve_default()?, master_password)
290}
291
292pub fn unlock_with_password(master_password: &str) -> Result<UnlockedVault, VaultError> {
294 unlock_with_password_and_paths(&VaultPaths::resolve_default()?, master_password)
295}
296
297pub fn rotate_master_password(current_password: &str, new_password: &str) -> Result<(), VaultError> {
299 rotate_master_password_with_paths(&VaultPaths::resolve_default()?, current_password, new_password)
300}
301
302pub(crate) fn initialize_vault_with_paths(paths: &VaultPaths, master_password: &str) -> Result<(), VaultError> {
303 if master_password.is_empty() {
304 return Err(VaultError::InvalidMasterPassword);
305 }
306 if paths.metadata_path().exists() {
307 return Err(VaultError::VaultAlreadyInitialized);
308 }
309
310 ensure_vault_layout(paths)?;
311
312 let mut data_key = [0u8; DATA_KEY_LEN];
313 random_fill(&mut data_key).map_err(|err| VaultError::EncryptFailed(format!("secure random generation failed: {err}")))?;
314 let metadata = build_metadata_from_data_key(master_password, &data_key)?;
315 data_key.zeroize();
316 write_json_atomic(&paths.metadata_path(), &metadata)?;
317 set_restrictive_file_permissions(&paths.metadata_path())?;
318 Ok(())
319}
320
321pub(crate) fn unlock_with_password_and_paths(paths: &VaultPaths, master_password: &str) -> Result<UnlockedVault, VaultError> {
322 if master_password.is_empty() {
323 return Err(VaultError::InvalidMasterPassword);
324 }
325 let metadata_path = paths.metadata_path();
326 if !metadata_path.is_file() {
327 return Err(VaultError::VaultNotInitialized);
328 }
329
330 let metadata = read_json::<VaultMetadata>(&metadata_path)?;
331 let data_key = decrypt_wrapped_data_key(master_password, &metadata)?;
332
333 Ok(UnlockedVault {
334 paths: paths.clone(),
335 data_key: Zeroizing::new(data_key),
336 })
337}
338
339pub(crate) fn rotate_master_password_with_paths(paths: &VaultPaths, current_password: &str, new_password: &str) -> Result<(), VaultError> {
340 if new_password.is_empty() {
341 return Err(VaultError::InvalidMasterPassword);
342 }
343 let unlocked = unlock_with_password_and_paths(paths, current_password)?;
344 let metadata_path = paths.metadata_path();
345 let existing = read_json::<VaultMetadata>(&metadata_path)?;
346 let mut updated = build_metadata_from_data_key(new_password, &unlocked.data_key_copy())?;
347 updated.created_at = existing.created_at;
348 updated.updated_at = Utc::now().to_rfc3339();
349 write_json_atomic(&metadata_path, &updated)?;
350 set_restrictive_file_permissions(&metadata_path)?;
351 Ok(())
352}
353
354pub(crate) fn list_entries_with_paths(paths: &VaultPaths) -> Result<Vec<String>, VaultError> {
355 if !paths.metadata_path().is_file() {
356 return Err(VaultError::VaultNotInitialized);
357 }
358
359 let entries_dir = paths.entries_dir();
360 if !entries_dir.exists() {
361 return Ok(Vec::new());
362 }
363 if !entries_dir.is_dir() {
364 return Err(VaultError::InvalidVaultFormat("entries path was not a directory".to_string()));
365 }
366
367 let mut entries = Vec::new();
368 for entry in fs::read_dir(entries_dir)? {
369 let entry = entry?;
370 let path = entry.path();
371 if !path.is_file() || path.extension().and_then(|extension| extension.to_str()) != Some("json") {
372 continue;
373 }
374
375 let Some(name) = path.file_stem().and_then(|stem| stem.to_str()) else {
376 return Err(VaultError::InvalidVaultFormat("entry file name was not valid UTF-8".to_string()));
377 };
378 if !validate_vault_entry_name(name) {
379 return Err(VaultError::InvalidVaultFormat(format!("invalid entry file name: {name}")));
380 }
381 entries.push(name.to_string());
382 }
383
384 entries.sort_unstable();
385 Ok(entries)
386}
387
388pub(crate) fn entry_exists_with_paths(paths: &VaultPaths, name: &str) -> Result<bool, VaultError> {
389 if !validate_vault_entry_name(name) {
390 return Err(VaultError::InvalidEntryName);
391 }
392 if !paths.metadata_path().is_file() {
393 return Err(VaultError::VaultNotInitialized);
394 }
395
396 Ok(paths.entry_path(name)?.is_file())
397}
398
399fn build_metadata_from_data_key(master_password: &str, data_key: &[u8; DATA_KEY_LEN]) -> Result<VaultMetadata, VaultError> {
400 let mut salt = [0u8; KDF_SALT_LEN];
401 random_fill(&mut salt).map_err(|err| VaultError::EncryptFailed(format!("secure random generation failed: {err}")))?;
402 let mut nonce = [0u8; WRAPPED_KEY_NONCE_LEN];
403 random_fill(&mut nonce).map_err(|err| VaultError::EncryptFailed(format!("secure random generation failed: {err}")))?;
404
405 let mut wrapping_key = Zeroizing::new([0u8; DATA_KEY_LEN]);
406 derive_key(master_password.as_bytes(), &salt, &mut wrapping_key)?;
407 let cipher =
408 XChaCha20Poly1305::new_from_slice(&wrapping_key[..]).map_err(|err| VaultError::EncryptFailed(format!("invalid cipher key material: {err}")))?;
409 let ciphertext = cipher
410 .encrypt(
411 XNonce::from_slice(&nonce),
412 Payload {
413 msg: data_key,
414 aad: WRAPPED_KEY_AAD,
415 },
416 )
417 .map_err(|_| VaultError::EncryptFailed("failed to wrap data key".to_string()))?;
418
419 let now = Utc::now().to_rfc3339();
420 Ok(VaultMetadata {
421 version: VAULT_VERSION,
422 kdf_salt: BASE64.encode(salt),
423 kdf_memory_kib: KDF_MEMORY_KIB,
424 kdf_time_cost: KDF_TIME_COST,
425 kdf_parallelism: KDF_PARALLELISM,
426 wrapped_dek_nonce: BASE64.encode(nonce),
427 wrapped_dek_ciphertext: BASE64.encode(ciphertext),
428 created_at: now.clone(),
429 updated_at: now,
430 })
431}
432
433fn decrypt_wrapped_data_key(master_password: &str, metadata: &VaultMetadata) -> Result<[u8; DATA_KEY_LEN], VaultError> {
434 if metadata.version != VAULT_VERSION {
435 return Err(VaultError::InvalidVaultFormat("unsupported vault version".to_string()));
436 }
437 if metadata.kdf_memory_kib == 0 || metadata.kdf_time_cost == 0 || metadata.kdf_parallelism == 0 {
438 return Err(VaultError::InvalidVaultFormat("invalid KDF parameters".to_string()));
439 }
440
441 let salt = decode_fixed::<KDF_SALT_LEN>(&metadata.kdf_salt, "KDF salt")?;
442 let nonce = decode_fixed::<WRAPPED_KEY_NONCE_LEN>(&metadata.wrapped_dek_nonce, "wrapped DEK nonce")?;
443 let ciphertext = decode_bytes(&metadata.wrapped_dek_ciphertext, "wrapped DEK ciphertext")?;
444
445 let params = Params::new(metadata.kdf_memory_kib, metadata.kdf_time_cost, metadata.kdf_parallelism, Some(DATA_KEY_LEN))
446 .map_err(|err| VaultError::InvalidVaultFormat(format!("invalid KDF parameters: {err}")))?;
447 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
448 let mut wrapping_key = Zeroizing::new([0u8; DATA_KEY_LEN]);
449 argon2
450 .hash_password_into(master_password.as_bytes(), &salt, &mut wrapping_key[..])
451 .map_err(|_| VaultError::InvalidMasterPassword)?;
452
453 let cipher =
454 XChaCha20Poly1305::new_from_slice(&wrapping_key[..]).map_err(|err| VaultError::EncryptFailed(format!("invalid cipher key material: {err}")))?;
455 let mut plaintext = cipher
456 .decrypt(
457 XNonce::from_slice(&nonce),
458 Payload {
459 msg: ciphertext.as_slice(),
460 aad: WRAPPED_KEY_AAD,
461 },
462 )
463 .map_err(|_| VaultError::InvalidMasterPassword)?;
464 if plaintext.len() != DATA_KEY_LEN {
465 plaintext.zeroize();
466 return Err(VaultError::InvalidVaultFormat("wrapped DEK plaintext had the wrong length".to_string()));
467 }
468
469 let mut data_key = [0u8; DATA_KEY_LEN];
470 data_key.copy_from_slice(&plaintext);
471 plaintext.zeroize();
472 Ok(data_key)
473}
474
475fn derive_key(passphrase: &[u8], salt: &[u8], key_output: &mut [u8; DATA_KEY_LEN]) -> Result<(), VaultError> {
476 let params = Params::new(KDF_MEMORY_KIB, KDF_TIME_COST, KDF_PARALLELISM, Some(DATA_KEY_LEN))
477 .map_err(|err| VaultError::EncryptFailed(format!("invalid KDF parameters: {err}")))?;
478 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
479 argon2
480 .hash_password_into(passphrase, salt, key_output)
481 .map_err(|err| VaultError::EncryptFailed(format!("failed to derive encryption key: {err}")))
482}
483
484fn ensure_vault_layout(paths: &VaultPaths) -> Result<(), VaultError> {
485 fs::create_dir_all(paths.vault_dir())?;
486 set_restrictive_directory_permissions(&paths.vault_dir())?;
487 fs::create_dir_all(paths.entries_dir())?;
488 set_restrictive_directory_permissions(&paths.entries_dir())?;
489 fs::create_dir_all(paths.run_dir())?;
490 set_restrictive_directory_permissions(&paths.run_dir())?;
491 Ok(())
492}
493
494fn write_json_atomic<T: Serialize>(path: &Path, value: &T) -> Result<(), VaultError> {
495 let Some(parent) = path.parent() else {
496 return Err(VaultError::Io(io::Error::other("invalid output path")));
497 };
498 fs::create_dir_all(parent)?;
499 set_restrictive_directory_permissions(parent)?;
500
501 let serialized = serde_json::to_vec_pretty(value).map_err(|err| VaultError::InvalidVaultFormat(format!("failed to serialize JSON: {err}")))?;
502 let file_name = path.file_name().and_then(|segment| segment.to_str()).unwrap_or("vault-data");
503 let tmp_path = parent.join(format!(".{file_name}.tmp-{}", Utc::now().timestamp_nanos_opt().unwrap_or_default()));
504 fs::write(&tmp_path, serialized)?;
505 set_restrictive_file_permissions(&tmp_path)?;
506 fs::rename(&tmp_path, path)?;
507 Ok(())
508}
509
510fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, VaultError> {
511 let bytes = fs::read(path)?;
512 serde_json::from_slice(&bytes).map_err(|err| VaultError::InvalidVaultFormat(format!("failed to parse JSON: {err}")))
513}
514
515fn decode_bytes(encoded: &str, label: &str) -> Result<Vec<u8>, VaultError> {
516 BASE64
517 .decode(encoded)
518 .map_err(|err| VaultError::InvalidVaultFormat(format!("failed to decode {label}: {err}")))
519}
520
521fn decode_fixed<const N: usize>(encoded: &str, label: &str) -> Result<[u8; N], VaultError> {
522 let decoded = decode_bytes(encoded, label)?;
523 if decoded.len() != N {
524 return Err(VaultError::InvalidVaultFormat(format!("{label} had the wrong length")));
525 }
526 let mut output = [0u8; N];
527 output.copy_from_slice(&decoded);
528 Ok(output)
529}
530
531fn entry_aad(name: &str) -> String {
532 format!("{}{}", String::from_utf8_lossy(ENTRY_AAD_PREFIX), name)
533}
534
535fn set_restrictive_directory_permissions(path: &Path) -> Result<(), VaultError> {
536 use std::os::unix::fs::PermissionsExt;
537
538 fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
539 Ok(())
540}
541
542fn set_restrictive_file_permissions(path: &Path) -> Result<(), VaultError> {
543 use std::os::unix::fs::PermissionsExt;
544
545 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
546 Ok(())
547}
548
549#[cfg(test)]
550#[path = "../test/auth/vault.rs"]
551mod tests;