Skip to content

Commit

Permalink
add JWSValidation module with functions to validate the JWS transaction
Browse files Browse the repository at this point in the history
  • Loading branch information
Lgdev07 committed Aug 13, 2024
1 parent f36c5f7 commit b03efa7
Show file tree
Hide file tree
Showing 5 changed files with 225 additions and 2 deletions.
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,21 @@ 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,
"the-transaction-id"
)
```

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.
82 changes: 82 additions & 0 deletions lib/app_store/jws_validation/jws_validation.ex
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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"},
}
125 changes: 125 additions & 0 deletions test/app_store/jws_validation/jws_validation_test.exs
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit b03efa7

Please sign in to comment.