tsafe-cli 1.0.21

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
//! Windows Hello biometric challenge gate (E4.1).
//!
//! Issues a true `IUserConsentVerifierInterop::RequestVerificationForWindowAsync`
//! challenge before the vault unlock keyring read. The credential stored in
//! Windows Credential Manager is only retrieved after this challenge passes.
//!
//! # Fallback
//!
//! `GetConsoleWindow()` returns NULL in ConPTY / Windows Terminal / headless
//! contexts. When NULL is returned the caller receives
//! `BiometricChallengeError::NoWindowHandle` and MUST continue to the keyring
//! read — a silently-stored credential can still unlock the vault. The
//! interactive password prompt is only reached if the keyring has no entry.
//! `NoWindowHandle` must never be treated as a hard failure.
//!
//! # Platform gating
//!
//! Everything in this module is `#[cfg(windows)]`. On non-Windows platforms
//! the entire module is empty and the `BiometricChallengeError` type is not
//! defined. The `helpers.rs` caller is guarded by
//! `#[cfg(all(windows, feature = "biometric"))]`.

use std::fmt;

/// Errors that the Windows Hello challenge function can return.
///
/// The caller in `helpers.rs` maps these to fallback vs. hard-error behavior:
/// - `NoWindowHandle`, `NotEnrolled`, `NotConfigured` → fall back to password prompt
/// - `Canceled` → surface `SafeError::BiometricCanceled` — do not fall through
/// - `Failed` → surface `SafeError::BiometricFailed`
#[cfg(windows)]
#[derive(Debug)]
pub enum BiometricChallengeError {
    /// `GetConsoleWindow()` returned NULL — no HWND available for the consent
    /// dialog. Common in ConPTY / Windows Terminal and headless/SSH sessions.
    NoWindowHandle,

    /// The device has no enrolled biometric factors (`DeviceNotPresent`).
    NotEnrolled,

    /// The user dismissed the consent dialog (`Canceled`).
    Canceled,

    /// Windows Hello is not configured for the current user
    /// (`NotConfiguredForUser`).
    NotConfigured,

    /// Any other `UserConsentVerificationResult` variant or OS-level error.
    Failed(String),
}

#[cfg(windows)]
impl fmt::Display for BiometricChallengeError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::NoWindowHandle => {
                f.write_str("no console window handle (ConPTY / headless context)")
            }
            Self::NotEnrolled => f.write_str("no biometric device enrolled for Windows Hello"),
            Self::Canceled => f.write_str("Windows Hello verification canceled by user"),
            Self::NotConfigured => {
                f.write_str("Windows Hello is not configured for the current user")
            }
            Self::Failed(msg) => write!(f, "Windows Hello verification failed: {msg}"),
        }
    }
}

/// Request a Windows Hello biometric verification before allowing vault access.
///
/// Returns `Ok(true)` when verification succeeds. Returns `Ok(false)` is not
/// expected in practice but covers any OS-level verified-false response.
///
/// # Errors
///
/// | Variant | Caller action |
/// |---------|---------------|
/// | `NoWindowHandle` | Challenge unavailable — continue to keyring read |
/// | `NotEnrolled` | Challenge unavailable — continue to keyring read |
/// | `NotConfigured` | Challenge unavailable — continue to keyring read |
/// | `Canceled` | Hard error — user actively dismissed the dialog |
/// | `Failed` | Hard error — surface details to the user |
#[cfg(windows)]
pub fn request_windows_hello_verification(reason: &str) -> Result<bool, BiometricChallengeError> {
    use windows::{
        Security::Credentials::UI::UserConsentVerificationResult,
        Win32::System::Console::GetConsoleWindow,
        Win32::System::WinRT::IUserConsentVerifierInterop,
    };
    use windows_future::IAsyncOperation;

    // Safety: GetConsoleWindow is always safe to call; it returns NULL when no
    // console is attached (ConPTY, Windows Terminal, SSH, headless).
    let hwnd = unsafe { GetConsoleWindow() };
    if hwnd.0.is_null() {
        tracing::debug!("windows_hello: GetConsoleWindow returned NULL — challenge unavailable");
        return Err(BiometricChallengeError::NoWindowHandle);
    }

    // Acquire the IUserConsentVerifierInterop activation factory.
    // UserConsentVerifier's runtime class name is used to locate the factory via
    // RoGetActivationFactory; the factory is then queried for the interop interface.
    // This succeeds on any Windows 10+ system regardless of biometric hardware.
    let interop: IUserConsentVerifierInterop = windows::core::imp::load_factory::<
        windows::Security::Credentials::UI::UserConsentVerifier,
        IUserConsentVerifierInterop,
    >()
    .map_err(|e| {
        tracing::debug!("windows_hello: failed to load factory: {e}");
        BiometricChallengeError::Failed(e.to_string())
    })?;

    let reason_hstring = windows::core::HSTRING::from(reason);

    // RequestVerificationForWindowAsync is unsafe because it dereferences the
    // HWND and launches a UI operation.
    let operation: IAsyncOperation<UserConsentVerificationResult> = unsafe {
        interop
            .RequestVerificationForWindowAsync(hwnd, &reason_hstring)
            .map_err(|e| {
                tracing::debug!("windows_hello: RequestVerificationForWindowAsync error: {e}");
                BiometricChallengeError::Failed(e.to_string())
            })?
    };

    // Block synchronously — appropriate for a CLI binary with no async runtime.
    let result = operation.get().map_err(|e| {
        tracing::debug!("windows_hello: async operation .get() error: {e}");
        BiometricChallengeError::Failed(e.to_string())
    })?;

    tracing::debug!("windows_hello: UserConsentVerificationResult = {result:?}");

    match result {
        UserConsentVerificationResult::Verified => Ok(true),
        UserConsentVerificationResult::DeviceNotPresent => {
            Err(BiometricChallengeError::NotEnrolled)
        }
        UserConsentVerificationResult::Canceled => Err(BiometricChallengeError::Canceled),
        UserConsentVerificationResult::NotConfiguredForUser => {
            Err(BiometricChallengeError::NotConfigured)
        }
        other => Err(BiometricChallengeError::Failed(format!(
            "unexpected result: {other:?}"
        ))),
    }
}

