Skip to content

Commit

Permalink
feat: add ERC-2335 compatible keystore (#1013)
Browse files Browse the repository at this point in the history
  • Loading branch information
MegaRedHand authored Apr 23, 2024
1 parent 01d0827 commit a30602e
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 1 deletion.
114 changes: 114 additions & 0 deletions lib/keystore.ex
Original file line number Diff line number Diff line change
@@ -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
<<key::binary-size(16), _::binary>> = 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
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
2 changes: 2 additions & 0 deletions mix.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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"},
Expand Down
12 changes: 12 additions & 0 deletions native/bls_nif/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ fn sign<'env>(
private_key: Binary,
message: Binary,
) -> Result<Binary<'env>, 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)),
Expand Down Expand Up @@ -51,6 +54,9 @@ fn aggregate<'env>(env: Env<'env>, signatures: Vec<Binary>) -> Result<Binary<'en

#[rustler::nif]
fn verify<'env>(public_key: Binary, message: Binary, signature: Binary) -> Result<bool, String> {
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))?;
Expand Down Expand Up @@ -86,6 +92,9 @@ fn fast_aggregate_verify<'env>(
message: Binary,
signature: Binary,
) -> Result<bool, String> {
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
Expand All @@ -104,6 +113,9 @@ fn eth_fast_aggregate_verify<'env>(
message: Binary,
signature: Binary,
) -> Result<bool, String> {
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
Expand Down
2 changes: 1 addition & 1 deletion test/unit/bit_list_test.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
defmodule BitListTest do
defmodule Unit.BitListTest do
use ExUnit.Case
alias LambdaEthereumConsensus.Utils.BitList

Expand Down
55 changes: 55 additions & 0 deletions test/unit/keystore_test.exs
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit a30602e

Please sign in to comment.