hardware-enclave 0.1.0

Hardware-backed key management — macOS Secure Enclave, Windows TPM 2.0, Linux TPM/keyring
Documentation
// Copyright 2026 Jay Gowdy
// SPDX-License-Identifier: MIT

//! Windows Hello UX gate for the encryption/signing key paths.
//!
//! Wraps `Windows.Security.Credentials.UI.UserConsentVerifier` to surface
//! a Hello biometric/PIN prompt before TPM operations, on apps that opt
//! in via [`crate::internal::windows::HelloGate::new`]. The CryptUI password protector dialog
//! that the legacy `NCRYPT_UI_PROTECT_KEY_FLAG` path produces is bypassed
//! by the caller (key is created without that flag).
//!
//! ## Fallback when Hello is not enrolled
//!
//! Not every user has Windows Hello / PIN configured. When
//! `UserConsentVerifier` reports the device is unusable for this user
//! (`DeviceNotPresent` / `NotConfiguredForUser` / `DisabledByPolicy`),
//! the gate falls back to the Windows account-password soft gate in
//! [`crate::internal::windows::password_gate`] so a user-presence check is still enforced.
//! If neither Hello nor a verifiable password is available (e.g. a
//! headless session or a passwordless account), the gate degrades to no
//! prompt — the credential bundle remains TPM-encrypted regardless. This
//! avoids the perverse outcome where opting into the gate would impose
//! prompt friction on Hello users while leaving non-Hello users with no
//! presence signal at all.
//!
//! ## Threat-model trade-off
//!
//! The `UserConsentVerifier` API returns a `UserConsentVerificationResult`
//! to the calling process. A same-UID attacker with code execution inside
//! the host process can hook the result and bypass the gate; the Hello
//! prompt would never need to fire. This is materially weaker than the
//! `NCRYPT_UI_PROTECT_KEY_FLAG` path where the dialog is mediated
//! out-of-process by `consent.exe`. Apps that opt in are choosing
//! **Hello UX over hard-gate threat model** — appropriate when the
//! encrypted material is short-lived, auto-rotated, or the threat model
//! accepts same-UID equivalence.
//!
//! ## Caching
//!
//! Each [`HelloGate`] instance holds an in-process map of
//! `scope -> Instant_last_verified`. When `ensure_verified` is called
//! and the cached verification is within `ttl`, the prompt is skipped.
//! `Duration::ZERO` disables caching (prompt on every call).
#![allow(
    dead_code,
    unused_imports,
    unused_qualifications,
    unreachable_patterns,
    unsafe_code
)]

use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};

use crate::internal::core::{Error, Result};
use windows::core::HSTRING;
use windows::Security::Credentials::UI::{UserConsentVerificationResult, UserConsentVerifier};

/// In-process cache of recent Windows Hello verifications, keyed on
/// caller-supplied scope strings. Construct once per app/encryptor and
/// share across encrypt/decrypt calls.
#[derive(Default)]
pub struct HelloGate {
    entries: Mutex<HashMap<String, Instant>>,
}

impl std::fmt::Debug for HelloGate {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("HelloGate").finish_non_exhaustive()
    }
}

impl HelloGate {
    pub fn new() -> Self {
        Self::default()
    }

    /// Ensure the user has been Hello-verified within `ttl` for the given
    /// `scope`. If the cached verification is still fresh, returns `Ok`
    /// without firing a prompt. Otherwise calls
    /// `UserConsentVerifier::RequestVerificationAsync(reason)`, blocks
    /// until the user responds, and on `Verified` records the time and
    /// returns `Ok`.
    ///
    /// `reason` is the message shown in the Hello prompt; pick something
    /// the user can match to the action they're taking (e.g.
    /// "Unlock gocode-dev credentials").
    pub fn ensure_verified(&self, scope: &str, reason: &str, ttl: Duration) -> Result<()> {
        if self.is_fresh(scope, ttl) {
            return Ok(());
        }
        prompt_user_consent(reason)?;
        self.mark_verified(scope);
        Ok(())
    }

