road-runner-common 0.11.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! Enforcement contract shared by every service that gates a sensitive action on
//! a confirmation token (cex-ledger, cex-account). The security-critical pieces —
//! token verification, action-hash match, the single-use jti key, TTL, and the
//! shadow/enforce mode semantics — live here so they can never drift between
//! services. The only per-service code is the trivial atomic Redis burn, kept out
//! of this crate so `road-runner-common` needs no `redis` dependency.
//!
//! Typical caller:
//! ```ignore
//! if mode == ConfirmationMode::Off { return Ok(()); }
//! let expected = action.canonical_hash_hex(sub);
//! let outcome = match confirm::check(secret_raw, token, sub, action.purpose(), &expected, now) {
//!     confirm::PreBurn::Terminal(o) => o,
//!     confirm::PreBurn::Burn { jti, ttl_secs } => {
//!         // SET NX EX confirm::used_jti_key(&jti) 1 ttl_secs
//!         match burn { Ok(Some(_)) => Outcome::Ok, Ok(None) => Outcome::Replayed, Err(_) => Outcome::Unavailable }
//!     }
//! };
//! match confirm::apply_mode(mode, outcome) {
//!     ModeDecision::Pass => Ok(()),
//!     ModeDecision::Reject(msg) => Err(AppError::Forbidden { message: msg }),
//! }
//! ```

use crate::confirm::token::{self, TokenError};

/// Staged rollout mode, parsed from `*_CONFIRMATION_MODE` env.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConfirmationMode {
    /// Feature off — enforcement is a no-op.
    #[default]
    Off,
    /// Every failure is counted/logged but never rejected (safe rollout).
    Shadow,
    /// Failures reject with `CONFIRMATION_REQUIRED`.
    Enforce,
}

impl ConfirmationMode {
    pub fn as_str(&self) -> &'static str {
        match self {
            ConfirmationMode::Off => "off",
            ConfirmationMode::Shadow => "shadow",
            ConfirmationMode::Enforce => "enforce",
        }
    }

    /// Parse `off` | `shadow` | `enforce` (case-insensitive); anything else → `Off`.
    pub fn from_env_str(raw: &str) -> Self {
        match raw.trim().to_ascii_lowercase().as_str() {
            "shadow" => ConfirmationMode::Shadow,
            "enforce" => ConfirmationMode::Enforce,
            _ => ConfirmationMode::Off,
        }
    }
}

/// Stable error-code prefix the UI branches on to (re)start the confirm flow.
pub const CONFIRMATION_REQUIRED: &str = "CONFIRMATION_REQUIRED";

/// The outcome of evaluating a confirmation token against an expected action.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
    Ok,
    Missing,
    Invalid,
    Expired,
    SubMismatch,
    PurposeMismatch,
    HashMismatch,
    Replayed,
    /// Secret unset/invalid or Redis unreachable — fail closed in enforce mode.
    Unavailable,
}

impl Outcome {
    pub fn as_str(&self) -> &'static str {
        match self {
            Outcome::Ok => "ok",
            Outcome::Missing => "missing",
            Outcome::Invalid => "invalid",
            Outcome::Expired => "expired",
            Outcome::SubMismatch => "sub_mismatch",
            Outcome::PurposeMismatch => "purpose_mismatch",
            Outcome::HashMismatch => "hash_mismatch",
            Outcome::Replayed => "replayed",
            Outcome::Unavailable => "unavailable",
        }
    }
}

/// Result of the pure, pre-Redis check.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PreBurn {
    /// All static checks passed; the caller must atomically
    /// `SET NX EX used_jti_key(jti) 1 ttl_secs` and map the result to an
    /// [`Outcome`] (`Some` → `Ok`, `None` → `Replayed`, error → `Unavailable`).
    Burn { jti: String, ttl_secs: i64 },
    /// A terminal outcome that needs no Redis round-trip.
    Terminal(Outcome),
}

/// Redis key under which a spent `jti` is burned for the token's remaining life.
pub fn used_jti_key(jti: &str) -> String {
    format!("gw:confirm:used:{jti}")
}

/// Verify the token and match subject / purpose / action-hash — everything except
/// the single-use jti burn. Pure (no I/O). Returns [`PreBurn::Burn`] when only the
/// replay check remains.
pub fn check(
    secret_raw: Option<&str>,
    token: Option<&str>,
    expected_sub: &str,
    expected_purpose: &str,
    expected_hash_hex: &str,
    now_epoch: i64,
) -> PreBurn {
    let Some(token) = token.map(str::trim).filter(|t| !t.is_empty()) else {
        return PreBurn::Terminal(Outcome::Missing);
    };
    let Some(secret_raw) = secret_raw else {
        return PreBurn::Terminal(Outcome::Unavailable);
    };
    let Ok(secret) = token::decode_secret(secret_raw) else {
        return PreBurn::Terminal(Outcome::Unavailable);
    };

    let claims = match token::verify(&secret, token, now_epoch) {
        Ok(claims) => claims,
        Err(TokenError::Expired) => return PreBurn::Terminal(Outcome::Expired),
        Err(TokenError::Invalid) => return PreBurn::Terminal(Outcome::Invalid),
    };

    if claims.sub != expected_sub {
        return PreBurn::Terminal(Outcome::SubMismatch);
    }
    if claims.purpose != expected_purpose {
        return PreBurn::Terminal(Outcome::PurposeMismatch);
    }
    if claims.tx_hash_hex != expected_hash_hex {
        return PreBurn::Terminal(Outcome::HashMismatch);
    }

    let ttl_secs = (claims.exp - now_epoch).max(1);
    PreBurn::Burn { jti: claims.jti, ttl_secs }
}

