Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add ERC-2335 compatible keystore #1013

Merged
merged 9 commits into from
Apr 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading