foundation_keychain 0.1.7

Bitwarden-compatible vault + SSH key provisioning built on foundation_db and foundation_auth
//! Two-factor (TOTP) API — portable handlers (spec-57, F008 Stage 1).
//!
//! WHY: authenticator-app 2FA. Ported from OrangeVault `api/two_factor.rs`,
//! decoupled from any transport. The TOTP algorithm is
//! `foundation_auth::TOTPSecret` (decision 01); this module owns only the setup
//! flow and secret storage.
//!
//! WHAT: `get_authenticator` (fetch/generate the secret), `enable_authenticator`
//! (verify a code, turn 2FA on, issue a recovery code), `disable_authenticator`,
//! and `verify` (used by the login 2FA challenge — accepts a TOTP code or the
//! recovery code).
//!
//! HOW: the secret is a base32-encoded 256-bit random key; verification rebuilds
//! `TOTPSecret::from_bytes` from the decoded secret.

use crate::core::clock::wall_now_utc;
use base32::Alphabet;
use serde::Serialize;

use foundation_auth::shared::two_factor::TOTPSecret;

use crate::core::context::KeychainContext;
use crate::core::crypto;
use crate::core::error::{AppError, AppResult};
use crate::core::store::two_factor as store;
use crate::core::store::two_factor::TwoFactorRow;

const ALPHABET: Alphabet = Alphabet::Rfc4648 { padding: false };
/// TOTP verification window (±1 time step for clock drift).
const TOLERANCE: u64 = 1;

/// `GET /api/two-factor/get-authenticator` response.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AuthenticatorResponse {
    pub key: String,
    pub enabled: bool,
    pub object: &'static str,
}

/// `POST /api/two-factor/authenticator` (enable) response.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EnableAuthenticatorResponse {
    pub enabled: bool,
    pub key: String,
    pub recovery_code: String,
}

fn new_secret_base32() -> AppResult<String> {
    Ok(base32::encode(ALPHABET, &crypto::random_bytes(32)?))
}

fn totp_from_base32(secret: &str) -> AppResult<TOTPSecret> {
    let bytes = base32::decode(ALPHABET, secret)
        .ok_or_else(|| AppError::Internal("stored TOTP secret is not valid base32".into()))?;
    Ok(TOTPSecret::from_bytes(bytes))
}

/// Fetch the user's authenticator secret, generating (and persisting, disabled) a
/// fresh one if none exists yet.
///
/// # Errors
///
/// Returns an error if the operation fails.
pub async fn get_authenticator(
    ctx: &KeychainContext,
    user_uuid: &str,
) -> AppResult<AuthenticatorResponse> {
    if let Some(existing) = store::get(ctx.db(), user_uuid).await? {
        return Ok(AuthenticatorResponse {
            key: existing.secret,
            enabled: existing.enabled,
            object: "twoFactorAuthenticator",
        });
    }
    let now = wall_now_utc(ctx.ctx());
    let row = TwoFactorRow {
        user_uuid: user_uuid.to_string(),
        secret: new_secret_base32()?,
        enabled: false,
        recovery_code: None,
        created_at: now,
        updated_at: now,
    };
    store::upsert(ctx.db(), &row).await?;
    Ok(AuthenticatorResponse {
        key: row.secret,
        enabled: false,
        object: "twoFactorAuthenticator",
    })
}

/// Enable 2FA after verifying a current code. Issues a recovery code.
///
/// # Errors
///
/// Returns an error if the operation fails.
pub async fn enable_authenticator(
    ctx: &KeychainContext,
    user_uuid: &str,
    token: &str,
) -> AppResult<EnableAuthenticatorResponse> {
    let mut row = store::get(ctx.db(), user_uuid)
        .await?
        .ok_or_else(|| AppError::BadRequest("call get-authenticator first".into()))?;

    if !totp_from_base32(&row.secret)?.verify(token, TOLERANCE, ctx.ctx()) {
        return Err(AppError::BadRequest("invalid verification code".into()).into());
    }

    let recovery_code = crypto::new_uuid_v4()?.to_string().replace('-', "");
    row.enabled = true;
    row.recovery_code = Some(recovery_code.clone());
    row.updated_at = wall_now_utc(ctx.ctx());
    store::upsert(ctx.db(), &row).await?;

    Ok(EnableAuthenticatorResponse {
        enabled: true,
        key: row.secret,
        recovery_code,
    })
}

/// Disable 2FA after verifying a current code.
///
/// # Errors
///
/// Returns an error if the operation fails.
pub async fn disable_authenticator(
    ctx: &KeychainContext,
    user_uuid: &str,
    token: &str,
) -> AppResult<()> {
    let row = store::get(ctx.db(), user_uuid)
        .await?
        .ok_or_else(|| AppError::BadRequest("two-factor is not configured".into()))?;
    if !totp_from_base32(&row.secret)?.verify(token, TOLERANCE, ctx.ctx()) {
        return Err(AppError::BadRequest("invalid verification code".into()).into());
    }
    store::delete(ctx.db(), user_uuid).await
}

/// Whether the user has 2FA enabled (login must issue a challenge).
///
/// # Errors
///
/// Returns an error if the operation fails.
pub async fn is_enabled(ctx: &KeychainContext, user_uuid: &str) -> AppResult<bool> {
    Ok(store::get(ctx.db(), user_uuid)
        .await?
        .is_some_and(|r| r.enabled))
}

/// Verify a login 2FA response — accepts a TOTP code or the recovery code.
///
/// # Errors
///
/// Returns an error if the operation fails.
pub async fn verify(ctx: &KeychainContext, user_uuid: &str, code: &str) -> AppResult<bool> {
    let Some(row) = store::get(ctx.db(), user_uuid).await? else {
        return Ok(false);
    };
    if !row.enabled {
        return Ok(false);
    }
    if row.recovery_code.as_deref() == Some(code) {
        return Ok(true);
    }
    Ok(totp_from_base32(&row.secret)?.verify(code, TOLERANCE, ctx.ctx()))
}