secrets-vault 2.3.0

AES-256-GCM encrypted key-value vault with PBKDF2 key derivation. Store API keys and tokens securely instead of plaintext dotfiles.
Documentation
//! Owner-only ACLs for the vault's files on Windows — the `0600`/`0700`
//! replacement.
//!
//! Every `#[cfg(unix)]` branch in this crate that opens a file `.mode(0o600)`
//! or chmods a directory `0o700` has a `#[cfg(not(unix))]` twin that just
//! writes the file. Those twins are correct as far as they go — they inherit
//! the default ACL of `%USERPROFILE%`, which on a normal single-user install
//! already excludes other standard users. But "inherits whatever the profile
//! happens to grant" is a weaker and much less legible promise than `0600`,
//! and it silently absorbs any inherited grant an admin, an installer, or a
//! synced-folder tool adds later.
//!
//! So instead of documenting the reliance, this module makes the intent
//! explicit: replace the object's DACL with a **protected** (inheritance-
//! blocking) DACL granting full control to exactly one SID — the current user —
//! and nobody else. Applied to the secrets dir, the DPAPI keyring, and the
//! files inside them, that is the honest analogue of the Unix modes.
//!
//! Two caveats stated plainly, because they are the difference from Unix:
//!   * **Administrators and SYSTEM can still take ownership** and read anything.
//!     No file ACL prevents that; on Unix, root is likewise unbounded. The DPAPI
//!     layer in `keychain_win.rs` is what keeps the master key meaningful
//!     against a file-level attacker.
//!   * Removing inherited ACEs needs `WRITE_DAC`, which the owner has. If the
//!     call fails anyway the caller **says so** rather than assuming success —
//!     these are security controls, and a security control that fails quietly
//!     is worse than one that was never claimed.

use std::path::Path;

use windows::core::{HSTRING, PWSTR};
use windows::Win32::Foundation::{CloseHandle, LocalFree, HANDLE, HLOCAL};
use windows::Win32::Security::Authorization::{
    ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW,
    SetNamedSecurityInfoW, SDDL_REVISION_1, SE_FILE_OBJECT,
};
use windows::Win32::Security::{
    GetSecurityDescriptorDacl, TokenUser, ACL, DACL_SECURITY_INFORMATION,
    PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, TOKEN_QUERY, TOKEN_USER,
};
use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};

/// The current process user's SID in string form (`S-1-5-21-…`).
///
/// Also the identity the session broker's pipe is named for and locked to
/// (`session_win.rs`), so this is shared rather than duplicated.
pub(crate) fn current_user_sid() -> Result<String, String> {
    unsafe {
        let mut token = HANDLE::default();
        OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token)
            .map_err(|e| format!("OpenProcessToken: {e}"))?;

        // Two-call idiom: a zero-length probe (expected to fail) reports the
        // buffer size, then the real call fills it.
        let mut len = 0u32;
        let _ = windows::Win32::Security::GetTokenInformation(token, TokenUser, None, 0, &mut len);
        if len == 0 {
            let _ = CloseHandle(token);
            return Err("GetTokenInformation reported a zero-length TOKEN_USER".into());
        }
        let mut buf = vec![0u8; len as usize];
        let res = windows::Win32::Security::GetTokenInformation(
            token,
            TokenUser,
            Some(buf.as_mut_ptr() as *mut core::ffi::c_void),
            len,
            &mut len,
        );
        let _ = CloseHandle(token);
        res.map_err(|e| format!("GetTokenInformation(TokenUser): {e}"))?;

        let tu = &*(buf.as_ptr() as *const TOKEN_USER);
        let mut s = PWSTR::null();
        ConvertSidToStringSidW(tu.User.Sid, &mut s)
            .map_err(|e| format!("ConvertSidToStringSid: {e}"))?;
        let out = s.to_string().map_err(|e| format!("SID string: {e}"))?;
        let _ = LocalFree(Some(HLOCAL(s.0 as *mut core::ffi::c_void)));
        Ok(out)
    }
}

/// Replace `path`'s DACL with a protected, owner-only one.
///
/// SDDL: `D:P(A;OICI;FA;;;<sid>)`
///   `D:`   — this is a DACL
///   `P`    — protected: inherited ACEs from the parent are DROPPED
///   `A`    — allow
///   `OICI` — object+container inherit (children of a directory get it too;
///            inert on a plain file)
///   `FA`   — full access
///
/// Best-effort by contract, but never silently: the error is returned so the
/// caller can surface it.
pub fn restrict_to_owner(path: &Path) -> Result<(), String> {
    let sid = current_user_sid()?;
    let sddl = HSTRING::from(format!("D:P(A;OICI;FA;;;{sid})"));

    unsafe {
        let mut psd = PSECURITY_DESCRIPTOR::default();
        ConvertStringSecurityDescriptorToSecurityDescriptorW(
            &sddl,
            SDDL_REVISION_1,
            &mut psd,
            None,
        )
        .map_err(|e| format!("building security descriptor: {e}"))?;

        let mut dacl: *mut ACL = std::ptr::null_mut();
        let mut present = false.into();
        let mut defaulted = false.into();
        let got = GetSecurityDescriptorDacl(psd, &mut present, &mut dacl, &mut defaulted);
        if got.is_err() || dacl.is_null() {
            let _ = LocalFree(Some(HLOCAL(psd.0)));
            return Err("could not extract the DACL from the security descriptor".into());
        }

        let rc = SetNamedSecurityInfoW(
            &HSTRING::from(path.as_os_str()),
            SE_FILE_OBJECT,
            DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION,
            None,
            None,
            Some(dacl),
            None,
        );
        let _ = LocalFree(Some(HLOCAL(psd.0)));

        if rc.is_ok() {
            Ok(())
        } else {
            Err(format!("SetNamedSecurityInfo failed (WIN32_ERROR {})", rc.0))
        }
    }
}

/// Apply `restrict_to_owner`, printing a warning instead of failing the command.
/// Used on the vault/keyring directories, where a failed tighten should be
/// visible but must not stop the user reaching their secrets.
pub fn restrict_to_owner_warn(path: &Path) {
    if let Err(e) = restrict_to_owner(path) {
        eprintln!(
            "(warning: could not restrict {} to owner-only: {e} — falling back to the \
             inherited profile ACL)",
            path.display()
        );
    }
}

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

    #[test]
    fn sid_looks_like_a_sid() {
        let sid = current_user_sid().expect("current user SID");
        assert!(sid.starts_with("S-1-"), "unexpected SID form: {sid}");
    }

    #[test]
    fn restricts_a_real_file() {
        let dir = std::env::temp_dir().join("secrets-winacl-test");
        std::fs::create_dir_all(&dir).unwrap();
        let f = dir.join("probe.txt");
        std::fs::write(&f, b"x").unwrap();
        restrict_to_owner(&f).expect("restrict a file we own");
        // Still readable by us afterwards — an owner-only DACL must not lock
        // the owner out.
        assert_eq!(std::fs::read(&f).unwrap(), b"x");
        let _ = std::fs::remove_file(&f);
        let _ = std::fs::remove_dir(&dir);
    }
}