/// What the enforcing service should do given the final outcome and mode.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModeDecision {
    /// Allow the request (success, or a shadow-mode failure).
    Pass,
    /// Reject with this `CONFIRMATION_REQUIRED: …` message (enforce-mode failure).
    Reject(String),
}

/// Map `(mode, outcome)` to a decision. `Off` is expected to be handled by the
/// caller before calling [`check`]; here it also passes.
pub fn apply_mode(mode: ConfirmationMode, outcome: Outcome) -> ModeDecision {
    if outcome == Outcome::Ok {
        return ModeDecision::Pass;
    }
    match mode {
        ConfirmationMode::Off | ConfirmationMode::Shadow => ModeDecision::Pass,
        ConfirmationMode::Enforce => {
            ModeDecision::Reject(format!("{CONFIRMATION_REQUIRED}: confirmation {}", outcome.as_str()))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::confirm::action::{ConfirmAction, Withdrawal};
    use crate::confirm::token::{mint, ConfirmationClaims};

    const SECRET_B64: &str = "KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio=";

    fn withdrawal() -> Withdrawal {
        Withdrawal {
            amount: "2.5".parse().unwrap(),
            currency_id: 42,
            blockchain_id: 7,
            send_type: "ADDRESS".into(),
            recipient: "bc1qexampleaddressxyz".into(),
        }
    }

    fn token_for(sub: &str, action: &Withdrawal, jti: &str, now: i64, exp: i64) -> String {
        let secret = token::decode_secret(SECRET_B64).unwrap();
        mint(
            &secret,
            &ConfirmationClaims {
                v: 1,
                sub: sub.into(),
                purpose: action.purpose().into(),
                tx_hash_hex: action.canonical_hash_hex(sub),
                jti: jti.into(),
                method: "webauthn".into(),
                iat: now,
                exp,
            },
        )
    }

    fn check_wd(sub: &str, token: Option<&str>, now: i64) -> PreBurn {
        let action = withdrawal();
        check(
            Some(SECRET_B64),
            token,
            sub,
            action.purpose(),
            &action.canonical_hash_hex(sub),
            now,
        )
    }

    #[test]
    fn happy_path_reaches_burn() {
        let now = 1_700_000_000;
        let t = token_for("u1", &withdrawal(), "jti-1", now, now + 90);
        match check_wd("u1", Some(&t), now) {
            PreBurn::Burn { jti, ttl_secs } => {
                assert_eq!(jti, "jti-1");
                assert!(ttl_secs > 0 && ttl_secs <= 90);
            }
            other => panic!("expected Burn, got {other:?}"),
        }
    }

    #[test]
    fn missing_and_unavailable() {
        let now = 1_700_000_000;
        assert_eq!(check_wd("u1", None, now), PreBurn::Terminal(Outcome::Missing));
        let action = withdrawal();
        assert_eq!(
            check(None, Some("x.y"), "u1", action.purpose(), "h", now),
            PreBurn::Terminal(Outcome::Unavailable)
        );
    }

    #[test]
    fn sub_purpose_hash_mismatches() {
        let now = 1_700_000_000;
        let t = token_for("u1", &withdrawal(), "jti-2", now, now + 90);
        // token for u1 presented as u2
        assert_eq!(
            check_wd("u2", Some(&t), now),
            PreBurn::Terminal(Outcome::SubMismatch)
        );
        // token bound to a different amount
        let other = Withdrawal { amount: "9.9".parse().unwrap(), ..withdrawal() };
        let t2 = token_for("u1", &other, "jti-3", now, now + 90);
        assert_eq!(
            check_wd("u1", Some(&t2), now),
            PreBurn::Terminal(Outcome::HashMismatch)
        );
    }

    #[test]
    fn expired_and_bad_signature() {
        let now = 1_700_000_000;
        let t = token_for("u1", &withdrawal(), "jti-4", now, now - 1);
        assert_eq!(check_wd("u1", Some(&t), now), PreBurn::Terminal(Outcome::Expired));
        assert_eq!(
            check_wd("u1", Some("garbage.token"), now),
            PreBurn::Terminal(Outcome::Invalid)
        );
    }

    #[test]
    fn mode_decisions() {
        assert_eq!(apply_mode(ConfirmationMode::Enforce, Outcome::Ok), ModeDecision::Pass);
        assert_eq!(apply_mode(ConfirmationMode::Shadow, Outcome::Missing), ModeDecision::Pass);
        match apply_mode(ConfirmationMode::Enforce, Outcome::Replayed) {
            ModeDecision::Reject(m) => assert!(m.contains("CONFIRMATION_REQUIRED")),
            _ => panic!("enforce failure must reject"),
        }
    }

    #[test]
    fn mode_parsing() {
        assert_eq!(ConfirmationMode::from_env_str(" Enforce "), ConfirmationMode::Enforce);
        assert_eq!(ConfirmationMode::from_env_str("SHADOW"), ConfirmationMode::Shadow);
        assert_eq!(ConfirmationMode::from_env_str("nonsense"), ConfirmationMode::Off);
    }
}