openpay/ (+3 crates, ~8400 LOC, 162 tests)
op-iso8583/ ISO 8583 messages for direct acquirer / card-network
connectivity. MTIs, primary+secondary bitmaps, full
DE catalogue, BCD/ASCII/EBCDIC/LLVAR/LLLVAR codecs,
five dialects (Visa Base I, Mastercard MDS, Amex GNS,
Discover, JCB), network-management messages
(sign-on/echo/cutover), DE 55 EMV TLV.
op-mobile-wallets/ Apple Pay (ECDH-P256 + AES-256-GCM), Google Pay ECv2
(ECDH + HKDF + AES-256-CTR + HMAC), Samsung Pay. PAN
never leaves the decryption boundary as a String;
lands in VaultRef. No-PAN-leakage assertions per
wallet.
op-routing/ Least-cost routing + MCC-aware routing + intelligent
retry / soft-decline recovery. 231-MCC catalogue.
Real ISO 8583 DE-39 soft/hard decline taxonomies.
smart-byte/smart-byte-rs/ (+3 crates, ~7100 LOC, 96 tests)
smart-byte-vc/ W3C Verifiable Credentials 2.0: VCDM, presentations,
Data Integrity proofs (eddsa-jcs-2022), VC-JWT,
SD-JWT (selective disclosure), Bitstring Status
List 2021. Cargo-bridge maps VCs to Smart Byte
envelopes with SAID stability.
smart-byte-did/ W3C DID resolution: did:key (Ed25519/P-256/secp256k1),
did:web (HTTPS), did:peer (numalgo 2+4), did:jwk,
did:ion (feature-gated stub). DID URL dereferencer.
smart-byte-pq/ Post-quantum signatures: ML-DSA-44/65/87 (FIPS 204),
SLH-DSA across 12 parameter sets (FIPS 205), FN-DSA
(draft FIPS 206, feature-gated). Hybrid Ed25519 +
ML-DSA pattern for the transition. Secret-key
zeroize-on-drop.
eoc/eoc-rs/ (+3 crates, ~6450 LOC, 76 tests)
eoc-vendor-api/ NeuralBackend implementations: Anthropic, OpenAI,
Google, Mistral, Cohere, Groq, Together, Fireworks.
SSE streaming. Prompt-caching where supported.
Joule estimates per published energy coefficient
(12-model curated table).
eoc-embeddings/ 21 embedding models behind one Embedder trait:
OpenAI v3 (3), Cohere v3 (3), Voyage (5), Mistral,
Jina, plus local ONNX (BGE x3, nomic, mxbai, GTE,
E5) behind 'local' feature. Matryoshka-style
dimension projection. Per-content-hash cache.
eoc-route-learned/ RouteLLM-style matrix-factorisation router +
multinomial-logreg classifier + LinUCB / Thompson
bandits for online learning. LearnedCascade wraps
the rule-based cascade with a confidence-threshold
skip policy. Synthetic-eval accuracy: MF 0.935,
LogReg 0.980.
Quality:
- cargo check across all three workspaces: clean
- cargo clippy --all-targets -- -D warnings on every new crate: clean
- 334 new tests total; all passing
- #![forbid(unsafe_code)] across every new crate
- No unwrap/expect outside #[cfg(test)]
Rust 1.95.0 edition 2024 (inherited via root rust-toolchain.toml).
465 lines
16 KiB
Rust
465 lines
16 KiB
Rust
//! Samsung Pay payment-token decryption.
|
|
//!
|
|
//! ## Format
|
|
//!
|
|
//! Samsung Pay follows the same shape as Apple Pay's PaymentToken
|
|
//! (ECDH P-256 ephemeral key + symmetric AEAD) but with a few
|
|
//! differences:
|
|
//!
|
|
//! 1. **KDF: HKDF-SHA256** (Apple Pay uses X9.63-KDF).
|
|
//! Salt = ASCII `"Samsung Pay"`, info = `merchantId` (UTF-8 bytes).
|
|
//! 2. **AEAD: AES-256-GCM** with a *zero* IV (same as Apple Pay) —
|
|
//! the ephemeral ECDH key rotates per transaction so IV reuse is
|
|
//! not an issue.
|
|
//! 3. **Signing cert path: Samsung Pay leaf** signed by Samsung's
|
|
//! enrollment CA, not Apple's. Operators wire the verified chain
|
|
//! plus the signing leaf's bound merchant id, the same way we do
|
|
//! for Apple Pay.
|
|
//!
|
|
//! Samsung does not publish a canonical public test vector, so the
|
|
//! conformance test in this module is round-trip on a synthetic
|
|
//! vector built with the same crypto stack used to decrypt — the
|
|
//! negative tests still cover the signature / cert / merchant
|
|
//! mismatch failure modes precisely.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use aes_gcm::aead::{Aead, KeyInit};
|
|
use aes_gcm::{Aes256Gcm, Nonce};
|
|
use base64::Engine as _;
|
|
use hkdf::Hkdf;
|
|
use op_core::{Currency, Money};
|
|
use p256::SecretKey;
|
|
use p256::ecdh::diffie_hellman;
|
|
use p256::elliptic_curve::sec1::FromEncodedPoint;
|
|
use p256::{EncodedPoint, PublicKey};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::Sha256;
|
|
use subtle::ConstantTimeEq;
|
|
use zeroize::Zeroize;
|
|
|
|
use crate::apple_pay::{ApplePayDataType, CertChain, ExpirationDate};
|
|
use crate::cryptogram::EciIndicator;
|
|
use crate::error::{Error, Result};
|
|
use crate::wallet::{Base64Encoded, DecryptedToken, PaymentMethodInfo, VaultTokenizer, Wallet, WalletDecryptor};
|
|
|
|
/// Samsung Pay payment token shape.
|
|
///
|
|
/// Mirrors Apple Pay's outer envelope structurally so a single
|
|
/// `WalletDecryptor::decrypt(&[u8])` callsite handles both.
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct SamsungPayToken {
|
|
/// Base64-encoded inner `paymentData` JSON.
|
|
pub payment_data: Base64Encoded,
|
|
/// Wallet-attached metadata: network, last4, display name.
|
|
pub payment_method: PaymentMethodInfo,
|
|
/// Transaction identifier the wallet generated.
|
|
pub transaction_identifier: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
struct WireSamsungData {
|
|
version: String,
|
|
data: String,
|
|
#[serde(rename = "ephemeralPublicKey")]
|
|
ephemeral_public_key: String,
|
|
#[serde(rename = "publicKeyHash")]
|
|
public_key_hash: String,
|
|
#[serde(rename = "transactionId")]
|
|
transaction_id: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
struct InnerSamsungPayload {
|
|
#[serde(rename = "applicationPrimaryAccountNumber")]
|
|
application_primary_account_number: String,
|
|
#[serde(rename = "applicationExpirationDate")]
|
|
application_expiration_date: String,
|
|
#[serde(rename = "currencyCode")]
|
|
currency_code: String,
|
|
#[serde(rename = "transactionAmount")]
|
|
transaction_amount: i64,
|
|
#[serde(rename = "paymentDataType")]
|
|
payment_data_type: String,
|
|
#[serde(rename = "onlinePaymentCryptogram")]
|
|
online_payment_cryptogram: String,
|
|
#[serde(rename = "eciIndicator", default)]
|
|
eci_indicator: Option<String>,
|
|
}
|
|
|
|
/// Samsung Pay decryptor configuration.
|
|
#[derive(Clone, Debug)]
|
|
pub struct SamsungPayConfig {
|
|
/// Merchant id (UTF-8 bytes). Bound into the HKDF `info` argument.
|
|
pub merchant_id: String,
|
|
/// Cert chain pre-verified by the operator's X.509 stack.
|
|
pub cert_chain: CertChain,
|
|
/// Merchant enrollment private key (P-256).
|
|
pub recipient_private_key: SecretKey,
|
|
/// Now, in unix seconds. Injectable for tests.
|
|
pub now_unix: i64,
|
|
}
|
|
|
|
/// Samsung Pay decryptor.
|
|
pub struct SamsungPayDecryptor {
|
|
config: SamsungPayConfig,
|
|
tokenizer: Arc<dyn VaultTokenizer>,
|
|
}
|
|
|
|
impl SamsungPayDecryptor {
|
|
/// Construct.
|
|
#[must_use]
|
|
pub fn new(config: SamsungPayConfig, tokenizer: Arc<dyn VaultTokenizer>) -> Self {
|
|
Self { config, tokenizer }
|
|
}
|
|
}
|
|
|
|
/// Decrypt a Samsung Pay token.
|
|
pub fn decrypt(
|
|
token: &SamsungPayToken,
|
|
merchant_id: &str,
|
|
cert_chain: &CertChain,
|
|
recipient_private_key: &SecretKey,
|
|
tokenizer: &dyn VaultTokenizer,
|
|
now_unix: i64,
|
|
) -> Result<DecryptedToken> {
|
|
// Cert / merchant binding. Samsung embeds the merchant id hash in
|
|
// the leaf cert the same way Apple does (different OID, same
|
|
// semantic). We re-use the ApplePay CertChain shape because the
|
|
// shape is identical.
|
|
let merchant_id_hash: [u8; 32] = {
|
|
use sha2::Digest;
|
|
let mut h = sha2::Sha256::new();
|
|
h.update(merchant_id.as_bytes());
|
|
h.finalize().into()
|
|
};
|
|
cert_chain.verify(now_unix, &merchant_id_hash)?;
|
|
|
|
// Outer envelope.
|
|
let payment_data_bytes = base64::engine::general_purpose::STANDARD
|
|
.decode(token.payment_data.as_str())
|
|
.map_err(|_| Error::MalformedPayload("paymentData not base64"))?;
|
|
let wire: WireSamsungData = serde_json::from_slice(&payment_data_bytes)
|
|
.map_err(|_| Error::MalformedPayload("paymentData JSON"))?;
|
|
|
|
// publicKeyHash binds the wallet payload to *this* recipient.
|
|
let recipient_pub_sec1 = recipient_private_key.public_key().to_sec1_bytes();
|
|
let recipient_pub_hash = {
|
|
use sha2::Digest;
|
|
let mut h = sha2::Sha256::new();
|
|
h.update(&recipient_pub_sec1);
|
|
h.finalize()
|
|
};
|
|
let advertised_hash = base64::engine::general_purpose::STANDARD
|
|
.decode(&wire.public_key_hash)
|
|
.map_err(|_| Error::MalformedPayload("publicKeyHash not base64"))?;
|
|
if advertised_hash.len() != 32
|
|
|| recipient_pub_hash
|
|
.as_slice()
|
|
.ct_eq(&advertised_hash)
|
|
.unwrap_u8()
|
|
!= 1
|
|
{
|
|
return Err(Error::BadSignature);
|
|
}
|
|
|
|
// ECDH P-256.
|
|
let ephem_pub_bytes = base64::engine::general_purpose::STANDARD
|
|
.decode(&wire.ephemeral_public_key)
|
|
.map_err(|_| Error::MalformedPayload("ephemeralPublicKey not base64"))?;
|
|
let ephem_point = EncodedPoint::from_bytes(&ephem_pub_bytes)
|
|
.map_err(|_| Error::KeyAgreementFailed)?;
|
|
let ephem_pk_opt = PublicKey::from_encoded_point(&ephem_point);
|
|
let ephem_pk = Option::<PublicKey>::from(ephem_pk_opt).ok_or(Error::KeyAgreementFailed)?;
|
|
let shared = diffie_hellman(recipient_private_key.to_nonzero_scalar(), ephem_pk.as_affine());
|
|
let mut z = shared.raw_secret_bytes().to_vec();
|
|
|
|
// HKDF-SHA256: salt = "Samsung Pay", info = merchant_id, OKM = 32 bytes.
|
|
let hk = Hkdf::<Sha256>::new(Some(b"Samsung Pay"), &z);
|
|
let mut aes_key = [0u8; 32];
|
|
hk.expand(merchant_id.as_bytes(), &mut aes_key)
|
|
.map_err(|_| Error::Internal("HKDF expand"))?;
|
|
z.zeroize();
|
|
|
|
// AES-256-GCM.
|
|
let cipher = Aes256Gcm::new_from_slice(&aes_key)
|
|
.map_err(|_| Error::Internal("aes-256-gcm key length"))?;
|
|
let nonce = Nonce::from_slice(&[0u8; 12]);
|
|
let ct = base64::engine::general_purpose::STANDARD
|
|
.decode(&wire.data)
|
|
.map_err(|_| Error::MalformedPayload("data not base64"))?;
|
|
let mut plaintext = cipher
|
|
.decrypt(nonce, ct.as_ref())
|
|
.map_err(|_| Error::AeadAuthFailed)?;
|
|
aes_key.zeroize();
|
|
|
|
let inner: InnerSamsungPayload = serde_json::from_slice(&plaintext)
|
|
.map_err(|_| Error::MalformedPayload("inner JSON"))?;
|
|
plaintext.zeroize();
|
|
|
|
// Tokenize DPAN -> VaultRef.
|
|
let mut dpan = inner.application_primary_account_number.clone().into_bytes();
|
|
let vault_ref = tokenizer.tokenize(&dpan).map_err(|e| match e {
|
|
Error::VaultTokenizer(m) => Error::VaultTokenizer(m),
|
|
other => Error::VaultTokenizer(format!("{other}")),
|
|
})?;
|
|
dpan.zeroize();
|
|
|
|
let expiration = ExpirationDate::parse_yymmdd(&inner.application_expiration_date)?;
|
|
let currency = match inner.currency_code.as_str() {
|
|
"840" => Currency::USD,
|
|
"978" => Currency::EUR,
|
|
"986" => Currency::BRL,
|
|
"356" => Currency::INR,
|
|
"826" => Currency::GBP,
|
|
"392" => Currency::JPY,
|
|
"156" => Currency::CNY,
|
|
_ => return Err(Error::MalformedPayload("unknown ISO 4217 numeric code")),
|
|
};
|
|
let amount = Money::from_minor(inner.transaction_amount, currency);
|
|
let data_type = match inner.payment_data_type.as_str() {
|
|
"3DSecure" => ApplePayDataType::ThreeDSecure,
|
|
"EMV" => ApplePayDataType::Emv,
|
|
_ => return Err(Error::MalformedPayload("unknown paymentDataType")),
|
|
};
|
|
let cryptogram_bytes = base64::engine::general_purpose::STANDARD
|
|
.decode(&inner.online_payment_cryptogram)
|
|
.map_err(|_| Error::MalformedPayload("cryptogram not base64"))?;
|
|
let eci = inner.eci_indicator.unwrap_or_default();
|
|
|
|
Ok(DecryptedToken {
|
|
application_primary_account_number: vault_ref,
|
|
application_expiration_date: expiration,
|
|
currency_code: currency,
|
|
transaction_amount: amount,
|
|
payment_data_type: data_type,
|
|
online_payment_cryptogram: cryptogram_bytes,
|
|
eci_indicator: EciIndicator::new(eci),
|
|
})
|
|
}
|
|
|
|
impl WalletDecryptor for SamsungPayDecryptor {
|
|
fn decrypt(&self, raw: &[u8]) -> Result<DecryptedToken> {
|
|
let token: SamsungPayToken = serde_json::from_slice(raw)
|
|
.map_err(|_| Error::MalformedPayload("SamsungPayToken JSON"))?;
|
|
decrypt(
|
|
&token,
|
|
&self.config.merchant_id,
|
|
&self.config.cert_chain,
|
|
&self.config.recipient_private_key,
|
|
self.tokenizer.as_ref(),
|
|
self.config.now_unix,
|
|
)
|
|
}
|
|
|
|
fn wallet(&self) -> Wallet {
|
|
Wallet::SamsungPay
|
|
}
|
|
}
|
|
|
|
/// Test-only helpers. Samsung does not publish a canonical vector;
|
|
/// we round-trip a synthetic one.
|
|
#[doc(hidden)]
|
|
pub mod test_support {
|
|
use super::*;
|
|
use aes_gcm::aead::Aead;
|
|
use p256::SecretKey;
|
|
use p256::elliptic_curve::rand_core::OsRng;
|
|
use sha2::Digest;
|
|
|
|
pub fn build_token(
|
|
recipient_priv: &SecretKey,
|
|
merchant_id: &str,
|
|
dpan: &str,
|
|
expiration_yymmdd: &str,
|
|
currency_numeric: &str,
|
|
amount_minor: i64,
|
|
data_type: &str,
|
|
cryptogram_b64: &str,
|
|
eci: Option<&str>,
|
|
transaction_id: &str,
|
|
) -> SamsungPayToken {
|
|
let ephem_priv = SecretKey::random(&mut OsRng);
|
|
let ephem_pub_sec1 = ephem_priv.public_key().to_sec1_bytes();
|
|
|
|
let shared = diffie_hellman(
|
|
ephem_priv.to_nonzero_scalar(),
|
|
recipient_priv.public_key().as_affine(),
|
|
);
|
|
let z = shared.raw_secret_bytes().to_vec();
|
|
let hk = Hkdf::<Sha256>::new(Some(b"Samsung Pay"), &z);
|
|
let mut aes_key = [0u8; 32];
|
|
hk.expand(merchant_id.as_bytes(), &mut aes_key).unwrap();
|
|
|
|
let inner = serde_json::json!({
|
|
"applicationPrimaryAccountNumber": dpan,
|
|
"applicationExpirationDate": expiration_yymmdd,
|
|
"currencyCode": currency_numeric,
|
|
"transactionAmount": amount_minor,
|
|
"paymentDataType": data_type,
|
|
"onlinePaymentCryptogram": cryptogram_b64,
|
|
"eciIndicator": eci.unwrap_or(""),
|
|
});
|
|
let plaintext = serde_json::to_vec(&inner).unwrap();
|
|
let cipher = Aes256Gcm::new_from_slice(&aes_key).unwrap();
|
|
let nonce = Nonce::from_slice(&[0u8; 12]);
|
|
let ciphertext = cipher.encrypt(nonce, plaintext.as_ref()).unwrap();
|
|
|
|
let recipient_pub_sec1 = recipient_priv.public_key().to_sec1_bytes();
|
|
let mut h = sha2::Sha256::new();
|
|
h.update(&recipient_pub_sec1);
|
|
let pkh = h.finalize();
|
|
|
|
let wire = WireSamsungData {
|
|
version: "EC_v1_samsung".into(),
|
|
data: base64::engine::general_purpose::STANDARD.encode(&ciphertext),
|
|
ephemeral_public_key: base64::engine::general_purpose::STANDARD
|
|
.encode(&ephem_pub_sec1),
|
|
public_key_hash: base64::engine::general_purpose::STANDARD.encode(pkh),
|
|
transaction_id: transaction_id.into(),
|
|
};
|
|
let wire_bytes = serde_json::to_vec(&wire).unwrap();
|
|
|
|
SamsungPayToken {
|
|
payment_data: Base64Encoded::new(
|
|
base64::engine::general_purpose::STANDARD.encode(&wire_bytes),
|
|
),
|
|
payment_method: PaymentMethodInfo {
|
|
network: "mc".into(),
|
|
last4: dpan
|
|
.chars()
|
|
.rev()
|
|
.take(4)
|
|
.collect::<Vec<_>>()
|
|
.iter()
|
|
.rev()
|
|
.collect(),
|
|
display_name: None,
|
|
},
|
|
transaction_identifier: transaction_id.into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::test_support::*;
|
|
use super::*;
|
|
use crate::wallet::Sha256VaultTokenizer;
|
|
use p256::SecretKey;
|
|
use p256::elliptic_curve::rand_core::OsRng;
|
|
use sha2::Digest;
|
|
|
|
fn fresh_setup() -> (SecretKey, String, CertChain) {
|
|
let priv_key = SecretKey::random(&mut OsRng);
|
|
let merchant_id = "samsung-merchant-test".to_string();
|
|
let mut h = sha2::Sha256::new();
|
|
h.update(merchant_id.as_bytes());
|
|
let hash: [u8; 32] = h.finalize().into();
|
|
let chain = CertChain::new(true, 2_000_000_000, hash);
|
|
(priv_key, merchant_id, chain)
|
|
}
|
|
|
|
#[test]
|
|
fn samsung_roundtrip_to_vault_ref() {
|
|
let (priv_key, merchant_id, chain) = fresh_setup();
|
|
let token = build_token(
|
|
&priv_key,
|
|
&merchant_id,
|
|
"5555555555554444",
|
|
"280630",
|
|
"840",
|
|
7500,
|
|
"EMV",
|
|
"Q1JZUFRP",
|
|
Some("02"),
|
|
"txn-samsung-001",
|
|
);
|
|
let tokenizer = Sha256VaultTokenizer;
|
|
let out = decrypt(
|
|
&token,
|
|
&merchant_id,
|
|
&chain,
|
|
&priv_key,
|
|
&tokenizer,
|
|
1_700_000_000,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(out.transaction_amount.minor_units, 7500);
|
|
assert_eq!(out.payment_data_type, ApplePayDataType::Emv);
|
|
assert!(out.application_primary_account_number.as_str().starts_with("sha256:"));
|
|
}
|
|
|
|
#[test]
|
|
fn samsung_wrong_merchant_id_yields_aead_failure() {
|
|
// If the merchant id binds into HKDF info, a wrong id makes a
|
|
// different AES key, which surfaces as an AEAD-auth failure
|
|
// (the cert-chain binding pre-check is on hashed merchant id,
|
|
// so we tamper that separately).
|
|
let (priv_key, merchant_id, chain) = fresh_setup();
|
|
let token = build_token(
|
|
&priv_key,
|
|
&merchant_id,
|
|
"5555555555554444",
|
|
"280630",
|
|
"840",
|
|
100,
|
|
"EMV",
|
|
"QUFB",
|
|
None,
|
|
"txn",
|
|
);
|
|
// Build a *new* CertChain bound to the WRONG merchant id so
|
|
// we get past the cert verify and exercise the HKDF path.
|
|
let mut h = sha2::Sha256::new();
|
|
h.update(b"WRONG-MERCHANT");
|
|
let wrong_hash: [u8; 32] = h.finalize().into();
|
|
let wrong_chain = CertChain::new(true, chain.leaf_not_after_unix, wrong_hash);
|
|
let tokenizer = Sha256VaultTokenizer;
|
|
let err = decrypt(
|
|
&token,
|
|
"WRONG-MERCHANT",
|
|
&wrong_chain,
|
|
&priv_key,
|
|
&tokenizer,
|
|
1_700_000_000,
|
|
)
|
|
.unwrap_err();
|
|
assert_eq!(err, Error::AeadAuthFailed);
|
|
}
|
|
|
|
#[test]
|
|
fn samsung_merchant_id_mismatch_caught_at_cert() {
|
|
// Same merchant id used to encrypt, but the configured cert
|
|
// chain binds a *different* hash. The CertChain verifier
|
|
// catches this with MerchantIdMismatch before we get to crypto.
|
|
let (priv_key, merchant_id, _) = fresh_setup();
|
|
let mut h = sha2::Sha256::new();
|
|
h.update(b"OTHER-MERCHANT");
|
|
let bad_hash: [u8; 32] = h.finalize().into();
|
|
let bad_chain = CertChain::new(true, 2_000_000_000, bad_hash);
|
|
let token = build_token(
|
|
&priv_key,
|
|
&merchant_id,
|
|
"5555555555554444",
|
|
"280630",
|
|
"840",
|
|
100,
|
|
"EMV",
|
|
"QUFB",
|
|
None,
|
|
"txn",
|
|
);
|
|
let tokenizer = Sha256VaultTokenizer;
|
|
let err = decrypt(
|
|
&token,
|
|
&merchant_id,
|
|
&bad_chain,
|
|
&priv_key,
|
|
&tokenizer,
|
|
1_700_000_000,
|
|
)
|
|
.unwrap_err();
|
|
assert_eq!(err, Error::MerchantIdMismatch);
|
|
}
|
|
}
|