envseal 0.3.8

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Device-bound key sealing — the layer that makes the master key
//! unreadable outside this physical device, even by root.
//!
//! # Threat model
//!
//! The passphrase-wrapped master key on disk is, by itself, only as strong
//! as the passphrase. An attacker with `root` can read `master.key`, copy
//! it to another machine, and offline-bruteforce the passphrase at GPU
//! speed.
//!
//! Hardware sealing closes that hole: even with `master.key` and the
//! correct passphrase, decryption requires a secret that is bound to the
//! physical device — a TPM-rooted key, the macOS Secure Enclave, or
//! Windows Data Protection. The blob simply will not decrypt anywhere
//! else.
//!
//! # Backends
//!
//! - [`Backend::Dpapi`] — Windows. `CryptProtectData` with user scope.
//!   On Windows 10+ with TPM, DPAPI keys are TPM-rooted automatically.
//! - [`Backend::SecureEnclave`] — macOS. ECC keypair generated in SEP;
//!   private key never leaves the chip.
//! - [`Backend::Tpm2`] — Linux. Sealed object on the TPM, bound to the
//!   physical device.
//! - [`Backend::None`] — No hardware backing available. Passphrase-only.
//!   Reported by `envseal doctor` as a degraded protection tier.
//!
//! # On-disk format
//!
//! ```text
//! [4 bytes: magic = "ES2\0"]
//! [1 byte: backend_id]
//! [4 bytes: u32 LE — length of sealed envelope]
//! [N bytes: sealed envelope (opaque, contains the passphrase-wrapped
//!           inner blob: argon2_salt || nonce || ciphertext+tag)]
//! ```
//!
//! Files that do not start with the magic are rejected as corrupted —
//! v2 is the only supported on-disk format.

#[cfg(windows)]
pub mod dpapi;
#[cfg(target_os = "macos")]
pub mod secure_enclave;
#[cfg(target_os = "linux")]
pub mod tpm2;

use crate::error::Error;

/// 4-byte magic identifying a hardware-sealed master.key file.
pub const V2_MAGIC: [u8; 4] = *b"ES2\0";

/// Stable, on-disk identifier for each backend. Persisted in the v2
/// envelope so we can refuse to load a blob sealed by a different
/// backend (e.g. swapped OS) instead of silently corrupting it.
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backend {
    /// No device-bound key — passphrase-only protection.
    None = 0,
    /// Windows Data Protection API (user scope; TPM-rooted on Windows
    /// 10+ devices that have a TPM).
    Dpapi = 1,
    /// macOS Secure Enclave Processor — keypair lives entirely inside
    /// the dedicated security chip.
    SecureEnclave = 2,
    /// Linux TPM 2.0 sealed object bound to the physical TPM device.
    Tpm2 = 3,
}

impl Backend {
    /// Decode a backend id from its single-byte on-disk representation.
    pub fn from_id(id: u8) -> Option<Self> {
        match id {
            0 => Some(Self::None),
            1 => Some(Self::Dpapi),
            2 => Some(Self::SecureEnclave),
            3 => Some(Self::Tpm2),
            _ => None,
        }
    }

    /// Stable single-byte identifier persisted in the v2 envelope.
    pub fn id(&self) -> u8 {
        *self as u8
    }

    /// Human-readable name for `envseal doctor` output.
    pub fn name(&self) -> &'static str {
        match self {
            Self::None => "passphrase-only (no hardware seal)",
            Self::Dpapi => "Windows DPAPI",
            Self::SecureEnclave => "macOS Secure Enclave",
            Self::Tpm2 => "Linux TPM 2.0",
        }
    }

    /// Protection tier — for `doctor` and audit reporting.
    /// Tier 1 = true hardware seal (key never enters CPU RAM in plaintext).
    /// Tier 2 = OS-bound (key derived from device-specific OS secret).
    /// Tier 3 = passphrase-only (no device binding).
    pub fn tier(&self) -> u8 {
        match self {
            Self::SecureEnclave | Self::Tpm2 => 1,
            Self::Dpapi => 2,
            Self::None => 3,
        }
    }
}

