tsafe-core 1.0.13

Core runtime engine for tsafe — encrypted credential storage, process injection contracts, audit log, RBAC
Documentation
//! OS credential store for vault passwords (biometric / keyring unlock).
//!
//! On **macOS**, passwords are stored with
//! [`SecAccessControl`](https://developer.apple.com/documentation/security/secaccesscontrol)
//! **BiometryCurrentSet** (`kSecAccessControlBiometryCurrentSet`) constraints so **Touch ID** is
//! required when reading the secret and the credential is invalidated when the biometric enrollment
//! changes (new finger enrolled). This is strictly stronger than the former `USER_PRESENCE` flag
//! (Tranche 2 upgrade, E4.2, 2026-05-16 — see ADR-008 implementation note).
//!
//! If the OS returns a **missing entitlement** error (typical for unsigned `cargo install` / dev
//! binaries), [`store_password`](crate::keyring_store::store_password) falls back to the same [`keyring`](https://docs.rs/keyring)-based
//! login keychain storage as other platforms so setup commands still succeed.
//!
//! On other platforms, behavior is unchanged ([`keyring`](https://docs.rs/keyring)).
//!
//! **macOS:** [`quick_unlock_storage_note`](crate::keyring_store::quick_unlock_storage_note) classifies
//! whether the credential appears to live in the data-protection keychain vs the legacy login-keychain
//! path (for `doctor` / `biometric status` UX).

#[cfg(not(target_os = "macos"))]
mod generic;
#[cfg(target_os = "macos")]
mod macos;

#[cfg(not(target_os = "macos"))]
use generic as imp;
#[cfg(target_os = "macos")]
use macos as imp;

/// Store the vault password in the OS credential store.
pub fn store_password(profile: &str, password: &str) -> crate::errors::SafeResult<()> {
    imp::store_password(profile, password)
}

/// Retrieve the vault password from the OS credential store.
/// Returns `None` if no entry exists.
pub fn retrieve_password(profile: &str) -> crate::errors::SafeResult<Option<String>> {
    imp::retrieve_password(profile)
}

/// Remove the vault password from the OS credential store.
pub fn remove_password(profile: &str) -> crate::errors::SafeResult<()> {
    imp::remove_password(profile)
}

/// Check if a keyring entry exists for this profile.
///
/// Cost varies by platform — callers should treat this as **rare-path only**, not hot-loop:
///
/// - **macOS** — cheap probe via `kSecUseAuthenticationUISkip`; will not prompt for Touch ID
///   or any biometric UI even when the entry has a user-presence ACL. Safe to call freely.
/// - **Windows / Linux** — performs a full `keyring::Entry::get_password()` round-trip and
///   discards the bytes. No prompt fires on Windows Credential Manager, and Linux
///   Secret Service typically prompts only once at session start to unlock the keyring
///   (not per-read). Treat as a real OS round-trip — appropriate for `tsafe doctor`,
///   `tsafe biometric status`, and onboarding guards, but **not** appropriate for
///   render loops or hot paths.
///
/// Pairing this with [`retrieve_password`] back-to-back is wasteful — `retrieve_password`
/// already returns `Ok(None)` for missing entries. Call only one of the two in a single
/// code path. See `docs/keyring-audit.md` for the call-site survey.
pub fn has_password(profile: &str) -> bool {
    imp::has_password(profile)
}

/// Extra context for CLI (`biometric status`, `doctor`) about **macOS** quick-unlock storage tier.
///
/// On non-macOS, always returns `None`. On macOS, returns `None` when quick unlock is not
/// configured.
pub fn quick_unlock_storage_note(profile: &str) -> Option<String> {
    imp::quick_unlock_storage_note(profile)
}

/// Classify a keyring/Crypto error as a stale-credential signal.
///
/// A "stale credential" means the credential store entry exists but the password
/// it contains no longer opens the vault — typically because the vault password
/// was rotated or (on macOS) a new fingerprint was enrolled after `biometric enable`.
///
/// This helper checks the *error message text* returned from a failed vault open
/// that used the retrieved keyring password.  It does **not** inspect the keyring
/// read itself (the read may succeed; the stale credential is only discovered when
/// the returned password fails decryption and the decrypt error text indicates a
/// credential mismatch rather than a corrupted vault).
///
/// Current detection heuristics (conservative — may miss platform-specific variants):
/// - `"wrong password"` — from `SafeError::DecryptionFailed` display
/// - `"decryption failed"` — same
/// - `"credential"` + `"changed"` or `"stale"` — OS-level messages that may
///   surface on some keychain/libsecret backends
///
/// Callers that use `retrieve_password` followed by `Vault::open` and see
/// `SafeError::DecryptionFailed` SHOULD call this to decide whether to emit a
/// `StaleBiometricCredential` error rather than a generic decryption failure.
pub fn looks_like_stale_credential(error_display: &str) -> bool {
    let lower = error_display.to_lowercase();
    // Primary: vault decryption failed with a stored password → stale.
    if lower.contains("wrong password") || lower.contains("decryption failed") {
        return true;
    }
    // Secondary: OS-level messages (rare, platform-specific).
    if (lower.contains("credential") || lower.contains("keychain"))
        && (lower.contains("changed") || lower.contains("stale") || lower.contains("invalid"))
    {
        return true;
    }
    false
}

/// Internal account used only to verify the OS credential store accepts writes and deletes.
const PROBE_ACCOUNT: &str = "tsafekeyringprobe";

/// Returns `Ok(())` if the OS credential store can store and remove a credential.
///
/// Use this before offering interactive “quick unlock” setup (e.g. after `tsafe init`). This does
/// not prompt for biometrics. Fails on headless/SSH sessions or when no credential backend exists.
pub fn probe_credential_store() -> crate::errors::SafeResult<()> {
    store_password(PROBE_ACCOUNT, "tsafe-probe")?;
    remove_password(PROBE_ACCOUNT)?;
    Ok(())
}