open-standards/openpay/crates/op-3ds2/src/version.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

258 lines
9.5 KiB
Rust

//! 3-D Secure protocol versions and per-version field capability matrix.
//!
//! The 2.x family is not one wire format: every minor revision added
//! or removed fields from the message catalogue and changed the
//! required/optional/forbidden classification for several. This module
//! captures the differences as a single `FieldRule` lookup so the
//! `message` codec can validate AReq/CReq payloads against the
//! negotiated version *before* they hit the DS.
//!
//! Versions supported:
//!
//! - **2.1.0** — initial production release. Browser-flow only;
//! no decoupled, no 3RI, no acctType for fraud scoring.
//! - **2.2.0** — added decoupled authentication, 3RI, whitelisting
//! ("trusted beneficiary"), and the `purchaseInstalData` recurring
//! fields. PSD2 RTS-baseline version.
//! - **2.3.0** — added the SPC (Secure Payment Confirmation) browser
//! API, expanded device-info envelope, and the PSD3-aligned
//! delegated-authentication identifier set.
use serde::{Deserialize, Serialize};
/// The three EMVCo 3DS protocol versions OpenPay speaks.
///
/// Each variant carries the canonical version string as published in
/// the EMVCo message catalogue.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ProtocolVersion {
/// 3DS 2.1.0 — initial production release.
V2_1,
/// 3DS 2.2.0 — adds decoupled, 3RI, whitelisting. PSD2 baseline.
V2_2,
/// 3DS 2.3.0 — adds SPC, expanded device-info, PSD3-aligned
/// delegated-authentication identifiers.
V2_3,
}
impl ProtocolVersion {
/// Canonical wire string (`"2.1.0"`, `"2.2.0"`, `"2.3.0"`).
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::V2_1 => "2.1.0",
Self::V2_2 => "2.2.0",
Self::V2_3 => "2.3.0",
}
}
/// Parse a version string from the DS version-check response.
#[must_use]
pub fn parse(s: &str) -> Option<Self> {
match s.trim() {
"2.1.0" => Some(Self::V2_1),
"2.2.0" => Some(Self::V2_2),
"2.3.0" => Some(Self::V2_3),
_ => None,
}
}
/// Highest-version-first ordering, used by version negotiation.
#[must_use]
pub const fn preference_order() -> [Self; 3] {
[Self::V2_3, Self::V2_2, Self::V2_1]
}
/// True if this version supports decoupled authentication (2.2.0+).
#[must_use]
pub const fn supports_decoupled(self) -> bool {
matches!(self, Self::V2_2 | Self::V2_3)
}
/// True if this version supports the 3RI subsequent-transaction
/// flow (2.2.0+).
#[must_use]
pub const fn supports_threeri(self) -> bool {
matches!(self, Self::V2_2 | Self::V2_3)
}
/// True if this version supports the SPC browser API (2.3.0).
#[must_use]
pub const fn supports_spc(self) -> bool {
matches!(self, Self::V2_3)
}
/// True if this version supports trusted-beneficiary
/// ("whitelisting") exemption signalling (2.2.0+).
#[must_use]
pub const fn supports_trusted_beneficiary(self) -> bool {
matches!(self, Self::V2_2 | Self::V2_3)
}
/// True if this version supports the data-only flow (2.2.0+).
#[must_use]
pub const fn supports_data_only(self) -> bool {
matches!(self, Self::V2_2 | Self::V2_3)
}
}
/// Per-field, per-version constraint.
///
/// Each [`ProtocolVersion`] has a different required/optional/forbidden
/// classification for the same field, depending on the
/// `messageCategory`, `deviceChannel`, and `acctType`. This enum is
/// the lookup result returned by [`field_rule`].
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum FieldRule {
/// Must be present; missing it yields
/// [`crate::Error::MissingField`].
Required,
/// May be present.
Optional,
/// Must not be present; including it yields
/// [`crate::Error::ForbiddenField`].
Forbidden,
/// Conditional on another field; the codec layer enforces the
/// specific predicate.
Conditional,
}
/// Look up the rule for a field in the AReq/CReq message catalogue.
///
/// `field` is the camelCase JSON key as published by EMVCo
/// (e.g. `"threeDSRequestorChallengeInd"`).
#[must_use]
pub const fn field_rule(field: &str, version: ProtocolVersion) -> FieldRule {
// We pattern-match on the byte length first to keep this `const`
// friendly, then on the string. The set is finite and small (~40
// discriminating fields across the three versions).
match (version, field.as_bytes()) {
// threeDSServerTransID — required in all versions.
(_, b"threeDSServerTransID") => FieldRule::Required,
// messageVersion — required in all.
(_, b"messageVersion") => FieldRule::Required,
// messageType — required in all.
(_, b"messageType") => FieldRule::Required,
// acctNumber — required in AReq for all versions.
(_, b"acctNumber") => FieldRule::Required,
// deviceChannel — required for AReq.
(_, b"deviceChannel") => FieldRule::Required,
// messageCategory — required.
(_, b"messageCategory") => FieldRule::Required,
// browserInfo — required when deviceChannel is "02" (browser);
// the codec layer enforces the conditional.
(_, b"browserInfo") => FieldRule::Conditional,
// sdkAppID — required when deviceChannel is "01" (app).
(_, b"sdkAppID") => FieldRule::Conditional,
(_, b"sdkEphemPubKey") => FieldRule::Conditional,
(_, b"sdkReferenceNumber") => FieldRule::Conditional,
(_, b"sdkTransID") => FieldRule::Conditional,
(_, b"sdkMaxTimeout") => FieldRule::Conditional,
// 3RI is 2.2.0+; forbidden in 2.1.0.
(ProtocolVersion::V2_1, b"threeRIInd") => FieldRule::Forbidden,
(ProtocolVersion::V2_2 | ProtocolVersion::V2_3, b"threeRIInd") => FieldRule::Optional,
// Decoupled is 2.2.0+; forbidden in 2.1.0.
(ProtocolVersion::V2_1, b"threeDSReqAuthMethod") => FieldRule::Forbidden,
(ProtocolVersion::V2_2 | ProtocolVersion::V2_3, b"threeDSReqAuthMethod") => {
FieldRule::Optional
}
(ProtocolVersion::V2_1, b"decoupledAuthInd") => FieldRule::Forbidden,
(ProtocolVersion::V2_2 | ProtocolVersion::V2_3, b"decoupledAuthInd") => {
FieldRule::Optional
}
(ProtocolVersion::V2_1, b"decoupledAuthMaxTime") => FieldRule::Forbidden,
(ProtocolVersion::V2_2 | ProtocolVersion::V2_3, b"decoupledAuthMaxTime") => {
FieldRule::Optional
}
// Whitelisting / trusted-beneficiary is 2.2.0+.
(ProtocolVersion::V2_1, b"whiteListStatus") => FieldRule::Forbidden,
(ProtocolVersion::V2_2 | ProtocolVersion::V2_3, b"whiteListStatus") => FieldRule::Optional,
// SPC is 2.3.0 only.
(ProtocolVersion::V2_1 | ProtocolVersion::V2_2, b"spcIncomp") => FieldRule::Forbidden,
(ProtocolVersion::V2_3, b"spcIncomp") => FieldRule::Optional,
// Delegated authentication — 2.3.0 expanded set.
(ProtocolVersion::V2_1 | ProtocolVersion::V2_2, b"delegatedAuthData") => {
FieldRule::Forbidden
}
(ProtocolVersion::V2_3, b"delegatedAuthData") => FieldRule::Optional,
// Anything else is optional by default; the codec layer is
// permissive on unknown extensions so scheme-specific addenda
// (`extensions[]`) pass through.
_ => FieldRule::Optional,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_strings_round_trip() {
for v in ProtocolVersion::preference_order() {
assert_eq!(ProtocolVersion::parse(v.as_str()), Some(v));
}
}
#[test]
fn unknown_version_parses_to_none() {
assert_eq!(ProtocolVersion::parse("2.4.0"), None);
assert_eq!(ProtocolVersion::parse(""), None);
}
#[test]
fn preference_order_is_high_to_low() {
let p = ProtocolVersion::preference_order();
assert_eq!(p[0], ProtocolVersion::V2_3);
assert_eq!(p[2], ProtocolVersion::V2_1);
}
#[test]
fn capabilities_match_spec() {
assert!(!ProtocolVersion::V2_1.supports_decoupled());
assert!(ProtocolVersion::V2_2.supports_decoupled());
assert!(ProtocolVersion::V2_3.supports_decoupled());
assert!(!ProtocolVersion::V2_1.supports_threeri());
assert!(ProtocolVersion::V2_2.supports_threeri());
assert!(!ProtocolVersion::V2_2.supports_spc());
assert!(ProtocolVersion::V2_3.supports_spc());
}
#[test]
fn threeri_forbidden_in_2_1_optional_in_higher() {
assert_eq!(
field_rule("threeRIInd", ProtocolVersion::V2_1),
FieldRule::Forbidden
);
assert_eq!(
field_rule("threeRIInd", ProtocolVersion::V2_2),
FieldRule::Optional
);
assert_eq!(
field_rule("threeRIInd", ProtocolVersion::V2_3),
FieldRule::Optional
);
}
#[test]
fn spc_only_in_2_3() {
assert_eq!(
field_rule("spcIncomp", ProtocolVersion::V2_2),
FieldRule::Forbidden
);
assert_eq!(
field_rule("spcIncomp", ProtocolVersion::V2_3),
FieldRule::Optional
);
}
#[test]
fn core_required_fields_are_required_everywhere() {
for v in ProtocolVersion::preference_order() {
assert_eq!(field_rule("threeDSServerTransID", v), FieldRule::Required);
assert_eq!(field_rule("messageVersion", v), FieldRule::Required);
assert_eq!(field_rule("acctNumber", v), FieldRule::Required);
}
}
}