Skip to main content

cossh/auth/
vault.rs

1//! Encrypted local password vault.
2//!
3//! Vault data is stored under `~/.color-ssh/vault` with restrictive
4//! permissions and authenticated encryption at rest.
5
6use 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)]
37/// Vault metadata containing wrapped key material and KDF settings.
38pub 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)]
51/// Encrypted vault entry payload.
52pub 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)]
61/// Filesystem paths used by vault and agent runtime data.
62pub struct VaultPaths {
63    base_dir: PathBuf,
64}
65
66#[derive(Debug)]
67/// Errors returned by vault operations.
68pub 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    /// Resolve default paths rooted at `~/.color-ssh`.
106    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    /// Base directory used for all `color-ssh` data files.
121    pub fn base_dir(&self) -> &Path {
122        &self.base_dir
123    }
124
125    /// Path to the encrypted vault directory.
126    pub fn vault_dir(&self) -> PathBuf {
127        self.base_dir.join(VAULT_DIRNAME)
128    }
129
130    /// Path to vault metadata JSON.
131    pub fn metadata_path(&self) -> PathBuf {
132        self.vault_dir().join(VAULT_METADATA_FILENAME)
133    }
134
135    /// Directory containing encrypted entry JSON files.
136    pub fn entries_dir(&self) -> PathBuf {
137        self.vault_dir().join(VAULT_ENTRIES_DIRNAME)
138    }
139
140    /// Path to one entry file after name validation.
141    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    /// Runtime directory used by unlock-agent IPC/event files.
149    pub fn run_dir(&self) -> PathBuf {
150        self.base_dir.join(RUN_DIRNAME)
151    }
152}
153
154#[derive(Debug)]
155/// Unlocked vault handle carrying decrypted data key material.
156pub 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    /// Encrypt and store one secret under `name`.
170    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    /// Decrypt and return one secret by `name`.
206    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    /// Remove one secret entry by `name`.
253    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    /// Return paths associated with this unlocked vault handle.
263    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
272/// Returns whether the default vault is initialized.
273pub fn vault_exists() -> Result<bool, VaultError> {
274    Ok(VaultPaths::resolve_default()?.metadata_path().is_file())
275}
276
277/// List all entry names in the default vault.
278pub fn list_entries() -> Result<Vec<String>, VaultError> {
279    list_entries_with_paths(&VaultPaths::resolve_default()?)
280}
281
282/// Returns whether the named entry exists in the default vault.
283pub fn entry_exists(name: &str) -> Result<bool, VaultError> {
284    entry_exists_with_paths(&VaultPaths::resolve_default()?, name)
285}
286
287/// Initialize the default vault with a master password.
288pub fn initialize_vault(master_password: &str) -> Result<(), VaultError> {
289    initialize_vault_with_paths(&VaultPaths::resolve_default()?, master_password)
290}
291
292/// Unlock the default vault and return a handle for entry operations.
293pub fn unlock_with_password(master_password: &str) -> Result<UnlockedVault, VaultError> {
294    unlock_with_password_and_paths(&VaultPaths::resolve_default()?, master_password)
295}
296
297/// Rotate the default vault master password.
298pub 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;