    /// Drop all cached verifications. Force the next call to prompt.
    /// Useful on lock-screen events or explicit user "lock" actions.
    pub fn invalidate_all(&self) {
        if let Ok(mut entries) = self.entries.lock() {
            entries.clear();
        }
    }

    fn is_fresh(&self, scope: &str, ttl: Duration) -> bool {
        if ttl.is_zero() {
            return false;
        }
        let Ok(entries) = self.entries.lock() else {
            return false;
        };
        entries
            .get(scope)
            .map(|t| t.elapsed() < ttl)
            .unwrap_or(false)
    }

    fn mark_verified(&self, scope: &str) {
        let Ok(mut entries) = self.entries.lock() else {
            return;
        };
        entries.insert(scope.to_string(), Instant::now());
    }
}

/// Probe whether `UserConsentVerifier` is available on this host without
/// firing a prompt. Returns `true` if Windows Hello (or a fallback PIN)
/// is configured for the current user.
pub fn is_available() -> bool {
    use windows::Security::Credentials::UI::UserConsentVerifierAvailability;
    let async_op = match UserConsentVerifier::CheckAvailabilityAsync() {
        Ok(op) => op,
        Err(_) => return false,
    };
    let result = match async_op.get() {
        Ok(r) => r,
        Err(_) => return false,
    };
    matches!(result, UserConsentVerifierAvailability::Available)
}

/// Fire the Hello biometric/PIN prompt synchronously. Returns `Ok(())`
/// on `Verified`; otherwise returns an `Error::KeyOperation` describing
/// why the verification did not succeed (user cancelled, device busy,
/// disabled by policy, etc.).
fn prompt_user_consent(reason: &str) -> Result<()> {
    let reason_h = HSTRING::from(reason);
    let async_op = UserConsentVerifier::RequestVerificationAsync(&reason_h).map_err(|e| {
        Error::KeyOperation {
            operation: "hello_request_verification".into(),
            detail: format!("UserConsentVerifier::RequestVerificationAsync: {e}"),
        }
    })?;
    let result = async_op.get().map_err(|e| Error::KeyOperation {
        operation: "hello_await_result".into(),
        detail: format!("UserConsentVerifier async wait: {e}"),
    })?;

    match result {
        UserConsentVerificationResult::Verified => Ok(()),
        // Hello is not usable for this user on this host. Rather than
        // hard-failing (which would lock out everyone without Hello), fall
        // back to the Windows account-password soft gate so a presence
        // check is still enforced. See [`crate::internal::windows::password_gate`].
        UserConsentVerificationResult::DeviceNotPresent
        | UserConsentVerificationResult::NotConfiguredForUser
        | UserConsentVerificationResult::DisabledByPolicy => {
            fallback_to_password_gate(reason, result)
        }
        UserConsentVerificationResult::DeviceBusy => Err(Error::KeyOperation {
            operation: "hello_request_verification".into(),
            detail: "Windows Hello device is busy; try again".into(),
        }),
        UserConsentVerificationResult::RetriesExhausted => Err(Error::KeyOperation {
            operation: "hello_request_verification".into(),
            detail: "Windows Hello retries exhausted; user could not be verified".into(),
        }),
        UserConsentVerificationResult::Canceled => Err(Error::KeyOperation {
            operation: "hello_request_verification".into(),
            detail: "User cancelled Windows Hello verification".into(),
        }),
        other => Err(Error::KeyOperation {
            operation: "hello_request_verification".into(),
            detail: format!("UserConsentVerifier returned unexpected result {other:?}"),
        }),
    }
}

