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) {
let mut any_succeeded = false;
#[cfg(target_os = "linux")]
{
if self.try_memfd_secret() {
return;
}
}
#[cfg(unix)]
{
let rc =
unsafe { libc::mlock(self.bytes.as_ptr().cast::<libc::c_void>(), MASTER_KEY_LEN) };
if rc == 0 {
any_succeeded = true;
}
}
#[cfg(target_os = "linux")]
{
crate::guard::mark_dontdump(self.bytes.as_ptr(), MASTER_KEY_LEN);
}
#[cfg(windows)]
{
let ok = unsafe {
use windows_sys::Win32::System::Memory::VirtualLock;
VirtualLock(self.bytes.as_ptr() as *mut std::ffi::c_void, MASTER_KEY_LEN)
} != 0;
if ok {
any_succeeded = true;
}
}
if !any_succeeded {
announce_memory_protection_unavailable();
}
}
#[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);
}
}
}
fn announce_memory_protection_unavailable() {
use std::sync::atomic::{AtomicBool, Ordering};
static ANNOUNCED: AtomicBool = AtomicBool::new(false);
if ANNOUNCED.swap(true, Ordering::AcqRel) {
return;
}
let _ = crate::guard::emit_signal_inline(
crate::guard::Signal::new(
crate::guard::SignalId::new("memory.protection.unavailable"),
crate::guard::Category::DiskPermissions,
crate::guard::Severity::Warn,
"master-key memory is not protected",
"all platform memory-protection mechanisms (memfd_secret, mlock, \
VirtualLock) failed; the master key is in regular pageable \
memory and may be readable from /proc/<pid>/mem or swapped to \
disk. Likely cause: ulimit -l 0 (no CAP_IPC_LOCK), an SELinux \
policy that denies mlock, or a Windows page-quota cap.",
"raise the per-process locked-memory limit (e.g. `ulimit -l \
unlimited` or `setcap cap_ipc_lock+ep $(which envseal)`); on \
Windows, run as a user with `SeLockMemoryPrivilege`",
),
&crate::security_config::load_system_defaults(),
);
}
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 {
crate::test_backdoors::assert_test_backdoor_safe();
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);
if !mk_path.exists() {
return create_master_key(root, &mk_path);
}
match std::fs::read(&mk_path) {
Err(e) => Err(Error::StorageIo(e)),
Ok(raw) => {
if crate::vault::hardware::parse_v2(&raw).is_ok() {
return unlock_master_key(&mk_path);
}
let min_v1_len = ARGON2_SALT_LEN + NONCE_LEN;
if raw.len() >= min_v1_len {
return unlock_master_key(&mk_path);
}
let _ = crate::audit::log_required_at(
root,
&crate::audit::AuditEvent::SignalRecorded {
tier: "Lockdown".to_string(),
classification: format!(
"critical [vault.master_key.corrupt] {} bytes (need >= {min_v1_len}); \
refusing to auto-delete",
raw.len()
),
},
);
Err(Error::CryptoFailure(format!(
"master.key at {} is too short to be valid ({} bytes; need >= {min_v1_len}). \
envseal refuses to silently delete it — a partial write or corrupted backup \
could otherwise cause every stored secret to be lost without warning. \
To start fresh, explicitly remove the file yourself \
(`rm {}/master.key`), or restore the file from a backup. \
If you intend to wipe the vault, use `envseal emergency-revoke`.",
mk_path.display(),
raw.len(),
root.display(),
)))
}
}
}
pub fn open_master_key_with_passphrase(
root: &Path,
passphrase: &Zeroizing<String>,
) -> Result<MasterKey, Error> {
let mk_path = master_key_path(root);
if !mk_path.exists() {
return create_master_key_with(root, &mk_path, passphrase);
}
match std::fs::read(&mk_path) {
Err(e) => Err(Error::StorageIo(e)),
Ok(raw) => {
if crate::vault::hardware::parse_v2(&raw).is_ok() {
return unlock_master_key_with(&mk_path, passphrase);
}
let min_v1_len = ARGON2_SALT_LEN + NONCE_LEN;
if raw.len() >= min_v1_len {
return unlock_master_key_with(&mk_path, passphrase);
}
let _ = crate::audit::log_required_at(
root,
&crate::audit::AuditEvent::SignalRecorded {
tier: "Lockdown".to_string(),
classification: format!(
"critical [vault.master_key.corrupt] {} bytes (need >= {min_v1_len}); \
refusing to auto-delete",
raw.len()
),
},
);
Err(Error::CryptoFailure(format!(
"master.key at {} is too short to be valid ({} bytes; need >= {min_v1_len}). \
envseal refuses to silently delete it — restore from backup, or explicitly \
remove the file yourself if you intend to start over.",
mk_path.display(),
raw.len(),
)))
}
}
}
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 path_looks_like_test = std::env::current_exe()
.ok()
.and_then(|p| {
p.to_str()
.map(|s| s.contains("/target/") || s.contains("\\target\\"))
})
.unwrap_or(false);
let in_test_env = cfg!(test)
|| path_looks_like_test
|| std::env::var("CARGO_TARGET_TMPDIR").is_ok()
|| std::env::var("CARGO_FUZZ_TARGET").is_ok()
|| std::env::var("ENVSEAL_TEST_BACKDOORS_OK").is_ok();
if matches!(backend, hardware::Backend::None)
&& std::env::var("ENVSEAL_ACCEPT_NO_HARDWARE_SEAL").is_err()
&& !in_test_env
{
let _ = crate::audit::log_required_at(
mk_path.parent().unwrap_or(std::path::Path::new(".")),
&crate::audit::AuditEvent::SignalRecorded {
tier: "Hardened".to_string(),
classification: "critical [vault.no_hardware_seal] refused master.key write — \
no DPAPI / Secure Enclave / TPM 2.0 available"
.to_string(),
},
);
return Err(Error::HardwareSealFailed(format!(
"no hardware-sealing backend is available on this machine \
(no DPAPI / Secure Enclave / TPM 2.0). envseal refuses to \
write {} as a passphrase-only vault by default — that downgrade \
would silently weaken the on-disk threat model. \n\
\n\
To proceed anyway (e.g. on a CI runner or a VM without a vTPM), \
set ENVSEAL_ACCEPT_NO_HARDWARE_SEAL=1 in your environment and \
rerun the command. The operator who sets that flag is \
acknowledging that a master.key on this machine is protected \
only by the Argon2id-wrapped passphrase, not also by a \
device-bound seal.",
mk_path.display(),
)));
}
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 exists = mk_path.exists();
if exists {
match std::fs::read(&mk_path) {
Err(e) => return Err(Error::StorageIo(e)),
Ok(raw) => {
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 _ = crate::audit::log_required_at(
root,
&crate::audit::AuditEvent::SignalRecorded {
tier: "Lockdown".to_string(),
classification: format!(
"critical [vault.master_key.corrupt] {} bytes \
(need >= {min_v1_len}); refusing to auto-delete \
(FIDO2 path)",
raw.len()
),
},
);
return Err(Error::CryptoFailure(format!(
"master.key at {} is too short to be valid ({} bytes; need \
>= {min_v1_len}). envseal refuses to silently delete it; \
restore from backup or explicitly remove the file before \
retrying.",
mk_path.display(),
raw.len(),
)));
}
}
}
}
}
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;
use std::fmt::Write as _;
let digest = sha2::Sha256::digest(credential_id);
let mut out = String::with_capacity(digest.len() * 2);
for b in digest {
let _ = write!(out, "{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 Fido2Status::Enrolled {
credential_id: cred1,
} = fido2_status_at(&mk).unwrap()
else {
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 Fido2Status::Enrolled {
credential_id: cred2,
} = fido2_status_at(&mk).unwrap()
else {
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"
);
}
}