From d49388df64dcf9f378b4a126548bfd6f65a7ea7e Mon Sep 17 00:00:00 2001 From: tikhop Date: Wed, 24 Jan 2024 03:00:02 +0100 Subject: [PATCH] chore: Add more tests --- Cargo.toml | 3 +- assets/appTransaction.json | 13 + assets/signedNotification.json | 16 + assets/signedRenewalInfo.json | 16 + assets/signedSummaryNotification.json | 21 + assets/signedTransaction.json | 28 + src/chain_verifier.rs | 32 +- src/primitives/account_tenure.rs | 2 +- src/primitives/app_transaction.rs | 1 - src/primitives/auto_renew_status.rs | 2 +- src/primitives/consumption_status.rs | 2 +- src/primitives/delivery_status.rs | 2 +- src/primitives/environment.rs | 4 + src/primitives/expiration_intent.rs | 2 +- src/primitives/extend_reason_code.rs | 2 +- .../jws_renewal_info_decoded_payload.rs | 2 +- src/primitives/lifetime_dollars_purchased.rs | 2 +- src/primitives/lifetime_dollars_refunded.rs | 2 +- src/primitives/offer_type.rs | 2 +- src/primitives/order_lookup_status.rs | 2 +- src/primitives/platform.rs | 2 +- src/primitives/play_time.rs | 2 +- src/primitives/price_increase_status.rs | 2 +- src/primitives/revocation_reason.rs | 2 +- src/primitives/status.rs | 2 +- src/primitives/user_status.rs | 2 +- src/signed_data_verifier.rs | 587 +++++++++++++++++- src/utils.rs | 40 ++ 28 files changed, 742 insertions(+), 53 deletions(-) create mode 100644 assets/appTransaction.json create mode 100644 assets/signedNotification.json create mode 100644 assets/signedRenewalInfo.json create mode 100644 assets/signedSummaryNotification.json create mode 100644 assets/signedTransaction.json diff --git a/Cargo.toml b/Cargo.toml index 633244b..35b304b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,5 +31,4 @@ base64 = "0.21.7" # Utils thiserror = "1.0.56" -[dev-dependencies] -dotenv = "0.15.0" + diff --git a/assets/appTransaction.json b/assets/appTransaction.json new file mode 100644 index 0000000..b3b937b --- /dev/null +++ b/assets/appTransaction.json @@ -0,0 +1,13 @@ +{ + "receiptType": "LocalTesting", + "appAppleId": 531412, + "bundleId": "com.example", + "applicationVersion": "1.2.3", + "versionExternalIdentifier": 512, + "receiptCreationDate": 1698148900000, + "originalPurchaseDate": 1698148800000, + "originalApplicationVersion": "1.1.2", + "deviceVerification": "device_verification_value", + "deviceVerificationNonce": "48ccfa42-7431-4f22-9908-7e88983e105a", + "preorderDate": 1698148700000 +} \ No newline at end of file diff --git a/assets/signedNotification.json b/assets/signedNotification.json new file mode 100644 index 0000000..bd473c4 --- /dev/null +++ b/assets/signedNotification.json @@ -0,0 +1,16 @@ +{ + "notificationType": "SUBSCRIBED", + "subtype": "INITIAL_BUY", + "notificationUUID": "002e14d5-51f5-4503-b5a8-c3a1af68eb20", + "data": { + "environment": "LocalTesting", + "appAppleId": 41234, + "bundleId": "com.example", + "bundleVersion": "1.2.3", + "signedTransactionInfo": "signed_transaction_info_value", + "signedRenewalInfo": "signed_renewal_info_value", + "status": 1 + }, + "version": "2.0", + "signedDate": 1698148900000 +} \ No newline at end of file diff --git a/assets/signedRenewalInfo.json b/assets/signedRenewalInfo.json new file mode 100644 index 0000000..17c07a8 --- /dev/null +++ b/assets/signedRenewalInfo.json @@ -0,0 +1,16 @@ +{ + "expirationIntent": 1, + "originalTransactionId": "12345", + "autoRenewProductId": "com.example.product.2", + "productId": "com.example.product", + "autoRenewStatus": 1, + "isInBillingRetryPeriod": true, + "priceIncreaseStatus": 0, + "gracePeriodExpiresDate": 1698148900000, + "offerType": 2, + "offerIdentifier": "abc.123", + "signedDate": 1698148800000, + "environment": "LocalTesting", + "recentSubscriptionStartDate": 1698148800000, + "renewalDate": 1698148850000 +} \ No newline at end of file diff --git a/assets/signedSummaryNotification.json b/assets/signedSummaryNotification.json new file mode 100644 index 0000000..3a22ec0 --- /dev/null +++ b/assets/signedSummaryNotification.json @@ -0,0 +1,21 @@ +{ + "notificationType": "RENEWAL_EXTENSION", + "subtype": "SUMMARY", + "notificationUUID": "002e14d5-51f5-4503-b5a8-c3a1af68eb20", + "version": "2.0", + "signedDate": 1698148900000, + "summary": { + "environment": "LocalTesting", + "appAppleId": 41234, + "bundleId": "com.example", + "productId": "com.example.product", + "requestIdentifier": "efb27071-45a4-4aca-9854-2a1e9146f265", + "storefrontCountryCodes": [ + "CAN", + "USA", + "MEX" + ], + "succeededCount": 5, + "failedCount": 2 + } +} \ No newline at end of file diff --git a/assets/signedTransaction.json b/assets/signedTransaction.json new file mode 100644 index 0000000..0211857 --- /dev/null +++ b/assets/signedTransaction.json @@ -0,0 +1,28 @@ +{ + "transactionId":"23456", + "originalTransactionId":"12345", + "webOrderLineItemId":"34343", + "bundleId":"com.example", + "productId":"com.example.product", + "subscriptionGroupIdentifier":"55555", + "purchaseDate":1698148900000, + "originalPurchaseDate":1698148800000, + "expiresDate":1698149000000, + "quantity":1, + "type":"Auto-Renewable Subscription", + "appAccountToken": "7e3fb20b-4cdb-47cc-936d-99d65f608138", + "inAppOwnershipType":"PURCHASED", + "signedDate":1698148900000, + "revocationReason": 1, + "revocationDate": 1698148950000, + "isUpgraded": true, + "offerType":1, + "offerIdentifier": "abc.123", + "environment":"LocalTesting", + "transactionReason":"PURCHASE", + "storefront":"USA", + "storefrontId":"143441", + "price": 10990, + "currency": "USD", + "offerDiscountType": "PAY_AS_YOU_GO" +} \ No newline at end of file diff --git a/src/chain_verifier.rs b/src/chain_verifier.rs index fdeae2e..4ea0a2a 100644 --- a/src/chain_verifier.rs +++ b/src/chain_verifier.rs @@ -99,7 +99,7 @@ pub fn verify_chain( }; let leaf_certificate = leaf_certificate.1; - let Some(_) = leaf_certificate.get_extension_unique(&oid!(1.2.840 .113635 .100 .6 .11 .1))? + let Some(_) = leaf_certificate.get_extension_unique(&oid!(1.2.840.113635.100.6.11.1))? else { return Err(ChainVerifierError::VerificationFailure(InvalidCertificate)); }; @@ -113,7 +113,7 @@ pub fn verify_chain( let intermediate_certificate = intermediate_certificate.1; let Some(_) = - intermediate_certificate.get_extension_unique(&oid!(1.2.840 .113635 .100 .6 .2 .1))? + intermediate_certificate.get_extension_unique(&oid!(1.2.840.113635.100.6.2.1))? else { return Err(ChainVerifierError::VerificationFailure(InvalidCertificate)); }; @@ -164,15 +164,6 @@ mod tests { use crate::utils::StringExt; use base64::engine::general_purpose::STANDARD; use base64::{DecodeError, Engine}; - - pub fn signed_payload() -> String { - std::env::var("SIGNED_PAYLOAD").expect("SIGNED_PAYLOAD must be set") - } - - pub fn apple_root_cert() -> String { - std::env::var("APPLE_ROOT_BASE64_ENCODED").expect("APPLE_ROOT_BASE64_ENCODED must be set") - } - extern crate base64; use x509_parser::error::X509Error::SignatureVerificationError; @@ -210,7 +201,9 @@ mod tests { #[test] fn test_valid_chain_invalid_intermediate_oid_without_ocsp() -> Result<(), ChainVerifierError> { let root = ROOT_CA_BASE64_ENCODED.as_der_bytes().unwrap(); - let leaf = LEAF_CERT_BASE64_ENCODED.as_der_bytes().unwrap(); + let leaf = LEAF_CERT_FOR_INTERMEDIATE_CA_INVALID_OID_BASE64_ENCODED + .as_der_bytes() + .unwrap(); let intermediate = INTERMEDIATE_CA_INVALID_OID_BASE64_ENCODED .as_der_bytes() .unwrap(); @@ -326,4 +319,19 @@ mod tests { ); Ok(()) } + + #[test] + fn test_apple_chain_is_valid() -> Result<(), ChainVerifierError> { + let root = REAL_APPLE_ROOT_BASE64_ENCODED.as_der_bytes().unwrap(); + let leaf = REAL_APPLE_SIGNING_CERTIFICATE_BASE64_ENCODED + .as_der_bytes() + .unwrap(); + let intermediate = REAL_APPLE_INTERMEDIATE_BASE64_ENCODED + .as_der_bytes() + .unwrap(); + let chain = vec![leaf.clone(), intermediate, root.clone()]; + + let _public_key = verify_chain(&chain, &vec![root], Some(EFFECTIVE_DATE)).unwrap(); + Ok(()) + } } diff --git a/src/primitives/account_tenure.rs b/src/primitives/account_tenure.rs index 1fca02d..ef7f431 100644 --- a/src/primitives/account_tenure.rs +++ b/src/primitives/account_tenure.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; /// The age of the customer’s account. /// diff --git a/src/primitives/app_transaction.rs b/src/primitives/app_transaction.rs index 91fb45d..5359540 100644 --- a/src/primitives/app_transaction.rs +++ b/src/primitives/app_transaction.rs @@ -71,7 +71,6 @@ pub struct AppTransaction { } impl AppTransaction { - /// The date that the App Store signed the JWS app transaction. /// [signedDate](https://developer.apple.com/documentation/storekit/apptransaction/3954449-signeddate) pub fn signed_date(&self) -> Option> { diff --git a/src/primitives/auto_renew_status.rs b/src/primitives/auto_renew_status.rs index b6f8cce..b013f4c 100644 --- a/src/primitives/auto_renew_status.rs +++ b/src/primitives/auto_renew_status.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; /// The renewal status for an auto-renewable subscription. /// diff --git a/src/primitives/consumption_status.rs b/src/primitives/consumption_status.rs index 3b7b3cb..9eb85ee 100644 --- a/src/primitives/consumption_status.rs +++ b/src/primitives/consumption_status.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; /// A value that indicates the extent to which the customer consumed the in-app purchase. /// diff --git a/src/primitives/delivery_status.rs b/src/primitives/delivery_status.rs index 0afdac9..ce64b97 100644 --- a/src/primitives/delivery_status.rs +++ b/src/primitives/delivery_status.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; /// A value that indicates whether the app successfully delivered an in-app purchase that works properly. /// diff --git a/src/primitives/environment.rs b/src/primitives/environment.rs index c08cc4b..be73fe7 100644 --- a/src/primitives/environment.rs +++ b/src/primitives/environment.rs @@ -6,4 +6,8 @@ pub enum Environment { Sandbox, #[serde(rename = "Production")] Production, + #[serde(rename = "Xcode")] + Xcode, + #[serde(rename = "LocalTesting")] + LocalTesting, } diff --git a/src/primitives/expiration_intent.rs b/src/primitives/expiration_intent.rs index c2f7c10..dff8cd1 100644 --- a/src/primitives/expiration_intent.rs +++ b/src/primitives/expiration_intent.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; /// The reason an auto-renewable subscription expired. /// diff --git a/src/primitives/extend_reason_code.rs b/src/primitives/extend_reason_code.rs index 576a988..e9db30b 100644 --- a/src/primitives/extend_reason_code.rs +++ b/src/primitives/extend_reason_code.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; /// The code that represents the reason for the subscription-renewal-date extension. /// diff --git a/src/primitives/jws_renewal_info_decoded_payload.rs b/src/primitives/jws_renewal_info_decoded_payload.rs index 9783205..d9e1af4 100644 --- a/src/primitives/jws_renewal_info_decoded_payload.rs +++ b/src/primitives/jws_renewal_info_decoded_payload.rs @@ -4,9 +4,9 @@ use crate::primitives::expiration_intent::ExpirationIntent; use crate::primitives::offer_type::OfferType; use crate::primitives::price_increase_status::PriceIncreaseStatus; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use serde_with::formats::Flexible; use serde_with::TimestampMilliSeconds; -use serde::{Deserialize, Serialize}; /// A decoded payload containing subscription renewal information for an auto-renewable subscription. /// diff --git a/src/primitives/lifetime_dollars_purchased.rs b/src/primitives/lifetime_dollars_purchased.rs index 22cc31e..33ce69e 100644 --- a/src/primitives/lifetime_dollars_purchased.rs +++ b/src/primitives/lifetime_dollars_purchased.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; /// A value that indicates the total amount, in USD, of in-app purchases the customer has made in your app, across all platforms. /// diff --git a/src/primitives/lifetime_dollars_refunded.rs b/src/primitives/lifetime_dollars_refunded.rs index a63c307..efa0a8f 100644 --- a/src/primitives/lifetime_dollars_refunded.rs +++ b/src/primitives/lifetime_dollars_refunded.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; /// A value that indicates the dollar amount of refunds the customer has received in your app, since purchasing the app, across all platforms. /// diff --git a/src/primitives/offer_type.rs b/src/primitives/offer_type.rs index c80780c..c0c911b 100644 --- a/src/primitives/offer_type.rs +++ b/src/primitives/offer_type.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; /// The type of subscription offer. /// diff --git a/src/primitives/order_lookup_status.rs b/src/primitives/order_lookup_status.rs index 71837ac..470777e 100644 --- a/src/primitives/order_lookup_status.rs +++ b/src/primitives/order_lookup_status.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; /// A value that indicates whether the order ID in the request is valid for your app. /// diff --git a/src/primitives/platform.rs b/src/primitives/platform.rs index 7c70aa7..839192d 100644 --- a/src/primitives/platform.rs +++ b/src/primitives/platform.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; /// The platform on which the customer consumed the in-app purchase. /// diff --git a/src/primitives/play_time.rs b/src/primitives/play_time.rs index 09fe31b..1c72be2 100644 --- a/src/primitives/play_time.rs +++ b/src/primitives/play_time.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; /// A value that indicates the amount of time that the customer used the app. /// diff --git a/src/primitives/price_increase_status.rs b/src/primitives/price_increase_status.rs index a801a7c..2eda0b4 100644 --- a/src/primitives/price_increase_status.rs +++ b/src/primitives/price_increase_status.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; /// The status that indicates whether an auto-renewable subscription is subject to a price increase. /// diff --git a/src/primitives/revocation_reason.rs b/src/primitives/revocation_reason.rs index 91b1242..66e48dd 100644 --- a/src/primitives/revocation_reason.rs +++ b/src/primitives/revocation_reason.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; /// The reason for a refunded transaction. /// diff --git a/src/primitives/status.rs b/src/primitives/status.rs index f62985d..a614fe4 100644 --- a/src/primitives/status.rs +++ b/src/primitives/status.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; /// The status of an auto-renewable subscription. /// diff --git a/src/primitives/user_status.rs b/src/primitives/user_status.rs index 51f381f..3a87318 100644 --- a/src/primitives/user_status.rs +++ b/src/primitives/user_status.rs @@ -1,4 +1,4 @@ -use serde_repr::{Serialize_repr, Deserialize_repr}; +use serde_repr::{Deserialize_repr, Serialize_repr}; #[derive(Debug, Clone, Deserialize_repr, Serialize_repr, Hash, PartialEq, Eq)] #[repr(u8)] diff --git a/src/signed_data_verifier.rs b/src/signed_data_verifier.rs index 8bfc143..63a9c7a 100644 --- a/src/signed_data_verifier.rs +++ b/src/signed_data_verifier.rs @@ -1,14 +1,15 @@ -use base64::DecodeError; +use base64::engine::general_purpose::STANDARD; +use base64::{DecodeError, Engine}; use crate::chain_verifier::{verify_chain, ChainVerifierError}; +use crate::primitives::app_transaction::AppTransaction; use crate::primitives::environment::Environment; use crate::primitives::jws_renewal_info_decoded_payload::JWSRenewalInfoDecodedPayload; use crate::primitives::jws_transaction_decoded_payload::JWSTransactionDecodedPayload; use crate::primitives::response_body_v2_decoded_payload::ResponseBodyV2DecodedPayload; -use crate::utils::StringExt; +use crate::utils::{base64_url_to_base64, StringExt}; use jsonwebtoken::{Algorithm, DecodingKey, Validation}; use serde::de::DeserializeOwned; -use crate::primitives::app_transaction::AppTransaction; #[derive(thiserror::Error, Debug, PartialEq)] pub enum SignedDataVerifierError { @@ -191,7 +192,8 @@ impl SignedDataVerifier { &self, signed_app_transaction: &str, ) -> Result { - let decoded_app_transaction: AppTransaction = self.decode_signed_object(signed_app_transaction)?; + let decoded_app_transaction: AppTransaction = + self.decode_signed_object(signed_app_transaction)?; if decoded_app_transaction.bundle_id.as_ref() != Some(&self.bundle_id) { return Err(SignedDataVerifierError::InvalidAppIdentifier); @@ -209,6 +211,31 @@ impl SignedDataVerifier { &self, signed_obj: &str, ) -> Result { + // Data is not signed by the App Store, and verification should be skipped + // The environment MUST be checked in the public method calling this + if self.environment == Environment::Xcode || self.environment == Environment::LocalTesting { + const EXPECTED_JWT_SEGMENTS: usize = 3; + + let body_segments: Vec<&str> = signed_obj.split('.').collect(); + + if body_segments.len() != EXPECTED_JWT_SEGMENTS { + return Err(SignedDataVerifierError::VerificationFailure); + } + + let _ = jsonwebtoken::decode_header(&signed_obj)?; + let body_data = base64_url_to_base64(body_segments[1]); + + let decoded_body = match STANDARD.decode(body_data) { + Ok(decoded_body) => match serde_json::from_slice(&decoded_body) { + Ok(decoded) => decoded, + Err(_) => return Err(SignedDataVerifierError::VerificationFailure), + }, + Err(_) => return Err(SignedDataVerifierError::VerificationFailure), + }; + + return Ok(decoded_body); + } + let header = jsonwebtoken::decode_header(signed_obj)?; let Some(x5c) = header.x5c else { @@ -245,7 +272,21 @@ impl SignedDataVerifier { #[cfg(test)] mod tests { use super::*; + use crate::primitives::auto_renew_status::AutoRenewStatus; + use crate::primitives::expiration_intent::ExpirationIntent; + use crate::primitives::in_app_ownership_type::InAppOwnershipType; use crate::primitives::notification_type_v2::NotificationTypeV2; + use crate::primitives::offer_discount_type::OfferDiscountType; + use crate::primitives::offer_type::OfferType; + use crate::primitives::price_increase_status::PriceIncreaseStatus; + use crate::primitives::product_type::ProductType; + use crate::primitives::revocation_reason::RevocationReason; + use crate::primitives::status::Status; + use crate::primitives::subtype::Subtype; + use crate::primitives::transaction_reason::TransactionReason; + use ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING; + use serde_json::{Map, Value}; + use std::fs; const ROOT_CA_BASE64_ENCODED: &str = "MIIBgjCCASmgAwIBAgIJALUc5ALiH5pbMAoGCCqGSM49BAMDMDYxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRIwEAYDVQQHDAlDdXBlcnRpbm8wHhcNMjMwMTA1MjEzMDIyWhcNMzMwMTAyMjEzMDIyWjA2MQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTESMBAGA1UEBwwJQ3VwZXJ0aW5vMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEc+/Bl+gospo6tf9Z7io5tdKdrlN1YdVnqEhEDXDShzdAJPQijamXIMHf8xWWTa1zgoYTxOKpbuJtDplz1XriTaMgMB4wDAYDVR0TBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYwCgYIKoZIzj0EAwMDRwAwRAIgemWQXnMAdTad2JDJWng9U4uBBL5mA7WI05H7oH7c6iQCIHiRqMjNfzUAyiu9h6rOU/K+iTR0I/3Y/NSWsXHX+acc"; @@ -257,16 +298,27 @@ mod tests { #[test] fn test_app_store_server_notification_decoding() { - let verifier = get_payload_verifier(); + let verifier = get_signed_data_verifier(Environment::Sandbox, "com.example", None); let notification = verifier .verify_and_decode_notification(TEST_NOTIFICATION) .unwrap(); assert_eq!(notification.notification_type, NotificationTypeV2::Test); } + #[test] + fn test_app_store_server_notification_decoding_production() { + let verifier = get_signed_data_verifier(Environment::Production, "com.example", None); + let error = verifier + .verify_and_decode_notification(TEST_NOTIFICATION) + .err() + .unwrap(); + + assert_eq!(error, SignedDataVerifierError::InvalidEnvironment); + } + #[test] fn test_missing_x5c_header() { - let verifier = get_payload_verifier(); + let verifier = get_signed_data_verifier(Environment::Sandbox, "com.example", None); let result = verifier.verify_and_decode_notification(MISSING_X5C_HEADER_CLAIM); assert_eq!( result.err().unwrap(), @@ -276,7 +328,7 @@ mod tests { #[test] fn test_wrong_bundle_id_for_server_notification() { - let verifier = get_payload_verifier(); + let verifier = get_signed_data_verifier(Environment::Sandbox, "com.example", None); let result = verifier.verify_and_decode_notification(WRONG_BUNDLE_ID); assert_eq!( result.err().unwrap(), @@ -286,12 +338,7 @@ mod tests { #[test] fn test_wrong_app_apple_id_for_server_notification() { - let verifier = SignedDataVerifier::new( - vec![ROOT_CA_BASE64_ENCODED.as_der_bytes().unwrap()], - Environment::Production, - "com.example".to_string(), - Some(1235), - ); + let verifier = get_signed_data_verifier(Environment::Production, "com.example", Some(1235)); let result = verifier.verify_and_decode_notification(TEST_NOTIFICATION); assert_eq!( result.err().unwrap(), @@ -301,7 +348,7 @@ mod tests { #[test] fn test_renewal_info_decoding() { - let verifier = get_payload_verifier(); + let verifier = get_signed_data_verifier(Environment::Sandbox, "com.example", None); let renewal_info = verifier .verify_and_decode_renewal_info(RENEWAL_INFO) .unwrap(); @@ -310,7 +357,7 @@ mod tests { #[test] fn test_transaction_info_decoding() { - let verifier = get_payload_verifier(); + let verifier = get_signed_data_verifier(Environment::Sandbox, "com.example", None); let notification = verifier .verify_and_decode_signed_transaction(TRANSACTION_INFO) .unwrap(); @@ -319,7 +366,7 @@ mod tests { #[test] fn test_malformed_jwt_with_too_many_parts() { - let verifier = get_payload_verifier(); + let verifier = get_signed_data_verifier(Environment::Sandbox, "com.example", None); let result = verifier.verify_and_decode_notification("a.b.c.d"); assert!(result .err() @@ -330,7 +377,7 @@ mod tests { #[test] fn test_malformed_jwt_with_malformed_data() { - let verifier = get_payload_verifier(); + let verifier = get_signed_data_verifier(Environment::Sandbox, "com.example", None); let result = verifier.verify_and_decode_notification("a.b.c"); assert!(result .err() @@ -339,14 +386,512 @@ mod tests { .contains("InternalJWTError")); } - fn get_payload_verifier() -> SignedDataVerifier { + fn get_signed_data_verifier( + environment: Environment, + bundle_id: &str, + app_apple_id: Option, + ) -> SignedDataVerifier { let verifier = SignedDataVerifier::new( vec![ROOT_CA_BASE64_ENCODED.as_der_bytes().unwrap()], - Environment::Sandbox, - "com.example".to_string(), - None, + environment, + bundle_id.to_string(), + app_apple_id.or(Some(1234)), ); verifier } + + #[test] + fn test_decoded_payloads_app_transaction_decoding() { + let signed_app_transaction = create_signed_data_from_json("assets/appTransaction.json"); + + let signed_data_verifier = get_default_signed_data_verifier(); + + match signed_data_verifier.verify_and_decode_app_transaction(&signed_app_transaction) { + Ok(app_transaction) => { + assert_eq!( + Some(&Environment::LocalTesting), + app_transaction.receipt_type.as_ref() + ); + assert_eq!( + 531412, + app_transaction.app_apple_id.expect("Expect app_apple_id") + ); + assert_eq!( + "com.example", + app_transaction.bundle_id.expect("Expect bundle_id") + ); + assert_eq!( + "1.2.3", + app_transaction + .application_version + .expect("Expect application_version") + ); + assert_eq!( + 512, + app_transaction + .version_external_identifier + .expect("Expect version_external_identifier") + ); + assert_eq!( + 1698148900, + app_transaction + .receipt_creation_date + .expect("Expect receipt_creation_date") + .timestamp() + ); + assert_eq!( + 1698148800, + app_transaction + .original_purchase_date + .expect("Expect original_purchase_date") + .timestamp() + ); + assert_eq!( + "1.1.2", + app_transaction + .original_application_version + .expect("Expect original_application_version") + ); + assert_eq!( + "device_verification_value", + app_transaction + .device_verification + .expect("Expect device_verification") + ); + assert_eq!( + "48ccfa42-7431-4f22-9908-7e88983e105a", + app_transaction + .device_verification_nonce + .expect("Expect device_verification_nonce") + .to_string() + ); + assert_eq!( + 1698148700, + app_transaction + .preorder_date + .expect("Expect preorder_date") + .timestamp() + ); + } + Err(err) => panic!("Failed to verify and decode app transaction: {:?}", err), + } + } + + #[test] + fn test_decoded_payloads_transaction_decoding() { + let signed_transaction = create_signed_data_from_json("assets/signedTransaction.json"); + + let signed_data_verifier = get_default_signed_data_verifier(); + + match signed_data_verifier.verify_and_decode_signed_transaction(&signed_transaction) { + Ok(transaction) => { + assert_eq!( + "12345", + transaction + .original_transaction_id + .as_deref() + .expect("Expect original_transaction_id") + ); + assert_eq!( + "23456", + transaction + .transaction_id + .as_deref() + .expect("Expect transaction_id") + ); + assert_eq!( + "34343", + transaction + .web_order_line_item_id + .as_deref() + .expect("Expect web_order_line_item_id") + ); + assert_eq!( + "com.example", + transaction.bundle_id.as_deref().expect("Expect bundle_id") + ); + assert_eq!( + "com.example.product", + transaction + .product_id + .as_deref() + .expect("Expect product_id") + ); + assert_eq!( + "55555", + transaction + .subscription_group_identifier + .as_deref() + .expect("Expect subscription_group_identifier") + ); + assert_eq!( + 1698148800, + transaction + .original_purchase_date + .expect("Expect original_purchase_date") + .timestamp() + ); + assert_eq!( + 1698148900, + transaction + .purchase_date + .expect("Expect purchase_date") + .timestamp() + ); + assert_eq!( + 1698148950, + transaction + .revocation_date + .expect("Expect revocation_date") + .timestamp() + ); + assert_eq!( + 1698149000, + transaction + .expires_date + .expect("Expect expires_date") + .timestamp() + ); + assert_eq!(1, transaction.quantity.expect("Expect quantity")); + assert_eq!( + ProductType::AutoRenewableSubscription, + transaction.r#type.expect("Expect type") + ); + assert_eq!( + "7e3fb20b-4cdb-47cc-936d-99d65f608138", + transaction + .app_account_token + .expect("Expect app_account_token") + .to_string() + ); + assert_eq!( + InAppOwnershipType::Purchased, + transaction + .in_app_ownership_type + .expect("Expect in_app_ownership_type") + ); + assert_eq!( + 1698148900, + transaction + .signed_date + .expect("Expect signed_date") + .timestamp() + ); + assert_eq!( + RevocationReason::RefundedDueToIssue, + transaction + .revocation_reason + .expect("Expect revocation_reason") + ); + assert_eq!( + "abc.123", + transaction + .offer_identifier + .as_deref() + .expect("Expect offer_identifier") + ); + assert!(transaction.is_upgraded.unwrap_or_default()); + assert_eq!( + OfferType::IntroductoryOffer, + transaction.offer_type.expect("Expect offer_type") + ); + assert_eq!( + "USA", + transaction + .storefront + .as_deref() + .expect("Expect storefront") + ); + assert_eq!( + "143441", + transaction + .storefront_id + .as_deref() + .expect("Expect storefront_id") + ); + assert_eq!( + TransactionReason::Purchase, + transaction + .transaction_reason + .expect("Expect transaction_reason") + ); + assert_eq!( + Environment::LocalTesting, + transaction.environment.expect("Expect environment") + ); + assert_eq!(10990, transaction.price.expect("Expect price")); + assert_eq!( + "USD", + transaction.currency.as_deref().expect("Expect currency") + ); + assert_eq!( + OfferDiscountType::PayAsYouGo, + transaction + .offer_discount_type + .expect("Expect offer_discount_type") + ); + } + Err(err) => panic!("Failed to verify and decode signed transaction: {:?}", err), + } + } + + #[test] + fn test_decoded_payloads_renewal_info_decoding() { + let signed_renewal_info = create_signed_data_from_json("assets/signedRenewalInfo.json"); + + let signed_data_verifier = get_default_signed_data_verifier(); + + match signed_data_verifier.verify_and_decode_renewal_info(&signed_renewal_info) { + Ok(renewal_info) => { + assert_eq!( + ExpirationIntent::CustomerCancelled, + renewal_info + .expiration_intent + .expect("Expect expiration_intent") + ); + assert_eq!( + "12345", + renewal_info + .original_transaction_id + .as_deref() + .expect("Expect original_transaction_id") + ); + assert_eq!( + "com.example.product.2", + renewal_info + .auto_renew_product_id + .as_deref() + .expect("Expect auto_renew_product_id") + ); + assert_eq!( + "com.example.product", + renewal_info + .product_id + .as_deref() + .expect("Expect product_id") + ); + assert_eq!( + AutoRenewStatus::On, + renewal_info + .auto_renew_status + .expect("Expect auto_renew_status") + ); + assert!(renewal_info.is_in_billing_retry_period.unwrap_or_default()); + assert_eq!( + PriceIncreaseStatus::CustomerHasNotResponded, + renewal_info + .price_increase_status + .expect("Expect price_increase_status") + ); + assert_eq!( + 1698148900, + renewal_info + .grace_period_expires_date + .expect("Expect grace_period_expires_date") + .timestamp() + ); + assert_eq!( + OfferType::PromotionalOffer, + renewal_info.offer_type.expect("Expect offer_type") + ); + assert_eq!( + "abc.123", + renewal_info + .offer_identifier + .as_deref() + .expect("Expect offer_identifier") + ); + assert_eq!( + 1698148800, + renewal_info + .signed_date + .expect("Expect signed_date") + .timestamp() + ); + assert_eq!( + Environment::LocalTesting, + renewal_info.environment.expect("Expect environment") + ); + assert_eq!( + 1698148800, + renewal_info + .recent_subscription_start_date + .expect("Expect recent_subscription_start_date") + .timestamp() + ); + assert_eq!( + 1698148850, + renewal_info + .renewal_date + .expect("Expect renewal_date") + .timestamp() + ); + } + Err(err) => panic!("Failed to verify and decode renewal info: {:?}", err), + } + } + + #[test] + fn test_decoded_payloads_notification_decoding() { + let signed_notification = create_signed_data_from_json("assets/signedNotification.json"); + + let signed_data_verifier = get_default_signed_data_verifier(); + + match signed_data_verifier.verify_and_decode_notification(&signed_notification) { + Ok(notification) => { + assert_eq!( + NotificationTypeV2::Subscribed, + notification.notification_type + ); + assert_eq!( + Subtype::InitialBuy, + notification.subtype.expect("Expect subtype") + ); + assert_eq!( + "002e14d5-51f5-4503-b5a8-c3a1af68eb20", + notification.notification_uuid + ); + assert_eq!( + "2.0", + notification.version.as_deref().expect("Expect version") + ); + assert_eq!( + 1698148900, + notification + .signed_date + .expect("Expect signed_date") + .timestamp() + ); + assert!(notification.data.is_some()); + assert!(notification.summary.is_none()); + + if let Some(data) = notification.data { + assert_eq!( + Environment::LocalTesting, + data.environment.expect("Expect environment") + ); + assert_eq!(41234, data.app_apple_id.expect("Expect app_apple_id")); + assert_eq!( + "com.example", + data.bundle_id.as_deref().expect("Expect bundle_id") + ); + assert_eq!( + "1.2.3", + data.bundle_version + .as_deref() + .expect("Expect bundle_version") + ); + assert_eq!( + "signed_transaction_info_value", + data.signed_transaction_info + .as_deref() + .expect("Expect signed_transaction_info") + ); + assert_eq!( + "signed_renewal_info_value", + data.signed_renewal_info + .as_deref() + .expect("Expect signed_renewal_info") + ); + assert_eq!(Status::Active, data.status.expect("Expect status")); + } else { + panic!("Data field is expected to be present in the notification"); + } + } + Err(err) => panic!("Failed to verify and decode notification: {:?}", err), + } + } + + #[test] + fn test_summary_notification_decoding() { + let signed_summary_notification = + create_signed_data_from_json("assets/signedSummaryNotification.json"); + + let signed_data_verifier = get_default_signed_data_verifier(); + + match signed_data_verifier.verify_and_decode_notification(&signed_summary_notification) { + Ok(notification) => { + assert_eq!( + NotificationTypeV2::RenewalExtension, + notification.notification_type + ); + assert_eq!( + Subtype::Summary, + notification.subtype.expect("Expect subtype") + ); + assert_eq!( + "002e14d5-51f5-4503-b5a8-c3a1af68eb20", + notification.notification_uuid + ); + assert_eq!( + "2.0", + notification.version.as_deref().expect("Expect version") + ); + assert_eq!( + 1698148900, + notification + .signed_date + .expect("Expect signed_date") + .timestamp() + ); + assert!(notification.data.is_none()); + assert!(notification.summary.is_some()); + + if let Some(summary) = notification.summary { + assert_eq!( + Environment::LocalTesting, + summary.environment.expect("Expect environment") + ); + assert_eq!(41234, summary.app_apple_id.expect("Expect app_apple_id")); + assert_eq!( + "com.example", + summary.bundle_id.as_deref().expect("Expect bundle_id") + ); + assert_eq!( + "com.example.product", + summary.product_id.as_deref().expect("Expect product_id") + ); + assert_eq!( + "efb27071-45a4-4aca-9854-2a1e9146f265", + summary.request_identifier + ); + assert_eq!(vec!["CAN", "USA", "MEX"], summary.storefront_country_codes); + assert_eq!(5, summary.succeeded_count); + assert_eq!(2, summary.failed_count); + } else { + panic!("Summary field is expected to be present in the notification"); + } + } + Err(err) => panic!( + "Failed to verify and decode summary notification: {:?}", + err + ), + } + } + + fn get_default_signed_data_verifier() -> SignedDataVerifier { + return get_signed_data_verifier(Environment::LocalTesting, "com.example", None); + } + + fn create_signed_data_from_json(path: &str) -> String { + let json_payload = fs::read_to_string(path).expect("Failed to read JSON file"); + let json: Map = + serde_json::from_str(json_payload.as_str()).expect("Expect JSON"); + + let header = jsonwebtoken::Header::new(Algorithm::ES256); + let private_key = generate_p256_private_key(); + let key = jsonwebtoken::EncodingKey::from_ec_der(private_key.as_ref()); + let payload = jsonwebtoken::encode(&header, &json, &key).expect("Failed to encode JWT"); + payload + } + + fn generate_p256_private_key() -> Vec { + let rng = ring::rand::SystemRandom::new(); + let private_key = + ring::signature::EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_FIXED_SIGNING, &rng) + .expect("Failed to generate private key"); + + private_key.as_ref().to_vec() + } } diff --git a/src/utils.rs b/src/utils.rs index 3ef2d20..a3994ea 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -13,6 +13,7 @@ use std::time::SystemTime; /// This function may panic if the system time is earlier than the UNIX EPOCH, /// which is an invalid state for most systems. /// +#[allow(dead_code)] pub(crate) fn system_timestamp() -> u64 { match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { Ok(n) => n.as_secs(), @@ -20,6 +21,27 @@ pub(crate) fn system_timestamp() -> u64 { } } +/// Converts a base64URL-encoded string to a standard base64-encoded string. +/// +/// Replaces '/' with '+' and '_' with '-', and adds padding if needed. +/// +/// # Examples +/// +/// ```ignore +/// let encoded_string = "aGVsbG8gd29ybGQh"; +/// let result = base64_url_to_base64(encoded_string); +/// assert_eq!(result, "aGVsbG8gd29ybGQh=="); +/// ``` +pub(crate) fn base64_url_to_base64(encoded_string: &str) -> String { + let replaced_string = encoded_string.replace('/', "+").replace('_', "-"); + + if replaced_string.len() % 4 != 0 { + return replaced_string.clone() + &"=".repeat(4 - replaced_string.len() % 4); + } + + replaced_string +} + /// A trait for extending the functionality of Rust strings. pub trait StringExt { /// Converts the string into a DER-encoded byte vector. @@ -47,3 +69,21 @@ impl StringExt for &str { STANDARD.decode(self) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_base64_url_to_base64() { + // Test with a base64URL-encoded string + let encoded_string = "aGVsbG8gd29ybGQh"; + let result = base64_url_to_base64(encoded_string); + assert_eq!(result, "aGVsbG8gd29ybGQh"); + + // Test with a base64URL-encoded string requiring padding + let encoded_string_padding = "aGVsbG8gd29ybz"; + let result_padding = base64_url_to_base64(encoded_string_padding); + assert_eq!(result_padding, "aGVsbG8gd29ybz=="); + } +}