diff --git a/README.md b/README.md index 2adf22b..52c9259 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ iex> token = AppStore.Token.generate_token( Get transactions history: ```elixir -iex> {:ok, %AppStore.API.Response{body: body, status: status}} = +iex> {:ok, %AppStore.API.Response{body: %{"signedTransactions" => signed_transactions} = body, status: status}} = AppStore.API.get_transaction_history( app_store.api_config, token, @@ -74,4 +74,13 @@ iex> {:ok, %AppStore.API.Response{body: body, status: status}} = ) ``` +Validate the response: + +```elixir +iex> [ + {:ok, %JOSE.JWT{fields: %{"bundleId" => "com.example", "environment" => "Sandbox", "signedDate" => 1_672_956_154_000}}}, + {:ok, %JOSE.JWT{fields: %{"bundleId" => "com.example2", "environment" => "Sandbox", "signedDate" => 1_672_956_154_000}}} + ] = JWSValidation.validate(signed_transactions) +``` + Please check [https://hexdocs.pm/app_store](https://hexdocs.pm/app_store) for a full documentation. diff --git a/lib/app_store/jws_validation/jws_validation.ex b/lib/app_store/jws_validation/jws_validation.ex new file mode 100644 index 0000000..27e7bc5 --- /dev/null +++ b/lib/app_store/jws_validation/jws_validation.ex @@ -0,0 +1,82 @@ +defmodule AppStore.JWSValidation do + @moduledoc """ + A module to validate the JWS from Apple. + """ + + # Apple Root CA G3 public certificate available at https://www.apple.com/certificateauthority/ + @apple_root_cert File.read!(Path.join(File.cwd!(), "/priv/certs/AppleRootCA-G3.cer")) + + @doc """ + Validate the signed payload from Apple. + + Official documentation: [JWS Transaction + ](https://developer.apple.com/documentation/appstoreserverapi/jwstransaction) + + ## Examples + iex> AppStore.JWSValidation.validate(" + eyJhbGciOiJFUzI1NiIsImtpZCI6IjJYOVI0SFhGMzQiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJhcHBzdG9yZWNvbm5lY3QtdjEiLCJiaWQiOiJjb20uZXhhbXBsZS50ZXN0YnVuZGxlaWQyMDIxIiwiZXhwIjoxNjI5NTA2MjQwLCJpYXQiOjE2Mjk1MDI3MDAsImlzcyI6IjU3MjQ2NTQyLTk2ZmUtMWE2My1lMDUzLTA4MjRkMDExMDcyYSIsIm5vbmNlIjoiMnFlaWc0a2wxOTQ0aHFhbmVzMDAwMGMxIn0.gYa_A7J6a6UAyBTAohf4gj28jT0k-OX1CW8cwsVGb4EewEm3owdsv6iWvzt7SutCndCBg5hPfNFWuZ0Au20HxA" + ) + {:ok, + %JOSE.JWT{ + fields: %{ + "bundleId" => "com.example", + "environment" => "Sandbox", + "signedDate" => 1_672_956_154_000 + } + }} + + iex> AppStore.JWSValidation.validate(["signed_payload1", "signed_payload2"])) + [ + {:ok, %JOSE.JWT{fields: %{"bundleId" => "com.example", "environment" => "Sandbox", "signedDate" => 1_672_956_154_000}}}, + {:ok, %JOSE.JWT{fields: %{"bundleId" => "com.example2", "environment" => "Sandbox", "signedDate" => 1_672_956_154_000}}} + ] + """ + @spec validate(String.t() | list()) :: {:error, atom} | {:ok, %JOSE.JWT{}} + def validate(signed_payload) when is_binary(signed_payload) do + with {:ok, [leaf_cert | _] = cert_chain} <- get_binary_cert_chain(signed_payload), + {:ok, _pk_info} <- __MODULE__.validate_certificate_chain(cert_chain), + {true, jwt, _jws} <- JOSE.JWT.verify(get_jwk(leaf_cert), signed_payload) do + {:ok, jwt} + else + {_valid_signature? = false, _jwt, _jws} -> {:error, :invalid_signature} + {:error, reason} -> {:error, reason} + end + end + + def validate(signed_payloads) when is_list(signed_payloads) do + Enum.map(signed_payloads, &validate/1) + end + + def validate(_), do: {:error, :invalid_jws} + + def get_binary_cert_chain(signed_payload) do + with header <- JOSE.JWS.peek_protected(signed_payload), + {:ok, decoded_header} <- Jason.decode(header), + [_ | _] = base64_cert_chain <- Map.get(decoded_header, "x5c") do + {:ok, Enum.map(base64_cert_chain, &Base.decode64!/1)} + else + _ -> {:error, :invalid_jws} + end + end + + # We allow sending an ext_apple_root_cert (external) to be able to test this function + def validate_certificate_chain(cert_chain, ext_apple_root_cert \\ nil) + + def validate_certificate_chain([raw_leaf, raw_intermediate, _raw_root], ext_apple_root_cert) do + apple_root_cert = ext_apple_root_cert || @apple_root_cert + + case :public_key.pkix_path_validation(apple_root_cert, [raw_intermediate, raw_leaf], []) do + {:ok, pk_info} -> {:ok, pk_info} + _ -> {:error, :invalid_cert_chain} + end + end + + def validate_certificate_chain(_, _), do: {:error, :invalid_cert_chain} + + defp get_jwk(leaf_cert) do + leaf_cert + |> X509.Certificate.from_der!() + |> X509.Certificate.public_key() + |> JOSE.JWK.from_key() + end +end diff --git a/mix.exs b/mix.exs index d7e9d04..dcde5ab 100644 --- a/mix.exs +++ b/mix.exs @@ -42,11 +42,15 @@ defmodule AppStore.MixProject do # JWT {:joken, "~> 2.0"}, + # JWS + {:x509, "~> 0.8.9"}, + # doc {:ex_doc, "~> 0.24", only: :dev, runtime: false}, # test - {:bypass, "~> 2.1", only: :test} + {:bypass, "~> 2.1", only: :test}, + {:mock, "~> 0.3.8", only: :test} ] end diff --git a/mix.lock b/mix.lock index d22d2bf..f54dfda 100644 --- a/mix.lock +++ b/mix.lock @@ -14,8 +14,10 @@ "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, + "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "mint": {:hex, :mint, "1.6.1", "065e8a5bc9bbd46a41099dfea3e0656436c5cbcb6e741c80bd2bad5cd872446f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4fc518dcc191d02f433393a72a7ba3f6f94b101d094cb6bf532ea54c89423780"}, + "mock": {:hex, :mock, "0.3.8", "7046a306b71db2488ef54395eeb74df0a7f335a7caca4a3d3875d1fc81c884dd", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "7fa82364c97617d79bb7d15571193fc0c4fe5afd0c932cef09426b3ee6fe2022"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, @@ -24,4 +26,5 @@ "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "x509": {:hex, :x509, "0.8.9", "03c47e507171507d3d3028d802f48dd575206af2ef00f764a900789dfbe17476", [:mix], [], "hexpm", "ea3fb16a870a199cb2c45908a2c3e89cc934f0434173dc0c828136f878f11661"}, } diff --git a/test/app_store/jws_validation/jws_validation_test.exs b/test/app_store/jws_validation/jws_validation_test.exs new file mode 100644 index 0000000..5946709 --- /dev/null +++ b/test/app_store/jws_validation/jws_validation_test.exs @@ -0,0 +1,125 @@ +defmodule AppStore.JWSValidationTest do + use AppStore.TestCase, async: false + + import Mock + + alias AppStore.JWSValidation + + describe "validate/2" do + test_with_mock "returns success for a valid JWS", + %{}, + AppStore.JWSValidation, + [:passthrough], + validate_certificate_chain: fn cert_chain -> {:ok, cert_chain} end do + assert {:ok, + %JOSE.JWT{ + fields: %{ + "bundleId" => "com.example", + "environment" => "Sandbox", + "signedDate" => 1_672_956_154_000 + } + }} == JWSValidation.validate(apple_jws_response_v2()) + end + + test_with_mock "returns success for a valid list of JWS", + %{}, + AppStore.JWSValidation, + [:passthrough], + validate_certificate_chain: fn cert_chain -> {:ok, cert_chain} end do + assert [ + {:ok, + %JOSE.JWT{ + fields: %{ + "bundleId" => "com.example", + "environment" => "Sandbox", + "signedDate" => 1_672_956_154_000 + } + }}, + {:ok, + %JOSE.JWT{ + fields: %{ + "bundleId" => "com.example", + "environment" => "Sandbox", + "signedDate" => 1_672_956_154_000 + } + }} + ] == JWSValidation.validate([apple_jws_response_v2(), apple_jws_response_v2()]) + end + + test "returns error for an invalid JWS header" do + header_without_x5c = %{"alg" => "ES256"} + + assert {:error, :invalid_jws} == + JOSE.JWK.generate_key({:ec, :secp256r1}) + |> JOSE.JWS.sign("{}", header_without_x5c) + |> JOSE.JWS.compact() + |> JWSValidation.validate() + end + + test "returns error for a JWS with invalid signature" do + with_mocks([ + {AppStore.JWSValidation, [:passthrough], + validate_certificate_chain: fn cert_chain -> {:ok, cert_chain} end}, + {JOSE.JWT, [:passthrough], verify: fn _jwk, _jws -> {false, %JOSE.JWT{}, %JOSE.JWS{}} end} + ]) do + assert {:error, :invalid_signature} == JWSValidation.validate(apple_jws_response_v2()) + end + end + + test "returns error when certificate chain validation fails" do + with_mock JWSValidation, [:passthrough], + validate_certificate_chain: fn _cert_chain -> {:error, :invalid_cert_chain} end do + assert {:error, :invalid_cert_chain} == JWSValidation.validate(apple_jws_response_v2()) + end + end + + test "returns error when JWS is nil" do + assert {:error, :invalid_jws} == JWSValidation.validate(nil) + end + + test "returns error when jws header has a incorrect cert chain" do + jws_header = %{ + "alg" => "ES256", + "x5c" => ["wrong_leaf, wrong_raw, wrong_root"] + } + + assert {:error, :invalid_jws} == + JOSE.JWK.generate_key({:ec, :secp256r1}) + |> JOSE.JWS.sign("{}", jws_header) + |> JOSE.JWS.compact() + |> JWSValidation.validate() + end + end + + describe "validate_certificate_chain/2" do + setup do + {:ok, cert_chain} = JWSValidation.get_binary_cert_chain(apple_jws_response_v2()) + {:ok, cert_chain: cert_chain} + end + + test "returns success for a valid certificate chain", %{ + cert_chain: [_leaf, _int, root] = cert_chain + } do + assert {:ok, _pk_info} = JWSValidation.validate_certificate_chain(cert_chain, root) + end + + test "returns error for an invalid certificate chain", %{cert_chain: cert_chain} do + assert {:error, :invalid_cert_chain} == JWSValidation.validate_certificate_chain(cert_chain) + end + + test "returns error when an incomplete certificate chain is provided", %{ + cert_chain: cert_chain + } do + assert {:error, :invalid_cert_chain} == + JWSValidation.validate_certificate_chain([List.first(cert_chain)]) + end + + test "returns error when an empty certificate chain is provided" do + assert {:error, :invalid_cert_chain} == JWSValidation.validate_certificate_chain([]) + end + end + + defp apple_jws_response_v2 do + "eyJ4NWMiOlsiTUlJQm9EQ0NBVWFnQXdJQkFnSUJDekFLQmdncWhrak9QUVFEQWpCTk1Rc3dDUVlEVlFRR0V3SlZVekVUTUJFR0ExVUVDQXdLUTJGc2FXWnZjbTVwWVRFU01CQUdBMVVFQnd3SlEzVndaWEowYVc1dk1SVXdFd1lEVlFRS0RBeEpiblJsY20xbFpHbGhkR1V3SGhjTk1qTXdNVEEwTVRZek56TXhXaGNOTXpJeE1qTXhNVFl6TnpNeFdqQkZNUXN3Q1FZRFZRUUdFd0pWVXpFVE1CRUdBMVVFQ0F3S1EyRnNhV1p2Y201cFlURVNNQkFHQTFVRUJ3d0pRM1Z3WlhKMGFXNXZNUTB3Q3dZRFZRUUtEQVJNWldGbU1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRTRyV0J4R21GYm5QSVBRSTB6c0JLekx4c2o4cEQydnFicjB5UElTVXgyV1F5eG1yTnFsOWZoSzhZRUV5WUZWNysrcDVpNFlVU1Ivbzl1UUlnQ1BJaHJLTWZNQjB3Q1FZRFZSMFRCQUl3QURBUUJnb3Foa2lHOTJOa0Jnc0JCQUlUQURBS0JnZ3Foa2pPUFFRREFnTklBREJGQWlFQWtpRVprb0ZNa2o0Z1huK1E5alhRWk1qWjJnbmpaM2FNOE5ZcmdmVFVpdlFDSURKWVowRmFMZTduU0lVMkxXTFRrNXRYVENjNEU4R0pTWWYvc1lSeEVGaWUiLCJNSUlCbHpDQ0FUMmdBd0lCQWdJQkJqQUtCZ2dxaGtqT1BRUURBakEyTVFzd0NRWURWUVFHRXdKVlV6RVRNQkVHQTFVRUNBd0tRMkZzYVdadmNtNXBZVEVTTUJBR0ExVUVCd3dKUTNWd1pYSjBhVzV2TUI0WERUSXpNREV3TkRFMk1qWXdNVm9YRFRNeU1USXpNVEUyTWpZd01Wb3dUVEVMTUFrR0ExVUVCaE1DVlZNeEV6QVJCZ05WQkFnTUNrTmhiR2xtYjNKdWFXRXhFakFRQmdOVkJBY01DVU4xY0dWeWRHbHViekVWTUJNR0ExVUVDZ3dNU1c1MFpYSnRaV1JwWVhSbE1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRUZRM2xYMnNxTjlHSXdBaWlNUURRQy9reW5TZ1g0N1J3dmlET3RNWFh2eUtkUWU2Q1BzUzNqbzJ1UkR1RXFBeFdlT2lDcmpsRFdzeXo1d3dkVTBndGFxTWxNQ013RHdZRFZSMFRCQWd3QmdFQi93SUJBREFRQmdvcWhraUc5Mk5rQmdJQkJBSVRBREFLQmdncWhrak9QUVFEQWdOSUFEQkZBaUVBdm56TWNWMjY4Y1JiMS9GcHlWMUVoVDNXRnZPenJCVVdQNi9Ub1RoRmF2TUNJRmJhNXQ2WUt5MFIySkR0eHF0T2pKeTY2bDZWN2QvUHJBRE5wa21JUFcraSIsIk1JSUJYRENDQVFJQ0NRQ2ZqVFVHTERuUjlqQUtCZ2dxaGtqT1BRUURBekEyTVFzd0NRWURWUVFHRXdKVlV6RVRNQkVHQTFVRUNBd0tRMkZzYVdadmNtNXBZVEVTTUJBR0ExVUVCd3dKUTNWd1pYSjBhVzV2TUI0WERUSXpNREV3TkRFMk1qQXpNbG9YRFRNek1ERXdNVEUyTWpBek1sb3dOakVMTUFrR0ExVUVCaE1DVlZNeEV6QVJCZ05WQkFnTUNrTmhiR2xtYjNKdWFXRXhFakFRQmdOVkJBY01DVU4xY0dWeWRHbHViekJaTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCSFB2d1pmb0tMS2FPclgvV2U0cU9iWFNuYTVUZFdIVlo2aElSQTF3MG9jM1FDVDBJbzJwbHlEQjMvTVZsazJ0YzRLR0U4VGlxVzdpYlE2WmM5VjY0azB3Q2dZSUtvWkl6ajBFQXdNRFNBQXdSUUloQU1USGhXdGJBUU4waFN4SVhjUDRDS3JEQ0gvZ3N4V3B4NmpUWkxUZVorRlBBaUIzNW53azVxMHpjSXBlZnZZSjBNVS95R0dIU1dlejBicTBwRFlVTy9ubUR3PT0iXSwidHlwIjoiSldUIiwiYWxnIjoiRVMyNTYifQ.eyJlbnZpcm9ubWVudCI6IlNhbmRib3giLCJidW5kbGVJZCI6ImNvbS5leGFtcGxlIiwic2lnbmVkRGF0ZSI6MTY3Mjk1NjE1NDAwMH0.PnHWpeIJZ8f2Q218NSGLo_aR0IBEJvC6PxmxKXh-qfYTrZccx2suGl223OSNAX78e4Ylf2yJCG2N-FfU-NIhZQ" + end +end