I'm implementing payment processing with Apple Pay on the web, but I've been stuck right at the final step of the flow: decrypting the payment data sent by Apple.
Here is a summary of my implementation:
The backend language is Java. The frontend portal requests the session and performs the payment using the endpoints exposed by the backend. I created .p12 files from the .cer files returned by the Apple Developer portal for both certificates (Merchant Identity and Payment Processing) and I'm using them in my backend.
The merchant validation works perfectly; the user is able to request a session and proceed to the payment sheet.
However, when the frontend sends the encrypted token back to my sale endpoint, the problem begins. My code consistently fails when trying to decrypt the data (inside the paymentData node) throwing a javax.crypto.AEADBadTagException: Tag mismatch!
I can confirm that the certificate used by Apple to encrypt the payment data is the correct one. The hash received from the PKPaymentToken (header.publicKeyHash) object exactly matches the hash generated manually on my side from my .p12 file.
In the decryption process, I'm using Bouncy Castle only to calculate the Elliptic Curve (ECC) shared secret. For the final AES-GCM decryption, I am using Java's native provider since I already have the bytes of the shared secret calculated. (Originally, I was doing it entirely with BC, but it failed with the exact same error).
We have exhaustively verified our cryptographic implementation:
We successfully reconstruct the ephemeralPublicKey and compute the ECDH Shared Secret using our Payment Processing Certificate's private key (prime256v1).
We perform the Key Derivation Function (KDF) using id-aes256-GCM, PartyU as Apple, and counter 00000001.
For PartyV, we have tried calculating the SHA-256 hash of our exact Merchant ID string.
We also extracted the exact ASN.1 hex payload from the certificate's extension OID 1.2.840.113635.100.6.32 and used it as PartyV.
We have tried generating brand new CSRs and Processing Certificates via OpenSSL directly from the terminal.
Despite having the correct ECDH shared secret (and confirming Apple used our public key via the hash), the AES tag validation always fails.et, the AES tag validation always fails.
Given that the math seems correct and the public key hashes match, could there be an environment mismatch (Sandbox vs. Production) or a domain validation issue causing Apple to encrypt the payload with a dummy PartyV or scramble the data altogether?
Any guidance on this behavior or the exact PartyV expected in this scenario would be highly appreciated.
Hi @MrDanSB,
You wrote:
Given that the math seems correct and the public key hashes match, could there be an environment mismatch (Sandbox vs. Production) or a domain validation issue causing Apple to encrypt the payload with a dummy PartyV or scramble the data altogether?
The Tag mismatch in AES-GCM almost always means the derived key is wrong, not the decryption step itself. Given what you've descried, there are several high-probability culprits beyond what you've already investigated:
- ECHD Shared Secret
- The KDF
- The exact value of PartyV
ECHD Shared Secret
Even when Bouncy Castle computes the correct EC point, the shared secrete Z must be exactly 32-byte big-endian X-coordinate of that point. Two common silent corruptions:
- BigInteger byte length stripping / sign inflation. Some libraries may return 33 signed bytes, or fewer than 32—the value needs to be exactly 32 bytes.
- Using Bouncy Castles' BasicAgreement vs ECDHBasicAgreement. All signing algorithms should use ECDH.
The KDF
Apple Pay Web uses ANSI X9.63 KDF with SHA-256, not NIST SP 800-56A Concat KDF. The critical difference: ANSI X9.63 has no length prefixes on OtherInfo components. Some Bouncy Castle KDF helpers add 4-byte length fields before each component. If you're using ConcatenationKDFGenerator or any wrapper, verify it doesn't inject these lengths.
The exact byte concatenation fed into SHA-256 must be:
SHA-256( 0x00000001 || Z || "id-aes256-GCM" || "Apple" || partyVInfo )
Confirm your byte lengths: "id-aes256-GCM" = 13 bytes, "Apple" = 5 bytes, partyVInfo = 32 bytes. Total OtherInfo = 50 bytes. Total SHA-256 input = 4 + 32 + 50 = 86 bytes. If your input differs, you've found the bug.
The exact value of PartyV The SHA-256 for PartyV hashes the raw ASCII merchant ID string—and nothing more:
// CORRECT
String merchantId = "merchant.com.yourcompany"; // exactly as registered
byte[] partyVInfo = MessageDigest.getInstance("SHA-256")
.digest(merchantId.getBytes(StandardCharsets.US_ASCII));
Your ASN.1 attempt likely failed because the certificate extension value for OID 1.2.840.113635.100.6.32 is DER-encoded as an IA5String, which prepends 0x16 (tag) + length byte(s) before the string content. If you hashed those raw DER bytes rather than the decoded string content, the PartyV was wrong.
If you have questions about any of the above, please review the guide below:
Getting Started with Apple Pay In-App Provisioning, Verification, and Security v4
You can contact the provisioning team that approved your entitlement request to ask for this guide, if needed.
Cheers,
Paris X Pinkney | WWDR | DTS Engineer