foundation_keychain 0.1.7

Bitwarden-compatible vault + SSH key provisioning built on foundation_db and foundation_auth
//! Cross-platform password crypto helpers (spec-57, F008 Stage 1).
//!
//! WHY: Per [decision 01](../../../specifications/57-foundation-social-and-keychain/decisions/01-crypto-backend.md)
//! the keychain owns no crypto — it derives password verifiers via
//! `foundation_auth::shared::password_hash`. But that PBKDF2 is **sync + infallible
//! on native** and **async + fallible (`WebCrypto`) on wasm**. These wrappers hide
//! that split behind a single always-`async` API so handlers have one call site.
//!
//! WHAT: `derive_password_hash` / `verify_password_hash` over PBKDF2-HMAC-SHA256,
//! `random_salt`, and a constant-time comparison.
//!
//! HOW: server-side verifier = PBKDF2 of the client-provided `master_password_hash`
//! with a per-user random salt at [`SERVER_ITERATIONS`]. On login the same
//! derivation is recomputed and compared in constant time.

use foundation_auth::shared::password_hash::pbkdf2_derive;
use foundation_compact::entropy;
use foundation_compact::uuid::{self, Uuid};

use crate::core::error::{AppError, AppResult};

/// Derived-key length (bytes) for the server-side password verifier.
pub const KEY_LEN: u32 = 32;

/// Server-side re-hash iteration count. `WebCrypto` caps PBKDF2 at 100k, so this is
/// shared by both backends for identical output (`PBKDF2_SERVER_ITERATIONS`).
pub const SERVER_ITERATIONS: u32 = foundation_auth::shared::password_hash::PBKDF2_SERVER_ITERATIONS;

/// Derive the server-side password verifier from the client's master password hash.
#[cfg(not(target_family = "wasm"))]
///
/// # Errors
///
/// Returns an error if the operation fails.
// Async keeps every derivation entry point uniform for the wasm host.
#[allow(clippy::unused_async)]
pub async fn derive_password_hash(
    client_hash: &[u8],
    salt: &[u8],
    iterations: u32,
) -> AppResult<Vec<u8>> {
    Ok(pbkdf2_derive(client_hash, salt, iterations, KEY_LEN)
        .as_bytes()
        .to_vec())
}

/// Derive the server-side password verifier from the client's master password hash.
#[cfg(target_family = "wasm")]
pub async fn derive_password_hash(
    client_hash: &[u8],
    salt: &[u8],
    iterations: u32,
) -> AppResult<Vec<u8>> {
    let key = pbkdf2_derive(client_hash, salt, iterations, KEY_LEN)
        .await
        .map_err(|e| {
            crate::core::error::AppError::Internal(format!("pbkdf2 derive failed: {e:?}"))
        })?;
    Ok(key.as_bytes().to_vec())
}

/// Recompute the verifier and compare it to the stored one in constant time.
///
/// # Errors
///
/// Returns an error if the operation fails.
pub async fn verify_password_hash(
    client_hash: &[u8],
    salt: &[u8],
    expected: &[u8],
    iterations: u32,
) -> AppResult<bool> {
    let derived = derive_password_hash(client_hash, salt, iterations).await?;
    Ok(constant_time_eq(&derived, expected))
}

/// Constant-time byte comparison (avoids leaking match position via timing).
#[must_use]
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff = 0u8;
    for (x, y) in a.iter().zip(b.iter()) {
        diff |= x ^ y;
    }
    diff == 0
}

/// `len` random bytes from the platform entropy source (rule 11).
///
/// # Errors
///
/// Returns `AppError::Internal` if the platform entropy source is unavailable.
pub fn random_bytes(len: usize) -> AppResult<Vec<u8>> {
    // `fill_uninit` writes every byte before it hands the slice back, so the
    // buffer needs no zero pass. That also keeps a zeroed literal away from the
    // key material, which a static scanner reads as a hard-coded value.
    let mut bytes = vec![std::mem::MaybeUninit::<u8>::uninit(); len];
    let filled = entropy::fill_uninit(&mut bytes)
        .map_err(|e| AppError::Internal(format!("entropy source unavailable: {e}")))?;
    Ok(filled.to_vec())
}

/// A fresh random version-4 UUID from the `foundation_compact` entropy seam.
///
/// # Errors
///
/// Returns `AppError::Internal` if the platform entropy source is unavailable.
pub fn new_uuid_v4() -> AppResult<Uuid> {
    uuid::new_v4()
        .map_err(|e| AppError::Internal(format!("entropy source unavailable: {e}")))
        .map_err(Into::into)
}

/// A fresh 16-byte random salt from the platform entropy source.
///
/// # Errors
///
/// Returns `AppError::Internal` if the platform entropy source is unavailable.
pub fn random_salt() -> AppResult<Vec<u8>> {
    random_bytes(16)
}

/// Compute the Bitwarden client-side master password hash from a raw password.
///
/// `bw login` sends the raw master password; `localhost/register` sends the
/// pre-hashed value.  This function computes the hash chain `bw` uses so the
/// server can verify it against the stored verifier:
/// ```text
///   masterKey  = PBKDF2-SHA256(password, email_lower, client_iterations, 256)
///   localHash  = PBKDF2-SHA256(masterKey, password, 1, 256)
/// ```
/// Returns `Some(base64(localHash))` on success, `None` if the password is too
/// short to be a real master password.
///
/// # Errors
///
/// Returns an error if the operation fails.
// Async keeps every derivation entry point uniform for the wasm host.
#[allow(clippy::unused_async)]
pub async fn derive_client_hash(
    password: &str,
    email: &str,
    client_iterations: u32,
) -> Result<Option<String>, crate::core::error::AppError> {
    use base64::Engine;
    use pbkdf2::pbkdf2_hmac;
    use sha2::Sha256;

    let email_lower = email.trim().to_lowercase();
    if password.len() < 8 {
        return Ok(None);
    }
    let mut mk = vec![0u8; 32];
    pbkdf2_hmac::<Sha256>(
        password.as_bytes(),
        email_lower.as_bytes(),
        client_iterations,
        &mut mk,
    );
    let mut lh = vec![0u8; 32];
    pbkdf2_hmac::<Sha256>(&mk, password.as_bytes(), 1, &mut lh);
    Ok(Some(base64::engine::general_purpose::STANDARD.encode(&lh)))
}

/// A fresh 32-byte random key (base64-encoded string) from the platform
/// entropy source.
///
/// Used as a placeholder `akey` when no client-provided key is sent at
/// registration — ensures `Key` in the login response is always non-null
/// so the `bw` CLI doesn't refuse to proceed.
///
/// # Errors
///
/// Returns `AppError::Internal` if the platform entropy source is unavailable.
pub fn random_key() -> AppResult<String> {
    use base64::Engine;
    Ok(base64::engine::general_purpose::STANDARD.encode(random_bytes(32)?))
}