road-runner-common 0.11.0

Shared Rust utilities for exchange ecosystem backend services.
Documentation
//! Confirmation token — the short-lived, single-use proof that a user confirmed
//! exactly one sensitive action. Minted by cex-auth after a successful WebAuthn
//! assertion (or dynamic-linking OTP) and enforced by the executing service
//! (cex-ledger, cex-account), which recomputes the action hash from the request
//! body, checks the HMAC with the shared `CONFIRMATION_TOKEN_SECRET`, and burns
//! `jti` in Redis.
//!
//! Format: `base64url(payload_json) + "." + base64url(HMAC_SHA256(secret, payload_b64))`.
//! This is a versioned contract (`v = 1`): the wire shape must never change under
//! `v1` — bump the version and update every consumer together.

use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;

type HmacSha256 = Hmac<Sha256>;

/// Claims inside a confirmation token. `tx_hash_hex` is the domain-separated
/// action hash (see [`crate::confirm::ConfirmAction`]); the name is kept for wire
/// compatibility with already-issued withdrawal tokens.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfirmationClaims {
    pub v: u8,
    pub sub: String,
    /// Which action the token authorizes (e.g. `withdrawal`, `create_api_key`).
    pub purpose: String,
    /// hex(SHA-256) of the canonical action payload the user confirmed.
    pub tx_hash_hex: String,
    /// Single-use id, burned by the enforcing service.
    pub jti: String,
    /// How the user confirmed: `webauthn` | `otp`.
    pub method: String,
    pub iat: i64,
    pub exp: i64,
}

/// Why a token failed verification. Enforcing services map `Expired` and the rest
/// to distinct outcomes for their shadow/enforce metrics.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenError {
    /// Structurally malformed, bad signature, or unsupported version.
    Invalid,
    /// Well-formed and correctly signed but past `exp`.
    Expired,
}

/// Secret decoded from base64 was missing or too short.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SecretError;

impl std::fmt::Display for SecretError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("CONFIRMATION_TOKEN_SECRET must be valid base64 decoding to >= 32 bytes")
    }
}

impl std::error::Error for SecretError {}

/// Decode the base64 secret, tolerating the usual secret-store quirks
/// (whitespace, URL-safe / unpadded variants). Requires >= 32 decoded bytes.
pub fn decode_secret(raw: &str) -> Result<Vec<u8>, SecretError> {
    use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE};
    let trimmed = raw.trim();
    let key = STANDARD
        .decode(trimmed)
        .or_else(|_| STANDARD_NO_PAD.decode(trimmed))
        .or_else(|_| URL_SAFE.decode(trimmed))
        .or_else(|_| URL_SAFE_NO_PAD.decode(trimmed))
        .map_err(|_| SecretError)?;
    if key.len() < 32 {
        return Err(SecretError);
    }
    Ok(key)
}

/// Mint a signed confirmation token. Serialization of the simple claims struct is
/// infallible in practice.
pub fn mint(secret: &[u8], claims: &ConfirmationClaims) -> String {
    let payload_b64 =
        URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).expect("claims serialize"));
    let mut mac =
        <HmacSha256 as Mac>::new_from_slice(secret).expect("HMAC accepts any key length");
    mac.update(payload_b64.as_bytes());
    let sig_b64 = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
    format!("{payload_b64}.{sig_b64}")
}

/// Verify signature + version + expiry and return the claims. Subject / purpose /
/// action-hash / jti checks are the caller's job — they need request context.
pub fn verify(
    secret: &[u8],
    token: &str,
    now_epoch: i64,
) -> Result<ConfirmationClaims, TokenError> {
    let (payload_b64, sig_b64) = token.split_once('.').ok_or(TokenError::Invalid)?;
    let sig = URL_SAFE_NO_PAD.decode(sig_b64).map_err(|_| TokenError::Invalid)?;

    let mut mac =
        <HmacSha256 as Mac>::new_from_slice(secret).expect("HMAC accepts any key length");
    mac.update(payload_b64.as_bytes());
    mac.verify_slice(&sig).map_err(|_| TokenError::Invalid)?;

    let payload = URL_SAFE_NO_PAD.decode(payload_b64).map_err(|_| TokenError::Invalid)?;
    let claims: ConfirmationClaims =
        serde_json::from_slice(&payload).map_err(|_| TokenError::Invalid)?;

    if claims.v != 1 {
        return Err(TokenError::Invalid);
    }
    if now_epoch >= claims.exp {
        return Err(TokenError::Expired);
    }
    Ok(claims)
}

#[cfg(test)]
mod tests {
    use super::*;

    const SECRET_B64: &str = "KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio="; // 32 × '*'

    fn claims(now: i64) -> ConfirmationClaims {
        ConfirmationClaims {
            v: 1,
            sub: "user-1".into(),
            purpose: "withdrawal".into(),
            tx_hash_hex: "deadbeef".into(),
            jti: "jti-1".into(),
            method: "webauthn".into(),
            iat: now,
            exp: now + 90,
        }
    }

    #[test]
    fn mint_then_verify_roundtrips() {
        let secret = decode_secret(SECRET_B64).unwrap();
        let now = 1_700_000_000;
        let token = mint(&secret, &claims(now));
        let got = verify(&secret, &token, now).unwrap();
        assert_eq!(got.sub, "user-1");
        assert_eq!(got.tx_hash_hex, "deadbeef");
        assert_eq!(got.jti, "jti-1");
    }

    #[test]
    fn tampered_signature_is_invalid() {
        let secret = decode_secret(SECRET_B64).unwrap();
        let now = 1_700_000_000;
        let mut token = mint(&secret, &claims(now));
        token.replace_range(token.len() - 4.., "AAAA");
        assert_eq!(verify(&secret, &token, now).unwrap_err(), TokenError::Invalid);
    }

    #[test]
    fn expired_token_reports_expired() {
        let secret = decode_secret(SECRET_B64).unwrap();
        let now = 1_700_000_000;
        let token = mint(&secret, &claims(now));
        assert_eq!(
            verify(&secret, &token, now + 91).unwrap_err(),
            TokenError::Expired
        );
    }

    #[test]
    fn short_secret_rejected() {
        assert!(decode_secret("YWJj").is_err()); // "abc"
    }
}