/// Concrete keystore — one variant per platform backend, plus a no-op.
///
/// We use an enum rather than a `Box<dyn Trait>` so all dispatch is
/// statically known and there is zero runtime allocation on the unlock
/// hot path.
pub enum DeviceKeystore {
    /// No hardware backing available — seal/unseal are passthrough.
    None,
    /// Windows DPAPI backend.
    #[cfg(windows)]
    Dpapi(dpapi::DpapiKeystore),
    /// macOS Secure Enclave backend.
    #[cfg(target_os = "macos")]
    SecureEnclave(secure_enclave::SecureEnclaveKeystore),
    /// Linux TPM 2.0 backend (via tpm2-tools).
    #[cfg(target_os = "linux")]
    Tpm2(tpm2::Tpm2Keystore),
}

impl DeviceKeystore {
    /// Probe the system for the strongest available backend.
    ///
    /// Order of preference per platform:
    /// - **Windows**: DPAPI (always available on supported targets).
    /// - **macOS**: Secure Enclave; falls back to `None` on hardware
    ///   without an SEP (e.g. older Intel Macs without T2).
    /// - **Linux**: TPM 2.0 if `tpm2-tools` are installed and a TPM is
    ///   accessible; otherwise `None`.
    pub fn select() -> Self {
        #[cfg(windows)]
        {
            Self::Dpapi(dpapi::DpapiKeystore::new())
        }
        #[cfg(target_os = "macos")]
        {
            match secure_enclave::SecureEnclaveKeystore::try_new() {
                Some(ks) => Self::SecureEnclave(ks),
                None => Self::None,
            }
        }
        #[cfg(target_os = "linux")]
        {
            match tpm2::Tpm2Keystore::try_new() {
                Some(ks) => Self::Tpm2(ks),
                None => Self::None,
            }
        }
        #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))]
        {
            Self::None
        }
    }

    /// The backend variant currently in use by this keystore. Reported
    /// in `envseal doctor` and persisted in the v2 envelope so a vault
    /// file moved between machines fails to unseal with a precise error
    /// instead of silently returning gibberish.
    pub fn backend(&self) -> Backend {
        match self {
            Self::None => Backend::None,
            #[cfg(windows)]
            Self::Dpapi(_) => Backend::Dpapi,
            #[cfg(target_os = "macos")]
            Self::SecureEnclave(_) => Backend::SecureEnclave,
            #[cfg(target_os = "linux")]
            Self::Tpm2(_) => Backend::Tpm2,
        }
    }

    /// Wrap `plaintext` with this device's hardware key. The result
    /// is opaque and only this device, in this user session (where
    /// applicable), can unwrap it.
    pub fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>, Error> {
        match self {
            Self::None => Ok(plaintext.to_vec()),
            #[cfg(windows)]
            Self::Dpapi(ks) => ks.seal(plaintext),
            #[cfg(target_os = "macos")]
            Self::SecureEnclave(ks) => ks.seal(plaintext),
            #[cfg(target_os = "linux")]
            Self::Tpm2(ks) => ks.seal(plaintext),
        }
    }

    /// Unwrap a previously sealed blob. Fails if the blob originated
    /// from a different machine, a different user logon (DPAPI), or a
    /// revoked SEP/TPM key.
    pub fn unseal(&self, sealed: &[u8]) -> Result<Vec<u8>, Error> {
        match self {
            Self::None => Ok(sealed.to_vec()),
            #[cfg(windows)]
            Self::Dpapi(ks) => ks.unseal(sealed),
            #[cfg(target_os = "macos")]
            Self::SecureEnclave(ks) => ks.unseal(sealed),
            #[cfg(target_os = "linux")]
            Self::Tpm2(ks) => ks.unseal(sealed),
        }
    }
}

/// Build a v2 master.key envelope around the passphrase-wrapped inner blob.
///
/// Layout: `magic || backend_id || u32_le(len(sealed)) || sealed`
///
/// Sealed payloads larger than `u32::MAX` (≈ 4 GiB) are clamped: the
/// master key plus its passphrase wrapping is at most a few hundred
/// bytes in practice, so the saturation is a defensive measure for
/// malformed callers rather than a real-world condition.
pub fn pack_v2(backend: Backend, sealed: &[u8]) -> Vec<u8> {
    let mut out = Vec::with_capacity(4 + 1 + 4 + sealed.len());
    out.extend_from_slice(&V2_MAGIC);
    out.push(backend.id());
    let len_le = u32::try_from(sealed.len()).unwrap_or(u32::MAX);
    out.extend_from_slice(&len_le.to_le_bytes());
    out.extend_from_slice(sealed);
    out
}