// ── Tests ─────────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    // Note: tests that require actual Windows Hello hardware or an OS prompt
    // are marked #[ignore] — they cannot run in CI or headless environments.

    /// Verifies that `BiometricChallengeError::NoWindowHandle` formats correctly.
    /// This is exercised by the NULL-HWND path in `request_windows_hello_verification`.
    #[cfg(windows)]
    #[test]
    fn no_window_handle_display_is_descriptive() {
        use super::BiometricChallengeError;
        let msg = BiometricChallengeError::NoWindowHandle.to_string();
        assert!(
            msg.contains("ConPTY") || msg.contains("headless"),
            "expected description to mention ConPTY or headless, got: {msg}"
        );
    }

    /// Verifies that `NotEnrolled` maps to a sensible message (device_not_present path).
    #[cfg(windows)]
    #[test]
    fn device_not_present_display_mentions_biometric_device() {
        use super::BiometricChallengeError;
        let msg = BiometricChallengeError::NotEnrolled.to_string();
        assert!(
            msg.contains("biometric") || msg.contains("enrolled"),
            "expected description to mention biometric or enrolled, got: {msg}"
        );
    }

    /// Verifies that `Canceled` formats clearly.
    #[cfg(windows)]
    #[test]
    fn canceled_display_mentions_canceled() {
        use super::BiometricChallengeError;
        let msg = BiometricChallengeError::Canceled.to_string();
        assert!(
            msg.contains("cancel"),
            "expected description to mention 'cancel', got: {msg}"
        );
    }

    /// Verifies that `Failed` carries the inner message through.
    #[cfg(windows)]
    #[test]
    fn failed_display_includes_inner_message() {
        use super::BiometricChallengeError;
        let msg = BiometricChallengeError::Failed("OS error 42".to_string()).to_string();
        assert!(
            msg.contains("OS error 42"),
            "expected inner message in display, got: {msg}"
        );
    }

    /// Verifies that `NoWindowHandle`, `NotEnrolled`, and `NotConfigured` are
    /// classified as "challenge unavailable" — the caller continues to the
    /// keyring read rather than treating these as hard errors.
    ///
    /// This test verifies the error-classification contract: the three variants
    /// must match the "proceed to keyring" arm in `helpers.rs`.
    #[cfg(windows)]
    #[test]
    fn challenge_unavailable_variants_are_not_hard_errors() {
        use super::BiometricChallengeError;

        for err in [
            BiometricChallengeError::NoWindowHandle,
            BiometricChallengeError::NotEnrolled,
            BiometricChallengeError::NotConfigured,
        ] {
            let is_challenge_unavailable = matches!(
                err,
                BiometricChallengeError::NoWindowHandle
                    | BiometricChallengeError::NotEnrolled
                    | BiometricChallengeError::NotConfigured
            );
            assert!(
                is_challenge_unavailable,
                "{err} must be a challenge-unavailable case (proceed to keyring), not a hard error"
            );
        }
    }

    /// Verifies that `Canceled` and `Failed` are NOT in the fallback arm —
    /// they must surface as hard errors.
    #[cfg(windows)]
    #[test]
    fn canceled_and_failed_are_not_fallback_cases() {
        use super::BiometricChallengeError;

        let canceled = BiometricChallengeError::Canceled;
        let failed = BiometricChallengeError::Failed("detail".to_string());

        assert!(
            !matches!(
                canceled,
                BiometricChallengeError::NoWindowHandle
                    | BiometricChallengeError::NotEnrolled
                    | BiometricChallengeError::NotConfigured
            ),
            "Canceled must not be a fallback case"
        );
        assert!(
            !matches!(
                failed,
                BiometricChallengeError::NoWindowHandle
                    | BiometricChallengeError::NotEnrolled
                    | BiometricChallengeError::NotConfigured
            ),
            "Failed must not be a fallback case"
        );
    }

    /// Full end-to-end Windows Hello challenge test.
    ///
    /// Requires actual Windows Hello hardware (fingerprint reader, IR camera, etc.)
    /// and an interactive desktop session. Skipped in CI.
    #[cfg(windows)]
    #[test]
    #[ignore = "requires Windows Hello hardware and an interactive desktop session — run manually"]
    fn live_windows_hello_challenge_returns_verified() {
        use super::request_windows_hello_verification;
        let result = request_windows_hello_verification("tsafe test — verify your identity");
        assert!(
            matches!(result, Ok(true)),
            "expected Verified, got: {result:?}"
        );
    }
}