Series Intro: Decrypting Apple Pay Payment Blob Using .NET
Step 1 on Apple’s guide for decrypting the payment blob is signature verification, which is done basically to make sure the message actually came from Apple and was not tampered with en route to us. That validates the integrity and authenticity of the message which checks a couple boxes on our list of security best practices.
Let’s take a look at a payment blob JSON generated by Apple:
"token": { "paymentData": { "version": "EC_v1", "data": "TReIvEjgA/I7Wb9s4BIID/mQc5Cj...", "signature": "MIAGCSqGSIb3DQEHAqCAMIACAQEx...", "header": { "ephemeralPublicKey": "MFkwEwYHKoZIzj0CA...", "publicKeyHash": "FRMU8WTRORF9e...", "transactionId": "611451d06a8a4fa4693b5bb25d249ab7861957e9105b4bcf16598180c4fc6816" } }, "paymentMethod": { "displayName": "MasterCard 1489", "network": "MasterCard", "type": "debit" }, "transactionIdentifier": "611451D06A8A4FA4693B5BB25D249AB7861957E9105B4BCF16598180C4FC6816" }
Apple’s guide says this about the token.PaymentData.signature element:
Key | Value | Description |
signature | detached PKCS #7 signature, Base64 encoded as string | Signature of the payment and header data. The signature includes the signing certificate, its intermediate CA certificate, and information about the signing algorithm. |
In .NET we have the SignedCms class to handle CMS/PKCS #7 messages. PKCS#7 is basically a standard for how to digitally sign a message using a certificate. Google it, dear friend. The key bit of information in Apple’s doc is that the signature is “detached” which means the content is not part of the signature. In order for us to calculate the signature, to verify it matches, we must re-attach the content. The description says the signature covers the “payment and header data” and then there is this detail under 1d:
For ECC (EC_v1), ensure that the signature is a valid ECDSA signature (
Note: For this entire blog series I’m going to ignore RSA_v1 format blobs. Our Apple rep told us that is exclusive to China, which wasn’t important for us. I built a lot of it before we learned that, so if you really want some help post in the comments and let me know!
Here’s a method for building the ContentInfo object we’ll attach to our signature validator:
public static ContentInfo BuildContentForSignatureValidation(string data, string headerEphemeralPublicKey, byte[] headerTransactionId, byte[] headerApplicationData) { using (MemoryStream ConcatenatedData = new MemoryStream()) { using (BinaryWriter Writer = new BinaryWriter(ConcatenatedData)) { Writer.Write(Convert.FromBase64String(headerEphemeralPublicKey)); Writer.Write(Convert.FromBase64String(data)); Writer.Write(headerTransactionId); if (headerApplicationData != null) Writer.Write(headerApplicationData); return new ContentInfo(ConcatenatedData.ToArray()); } } }
We can use that ContentInfo instance to validate the signature using a SignedCms instance:
public static void VerifyApplePaySignature( string signature, string data, string headerEphemeralPublicKey, byte[] headerTransactionId, byte[] headerApplicationData, int messageTimeToLiveInSeconds = 60 * 5) { SignedCms SignedCms = new SignedCms( BuildContentForSignatureValidation(data, headerEphemeralPublicKey, headerTransactionId, headerApplicationData), detached: true); try { SignedCms.Decode(Convert.FromBase64String(signature)); SignedCms.CheckSignature(verifySignatureOnly: false); } catch (Exception SignatureException) { throw new InvalidOperationException("ApplePay signature was invalid.", SignatureException); } VerifySignatureCertificates(SignedCms.Certificates); VerifyApplePaySignatureSigningTime(SignedCms, messageTimeToLiveInSeconds); } public static (X509Certificate2 intermediaryCertificate, X509Certificate2 leafCertificate) VerifySignatureCertificates(X509Certificate2Collection signatureCertificates) { if (signatureCertificates.Count != 2) throw new InvalidOperationException("ApplePay signature contained an invalid number of certificates."); X509Certificate2 IntermediaryCertificate = null; X509Certificate2 LeafCertificate = null; foreach (X509Certificate2 Certificate in signatureCertificates) { if (Certificate.Extensions["Basic Constraints"] is X509BasicConstraintsExtension BasicConstraintsExtension && BasicConstraintsExtension.CertificateAuthority) { if (Certificate.Extensions["1.2.840.113635.100.6.2.14"] == null) throw new InvalidOperationException("ApplePay signature intermediary certificate didn't contain Apple custom OID."); IntermediaryCertificate = Certificate; continue; } if (Certificate.Extensions["1.2.840.113635.100.6.29"] == null) throw new InvalidOperationException("ApplePay signature leaf certificate didn't contain Apple custom OID."); LeafCertificate = Certificate; } if (LeafCertificate == null || IntermediaryCertificate == null) throw new InvalidOperationException("Intermediary and/or leaf certificates could not be found in PKCS7 signature."); return (IntermediaryCertificate, LeafCertificate); }
We need to pause here to talk about certificate trust. In the above, we are passing false as verifySignatureOnly parameter to CheckSignature. That is going to have huge ramifications in your production environments, friends. That false is telling SignedCms to verify the chain of trust for the certificates used to sign the message. Apple says this in 1b regarding the certificate authority:
Ensure that the root CA is the Apple Root CA – G3. This certificate is available from apple.com/certificateauthority.
What that means is Apple’s certificates are self-signed. They are not purchased from an authority Windows will trust by default. If you take no action, and use false as above, your validation will fail. You have some options…
- Specify verifySignatureOnly: true in the call.
- This turns off certificate validation. Dangerous.
- Download and import Apple’s Root CA into Windows’ Trusted Root Certification Authorities machine store.
- This is perfectly acceptable as a solution. But it’s probably going to be manually done. On all your servers, on all dev machines. If you are doing this for fun, or in a very tiny shop, that’s probably good enough.
- Specify verifySignatureOnly: true AND validate the certificates yourself.
- Lot’s more code to do this, but if you have a lot of developers or a lot of production servers, it’s the operationally better solution. Our product already has a DB-driven certificate store, so I went with this method.
How to build a DB-driven certificate store is way out of the scope of what this series is covering. Here’s some code to show (more or less) how it could look:
public static void VerifyApplePaySignature( string signature, string data, string headerEphemeralPublicKey, byte[] headerTransactionId, byte[] headerApplicationData, int messageTimeToLiveInSeconds = 60 * 5) { SignedCms SignedCms = new SignedCms( BuildContentForSignatureValidation(data, headerEphemeralPublicKey, headerTransactionId, headerApplicationData), detached: true); try { SignedCms.Decode(Convert.FromBase64String(signature)); SignedCms.CheckSignature(verifySignatureOnly: true); } catch (Exception SignatureException) { throw new InvalidOperationException("ApplePay signature was invalid.", SignatureException); } (X509Certificate2 intermediaryCertificate, X509Certificate2 leafCertificate) = VerifySignatureCertificates(SignedCms.Certificates); VerifyCertificateChainTrust(intermediaryCertificate, leafCertificate); VerifyApplePaySignatureSigningTime(SignedCms, messageTimeToLiveInSeconds); } public static (X509Certificate2 intermediaryCertificate, X509Certificate2 leafCertificate) VerifySignatureCertificates(X509Certificate2Collection signatureCertificates) { if (signatureCertificates.Count != 2) throw new InvalidOperationException("ApplePay signature contained an invalid number of certificates."); X509Certificate2 IntermediaryCertificate = null; X509Certificate2 LeafCertificate = null; foreach (X509Certificate2 Certificate in signatureCertificates) { if (Certificate.Extensions["Basic Constraints"] is X509BasicConstraintsExtension BasicConstraintsExtension && BasicConstraintsExtension.CertificateAuthority) { if (Certificate.Extensions["1.2.840.113635.100.6.2.14"] == null) throw new InvalidOperationException("ApplePay signature intermediary certificate didn't contain Apple custom OID."); IntermediaryCertificate = Certificate; continue; } if (Certificate.Extensions["1.2.840.113635.100.6.29"] == null) throw new InvalidOperationException("ApplePay signature leaf certificate didn't contain Apple custom OID."); LeafCertificate = Certificate; } if (LeafCertificate == null || IntermediaryCertificate == null) throw new InvalidOperationException("Intermediary and/or leaf certificates could not be found in PKCS7 signature."); return (IntermediaryCertificate, LeafCertificate); } public static void VerifyCertificateChainTrust(X509Certificate2 intermediaryCertificate, X509Certificate2 leafCertificate) { // FindApplePayRootCertificates returns Apple X509 certificates from some kind of repository. DB, file system, embedded resources, be creative my friends! X509Certificate2Collection RootCertificates = FindApplePayRootCertificates(); using (X509Chain Chain = new X509Chain()) { Chain.ChainPolicy.ExtraStore.Add(intermediaryCertificate); foreach (X509Certificate2 Certificate in RootCertificates) { Chain.ChainPolicy.ExtraStore.Add(Certificate); } Chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; bool IsValid = Chain.Build(leafCertificate); IsValid = IsValid || (Chain.ChainStatus.Length == 1 && Chain.ChainStatus[0].Status == X509ChainStatusFlags.UntrustedRoot && Chain.ChainPolicy.ExtraStore.Contains(Chain.ChainElements[Chain.ChainElements.Count - 1].Certificate)); if (!IsValid) throw new InvalidOperationException("Certificate trust could not be established for PKCS7 signature certificates."); } }
Before you use that, there are things to consider. Chain.ChainPolicy.RevocationMode = X509RevocationMode.
Both versions of VerifyApplePaySignature above call a sub-method called VerifyApplePaySignatureSigningTime. This is to satisfy the 1e requirement on Apple’s guide, which is to make sure the message is not a replay. Preventing replay attack is another check on our security best practices list, but Apple is kind of cheating here. Message counter is the way to go, but it is really hard to do on a distributed system, which I’m sure Apple’s is. Notice Apple doesn’t give you an exact time frame to use as a validity period? They are kind of putting it on us friends.
public static void VerifyApplePaySignatureSigningTime(SignedCms signedCms, int messageTimeToLiveInSeconds) { Oid SigningTimeOid = new Oid("1.2.840.113549.1.9.5"); DateTime? SigningTime = null; foreach (SignerInfo SignerInfo in signedCms.SignerInfos ?? (IEnumerable)Array.Empty<SignerInfo>()) { foreach (CryptographicAttributeObject SignedAttribute in SignerInfo.SignedAttributes ?? (IEnumerable)Array.Empty<CryptographicAttributeObject>()) { if (SignedAttribute.Oid.Value == SigningTimeOid.Value && SignedAttribute.Values.Count > 0 && SignedAttribute.Values[0] is Pkcs9SigningTime Pkcs9SigningTime) { SigningTime = Pkcs9SigningTime.SigningTime; break; } } } if (!SigningTime.HasValue) throw new InvalidOperationException("ApplePay signature SigningTime OID was not found."); if (DateTime.UtcNow > SigningTime.Value.AddSeconds(messageTimeToLiveInSeconds)) throw new InvalidOperationException("ApplePay message has expired."); }
I picked five minutes as the default, for really no good reason. Whatever you pick, watch out for your system clocks! If you pick one minute, and your time gets out of sync, you might reject perfectly valid messages. “Time” is something we take for granted on our systems. A support escalation at 3:00 AM when your system starts rejecting everything will change that, I promise you.
The good news is at this point, we’ve validated the signature. Piece of cake, right?