Skip to content

Commit

Permalink
first release commit
Browse files Browse the repository at this point in the history
  • Loading branch information
wchenNL committed Mar 1, 2024
1 parent 99b9567 commit 1d90cb1
Show file tree
Hide file tree
Showing 17 changed files with 808 additions and 2 deletions.
4 changes: 4 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
11 changes: 11 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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"
62 changes: 62 additions & 0 deletions .github/workflows/elixir.yml
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Changelog
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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).
15 changes: 15 additions & 0 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions config/dev.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import Config
1 change: 1 addition & 0 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import Config
123 changes: 123 additions & 0 deletions lib/aws/signer.ex
Original file line number Diff line number Diff line change
@@ -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
117 changes: 117 additions & 0 deletions lib/aws/utils.ex
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 1d90cb1

Please sign in to comment.