envseal 0.3.8

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Windows Data Protection API backend.
//!
//! `CryptProtectData` encrypts a blob under a key that is bound to the
//! current user's logon credential. On Windows 10+ devices with a TPM,
//! that key is itself sealed by the TPM — so a copy of `master.key`
//! taken from the disk cannot be decrypted on any other machine, by
//! any other user, or even by the same user on a different physical
//! device.
//!
//! We pass a per-seal random `pOptionalEntropy` (16 bytes) so the
//! DPAPI ciphertext is unique even if two installations happened to
//! share a Windows user, and store it inline at the head of the sealed
//! envelope.
//!
//! # Sealed envelope
//!
//! ```text
//! [16 bytes: per-seal entropy] [N bytes: DPAPI ciphertext]
//! ```

use rand::RngCore;
use windows_sys::Win32::Foundation::LocalFree;
use windows_sys::Win32::Security::Cryptography::{
    CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
};

use crate::error::Error;

const ENTROPY_LEN: usize = 16;

/// Zero-sized handle for the Windows Data Protection API backend.
/// Stateless — every seal/unseal call goes straight to `CryptProtectData`
/// / `CryptUnprotectData` with a fresh per-seal entropy salt.
#[derive(Default)]
pub struct DpapiKeystore;

impl DpapiKeystore {
    /// Construct a DPAPI keystore handle. Cannot fail — DPAPI is
    /// universally available on supported Windows targets.
    #[must_use]
    pub fn new() -> Self {
        Self
    }

    /// Wrap `plaintext` so only this Windows user logon (on this
    /// machine) can recover it. The output is a self-contained
    /// envelope (per-seal entropy concatenated with the DPAPI blob).
    pub fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>, Error> {
        let mut entropy = [0u8; ENTROPY_LEN];
        rand::rngs::OsRng.fill_bytes(&mut entropy);

        let cipher = dpapi_protect(plaintext, &entropy)?;

        let mut out = Vec::with_capacity(ENTROPY_LEN + cipher.len());
        out.extend_from_slice(&entropy);
        out.extend_from_slice(&cipher);
        Ok(out)
    }

    /// Recover plaintext from a DPAPI envelope previously produced by
    /// [`Self::seal`]. Fails if the blob originated from a different
    /// user, a different machine, or has been corrupted.
    pub fn unseal(&self, sealed: &[u8]) -> Result<Vec<u8>, Error> {
        if sealed.len() < ENTROPY_LEN {
            return Err(Error::CryptoFailure(
                "DPAPI envelope shorter than entropy header".to_string(),
            ));
        }
        let (entropy, cipher) = sealed.split_at(ENTROPY_LEN);
        dpapi_unprotect(cipher, entropy)
    }
}

fn dpapi_protect(plaintext: &[u8], entropy: &[u8]) -> Result<Vec<u8>, Error> {
    let input = make_blob(plaintext);
    let entropy_blob = make_blob(entropy);
    let mut output = CRYPT_INTEGER_BLOB {
        cbData: 0,
        pbData: std::ptr::null_mut(),
    };

    let ok = unsafe {
        CryptProtectData(
            &input,
            std::ptr::null(), // szDataDescr
            &entropy_blob,
            std::ptr::null_mut(), // pvReserved
            std::ptr::null_mut(), // pPromptStruct
            CRYPTPROTECT_UI_FORBIDDEN,
            &mut output,
        )
    };
    if ok == 0 {
        return Err(Error::CryptoFailure(format!(
            "CryptProtectData failed: GetLastError={}",
            unsafe { windows_sys::Win32::Foundation::GetLastError() }
        )));
    }

    let result =
        unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() };
    unsafe {
        LocalFree(output.pbData.cast());
    }
    Ok(result)
}

fn dpapi_unprotect(cipher: &[u8], entropy: &[u8]) -> Result<Vec<u8>, Error> {
    let input = make_blob(cipher);
    let entropy_blob = make_blob(entropy);
    let mut output = CRYPT_INTEGER_BLOB {
        cbData: 0,
        pbData: std::ptr::null_mut(),
    };

    let ok = unsafe {
        CryptUnprotectData(
            &input,
            std::ptr::null_mut(), // ppszDataDescr
            &entropy_blob,
            std::ptr::null_mut(), // pvReserved
            std::ptr::null_mut(), // pPromptStruct
            CRYPTPROTECT_UI_FORBIDDEN,
            &mut output,
        )
    };
    if ok == 0 {
        return Err(Error::CryptoFailure(format!(
            "CryptUnprotectData failed (different user/machine, or corrupted blob): \
             GetLastError={}",
            unsafe { windows_sys::Win32::Foundation::GetLastError() }
        )));
    }

    let result =
        unsafe { std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec() };
    unsafe {
        LocalFree(output.pbData.cast());
    }
    Ok(result)
}

fn make_blob(data: &[u8]) -> CRYPT_INTEGER_BLOB {
    // DPAPI's `cbData` is `DWORD` (`u32`). Inputs to envseal are master-
    // key envelope blobs — kilobytes at most, never approaching 4 GiB.
    // A `try_from` here would just panic on the same underflow `as` does;
    // there's no recoverable error to map.
    #[allow(clippy::cast_possible_truncation)]
    let cb = data.len() as u32;
    CRYPT_INTEGER_BLOB {
        cbData: cb,
        pbData: data.as_ptr().cast_mut(),
    }
}

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

    #[test]
    fn seal_then_unseal_roundtrips() {
        let ks = DpapiKeystore::new();
        let plaintext = b"the master key wrapped envelope goes here";
        let sealed = ks
            .seal(plaintext)
            .expect("DPAPI seal must succeed in user session");
        assert_ne!(
            sealed.as_slice(),
            plaintext,
            "sealed must not equal plaintext"
        );
        let recovered = ks
            .unseal(&sealed)
            .expect("DPAPI unseal must succeed in same session");
        assert_eq!(recovered, plaintext);
    }

    #[test]
    fn unseal_rejects_corrupted_entropy() {
        let ks = DpapiKeystore::new();
        let plaintext = b"some payload";
        let mut sealed = ks.seal(plaintext).unwrap();
        // Flip a bit in the entropy — DPAPI must refuse to decrypt.
        sealed[0] ^= 0x01;
        assert!(ks.unseal(&sealed).is_err());
    }

    #[test]
    fn unseal_rejects_corrupted_ciphertext() {
        let ks = DpapiKeystore::new();
        let plaintext = b"some payload";
        let mut sealed = ks.seal(plaintext).unwrap();
        // Flip a bit deep inside the ciphertext.
        let last = sealed.len() - 1;
        sealed[last] ^= 0x01;
        assert!(ks.unseal(&sealed).is_err());
    }

    #[test]
    fn seal_unseal_handles_large_blob() {
        let ks = DpapiKeystore::new();
        let plaintext = vec![0xABu8; 4096];
        let sealed = ks.seal(&plaintext).unwrap();
        assert_eq!(ks.unseal(&sealed).unwrap(), plaintext);
    }

    #[test]
    fn unseal_rejects_truncated_envelope() {
        let ks = DpapiKeystore::new();
        // 8 bytes < ENTROPY_LEN
        assert!(ks.unseal(&[0u8; 8]).is_err());
    }
}