Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save behe/25ddd9e873f36657776f69e6d4ea8ade to your computer and use it in GitHub Desktop.
Save behe/25ddd9e873f36657776f69e6d4ea8ade to your computer and use it in GitHub Desktop.
Apple App Store Server Notifications v2 validation

Apple App Store Server Notifications v2 validation

Background

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:

  1. From the JWS JOSE header extract the x5c field containing the list of base64 encoded certificate chain.
  2. Download the Apple Root CA - G3 Root certificate from Apple's PKI page .
  3. Verify the X.509 certificate chain against the Apple Root certificate.
  4. Extract the public key from the validated certificate chain.
  5. 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.

Solution

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)
@raquelmsmith
Copy link

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?

@behe
Copy link
Author

behe commented May 25, 2022

@raquelmsmith No, that sounds like you are using the old Apple Server Notifications v1: https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv1

@ErikAgrell
Copy link

@raquelmsmith Facing the exact same issue, and using v2. Did you find a solution?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment