vti-common 0.11.19

Shared server-side infrastructure for VTA and VTC services
Documentation
//! Canonical `/auth/*` flow handlers, parametrised over
//! [`crate::auth::AuthBackend`].
//!
//! Each handler takes a `&impl AuthBackend` plus a pre-extracted
//! input struct ([`ChallengeInput`], [`AuthenticateInput`],
//! [`RefreshInput`]) and returns the canonical wire response shape
//! from `vta_sdk::protocols::auth`. Transport-specific extraction
//! (REST JSON decode, DIDComm `unpack_signed`, JWS verification)
//! lives in the calling route handler.
//!
//! See [`crate::auth::backend`] for the trait shape and design notes.

pub mod authenticate;
pub mod challenge;
pub mod mint;
pub mod refresh;
pub mod store;

pub use authenticate::{handle_authenticate, handle_authenticate_with_aal};
pub use challenge::handle_challenge;
pub use mint::{MintedTokens, mint_session_tokens};
pub use refresh::handle_refresh;
pub use store::KeyspaceSessionStore;

use crate::auth::AuthError;
use crate::auth::session::Session;

/// Compute the `(amr, acr)` to issue on refresh.
///
/// Preserves the old session's AAL claims if present; falls back
/// to `(["did"], "aal1")` for pre-migration rows where amr is empty.
/// Without preservation, a session step-upped to `aal2` would
/// silently drop to `aal1` on every 15-minute refresh.
pub(crate) fn refresh_amr_acr(session: &Session) -> (Vec<String>, String) {
    if session.amr.is_empty() {
        (vec!["did".to_string()], "aal1".to_string())
    } else {
        (session.amr.clone(), session.acr.clone())
    }
}

/// Reject a `created_time` outside the configured freshness window.
///
/// Bounds DIDComm replay risk: an envelope from the holder can't
/// be silently replayed minutes/hours later. The window is
/// per-backend ([`crate::auth::AuthBackend::didcomm_freshness_window`])
/// — auth-family messages typically run at 60s, less time-sensitive
/// flows at 5min.
pub(crate) fn check_freshness(
    created_time: Option<u64>,
    session_created_at: u64,
    now: u64,
    window: u64,
) -> Result<(), AuthError> {
    let Some(ct) = created_time else {
        // REST transport — no message timestamp to check.
        return Ok(());
    };
    if ct < session_created_at {
        return Err(AuthError::StaleMessage);
    }
    if now.saturating_sub(ct) > window {
        return Err(AuthError::StaleMessage);
    }
    Ok(())
}

/// Constant-time challenge comparison.
///
/// Pre-condition: the challenge in the session was generated by
/// the canonical [`handle_challenge`] handler (32 random bytes as
/// 64 hex chars). The comparison happens on the raw byte
/// representations so a length-mismatch path doesn't short-circuit.
pub(crate) fn constant_time_challenge_eq(a: &str, b: &str) -> bool {
    let a = a.as_bytes();
    let b = b.as_bytes();
    if a.len() != b.len() {
        // Equal-length precondition: both sides should be 64 hex
        // chars. Mismatch is itself a rejection; returning early
        // is OK because the length is a non-secret invariant of
        // the challenge format (hex-encoded 32 random bytes).
        return false;
    }
    let mut diff = 0u8;
    for i in 0..a.len() {
        diff |= a[i] ^ b[i];
    }
    diff == 0
}