diff --git a/lib/keystore.ex b/lib/keystore.ex new file mode 100644 index 000000000..959d292ab --- /dev/null +++ b/lib/keystore.ex @@ -0,0 +1,114 @@ +defmodule Keystore do + @moduledoc """ + [ERC-2335](https://eips.ethereum.org/EIPS/eip-2335) compliant keystore. + """ + + @secret_key_bytes 32 + @salt_bytes 32 + @derived_key_size 32 + @iv_size 16 + @checksum_message_size 32 + + @spec decode_from_files!(Path.t(), Path.t()) :: {Types.bls_pubkey(), Bls.privkey()} + def decode_from_files!(json, password) do + password = File.read!(password) + File.read!(json) |> decode_str!(password) + end + + @spec decode_str!(String.t(), String.t()) :: {Types.bls_pubkey(), Bls.privkey()} + def decode_str!(json, password) do + decoded_json = Jason.decode!(json) + # We only support version 4 (the only one) + %{"version" => 4} = decoded_json + validate_empty_path!(decoded_json["path"]) + + privkey = decrypt!(decoded_json["crypto"], password) + # TODO: derive from privkey and validate with this pubkey + pubkey = Map.fetch!(decoded_json, "pubkey") |> parse_binary!() + {pubkey, privkey} + end + + # TODO: support keystore paths + defp validate_empty_path!(path) when byte_size(path) > 0, + do: raise("Only empty-paths are supported") + + defp validate_empty_path!(_), do: :ok + + defp decrypt!(%{"kdf" => kdf, "checksum" => checksum, "cipher" => cipher}, password) do + password = sanitize_password(password) + derived_key = derive_key!(kdf, password) + + {iv, cipher_message} = parse_cipher!(cipher) + checksum_message = parse_checksum!(checksum) + verify_password!(derived_key, cipher_message, checksum_message) + secret = decrypt_secret(derived_key, iv, cipher_message) + + if byte_size(secret) != @secret_key_bytes do + raise "Invalid secret length: #{byte_size(secret)}" + end + + secret + end + + defp derive_key!(%{"function" => "scrypt", "params" => params}, password) do + %{"dklen" => @derived_key_size, "salt" => hex_salt, "n" => n, "p" => p, "r" => r} = params + salt = parse_binary!(hex_salt) + + if byte_size(salt) != @salt_bytes do + raise "Invalid salt size: #{byte_size(salt)}" + end + + log_n = n |> :math.log2() |> trunc() + Scrypt.hash(password, salt, log_n, r, p, @derived_key_size) + end + + # TODO: support pbkdf2 + defp derive_key!(%{"function" => "pbkdf2"} = drf, _password) do + %{"dklen" => _dklen, "salt" => _salt, "c" => _c, "prf" => "hmac-sha256"} = drf + end + + defp decrypt_secret(derived_key, iv, cipher_message) do + <> = derived_key + :crypto.crypto_one_time(:aes_128_ctr, key, iv, cipher_message, false) + end + + defp verify_password!(derived_key, cipher_message, checksum_message) do + dk_slice = derived_key |> binary_part(16, 16) + + pre_image = dk_slice <> cipher_message + checksum = :crypto.hash(:sha256, pre_image) + + if checksum != checksum_message do + raise "Invalid password" + end + end + + defp parse_checksum!(%{"function" => "sha256", "message" => hex_message}) do + message = parse_binary!(hex_message) + + if byte_size(message) != @checksum_message_size do + "Invalid checksum size: #{byte_size(message)}" + end + + message + end + + defp parse_cipher!(%{ + "function" => "aes-128-ctr", + "params" => %{"iv" => hex_iv}, + "message" => hex_message + }) do + iv = parse_binary!(hex_iv) + + if byte_size(iv) != @iv_size do + raise "Invalid IV size: #{byte_size(iv)}" + end + + {iv, parse_binary!(hex_message)} + end + + defp parse_binary!(hex), do: Base.decode16!(hex, case: :mixed) + + defp sanitize_password(password), + do: password |> String.normalize(:nfkd) |> String.replace(~r/[\x00-\x1f\x80-\x9f\x7f]/, "") +end diff --git a/mix.exs b/mix.exs index 2d1fca0dd..5336a2320 100644 --- a/mix.exs +++ b/mix.exs @@ -35,6 +35,7 @@ defmodule LambdaEthereumConsensus.MixProject do {:tesla, "~> 1.4"}, {:exleveldb, "~> 0.14"}, {:jason, "~> 1.4"}, + {:scrypt_elixir, "~> 0.1.1", hex: :scrypt_elixir_copy}, {:joken, "~> 2.6"}, {:rustler, "~> 0.32"}, {:broadway, "~> 1.0"}, diff --git a/mix.lock b/mix.lock index d837f6533..7f6146fd6 100644 --- a/mix.lock +++ b/mix.lock @@ -17,6 +17,7 @@ "eep": {:git, "https://github.com/virtan/eep", "8f6e5e3ade0606390d928830db61350a5451dda8", [branch: "master"]}, "eflambe": {:hex, :eflambe, "0.3.1", "ef0a35084fad1f50744496730a9662782c0a9ebf449d3e03143e23295c5926ea", [:rebar3], [{:meck, "0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "58d5997be606d4e269e9e9705338e055281fdf3e4935cc902c8908e9e4516c5f"}, "eleveldb": {:hex, :eleveldb, "2.2.20", "1fff63a5055bbf4bf821f797ef76065882b193f5e8095f95fcd9287187773b58", [:rebar3], [], "hexpm", "0e67df12ef836a7bcdde9373c59f1ae18b335defd1d66b820d3d4dd7ca1844e2"}, + "elixir_make": {:hex, :elixir_make, "0.8.3", "d38d7ee1578d722d89b4d452a3e36bcfdc644c618f0d063b874661876e708683", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.0", [hex: :certifi, repo: "hexpm", optional: true]}], "hexpm", "5c99a18571a756d4af7a4d89ca75c28ac899e6103af6f223982f09ce44942cc9"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "escape": {:hex, :escape, "0.1.0", "548edab75e6e6938b1e199ef59cb8e504bcfd3bcf83471d4ae9a3c7a7a3c7d45", [:mix], [], "hexpm", "a5d8e92db4677155df54bc1306d401b5233875d570d474201db03cb3047491cd"}, "ex2ms": {:hex, :ex2ms, "1.7.0", "45b9f523d0b777667ded60070d82d871a37e294f0b6c5b8eca86771f00f82ee1", [:mix], [], "hexpm", "2589eee51f81f1b1caa6d08c990b1ad409215fe6f64c73f73c67d36ed10be827"}, @@ -60,6 +61,7 @@ "rewrite": {:hex, :rewrite, "0.10.1", "238073297d122dad6b5501d761cb3bc0ce5bb4ab86e34c826c395f5f44b2f562", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "91f8d6fe363033e8ff60097bb5e0b76867667df0b4d67e79c2850444c02d8b19"}, "rexbug": {:hex, :rexbug, "1.0.6", "024071c67d970151fbdc06f299faf8db3e1b2ac759a28623a9cc80a517fc74f2", [:mix], [{:mix_test_watch, ">= 0.5.0", [hex: :mix_test_watch, repo: "hexpm", optional: true]}, {:redbug, "~> 1.2", [hex: :redbug, repo: "hexpm", optional: false]}], "hexpm", "148ea724979413e9fd84ca3b4bb5d2d8b840ac481adfd645f5846fda409a642c"}, "rustler": {:hex, :rustler, "0.32.1", "f4cf5a39f9e85d182c0a3f75fa15b5d0add6542ab0bf9ceac6b4023109ebd3fc", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.6", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "b96be75526784f86f6587f051bc8d6f4eaff23d6e0f88dbcfe4d5871f52946f7"}, + "scrypt_elixir": {:hex, :scrypt_elixir_copy, "0.1.1", "2b23573e8d9e6c93c8116cd17f9b453b6ebf0725b5317ecaeacaf73353a4dbd3", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "1eb5768b6b6c657770cbc00a9724f47bad4e9d664a2da3916030d591223561e7"}, "sentry": {:hex, :sentry, "10.4.0", "d8ffe8ce15b4b53f5e879299c3c222324c289a47a507c0b251c4f91ce7bae9ff", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 0.3.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "e5f98892152879dc87363b1b7f774eeddb8cf7dddfa7355e40eba188b2cae58a"}, "snappyer": {:hex, :snappyer, "1.2.9", "9cc58470798648ce34c662ca0aa6daae31367667714c9a543384430a3586e5d3", [:rebar3], [], "hexpm", "18d00ca218ae613416e6eecafe1078db86342a66f86277bd45c95f05bf1c8b29"}, "sourceror": {:hex, :sourceror, "1.0.2", "c5e86fdc14881f797749d1fe5df017ca66727a8146e7ee3e736605a3df78f3e6", [:mix], [], "hexpm", "832335e87d0913658f129d58b2a7dc0490ddd4487b02de6d85bca0169ec2bd79"}, diff --git a/native/bls_nif/src/lib.rs b/native/bls_nif/src/lib.rs index b4c53d444..f2966caf8 100644 --- a/native/bls_nif/src/lib.rs +++ b/native/bls_nif/src/lib.rs @@ -16,6 +16,9 @@ fn sign<'env>( private_key: Binary, message: Binary, ) -> Result, String> { + if message.len() != 32 { + return Err(format!("Message must be 32 bytes long")); + } let sk = match SecretKey::deserialize(private_key.as_slice()) { Ok(sk) => sk, Err(e) => return Err(format!("{:?}", e)), @@ -51,6 +54,9 @@ fn aggregate<'env>(env: Env<'env>, signatures: Vec) -> Result(public_key: Binary, message: Binary, signature: Binary) -> Result { + if message.len() != 32 { + return Err(format!("Message must be 32 bytes long")); + } let sig = Signature::deserialize(signature.as_slice()).map_err(|err| format!("{:?}", err))?; let pubkey = PublicKey::deserialize(public_key.as_slice()).map_err(|err| format!("{:?}", err))?; @@ -86,6 +92,9 @@ fn fast_aggregate_verify<'env>( message: Binary, signature: Binary, ) -> Result { + if message.len() != 32 { + return Err(format!("Message must be 32 bytes long")); + } let aggregate_sig = AggregateSignature::deserialize(signature.as_slice()) .map_err(|err| format!("{:?}", err))?; let pubkeys_result = public_keys @@ -104,6 +113,9 @@ fn eth_fast_aggregate_verify<'env>( message: Binary, signature: Binary, ) -> Result { + if message.len() != 32 { + return Err(format!("Message must be 32 bytes long")); + } let aggregate_sig = AggregateSignature::deserialize(signature.as_slice()) .map_err(|err| format!("{:?}", err))?; let pubkeys_result = public_keys diff --git a/test/unit/bit_list_test.exs b/test/unit/bit_list_test.exs index db18fdfbc..67b0fb003 100644 --- a/test/unit/bit_list_test.exs +++ b/test/unit/bit_list_test.exs @@ -1,4 +1,4 @@ -defmodule BitListTest do +defmodule Unit.BitListTest do use ExUnit.Case alias LambdaEthereumConsensus.Utils.BitList diff --git a/test/unit/keystore_test.exs b/test/unit/keystore_test.exs new file mode 100644 index 000000000..4524acc56 --- /dev/null +++ b/test/unit/keystore_test.exs @@ -0,0 +1,55 @@ +defmodule Unit.KeystoreTest do + use ExUnit.Case + + @eip_password "testpassword" + @eip_secret Base.decode16!("000000000019D6689C085AE165831E934FF763AE46A2A6C172B3F1B60A8CE26F") + + # Taken from lighthouse + @scrypt_json ~s({ + "crypto": { + "kdf": { + "function": "scrypt", + "params": { + "dklen": 32, + "n": 262144, + "p": 1, + "r": 8, + "salt": "d4e56740f876aef8c010b86a40d5f56745a118d0906a34e69aec8c0db1cb8fa3" + }, + "message": "" + }, + "checksum": { + "function": "sha256", + "params": {}, + "message": "149aafa27b041f3523c53d7acba1905fa6b1c90f9fef137568101f44b531a3cb" + }, + "cipher": { + "function": "aes-128-ctr", + "params": { + "iv": "264daa3f303d7259501c93d997d84fe6" + }, + "message": "54ecc8863c0550351eee5720f3be6a5d4a016025aa91cd6436cfec938d6a8d30" + } + }, + "pubkey": "9612d7a727c9d0a22e185a1c768478dfe919cada9266988cb32359c11f2b7b27f4ae4040902382ae2910c15e2b420d07", + "uuid": "1d85ae20-35c5-4611-98e8-aa14a633906f", + "path": "", + "version": 4 + }) + + test "eip scrypt test vector" do + {pubkey, privkey} = Keystore.decode_str!(@scrypt_json, @eip_password) + + expected_pubkey = + Base.decode16!( + "9612D7A727C9D0A22E185A1C768478DFE919CADA9266988CB32359C11F2B7B27F4AE4040902382AE2910C15E2B420D07" + ) + + assert privkey == @eip_secret + assert pubkey == expected_pubkey + + digest = :crypto.hash(:sha256, "test message") + {:ok, signature} = Bls.sign(privkey, digest) + assert Bls.valid?(pubkey, digest, signature) + end +end