use std::path::{Path, PathBuf};
use crate::error::Error;
use crate::vault::Vault;
#[derive(Debug, Clone)]
pub struct CtfSetupResult {
pub hash: String,
pub hash_path: PathBuf,
}
#[derive(Debug, Clone)]
pub enum CtfVerifyResult {
Correct {
flag: String,
hash: String,
},
Incorrect {
submitted_hash: String,
expected_hash: String,
},
}
#[derive(Debug, Clone)]
pub enum CtfStatus {
Active {
hash: String,
tier: String,
},
Partial,
Inactive,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DoctorCheck {
Ok(String),
Fail(String),
NotApplicable(String),
}
impl DoctorCheck {
#[must_use]
pub fn is_fail(&self) -> bool {
matches!(self, Self::Fail(_))
}
}
#[derive(Debug, Clone)]
pub struct CtfDoctorReport {
pub backend: DoctorCheck,
pub ptrace_scope: DoctorCheck,
pub windows_dacl: DoctorCheck,
pub macos_hardened_runtime: DoctorCheck,
pub no_ld_preload: DoctorCheck,
pub memfd_secret: DoctorCheck,
pub challenge_active: DoctorCheck,
pub audit_chain_intact: DoctorCheck,
pub audit_encryption: DoctorCheck,
pub fido2_enrolled: DoctorCheck,
pub is_ready: bool,
}
impl CtfDoctorReport {
#[must_use]
pub fn checks(&self) -> [(&'static str, &DoctorCheck); 10] {
[
("hardware-sealed master key", &self.backend),
("Linux ptrace_scope", &self.ptrace_scope),
("Windows process DACL", &self.windows_dacl),
("macOS hardened runtime", &self.macos_hardened_runtime),
("LD_PRELOAD / LD_AUDIT", &self.no_ld_preload),
("Linux memfd_secret", &self.memfd_secret),
("CTF challenge active", &self.challenge_active),
("audit-log chain intact", &self.audit_chain_intact),
("audit-event encryption", &self.audit_encryption),
("FIDO2 third-factor enrolled", &self.fido2_enrolled),
]
}
}
#[allow(clippy::too_many_lines)]
pub fn ctf_doctor(root: &Path) -> CtfDoctorReport {
crate::guard::harden_process();
let backend = {
let b = crate::keychain::active_backend();
if b == crate::vault::hardware::Backend::None {
DoctorCheck::Fail(format!(
"no hardware backend ({}) — master.key is decryptable on any \
machine that copies it. Install TPM 2.0 + tpm2-tools (Linux), \
use the default DPAPI (Windows), or sign with Secure Enclave \
entitlement (macOS).",
b.name()
))
} else {
DoctorCheck::Ok(format!("backend = {}", b.name()))
}
};
let ptrace_scope = if cfg!(target_os = "linux") {
match crate::guard::check_ptrace_scope() {
None => DoctorCheck::Ok("ptrace_scope ≥ 1 (same-UID ptrace blocked)".to_string()),
Some(detail) => DoctorCheck::Fail(detail),
}
} else {
DoctorCheck::NotApplicable("ptrace_scope is a Linux concept".to_string())
};
let windows_dacl = if cfg!(windows) {
if crate::guard::vm_read_access_blocked() {
DoctorCheck::Ok(
"PROCESS_VM_READ denied — same-user ReadProcessMemory is blocked".to_string(),
)
} else {
DoctorCheck::Fail(
"process DACL still grants PROCESS_VM_READ to the owner — \
a same-user attacker can ReadProcessMemory the unlocked \
master key. harden_process() may have failed at startup."
.to_string(),
)
}
} else {
DoctorCheck::NotApplicable("Windows DACL hardening is Windows-only".to_string())
};
#[cfg(target_os = "macos")]
let macos_hardened_runtime = match crate::guard::check_macos_hardened_runtime() {
Ok(()) => {
DoctorCheck::Ok("hardened runtime engaged — task_for_pid denied by default".to_string())
}
Err(detail) => DoctorCheck::Fail(detail),
};
#[cfg(not(target_os = "macos"))]
let macos_hardened_runtime =
DoctorCheck::NotApplicable("hardened runtime is a macOS concept".to_string());
let no_ld_preload = match crate::guard::check_self_preload() {
Ok(()) => DoctorCheck::Ok("no LD_PRELOAD / LD_AUDIT in startup environ".to_string()),
Err(e) => DoctorCheck::Fail(e.to_string()),
};
let memfd_secret = if cfg!(target_os = "linux") {
if crate::guard::test_memfd_secret() {
DoctorCheck::Ok("memfd_secret available — master key in unmapped pages".to_string())
} else {
DoctorCheck::Fail(
"memfd_secret unavailable (kernel < 5.14 or CONFIG_SECRETMEM=n) — \
master key falls back to mlock-only protection."
.to_string(),
)
}
} else {
DoctorCheck::NotApplicable("memfd_secret is a Linux 5.14+ feature".to_string())
};
let audit_chain_intact = check_audit_chain_intact(root);
let audit_encryption = check_audit_encryption_active(root);
let fido2_enrolled = check_fido2_enrolled(root);
let challenge_active = match ctf_status(root) {
Ok(CtfStatus::Active { hash, tier }) => {
DoctorCheck::Ok(format!("challenge active (tier={tier}, hash={hash})"))
}
Ok(CtfStatus::Partial) => DoctorCheck::Fail(
"ctf-flag exists but ctf-hash.txt is missing — verifier cannot \
check submissions. Run `envseal ctf reset` and `envseal ctf start`."
.to_string(),
),
Ok(CtfStatus::Inactive) => {
DoctorCheck::Fail("no challenge set up — run `envseal ctf start` first.".to_string())
}
Err(e) => DoctorCheck::Fail(format!("status check failed: {e}")),
};
let is_ready = ![
&backend,
&ptrace_scope,
&windows_dacl,
&macos_hardened_runtime,
&no_ld_preload,
&memfd_secret,
&audit_chain_intact,
&audit_encryption,
&fido2_enrolled,
]
.iter()
.any(|c| c.is_fail());
CtfDoctorReport {
backend,
ptrace_scope,
windows_dacl,
macos_hardened_runtime,
no_ld_preload,
memfd_secret,
challenge_active,
audit_chain_intact,
audit_encryption,
fido2_enrolled,
is_ready,
}
}
fn check_audit_chain_intact(root: &Path) -> DoctorCheck {
let log_path = root.join("audit.log");
if !log_path.exists() {
return DoctorCheck::Fail(
"audit.log missing — without a chained record of attempts, the CTF \
integrity claim is unverifiable."
.to_string(),
);
}
let rotated = std::fs::read_dir(root).ok().is_some_and(|rd| {
rd.filter_map(Result::ok).any(|e| {
e.file_name()
.to_string_lossy()
.starts_with("audit.log.corrupted-")
})
});
let parsed = crate::audit::read_last_parsed_at(root, usize::MAX);
if parsed.dropped_lines > 0 {
return DoctorCheck::Fail(format!(
"audit.log has {} unparseable line(s) — chain verification cannot \
complete, run `envseal audit verify` for details",
parsed.dropped_lines
));
}
if rotated {
return DoctorCheck::Fail(
"audit.log.corrupted-* sibling exists — a previous tamper or \
partial-write triggered chain rotation. Inspect the rotated file \
before claiming CTF integrity."
.to_string(),
);
}
DoctorCheck::Ok(format!(
"chain verifies, {} entries on the record",
parsed.entries.len()
))
}
fn check_audit_encryption_active(root: &Path) -> DoctorCheck {
let key_path = root.join("audit.key");
let log_path = root.join("audit.log");
if !log_path.exists() {
return DoctorCheck::NotApplicable(
"audit.log not yet created — first append will generate audit.key".to_string(),
);
}
if !key_path.exists() {
return DoctorCheck::Fail(
"audit.log exists but audit.key is missing — events on disk are \
plaintext (legacy or post-deletion). Run `envseal audit migrate` \
once it ships, or rotate the audit log to start a fresh \
encrypted chain."
.to_string(),
);
}
DoctorCheck::Ok("per-vault audit cipher active (0.3.13+)".to_string())
}
fn check_fido2_enrolled(root: &Path) -> DoctorCheck {
let mk_path = root.join("master.key");
if !mk_path.exists() {
return DoctorCheck::Fail(
"no master.key found — vault is uninitialized, CTF cannot be defended".to_string(),
);
}
#[cfg(feature = "fido2")]
{
match crate::keychain::fido2_unlock::fido2_status_at(&mk_path) {
Ok(crate::keychain::fido2_unlock::Fido2Status::Enrolled { credential_id }) => {
use sha2::Digest;
let hash = format!("{:x}", sha2::Sha256::digest(&credential_id));
DoctorCheck::Ok(format!(
"FIDO2 v3 envelope (cred {})",
&hash[..hash.len().min(8)]
))
}
Ok(_) => DoctorCheck::Fail(
"vault uses passphrase-only wrapping — captured-passphrase + \
file-exfil attack still wins. Run `envseal fido2 enroll`."
.to_string(),
),
Err(e) => DoctorCheck::Fail(format!("FIDO2 status check failed: {e}")),
}
}
#[cfg(not(feature = "fido2"))]
{
let _ = mk_path;
DoctorCheck::NotApplicable(
"this build was compiled without the `fido2` feature".to_string(),
)
}
}
pub fn ctf_doctor_default() -> Result<CtfDoctorReport, Error> {
let root = super::vault_root()?;
Ok(ctf_doctor(&root))
}
pub fn ctf_status(root: &Path) -> Result<CtfStatus, Error> {
let hash_path = root.join("ctf-hash.txt");
crate::guard::verify_not_symlink(&hash_path)?;
let has_flag = super::secret_exists(root, "ctf-flag")?;
if has_flag && hash_path.exists() {
let hash = {
use std::io::Read;
let f = crate::file::atomic_open::open_read_no_traverse(&hash_path)?;
let mut s = String::new();
f.take(64 * 1024).read_to_string(&mut s)?;
s.trim().to_string()
};
let sealed_path = root.join("security.sealed");
let legacy_path = root.join("security.toml");
let tier = if legacy_path.exists() {
let content = {
use std::io::Read;
match crate::file::atomic_open::open_read_no_traverse(&legacy_path) {
Ok(f) => {
let mut s = String::new();
let _ = f.take(1024 * 1024).read_to_string(&mut s);
s
}
Err(_) => String::new(),
}
};
if content.contains("Lockdown") {
"Lockdown".to_string()
} else if content.contains("Hardened") {
"Hardened".to_string()
} else {
"Standard".to_string()
}
} else if sealed_path.exists() {
"(sealed — unlock with `envseal security show` to view)".to_string()
} else {
"Standard (default — no config saved)".to_string()
};
Ok(CtfStatus::Active { hash, tier })
} else if has_flag {
Ok(CtfStatus::Partial)
} else {
Ok(CtfStatus::Inactive)
}
}
pub fn ctf_verify(root: &Path, submitted: &str) -> Result<CtfVerifyResult, Error> {
use sha2::Digest;
const MAX_FLAG_LEN: usize = 1024;
if submitted.len() > MAX_FLAG_LEN {
return Err(Error::CryptoFailure(format!(
"submitted flag exceeds maximum length of {MAX_FLAG_LEN} bytes"
)));
}
let hash_path = root.join("ctf-hash.txt");
crate::guard::verify_not_symlink(&hash_path)?;
if !hash_path.exists() {
return Err(Error::CryptoFailure(
"no CTF challenge is currently active. Run `envseal ctf start` to set one up."
.to_string(),
));
}
let expected = {
use std::io::Read;
let f = crate::file::atomic_open::open_read_no_traverse(&hash_path)?;
let mut s = String::new();
f.take(64 * 1024).read_to_string(&mut s)?;
s.trim().to_string()
};
let submitted_hash = format!("{:x}", sha2::Sha256::digest(submitted.as_bytes()));
let correct = crate::guard::constant_time_eq(submitted_hash.as_bytes(), expected.as_bytes());
let _ = crate::audit::log_required_at(
root,
&crate::audit::AuditEvent::SignalRecorded {
tier: "Lockdown".to_string(),
classification: format!(
"info [ctf.verify.{verdict}] submitted_hash={submitted_hash}",
verdict = if correct { "correct" } else { "incorrect" }
),
},
);
std::thread::sleep(VERIFY_RATE_LIMIT);
if correct {
Ok(CtfVerifyResult::Correct {
flag: submitted.to_string(),
hash: submitted_hash,
})
} else {
Ok(CtfVerifyResult::Incorrect {
submitted_hash,
expected_hash: expected,
})
}
}
const VERIFY_RATE_LIMIT: std::time::Duration = std::time::Duration::from_millis(250);
pub fn ctf_status_default() -> Result<CtfStatus, Error> {
let root = super::vault_root()?;
ctf_status(&root)
}
pub fn ctf_verify_default(submitted: &str) -> Result<CtfVerifyResult, Error> {
let root = super::vault_root()?;
ctf_verify(&root, submitted)
}
pub fn ctf_setup_default() -> Result<CtfSetupResult, Error> {
let vault = Vault::open_default()?;
if vault
.list()?
.iter()
.any(|n| n.eq_ignore_ascii_case("ctf-flag"))
{
return Err(Error::CryptoFailure(
"a CTF challenge already exists. Run `envseal ctf reset` first.".to_string(),
));
}
ctf_setup(&vault)
}
pub fn ctf_reset_default() -> Result<bool, Error> {
let root = super::vault_root()?;
let hash_path = root.join("ctf-hash.txt");
let flag_path = root.join("vault").join("ctf-flag.seal");
let prev_tier_path = root.join("ctf-prev-tier.txt");
crate::guard::verify_not_symlink(&hash_path)?;
crate::guard::verify_not_symlink(&flag_path)?;
crate::guard::verify_not_symlink(&prev_tier_path)?;
let mut removed = false;
if prev_tier_path.exists() {
let content = {
use std::io::Read;
crate::file::atomic_open::open_read_no_traverse(&prev_tier_path)
.and_then(|f| {
let mut s = String::new();
f.take(1024).read_to_string(&mut s)?;
Ok(s)
})
.unwrap_or_default()
};
let prev = content.trim();
let restore_to = match prev {
"Standard" => Some(crate::security_config::SecurityTier::Standard),
"Hardened" => Some(crate::security_config::SecurityTier::Hardened),
"Lockdown" => Some(crate::security_config::SecurityTier::Lockdown),
_ => None,
};
if let Some(tier) = restore_to {
if let Ok(vault) = Vault::open_default() {
if let Ok(mut config) =
crate::security_config::load_config(vault.root(), vault.master_key_bytes())
{
config.apply_preset(tier);
let _ = crate::security_config::save_config(
vault.root(),
&config,
vault.master_key_bytes(),
);
}
}
}
let _ = std::fs::remove_file(&prev_tier_path);
removed = true;
}
if hash_path.exists() {
std::fs::remove_file(&hash_path).map_err(Error::StorageIo)?;
removed = true;
}
if flag_path.exists() {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
if let Ok(mut file) = std::fs::OpenOptions::new()
.read(true)
.write(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(&flag_path)
{
if let Ok(meta) = file.metadata() {
let len = usize::try_from(meta.len()).unwrap_or(0);
if len > 0 {
use std::io::{Seek, SeekFrom, Write};
let _ = file.seek(SeekFrom::Start(0));
let buf = [0u8; 8192];
let mut remaining = len;
while remaining > 0 {
let n = remaining.min(buf.len());
let _ = file.write_all(&buf[..n]);
remaining -= n;
}
let _ = file.sync_all();
}
}
}
}
#[cfg(not(unix))]
{
if let Ok(mut file) = crate::file::atomic_open::open_write_no_traverse(&flag_path) {
if let Ok(meta) = file.metadata() {
let len = usize::try_from(meta.len()).unwrap_or(0);
if len > 0 {
use std::io::{Seek, SeekFrom, Write};
let _ = file.seek(SeekFrom::Start(0));
let buf = [0u8; 8192];
let mut remaining = len;
while remaining > 0 {
let n = remaining.min(buf.len());
let _ = file.write_all(&buf[..n]);
remaining -= n;
}
let _ = file.sync_all();
}
}
}
}
std::fs::remove_file(&flag_path).map_err(Error::StorageIo)?;
removed = true;
}
Ok(removed)
}
pub fn ctf_setup(vault: &Vault) -> Result<CtfSetupResult, Error> {
use rand::RngCore;
use sha2::Digest;
use std::fmt::Write;
let backend = crate::keychain::active_backend();
if backend == crate::vault::hardware::Backend::None {
return Err(Error::CryptoFailure(format!(
"ctf start refused: vault is using tier 3 (passphrase-only) sealing — \
no hardware backend ({}). The CTF integrity claim depends on \
the master key being non-decryptable on any other machine. \
Install the platform hardware backend (TPM 2.0 + tpm2-tools on \
Linux, Secure Enclave on macOS, DPAPI is automatic on Windows) \
and re-run.",
backend.name()
)));
}
if let Some(detail) = crate::guard::check_ptrace_scope() {
return Err(Error::CryptoFailure(format!(
"ctf start refused: ptrace scope is permissive — {detail}. \
A permissive ptrace lets any same-UID process attach to envseal \
and read the unlocked master key from memory. Run \
`sudo sysctl kernel.yama.ptrace_scope=1` (or higher), confirm \
with `cat /proc/sys/kernel/yama/ptrace_scope`, then re-run."
)));
}
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
let mut hex = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(&mut hex, "{b:02x}");
}
let flag = {
let f = format!("ENVSEAL_CTF{{{hex}}}");
zeroize::Zeroizing::new(f)
};
zeroize::Zeroize::zeroize(&mut hex);
let hash = format!("{:x}", sha2::Sha256::digest(flag.as_bytes()));
vault
.store("ctf-flag", flag.as_bytes(), false)
.map_err(|e| match e {
Error::SecretAlreadyExists(_) => Error::CryptoFailure(
"a CTF challenge already exists. Run `envseal ctf reset` first.".to_string(),
),
other => other,
})?;
let mut config = crate::security_config::load_config(vault.root(), vault.master_key_bytes())
.unwrap_or_default();
let old_tier = format!("{:?}", config.tier);
let prev_tier_path = vault.root().join("ctf-prev-tier.txt");
crate::guard::verify_not_symlink(&prev_tier_path)?;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let prev_tmp =
prev_tier_path.with_extension(format!("tmp.{pid}.{nanos}", pid = std::process::id()));
std::fs::write(&prev_tmp, format!("{old_tier}\n")).map_err(Error::StorageIo)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&prev_tmp, std::fs::Permissions::from_mode(0o600))
.map_err(Error::StorageIo)?;
}
std::fs::rename(&prev_tmp, &prev_tier_path).map_err(Error::StorageIo)?;
config.apply_preset(crate::security_config::SecurityTier::Lockdown);
crate::security_config::save_config(vault.root(), &config, vault.master_key_bytes())?;
crate::audit::log_required_at(
vault.root(),
&crate::audit::AuditEvent::TierChanged {
from: old_tier,
to: format!("{:?}", config.tier),
},
)?;
crate::audit::log_required_at(
vault.root(),
&crate::audit::AuditEvent::SecretStored {
name: "ctf-flag".to_string(),
},
)?;
let hash_path = vault.root().join("ctf-hash.txt");
crate::guard::verify_not_symlink(&hash_path)?;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let hash_tmp = hash_path.with_extension(format!("tmp.{pid}.{nanos}", pid = std::process::id()));
std::fs::write(&hash_tmp, format!("{hash}\n")).map_err(Error::StorageIo)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&hash_tmp, std::fs::Permissions::from_mode(0o600))
.map_err(Error::StorageIo)?;
}
std::fs::rename(&hash_tmp, &hash_path).map_err(Error::StorageIo)?;
Ok(CtfSetupResult { hash, hash_path })
}