-
Notifications
You must be signed in to change notification settings - Fork 34
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add ERC-2335 compatible keystore (#1013)
- Loading branch information
1 parent
01d0827
commit a30602e
Showing
6 changed files
with
185 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |