open-standards/openpay/crates/op-3ds2/src/auth_response.rs
Transaction Science 3fe109d58f ingest batch 4: 9 crates · table-stakes completers across all three workspaces
OpenPay (3 crates, ~13k LOC, 175 tests)
  op-connect           sub-merchant onboarding + KYB + split-payments + payouts + 1099-K
  op-3ds2              3DS2.3 SCA processor (AReq/ARes/CReq/CRes/RReq/RRes + exemptions)
  op-tax               US/EU/UK/AU/CA/IN tax with Avalara/Stripe/CCH/Vertex/TaxJar adapters

Smart Byte (3 crates, ~7k LOC, 57 tests)
  smart-byte-libp2p    libp2p transport (kad/gossipsub/mdns/autonat/relay/dcutr/identify/ping/req_resp)
  smart-byte-crdt      LWW/GCounter/PnCounter/OrSet/LwwMap/RgaList/2P-Set + HLC + vector clock + Automerge/Yjs interop
  smart-byte-keri-witness  KERI key-rotation cycle + witness layer + watchers + recovery + delegation

EOC (3 crates, ~8k LOC, 108 tests)
  eoc-tools            function-calling across Anthropic/OpenAI/Google/Mistral/Cohere + parallel exec + sandbox
  eoc-multimodal       vision (CLIP/SigLIP/ColPali) + audio (Whisper/MMS) + video sampling + modality router
  eoc-spec-decode      vanilla + SpS-with-temp + lookahead speculative decoding + EAGLE/Medusa stubs

All three workspaces compile cleanly. Forbidden-unsafe, no unwrap outside tests.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 04:00:04 -04:00

211 lines
7 KiB
Rust

