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]>
258 lines
9.5 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|