I recently had to upgrade our backend's handling of App Store Server Notifications to the new v2 version. The old version had pretty basic security by only having a supplied password in the response that you verified with what you had configured it to be in App Store Connect. The new version on the other hand is now in JWS (JSON Web Signature) signed with an Apple X.509 certificate chain. Since it was not straight forward to figure out how to verify this certificate chain and signature I wanted to write down how I was able to do it in Elixir:
The following steps are needed to verify the notifications:
- From the JWS JOSE header extract the
x5c
field containing the list of base64 encoded certificate chain. - Download the Apple Root CA - G3 Root certificate from Apple's PKI page .
- Verify the X.509 certificate chain against the Apple Root certificate.
- Extract the public key from the validated certificate chain.
- Verify the JWS signature with the public key.
The JWS payload also include the signedRenewalInfo
and signedTransaction
under the data
field. These can also be verified in the same manner.
Mix.install([:kino, :jason, :jose, :req])
input = Kino.Input.textarea("Paste your own response body here: {\"signedPayload\":\"ey…\"}")
defmodule AppStoreServerNotificationV2 do
def verify(jws) do
# Extract certificates from the x5c field in the header
%{"x5c" => x5c} =
jws
|> JOSE.JWS.peek_protected()
|> Jason.decode!()
# Base64 decode and reverse them to the format expected by the verify function
cert_chain =
Enum.map(x5c, &Base.decode64!/1)
|> Enum.reverse()
# Verify certificate chain and extract the public key
public_key = verify_cert_chain(apple_root_ca(), cert_chain)
# Convert the public key into a JSON Web Key
jwk = JOSE.JWK.from_key(public_key)
# Verify the signature of the JWS
{true, payload, _jose_jws_protected_details} = JOSE.JWS.verify(jwk, jws)
# Convert the verified payload from JSON
Jason.decode!(payload)
end
# Get Apple Root CA and store it in persistent_term for faster reuse
defp apple_root_ca do
if cert = :persistent_term.get(:apple_root_ca, nil) do
cert
else
%Req.Response{body: cert} =
Req.get!("https://www.apple.com/certificateauthority/AppleRootCA-G3.cer")
:persistent_term.put(:apple_root_ca, cert)
cert
end
end
# Verify the cerificate chain and return the public key
defp verify_cert_chain(trusted_cert, cert_chain) do
{:ok, {{_key_oid_name, public_key_type, public_key_params}, _policy_tree}} =
:public_key.pkix_path_validation(trusted_cert, cert_chain, [])
{public_key_type, public_key_params}
end
end
body = Kino.Input.read(input)
%{"signedPayload" => jws} = Jason.decode!(body)
%{
"data" => %{
"signedRenewalInfo" => signed_renewal_info,
"signedTransactionInfo" => signed_transaction_info
}
} = AppStoreServerNotificationV2.verify(jws)
[signed_renewal_info, signed_transaction_info]
|> Enum.map(&AppStoreServerNotificationV2.verify/1)
In the payload from apple I am seeing a timestamp that messes up the base64 decoding. Eg.
2022-05-20T21:43:16.046463081Z
(with a period and a space). Are you seeing this in the payloads you receive? What do I do with the timestamp?