envseal 0.3.10

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! Binary integrity: SHA-256 hashing, hash verification, fd-pinning helpers.
//!
//! Hashing happens at approval time and on every subsequent injection so
//! we detect binary replacement (PATH poisoning, package update without
//! re-approval). The fd-based variants ([`hash_open_file`],
//! [`verify_file_hash`]) work against an already-opened `O_NOFOLLOW`
//! descriptor so the hashed bytes match the bytes that will execute, even
//! if an attacker swaps the path between hash and exec.

use std::path::Path;

use sha2::{Digest, Sha256};

use crate::error::Error;

/// Compute SHA-256 hash of a binary file for integrity verification.
///
/// Used to detect binary replacement / PATH poisoning attacks.
///
/// # Errors
/// Returns [`Error::BinaryResolution`] if the file cannot be read.
pub fn hash_binary(binary_path: &Path) -> Result<String, Error> {
    let bytes = std::fs::read(binary_path).map_err(|e| {
        Error::BinaryResolution(format!(
            "failed to read binary for hashing at {}: {e}",
            binary_path.display()
        ))
    })?;

    let mut hasher = Sha256::new();
    hasher.update(&bytes);
    let hash = hasher.finalize();

    Ok(hex::encode(hash))
}

/// Decode a 64-character SHA-256 hex fingerprint to raw bytes.
fn parse_sha256_hex(s: &str) -> Result<[u8; 32], Error> {
    if s.len() != 64 {
        return Err(Error::CryptoFailure(format!(
            "invalid SHA-256 hex length: {} (expected 64)",
            s.len()
        )));
    }
    let mut out = [0u8; 32];
    for i in 0..32 {
        out[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).map_err(|_| {
            Error::CryptoFailure("invalid hex digit in SHA-256 fingerprint".to_string())
        })?;
    }
    Ok(out)
}

/// Verify that a binary matches its stored hash.
///
/// # Errors
/// Returns [`Error::BinaryTampered`] if the hash doesn't match,
/// [`Error::BinaryResolution`] if the file is unreadable.
pub fn verify_binary_hash(binary_path: &Path, expected_hash: &str) -> Result<(), Error> {
    let actual_hash = hash_binary(binary_path)?;
    let Ok(expected) = parse_sha256_hex(expected_hash) else {
        return Err(Error::BinaryTampered {
            binary_path: binary_path.display().to_string(),
            expected_hash: expected_hash.to_string(),
            actual_hash,
        });
    };
    let actual = parse_sha256_hex(&actual_hash)?;
    if super::constant_time_eq(&expected, &actual) {
        Ok(())
    } else {
        Err(Error::BinaryTampered {
            binary_path: binary_path.display().to_string(),
            expected_hash: expected_hash.to_string(),
            actual_hash,
        })
    }
}

/// Verify that an open file matches its stored hash.
///
/// Used on Linux to avoid TOCTOU races between hashing and execution.
///
/// # Errors
/// Returns [`Error::BinaryTampered`] on mismatch, [`Error::BinaryResolution`]
/// on read failure.
pub fn verify_file_hash(
    mut file: &std::fs::File,
    expected_hash: &str,
    binary_path: &Path,
) -> Result<(), Error> {
    use std::io::{Read, Seek, SeekFrom};

    file.seek(SeekFrom::Start(0))
        .map_err(|e| Error::BinaryResolution(format!("failed to seek binary for hashing: {e}")))?;

    let mut hasher = Sha256::new();
    let mut buffer = [0; 8192];
    loop {
        let n = file.read(&mut buffer).map_err(|e| {
            Error::BinaryResolution(format!("failed to read binary for hashing: {e}"))
        })?;
        if n == 0 {
            break;
        }
        hasher.update(&buffer[..n]);
    }

    let actual_hash = hex::encode(hasher.finalize());
    let Ok(expected) = parse_sha256_hex(expected_hash) else {
        return Err(Error::BinaryTampered {
            binary_path: binary_path.display().to_string(),
            expected_hash: expected_hash.to_string(),
            actual_hash: actual_hash.clone(),
        });
    };
    let actual = parse_sha256_hex(&actual_hash).map_err(|_| Error::BinaryTampered {
        binary_path: binary_path.display().to_string(),
        expected_hash: expected_hash.to_string(),
        actual_hash: actual_hash.clone(),
    })?;
    if super::constant_time_eq(&expected, &actual) {
        Ok(())
    } else {
        Err(Error::BinaryTampered {
            binary_path: binary_path.display().to_string(),
            expected_hash: expected_hash.to_string(),
            actual_hash,
        })
    }
}

