use crate::confirm::token::{self, TokenError};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConfirmationMode {
#[default]
Off,
Shadow,
Enforce,
}
impl ConfirmationMode {
pub fn as_str(&self) -> &'static str {
match self {
ConfirmationMode::Off => "off",
ConfirmationMode::Shadow => "shadow",
ConfirmationMode::Enforce => "enforce",
}
}
pub fn from_env_str(raw: &str) -> Self {
match raw.trim().to_ascii_lowercase().as_str() {
"shadow" => ConfirmationMode::Shadow,
"enforce" => ConfirmationMode::Enforce,
_ => ConfirmationMode::Off,
}
}
}
pub const CONFIRMATION_REQUIRED: &str = "CONFIRMATION_REQUIRED";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Ok,
Missing,
Invalid,
Expired,
SubMismatch,
PurposeMismatch,
HashMismatch,
Replayed,
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",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PreBurn {
Burn { jti: String, ttl_secs: i64 },
Terminal(Outcome),
}
pub fn used_jti_key(jti: &str) -> String {
format!("gw:confirm:used:{jti}")
}
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 }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModeDecision {
Pass,
Reject(String),
}
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);
assert_eq!(
check_wd("u2", Some(&t), now),
PreBurn::Terminal(Outcome::SubMismatch)
);
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);
}
}