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)?;
let result = unlock_master_key_with(mk_path, &passphrase);
#[cfg(feature = "fido2-hardware")]
let result = match result {
Err(Error::Fido2Required) => {
let mut auth = crate::vault::fido2_hardware::HwAuthenticator::discover()?;
fido2_unlock::unlock_master_key_with_fido2(mk_path, &passphrase, &mut auth)
}
other => other,
};
match result {
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))?;
#[cfg(feature = "fido2")]
if crate::vault::fido2::is_v3(&inner) {
return Err(Error::Fido2Required);
}
unlock_master_key_v1_inner(&inner, passphrase)
}
fn unlock_master_key_v1_inner(
inner: &[u8],
passphrase: &Zeroizing<String>,
) -> Result<MasterKey, Error> {
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)?;
decrypt_master_key_with_wrap(wrapping_key, nonce_bytes, ciphertext)
}
fn decrypt_master_key_with_wrap(
wrapping_key: Zeroizing<[u8; 32]>,
nonce_bytes: &[u8],
ciphertext: &[u8],
) -> Result<MasterKey, Error> {
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(feature = "fido2")]
pub mod fido2_unlock {
use super::{
decrypt_master_key_with_wrap, derive_wrapping_key, master_key_path,
read_inner_envelope_at, unlock_master_key_v1_inner, validate_passphrase,
write_master_file, MasterKey, ARGON2_SALT_LEN, MASTER_KEY_LEN, NONCE_LEN,
};
use crate::error::Error;
use crate::vault::fido2;
use aes_gcm::aead::{Aead, OsRng};
use aes_gcm::{AeadCore, Aes256Gcm, Key, KeyInit};
use rand::RngCore;
use std::fs;
use std::path::Path;
use zeroize::Zeroizing;
pub const RELYING_PARTY_ID: &str = "envseal.local";
pub const RELYING_PARTY_NAME: &str = "envseal vault";
pub fn open_master_key_with_passphrase_and_fido2<A>(
root: &Path,
passphrase: &Zeroizing<String>,
authenticator: &mut A,
) -> Result<MasterKey, Error>
where
A: fido2::Fido2Authenticator,
{
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_fido2(&mk_path, passphrase, authenticator)
} else {
create_master_key_with_fido2(root, &mk_path, passphrase, authenticator)
}
}
pub fn create_master_key_with_fido2<A>(
root: &Path,
mk_path: &Path,
passphrase: &Zeroizing<String>,
authenticator: &mut A,
) -> Result<MasterKey, Error>
where
A: fido2::Fido2Authenticator,
{
validate_passphrase(passphrase)?;
let credential_id =
authenticator.make_credential(RELYING_PARTY_ID, RELYING_PARTY_NAME)?;
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 mut hmac_salt = [0u8; fido2::HMAC_SALT_LEN];
OsRng.fill_bytes(&mut hmac_salt);
let fido2_secret = authenticator.assert_with_hmac(&credential_id, &hmac_salt)?;
let argon2_output = derive_wrapping_key(passphrase, &argon2_salt)?;
let wrapping_key =
fido2::combine_passphrase_and_fido2(&argon2_output, &fido2_secret, &hmac_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!("v3: failed to wrap master key: {e}")))?;
let envelope = fido2::V3Envelope {
credential_id,
hmac_salt,
argon2_salt,
nonce: nonce.into(),
ciphertext,
};
let inner = fido2::pack(&envelope)?;
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);
hmac_salt.fill(0);
drop(wrapping_key);
drop(argon2_output);
Ok(key)
}
pub fn unlock_master_key_with_fido2<A>(
mk_path: &Path,
passphrase: &Zeroizing<String>,
authenticator: &mut A,
) -> Result<MasterKey, Error>
where
A: fido2::Fido2Authenticator,
{
let raw = fs::read(mk_path)?;
let inner = read_inner_envelope_at(&raw, Some(mk_path))?;
if !fido2::is_v3(&inner) {
let _ = authenticator;
return unlock_master_key_v1_inner(&inner, passphrase);
}
let env = fido2::parse(&inner)?;
let cred_hash = credential_id_hash(&env.credential_id);
let argon2_output = derive_wrapping_key(passphrase, &env.argon2_salt)?;
let fido2_secret = match authenticator.assert_with_hmac(&env.credential_id, &env.hmac_salt)
{
Ok(s) => s,
Err(e) => {
let _ = crate::audit::log(&crate::audit::AuditEvent::Fido2Unlock {
credential_id_hash: cred_hash.clone(),
succeeded: false,
});
return Err(e);
}
};
let wrapping_key =
fido2::combine_passphrase_and_fido2(&argon2_output, &fido2_secret, &env.hmac_salt);
let result = decrypt_master_key_with_wrap(wrapping_key, &env.nonce, &env.ciphertext);
let _ = crate::audit::log(&crate::audit::AuditEvent::Fido2Unlock {
credential_id_hash: cred_hash,
succeeded: result.is_ok(),
});
result
}
pub fn enroll_fido2_on_existing_master<A>(
mk_path: &Path,
master_key: &MasterKey,
passphrase: &Zeroizing<String>,
authenticator: &mut A,
) -> Result<(), Error>
where
A: fido2::Fido2Authenticator,
{
validate_passphrase(passphrase)?;
let credential_id =
authenticator.make_credential(RELYING_PARTY_ID, RELYING_PARTY_NAME)?;
let mut argon2_salt = [0u8; ARGON2_SALT_LEN];
OsRng.fill_bytes(&mut argon2_salt);
let mut hmac_salt = [0u8; fido2::HMAC_SALT_LEN];
OsRng.fill_bytes(&mut hmac_salt);
let fido2_secret = authenticator.assert_with_hmac(&credential_id, &hmac_salt)?;
let argon2_output = derive_wrapping_key(passphrase, &argon2_salt)?;
let wrapping_key =
fido2::combine_passphrase_and_fido2(&argon2_output, &fido2_secret, &hmac_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!("v3 enroll: re-wrap failed: {e}")))?;
let envelope = fido2::V3Envelope {
credential_id,
hmac_salt,
argon2_salt,
nonce: nonce.into(),
ciphertext,
};
let inner = fido2::pack(&envelope)?;
write_master_file(mk_path, &inner)?;
argon2_salt.fill(0);
hmac_salt.fill(0);
drop(wrapping_key);
drop(argon2_output);
Ok(())
}
pub fn disable_fido2_keep_master(
mk_path: &Path,
master_key: &MasterKey,
passphrase: &Zeroizing<String>,
) -> Result<(), Error> {
validate_passphrase(passphrase)?;
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_key.as_bytes().as_ref())
.map_err(|e| Error::CryptoFailure(format!("v3 disable: re-wrap failed: {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);
write_master_file(mk_path, &inner)?;
argon2_salt.fill(0);
drop(wrapping_key);
Ok(())
}
pub fn fido2_status_at(mk_path: &Path) -> Result<Fido2Status, Error> {
if !mk_path.exists() {
return Ok(Fido2Status::NoVault);
}
let raw = std::fs::read(mk_path)?;
let inner = read_inner_envelope_at(&raw, None)?;
if !fido2::is_v3(&inner) {
return Ok(Fido2Status::NotEnrolled);
}
let env = fido2::parse(&inner)?;
Ok(Fido2Status::Enrolled {
credential_id: env.credential_id,
})
}
#[derive(Debug, Clone)]
pub enum Fido2Status {
NoVault,
NotEnrolled,
Enrolled {
credential_id: Vec<u8>,
},
}
#[must_use]
pub fn credential_id_hash(credential_id: &[u8]) -> String {
use sha2::Digest;
let digest = sha2::Sha256::digest(credential_id);
let mut out = String::with_capacity(digest.len() * 2);
for b in digest {
out.push_str(&format!("{b:02x}"));
}
out
}
}
#[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(all(test, feature = "fido2"))]
mod fido2_e2e_tests {
use super::fido2_unlock::{
create_master_key_with_fido2, credential_id_hash, disable_fido2_keep_master,
enroll_fido2_on_existing_master, fido2_status_at,
open_master_key_with_passphrase_and_fido2, unlock_master_key_with_fido2, Fido2Status,
};
use super::*;
use crate::error::Error;
use crate::vault::fido2::tests::MockAuthenticator;
use tempfile::tempdir;
fn mk_passphrase() -> Zeroizing<String> {
Zeroizing::new("correct-horse-battery-staple".to_string())
}
#[test]
fn create_then_unlock_with_same_authenticator_succeeds() {
let dir = tempdir().unwrap();
let mk = master_key_path(dir.path());
let mut auth = MockAuthenticator::new([0x42; 32]);
let pass = mk_passphrase();
let key1 =
create_master_key_with_fido2(dir.path(), &mk, &pass, &mut auth).expect("create v3");
let key1_bytes = *key1.as_bytes();
let key2 = unlock_master_key_with_fido2(&mk, &pass, &mut auth).expect("unlock v3");
assert_eq!(
*key2.as_bytes(),
key1_bytes,
"round-trip through v3 envelope must preserve master key bytes"
);
}
#[test]
fn unlock_v3_without_authenticator_returns_fido2_required() {
let dir = tempdir().unwrap();
let mk = master_key_path(dir.path());
let mut auth = MockAuthenticator::new([0x77; 32]);
let pass = mk_passphrase();
let _ = create_master_key_with_fido2(dir.path(), &mk, &pass, &mut auth).unwrap();
let err = unlock_master_key_with(&mk, &pass).unwrap_err();
assert!(
matches!(err, Error::Fido2Required),
"expected Fido2Required, got {err:?}"
);
}
#[test]
fn unlock_v3_with_wrong_authenticator_fails() {
let dir = tempdir().unwrap();
let mk = master_key_path(dir.path());
let mut enroll = MockAuthenticator::new([0xAA; 32]);
let pass = mk_passphrase();
let _ = create_master_key_with_fido2(dir.path(), &mk, &pass, &mut enroll).unwrap();
let mut wrong = MockAuthenticator::new([0xBB; 32]);
let result = unlock_master_key_with_fido2(&mk, &pass, &mut wrong);
assert!(result.is_err(), "wrong authenticator must not unlock");
}
#[test]
fn unlock_v3_with_wrong_passphrase_fails() {
let dir = tempdir().unwrap();
let mk = master_key_path(dir.path());
let mut auth = MockAuthenticator::new([0xCC; 32]);
let pass = mk_passphrase();
let _ = create_master_key_with_fido2(dir.path(), &mk, &pass, &mut auth).unwrap();
let bad_pass = Zeroizing::new("wrong-passphrase".to_string());
let result = unlock_master_key_with_fido2(&mk, &bad_pass, &mut auth);
match result {
Err(Error::CryptoFailure(msg)) => assert!(
msg.contains("decryption failed"),
"expected decrypt error, got {msg}"
),
other => panic!("expected CryptoFailure, got {other:?}"),
}
}
#[test]
fn open_with_fido2_creates_when_missing_and_unlocks_when_present() {
let dir = tempdir().unwrap();
let mut auth = MockAuthenticator::new([0xDE; 32]);
let pass = mk_passphrase();
let key1 =
open_master_key_with_passphrase_and_fido2(dir.path(), &pass, &mut auth).unwrap();
let key1_bytes = *key1.as_bytes();
let key2 =
open_master_key_with_passphrase_and_fido2(dir.path(), &pass, &mut auth).unwrap();
assert_eq!(*key2.as_bytes(), key1_bytes);
}
#[test]
fn enroll_then_unlock_preserves_master_key_bytes() {
let dir = tempdir().unwrap();
let mk = master_key_path(dir.path());
let pass = mk_passphrase();
let key_v1 = create_master_key_with(dir.path(), &mk, &pass).unwrap();
let bytes_v1 = *key_v1.as_bytes();
drop(key_v1);
let mut auth = MockAuthenticator::new([0x10; 32]);
let key_v1_again = open_master_key_with_passphrase(dir.path(), &pass).unwrap();
enroll_fido2_on_existing_master(&mk, &key_v1_again, &pass, &mut auth).unwrap();
drop(key_v1_again);
match fido2_status_at(&mk).unwrap() {
Fido2Status::Enrolled { .. } => {}
other => panic!("expected v3 after enroll, got {other:?}"),
}
let key_v3 = unlock_master_key_with_fido2(&mk, &pass, &mut auth).unwrap();
assert_eq!(
*key_v3.as_bytes(),
bytes_v1,
"enrollment must not rotate the master key bytes"
);
}
#[test]
fn enroll_then_disable_round_trips_back_to_v1() {
let dir = tempdir().unwrap();
let mk = master_key_path(dir.path());
let pass = mk_passphrase();
let key_v1 = create_master_key_with(dir.path(), &mk, &pass).unwrap();
let bytes_original = *key_v1.as_bytes();
drop(key_v1);
let mut auth = MockAuthenticator::new([0x20; 32]);
let key_for_enroll = open_master_key_with_passphrase(dir.path(), &pass).unwrap();
enroll_fido2_on_existing_master(&mk, &key_for_enroll, &pass, &mut auth).unwrap();
drop(key_for_enroll);
assert!(matches!(
fido2_status_at(&mk).unwrap(),
Fido2Status::Enrolled { .. }
));
let key_for_disable = unlock_master_key_with_fido2(&mk, &pass, &mut auth).unwrap();
disable_fido2_keep_master(&mk, &key_for_disable, &pass).unwrap();
drop(key_for_disable);
assert!(matches!(
fido2_status_at(&mk).unwrap(),
Fido2Status::NotEnrolled
));
let key_after = open_master_key_with_passphrase(dir.path(), &pass).unwrap();
assert_eq!(*key_after.as_bytes(), bytes_original);
}
#[test]
fn fido2_status_at_reports_no_vault_when_file_missing() {
let dir = tempdir().unwrap();
let mk = master_key_path(dir.path());
match fido2_status_at(&mk).unwrap() {
Fido2Status::NoVault => {}
other => panic!("expected NoVault, got {other:?}"),
}
}
#[test]
fn fido2_status_at_reports_not_enrolled_for_v1_vault() {
let dir = tempdir().unwrap();
let mk = master_key_path(dir.path());
let pass = mk_passphrase();
let _ = create_master_key_with(dir.path(), &mk, &pass).unwrap();
match fido2_status_at(&mk).unwrap() {
Fido2Status::NotEnrolled => {}
other => panic!("expected NotEnrolled, got {other:?}"),
}
}
#[test]
fn credential_id_hash_is_stable_64_hex_chars() {
let id = b"opaque-credential-bytes-of-some-length-32+bytes";
let h = credential_id_hash(id);
assert_eq!(h.len(), 64);
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(h, credential_id_hash(id));
let different = credential_id_hash(b"different-credential");
assert_ne!(h, different);
}
#[test]
fn enroll_disable_repeats_with_fresh_credential_each_cycle() {
let dir = tempdir().unwrap();
let mk = master_key_path(dir.path());
let pass = mk_passphrase();
let _ = create_master_key_with(dir.path(), &mk, &pass).unwrap();
let mut auth1 = MockAuthenticator::new([0x33; 32]);
let key = open_master_key_with_passphrase(dir.path(), &pass).unwrap();
enroll_fido2_on_existing_master(&mk, &key, &pass, &mut auth1).unwrap();
drop(key);
let cred1 = match fido2_status_at(&mk).unwrap() {
Fido2Status::Enrolled { credential_id } => credential_id,
_ => panic!("not enrolled after first enroll"),
};
let key = unlock_master_key_with_fido2(&mk, &pass, &mut auth1).unwrap();
disable_fido2_keep_master(&mk, &key, &pass).unwrap();
drop(key);
let mut auth2 = MockAuthenticator::new([0x44; 32]);
let key = open_master_key_with_passphrase(dir.path(), &pass).unwrap();
enroll_fido2_on_existing_master(&mk, &key, &pass, &mut auth2).unwrap();
let cred2 = match fido2_status_at(&mk).unwrap() {
Fido2Status::Enrolled { credential_id } => credential_id,
_ => panic!("not enrolled after second enroll"),
};
assert_ne!(
cred1, cred2,
"second enrollment with a different authenticator must yield a different credential id"
);
}
#[test]
fn unlock_v3_with_corrupted_envelope_fails_clean() {
let dir = tempdir().unwrap();
let mk = master_key_path(dir.path());
let pass = mk_passphrase();
let mut auth = MockAuthenticator::new([0x55; 32]);
let _ = create_master_key_with_fido2(dir.path(), &mk, &pass, &mut auth).unwrap();
let raw = std::fs::read(&mk).unwrap();
let truncated = &raw[..raw.len() / 2];
std::fs::write(&mk, truncated).unwrap();
let result = unlock_master_key_with_fido2(&mk, &pass, &mut auth);
assert!(result.is_err(), "truncated v3 envelope must fail to unlock");
}
#[test]
fn fido2_path_does_not_disturb_legacy_v1_vault() {
let dir = tempdir().unwrap();
let mk = master_key_path(dir.path());
let pass = mk_passphrase();
let key1 = create_master_key_with(dir.path(), &mk, &pass).unwrap();
let key1_bytes = *key1.as_bytes();
drop(key1);
let mut auth = MockAuthenticator::new([0xEF; 32]);
let key2 =
unlock_master_key_with_fido2(&mk, &pass, &mut auth).expect("v1 vault must still open");
assert_eq!(*key2.as_bytes(), key1_bytes);
}
}
#[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"
);
}
}