/// SHA-256 fingerprint of an already-open executable file (rewinds to start).
///
/// Used with [`verify_file_hash`] and pinned `/proc/self/fd` execution on
/// Linux for TOCTOU resistance.
///
/// # Errors
/// Returns [`Error::BinaryResolution`] on read or seek failure.
pub fn hash_open_file(file: &mut std::fs::File) -> Result<String, Error> {
    use std::io::{Read, Seek, SeekFrom};

    file.seek(SeekFrom::Start(0))
        .map_err(|e| Error::BinaryResolution(format!("failed to seek binary for hashing: {e}")))?;

    let mut hasher = Sha256::new();
    let mut buffer = [0; 8192];
    loop {
        let n = file.read(&mut buffer).map_err(|e| {
            Error::BinaryResolution(format!("failed to read binary for hashing: {e}"))
        })?;
        if n == 0 {
            break;
        }
        hasher.update(&buffer[..n]);
    }

    Ok(hex::encode(hasher.finalize()))
}

/// Verify that a path is not a path-redirection primitive.
///
/// Prevents redirection attacks where an agent points
/// `~/.config/envseal/master.key` at an attacker-controlled file.
///
/// On Unix this rejects symlinks. On Windows this rejects **any reparse
/// point** — symlinks, NTFS junctions, mount points, `OneDrive`
/// cloud-files, `AppX` redirects, … — all of which silently redirect path
/// resolution and would let an attacker substitute file contents the
/// vault thinks it owns. `std::fs::FileType::is_symlink` covers only the
/// `IO_REPARSE_TAG_SYMLINK` case on Windows; we want all of them.
///
/// # Errors
/// Returns [`Error::PolicyTampered`] if the path is a symlink or reparse
/// point, [`Error::StorageIo`] on stat failure (other than `NotFound`).
pub fn verify_not_symlink(path: &std::path::Path) -> Result<(), Error> {
    match std::fs::symlink_metadata(path) {
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
        Err(e) => Err(Error::StorageIo(e)),
        Ok(meta) => {
            if meta.file_type().is_symlink() {
                return Err(Error::PolicyTampered(format!(
                    "file is a symlink: {}. symlinks in the vault directory are \
                     not allowed — they can be used to redirect reads/writes \
                     to attacker-controlled locations.",
                    path.display()
                )));
            }

            #[cfg(windows)]
            {
                if is_reparse_point(path) {
                    return Err(Error::PolicyTampered(format!(
                        "file is an NTFS reparse point: {}. reparse points (junctions, \
                         mount points, cloud-file placeholders, …) silently redirect \
                         path resolution and are not allowed in the vault directory.",
                        path.display()
                    )));
                }
            }

            Ok(())
        }
    }
}

/// Whether the file at `path` has the `FILE_ATTRIBUTE_REPARSE_POINT`
/// attribute set (junction, mount point, symlink, cloud-file, …).
#[cfg(windows)]
fn is_reparse_point(path: &std::path::Path) -> bool {
    use std::os::windows::ffi::OsStrExt;
    use windows_sys::Win32::Storage::FileSystem::{
        GetFileAttributesW, FILE_ATTRIBUTE_REPARSE_POINT, INVALID_FILE_ATTRIBUTES,
    };

    let wide: Vec<u16> = path
        .as_os_str()
        .encode_wide()
        .chain(std::iter::once(0))
        .collect();
    let attrs = unsafe { GetFileAttributesW(wide.as_ptr()) };
    if attrs == INVALID_FILE_ATTRIBUTES {
        return false;
    }
    (attrs & FILE_ATTRIBUTE_REPARSE_POINT) != 0
}

// Hex encoding lives at `crate::hex` — see `core/src/hex.rs`.
use crate::hex;