#![allow(dead_code)]
use anyhow::{anyhow, Result};
use rand::RngCore;
use std::path::{Path, PathBuf};
use crate::core::crypto::{self, VaultKey, SALT_SIZE};
use crate::core::vault::svault_dir;
const MASTER_FILE: &str = "master.enc";
const MASTER_SESSION: &str = ".master.session";
const MASTER_RECOVERY: &str = "master.recovery.enc";
pub const VAULT_KEYSLOT: &str = "keyslot.enc";
pub const KEYRING_KEYSLOT: &str = "keyring.keyslot.enc";
const MASTER_YUBIKEY: &str = "master.yubikey.enc";
const MASTER_YUBIKEY_META: &str = "master.yubikey.meta";
fn master_path() -> PathBuf {
svault_dir().join(MASTER_FILE)
}
fn session_path() -> PathBuf {
svault_dir().join(MASTER_SESSION)
}
fn keyslot_path(vault_dir: &Path) -> PathBuf {
vault_dir.join(VAULT_KEYSLOT)
}
fn keyring_keyslot_path() -> PathBuf {
svault_dir().join(KEYRING_KEYSLOT)
}
fn master_recovery_path() -> PathBuf {
svault_dir().join(MASTER_RECOVERY)
}
fn yubikey_path() -> PathBuf {
svault_dir().join(MASTER_YUBIKEY)
}
fn yubikey_meta_path() -> PathBuf {
svault_dir().join(MASTER_YUBIKEY_META)
}
pub fn master_recovery_exists() -> bool {
master_recovery_path().exists()
}
pub fn yubikey_enrolled() -> bool {
yubikey_path().exists() && yubikey_meta_path().exists()
}
pub fn exists() -> bool {
master_path().exists()
}
pub fn vault_has_keyslot(vault_dir: &Path) -> bool {
keyslot_path(vault_dir).exists()
}
pub fn keyring_has_keyslot() -> bool {
keyring_keyslot_path().exists()
}
pub struct Master {
mk: VaultKey,
}
impl Master {
pub fn init(passphrase: &str) -> Result<Self> {
let path = master_path();
if path.exists() {
return Err(anyhow!("a master passphrase is already set"));
}
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
crate::core::secfile::create_dir_owner_only(parent)?;
}
}
let mut mk_bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut mk_bytes);
let mk = VaultKey::from_bytes(mk_bytes);
write_master_slot(&path, &mk, passphrase)?;
Ok(Self { mk })
}
pub fn open(passphrase: &str) -> Result<Self> {
let blob = std::fs::read(master_path())
.map_err(|_| anyhow!("no master passphrase set — run 'svault master init'"))?;
if blob.len() < SALT_SIZE {
return Err(anyhow!("master.enc is too short — may be corrupted"));
}
let salt = &blob[..SALT_SIZE];
let kek = VaultKey::derive(passphrase, salt)?;
let mk_bytes =
crypto::decrypt(&kek, &blob).map_err(|_| anyhow!("wrong master passphrase"))?;
let mk_bytes: [u8; 32] = mk_bytes
.try_into()
.map_err(|_| anyhow!("master.enc holds an unexpected key length"))?;
Ok(Self {
mk: VaultKey::from_bytes(mk_bytes),
})
}
pub fn open_with_key(mk: [u8; 32]) -> Self {
Self {
mk: VaultKey::from_bytes(mk),
}
}
pub fn rekey(&self, new_passphrase: &str) -> Result<()> {
write_master_slot(&master_path(), &self.mk, new_passphrase)
}
pub fn write_recovery(&self) -> Result<String> {
let code = crate::core::recovery::generate_code();
crate::core::recovery::write_at(&master_recovery_path(), &self.mk, &code)?;
Ok(code)
}
pub fn key_bytes(&self) -> &[u8; 32] {
self.mk.bytes()
}
pub fn enroll_yubikey(&self, pin: Option<&str>) -> Result<()> {
let credential_id = crate::core::yubikey::enroll(pin)?;
let mut hmac_salt = [0u8; 32];
rand::thread_rng().fill_bytes(&mut hmac_salt);
let secret = crate::core::yubikey::derive_secret(&credential_id, &hmac_salt, pin)?;
let kek = VaultKey::from_bytes(secret);
let mut aes_salt = [0u8; SALT_SIZE];
rand::thread_rng().fill_bytes(&mut aes_salt);
let blob = crypto::encrypt(&kek, &aes_salt, self.mk.bytes())?;
crate::core::secfile::write_owner_only(&yubikey_path(), &blob)?;
let meta = YubiMeta {
credential_id: hex::encode(&credential_id),
hmac_salt: hex::encode(hmac_salt),
};
let json = serde_json::to_vec(&meta)?;
crate::core::secfile::write_owner_only(&yubikey_meta_path(), &json)?;
Ok(())
}
pub fn wrap_dek(&self, vault_dir: &Path, dek: &VaultKey) -> Result<()> {
self.wrap_dek_at(&keyslot_path(vault_dir), dek)
}
pub fn unwrap_dek(&self, vault_dir: &Path) -> Result<VaultKey> {
self.unwrap_dek_at(
&keyslot_path(vault_dir),
"vault is not wrapped under the master key (no keyslot.enc)",
)
}
pub fn wrap_keyring_dek(&self, dek: &VaultKey) -> Result<()> {
self.wrap_dek_at(&keyring_keyslot_path(), dek)
}
pub fn unwrap_keyring_dek(&self) -> Result<VaultKey> {
self.unwrap_dek_at(
&keyring_keyslot_path(),
"keyring is not wrapped under the master key (no keyring.keyslot.enc)",
)
}
fn wrap_dek_at(&self, path: &Path, dek: &VaultKey) -> Result<()> {
let mut salt = [0u8; SALT_SIZE];
rand::thread_rng().fill_bytes(&mut salt);
let blob = crypto::encrypt(&self.mk, &salt, dek.bytes())?;
crate::core::secfile::write_owner_only(path, &blob)?;
Ok(())
}
fn unwrap_dek_at(&self, path: &Path, missing: &str) -> Result<VaultKey> {
let blob = std::fs::read(path).map_err(|_| anyhow!("{missing}"))?;
let dek_bytes = crypto::decrypt(&self.mk, &blob)
.map_err(|_| anyhow!("could not unwrap the data key with this master"))?;
let dek_bytes: [u8; 32] = dek_bytes
.try_into()
.map_err(|_| anyhow!("keyslot holds an unexpected key length"))?;
Ok(VaultKey::from_bytes(dek_bytes))
}
}
pub fn recover(code: &str, new_passphrase: &str) -> Result<Master> {
let path = master_recovery_path();
if !path.exists() {
return Err(anyhow!(
"no master recovery code on this machine (master.recovery.enc missing)"
));
}
let mk = crate::core::recovery::unlock_at(&path, code)?;
write_master_slot(&master_path(), &mk, new_passphrase)?;
Ok(Master { mk })
}
#[derive(serde::Serialize, serde::Deserialize)]
struct YubiMeta {
credential_id: String,
hmac_salt: String,
}
pub fn open_with_yubikey(pin: Option<&str>) -> Result<Master> {
if !yubikey_enrolled() {
return Err(anyhow!(
"no YubiKey is enrolled on this machine (run 'svault master yubikey enroll')"
));
}
let meta: YubiMeta = serde_json::from_slice(&std::fs::read(yubikey_meta_path())?)
.map_err(|_| anyhow!("master.yubikey.meta is corrupted"))?;
let credential_id = hex::decode(&meta.credential_id)
.map_err(|_| anyhow!("bad credential id in YubiKey meta"))?;
let salt: [u8; 32] = hex::decode(&meta.hmac_salt)
.ok()
.and_then(|b| b.try_into().ok())
.ok_or_else(|| anyhow!("bad hmac salt in YubiKey meta"))?;
let secret = crate::core::yubikey::derive_secret(&credential_id, &salt, pin)?;
let kek = VaultKey::from_bytes(secret);
let blob = std::fs::read(yubikey_path())?;
let mk_bytes = crypto::decrypt(&kek, &blob)
.map_err(|_| anyhow!("this YubiKey did not unwrap the master key"))?;
let mk_bytes: [u8; 32] = mk_bytes
.try_into()
.map_err(|_| anyhow!("master.yubikey.enc holds an unexpected key length"))?;
Ok(Master {
mk: VaultKey::from_bytes(mk_bytes),
})
}
pub fn remove_yubikey() -> Result<()> {
crate::core::session::secure_remove(&yubikey_path())?;
crate::core::session::secure_remove(&yubikey_meta_path())?;
Ok(())
}
pub fn new_dek() -> VaultKey {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
VaultKey::from_bytes(bytes)
}
fn write_master_slot(path: &Path, mk: &VaultKey, passphrase: &str) -> Result<()> {
let mut salt = [0u8; SALT_SIZE];
rand::thread_rng().fill_bytes(&mut salt);
let kek = VaultKey::derive(passphrase, &salt)?;
let blob = crypto::encrypt(&kek, &salt, mk.bytes())?;
crate::core::secfile::write_owner_only(path, &blob)?;
Ok(())
}
pub fn unlock_session(mk: &[u8; 32]) -> Result<()> {
crate::core::session::write_session_key(&session_path(), mk)
}
pub fn lock_session() -> Result<()> {
crate::core::session::secure_remove(&session_path())?;
Ok(())
}
pub fn session_key() -> Option<[u8; 32]> {
crate::core::session::read_session_key(&session_path())
}
pub fn is_unlocked() -> bool {
session_key().is_some()
}
pub fn open_from_session() -> Option<Master> {
Some(Master::open_with_key(session_key()?))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::testlock::CWD_LOCK;
use crate::core::vault::SVAULT_DIR;
use std::sync::MutexGuard;
fn in_temp_cwd() -> (MutexGuard<'static, ()>, tempfile::TempDir, PathBuf) {
let guard = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::TempDir::new().unwrap();
let prev = std::env::current_dir().unwrap();
std::env::set_current_dir(tmp.path()).unwrap();
(guard, tmp, prev)
}
#[test]
fn init_then_open_recovers_the_same_master_key() {
let (_g, _tmp, prev) = in_temp_cwd();
let m = Master::init("Master!Pass#1").unwrap();
let mk = *m.key_bytes();
drop(m);
assert!(Master::open("nope").is_err());
let reopened = Master::open("Master!Pass#1").unwrap();
assert_eq!(reopened.key_bytes(), &mk);
std::env::set_current_dir(prev).unwrap();
}
#[test]
fn dek_wraps_under_master_and_unwraps_back() {
let (_g, _tmp, prev) = in_temp_cwd();
let m = Master::init("Master!Pass#2").unwrap();
let vault_dir = PathBuf::from(SVAULT_DIR).join("v");
crate::core::secfile::create_dir_owner_only(&vault_dir).unwrap();
let dek = new_dek();
let dek_bytes = *dek.bytes();
m.wrap_dek(&vault_dir, &dek).unwrap();
assert!(vault_has_keyslot(&vault_dir));
let unwrapped = m.unwrap_dek(&vault_dir).unwrap();
assert_eq!(unwrapped.bytes(), &dek_bytes);
std::env::set_current_dir(prev).unwrap();
}
#[test]
fn keyring_dek_wraps_under_master_and_unwraps_back() {
let (_g, _tmp, prev) = in_temp_cwd();
let m = Master::init("Master!Pass#KR").unwrap();
crate::core::secfile::create_dir_owner_only(&PathBuf::from(SVAULT_DIR)).unwrap();
let dek = new_dek();
let dek_bytes = *dek.bytes();
m.wrap_keyring_dek(&dek).unwrap();
assert!(keyring_has_keyslot());
let unwrapped = m.unwrap_keyring_dek().unwrap();
assert_eq!(unwrapped.bytes(), &dek_bytes);
std::env::set_current_dir(prev).unwrap();
}
#[test]
fn rekey_keeps_master_key_and_keeps_unwrapping_vaults() {
let (_g, _tmp, prev) = in_temp_cwd();
let m = Master::init("Old!Master#1").unwrap();
let vault_dir = PathBuf::from(SVAULT_DIR).join("v");
crate::core::secfile::create_dir_owner_only(&vault_dir).unwrap();
let dek = new_dek();
let dek_bytes = *dek.bytes();
m.wrap_dek(&vault_dir, &dek).unwrap();
m.rekey("New!Master#2").unwrap();
assert!(Master::open("Old!Master#1").is_err());
let reopened = Master::open("New!Master#2").unwrap();
assert_eq!(reopened.unwrap_dek(&vault_dir).unwrap().bytes(), &dek_bytes);
std::env::set_current_dir(prev).unwrap();
}
#[test]
fn wrong_master_cannot_unwrap_a_dek() {
let (_g, _tmp, prev) = in_temp_cwd();
let m = Master::init("Right!Master#1").unwrap();
let vault_dir = PathBuf::from(SVAULT_DIR).join("v");
crate::core::secfile::create_dir_owner_only(&vault_dir).unwrap();
m.wrap_dek(&vault_dir, &new_dek()).unwrap();
let other = Master::open_with_key([0x11u8; 32]);
assert!(other.unwrap_dek(&vault_dir).is_err());
std::env::set_current_dir(prev).unwrap();
}
#[test]
fn recovery_code_resets_the_master_and_keeps_unwrapping_stores() {
let (_g, _tmp, prev) = in_temp_cwd();
let m = Master::init("Old!Master#R").unwrap();
let vault_dir = PathBuf::from(SVAULT_DIR).join("v");
crate::core::secfile::create_dir_owner_only(&vault_dir).unwrap();
let dek = new_dek();
let dek_bytes = *dek.bytes();
m.wrap_dek(&vault_dir, &dek).unwrap();
let code = m.write_recovery().unwrap();
assert!(master_recovery_exists());
drop(m);
let recovered = recover(&code, "New!Master#R").unwrap();
assert_eq!(
recovered.unwrap_dek(&vault_dir).unwrap().bytes(),
&dek_bytes
);
assert!(Master::open("New!Master#R").is_ok());
assert!(recover("0000-0000-0000-0000-0000-0000-0000-0000-0000-0000", "X").is_err());
std::env::set_current_dir(prev).unwrap();
}
#[test]
fn session_caches_mk_then_lock_clears() {
let (_g, _tmp, prev) = in_temp_cwd();
crate::core::secfile::create_dir_owner_only(&PathBuf::from(SVAULT_DIR)).unwrap();
assert!(!is_unlocked());
unlock_session(&[5u8; 32]).unwrap();
assert!(is_unlocked());
assert_eq!(session_key(), Some([5u8; 32]));
lock_session().unwrap();
assert!(!is_unlocked());
std::env::set_current_dir(prev).unwrap();
}
}