use argon2::{Argon2, Algorithm, Version, Params};
use base64::{Engine, engine::general_purpose::STANDARD as B64};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce, aead::{Aead, KeyInit}};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use zeroize::Zeroize;
const NONCE_LEN: usize = 12;
#[derive(Debug)]
pub enum VaultError {
Io(std::io::Error),
WrongPassword,
Crypto,
Json(serde_json::Error),
}
impl std::fmt::Display for VaultError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VaultError::Io(e) => write!(f, "vault I/O error: {e}"),
VaultError::WrongPassword => write!(f, "wrong master password or corrupt vault"),
VaultError::Crypto => write!(f, "vault encryption error"),
VaultError::Json(e) => write!(f, "vault JSON error: {e}"),
}
}
}
#[derive(Serialize, Deserialize)]
struct VaultFile {
argon2_salt: String,
sentinel_ct: String,
sentinel_nonce: String,
entries: Vec<EncryptedEntry>,
}
#[derive(Serialize, Deserialize, Clone)]
struct EncryptedEntry {
domain: String,
username: String,
ciphertext: String,
nonce: String,
}
#[derive(Clone)]
pub struct VaultEntry {
pub domain: String,
pub username: String,
pub password: String,
}
pub struct Vault {
key: [u8; 32],
entries: Vec<VaultEntry>,
salt: Vec<u8>,
file_path: std::path::PathBuf,
}
impl Drop for Vault {
fn drop(&mut self) {
self.key.zeroize();
}
}
impl Vault {
pub fn create(master: &str) -> Result<Self, VaultError> {
let path = vault_path();
if path.exists() {
return Err(VaultError::Io(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"vault already exists — call Vault::open instead",
)));
}
let salt = random_bytes::<16>().to_vec();
let key = derive_key(master, &salt)?;
let mut vault = Vault { key, entries: Vec::new(), salt, file_path: path };
vault.save()?;
Ok(vault)
}
pub fn open(master: &str) -> Result<Self, VaultError> {
let path = vault_path();
let raw = std::fs::read_to_string(&path).map_err(VaultError::Io)?;
let vf: VaultFile = serde_json::from_str(&raw).map_err(VaultError::Json)?;
let salt = B64.decode(&vf.argon2_salt).map_err(|_| VaultError::WrongPassword)?;
let key = derive_key(master, &salt)?;
let sentinel_ct = B64.decode(&vf.sentinel_ct).map_err(|_| VaultError::WrongPassword)?;
let sentinel_nonce = B64.decode(&vf.sentinel_nonce).map_err(|_| VaultError::WrongPassword)?;
let sentinel_plain = aead_decrypt(&key, &sentinel_nonce, &sentinel_ct)?;
if sentinel_plain != b"duct-tape-v1" {
return Err(VaultError::WrongPassword);
}
let mut entries = Vec::with_capacity(vf.entries.len());
for enc in &vf.entries {
let ct = B64.decode(&enc.ciphertext).map_err(|_| VaultError::Crypto)?;
let nonce = B64.decode(&enc.nonce).map_err(|_| VaultError::Crypto)?;
let pwd = aead_decrypt(&key, &nonce, &ct)?;
entries.push(VaultEntry {
domain: enc.domain.clone(),
username: enc.username.clone(),
password: String::from_utf8(pwd).map_err(|_| VaultError::Crypto)?,
});
}
Ok(Vault { key, entries, salt, file_path: path })
}
pub fn upsert(&mut self, domain: String, username: String, password: String)
-> Result<(), VaultError>
{
match self.entries.iter_mut().find(|e| e.domain == domain && e.username == username) {
Some(e) => e.password = password,
None => self.entries.push(VaultEntry { domain, username, password }),
}
self.save()
}
pub fn remove(&mut self, domain: &str, username: &str) -> Result<bool, VaultError> {
let before = self.entries.len();
self.entries.retain(|e| !(e.domain == domain && e.username == username));
let removed = self.entries.len() < before;
if removed { self.save()?; }
Ok(removed)
}
pub fn for_domain(&self, domain: &str) -> Vec<&VaultEntry> {
self.entries.iter().filter(|e| e.domain == domain).collect()
}
pub fn all(&self) -> &[VaultEntry] { &self.entries }
fn save(&self) -> Result<(), VaultError> {
let (s_ct, s_nonce) = aead_encrypt(&self.key, b"duct-tape-v1")?;
let mut enc_entries = Vec::with_capacity(self.entries.len());
for e in &self.entries {
let (ct, nonce) = aead_encrypt(&self.key, e.password.as_bytes())?;
enc_entries.push(EncryptedEntry {
domain: e.domain.clone(),
username: e.username.clone(),
ciphertext: B64.encode(&ct),
nonce: B64.encode(&nonce),
});
}
let vf = VaultFile {
argon2_salt: B64.encode(&self.salt),
sentinel_ct: B64.encode(&s_ct),
sentinel_nonce: B64.encode(&s_nonce),
entries: enc_entries,
};
let json = serde_json::to_string_pretty(&vf).map_err(VaultError::Json)?;
if let Some(parent) = self.file_path.parent() {
std::fs::create_dir_all(parent).map_err(VaultError::Io)?;
}
let tmp = self.file_path.with_extension("json.tmp");
std::fs::write(&tmp, &json).map_err(VaultError::Io)?;
std::fs::rename(&tmp, &self.file_path).map_err(VaultError::Io)?;
Ok(())
}
pub fn exists() -> bool { vault_path().exists() }
pub fn fill_js(&self, domain: &str) -> Option<String> {
let entry = self.for_domain(domain).into_iter().next()?;
let u = entry.username.replace('\'', "\\'");
let p = entry.password.replace('\'', "\\'");
Some(format!(
"(function(){{\
var u=document.querySelector('input[type=email],input[type=text][autocomplete*=user],input[name*=user],input[id*=user][type=text]');\
var p=document.querySelector('input[type=password]');\
if(u){{ u.value='{u}'; u.dispatchEvent(new Event('input',{{bubbles:true}})); }}\
if(p){{ p.value='{p}'; p.dispatchEvent(new Event('input',{{bubbles:true}})); }}\
}})();"
))
}
}
fn derive_key(master: &str, salt: &[u8]) -> Result<[u8; 32], VaultError> {
let params = Params::new(65536, 3, 1, Some(32)).map_err(|_| VaultError::Crypto)?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut key = [0u8; 32];
argon2
.hash_password_into(master.as_bytes(), salt, &mut key)
.map_err(|_| VaultError::Crypto)?;
Ok(key)
}
fn aead_encrypt(key: &[u8; 32], plaintext: &[u8])
-> Result<(Vec<u8>, Vec<u8>), VaultError>
{
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let nonce_bytes = random_bytes::<NONCE_LEN>();
let nonce = Nonce::from_slice(&nonce_bytes);
let ct = cipher.encrypt(nonce, plaintext).map_err(|_| VaultError::Crypto)?;
Ok((ct, nonce_bytes.to_vec()))
}
fn aead_decrypt(key: &[u8; 32], nonce_bytes: &[u8], ciphertext: &[u8])
-> Result<Vec<u8>, VaultError>
{
if nonce_bytes.len() != NONCE_LEN {
return Err(VaultError::Crypto);
}
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
let nonce = Nonce::from_slice(nonce_bytes);
cipher.decrypt(nonce, ciphertext).map_err(|_| VaultError::WrongPassword)
}
fn random_bytes<const N: usize>() -> [u8; N] {
use rand::RngCore;
let mut buf = [0u8; N];
rand::rng().fill_bytes(&mut buf);
buf
}
pub(super) fn vault_path() -> std::path::PathBuf {
config_dir().join("duct-tape/vault.json")
}
pub(super) fn config_dir() -> std::path::PathBuf {
if let Ok(p) = std::env::var("XDG_CONFIG_HOME") {
return std::path::PathBuf::from(p);
}
if let Ok(home) = std::env::var("HOME") {
return std::path::PathBuf::from(home).join(".config");
}
std::path::PathBuf::from(".")
}