use anyhow::{Context, Result, bail};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use chacha20poly1305::{
ChaCha20Poly1305, Nonce,
aead::{Aead, AeadCore, KeyInit, OsRng},
};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use zeroize::Zeroize;
const CURRENT_VERSION: u8 = 2;
#[derive(Serialize, Deserialize)]
struct EncryptedVaultFile {
version: u8,
nonce: String,
ciphertext: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum SecretFormat {
#[default]
Raw,
Multiline,
Base64,
Json,
}
impl std::fmt::Display for SecretFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SecretFormat::Raw => write!(f, "raw"),
SecretFormat::Multiline => write!(f, "multiline"),
SecretFormat::Base64 => write!(f, "base64"),
SecretFormat::Json => write!(f, "json"),
}
}
}
impl std::str::FromStr for SecretFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"raw" => Ok(SecretFormat::Raw),
"multiline" => Ok(SecretFormat::Multiline),
"base64" => Ok(SecretFormat::Base64),
"json" => Ok(SecretFormat::Json),
_ => Err(format!(
"Invalid format '{}'. Valid options: raw, multiline, base64, json",
s
)),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SecretEntry {
pub value: String,
#[serde(default)]
pub format: SecretFormat,
#[serde(skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
impl Zeroize for SecretEntry {
fn zeroize(&mut self) {
self.value.zeroize();
self.hash.zeroize();
self.description.zeroize();
}
}
#[derive(Serialize, Deserialize, Debug)]
struct VaultPayloadV2 {
version: u8,
secrets: HashMap<String, SecretEntry>,
}
#[derive(Debug, Default)]
pub struct Vault {
path: PathBuf,
key: [u8; 32],
secrets: HashMap<String, SecretEntry>,
}
impl Zeroize for Vault {
fn zeroize(&mut self) {
self.secrets.drain().for_each(|(mut k, mut v)| {
k.zeroize();
v.zeroize();
});
self.key.zeroize();
}
}
impl Drop for Vault {
fn drop(&mut self) {
self.zeroize();
}
}
impl Vault {
pub fn load(vault_path: &Path, key: [u8; 32]) -> Result<Self> {
let mut vault = Vault {
path: vault_path.to_path_buf(),
key,
secrets: HashMap::new(),
};
if !vault_path.exists() {
return Ok(vault);
}
let content = fs::read_to_string(vault_path).context("Failed to read vault.enc")?;
let file_data: EncryptedVaultFile =
serde_json::from_str(&content).context("Failed to parse vault structure")?;
let cipher = ChaCha20Poly1305::new(&key.into());
let nonce_bytes = BASE64
.decode(&file_data.nonce)
.context("Invalid nonce base64")?;
let ciphertext = BASE64
.decode(&file_data.ciphertext)
.context("Invalid ciphertext base64")?;
let nonce = Nonce::from_slice(&nonce_bytes);
let plaintext = cipher
.decrypt(nonce, ciphertext.as_ref())
.map_err(|_| anyhow::anyhow!("Decryption failed. Data corrupted or wrong key."))?;
let secrets = match file_data.version {
1 => Self::migrate_v1_to_v2(&plaintext)?,
2 => Self::parse_v2(&plaintext)?,
v => bail!("Unsupported vault version: {}. Please upgrade cred.", v),
};
vault.secrets = secrets;
Ok(vault)
}
fn migrate_v1_to_v2(plaintext: &[u8]) -> Result<HashMap<String, SecretEntry>> {
let old_secrets: HashMap<String, String> =
serde_json::from_slice(plaintext).context("Failed to parse v1 secrets")?;
let now = Utc::now();
let migrated = old_secrets
.into_iter()
.map(|(k, v)| {
let format = Self::detect_format(&v);
let entry = SecretEntry {
value: v,
format,
hash: None,
created_at: now,
updated_at: now,
description: None,
};
(k, entry)
})
.collect();
Ok(migrated)
}
fn parse_v2(plaintext: &[u8]) -> Result<HashMap<String, SecretEntry>> {
let payload: VaultPayloadV2 =
serde_json::from_slice(plaintext).context("Failed to parse v2 payload")?;
Ok(payload.secrets)
}
pub fn detect_format(value: &str) -> SecretFormat {
if value.contains('\n') {
SecretFormat::Multiline
} else if value.starts_with('{') && value.ends_with('}') {
SecretFormat::Json
} else {
SecretFormat::Raw
}
}
pub fn save(&self) -> Result<()> {
let payload = VaultPayloadV2 {
version: CURRENT_VERSION,
secrets: self.secrets.clone(),
};
let plaintext = serde_json::to_vec(&payload)?;
let cipher = ChaCha20Poly1305::new(&self.key.into());
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, plaintext.as_ref())
.map_err(|e| anyhow::anyhow!("Encryption failed: {:?}", e))?;
let file_data = EncryptedVaultFile {
version: CURRENT_VERSION,
nonce: BASE64.encode(nonce),
ciphertext: BASE64.encode(ciphertext),
};
let json = serde_json::to_string_pretty(&file_data)?;
fs::write(&self.path, json).context("Failed to write to vault.enc")?;
Ok(())
}
pub fn set(&mut self, key: &str, value: &str) {
let now = Utc::now();
let format = Self::detect_format(value);
match self.secrets.get_mut(key) {
Some(entry) => {
entry.value = value.to_string();
entry.format = format;
entry.updated_at = now;
entry.hash = None;
}
None => {
self.secrets.insert(
key.to_string(),
SecretEntry {
value: value.to_string(),
format,
hash: None,
created_at: now,
updated_at: now,
description: None,
},
);
}
}
}
pub fn set_with_metadata(
&mut self,
key: &str,
value: &str,
format: SecretFormat,
description: Option<String>,
) {
let now = Utc::now();
match self.secrets.get_mut(key) {
Some(entry) => {
entry.value = value.to_string();
entry.format = format;
entry.description = description;
entry.updated_at = now;
entry.hash = None;
}
None => {
self.secrets.insert(
key.to_string(),
SecretEntry {
value: value.to_string(),
format,
hash: None,
created_at: now,
updated_at: now,
description,
},
);
}
}
}
pub fn get(&self, key: &str) -> Option<&String> {
self.secrets.get(key).map(|e| &e.value)
}
pub fn get_entry(&self, key: &str) -> Option<&SecretEntry> {
self.secrets.get(key)
}
pub fn remove(&mut self, key: &str) -> Option<String> {
self.secrets.remove(key).map(|e| e.value)
}
pub fn remove_entry(&mut self, key: &str) -> Option<SecretEntry> {
self.secrets.remove(key)
}
pub fn list(&self) -> HashMap<String, String> {
self.secrets
.iter()
.map(|(k, e)| (k.clone(), e.value.clone()))
.collect()
}
pub fn list_entries(&self) -> &HashMap<String, SecretEntry> {
&self.secrets
}
pub fn set_description(&mut self, key: &str, description: Option<String>) -> bool {
if let Some(entry) = self.secrets.get_mut(key) {
entry.description = description;
entry.updated_at = Utc::now();
true
} else {
false
}
}
#[allow(dead_code)]
pub fn set_hash(&mut self, key: &str, hash: Option<String>) -> bool {
if let Some(entry) = self.secrets.get_mut(key) {
entry.hash = hash;
true
} else {
false
}
}
}