/// Parsed v2 envelope — backend identifier and the inner sealed bytes
/// that belong to that backend's [`DeviceKeystore::unseal`].
#[derive(Debug)]
pub struct V2Envelope<'a> {
    /// Which backend produced the inner sealed bytes.
    pub backend: Backend,
    /// Opaque sealed payload — feed to the corresponding backend's `unseal`.
    pub sealed: &'a [u8],
}

/// Parse a master.key file as v2. v2 is the only on-disk format —
/// any blob that does not start with [`V2_MAGIC`], or whose header
/// is malformed, is rejected as corrupted.
pub fn parse_v2(buf: &[u8]) -> Result<V2Envelope<'_>, Error> {
    if buf.len() < 4 || buf[..4] != V2_MAGIC {
        return Err(Error::CryptoFailure(
            "master.key missing v2 magic — file is corrupted or from an \
             incompatible envseal build"
                .to_string(),
        ));
    }
    if buf.len() < 9 {
        return Err(Error::CryptoFailure(
            "master.key v2 header truncated".to_string(),
        ));
    }
    let backend = Backend::from_id(buf[4]).ok_or_else(|| {
        Error::CryptoFailure(format!("master.key v2 unknown backend id {}", buf[4]))
    })?;
    let mut len_bytes = [0u8; 4];
    len_bytes.copy_from_slice(&buf[5..9]);
    let sealed_len = u32::from_le_bytes(len_bytes) as usize;
    let body = &buf[9..];
    if body.len() != sealed_len {
        return Err(Error::CryptoFailure(format!(
            "master.key v2 length mismatch: header says {sealed_len}, body has {}",
            body.len()
        )));
    }
    Ok(V2Envelope {
        backend,
        sealed: body,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pack_then_parse_roundtrips() {
        let inner = b"hello world";
        let packed = pack_v2(Backend::Dpapi, inner);
        let env = parse_v2(&packed).expect("v2 detected");
        assert_eq!(env.backend, Backend::Dpapi);
        assert_eq!(env.sealed, inner);
    }

    #[test]
    fn missing_magic_rejected_as_corruption() {
        // Anything not starting with V2_MAGIC is treated as a corrupted
        // master.key — no implicit "v1 fallback" pretending an unknown
        // file format is acceptable.
        let bad = vec![0u8; 76];
        assert!(parse_v2(&bad).is_err());
    }

    #[test]
    fn truncated_v2_header_rejected() {
        let mut bad = V2_MAGIC.to_vec();
        bad.push(Backend::Dpapi.id());
        // Missing the 4-byte length field
        assert!(parse_v2(&bad).is_err());
    }

    #[test]
    fn unknown_backend_id_rejected() {
        let mut bad = V2_MAGIC.to_vec();
        bad.push(99); // not a valid backend id
        bad.extend_from_slice(&0u32.to_le_bytes());
        assert!(parse_v2(&bad).is_err());
    }

    #[test]
    fn length_mismatch_rejected() {
        let mut bad = V2_MAGIC.to_vec();
        bad.push(Backend::None.id());
        bad.extend_from_slice(&100u32.to_le_bytes()); // claims 100 bytes
        bad.extend_from_slice(&[0u8; 5]); // but only 5 follow
        assert!(parse_v2(&bad).is_err());
    }

    #[test]
    fn none_backend_seal_is_passthrough() {
        let ks = DeviceKeystore::None;
        let p = b"plaintext";
        let s = ks.seal(p).unwrap();
        assert_eq!(&s, p);
        assert_eq!(ks.unseal(&s).unwrap(), p);
    }

    #[test]
    fn backend_tiers_are_well_defined() {
        assert_eq!(Backend::SecureEnclave.tier(), 1);
        assert_eq!(Backend::Tpm2.tier(), 1);
        assert_eq!(Backend::Dpapi.tier(), 2);
        assert_eq!(Backend::None.tier(), 3);
    }
}