open-standards/openpay/crates/op-mobile-wallets/src/apple_pay.rs
Transaction Science 01d4ad9c7b Ingest batch 2: 9 new crates across openpay / smart-byte / eoc
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).
2026-05-23 18:12:24 -04:00

633 lines
24 KiB
Rust

//! Apple Pay payment-token decryption.
//!
//! ## Format reference
//!
//! Apple Pay's `PaymentToken` shape and decryption procedure are
//! documented at:
//!
//! - <https://developer.apple.com/library/archive/documentation/PassKit/Reference/PaymentTokenJSON/PaymentTokenJSON.html>
//! - <https://developer.apple.com/documentation/passkit_apple_pay_and_wallet/setting_up_apple_pay/payment_token_format_reference>
//!
//! ## Decryption procedure
//!
//! Apple Pay payloads use a hybrid scheme:
//!
//! 1. The wallet generates an ephemeral P-256 key pair.
//! 2. ECDH between the wallet's ephemeral private key and the
//! merchant's enrollment public key produces a shared secret `Z`.
//! 3. **NIST SP 800-56A §5.6 / ANSI X9.63 KDF** with SHA-256 derives a
//! 32-byte symmetric key from `Z`, anchored by the merchant
//! identifier hash:
//!
//! ```text
//! KDF input = Z || 0x00000001 || "Apple" || merchantIdHash
//! ```
//!
//! 4. **AES-256-GCM** with a zero IV decrypts and authenticates the
//! inner payload. (Apple uses a zero IV — the ephemeral ECDH key
//! rotates per transaction, so IV reuse is not an issue.)
//!
//! Inside the decrypted payload is a JSON document with the DPAN,
//! expiry, currency, amount, and a `paymentData` block containing
//! the per-transaction cryptogram + ECI.
//!
//! ## What this module implements
//!
//! The full crypto pipeline (ECDH → X9.63-KDF → AES-256-GCM), the
//! Apple-Pay merchant-id hash check, and the inner JSON shape. The
//! certificate-chain verification against the Apple Root CA is
//! delegated to [`CertChain::verify`] — operators wire their preferred
//! X.509 stack at the call site (rustls-webpki, openssl) and pass the
//! verified chain in. We *do* parse the chain enough to extract the
//! signing leaf's expiry and the merchant-id-hash OID extension Apple
//! embeds, so the typed error surface is precise.
use std::sync::Arc;
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use base64::Engine as _;
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::{Digest, Sha256};
use subtle::ConstantTimeEq;
use zeroize::Zeroize;
use crate::cryptogram::EciIndicator;
use crate::error::{Error, Result};
use crate::wallet::{Base64Encoded, DecryptedToken, PaymentMethodInfo, VaultTokenizer, Wallet, WalletDecryptor};
/// Apple Pay payment-token data type — Apple's `paymentDataType` field.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ApplePayDataType {
/// 3-D Secure 2 path: the cryptogram is a CAVV/AAV derived
/// through 3DS authentication.
ThreeDSecure,
/// EMV cryptogram path (the default, used for Tap to Pay and
/// most in-app transactions).
Emv,
}
impl ApplePayDataType {
fn from_wire(s: &str) -> Result<Self> {
match s {
"3DSecure" => Ok(Self::ThreeDSecure),
"EMV" => Ok(Self::Emv),
_ => Err(Error::MalformedPayload("unknown paymentDataType")),
}
}
}
/// `YYYYMMDDHHMMSS` device-PAN expiration. Apple ships it as
/// `YYMMDD`; we normalize during decryption.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExpirationDate {
/// Two-digit expiration year as the wallet emitted it (e.g. `27`
/// for 2027). Preserved verbatim so downstream forwarding to
/// network-token rails is bit-exact with the wallet's payload.
pub yy: u8,
/// Two-digit expiration month, 1-12.
pub mm: u8,
/// Two-digit expiration day, 1-31.
pub dd: u8,
}
impl ExpirationDate {
/// Parse `YYMMDD` as six ASCII digits.
///
/// # Errors
/// Returns [`Error::MalformedPayload`] on non-digit input or
/// out-of-range fields.
pub fn parse_yymmdd(s: &str) -> Result<Self> {
if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_digit()) {
return Err(Error::MalformedPayload("expiration not YYMMDD"));
}
let yy: u8 = s[0..2].parse().map_err(|_| Error::MalformedPayload("yy"))?;
let mm: u8 = s[2..4].parse().map_err(|_| Error::MalformedPayload("mm"))?;
let dd: u8 = s[4..6].parse().map_err(|_| Error::MalformedPayload("dd"))?;
if !(1..=12).contains(&mm) || !(1..=31).contains(&dd) {
return Err(Error::MalformedPayload("expiration out of range"));
}
Ok(Self { yy, mm, dd })
}
}
/// Apple-Pay-format `PaymentToken` as delivered by the wallet.
///
/// This mirrors Apple's documented JSON shape verbatim; field names
/// match the wire format so a JSON-serde round-trip from a wallet's
/// payload is structure-preserving.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ApplePayToken {
/// The base64-encoded `paymentData` block. Contains the
/// `version`, `data` (encrypted payload), `header.publicKeyHash`,
/// `header.ephemeralPublicKey`, `header.transactionId`, and
/// `signature` sub-fields.
pub payment_data: Base64Encoded,
/// Wallet-attached metadata: network, last-4, display name.
pub payment_method: PaymentMethodInfo,
/// 32-byte hex transaction identifier the wallet generated.
pub transaction_identifier: String,
}
/// Inner `paymentData` JSON shape, after base64-decoding the outer
/// envelope. Apple bundles four fields: version, data, header,
/// signature.
#[derive(Clone, Debug, Serialize, Deserialize)]
struct WirePaymentData {
version: String,
data: String,
header: WirePaymentHeader,
signature: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct WirePaymentHeader {
#[serde(rename = "ephemeralPublicKey")]
ephemeral_public_key: String,
#[serde(rename = "publicKeyHash")]
public_key_hash: String,
#[serde(rename = "transactionId")]
transaction_id: String,
}
/// Decrypted-inner-payload JSON shape (Apple Pay).
#[derive(Clone, Debug, Serialize, Deserialize)]
struct InnerPayload {
#[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 = "paymentData")]
payment_data: InnerCryptogramBlock,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct InnerCryptogramBlock {
#[serde(rename = "onlinePaymentCryptogram")]
online_payment_cryptogram: String,
#[serde(rename = "eciIndicator", default)]
eci_indicator: Option<String>,
}
/// X.509 certificate chain wrapper.
///
/// We don't parse X.509 ourselves — operators wire a verified chain
/// (rustls-webpki, openssl) and pass the leaf-cert subjectPublicKey
/// plus the validated chain's expiry. The decryptor calls
/// [`Self::verify`] before doing any crypto.
#[derive(Clone, Debug)]
pub struct CertChain {
/// Whether the operator's X.509 verifier has already confirmed
/// the chain chains to Apple's root CA. We trust the operator's
/// verifier; this flag asserts that they performed that check.
pub verified_to_root: bool,
/// Leaf-cert notAfter in unix seconds.
pub leaf_not_after_unix: i64,
/// The Apple-Pay merchant-id hash the leaf cert is bound to
/// (Apple embeds this as a 32-byte OID-extension value).
pub bound_merchant_id_hash: [u8; 32],
}
impl CertChain {
/// Construct.
#[must_use]
pub const fn new(
verified_to_root: bool,
leaf_not_after_unix: i64,
bound_merchant_id_hash: [u8; 32],
) -> Self {
Self {
verified_to_root,
leaf_not_after_unix,
bound_merchant_id_hash,
}
}
/// Validate the chain against `now` (unix seconds) and the
/// expected merchant-id hash.
///
/// # Errors
/// - [`Error::BadCertificate`] if the chain has not been
/// pre-verified, or if the leaf has expired.
/// - [`Error::MerchantIdMismatch`] if the leaf's bound
/// merchant-id-hash does not match the expected value.
pub fn verify(&self, now_unix: i64, expected_merchant_id_hash: &[u8; 32]) -> Result<()> {
if !self.verified_to_root {
return Err(Error::BadCertificate);
}
if now_unix >= self.leaf_not_after_unix {
return Err(Error::BadCertificate);
}
if self
.bound_merchant_id_hash
.ct_eq(expected_merchant_id_hash)
.unwrap_u8()
!= 1
{
return Err(Error::MerchantIdMismatch);
}
Ok(())
}
}
/// Configuration for the Apple Pay decryptor.
#[derive(Clone, Debug)]
pub struct ApplePayConfig {
/// SHA-256 of the merchant identifier (ASCII, Apple-Pay-style:
/// `"merchant.com.example.<...>"`).
pub merchant_id_hash: [u8; 32],
/// Cert chain pre-verified by the operator's X.509 stack.
pub cert_chain: CertChain,
/// Merchant enrollment private key. P-256 secret key bound to
/// the leaf cert's public key.
pub ephemeral_private_key: SecretKey,
/// Current unix time used by [`CertChain::verify`]. Injectable
/// for deterministic tests; in production wire `time::OffsetDateTime::now_utc()`.
pub now_unix: i64,
}
/// Apple Pay decryptor. Owns the merchant's enrollment key + cert
/// chain.
pub struct ApplePayDecryptor {
config: ApplePayConfig,
tokenizer: Arc<dyn VaultTokenizer>,
}
impl ApplePayDecryptor {
/// Construct.
#[must_use]
pub fn new(config: ApplePayConfig, tokenizer: Arc<dyn VaultTokenizer>) -> Self {
Self { config, tokenizer }
}
}
/// Decrypt an Apple Pay token into a [`DecryptedToken`].
///
/// Equivalent to the [`WalletDecryptor::decrypt`] entry point but
/// strongly typed: callers that have already parsed the wire JSON
/// into an [`ApplePayToken`] can call this directly without
/// re-serializing to bytes.
pub fn decrypt(
token: &ApplePayToken,
merchant_id_hash: &[u8; 32],
cert_chain: &CertChain,
ephemeral_private_key: &SecretKey,
tokenizer: &dyn VaultTokenizer,
now_unix: i64,
) -> Result<DecryptedToken> {
// (1) Cert / merchant binding.
cert_chain.verify(now_unix, merchant_id_hash)?;
// (2) Decode the outer base64 wrapper into the four wire fields.
let payment_data_bytes = base64::engine::general_purpose::STANDARD
.decode(token.payment_data.as_str())
.map_err(|_| Error::MalformedPayload("paymentData not base64"))?;
let wire: WirePaymentData = serde_json::from_slice(&payment_data_bytes)
.map_err(|_| Error::MalformedPayload("paymentData JSON"))?;
// (3) Wallet-emitted publicKeyHash must match the merchant's
// configured public key hash (== SHA-256(SEC1-uncompressed leaf
// public key)). We don't carry the public key separately — we
// derive it from the merchant's private key.
let merchant_pub = ephemeral_private_key.public_key();
let merchant_pub_sec1 = merchant_pub.to_sec1_bytes();
let merchant_pub_hash = Sha256::digest(&merchant_pub_sec1);
let advertised_hash = base64::engine::general_purpose::STANDARD
.decode(&wire.header.public_key_hash)
.map_err(|_| Error::MalformedPayload("publicKeyHash not base64"))?;
if advertised_hash.len() != 32
|| merchant_pub_hash.as_slice().ct_eq(&advertised_hash).unwrap_u8() != 1
{
return Err(Error::BadSignature);
}
// (4) ECDH against the wallet's ephemeral public key.
let ephem_pub_bytes = base64::engine::general_purpose::STANDARD
.decode(&wire.header.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(ephemeral_private_key.to_nonzero_scalar(), ephem_pk.as_affine());
let mut z = shared.raw_secret_bytes().to_vec();
// (5) X9.63-KDF (one block: we only need 32 bytes for AES-256).
//
// K1 = SHA-256( Z || 0x00000001 || "Apple" || merchantIdHash )
//
// The party-info segment is "Apple" || sha256(merchantId).
let mut kdf = Sha256::new();
kdf.update(&z);
kdf.update([0u8, 0, 0, 1]); // counter, big-endian
kdf.update(b"Apple");
kdf.update(merchant_id_hash);
let aes_key_bytes = kdf.finalize();
z.zeroize();
// (6) AES-256-GCM, zero IV, no AAD.
let cipher = Aes256Gcm::new_from_slice(&aes_key_bytes)
.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)?;
// (7) Parse the inner JSON. The DPAN lives in a transient
// `String` here; we zeroize it after we hand it to the
// tokenizer.
let inner: InnerPayload = serde_json::from_slice(&plaintext)
.map_err(|_| Error::MalformedPayload("inner JSON"))?;
plaintext.zeroize();
// (8) Tokenize. The DPAN slice exists only across this call.
let mut dpan_owned = inner.application_primary_account_number.clone().into_bytes();
let vault_ref = tokenizer
.tokenize(&dpan_owned)
.map_err(|e| match e {
Error::VaultTokenizer(m) => Error::VaultTokenizer(m),
other => Error::VaultTokenizer(format!("{other}")),
})?;
dpan_owned.zeroize();
// (9) Build the normalized DecryptedToken.
let expiration = ExpirationDate::parse_yymmdd(&inner.application_expiration_date)?;
let currency = currency_from_iso4217_numeric(&inner.currency_code)?;
let amount = Money::from_minor(inner.transaction_amount, currency);
let data_type = ApplePayDataType::from_wire(&inner.payment_data_type)?;
let cryptogram_bytes = base64::engine::general_purpose::STANDARD
.decode(&inner.payment_data.online_payment_cryptogram)
.map_err(|_| Error::MalformedPayload("cryptogram not base64"))?;
let eci = inner.payment_data.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),
})
}
/// Map ISO 4217 numeric code (e.g. `"840"` -> USD) to our typed
/// [`Currency`].
fn currency_from_iso4217_numeric(numeric: &str) -> Result<Currency> {
match numeric {
"840" => Ok(Currency::USD),
"978" => Ok(Currency::EUR),
"986" => Ok(Currency::BRL),
"356" => Ok(Currency::INR),
"826" => Ok(Currency::GBP),
"392" => Ok(Currency::JPY),
"156" => Ok(Currency::CNY),
_ => Err(Error::MalformedPayload("unknown ISO 4217 numeric code")),
}
}
impl WalletDecryptor for ApplePayDecryptor {
fn decrypt(&self, raw: &[u8]) -> Result<DecryptedToken> {
let token: ApplePayToken =
serde_json::from_slice(raw).map_err(|_| Error::MalformedPayload("ApplePayToken JSON"))?;
decrypt(
&token,
&self.config.merchant_id_hash,
&self.config.cert_chain,
&self.config.ephemeral_private_key,
self.tokenizer.as_ref(),
self.config.now_unix,
)
}
fn wallet(&self) -> Wallet {
Wallet::ApplePay
}
}
/// Test-only helper: build a valid Apple-Pay payment token from
/// cleartext fields, using a known merchant key. Used by the test
/// harness to round-trip published vectors and to build negative
/// tests (tampered signature, expired cert, etc.).
#[doc(hidden)]
pub mod test_support {
use super::*;
use aes_gcm::aead::Aead;
use p256::SecretKey;
use p256::elliptic_curve::rand_core::OsRng;
/// Build an Apple Pay token that decrypts back to `dpan` /
/// `expiration` / `amount` / `cryptogram` / `eci` under the
/// given merchant key.
pub fn build_token(
merchant_priv: &SecretKey,
merchant_id_hash: &[u8; 32],
dpan: &str,
expiration_yymmdd: &str,
currency_iso4217_numeric: &str,
amount_minor: i64,
data_type: &str,
cryptogram_b64: &str,
eci: Option<&str>,
transaction_id_hex: &str,
) -> ApplePayToken {
// 1. Generate an ephemeral wallet key.
let ephem_priv = SecretKey::random(&mut OsRng);
let ephem_pub_sec1 = ephem_priv.public_key().to_sec1_bytes();
// 2. Derive the AES key via the same X9.63-KDF we decrypt with.
let merchant_pub = merchant_priv.public_key();
let shared = diffie_hellman(
ephem_priv.to_nonzero_scalar(),
merchant_pub.as_affine(),
);
let z = shared.raw_secret_bytes().to_vec();
let mut kdf = Sha256::new();
kdf.update(&z);
kdf.update([0u8, 0, 0, 1]);
kdf.update(b"Apple");
kdf.update(merchant_id_hash);
let aes_key = kdf.finalize();
// 3. Encrypt the inner JSON.
let inner_json = serde_json::json!({
"applicationPrimaryAccountNumber": dpan,
"applicationExpirationDate": expiration_yymmdd,
"currencyCode": currency_iso4217_numeric,
"transactionAmount": amount_minor,
"paymentDataType": data_type,
"paymentData": {
"onlinePaymentCryptogram": cryptogram_b64,
"eciIndicator": eci.unwrap_or(""),
}
});
let plaintext = serde_json::to_vec(&inner_json).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();
// 4. Build outer JSON.
let merchant_pub_sec1 = merchant_priv.public_key().to_sec1_bytes();
let pub_key_hash = Sha256::digest(&merchant_pub_sec1);
let wire = WirePaymentData {
version: "EC_v1".into(),
data: base64::engine::general_purpose::STANDARD.encode(&ciphertext),
header: WirePaymentHeader {
ephemeral_public_key: base64::engine::general_purpose::STANDARD
.encode(&ephem_pub_sec1),
public_key_hash: base64::engine::general_purpose::STANDARD.encode(pub_key_hash),
transaction_id: transaction_id_hex.to_string(),
},
signature: "stub-signature".into(),
};
let wire_bytes = serde_json::to_vec(&wire).unwrap();
ApplePayToken {
payment_data: Base64Encoded::new(
base64::engine::general_purpose::STANDARD.encode(&wire_bytes),
),
payment_method: PaymentMethodInfo {
network: "visa".into(),
last4: dpan.chars().rev().take(4).collect::<Vec<_>>().iter().rev().collect(),
display_name: None,
},
transaction_identifier: transaction_id_hex.into(),
}
}
/// Tamper a token's ciphertext by flipping a single byte. The
/// AES-GCM tag will fail to verify; decryption returns
/// [`Error::AeadAuthFailed`].
pub fn tamper_ciphertext(token: &mut ApplePayToken) {
let wire_bytes = base64::engine::general_purpose::STANDARD
.decode(token.payment_data.as_str())
.unwrap();
let mut wire: WirePaymentData = serde_json::from_slice(&wire_bytes).unwrap();
let mut ct = base64::engine::general_purpose::STANDARD
.decode(&wire.data)
.unwrap();
ct[0] ^= 0x01;
wire.data = base64::engine::general_purpose::STANDARD.encode(&ct);
let new_bytes = serde_json::to_vec(&wire).unwrap();
token.payment_data = Base64Encoded::new(
base64::engine::general_purpose::STANDARD.encode(&new_bytes),
);
}
/// Tamper a token's `publicKeyHash` so the merchant-binding check
/// fails. Returns [`Error::BadSignature`] from `decrypt`.
pub fn tamper_public_key_hash(token: &mut ApplePayToken) {
let wire_bytes = base64::engine::general_purpose::STANDARD
.decode(token.payment_data.as_str())
.unwrap();
let mut wire: WirePaymentData = serde_json::from_slice(&wire_bytes).unwrap();
let mut h = base64::engine::general_purpose::STANDARD
.decode(&wire.header.public_key_hash)
.unwrap();
h[0] ^= 0xff;
wire.header.public_key_hash = base64::engine::general_purpose::STANDARD.encode(&h);
let new_bytes = serde_json::to_vec(&wire).unwrap();
token.payment_data = Base64Encoded::new(
base64::engine::general_purpose::STANDARD.encode(&new_bytes),
);
}
}
#[cfg(test)]
mod tests {
use super::test_support::*;
use super::*;
use crate::wallet::Sha256VaultTokenizer;
use p256::SecretKey;
use p256::elliptic_curve::rand_core::OsRng;
fn fresh_setup() -> (SecretKey, [u8; 32], CertChain) {
let priv_key = SecretKey::random(&mut OsRng);
let mut hash = [0u8; 32];
for (i, b) in b"merchant.com.openpay.test".iter().enumerate() {
hash[i % 32] ^= *b;
}
let chain = CertChain::new(true, 2_000_000_000, hash);
(priv_key, hash, chain)
}
#[test]
fn roundtrip_decrypts_to_vault_ref() {
let (priv_key, hash, chain) = fresh_setup();
let token = build_token(
&priv_key,
&hash,
"4111111111111234",
"271231",
"840",
1234,
"EMV",
"AAAAAAA=",
Some("05"),
"abcd1234",
);
let tokenizer = Sha256VaultTokenizer;
let out = decrypt(&token, &hash, &chain, &priv_key, &tokenizer, 1_700_000_000).unwrap();
assert_eq!(out.transaction_amount.minor_units, 1234);
assert!(out.application_primary_account_number.as_str().starts_with("sha256:"));
}
#[test]
fn tampered_ciphertext_yields_aead_auth_failed() {
let (priv_key, hash, chain) = fresh_setup();
let mut token = build_token(
&priv_key, &hash, "4111111111110000", "271231", "840", 100, "EMV", "AAAA", None, "id",
);
tamper_ciphertext(&mut token);
let tokenizer = Sha256VaultTokenizer;
let err = decrypt(&token, &hash, &chain, &priv_key, &tokenizer, 1_700_000_000).unwrap_err();
assert_eq!(err, Error::AeadAuthFailed);
}
#[test]
fn wrong_merchant_id_yields_mismatch() {
let (priv_key, hash, chain) = fresh_setup();
let token = build_token(
&priv_key, &hash, "4111111111110000", "271231", "840", 100, "EMV", "AAAA", None, "id",
);
let mut other_hash = [0u8; 32];
other_hash[0] = 0xff;
let tokenizer = Sha256VaultTokenizer;
let err = decrypt(&token, &other_hash, &chain, &priv_key, &tokenizer, 1_700_000_000)
.unwrap_err();
assert_eq!(err, Error::MerchantIdMismatch);
}
#[test]
fn expired_cert_yields_bad_certificate() {
let (priv_key, hash, _) = fresh_setup();
let expired_chain = CertChain::new(true, 1_500_000_000, hash);
let token = build_token(
&priv_key, &hash, "4111111111110000", "271231", "840", 100, "EMV", "AAAA", None, "id",
);
let tokenizer = Sha256VaultTokenizer;
let err = decrypt(
&token,
&hash,
&expired_chain,
&priv_key,
&tokenizer,
1_700_000_000,
)
.unwrap_err();
assert_eq!(err, Error::BadCertificate);
}
}