use std::fs;
use std::path::{Path, PathBuf};
use aes_gcm::aead::{Aead, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit};
use argon2::{Algorithm, Argon2, Params, Version};
use hkdf::Hkdf;
use rand::RngCore;
use sha2::Sha256;
use zeroize::Zeroizing;
#[cfg(target_os = "linux")]
use zeroize::Zeroize;
use crate::error::Error;
use crate::gui;
use crate::vault::hardware::{self, Backend, DeviceKeystore};
const ARGON2_SALT_LEN: usize = 16;
const NONCE_LEN: usize = 12;
const MASTER_KEY_LEN: usize = 32;
const MIN_PASSPHRASE_LEN: usize = 8;
pub struct MasterKey {
bytes: Zeroizing<[u8; MASTER_KEY_LEN]>,
#[cfg(target_os = "linux")]
in_secret_mem: bool,
#[cfg(target_os = "linux")]
secret_fd: Option<i32>,
#[cfg(target_os = "linux")]
secret_ptr: Option<usize>,
}
impl MasterKey {
pub fn as_aes_key(&self) -> &Key<Aes256Gcm> {
Key::<Aes256Gcm>::from_slice(self.as_bytes())
}
pub fn as_bytes(&self) -> &[u8; MASTER_KEY_LEN] {
#[cfg(target_os = "linux")]
{
if self.in_secret_mem {
if let Some(ptr) = self.secret_ptr {
return unsafe { &*(ptr as *const [u8; MASTER_KEY_LEN]) };
}
}
}
&self.bytes
}
fn protect(&mut self) {
#[cfg(target_os = "linux")]
{
if self.try_memfd_secret() {
return;
}
}
#[cfg(unix)]
unsafe {
libc::mlock(self.bytes.as_ptr().cast::<libc::c_void>(), MASTER_KEY_LEN);
}
#[cfg(target_os = "linux")]
{
crate::guard::mark_dontdump(self.bytes.as_ptr(), MASTER_KEY_LEN);
}
#[cfg(windows)]
unsafe {
use windows_sys::Win32::System::Memory::VirtualLock;
VirtualLock(self.bytes.as_ptr() as *mut std::ffi::c_void, MASTER_KEY_LEN);
}
}
#[cfg(target_os = "linux")]
fn try_memfd_secret(&mut self) -> bool {
#[cfg(target_arch = "x86_64")]
const SYS_MEMFD_SECRET: libc::c_long = 447;
#[cfg(target_arch = "aarch64")]
const SYS_MEMFD_SECRET: libc::c_long = 447;
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
{
return false;
}
#[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
unsafe {
let fd = libc::syscall(SYS_MEMFD_SECRET, libc::O_CLOEXEC as u32);
if fd < 0 {
return false;
}
#[allow(clippy::cast_possible_truncation)]
let fd = fd as i32;
#[allow(clippy::cast_possible_wrap)]
let key_size = MASTER_KEY_LEN as libc::off_t;
if libc::ftruncate(fd, key_size) != 0 {
libc::close(fd);
return false;
}
let ptr = libc::mmap(
std::ptr::null_mut(),
MASTER_KEY_LEN,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_SHARED,
fd,
0,
);
if ptr == libc::MAP_FAILED {
libc::close(fd);
return false;
}
std::ptr::copy_nonoverlapping(self.bytes.as_ptr(), ptr.cast::<u8>(), MASTER_KEY_LEN);
self.in_secret_mem = true;
self.secret_fd = Some(fd);
self.secret_ptr = Some(ptr as usize);
self.bytes.zeroize();
true
}
}
}
impl Drop for MasterKey {
fn drop(&mut self) {
#[cfg(target_os = "linux")]
{
if self.in_secret_mem {
if let Some(ptr) = self.secret_ptr {
let ptr = ptr as *mut libc::c_void;
unsafe {
libc::explicit_bzero(ptr, MASTER_KEY_LEN);
libc::munmap(ptr, MASTER_KEY_LEN);
}
}
if let Some(fd) = self.secret_fd {
unsafe {
libc::close(fd);
}
}
return;
}
}
#[cfg(unix)]
unsafe {
libc::munlock(self.bytes.as_ptr().cast::<libc::c_void>(), MASTER_KEY_LEN);
}
#[cfg(windows)]
unsafe {
use windows_sys::Win32::System::Memory::VirtualUnlock;
VirtualUnlock(self.bytes.as_ptr() as *mut std::ffi::c_void, MASTER_KEY_LEN);
}
}
}
impl std::fmt::Debug for MasterKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MasterKey")
.field("bytes", &"[REDACTED]")
.finish()
}
}
#[cfg(any(test, feature = "test-backdoors"))]
#[doc(hidden)]
impl MasterKey {
pub fn from_test_bytes(bytes: [u8; MASTER_KEY_LEN]) -> Self {
let mut key = Self {
bytes: Zeroizing::new(bytes),
#[cfg(target_os = "linux")]
in_secret_mem: false,
#[cfg(target_os = "linux")]
secret_fd: None,
#[cfg(target_os = "linux")]
secret_ptr: None,
};
key.protect();
key
}
}
pub fn master_key_path(root: &Path) -> PathBuf {
root.join("master.key")
}
pub fn open_master_key(root: &Path) -> Result<MasterKey, Error> {
let mk_path = master_key_path(root);
let mut exists = mk_path.exists();
if exists {
if let Ok(raw) = std::fs::read(&mk_path) {
if crate::vault::hardware::parse_v2(&raw).is_err() {
let min_v1_len = ARGON2_SALT_LEN + NONCE_LEN;
if raw.len() < min_v1_len {
let _ = std::fs::remove_file(&mk_path);
exists = false;
}
}
} else {
exists = false;
}
}
if exists {
unlock_master_key(&mk_path)
} else {
create_master_key(root, &mk_path)
}
}
pub fn open_master_key_with_passphrase(
root: &Path,
passphrase: &Zeroizing<String>,
) -> Result<MasterKey, Error> {
let mk_path = master_key_path(root);
let mut exists = mk_path.exists();
if exists {
if let Ok(raw) = std::fs::read(&mk_path) {
if crate::vault::hardware::parse_v2(&raw).is_err() {
let min_v1_len = ARGON2_SALT_LEN + NONCE_LEN;
if raw.len() < min_v1_len {
let _ = std::fs::remove_file(&mk_path);
exists = false;
}
}
} else {
exists = false; }
}
if exists {
unlock_master_key_with(&mk_path, passphrase)
} else {
create_master_key_with(root, &mk_path, passphrase)
}
}
fn create_master_key(root: &Path, mk_path: &Path) -> Result<MasterKey, Error> {
let passphrase =
gui::request_passphrase(true, &crate::security_config::load_system_defaults())?;
create_master_key_with(root, mk_path, &passphrase)
}
fn create_master_key_with(
root: &Path,
mk_path: &Path,
passphrase: &Zeroizing<String>,
) -> Result<MasterKey, Error> {
validate_passphrase(passphrase)?;
let mut master_bytes = [0u8; MASTER_KEY_LEN];
OsRng.fill_bytes(&mut master_bytes);
let mut argon2_salt = [0u8; ARGON2_SALT_LEN];
OsRng.fill_bytes(&mut argon2_salt);
let wrapping_key = derive_wrapping_key(passphrase, &argon2_salt)?;
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(wrapping_key.as_ref()));
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, master_bytes.as_ref())
.map_err(|e| Error::CryptoFailure(format!("failed to wrap master key: {e}")))?;
let mut inner = Vec::with_capacity(ARGON2_SALT_LEN + NONCE_LEN + ciphertext.len());
inner.extend_from_slice(&argon2_salt);
inner.extend_from_slice(&nonce);
inner.extend_from_slice(&ciphertext);
fs::create_dir_all(root)?;
write_master_file(mk_path, &inner)?;
let mut key = MasterKey {
bytes: Zeroizing::new(master_bytes),
#[cfg(target_os = "linux")]
in_secret_mem: false,
#[cfg(target_os = "linux")]
secret_fd: None,
#[cfg(target_os = "linux")]
secret_ptr: None,
};
key.protect();
master_bytes.fill(0);
argon2_salt.fill(0);
drop(wrapping_key);
Ok(key)
}
fn unlock_master_key(mk_path: &Path) -> Result<MasterKey, Error> {
let cfg = crate::security_config::load_system_defaults();
let mut prev_error: Option<String> = None;
for attempt in 1..=MAX_UNLOCK_ATTEMPTS {
let passphrase = gui::request_passphrase_with_hint(false, prev_error.as_deref(), &cfg)?;
match unlock_master_key_with(mk_path, &passphrase) {
Ok(key) => return Ok(key),
Err(Error::CryptoFailure(msg)) if msg.contains("wrong passphrase") => {
let remaining = MAX_UNLOCK_ATTEMPTS - attempt;
if remaining == 0 {
return Err(Error::CryptoFailure(format!(
"wrong passphrase after {MAX_UNLOCK_ATTEMPTS} attempts"
)));
}
prev_error = Some(format!(
"Incorrect passphrase. {remaining} attempt{} left.",
if remaining == 1 { "" } else { "s" }
));
}
Err(e) => return Err(e),
}
}
Err(Error::UserDenied)
}
const MAX_UNLOCK_ATTEMPTS: u32 = 3;
fn unlock_master_key_with(
mk_path: &Path,
passphrase: &Zeroizing<String>,
) -> Result<MasterKey, Error> {
let raw = fs::read(mk_path)?;
let inner = read_inner_envelope_at(&raw, Some(mk_path))?;
let min_len = ARGON2_SALT_LEN + NONCE_LEN;
if inner.len() < min_len {
return Err(Error::CryptoFailure(
"master.key file corrupted: too short".to_string(),
));
}
let argon2_salt = &inner[..ARGON2_SALT_LEN];
let nonce_bytes = &inner[ARGON2_SALT_LEN..ARGON2_SALT_LEN + NONCE_LEN];
let ciphertext = &inner[ARGON2_SALT_LEN + NONCE_LEN..];
let wrapping_key = derive_wrapping_key(passphrase, argon2_salt)?;
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(wrapping_key.as_ref()));
let nonce = aes_gcm::Nonce::from_slice(nonce_bytes);
let master_bytes_vec = Zeroizing::new(cipher.decrypt(nonce, ciphertext).map_err(|_| {
Error::CryptoFailure(
"wrong passphrase or corrupted master.key — decryption failed".to_string(),
)
})?);
if master_bytes_vec.len() != MASTER_KEY_LEN {
return Err(Error::CryptoFailure(format!(
"master key has wrong length: expected {MASTER_KEY_LEN}, got {}",
master_bytes_vec.len()
)));
}
let mut master_bytes = [0u8; MASTER_KEY_LEN];
master_bytes.copy_from_slice(&master_bytes_vec);
let mut key = MasterKey {
bytes: Zeroizing::new(master_bytes),
#[cfg(target_os = "linux")]
in_secret_mem: false,
#[cfg(target_os = "linux")]
secret_fd: None,
#[cfg(target_os = "linux")]
secret_ptr: None,
};
key.protect();
master_bytes.fill(0);
drop(wrapping_key);
Ok(key)
}
pub fn derive_hmac_key(master_key: &[u8; 32]) -> Result<[u8; 32], Error> {
let hk = Hkdf::<Sha256>::new(Some(b"envseal-hmac-salt-v1"), master_key);
let mut out = [0u8; 32];
hk.expand(b"envseal-policy-security-hmac", &mut out)
.map_err(|_| {
Error::CryptoFailure(
"HKDF-derived HMAC key expansion failed (invalid output length)".to_string(),
)
})?;
Ok(out)
}
fn derive_wrapping_key(passphrase: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>, Error> {
let params = Params::new(
65536, 3, 4, Some(32),
)
.map_err(|e| Error::CryptoFailure(format!("invalid argon2 params: {e}")))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut output = Zeroizing::new([0u8; 32]);
argon2
.hash_password_into(passphrase.as_bytes(), salt, output.as_mut())
.map_err(|e| Error::CryptoFailure(format!("argon2id derivation failed: {e}")))?;
Ok(output)
}
fn validate_passphrase(passphrase: &str) -> Result<(), Error> {
if passphrase.len() < MIN_PASSPHRASE_LEN {
return Err(Error::CryptoFailure(format!(
"passphrase too short: minimum {MIN_PASSPHRASE_LEN} characters required"
)));
}
Ok(())
}
pub fn change_passphrase(root: &Path) -> Result<(), Error> {
let mk_path = master_key_path(root);
if !mk_path.exists() {
return Err(Error::CryptoFailure(
"no master key found — store a secret first".to_string(),
));
}
let master_key = unlock_master_key(&mk_path)?;
let new_passphrase =
gui::request_passphrase(true, &crate::security_config::load_system_defaults())?;
validate_passphrase(&new_passphrase)?;
let mut new_salt = [0u8; ARGON2_SALT_LEN];
OsRng.fill_bytes(&mut new_salt);
let wrapping_key = derive_wrapping_key(&new_passphrase, &new_salt)?;
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(wrapping_key.as_ref()));
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, master_key.as_bytes().as_ref())
.map_err(|e| Error::CryptoFailure(format!("failed to re-wrap master key: {e}")))?;
let mut inner = Vec::with_capacity(ARGON2_SALT_LEN + NONCE_LEN + ciphertext.len());
inner.extend_from_slice(&new_salt);
inner.extend_from_slice(&nonce);
inner.extend_from_slice(&ciphertext);
write_master_file(&mk_path, &inner)?;
Ok(())
}
fn write_master_file(mk_path: &Path, inner: &[u8]) -> Result<(), Error> {
let keystore = DeviceKeystore::select();
let backend = keystore.backend();
let sealed = keystore
.seal(inner)
.map_err(|e| Error::HardwareSealFailed(format!("seal of master.key failed: {e}")))?;
let envelope = hardware::pack_v2(backend, &sealed);
let parent = mk_path.parent().ok_or_else(|| {
Error::CryptoFailure(format!(
"master.key path {} has no parent — cannot stage atomic write",
mk_path.display()
))
})?;
fs::create_dir_all(parent)?;
let tmp_path = atomic_tmp_path(mk_path);
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = fs::OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.open(&tmp_path)?;
f.write_all(&envelope)?;
f.sync_all()?;
drop(f);
}
#[cfg(not(unix))]
{
if let Err(e) = fs::write(&tmp_path, &envelope) {
let _ = fs::remove_file(&tmp_path);
return Err(Error::StorageIo(e));
}
#[cfg(windows)]
{
let _ = crate::policy::windows_acl::set_owner_only_dacl(&tmp_path);
}
}
if let Err(e) = fs::rename(&tmp_path, mk_path) {
let _ = fs::remove_file(&tmp_path);
return Err(Error::StorageIo(e));
}
#[cfg(unix)]
{
if let Ok(dir) = fs::File::open(parent) {
let _ = dir.sync_all();
}
}
Ok(())
}
fn atomic_tmp_path(mk_path: &Path) -> std::path::PathBuf {
use std::time::{SystemTime, UNIX_EPOCH};
let pid = std::process::id();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
let mut p = mk_path.to_path_buf();
let stem = mk_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("master.key");
p.set_file_name(format!("{stem}.tmp.{pid}.{nanos}"));
p
}
#[cfg(test)]
fn read_inner_envelope(raw: &[u8]) -> Result<Vec<u8>, Error> {
read_inner_envelope_at(raw, None)
}
fn read_inner_envelope_at(raw: &[u8], mk_path: Option<&Path>) -> Result<Vec<u8>, Error> {
if let Ok(env) = hardware::parse_v2(raw) {
let keystore = DeviceKeystore::select();
let active = keystore.backend();
if env.backend != Backend::None && env.backend != active {
return Err(Error::DeviceMismatch {
sealed_by: env.backend.name().to_string(),
active: active.name().to_string(),
});
}
if env.backend == Backend::None {
Ok(env.sealed.to_vec())
} else {
keystore.unseal(env.sealed).map_err(|e| {
Error::HardwareSealFailed(format!(
"unseal of master.key failed — likely a different user logon, \
a different physical device, or a wiped TPM/SEP keypair: {e}"
))
})
}
} else {
let min_v1_len = ARGON2_SALT_LEN + NONCE_LEN;
if raw.len() < min_v1_len {
return Err(Error::CryptoFailure(
"master.key is too short to be a valid v1 or v2 file — \
the vault may be corrupted"
.to_string(),
));
}
eprintln!(
"envseal: ℹ️ detected legacy (pre-v2) master.key — \
upgrading to v2 envelope format on disk"
);
let inner = raw.to_vec();
if let Some(path) = mk_path {
if let Err(e) = write_master_file(path, &inner) {
eprintln!(
"envseal: ⚠️ v1→v2 on-disk upgrade failed \
(vault still usable, will retry next unlock): {e}"
);
}
}
Ok(inner)
}
}
pub fn active_backend() -> Backend {
DeviceKeystore::select().backend()
}
#[cfg(test)]
mod hardware_seal_tests {
use super::*;
use tempfile::tempdir;
fn synthetic_inner_blob() -> Vec<u8> {
vec![0u8; 16 + 12 + 48]
}
#[test]
fn legacy_v1_blob_accepted_as_migration() {
let v1_blob = synthetic_inner_blob();
let inner = read_inner_envelope(&v1_blob)
.expect("plausible v1 blob should be accepted for migration");
assert_eq!(inner, v1_blob);
}
#[test]
fn too_short_blob_rejected() {
let tiny = vec![0u8; 10];
let err = read_inner_envelope(&tiny).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("too short") || msg.contains("corrupted"),
"expected short-file error, got: {msg}"
);
}
#[test]
fn v2_envelope_with_active_backend_unseals() {
let dir = tempdir().unwrap();
let mk = dir.path().join("master.key");
let inner = synthetic_inner_blob();
write_master_file(&mk, &inner).unwrap();
let raw = std::fs::read(&mk).unwrap();
assert_eq!(&raw[..4], hardware::V2_MAGIC.as_slice());
let recovered_inner = read_inner_envelope(&raw).unwrap();
assert_eq!(recovered_inner, inner);
}
#[test]
fn v2_envelope_with_mismatched_backend_id_is_rejected() {
let active = active_backend();
let mismatch = match active {
Backend::Dpapi => Backend::Tpm2,
Backend::SecureEnclave | Backend::Tpm2 | Backend::None => Backend::Dpapi,
};
if mismatch == active {
return; }
let envelope = hardware::pack_v2(mismatch, &synthetic_inner_blob());
let err = read_inner_envelope(&envelope).unwrap_err();
match err {
Error::DeviceMismatch {
sealed_by,
active: active_str,
} => {
assert_eq!(sealed_by, mismatch.name());
assert_eq!(active_str, active.name());
}
other => panic!("expected DeviceMismatch, got {other:?}"),
}
}
#[test]
fn v2_envelope_with_none_backend_passes_through() {
let inner = synthetic_inner_blob();
let envelope = hardware::pack_v2(Backend::None, &inner);
let recovered = read_inner_envelope(&envelope).unwrap();
assert_eq!(recovered, inner);
}
#[test]
fn write_master_file_is_atomic_no_tmp_leaks() {
let dir = tempdir().unwrap();
let mk = dir.path().join("master.key");
write_master_file(&mk, &synthetic_inner_blob()).unwrap();
let entries: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(entries.contains(&"master.key".to_string()));
for name in &entries {
assert!(
!name.contains(".tmp."),
"found leftover tmp file after rotation: {name}"
);
}
}
#[test]
fn write_master_file_overwrites_previous_atomically() {
let dir = tempdir().unwrap();
let mk = dir.path().join("master.key");
let first = synthetic_inner_blob();
let mut second = synthetic_inner_blob();
second[1] = 0xAA; second[2] = 0xBB;
write_master_file(&mk, &first).unwrap();
write_master_file(&mk, &second).unwrap();
let raw_now = std::fs::read(&mk).unwrap();
let recovered = read_inner_envelope(&raw_now).unwrap();
assert_eq!(recovered, second);
}
#[test]
fn write_master_file_fails_clean_when_parent_unwritable() {
let dir = tempdir().unwrap();
let nested = dir.path().join("does/not/exist/master.key");
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, b"x").unwrap();
let bad = blocker.join("master.key");
let _ = nested;
let result = write_master_file(&bad, &synthetic_inner_blob());
assert!(
result.is_err(),
"writing under a regular-file parent must fail"
);
}
#[test]
fn atomic_tmp_path_is_unique_per_invocation() {
let mk = std::path::PathBuf::from("/some/dir/master.key");
let a = atomic_tmp_path(&mk);
std::thread::sleep(std::time::Duration::from_nanos(1));
let b = atomic_tmp_path(&mk);
assert_ne!(a, b);
assert!(a.to_string_lossy().contains("master.key.tmp."));
}
#[test]
fn write_then_read_corrupted_inner_still_fails_unseal_chain() {
let dir = tempdir().unwrap();
let mk = dir.path().join("master.key");
write_master_file(&mk, &synthetic_inner_blob()).unwrap();
let mut raw = std::fs::read(&mk).unwrap();
let last = raw.len() - 1;
raw[last] ^= 0x01;
let result = read_inner_envelope(&raw);
if active_backend() != Backend::None {
assert!(result.is_err(), "corrupted envelope must fail to unseal");
}
}
}
#[cfg(test)]
mod master_key_tests {
use super::*;
#[test]
fn different_keys_different_aes() {
let key1 = MasterKey::from_test_bytes([0x01; 32]);
let key2 = MasterKey::from_test_bytes([0x02; 32]);
assert_ne!(
key1.as_aes_key(),
key2.as_aes_key(),
"Fix: different bytes must produce different AES keys"
);
}
#[test]
fn master_key_as_aes_key_length() {
let key = MasterKey::from_test_bytes([0x42; 32]);
let aes_key = key.as_aes_key();
assert_eq!(aes_key.len(), 32, "Fix: AES-256 key must be 32 bytes");
}
#[test]
fn master_key_debug_redacts() {
let key = MasterKey::from_test_bytes([0xAA; 32]);
let debug = format!("{key:?}");
assert!(
debug.contains("REDACTED"),
"Fix: Debug must not leak key bytes"
);
assert!(
!debug.contains("170"),
"Fix: raw byte values must not appear in debug"
);
assert!(
!debug.contains("0xaa"),
"Fix: hex bytes must not appear in debug"
);
}
#[test]
fn master_key_zeroizes_on_drop() {
let key = MasterKey::from_test_bytes([0xFF; 32]);
assert_eq!(key.as_bytes()[0], 0xFF);
assert_eq!(key.as_bytes()[31], 0xFF);
drop(key);
}
#[test]
fn test_key_deterministic() {
let key1 = MasterKey::from_test_bytes([0x42; 32]);
let key2 = MasterKey::from_test_bytes([0x42; 32]);
assert_eq!(
key1.as_bytes(),
key2.as_bytes(),
"Fix: same input bytes must produce same key"
);
}
}