From 1d90cb1ecdd22c776aeb55dc830220e86cdcc30a Mon Sep 17 00:00:00 2001 From: Wen Chen Date: Fri, 1 Mar 2024 17:40:30 -0500 Subject: [PATCH] first release commit --- .formatter.exs | 4 + .github/dependabot.yml | 11 +++ .github/workflows/elixir.yml | 62 +++++++++++++ .gitignore | 29 ++++++ CHANGELOG.md | 1 + README.md | 21 ++++- config/config.exs | 15 ++++ config/dev.exs | 1 + config/test.exs | 1 + lib/aws/signer.ex | 123 ++++++++++++++++++++++++++ lib/aws/utils.ex | 117 ++++++++++++++++++++++++ mix.exs | 83 +++++++++++++++++ mix.lock | 33 +++++++ test/kms/kms_test.exs | 110 +++++++++++++++++++++++ test/kms/kms_utils_test.exs | 111 +++++++++++++++++++++++ test/support/fixtures/kms_fixtures.ex | 83 +++++++++++++++++ test/test_helper.exs | 5 ++ 17 files changed, 808 insertions(+), 2 deletions(-) create mode 100644 .formatter.exs create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/elixir.yml create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 config/config.exs create mode 100644 config/dev.exs create mode 100644 config/test.exs create mode 100644 lib/aws/signer.ex create mode 100644 lib/aws/utils.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 test/kms/kms_test.exs create mode 100644 test/kms/kms_utils_test.exs create mode 100644 test/support/fixtures/kms_fixtures.ex create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d2cda26 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..792b094 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "mix" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml new file mode 100644 index 0000000..2291012 --- /dev/null +++ b/.github/workflows/elixir.yml @@ -0,0 +1,62 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +name: Elixir CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + +jobs: + test: + name: Test - Lint - Dialyze + + runs-on: ubuntu-22.04 + + env: + MIX_ENV: test + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + strategy: + matrix: + otp: ['25.x', '26.x'] + elixir: ['1.15', '1.16'] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: ${{matrix.otp}} + elixir-version: ${{matrix.elixir}} + + - uses: actions/setup-node@v3 + with: + node-version: 16 + + - name: Restore dependencies cache + uses: actions/cache@v3 + with: + path: deps + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix- + + - name: Install dependencies + run: mix deps.get + + - name: Run tests + run: mix test + + - name: Credo + run: mix credo --strict + + - name: Dialyzer + run: mix dialyzer diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..85b9245 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +elixirium-*.tar + +# Temporary files, for example, from tests. +/tmp/ + +# LSP +/.elixir_ls/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..825c32f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/README.md b/README.md index e57a929..8e496ef 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,19 @@ -# elixir_ethers_kms -AWS KMS signer for Ethers +# Ethers - KMS Signer + +ethers_kms is a signer library for [Ethers](https://github.com/ExWeb3/elixir_ethers) using a Key Management Service such [AWS KMS](https://aws.amazon.com/kms/) apart from the built-in [signers](https://github.com/ExWeb3/elixir_ethers/blob/main/lib/ethers/signer/local.ex) supported by Ethers. + + +## Installation + +You can install the package by adding `ethers_kms` to the list of +dependencies in your `mix.exs` file: + +```elixir +def deps do + [ + {:ethers_kms, "~> 0.0.1"}, + ] +end +``` + +The complete documentation is available on [hexdocs](https://hexdocs.pm/ethers_kms). diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..e5dca41 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,15 @@ +import Config + +import_config "#{config_env()}.exs" + +config :ethers, + rpc_client: Ethereumex.HttpClient, + keccak_module: ExKeccak, + json_module: Jason, + secp256k1_module: ExSecp256k1 + +config :ex_aws, + access_key_id: CHANGEME, + secret_access_key: CHANGEME, + security_token: CHANGEME, + region: CHANGEME diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..becde76 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1 @@ +import Config diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..becde76 --- /dev/null +++ b/config/test.exs @@ -0,0 +1 @@ +import Config diff --git a/lib/aws/signer.ex b/lib/aws/signer.ex new file mode 100644 index 0000000..e58a4ae --- /dev/null +++ b/lib/aws/signer.ex @@ -0,0 +1,123 @@ +defmodule EthersKMS.AWS.Signer do + @moduledoc """ + KMS signer works with a AWS KMS Key. + ## Signer Options + - `:kms_key_id`: The KMS Key ID. + """ + + @behaviour Ethers.Signer + + import Ethers + alias Ethers.Transaction + alias Ethers.Utils, as: EthersUtils + alias EthersKMS.AWS.Utils + + # NOTE: Max value on curve / https://github.com/ethereum/EIPs/blob/master/EIPS/eip-2.md + @secp256_k1_n 115_792_089_237_316_195_423_570_985_008_687_907_852_837_564_279_074_904_382_605_163_141_518_161_494_337 + + @impl true + def sign_transaction(%Transaction{} = tx, opts) do + with {:ok, key_id} <- get_kms_key(opts), + {:ok, %{"PublicKey" => pem}} <- ExAws.KMS.get_public_key(key_id) |> ExAws.request(), + {:ok, public_key} <- Utils.public_key_from_pem(pem), + :ok <- validate_public_key(public_key, tx.from) do + {:ok, {r, s, recovery_id}} = + Transaction.encode(tx) + |> keccak_module().hash_256() + |> Base.encode64() + |> sign(key_id, public_key) + + y_parity_or_v = Transaction.calculate_y_parity_or_v(tx, recovery_id) + + signed = + %Ethers.Transaction{ + tx + | signature_r: r, + signature_s: s, + signature_y_parity_or_v: y_parity_or_v + } + |> Transaction.encode() + |> EthersUtils.hex_encode() + + {:ok, signed} + end + end + + defp get_kms_key(opts) do + case Keyword.get(opts, :kms_key_id) do + nil -> + {:error, :kms_key_not_found} + + key_id -> + {:ok, key_id} + end + end + + defp validate_public_key(_public_key, nil), do: {:error, :no_from_address} + + defp validate_public_key(public_key, from_address) do + derived_address = EthersUtils.public_key_to_address(public_key) + from_address = EthersUtils.to_checksum_address(from_address) + + if derived_address == from_address do + :ok + else + {:error, :wrong_public_key} + end + end + + defp sign(message, key_id, public_key) do + with {:ok, %{"Signature" => signature}} <- + ExAws.KMS.sign(message, key_id, "ECDSA_SHA_256", message_type: "DIGEST") + |> ExAws.request() do + # extract r and s from the signature + {r, s} = extract_rs_from_signature(signature) + + # determine recovery_id + {:ok, recovery_id} = calculate_v(message, r, s, public_key) + + {:ok, {r, s, recovery_id}} + end + end + + defp extract_rs_from_signature(signature) do + decoded_signature = Base.decode64!(signature) + {:"ECDSA-Sig-Value", r, s} = :public_key.der_decode(:"ECDSA-Sig-Value", decoded_signature) + + # NOTE: Because of EIP-2 not all elliptic curve signatures are accepted, + # the value of s needs to be smaller than half of the curve. + s = + case s > @secp256_k1_n / 2 do + true -> @secp256_k1_n - s + _ -> s + end + + encoded_r = r |> :binary.encode_unsigned() + encoded_s = s |> :binary.encode_unsigned() + + {encoded_r, encoded_s} + end + + defp calculate_v(message, signature_r, signature_s, public_key) do + decoded_message = message |> Base.decode64!() + + # NOTE: recovery_id can only be 0 or 1. If we can recover from the signature the same public key + # as the one used for signing then that is the right value. + case secp256k1_module().recover(decoded_message, signature_r, signature_s, 0) do + {:ok, pub_key_from_sig} -> + if public_key == pub_key_from_sig do + {:ok, 0} + else + {:ok, 1} + end + + _ -> + {:error, :ecrecover_error} + end + end + + @impl true + def accounts(_opts) do + {:error, :not_supported} + end +end diff --git a/lib/aws/utils.ex b/lib/aws/utils.ex new file mode 100644 index 0000000..76158c5 --- /dev/null +++ b/lib/aws/utils.ex @@ -0,0 +1,117 @@ +defmodule EthersKMS.AWS.Utils do + @moduledoc """ + Utility functions for interacting with AWS KMS + """ + + @elliptic_curve_spec "ECC_SECG_P256K1" + @key_usage "SIGN_VERIFY" + @default_description "created through API" + @default_tags [ + %{ + "TagKey" => "terraform", + "TagValue" => "false" + } + ] + @aws_alias_prefix "alias/" + + def list_keys(opts \\ []) do + list_key_opts = extract_key_list_opts(opts) + + with {:ok, %{"Keys" => keys, "Truncated" => is_truncated} = resp} <- + ExAws.KMS.list_keys(list_key_opts) |> ExAws.request() do + body = %{ + keys: keys, + truncated: is_truncated + } + + result = + if is_truncated do + %{"NextMarker" => next_marker} = resp + Map.merge(body, %{next_marker: next_marker}) + else + body + end + + {:ok, result} + end + end + + def create_elliptic_key_pair(opts \\ []) do + with {:ok, + %{ + "KeyMetadata" => %{ + "KeyId" => key_id + } + }} <- + ExAws.KMS.create_key( + key_spec: @elliptic_curve_spec, + key_usage: @key_usage, + description: @default_description, + tags: tags_for_key(opts) + ) + |> ExAws.request() do + {:ok, key_id} + end + end + + def create_key_alias(key_id) do + ExAws.KMS.create_alias(alias_for_key(), key_id) |> ExAws.request() + end + + def get_sender_address(key_id) do + with {:ok, %{"PublicKey" => pem}} <- ExAws.KMS.get_public_key(key_id) |> ExAws.request(), + {:ok, public_key} <- public_key_from_pem(pem) do + {:ok, public_key |> Ethers.Utils.public_key_to_address()} + end + end + + def public_key_from_pem(pem) do + pem_head = "-----BEGIN PUBLIC KEY-----\n" + pem_tail = "\n-----END PUBLIC KEY-----" + + full_pem = + if String.starts_with?(pem, pem_head) and String.ends_with?(pem, pem_tail) do + pem + else + pem_head <> pem <> pem_tail + end + + [{type, der, _}] = :public_key.pem_decode(full_pem) + + {_, _, public_key} = :public_key.der_decode(type, der) + + {:ok, public_key} + end + + def disable_key(key_id) do + ExAws.KMS.disable_key(key_id) |> ExAws.request() + end + + def enable_key(key_id) do + ExAws.KMS.enable_key(key_id) |> ExAws.request() + end + + defp extract_key_list_opts(opts) do + [limit: Keyword.get(opts, :limit), marker: Keyword.get(opts, :marker)] + end + + defp tags_for_key(opts) do + case Keyword.get(opts, :tags) do + [tags] -> @default_tags ++ [tags] + _ -> @default_tags + end + end + + defp alias_for_key do + wallet_alias = + [ + System.get_env("APP_ENVIRONMENT", "unknown"), + System.get_env("APP", "app"), + "wallet", + "#{System.system_time(:second)}" + ] + |> Enum.join("-") + + @aws_alias_prefix <> wallet_alias + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..eb11b54 --- /dev/null +++ b/mix.exs @@ -0,0 +1,83 @@ +defmodule EthersKMS.MixProject do + use Mix.Project + + @version "0.0.1" + @source_url "https://github.com/ExWeb3/elixir_ethers_kms" + + def project do + [ + app: :ethers_kms, + version: @version, + elixir: "~> 1.11", + elixirc_paths: elixirc_paths(Mix.env()), + start_permanent: Mix.env() == :prod, + name: "Ethers_KMS", + source_url: @source_url, + deps: deps(), + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test + ], + description: "A KMS based signer library for Ethers.", + package: package(), + docs: docs(), + dialyzer: dialyzer() + ] + end + + defp package do + [ + licenses: ["Apache-2.0"], + links: %{"GitHub" => @source_url}, + maintainers: ["Wen Chen"], + files: ["lib", "priv", "mix.exs", "README*", "LICENSE*", "CHANGELOG*"] + ] + end + + defp docs do + source_ref = + if String.ends_with?(@version, "-dev") do + "main" + else + "v#{@version}" + end + + [ + main: "readme", + extras: [ + "README.md": [title: "Introduction"] + ], + source_url: @source_url, + source_ref: source_ref, + markdown_processor: {ExDoc.Markdown.Earmark, footnotes: true} + ] + end + + def dialyzer do + [flags: [:error_handling, :extra_return, :underspecs, :unknown, :unmatched_returns]] + end + + # Specifies which paths to compile per environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:configparser_ex, "~> 4.0"}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.3", only: [:dev, :test], runtime: false}, + {:ethers, "~> 0.3.0"}, + {:excoveralls, "~> 0.10", only: :test}, + {:ex_aws, "~> 2.5.1"}, + {:ex_aws_kms, "~> 2.3.2"}, + {:ex_aws_sts, "~> 2.3.0"}, + {:ex_secp256k1, "~> 0.7.2"}, + {:mimic, "~> 1.7", only: :test}, + {:sweet_xml, "~> 0.7.4"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..c83f6da --- /dev/null +++ b/mix.lock @@ -0,0 +1,33 @@ +%{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "castore": {:hex, :castore, "1.0.5", "9eeebb394cc9a0f3ae56b813459f990abb0a3dedee1be6b27fdb50301930502f", [:mix], [], "hexpm", "8d7c597c3e4a64c395980882d4bca3cebb8d74197c590dc272cfd3b6a6310578"}, + "configparser_ex": {:hex, :configparser_ex, "4.0.0", "17e2b831cfa33a08c56effc610339b2986f0d82a9caa0ed18880a07658292ab6", [:mix], [], "hexpm", "02e6d1a559361a063cba7b75bc3eb2d6ad7e62730c551cc4703541fd11e65e5b"}, + "credo": {:hex, :credo, "1.7.5", "643213503b1c766ec0496d828c90c424471ea54da77c8a168c725686377b9545", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f799e9b5cd1891577d8c773d245668aa74a2fcd15eb277f51a0131690ebfb3fd"}, + "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, + "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ethereumex": {:hex, :ethereumex, "0.10.6", "6d75cac39b5b7a720b064fe48563f205d3d9784e5bde25f983dd07cf306c2a6d", [:make, :mix], [{:finch, "~> 0.16", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "58cf926239dabf8bd1fc6cf50a37b926274240b7f58ba5b235a20b5500a9a7e1"}, + "ethers": {:hex, :ethers, "0.3.0", "486b711c3be2426b6d90fff35427372ad102271841f3dec940db19fe2e6d19e7", [:mix], [{:ethereumex, "~> 0.10.6", [hex: :ethereumex, repo: "hexpm", optional: false]}, {:ex_abi, "~> 0.7.0", [hex: :ex_abi, repo: "hexpm", optional: false]}, {:ex_rlp, "~> 0.6.0", [hex: :ex_rlp, repo: "hexpm", optional: false]}, {:ex_secp256k1, "~> 0.7.2", [hex: :ex_secp256k1, repo: "hexpm", optional: true]}, {:idna, "~> 6.1", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "038512dfd097288f3166609c4580a60fdf47a9de6d5474fabcf643b8d1a411e9"}, + "ex_abi": {:hex, :ex_abi, "0.7.0", "4a2f83c47d98357c75862ca77fcbaa05d9199ba2888248fdb20d4f3befc3e151", [:mix], [{:ex_keccak, "~> 0.7.3", [hex: :ex_keccak, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d35c4ef7d860fd94bcf952ab866b7639df9054c009c098ddc94cf8916d2208d6"}, + "ex_aws": {:hex, :ex_aws, "2.5.1", "7418917974ea42e9e84b25e88b9f3d21a861d5f953ad453e212f48e593d8d39f", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1b95431f70c446fa1871f0eb9b183043c5a625f75f9948a42d25f43ae2eff12b"}, + "ex_aws_kms": {:hex, :ex_aws_kms, "2.3.2", "0a397bd4eb807dcb519dc738ef3ab4f89f371cbcb5880c4f94bbd3612b364446", [:mix], [{:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "1f2adcc51a3f160f27d0c4d2acab93dd1d7e75b3809d06477bf433b99b03f616"}, + "ex_aws_sts": {:hex, :ex_aws_sts, "2.3.0", "ce48c4cba7f1595a7d544458d0202ca313124026dba7b1a0021bbb1baa3d66d0", [:mix], [{:ex_aws, "~> 2.2", [hex: :ex_aws, repo: "hexpm", optional: false]}], "hexpm", "f14e4c7da3454514bf253b331e9422d25825485c211896ab3b81d2a4bdbf62f5"}, + "ex_keccak": {:hex, :ex_keccak, "0.7.3", "33298f97159f6b0acd28f6e96ce5ea975a0f4a19f85fe615b4f4579b88b24d06", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6.1", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4c5e6d9d5f77b64ab48769a0166a9814180d40ced68ed74ce60a5174ab55b3fc"}, + "ex_rlp": {:hex, :ex_rlp, "0.6.0", "985391d2356a7cb8712a4a9a2deb93f19f2fbca0323f5c1203fcaf64d077e31e", [:mix], [], "hexpm", "7135db93b861d9e76821039b60b00a6a22d2c4e751bf8c444bffe7a042f1abaf"}, + "ex_secp256k1": {:hex, :ex_secp256k1, "0.7.2", "33398c172813b90fab9ab75c12b98d16cfab472c6dcbde832b13c45ce1c01947", [:mix], [{:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "f3b1bf56e6992e28b9d86e3bf741a4aca3e641052eb47d13ae4f5f4d4944bdaf"}, + "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, + "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, + "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, + "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, + "mimic": {:hex, :mimic, "1.7.4", "cd2772ffbc9edefe964bc668bfd4059487fa639a5b7f1cbdf4fd22946505aa4f", [:mix], [], "hexpm", "437c61041ecf8a7fae35763ce89859e4973bb0666e6ce76d75efc789204447c3"}, + "mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"}, + "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, + "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, + "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"}, + "rustler_precompiled": {:hex, :rustler_precompiled, "0.6.3", "f838d94bc35e1844973ee7266127b156fdc962e9e8b7ff666c8fb4fed7964d23", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "e18ecca3669a7454b3a2be75ae6c3ef01d550bc9a8cf5fbddcfff843b881d7c6"}, + "sweet_xml": {:hex, :sweet_xml, "0.7.4", "a8b7e1ce7ecd775c7e8a65d501bc2cd933bff3a9c41ab763f5105688ef485d08", [:mix], [], "hexpm", "e7c4b0bdbf460c928234951def54fe87edf1a170f6896675443279e2dbeba167"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, +} diff --git a/test/kms/kms_test.exs b/test/kms/kms_test.exs new file mode 100644 index 0000000..09f5406 --- /dev/null +++ b/test/kms/kms_test.exs @@ -0,0 +1,110 @@ +defmodule EthersKMS.AWS.SignerTest do + use ExUnit.Case + use Mimic + + alias EthersKMS.AWS.Signer + alias EthersKMS.KMSFixtures + + describe "sign_transaction/2" do + test "signs the transaction with the correct data" do + expect(ExAws, :request, fn _ -> + KMSFixtures.kms_public_key_response() + end) + + expect(ExAws, :request, fn _ -> + KMSFixtures.kms_sign_response() + end) + + transaction = %Ethers.Transaction{ + type: :eip1559, + chain_id: "0x539", + nonce: "0xb66", + gas: "0x5A82", + from: "0x4eed49289Ac2876C9c966FC16b22F6eC5bf0817c", + to: "0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0", + value: "0x0", + data: "0x06fdde03", + gas_price: "0x10e7467522", + max_fee_per_gas: "0x1448BAF2F5", + max_priority_fee_per_gas: "0x0" + } + + assert {:ok, + "0x02f86f820539820b6680851448baf2f5825a8294ffcf8fdee72ac11b5c542428b35eef5769c409f0808406fdde03c001a032ba3398b3223445b858849e275d6dbb1a6708f305bb2b8c427143f9239bb9bea053fa0a993614ed279496088147827086767088e33a587e3c18b5978f8ac018e5"} == + Signer.sign_transaction(transaction, + kms_key_id: "ddb1aedd-77d1-4b90-a3a8-d77fb82ba533" + ) + end + + test "fails if no kms key id is given" do + transaction = %Ethers.Transaction{ + type: :eip1559, + chain_id: "0x539", + nonce: "0xb66", + gas: "0x5A82", + from: "0x4eed49289Ac2876C9c966FC16b22F6eC5bf0817c", + to: "0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0", + value: "0x0", + data: "0x06fdde03", + gas_price: "0x10e7467522", + max_fee_per_gas: "0x1448BAF2F5", + max_priority_fee_per_gas: "0x0" + } + + assert {:error, :kms_key_not_found} == + Signer.sign_transaction(transaction, + kms_key_id: nil + ) + end + + test "fails if no from address is given" do + expect(ExAws, :request, fn _ -> + KMSFixtures.kms_public_key_response() + end) + + transaction = %Ethers.Transaction{ + type: :eip1559, + chain_id: "0x539", + nonce: "0xb66", + gas: "0x5A82", + from: nil, + to: "0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0", + value: "0x0", + data: "0x06fdde03", + gas_price: "0x10e7467522", + max_fee_per_gas: "0x1448BAF2F5", + max_priority_fee_per_gas: "0x0" + } + + assert {:error, :no_from_address} == + Signer.sign_transaction(transaction, + kms_key_id: "ddb1aedd-77d1-4b90-a3a8-d77fb82ba533" + ) + end + + test "fails if from address does not match the public key specified in the signer" do + expect(ExAws, :request, fn _ -> + KMSFixtures.kms_public_key_response() + end) + + transaction = %Ethers.Transaction{ + type: :eip1559, + chain_id: "0x539", + nonce: "0xb66", + gas: "0x5A82", + from: "0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0", + to: "0xFFcf8FDEE72ac11b5c542428B35EEF5769C409f0", + value: "0x0", + data: "0x06fdde03", + gas_price: "0x10e7467522", + max_fee_per_gas: "0x1448BAF2F5", + max_priority_fee_per_gas: "0x0" + } + + assert {:error, :wrong_public_key} == + Signer.sign_transaction(transaction, + kms_key_id: "ddb1aedd-77d1-4b90-a3a8-d77fb82ba533" + ) + end + end +end diff --git a/test/kms/kms_utils_test.exs b/test/kms/kms_utils_test.exs new file mode 100644 index 0000000..244b35b --- /dev/null +++ b/test/kms/kms_utils_test.exs @@ -0,0 +1,111 @@ +defmodule EthersKMS.AWS.UtilsTest do + use ExUnit.Case + use Mimic + + alias EthersKMS.AWS.Utils + alias EthersKMS.KMSFixtures + + describe "list_keys/0" do + test "list keys on aws kms with truncated response" do + expect(ExAws, :request, fn _ -> + KMSFixtures.kms_list_keys_truncated_response() + end) + + assert {:ok, + %{ + keys: [ + %{ + "KeyArn" => + "arn:aws:kms:us-west-2:232250609916:key/0eb05eb7-faa7-4a65-8fc5-bf5615d1c73b", + "KeyId" => "0eb05eb7-faa7-4a65-8fc5-bf5615d1c73b" + } + ], + next_marker: + "AE0AAAACAHMAAAAJYWNjb3VudElkAHMAAAAMMjMyMjUwNjA5OTE2AHMAAAAEdGtJZABzAAAAJDBlYjA1ZWI3LWZhYTctNGE2NS04ZmM1LWJmNTYxNWQxYzczYg", + truncated: true + }} == Utils.list_keys(limit: 1) + end + + test "list keys on aws kms with full response" do + expect(ExAws, :request, fn _ -> + KMSFixtures.kms_list_keys_response() + end) + + assert {:ok, + %{ + keys: [ + %{ + "KeyArn" => + "arn:aws:kms:us-west-2:232250609916:key/0eb05eb7-faa7-4a65-8fc5-bf5615d1c73b", + "KeyId" => "0eb05eb7-faa7-4a65-8fc5-bf5615d1c73b" + }, + %{ + "KeyArn" => + "arn:aws:kms:us-west-2:232250609916:key/fc038303-ea86-43fb-b277-265398349e6e", + "KeyId" => "fc038303-ea86-43fb-b277-265398349e6e" + } + ], + truncated: false + }} == Utils.list_keys() + end + end + + describe "create_elliptic_key_pair/0" do + test "create a elliptic curve key pairs on aws kms" do + expect(ExAws, :request, fn _ -> + KMSFixtures.kms_key_creation_response() + end) + + key_id = "c3d230fb-b257-482a-9c84-a3962b71debc" + + assert {:ok, key_id} == Utils.create_elliptic_key_pair() + end + end + + describe "create_key_alias/1" do + test "create a key alias on aws kms" do + expect(ExAws, :request, fn _ -> + KMSFixtures.kms_key_alias_response() + end) + + key_id = "c3d230fb-b257-482a-9c84-a3962b71debc" + + assert {:ok, nil} == Utils.create_key_alias(key_id) + end + end + + describe "get_sender_address/1" do + test "get sender wallet address by converting the pem received from aws" do + expect(ExAws, :request, fn _ -> + KMSFixtures.kms_public_key_response() + end) + + key_id = "c3d230fb-b257-482a-9c84-a3962b71debc" + wallet = "0x4eed49289Ac2876C9c966FC16b22F6eC5bf0817c" + + assert {:ok, wallet} == Utils.get_sender_address(key_id) + end + end + + describe "disable_key/1" do + test "disable a key on aws kms" do + expect(ExAws, :request, fn _ -> + KMSFixtures.kms_key_disable_response() + end) + + key_id = "c3d230fb-b257-482a-9c84-a3962b71debc" + + assert {:ok, nil} == Utils.disable_key(key_id) + end + + test "disable a non-existent key on aws kms" do + key_id = "xyz" + + expect(ExAws, :request, fn _ -> + KMSFixtures.kms_key_disable_not_found_response(key_id) + end) + + assert {:error, _error_msg} = Utils.disable_key(key_id) + end + end +end diff --git a/test/support/fixtures/kms_fixtures.ex b/test/support/fixtures/kms_fixtures.ex new file mode 100644 index 0000000..1e9f18b --- /dev/null +++ b/test/support/fixtures/kms_fixtures.ex @@ -0,0 +1,83 @@ +defmodule EthersKMS.KMSFixtures do + @moduledoc """ + This module defines fixtures for Signers. + """ + + def kms_list_keys_truncated_response do + {:ok, + %{ + "KeyCount" => 1, + "Keys" => [ + %{ + "KeyArn" => + "arn:aws:kms:us-west-2:232250609916:key/0eb05eb7-faa7-4a65-8fc5-bf5615d1c73b", + "KeyId" => "0eb05eb7-faa7-4a65-8fc5-bf5615d1c73b" + } + ], + "NextMarker" => + "AE0AAAACAHMAAAAJYWNjb3VudElkAHMAAAAMMjMyMjUwNjA5OTE2AHMAAAAEdGtJZABzAAAAJDBlYjA1ZWI3LWZhYTctNGE2NS04ZmM1LWJmNTYxNWQxYzczYg", + "Truncated" => true + }} + end + + def kms_list_keys_response do + {:ok, + %{ + "KeyCount" => 2, + "Keys" => [ + %{ + "KeyArn" => + "arn:aws:kms:us-west-2:232250609916:key/0eb05eb7-faa7-4a65-8fc5-bf5615d1c73b", + "KeyId" => "0eb05eb7-faa7-4a65-8fc5-bf5615d1c73b" + }, + %{ + "KeyArn" => + "arn:aws:kms:us-west-2:232250609916:key/fc038303-ea86-43fb-b277-265398349e6e", + "KeyId" => "fc038303-ea86-43fb-b277-265398349e6e" + } + ], + "Truncated" => false + }} + end + + def kms_public_key_response do + {:ok, + %{ + "PublicKey" => + "MFYwEAYHKoZIzj0CAQYFK4EEAAoDQgAEsdTvgjvTDk/BF/COdU/4/v6HgCaceKifvcBfxKnZpCt5wFzZEgwWSLTsz1T9YaCaS0Xb0D0g7TaT8VAD+Tesmg==" + }} + end + + def kms_sign_response do + {:ok, + %{ + "Signature" => + "MEUCIDK6M5izIjRFuFiEniddbbsaZwjzBbsrjEJxQ/kjm7m+AiEArAX1ZsnrEthrafd+uH2PeEQ+VAN08CH/pxzG/UV2KFw=" + }} + end + + def kms_key_creation_response do + {:ok, + %{ + "KeyMetadata" => %{ + "KeyId" => "c3d230fb-b257-482a-9c84-a3962b71debc" + } + }} + end + + def kms_key_alias_response do + {:ok, nil} + end + + def kms_key_disable_response do + {:ok, nil} + end + + def kms_key_disable_not_found_response(key_id) do + {:error, {"NotFoundException", "Invalid keyId #{key_id}"}} + end + + def kms_key_enable_response do + {:ok, nil} + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..7457e33 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,5 @@ +ExUnit.start() + +Mimic.copy(Ethers) +Mimic.copy(Ethers.Multicall) +Mimic.copy(ExAws)