//! Authentication-response primitives: `transStatus`, ECI, CAVV.
//!
//! `transStatus` is the EMVCo-coded outcome a 3DS authentication
//! produces. Once the issuer ACS settles the cardholder challenge, the
//! ARes (or final CRes for challenge flows) carries one of the
//! letters below. The ECI and CAVV that the acquirer forwards in the
//! ISO 8583 / ISO 20022 authorization request derive from this
//! letter plus the card scheme.
//!
//! ## `transStatus` values (EMVCo Table A.6)
//!
//! - `Y` — Authentication / Account Verification Successful.
//! - `N` — Not Authenticated / Account Not Verified.
//! - `U` — Authentication / Account Verification could not be performed.
//! - `A` — Attempts processing performed; not authenticated.
//! - `C` — Challenge Required (cardholder must complete a challenge).
//! - `D` — Challenge Required; decoupled authentication.
//! - `R` — Authentication / Account Verification Rejected.
//! - `I` — Informational only; 3RI / data-only.
use base64::Engine;
use serde::{Deserialize, Serialize};
use crate::directory_server::DsRoute;
use crate::error::{Error, Result};
/// `transStatus` enum.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransactionStatus {
/// `Y` — authenticated. ECI 05 / 02 (scheme-dependent), CAVV
/// present.
Authenticated,
/// `N` — not authenticated. No CAVV.
NotAuthenticated,
/// `A` — attempts (stand-in). ECI 06 / 01, CAVV present (proxy).
AttemptStanin,
/// `R` — rejected by the issuer. No CAVV.
Rejected,
/// `C` — challenge required (in-band).
ChallengeRequired,
/// `D` — challenge required (decoupled).
ChallengeRequiredDecoupled,
/// `A` after a successful attempt cycle. Synonym for `AttemptStanin`
/// used by some integrators.
AttemptedSuccessful,
/// `I` — informational only (3RI / data-only). No ECI, no CAVV.
InfoOnly,
}
impl TransactionStatus {
/// Wire-format single-letter code.
#[must_use]
pub const fn as_letter(self) -> &'static str {
match self {
Self::Authenticated => "Y",
Self::NotAuthenticated => "N",
Self::AttemptStanin | Self::AttemptedSuccessful => "A",
Self::Rejected => "R",
Self::ChallengeRequired => "C",
Self::ChallengeRequiredDecoupled => "D",
Self::InfoOnly => "I",
}
}
/// Parse a wire-letter back into a [`TransactionStatus`]. Returns
/// `None` for unknown letters.
#[must_use]
pub fn from_letter(s: &str) -> Option<Self> {
match s {
"Y" => Some(Self::Authenticated),
"N" => Some(Self::NotAuthenticated),
"A" => Some(Self::AttemptStanin),
"R" => Some(Self::Rejected),
"C" => Some(Self::ChallengeRequired),
"D" => Some(Self::ChallengeRequiredDecoupled),
"I" => Some(Self::InfoOnly),
_ => None,
}
}
}
/// Electronic Commerce Indicator newtype.
///
/// Carried in the acquirer auth request as a two-digit field. Different
/// schemes encode the same semantics with different values; see
/// [`eci_for`] for the per-scheme mapping.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Eci(pub String);
/// Cardholder Authentication Verification Value (CAVV) /
/// Accountholder Authentication Value (AAV) / Amex Verification
/// Value (AEVV).
///
/// All three scheme names map to the same conceptual bag: a base64
/// blob the acquirer forwards to the issuer for cryptographic
/// verification. We model it as a thin newtype over `Vec<u8>` so the
/// codec is unambiguous about whether we hold raw or base64 bytes.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Cavv(Vec<u8>);
impl Cavv {
/// Construct from raw bytes.
#[must_use]
pub fn from_bytes(b: Vec<u8>) -> Self {
Self(b)
}
/// Decode from the standard base64 string that travels on the wire.
pub fn decode_base64(s: &str) -> Result<Self> {
base64::engine::general_purpose::STANDARD
.decode(s)
.map(Self)
.map_err(|_| Error::InvalidCryptogram)
}
/// Encode back to the wire base64 form.
#[must_use]
pub fn encode_base64(&self) -> String {
base64::engine::general_purpose::STANDARD.encode(&self.0)
}
/// Borrow the raw byte slice.
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
/// Per-scheme ECI mapping for a given `transStatus` letter.
///
/// Visa / Amex / Discover / JCB use the `05` / `06` coding;
/// Mastercard uses the `02` / `01` coding. All other letters map to
/// `"07"` (non-3DS / no liability shift) by convention.
#[must_use]
pub const fn eci_for(scheme: DsRoute, trans_status: &str) -> &'static str {
let bytes = trans_status.as_bytes();
let first = if bytes.is_empty() { 0 } else { bytes[0] };
match scheme {
DsRoute::Mastercard => match first {
b'Y' => "02",
b'A' => "01",
_ => "00",
},
// Visa / Amex / Discover / JCB / UnionPay / Mir all use 05/06.
_ => match first {
b'Y' => "05",
b'A' => "06",
_ => "07",
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trans_status_round_trips_via_letter() {
for ts in [
TransactionStatus::Authenticated,
TransactionStatus::NotAuthenticated,
TransactionStatus::AttemptStanin,
TransactionStatus::Rejected,
TransactionStatus::ChallengeRequired,
TransactionStatus::ChallengeRequiredDecoupled,
TransactionStatus::InfoOnly,
] {
let letter = ts.as_letter();
let back = TransactionStatus::from_letter(letter).unwrap();
assert_eq!(back.as_letter(), letter);
}
}
#[test]
fn visa_eci_05_on_success_06_on_attempts() {
assert_eq!(eci_for(DsRoute::Visa, "Y"), "05");
assert_eq!(eci_for(DsRoute::Visa, "A"), "06");
assert_eq!(eci_for(DsRoute::Visa, "N"), "07");
}
#[test]
fn mastercard_eci_02_on_success_01_on_attempts() {
assert_eq!(eci_for(DsRoute::Mastercard, "Y"), "02");
assert_eq!(eci_for(DsRoute::Mastercard, "A"), "01");
assert_eq!(eci_for(DsRoute::Mastercard, "N"), "00");
}
#[test]
fn amex_discover_jcb_match_visa_coding() {
assert_eq!(eci_for(DsRoute::Amex, "Y"), "05");
assert_eq!(eci_for(DsRoute::Discover, "Y"), "05");
assert_eq!(eci_for(DsRoute::Jcb, "Y"), "05");
}
#[test]
fn cavv_round_trip() {
let raw = vec![0xDE, 0xAD, 0xBE, 0xEF];
let c = Cavv::from_bytes(raw.clone());
let s = c.encode_base64();
let back = Cavv::decode_base64(&s).unwrap();
assert_eq!(back.as_bytes(), &raw);
}
#[test]
fn cavv_rejects_invalid_base64() {
assert!(matches!(
Cavv::decode_base64("!!!not-base-64!!!"),
Err(Error::InvalidCryptogram)
));
}
}