use subtle::ConstantTimeEq;
#[cfg(any(feature = "tempo", all(feature = "server", feature = "stripe")))]
const MIN_SECRET_KEY_BYTES: usize = 32;
#[cfg(any(feature = "tempo", all(feature = "server", feature = "stripe")))]
pub(crate) fn validate_secret_key(secret_key: &str) -> crate::error::Result<()> {
if secret_key.len() < MIN_SECRET_KEY_BYTES {
return Err(crate::error::MppError::InvalidConfig(format!(
"Secret key must be at least {MIN_SECRET_KEY_BYTES} bytes. \
Generate one with `openssl rand -base64 32`."
)));
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn compute_challenge_id(
secret_key: &str,
realm: &str,
method: &str,
intent: &str,
request: &str,
expires: Option<&str>,
digest: Option<&str>,
opaque: Option<&str>,
) -> String {
compute_challenge_id_with_header(
secret_key, realm, method, intent, request, expires, digest, opaque, None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn compute_challenge_id_with_header(
secret_key: &str,
realm: &str,
method: &str,
intent: &str,
request: &str,
expires: Option<&str>,
digest: Option<&str>,
opaque: Option<&str>,
header: Option<&str>,
) -> String {
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let mut hmac_input = vec![
realm,
method,
intent,
request,
expires.unwrap_or(""),
digest.unwrap_or(""),
];
let advertised = advertised_credential_header(header);
if let Some(ref header) = advertised {
hmac_input.push(header);
}
hmac_input.push(opaque.unwrap_or(""));
let hmac_input = hmac_input.join("|");
let mut mac =
HmacSha256::new_from_slice(secret_key.as_bytes()).expect("HMAC can take key of any size");
mac.update(hmac_input.as_bytes());
let result = mac.finalize();
super::base64url_encode(&result.into_bytes())
}
pub fn is_default_credential_header(header: Option<&str>) -> bool {
match header {
None => true,
Some(h) => h.is_empty() || h.eq_ignore_ascii_case("Authorization"),
}
}
pub(super) fn is_payment_authorization_header(header: &str) -> bool {
header.eq_ignore_ascii_case(super::PAYMENT_AUTHORIZATION_HEADER)
}
pub fn advertised_credential_header(header: Option<&str>) -> Option<String> {
let name = header?;
is_payment_authorization_header(name).then(|| name.to_string())
}
pub fn parse_advertised_credential_header(
header: Option<&str>,
) -> crate::error::Result<Option<String>> {
if is_default_credential_header(header) {
return Ok(None);
}
let name = header.unwrap_or("");
if !is_payment_authorization_header(name) {
return Err(crate::error::MppError::invalid_challenge_reason(
"Unsupported credential header: must be Payment-Authorization",
));
}
Ok(Some(name.to_string()))
}
pub(crate) fn constant_time_eq(a: &str, b: &str) -> bool {
a.as_bytes().ct_eq(b.as_bytes()).into()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::core::Base64UrlJson;
#[test]
fn test_compute_challenge_id_cross_sdk() {
let id = compute_challenge_id(
"test-secret-key-12345",
"api.example.com",
"tempo",
"charge",
&crate::protocol::core::base64url_encode(
br#"{"amount":"1000000","currency":"0x20c0000000000000000000000000000000000000","recipient":"0x1234567890abcdef1234567890abcdef12345678"}"#,
),
None,
None,
None,
);
assert_eq!(id, "XmJ98SdsAdzwP9Oa-8In322Uh6yweMO6rywdomWk_V4");
}
#[test]
fn test_golden_vectors() {
use crate::protocol::core::base64url_encode as b64;
let secret = "test-vector-secret";
let req_amount = b64(br#"{"amount":"1000000"}"#);
let req_multi = b64(br#"{"amount":"1000000","currency":"0x1234","recipient":"0xabcd"}"#);
let req_nested =
b64(br#"{"amount":"1000000","currency":"0x1234","methodDetails":{"chainId":42431}}"#);
let req_empty = b64(br#"{}"#);
#[allow(clippy::type_complexity)]
let vectors: Vec<(
&str,
&str,
&str,
&str,
&str,
Option<&str>,
Option<&str>,
&str,
)> = vec![
(
"required fields only",
"api.example.com",
"tempo",
"charge",
&req_amount,
None,
None,
"X6v1eo7fJ76gAxqY0xN9Jd__4lUyDDYmriryOM-5FO4",
),
(
"with expires",
"api.example.com",
"tempo",
"charge",
&req_amount,
Some("2025-01-06T12:00:00Z"),
None,
"ChPX33RkKSZoSUyZcu8ai4hhkvjZJFkZVnvWs5s0iXI",
),
(
"with digest",
"api.example.com",
"tempo",
"charge",
&req_amount,
None,
Some("sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE"),
"JHB7EFsPVb-xsYCo8LHcOzeX1gfXWVoUSzQsZhKAfKM",
),
(
"with expires and digest",
"api.example.com",
"tempo",
"charge",
&req_amount,
Some("2025-01-06T12:00:00Z"),
Some("sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE"),
"m39jbWWCIfmfJZSwCfvKFFtBl0Qwf9X4nOmDb21peLA",
),
(
"multi-field request",
"api.example.com",
"tempo",
"charge",
&req_multi,
None,
None,
"_H5TOnnlW0zduQ5OhQ3EyLVze_TqxLDPda2CGZPZxOc",
),
(
"nested methodDetails",
"api.example.com",
"tempo",
"charge",
&req_nested,
None,
None,
"TqujwpuDDg_zsWGINAd5XObO2rRe6uYufpqvtDmr6N8",
),
(
"empty request",
"api.example.com",
"tempo",
"charge",
&req_empty,
None,
None,
"yLN7yChAejW9WNmb54HpJIWpdb1WWXeA3_aCx4dxmkU",
),
(
"different realm",
"payments.other.com",
"tempo",
"charge",
&req_amount,
None,
None,
"3F5bOo2a9RUihdwKk4hGRvBvzQmVPBMDvW0YM-8GD00",
),
(
"different method",
"api.example.com",
"stripe",
"charge",
&req_amount,
None,
None,
"o0ra2sd7HcB4Ph0Vns69gRDUhSj5WNOnUopcDqKPLz4",
),
(
"different intent",
"api.example.com",
"tempo",
"session",
&req_amount,
None,
None,
"aAY7_IEDzsznNYplhOSE8cERQxvjFcT4Lcn-7FHjLVE",
),
];
for (label, realm, method, intent, request, expires, digest, expected) in &vectors {
let id = compute_challenge_id(
secret, realm, method, intent, request, *expires, *digest, None,
);
assert_eq!(&id, expected, "golden vector failed: {}", label);
}
}
#[test]
fn test_compute_challenge_id_deterministic() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let id1 = compute_challenge_id(
"secret",
"api",
"tempo",
"charge",
request.raw(),
None,
None,
None,
);
let id2 = compute_challenge_id(
"secret",
"api",
"tempo",
"charge",
request.raw(),
None,
None,
None,
);
assert_eq!(id1, id2);
}
#[test]
fn test_compute_challenge_id_different_secrets() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let id1 = compute_challenge_id(
"secret-a",
"api",
"tempo",
"charge",
request.raw(),
None,
None,
None,
);
let id2 = compute_challenge_id(
"secret-b",
"api",
"tempo",
"charge",
request.raw(),
None,
None,
None,
);
assert_ne!(id1, id2);
}
#[test]
fn test_opaque_affects_challenge_id() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000000"})).unwrap();
let id_without = compute_challenge_id(
"test-secret",
"api.example.com",
"tempo",
"charge",
request.raw(),
None,
None,
None,
);
let opaque =
Base64UrlJson::from_value(&serde_json::json!({"pi": "pi_3abc123XYZ"})).unwrap();
let id_with = compute_challenge_id(
"test-secret",
"api.example.com",
"tempo",
"charge",
request.raw(),
None,
None,
Some(opaque.raw()),
);
assert_ne!(id_without, id_with);
}
#[test]
fn test_opaque_golden_vectors() {
let secret = "test-vector-secret";
let req = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000000"})).unwrap();
let opaque1 =
Base64UrlJson::from_value(&serde_json::json!({"pi": "pi_3abc123XYZ"})).unwrap();
let id1 = compute_challenge_id(
secret,
"api.example.com",
"tempo",
"charge",
req.raw(),
None,
None,
Some(opaque1.raw()),
);
assert_eq!(
id1, "rxzKZ2qjXvinqCH96RORTZEPs1KXsA-0AUjrCAPFOWc",
"opaque golden vector failed: with opaque"
);
let id2 = compute_challenge_id(
secret,
"api.example.com",
"tempo",
"charge",
req.raw(),
Some("2025-01-06T12:00:00Z"),
None,
Some(opaque1.raw()),
);
assert_eq!(
id2, "KAfoMrA4fnzS1DPWN_cUv_b3_yHxCizdp6OhH7gluMY",
"opaque golden vector failed: with opaque and expires"
);
let opaque_empty = Base64UrlJson::from_value(&serde_json::json!({})).unwrap();
let id3 = compute_challenge_id(
secret,
"api.example.com",
"tempo",
"charge",
req.raw(),
None,
None,
Some(opaque_empty.raw()),
);
assert_eq!(
id3, "vb4IyH-0LdJ3s7L0QAw8jIzcZkyxksPhIvEfmHmzA9k",
"opaque golden vector failed: with empty opaque"
);
let opaque_multi = Base64UrlJson::from_value(
&serde_json::json!({"deposit": "dep_456", "pi": "pi_3abc123XYZ"}),
)
.unwrap();
let id4 = compute_challenge_id(
secret,
"api.example.com",
"tempo",
"charge",
req.raw(),
None,
None,
Some(opaque_multi.raw()),
);
assert_eq!(
id4, "aKskU8sadR5ZuFbUCsIwhO-ENxuVpTw17FdwHEXsJDk",
"opaque golden vector failed: with multi-key opaque"
);
}
}