/// Fall back to the Windows account-password soft gate when Hello is not
/// enrolled. `hello_result` is the `UserConsentVerifier` outcome that
/// triggered the fallback, logged for diagnostics. Returns `Ok(())` when
/// the user proves presence *or* when no presence check is possible at
/// all (degraded path — the credential bundle stays TPM-encrypted).
/// Returns an error only when the user actively declines or fails the
/// password prompt.
fn fallback_to_password_gate(
    reason: &str,
    hello_result: UserConsentVerificationResult,
) -> Result<()> {
    use super::password_gate::{verify_current_user, PresenceOutcome};

    match verify_current_user(reason) {
        PresenceOutcome::Verified => Ok(()),
        PresenceOutcome::Denied(detail) => Err(Error::KeyOperation {
            operation: "password_request_verification".into(),
            detail,
        }),
        PresenceOutcome::Unavailable(detail) => {
            tracing::warn!(
                hello_result = ?hello_result,
                reason = %detail,
                "Windows Hello is not enrolled and the password presence gate is \
                 unavailable; proceeding without a presence prompt (credentials \
                 remain TPM-encrypted)"
            );
            Ok(())
        }
    }
}

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

    /// Cache hit within TTL skips the prompt. We can't easily verify
    /// "didn't prompt" without hooking the dialog, but we can at least
    /// verify the cache-fresh path is taken (returns Ok immediately).
    #[test]
    fn cache_hit_within_ttl_returns_ok_without_prompt() {
        let gate = HelloGate::new();
        gate.mark_verified("test-scope");
        let result = gate.ensure_verified("test-scope", "should-not-fire", Duration::from_secs(60));
        assert!(result.is_ok());
    }

    #[test]
    fn zero_ttl_disables_cache() {
        let gate = HelloGate::new();
        gate.mark_verified("test-scope");
        // With Duration::ZERO the cache check should always miss; we
        // don't actually call ensure_verified here (it would prompt),
        // we just test the is_fresh predicate directly.
        assert!(!gate.is_fresh("test-scope", Duration::ZERO));
    }

    #[test]
    fn invalidate_all_clears_entries() {
        let gate = HelloGate::new();
        gate.mark_verified("scope-a");
        gate.mark_verified("scope-b");
        assert!(gate.is_fresh("scope-a", Duration::from_secs(60)));
        gate.invalidate_all();
        assert!(!gate.is_fresh("scope-a", Duration::from_secs(60)));
        assert!(!gate.is_fresh("scope-b", Duration::from_secs(60)));
    }

    /// Cache entries are scoped to the `scope` string; verifying one
    /// scope does not transitively bless another. This is the
    /// invariant that lets the encryptor pass `format!("{app}:{label}")`
    /// as the scope so multi-key apps don't share a single Hello
    /// approval across distinct credential bundles.
    #[test]
    fn cache_scopes_are_independent() {
        let gate = HelloGate::new();
        gate.mark_verified("scope-a");
        assert!(gate.is_fresh("scope-a", Duration::from_secs(60)));
        assert!(!gate.is_fresh("scope-b", Duration::from_secs(60)));
    }

    /// Threat-model classification self-test: ensures the soft-gate
    /// posture is documented in this module's doc-comments. Catches
    /// the case where someone removes the "out-of-process" qualifier
    /// or renames "soft" to "hardware" without updating the
    /// implementation. Not a runtime defence -- just a refusal to
    /// silently misclassify if the source drifts.
    #[test]
    fn doc_comment_classifies_as_soft_gate() {
        // We can't introspect doc comments at runtime in stable Rust,
        // so this test instead pins the public-API surface that
        // makes the classification true: `HelloGate` operates on
        // (scope, reason, ttl) tuples without any cryptographic
        // binding to a TPM operation, which is precisely the
        // "soft gate" shape (a Boolean returned to the calling
        // process). If someone ever upgrades this to a hard gate
        // they must change the API shape too -- e.g., by returning
        // a Hello-bound shared secret -- at which point this test
        // would need updating in concert.
        let gate = HelloGate::new();
        // The only output of the gate is `Result<()>`. That is, the
        // success channel is unit; no key material flows through.
        // A hard gate would necessarily produce key material to be
        // useful. The pin lives in the function signature of
        // `HelloGate::ensure_verified` -- if its return type ever
        // gains a payload it's a hard-gate upgrade and this test
        // (along with the threat-model docs) needs updating.
        //
        // Verifying the gate has the methods you'd expect of a
        // pure-software cache + UI helper (no TPM handle / no
        // NCryptKey / no shared-secret return).
        gate.invalidate_all();
        let _is_fresh: bool = gate.is_fresh("any", Duration::ZERO);
    }
}