diff --git a/CHANGELOG.md b/CHANGELOG.md index d8cafb7..1d35e5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog for Tezex +## v3.2.0 + +- [crypto] add a pure Elixir [BLS12-381](https://hexdocs.pm/tezex/Tezex.Crypto.BLS.html) implementation +- [crypto] implement signing with encrypted p256 key +- [crypto] implement tz4 (BLS12-381) support + ## v3.1.0 - [rpc]: adapt constants to Rio protocol update diff --git a/lib/crypto.ex b/lib/crypto.ex index fb556ca..a83fd25 100644 --- a/lib/crypto.ex +++ b/lib/crypto.ex @@ -11,6 +11,7 @@ defmodule Tezex.Crypto do """ alias Tezex.Crypto.Base58Check + alias Tezex.Crypto.BLS alias Tezex.Crypto.ECDSA alias Tezex.Crypto.KnownCurves alias Tezex.Crypto.PrivateKey @@ -22,18 +23,22 @@ defmodule Tezex.Crypto do @prefix_edpk <<13, 15, 37, 217>> @prefix_sppk <<3, 254, 226, 86>> @prefix_p2pk <<3, 178, 139, 127>> + @prefix_blpk <<6, 149, 135, 204>> # public key hash @prefix_tz1 <<6, 161, 159>> @prefix_tz2 <<6, 161, 161>> @prefix_tz3 <<6, 161, 164>> + @prefix_tz4 <<6, 161, 166>> # private key @prefix_edsk <<43, 246, 78, 7>> @prefix_spsk <<17, 162, 224, 201>> @prefix_p2sk <<16, 81, 238, 189>> + @prefix_blsk <<3, 150, 192, 40>> # sig @prefix_edsig <<9, 245, 205, 134, 18>> @prefix_spsig <<13, 115, 101, 19, 63>> @prefix_p2sig <<54, 240, 44, 52>> + @prefix_blsig <<40, 171, 64, 207>> @prefix_sig <<4, 130, 43>> @typedoc """ @@ -69,7 +74,7 @@ defmodule Tezex.Crypto do iex> check_signature(address, "x" <> signature, "", public_key) {:error, :invalid_signature} """ - @spec check_signature(binary, binary, binary, binary) :: + @spec check_signature(binary(), binary(), binary(), binary()) :: :ok | {:error, :address_mismatch | :invalid_pubkey_format | :invalid_signature | :bad_signature} @@ -91,6 +96,7 @@ defmodule Tezex.Crypto do {:ok, <<@prefix_edpk, public_key::binary-size(32)>> <> _} -> {:ok, public_key} {:ok, <<@prefix_sppk, public_key::binary-size(33)>> <> _} -> {:ok, public_key} {:ok, <<@prefix_p2pk, public_key::binary-size(33)>> <> _} -> {:ok, public_key} + {:ok, <<@prefix_blpk, public_key::binary-size(48)>> <> _} -> {:ok, public_key} _ -> {:error, :invalid_pubkey_format} end end @@ -98,7 +104,7 @@ defmodule Tezex.Crypto do @doc """ Verify that `signature` is a valid signature for `message` signed with the private key corresponding to public key `pubkey` """ - @spec verify_signature(binary, binary, binary) :: boolean() + @spec verify_signature(binary(), binary(), binary()) :: boolean() def verify_signature(signature, message, "ed" <> _ = pubkey) do # tz1… message_hash = hash_message(message) @@ -137,6 +143,17 @@ defmodule Tezex.Crypto do ECDSA.verify?(message, signature, public_key, hashfunc: fn msg -> Blake2.hash2b(msg, 32) end) end + def verify_signature(signature, msg, "BL" <> _ = pubkey) do + # tz4… + with {:ok, decoded_sig} <- decode_signature(signature), + {:ok, public_key} <- extract_pubkey(pubkey), + message <- :binary.decode_hex(msg) do + BLS.verify(decoded_sig, message, public_key) + else + _ -> false + end + end + def verify_signature(_, _, _) do {:error, :invalid_pubkey_format} end @@ -167,13 +184,15 @@ defmodule Tezex.Crypto do {:error, :invalid_prefix} iex> validate_address("tz3bPFa6mGv8m4Ppn7w5KSDyAbEPwbJNpC9p") :ok + iex> validate_address("tz4NWRt3aFyFU2Ydah917Eehxv6uf97j8tpZ") + :ok """ @spec validate_address(nonempty_binary()) :: :ok | {:error, :invalid_base58 | :invalid_checksum | :invalid_length | :invalid_prefix} def validate_address(address) do with {:ok, decoded} <- Base58Check.decode58(address), <> <- decoded, - true <- prefix in [@prefix_tz1, @prefix_tz2, @prefix_tz3], + true <- prefix in [@prefix_tz1, @prefix_tz2, @prefix_tz3, @prefix_tz4], :ok <- check_checksum(checksum, <>) do :ok else @@ -196,7 +215,7 @@ defmodule Tezex.Crypto do iex> check_address("tz2BC83pvEAag6r2ZV7kPghNAbjFoiqhCvZx", "x" <> pubkey) {:error, :invalid_pubkey_format} """ - @spec check_address(nonempty_binary, nonempty_binary) :: + @spec check_address(nonempty_binary(), nonempty_binary()) :: :ok | {:error, :address_mismatch | :invalid_pubkey_format} def check_address(address, pubkey) do case derive_address(pubkey) do @@ -221,7 +240,7 @@ defmodule Tezex.Crypto do iex> derive_address("p2pk65yRxCX65k6") {:error, :invalid_pubkey_format} """ - @spec derive_address(nonempty_binary) :: + @spec derive_address(nonempty_binary()) :: {:ok, nonempty_binary} | {:error, :invalid_pubkey_format} def derive_address(pubkey) do case Base58Check.decode58(pubkey) do @@ -237,6 +256,10 @@ defmodule Tezex.Crypto do {:ok, <<@prefix_p2pk, public_key::binary-size(33)>> <> _} -> derive_address(public_key, @prefix_tz3) + # tz4 addresses: "BLpk" <> _ + {:ok, <<@prefix_blpk, public_key::binary-size(48)>> <> _} -> + derive_address(public_key, @prefix_tz4) + _ -> {:error, :invalid_pubkey_format} end @@ -259,13 +282,14 @@ defmodule Tezex.Crypto do iex> encode_pubkey("tz1LPggcEZincSDQJUXrskwJPif4aJhWxMjd", "foo") :error """ - @spec encode_pubkey(nonempty_binary, nonempty_binary) :: :error | {:ok, nonempty_binary} + @spec encode_pubkey(nonempty_binary(), nonempty_binary()) :: :error | {:ok, nonempty_binary()} def encode_pubkey(pkh, hex_pubkey) do prefix = case pkh do "tz1" <> _ -> @prefix_edpk "tz2" <> _ -> @prefix_sppk "tz3" <> _ -> @prefix_p2pk + "tz4" <> _ -> @prefix_blpk _ -> :error end @@ -276,31 +300,45 @@ defmodule Tezex.Crypto do end defp decode_privkey({privkey, passphrase}) do - throw("not implemented") decode_privkey(privkey, passphrase) end defp decode_privkey(privkey, passphrase \\ nil) do - if binary_part(privkey, 2, 1) == "e" and is_nil(passphrase) do + # Check if this is an encrypted key + is_encrypted = binary_part(privkey, 2, 1) == "e" + + if is_encrypted and is_nil(passphrase) do throw("missing passphrase") end - prefix = - case privkey do - "edsk" <> _ -> @prefix_edsk - "edes" <> _ -> @prefix_edsk - "spsk" <> _ -> @prefix_spsk - "spes" <> _ -> @prefix_spsk - "p2sk" <> _ -> @prefix_p2sk - "p2es" <> _ -> @prefix_p2sk - end - - decoded_privkey = - Base58Check.decode58!(privkey) - |> binary_part(byte_size(prefix), 32) - |> Utils.pad(32, :leading) - - {privkey, decoded_privkey} + # Use the new PrivateKey.from_encoded_key for proper encrypted key support + case PrivateKey.from_encoded_key(privkey, passphrase) do + {:ok, private_key} -> + # Pad to ensure consistent 32-byte length + decoded_privkey = Utils.pad(private_key.secret, 32, :leading) + {privkey, decoded_privkey} + + {:error, _} -> + # Fallback to old implementation for backward compatibility + # This handles cases where the new implementation might not support a specific format + prefix = + case privkey do + "edsk" <> _ -> @prefix_edsk + "edes" <> _ -> @prefix_edsk + "spsk" <> _ -> @prefix_spsk + "spes" <> _ -> @prefix_spsk + "p2sk" <> _ -> @prefix_p2sk + "p2es" <> _ -> @prefix_p2sk + "BLsk" <> _ -> @prefix_blsk + end + + decoded_privkey = + Base58Check.decode58!(privkey) + |> binary_part(byte_size(prefix), 32) + |> Utils.pad(32, :leading) + + {privkey, decoded_privkey} + end end @doc """ @@ -351,8 +389,8 @@ defmodule Tezex.Crypto do r_bin = Integer.to_string(s.r, 16) s_bin = Integer.to_string(s.s, 16) - r_bin = Utils.pad(r_bin, 64, :leading) - s_bin = Utils.pad(s_bin, 64, :leading) + r_bin = String.pad_leading(r_bin, 64, "0") + s_bin = String.pad_leading(s_bin, 64, "0") signature = :binary.decode_hex(r_bin <> s_bin) @@ -365,12 +403,21 @@ defmodule Tezex.Crypto do r_bin = Integer.to_string(s.r, 16) s_bin = Integer.to_string(s.s, 16) - r_bin = Utils.pad(r_bin, 64, :leading) - s_bin = Utils.pad(s_bin, 64, :leading) + r_bin = String.pad_leading(r_bin, 64, "0") + s_bin = String.pad_leading(s_bin, 64, "0") signature = :binary.decode_hex(r_bin <> s_bin) Base58Check.encode(signature, @prefix_p2sig) + + "BL" <> _ -> + with {:ok, bls_key} <- BLS.deserialize_secret_key(decoded_key), + signature <- BLS.sign(bls_key, watermark <> msg) do + Base58Check.encode(signature, @prefix_blsig) + else + {:error, _} -> + raise RuntimeError, "Invalid BLS private key" + end end end @@ -409,6 +456,21 @@ defmodule Tezex.Crypto do end end + def decode_signature("BLsig" <> _ = sig) do + case Base58Check.decode58(sig) do + # BLS signatures are: 4-byte prefix + 96-byte signature + 4-byte checksum = 104 bytes total + {:ok, <<@prefix_blsig, sig_data::binary-size(96), _checksum::binary-size(4)>>} -> + {:ok, sig_data} + + # Fallback for different encoding formats + {:ok, <<@prefix_blsig, sig_data::binary-size(96)>> <> _} -> + {:ok, sig_data} + + _ -> + {:error, :invalid_signature} + end + end + def decode_signature(_) do {:error, :invalid_signature} end diff --git a/lib/crypto/base58_check.ex b/lib/crypto/base58_check.ex index 7db60ee..d5b4034 100644 --- a/lib/crypto/base58_check.ex +++ b/lib/crypto/base58_check.ex @@ -13,14 +13,14 @@ defmodule Tezex.Crypto.Base58Check do iex> encode(<<165, 37, 29, 103, 204, 101, 232, 200, 87, 148, 178, 91, 43, 72, 191, 252, 190, 134, 75, 170>>, <<6, 161, 164>>) "tz3bPFa6mGv8m4Ppn7w5KSDyAbEPwbJNpC9p" """ - @spec encode(binary, binary) :: nonempty_binary + @spec encode(binary(), binary()) :: nonempty_binary() def encode(payload, prefix) do (prefix <> payload) |> add_checksum() |> Base58Zero.encode() end - @spec add_checksum(binary) :: binary + @spec add_checksum(binary()) :: binary() defp add_checksum(payload) do payload |> double_sha256() @@ -28,7 +28,7 @@ defmodule Tezex.Crypto.Base58Check do |> append(payload) end - @spec append(binary, binary) :: binary + @spec append(binary(), binary()) :: binary() defp append(data1, data2), do: data2 <> data1 defp double_sha256(x), do: :crypto.hash(:sha256, :crypto.hash(:sha256, x)) @@ -54,9 +54,9 @@ defmodule Tezex.Crypto.Base58Check do iex> decode58!("BukQL") :binary.encode_unsigned(123_456_789) """ - @spec decode58!(binary) :: binary + @spec decode58!(binary()) :: binary() defdelegate decode58!(encoded), to: Base58Check - @spec decode58check!(binary) :: binary + @spec decode58check!(binary()) :: binary() defdelegate decode58check!(encoded), to: Base58Check end diff --git a/lib/crypto/bls.ex b/lib/crypto/bls.ex new file mode 100644 index 0000000..5744352 --- /dev/null +++ b/lib/crypto/bls.ex @@ -0,0 +1,516 @@ +defmodule Tezex.Crypto.BLS do + @moduledoc """ + Pure Elixir BLS12-381 cryptographic operations for Tezos tz4 addresses. + + This module provides BLS functionality: + - Private key operations (from 32-byte seeds or HKDF key generation) + - Public key derivation (48-byte compressed G1 format) + - Message signing and verification with multiple ciphersuites + - Serialization/deserialization compatible with Tezos formats + - Support for message augmentation, basic, and proof-of-possession schemes + + Based on the BLS12-381 curve with IETF standard ciphersuites: + - G2Basic: BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_ + - G2MessageAugmentation: BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_AUG_ (default) + - G2ProofOfPossession: BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_ + - MinPk variant (48-byte public keys, 96-byte signatures) + - Hash-to-curve with SSWU mapping + + Partly ported from pytezos@439bafada8f063f9643c789f6154d4646674d66a + > pytezos / MIT License / (c) 2020 Baking Bad / (c) 2018 Arthur Breitman + and + py_ecc@04151f01f59f902ab932a51e0ca0ebce3883fc51 + > py_ecc / MIT License / (c) (c) 2015 Vitalik Buterin + """ + + alias Tezex.Crypto.BLS.Constants + alias Tezex.Crypto.BLS.Fr + alias Tezex.Crypto.BLS.G1 + alias Tezex.Crypto.BLS.G2 + alias Tezex.Crypto.BLS.Pairing + + # Key and signature sizes (bytes) + @secret_key_size 32 + @public_key_size 48 + @signature_size 96 + + # IETF BLS signature ciphersuites + @ciphersuite_basic "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_" + @ciphersuite_aug "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_AUG_" + @ciphersuite_pop "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_" + @pop_tag "BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_" + + # HKDF key generation salt + @keygen_salt "BLS-SIG-KEYGEN-SALT-" + + defstruct [:secret_key] + + @type t :: %__MODULE__{secret_key: Fr.t()} + @type public_key :: binary() + @type signature :: binary() + @type ciphersuite :: :basic | :message_augmentation | :proof_of_possession + @type key_info :: binary() + + @doc """ + Creates a new BLS private key from a 32-byte seed. + + This matches octez-client behavior by using the seed directly as the scalar, + without any key derivation function. + + ## Examples + iex> seed = :crypto.strong_rand_bytes(32) + iex> {:ok, bls} = Tezex.Crypto.BLS.from_seed(seed) + iex> byte_size(Tezex.Crypto.BLS.Fr.to_bytes(bls.secret_key)) + 32 + """ + @spec from_seed(binary()) :: {:ok, t()} | {:error, :invalid_seed} + def from_seed(seed) when byte_size(seed) == @secret_key_size do + with {:ok, fr_element} <- Fr.from_bytes(seed), + false <- Fr.is_zero?(fr_element) do + {:ok, %__MODULE__{secret_key: fr_element}} + else + _ -> {:error, :invalid_seed} + end + end + + def from_seed(_), do: {:error, :invalid_seed} + + @doc """ + Generates a BLS private key using HKDF key generation (IETF standard). + + This follows the KeyGen algorithm from the IETF BLS specification. + + ## Parameters + - `ikm`: Input key material (typically 32+ bytes of entropy) + - `key_info`: Optional key info for domain separation (default: empty) + + ## Examples + iex> ikm = :crypto.strong_rand_bytes(32) + iex> {:ok, bls} = Tezex.Crypto.BLS.key_gen(ikm) + iex> is_struct(bls, Tezex.Crypto.BLS) + true + """ + @spec key_gen(binary(), key_info()) :: {:ok, t()} | {:error, :invalid_ikm} + def key_gen(ikm, key_info \\ <<>>) + + def key_gen(ikm, key_info) when byte_size(ikm) >= 32 do + case derive_secret_key(ikm, key_info) do + {:ok, secret_key} -> {:ok, %__MODULE__{secret_key: secret_key}} + error -> error + end + end + + def key_gen(_, _), do: {:error, :invalid_ikm} + + @doc """ + Creates a BLS private key from a secret exponent (integer or bytes). + + ## Examples + iex> secret_bytes = :crypto.strong_rand_bytes(32) + iex> {:ok, bls} = Tezex.Crypto.BLS.from_secret_exponent(secret_bytes) + iex> byte_size(Tezex.Crypto.BLS.serialize_secret_key(bls)) + 32 + """ + @spec from_secret_exponent(binary() | integer()) :: {:ok, t()} | {:error, :invalid_secret} + def from_secret_exponent(secret) when is_binary(secret) do + from_seed(secret) + end + + def from_secret_exponent(secret) when is_integer(secret) and secret > 0 do + fr_element = Fr.from_integer(secret) + + if Fr.is_zero?(fr_element) do + {:error, :invalid_secret} + else + {:ok, %__MODULE__{secret_key: fr_element}} + end + end + + def from_secret_exponent(_), do: {:error, :invalid_secret} + + @doc """ + Derives the BLS public key from the private key. + Returns a 48-byte compressed G1 point. + + ## Examples + iex> seed = :crypto.strong_rand_bytes(32) + iex> {:ok, bls} = Tezex.Crypto.BLS.from_seed(seed) + iex> pubkey = Tezex.Crypto.BLS.get_public_key(bls) + iex> byte_size(pubkey) + 48 + """ + @spec get_public_key(t()) :: public_key() + def get_public_key(%__MODULE__{secret_key: secret_key}) do + # Multiply the G1 generator by the private key scalar + generator = G1.generator() + public_key_point = G1.mul(generator, secret_key) + + # Compress to 48-byte format + G1.to_compressed_bytes(public_key_point) + end + + @doc """ + Signs a message with the BLS private key using the specified ciphersuite. + Returns a 96-byte signature. + + ## Ciphersuites + - `:message_augmentation` (default): Message augmented with public key (most secure) + - `:basic`: Basic BLS signature (requires message uniqueness for aggregation) + - `:proof_of_possession`: Proof-of-possession scheme + + ## Examples + iex> seed = :crypto.strong_rand_bytes(32) + iex> {:ok, bls} = Tezex.Crypto.BLS.from_seed(seed) + iex> message = "test message" + iex> signature = Tezex.Crypto.BLS.sign(bls, message) + iex> byte_size(signature) + 96 + """ + @spec sign(t(), binary(), ciphersuite()) :: signature() + def sign(bls_key, message, ciphersuite \\ :message_augmentation) + + def sign(%__MODULE__{secret_key: secret_key}, message, :message_augmentation) + when is_binary(message) do + public_key = get_public_key(%__MODULE__{secret_key: secret_key}) + augmented_message = public_key <> message + h_point = G2.hash_to_curve(augmented_message, @ciphersuite_aug) + signature_point = G2.mul(h_point, secret_key) + G2.to_compressed_bytes(signature_point) + end + + def sign(%__MODULE__{secret_key: secret_key}, message, :basic) when is_binary(message) do + h_point = G2.hash_to_curve(message, @ciphersuite_basic) + signature_point = G2.mul(h_point, secret_key) + G2.to_compressed_bytes(signature_point) + end + + def sign(%__MODULE__{secret_key: secret_key}, message, :proof_of_possession) + when is_binary(message) do + h_point = G2.hash_to_curve(message, @ciphersuite_pop) + signature_point = G2.mul(h_point, secret_key) + G2.to_compressed_bytes(signature_point) + end + + @doc """ + Verifies a BLS signature against a message and public key using the specified ciphersuite. + + ## Ciphersuites + - `:message_augmentation` (default): Message augmented with public key + - `:basic`: Basic BLS signature + - `:proof_of_possession`: Proof-of-possession scheme + + ## Examples + iex> seed = :crypto.strong_rand_bytes(32) + iex> {:ok, bls} = Tezex.Crypto.BLS.from_seed(seed) + iex> message = "test message" + iex> pubkey = Tezex.Crypto.BLS.get_public_key(bls) + iex> signature = Tezex.Crypto.BLS.sign(bls, message) + iex> Tezex.Crypto.BLS.verify(signature, message, pubkey) + true + """ + @spec verify(signature(), binary(), public_key(), ciphersuite()) :: boolean() + def verify(signature, message, public_key, ciphersuite \\ :message_augmentation) + + def verify(signature, message, public_key, ciphersuite) + when byte_size(signature) == @signature_size and + byte_size(public_key) == @public_key_size and + is_binary(message) do + with true <- is_valid_bls_signature_format?(signature), + true <- is_valid_bls_pubkey_format?(public_key) do + case ciphersuite do + :message_augmentation -> + augmented_message = public_key <> message + verify_signature(signature, augmented_message, public_key, @ciphersuite_aug) + + :basic -> + verify_signature(signature, message, public_key, @ciphersuite_basic) + + :proof_of_possession -> + verify_signature(signature, message, public_key, @ciphersuite_pop) + end + else + _ -> false + end + end + + def verify(_, _, _, _), do: false + + # Check if the signature has valid BLS format (compressed G2 point). + # Infinity points are allowed for signatures. + defp is_valid_bls_signature_format?(signature) when byte_size(signature) == 96 do + <> = signature + + # Check z1 (first component) compression flags by pattern matching on bits + # C B A _ _ _ _ _ + # │ │ │ + # │ │ └─ sign/lexicographic flag (bit 5) + # │ └─── infinity flag (bit 6) + # └───── compression flag (bit 7) + <> = z1 + + # bits 7,6,5 must be 0 + z2_no_flags = match?(<<0::size(3), _::size(5), _::binary-size(47)>>, z2) + + # compression must be enabled, z2 must have no flags + if c_flag == 1 and z2_no_flags do + # If infinity point, validate format: z1=0xC0+zeros, z2=zeros + if b_flag == 1 do + # Infinity signature: must have a_flag=0 and exact pattern + a_flag == 0 and signature == <<0xC0, 0::size(760)>> + else + # Non-infinity point: try to decompress and validate + case G2.from_compressed_bytes(signature) do + {:ok, point} -> G2.is_on_curve?(point) and not G2.is_infinity?(point) + {:error, _} -> false + end + end + else + false + end + end + + defp is_valid_bls_signature_format?(_), do: false + + # Check if the public key has valid BLS format (compressed G1 point). + # Infinity points are rejected for public keys. + defp is_valid_bls_pubkey_format?(pubkey) when byte_size(pubkey) == 48 do + # Extract compression flags by pattern matching on bits + # C B A _ _ _ _ _ + # │ │ │ + # │ │ └─ sign/lexicographic flag (bit 5) + # │ └─── infinity flag (bit 6) + # └───── compression flag (bit 7) + <> = + pubkey + + # c_flag must be 1 + if c_flag == 1 do + # Infinity points are rejected for public keys + if b_flag == 1 do + # Reject infinity public keys + false + else + # Try to decompress and validate the point + case G1.from_compressed_bytes(pubkey) do + {:ok, point} -> G1.is_on_curve?(point) and not G1.is_infinity?(point) + {:error, _} -> false + end + end + else + false + end + end + + defp is_valid_bls_pubkey_format?(_), do: false + + defp verify_signature(signature, message, public_key, ciphersuite) do + # This implements cryptographically secure BLS signature verification + # using the full BLS12-381 pairing implementation + + with {:ok, pubkey_point} <- G1.from_compressed_bytes(public_key), + {:ok, signature_point} <- G2.from_compressed_bytes(signature), + false <- G1.is_infinity?(pubkey_point), + false <- G2.is_infinity?(signature_point) do + # Hash the message to G2 + message_point = G2.hash_to_curve(message, ciphersuite) + + g1_generator = G1.generator() + Pairing.pairing_check(pubkey_point, message_point, g1_generator, signature_point) + else + _ -> false + end + end + + @doc """ + Deserializes a BLS private key from bytes. + """ + @spec deserialize_secret_key(binary()) :: {:ok, t()} | {:error, :invalid_key} + def deserialize_secret_key(secret_bytes) do + case from_seed(secret_bytes) do + {:ok, bls_key} when byte_size(secret_bytes) == @secret_key_size -> {:ok, bls_key} + _ -> {:error, :invalid_key} + end + end + + @doc """ + Serializes a BLS private key to bytes. + """ + @spec serialize_secret_key(t()) :: binary() + def serialize_secret_key(%__MODULE__{secret_key: secret_key}) do + Fr.to_bytes(secret_key) + end + + @doc """ + Returns the expected sizes for BLS keys and signatures. + """ + @spec sizes() :: %{ + secret_key: pos_integer(), + public_key: pos_integer(), + signature: pos_integer() + } + def sizes do + %{ + secret_key: @secret_key_size, + public_key: @public_key_size, + signature: @signature_size + } + end + + @doc """ + Converts a hex string to binary, handling both uppercase and lowercase. + """ + @spec from_hex(String.t()) :: {:ok, binary()} | {:error, :invalid_hex} + def from_hex(hex_string) when is_binary(hex_string) do + case Base.decode16(hex_string, case: :mixed) do + {:ok, binary} -> {:ok, binary} + :error -> {:error, :invalid_hex} + end + end + + @doc """ + Converts binary to hex string (lowercase). + """ + @spec to_hex(binary()) :: String.t() + def to_hex(binary) when is_binary(binary) do + Base.encode16(binary, case: :lower) + end + + @doc """ + Generates a proof-of-possession for a BLS public key. + + This proves that the holder knows the corresponding private key, + following the IETF BLS proof-of-possession specification. + """ + @spec pop_prove(t()) :: signature() + def pop_prove(%__MODULE__{secret_key: secret_key}) do + public_key = get_public_key(%__MODULE__{secret_key: secret_key}) + h_point = G2.hash_to_curve(public_key, @pop_tag) + signature_point = G2.mul(h_point, secret_key) + G2.to_compressed_bytes(signature_point) + end + + @doc """ + Verifies a proof-of-possession for a BLS public key. + """ + @spec pop_verify(public_key(), signature()) :: boolean() + def pop_verify(public_key, proof_of_possession) + when byte_size(public_key) == @public_key_size and + byte_size(proof_of_possession) == @signature_size do + with true <- is_valid_bls_signature_format?(proof_of_possession), + true <- is_valid_bls_pubkey_format?(public_key) do + verify_signature(proof_of_possession, public_key, public_key, @pop_tag) + else + _ -> false + end + end + + def pop_verify(_, _), do: false + + @doc """ + Returns the supported BLS ciphersuites and their domain separation tags. + """ + @spec ciphersuites() :: %{ + basic: String.t(), + message_augmentation: String.t(), + proof_of_possession: String.t(), + pop_tag: String.t() + } + def ciphersuites do + %{ + basic: @ciphersuite_basic, + message_augmentation: @ciphersuite_aug, + proof_of_possession: @ciphersuite_pop, + pop_tag: @pop_tag + } + end + + @doc """ + Validates a BLS public key to ensure it's a valid G1 point. + Infinity points are rejected for public keys . + """ + @spec validate_public_key(public_key()) :: boolean() + def validate_public_key(public_key) when byte_size(public_key) == @public_key_size do + is_valid_bls_pubkey_format?(public_key) + end + + def validate_public_key(_), do: false + + @doc """ + Validates a BLS signature to ensure it's a valid G2 point. + Infinity points are allowed for signatures (unlike public keys). + """ + @spec validate_signature(signature()) :: boolean() + def validate_signature(signature) when byte_size(signature) == @signature_size do + is_valid_bls_signature_format?(signature) + end + + def validate_signature(_), do: false + + # Private helper functions + + defp derive_secret_key(ikm, key_info) do + # IETF BLS KeyGen algorithm using HKDF + + salt = @keygen_salt + secret_key_int = derive_key_with_hkdf(ikm, salt, key_info, 0) + fr_element = Fr.from_integer(secret_key_int) + + if Fr.is_zero?(fr_element) do + derive_key_retry(ikm, salt, key_info, 1) + else + {:ok, fr_element} + end + end + + defp derive_key_retry(ikm, base_salt, key_info, iteration) when iteration < 256 do + # Update salt and try again (following IETF spec) + salt = :crypto.hash(:sha256, base_salt <> <>) + secret_key_int = derive_key_with_hkdf(ikm, salt, key_info, iteration) + fr_element = Fr.from_integer(secret_key_int) + + if Fr.is_zero?(fr_element) do + derive_key_retry(ikm, base_salt, key_info, iteration + 1) + else + {:ok, fr_element} + end + end + + defp derive_key_retry(_, _, _, _), do: {:error, :key_generation_failed} + + defp derive_key_with_hkdf(ikm, salt, key_info, _iteration) do + # HKDF-based key derivation compatible with py_ecc + # ceil((1.5 * ceil(log2(curve_order))) / 8) + l = 48 + + # HKDF Extract + prk = :crypto.mac(:hmac, :sha256, salt, ikm <> <<0>>) + + # HKDF Expand + info = key_info <> <> + okm = hkdf_expand(prk, info, l) + + # Convert to integer mod curve order + integer_from_bytes(okm) |> rem(Constants.curve_order()) + end + + defp hkdf_expand(prk, info, length) do + # HKDF Expand implementation + # ceil(length / 32) + n = div(length + 31, 32) + + 0..(n - 1) + |> Enum.reduce(<<>>, fn i, acc -> + t_prev = if i == 0, do: <<>>, else: binary_part(acc, byte_size(acc) - 32, 32) + t_i = :crypto.mac(:hmac, :sha256, prk, t_prev <> info <> <>) + acc <> t_i + end) + |> binary_part(0, length) + end + + defp integer_from_bytes(bytes) do + # Convert bytes to integer (big-endian) + bytes + |> :binary.bin_to_list() + |> Enum.reduce(0, fn byte, acc -> acc * 256 + byte end) + end +end diff --git a/lib/crypto/bls/constants.ex b/lib/crypto/bls/constants.ex new file mode 100644 index 0000000..f80afe9 --- /dev/null +++ b/lib/crypto/bls/constants.ex @@ -0,0 +1,26 @@ +defmodule Tezex.Crypto.BLS.Constants do + @moduledoc """ + BLS12-381 curve constants for pairing computation. + Standard BLS12-381 curve constants. + """ + + # BLS12-381 field modulus + @field_modulus 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_559_787 + + # Curve order (number of points on the curve) + @curve_order 52_435_875_175_126_190_479_447_740_508_185_965_837_690_552_500_527_637_822_603_658_699_938_581_184_513 + + # Pseudo-binary encoding of the ATE loop count for efficient Miller loop + # This is the binary representation (LSB first) for the Miller algorithm + # Binary: 1101001000000001000000000000000000000000000000010000000000000000 + # Reversed for Miller loop (LSB first): + @pseudo_binary_encoding for char <- + String.graphemes( + "0000000000000000100000000000000000000000000000001000000001001011" + ), + do: String.to_integer(char) + + def field_modulus, do: @field_modulus + def curve_order, do: @curve_order + def pseudo_binary_encoding, do: @pseudo_binary_encoding +end diff --git a/lib/crypto/bls/fq.ex b/lib/crypto/bls/fq.ex new file mode 100644 index 0000000..e494815 --- /dev/null +++ b/lib/crypto/bls/fq.ex @@ -0,0 +1,229 @@ +defmodule Tezex.Crypto.BLS.Fq do + @moduledoc """ + Base field Fq for BLS12-381. + + This is the base field over which the BLS12-381 elliptic curves are defined. + Modulus: 4002409555221667393417789825735904156556882819939007885332058136124031650490837864442687629129015664037894272559787 + """ + + alias Tezex.Crypto.Math + alias Tezex.Crypto.BLS.Constants + + @type t :: binary() + + # BLS12-381 base field modulus (q) + @modulus Constants.field_modulus() + @zero <<0::big-unsigned-integer-size(384)>> + @one <<1::big-unsigned-integer-size(384)>> + + @doc """ + Returns the field modulus. + """ + @spec modulus() :: non_neg_integer() + def modulus, do: @modulus + + @doc """ + Creates a field element from an integer. + Negative integers are converted to their positive modular equivalent. + """ + @spec from_integer(integer()) :: t() + def from_integer(n) when is_integer(n) do + # Handle negative integers by converting to positive modular equivalent + reduced = + if n >= 0 do + rem(n, @modulus) + else + # For negative n, compute n mod p as (p - ((-n) mod p)) mod p + @modulus - rem(-n, @modulus) + end + + <> + end + + @doc """ + Creates a field element from a binary (48 bytes, big-endian). + """ + @spec from_bytes(binary()) :: {:ok, t()} | {:error, :invalid_size} + def from_bytes(<> = bytes) do + if value < @modulus do + {:ok, bytes} + else + # Reduce if needed + {:ok, from_integer(value)} + end + end + + def from_bytes(_), do: {:error, :invalid_size} + + @doc """ + Converts a field element to integer. + """ + @spec to_integer(t()) :: non_neg_integer() + def to_integer(<>) do + value + end + + @doc """ + Converts a field element to 48-byte big-endian binary. + """ + @spec to_bytes(t()) :: binary() + def to_bytes(fq) when byte_size(fq) == 48 do + fq + end + + @doc """ + Zero element of the field. + """ + @spec zero :: t() + def zero, do: @zero + + @doc """ + One element of the field. + """ + @spec one :: t() + def one, do: @one + + @doc """ + Checks if a field element is zero. + """ + @spec is_zero?(t()) :: boolean() + def is_zero?(fq) do + fq == @zero + end + + @doc """ + Checks if a field element is one. + """ + @spec is_one?(t()) :: boolean() + def is_one?(fq) do + fq == @one + end + + @doc """ + Adds two field elements. + """ + @spec add(t(), t()) :: t() + def add(a, b) when byte_size(a) == 48 and byte_size(b) == 48 do + a_int = to_integer(a) + b_int = to_integer(b) + result = rem(a_int + b_int, @modulus) + from_integer(result) + end + + @doc """ + Subtracts two field elements (a - b). + """ + @spec sub(t(), t()) :: t() + def sub(a, b) when byte_size(a) == 48 and byte_size(b) == 48 do + a_int = to_integer(a) + b_int = to_integer(b) + result = rem(a_int - b_int + @modulus, @modulus) + from_integer(result) + end + + @doc """ + Multiplies two field elements. + """ + @spec mul(t(), t()) :: t() + def mul(a, b) when byte_size(a) == 48 and byte_size(b) == 48 do + a_int = to_integer(a) + b_int = to_integer(b) + result = rem(a_int * b_int, @modulus) + from_integer(result) + end + + @doc """ + Negates a field element. + """ + @spec neg(t()) :: t() + def neg(a) when byte_size(a) == 48 do + if is_zero?(a) do + @zero + else + a_int = to_integer(a) + result = rem(@modulus - a_int, @modulus) + from_integer(result) + end + end + + @doc """ + Squares a field element. + """ + @spec square(t()) :: t() + def square(a) when byte_size(a) == 48 do + mul(a, a) + end + + @doc """ + Computes the modular inverse of a field element. + Returns {:ok, inverse} or {:error, :not_invertible} if the element is zero. + """ + @spec inv(t()) :: {:ok, t()} | {:error, :not_invertible} + def inv(a) when byte_size(a) == 48 do + with false <- is_zero?(a), + a_int = to_integer(a), + {:ok, inv_int} <- Math.mod_inverse(a_int, @modulus) do + {:ok, from_integer(inv_int)} + else + _ -> {:error, :not_invertible} + end + end + + @doc """ + Raises a field element to a power. + """ + @spec pow(t(), non_neg_integer()) :: t() + def pow(base, exp) when byte_size(base) == 48 and is_integer(exp) and exp >= 0 do + base + |> to_integer() + |> Math.mod_pow(exp, @modulus) + |> from_integer() + end + + @doc """ + Checks if two field elements are equal. + """ + @spec eq?(t(), t()) :: boolean() + def eq?(a, a) when byte_size(a) == 48, do: true + def eq?(_, _), do: false + + @doc """ + Computes the square root of a field element if it exists. + """ + @spec sqrt(t()) :: {:ok, t()} | {:error, :no_sqrt} + def sqrt(a) when byte_size(a) == 48 do + # Use Tonelli-Shanks algorithm for square root + # Since q ≡ 3 (mod 4), we can use a^((q+1)/4) + with false <- is_zero?(a) and :is_zero, + 3 <- rem(@modulus, 4), + exp = div(@modulus + 1, 4), + candidate = pow(a, exp), + # Verify it's actually a square root + true <- eq?(square(candidate), a) do + {:ok, candidate} + else + :is_zero -> {:ok, @zero} + _ -> {:error, :no_sqrt} + end + end + + @doc """ + Generates a random field element. + """ + @spec random() :: t() + def random do + # Generate 48 random bytes and reduce modulo the field modulus + random_bytes = :crypto.strong_rand_bytes(48) + <> = random_bytes + from_integer(random_int) + end + + @doc """ + Computes the Frobenius endomorphism φ: Fq → Fq where φ(x) = x^p. + For the base field Fq, this is the identity function since x^p ≡ x (mod p). + """ + @spec frobenius(t()) :: t() + def frobenius(a) when byte_size(a) == 48 do + a + end +end diff --git a/lib/crypto/bls/fq12.ex b/lib/crypto/bls/fq12.ex new file mode 100644 index 0000000..fdb063c --- /dev/null +++ b/lib/crypto/bls/fq12.ex @@ -0,0 +1,254 @@ +defmodule Tezex.Crypto.BLS.Fq12 do + @moduledoc """ + The 12th-degree extension field Fq12 for BLS12-381. + This is the target field for pairings: GT = Fq12. + + The field is constructed as Fq12 = Fq[x]/(x^12 + 2 - 2*x^6). + """ + + alias Tezex.Crypto.BLS.Fq + alias Tezex.Crypto.BLS.FqP + alias Tezex.Crypto.Math + + @type t :: FqP.t() + + # BLS12-381 Fq12 modulus coefficients: x^12 + 2 - 2*x^6 = 0 + # So x^12 = 2*x^6 - 2 + # + implicit x^12 term + @modulus_coeffs [2, 0, 0, 0, 0, 0, -2, 0, 0, 0, 0, 0] + @zero Fq.zero() + + @doc """ + Creates an Fq12 element from a list of 12 Fq coefficients. + """ + @spec new(list(Fq.t())) :: t() + def new(coeffs) when is_list(coeffs) do + FqP.new(coeffs, @modulus_coeffs) + end + + @doc """ + Creates an Fq12 element from a list of coefficients and modulus coefficients. + This is used for testing compatibility. + """ + @spec new(list(Fq.t()), list(integer())) :: t() + def new(coeffs, modulus_coeffs) when is_list(coeffs) and is_list(modulus_coeffs) do + FqP.new(coeffs, modulus_coeffs) + end + + @doc """ + Creates an Fq12 element from integers. + """ + @spec from_integers(list(integer())) :: t() + def from_integers(integers) when is_list(integers) do + coeffs = Enum.map(integers, &Fq.from_integer/1) + new(coeffs) + end + + @doc """ + Returns the one element in Fq12. + """ + @spec one() :: t() + def one do + FqP.one(@modulus_coeffs) + end + + @doc """ + Returns the one element with custom modulus (for testing). + """ + @spec one(list(integer())) :: t() + def one(modulus_coeffs) do + FqP.one(modulus_coeffs) + end + + @doc """ + Adds two Fq12 elements. + """ + @spec add(t(), t()) :: t() + def add(a, b) do + FqP.add(a, b) + end + + @doc """ + Subtracts two Fq12 elements. + """ + @spec sub(t(), t()) :: t() + def sub(a, b) do + FqP.sub(a, b) + end + + @doc """ + Multiplies two Fq12 elements. + """ + @spec mul(t(), t()) :: t() + def mul(a, b) do + FqP.mul(a, b) + end + + @doc """ + Computes the modular inverse of an Fq12 element. + Uses the FqP inverse implementation. + """ + @spec inv(t()) :: t() + def inv(a) do + FqP.inv(a) + end + + @doc """ + Divides two Fq12 elements (a / b = a * b^(-1)). + """ + @spec field_div(t(), t()) :: t() + def field_div(a, b) do + mul(a, inv(b)) + end + + @doc """ + Raises an Fq12 element to a power. + """ + @spec pow(t(), integer()) :: t() + def pow(base, exponent) when exponent >= 0 do + Math.binary_pow(base, exponent, one(), &mul/2) + end + + @doc """ + Checks if an Fq12 element is zero. + """ + @spec is_zero?(t()) :: boolean() + def is_zero?(a) do + FqP.is_zero?(a) + end + + @doc """ + Checks if an Fq12 element is one. + """ + @spec is_one?(t()) :: boolean() + def is_one?(a) do + FqP.eq?(a, one()) + end + + @doc """ + Checks if two Fq12 elements are equal. + """ + @spec eq?(t(), t()) :: boolean() + def eq?(a, b) do + FqP.eq?(a, b) + end + + # Frobenius endomorphism constants + @f_1 3_699_099_184_852_670_630_486_504_326_366_823_957_760_732_022_002_356_992_020_042_909_103_225_195_948_437_585_701_677_765_097_109_891_745_284_740_289_733 + @f_2 151_655_185_184_498_381_465_642_749_684_540_099_398_075_398_968_325_446_656_007_613_510_403_227_271_200_139_370_504_932_015_952_886_146_304_766_135_027 + @f_3 793_479_390_729_215_512_621_379_701_633_421_447_060_886_740_281_060_493_010_456_487_427_281_649_075_476_305_620_758_731_620_351 + @f_4 4_002_409_555_221_667_392_624_310_435_006_688_643_935_503_118_305_586_438_271_171_395_842_971_157_480_381_377_015_405_980_053_539_358_417_135_540_939_436 + @f_5 1_028_732_146_235_106_349_975_324_479_215_795_277_384_839_936_929_757_896_155_643_118_032_610_843_298_655_225_875_571_310_552_543_014_690_878_354_869_257 + @f_6 4_002_409_555_221_667_392_624_310_435_006_688_643_935_503_118_305_586_438_271_171_395_842_971_157_480_381_377_015_405_980_053_539_358_417_135_540_939_437 + @f_7 1_754_153_922_101_215_937_019_363_459_062_510_355_973_529_075_922_864_898_999_271_009_044_415_232_054_910_173_010_132_757_073_180_257_089_147_177_468_460 + @f_8 3_125_332_594_171_059_424_908_108_096_204_648_978_570_118_281_977_575_435_832_422_631_601_824_034_463_382_777_937_621_250_592_425_535_493_320_683_825_557 + @f_9 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_559_786 + @f_10 303_310_370_368_996_762_931_285_499_369_080_198_796_150_797_936_650_893_312_015_227_020_806_454_542_400_278_741_009_864_031_905_772_292_609_532_270_054 + @f_11 2_057_464_292_470_212_699_950_648_958_431_590_554_769_679_873_859_515_792_311_286_236_065_221_686_597_310_451_751_142_621_105_086_029_381_756_709_738_514 + @f_12 4_002_409_555_221_667_391_830_831_044_277_473_131_314_123_416_672_164_991_210_284_655_561_910_664_469_924_889_588_124_330_978_063_052_796_376_809_319_087 + @f_13 793_479_390_729_215_512_621_379_701_633_421_447_060_886_740_281_060_493_010_456_487_427_281_649_075_476_305_620_758_731_620_350 + @f_14 2_248_255_633_120_451_456_398_426_366_673_393_800_583_353_744_016_142_986_332_787_127_079_616_418_435_927_691_432_554_872_055_835_406_948_747_095_091_327 + + # Frobenius endomorphism precomputed constants for BLS12-381 + # These represent x^i where x^p ≡ frobenius_constants[i] (mod field_modulus) + # Allows efficient computation: φ(Σ a_i * x^i) = Σ a_i^p * frobenius_constants[i] + @frobenius_constants [ + [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, @f_1, 0, 0, 0, 0, 0, @f_2, 0, 0, 0, 0], + [0, 0, @f_3, 0, 0, 0, 0, 0, @f_4, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, @f_5, 0, 0], + [0, 0, 0, 0, @f_6, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, @f_7, 0, 0, 0, 0, 0, @f_8], + [2, 0, 0, 0, 0, 0, @f_9, 0, 0, 0, 0, 0], + [0, @f_1, 0, 0, 0, 0, 0, @f_10, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, @f_4, 0, 0, 0], + [0, 0, 0, @f_11, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, @f_12, 0, 0, 0, 0, 0, @f_13, 0], + [0, 0, 0, 0, 0, @f_7, 0, 0, 0, 0, 0, @f_14] + ] + + # Hard part exponent for BLS12-381 final exponentiation + # This is (p^4 - p^2 + 1) / r, which is ~1268 bits instead of the naive ~4314 bits + @hard_part_exponent 0xF686B3D807D01C0BD38C3195C899ED3CDE88EEB996CA394506632528D6A9A2F230063CF081517F68F7764C28B6F8AE5A72BCE8D63CB9F827ECA0BA621315B2076995003FC77A17988F8761BDC51DC2378B9039096D1B767F17FCBDE783765915C97F36C6F18212ED0B283ED237DB421D160AEB6A1E79983774940996754C8C71A2629B0DEA236905CE937335D5B68FA9912AAE208CCF1E516C3F438E3BA79 + + @frobenius_base List.duplicate(@zero, 12) + + @doc """ + Computes the Frobenius endomorphism φ: Fq12 → Fq12 where φ(x) = x^p. + This raises each coefficient to the power p and applies the precomputed basis transformation. + """ + @spec frobenius(t()) :: t() + def frobenius(%{coeffs: coeffs, modulus_coeffs: modulus_coeffs}) do + # Apply Frobenius to each coefficient: a_i^p + frobenius_coeffs = Enum.map(coeffs, &Fq.frobenius/1) + + # Apply the basis transformation using precomputed constants + # φ(Σ a_i * x^i) = Σ a_i^p * frobenius_constants[i] + coeffs = + @frobenius_constants + |> Enum.zip(frobenius_coeffs) + |> Enum.reduce(@frobenius_base, fn {constant_coeffs, coeff}, acc -> + constant_fq12 = from_integers(constant_coeffs) + scaled = scalar_mul(constant_fq12, coeff) + + Enum.zip_with(acc, scaled.coeffs, &Fq.add/2) + end) + + %{coeffs: coeffs, modulus_coeffs: modulus_coeffs} + end + + @doc """ + Multiplies an Fq12 element by a scalar Fq element. + """ + @spec scalar_mul(t(), Fq.t()) :: t() + def scalar_mul(%{coeffs: coeffs, modulus_coeffs: modulus_coeffs}, scalar) do + coeffs = Enum.map(coeffs, &Fq.mul(&1, scalar)) + %{coeffs: coeffs, modulus_coeffs: modulus_coeffs} + end + + @doc """ + Optimized final exponentiation for BLS12-381. + + Instead of computing f^((p^12-1)/r) directly (~4314 bits), this computes: + 1. f^(p^2 + 1) using 2 Frobenius operations + 1 multiplication + 2. (f^(p^2 + 1))^(p^6 - 1) using 6 Frobenius operations + 1 inversion + 3. result^((p^4-p^2+1)/r) using optimized exponentiation (~1268 bits) + + This gives the same result as the naive approach but is much faster. + Total: 8 Frobenius + 1 mul + 1 inv + 1 pow(1268 bits) vs 1 pow(4314 bits) + """ + @spec optimized_final_exponentiation(t()) :: t() + def optimized_final_exponentiation(f) do + # Step 1: Compute f^(p^2 + 1) + # p2 = frobenius(frobenius(f)) * f + # f^p + f_p = frobenius(f) + # f^(p^2) + f_p2 = frobenius(f_p) + # f^(p^2 + 1) + p2 = mul(f_p2, f) + + # Step 2: Compute (f^(p^2 + 1))^(p^6 - 1) + # Apply 6 Frobenius operations to p2, then divide by p2 + temp = p2 + # p2^p + temp = frobenius(temp) + # p2^(p^2) + temp = frobenius(temp) + # p2^(p^3) + temp = frobenius(temp) + # p2^(p^4) + temp = frobenius(temp) + # p2^(p^5) + temp = frobenius(temp) + # p2^(p^6) + temp = frobenius(temp) + + # p2^(p^6 - 1) = (f^(p^2+1))^(p^6-1) + p3 = field_div(temp, p2) + + # Step 3: Compute p3^((p^4-p^2+1)/r) + # This is the "hard part" but only 1268 bits instead of 4314 + pow(p3, @hard_part_exponent) + end +end diff --git a/lib/crypto/bls/fq2.ex b/lib/crypto/bls/fq2.ex new file mode 100644 index 0000000..e0f0453 --- /dev/null +++ b/lib/crypto/bls/fq2.ex @@ -0,0 +1,362 @@ +defmodule Tezex.Crypto.BLS.Fq2 do + @moduledoc """ + Quadratic extension field Fq2 for BLS12-381. + + Fq2 = Fq[u] / (u^2 + 1) where Fq is the base field. + Elements are represented as a + b*u where a, b ∈ Fq. + """ + + alias Tezex.Crypto.BLS.Fq + alias Tezex.Crypto.Math + + @type t :: {Fq.t(), Fq.t()} + + @zero Fq.zero() + @one Fq.one() + @modulus Fq.modulus() + + @doc """ + Creates an Fq2 element from two Fq elements (a + b*u). + """ + @spec new(Fq.t(), Fq.t()) :: t() + def new(a, b) when byte_size(a) == 48 and byte_size(b) == 48 do + {a, b} + end + + @doc """ + Zero element of Fq2. + """ + @spec zero() :: t() + def zero do + {@zero, @zero} + end + + @doc """ + One element of Fq2. + """ + @spec one() :: t() + def one do + {@one, @zero} + end + + @doc """ + Checks if an Fq2 element is zero. + """ + @spec is_zero?(t()) :: boolean() + def is_zero?({a, b}) do + Fq.is_zero?(a) and Fq.is_zero?(b) + end + + @doc """ + Checks if an Fq2 element is one. + """ + @spec is_one?(t()) :: boolean() + def is_one?({a, b}) do + Fq.is_one?(a) and Fq.is_zero?(b) + end + + @doc """ + Adds two Fq2 elements. + """ + @spec add(t(), t()) :: t() + def add({a1, b1}, {a2, b2}) do + {Fq.add(a1, a2), Fq.add(b1, b2)} + end + + @doc """ + Subtracts two Fq2 elements (x - y). + """ + @spec sub(t(), t()) :: t() + def sub({a1, b1}, {a2, b2}) do + {Fq.sub(a1, a2), Fq.sub(b1, b2)} + end + + @doc """ + Multiplies two Fq2 elements. + (a1 + b1*u) * (a2 + b2*u) = (a1*a2 - b1*b2) + (a1*b2 + b1*a2)*u + """ + @spec mul(t(), t()) :: t() + def mul({a1, b1}, {a2, b2}) do + # (a1 + b1*u) * (a2 + b2*u) = a1*a2 + a1*b2*u + b1*a2*u + b1*b2*u^2 + # Since u^2 = -1, this becomes: (a1*a2 - b1*b2) + (a1*b2 + b1*a2)*u + a1_a2 = Fq.mul(a1, a2) + b1_b2 = Fq.mul(b1, b2) + a1_b2 = Fq.mul(a1, b2) + b1_a2 = Fq.mul(b1, a2) + + real_part = Fq.sub(a1_a2, b1_b2) + imag_part = Fq.add(a1_b2, b1_a2) + + {real_part, imag_part} + end + + @doc """ + Negates an Fq2 element. + """ + @spec neg(t()) :: t() + def neg({a, b}) do + {Fq.neg(a), Fq.neg(b)} + end + + @doc """ + Squares an Fq2 element. + (a + b*u)^2 = a^2 + 2*a*b*u + b^2*u^2 = (a^2 - b^2) + (2*a*b)*u + """ + @spec square(t()) :: t() + def square({a, b}) do + a_squared = Fq.square(a) + b_squared = Fq.square(b) + ab = Fq.mul(a, b) + two_ab = Fq.add(ab, ab) + + real_part = Fq.sub(a_squared, b_squared) + imag_part = two_ab + + {real_part, imag_part} + end + + @doc """ + Computes the conjugate of an Fq2 element. + conj(a + b*u) = a - b*u + """ + @spec conjugate(t()) :: t() + def conjugate({a, b}) do + {a, Fq.neg(b)} + end + + @doc """ + Computes the norm of an Fq2 element. + norm(a + b*u) = (a + b*u) * (a - b*u) = a^2 + b^2 + """ + @spec norm(t()) :: Fq.t() + def norm({a, b}) do + a_squared = Fq.square(a) + b_squared = Fq.square(b) + Fq.add(a_squared, b_squared) + end + + @doc """ + Computes the modular inverse of an Fq2 element. + inv(a + b*u) = conj(a + b*u) / norm(a + b*u) + """ + @spec inv(t()) :: {:ok, t()} | {:error, :not_invertible} + def inv(element) do + if is_zero?(element) do + {:error, :not_invertible} + else + case Fq.inv(norm(element)) do + {:ok, norm_inv} -> + conj = conjugate(element) + {conj_a, conj_b} = conj + result_a = Fq.mul(conj_a, norm_inv) + result_b = Fq.mul(conj_b, norm_inv) + {:ok, {result_a, result_b}} + + {:error, _} -> + {:error, :not_invertible} + end + end + end + + @doc """ + Raises an Fq2 element to a power. + """ + @spec pow(t(), non_neg_integer()) :: t() + def pow(base, exp) when is_integer(exp) and exp >= 0 do + Math.binary_pow(base, exp, one(), &mul/2) + end + + @doc """ + Checks if two Fq2 elements are equal. + """ + @spec eq?(t(), t()) :: boolean() + def eq?({a1, b1}, {a2, b2}) do + Fq.eq?(a1, a2) and Fq.eq?(b1, b2) + end + + @doc """ + Creates an Fq2 element from integers. + """ + @spec from_integers(non_neg_integer(), non_neg_integer()) :: t() + def from_integers(a_int, b_int) do + a = Fq.from_integer(a_int) + b = Fq.from_integer(b_int) + {a, b} + end + + @doc """ + Extracts the coefficients of an Fq2 element as a tuple of integers. + """ + @spec to_integers(t()) :: {non_neg_integer(), non_neg_integer()} + def to_integers({a, b}) do + {Fq.to_integer(a), Fq.to_integer(b)} + end + + @doc """ + Returns the field modulus. + """ + @spec modulus() :: non_neg_integer() + def modulus do + @modulus + end + + @doc """ + Multiplies an Fq2 element by a scalar. + """ + @spec mul_scalar(t(), non_neg_integer()) :: t() + def mul_scalar({a, b}, scalar) do + scalar_fq = Fq.from_integer(scalar) + {Fq.mul(a, scalar_fq), Fq.mul(b, scalar_fq)} + end + + @doc """ + Computes the square root of an Fq2 element using modular square root algorithm. + """ + @spec sqrt(t()) :: {:ok, t()} | {:error, :no_sqrt} + def sqrt(element) do + cond do + is_zero?(element) -> + {:ok, zero()} + + eq?(element, one()) -> + {:ok, one()} + + true -> + modular_squareroot_in_fq2(element) + end + end + + # Modular square root algorithm for Fq2 + # FQ2_ORDER constant + @fq2_order 16_019_282_247_729_705_411_943_748_644_318_972_617_695_120_099_330_552_659_862_384_536_985_976_748_491_357_143_400_656_079_302_193_429_974_954_385_540_170_730_531_103_884_539_706_905_936_200_202_421_036_435_811_093_013_034_271_812_758_016_407_969_496_331_661_418_541_023_677_774_899_971_425_993_489_485_368 + + # EIGHTH_ROOTS_OF_UNITY - need both the check roots and the divisor roots + @eighth_roots_check [ + # index 0: (1, 0) + {Fq.from_integer(1), Fq.from_integer(0)}, + # index 2: (0, 1) + {Fq.from_integer(0), Fq.from_integer(1)}, + # index 4: (-1, 0) + {Fq.from_integer( + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_559_786 + ), Fq.from_integer(0)}, + # index 6: (0, -1) + {Fq.from_integer(0), + Fq.from_integer( + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_559_786 + )} + ] + + # EIGHTH_ROOTS_OF_UNITY divisors (for indices 0, 1, 2, 3) + @eighth_roots_divisors [ + # index 0: (1, 0) + {Fq.from_integer(1), Fq.from_integer(0)}, + # index 1 + {Fq.from_integer( + 1_028_732_146_235_106_349_975_324_479_215_795_277_384_839_936_929_757_896_155_643_118_032_610_843_298_655_225_875_571_310_552_543_014_690_878_354_869_257 + ), + Fq.from_integer( + 2_973_677_408_986_561_043_442_465_346_520_108_879_172_042_883_009_249_989_176_415_018_091_420_807_192_182_638_567_116_318_576_472_649_347_015_917_690_530 + )}, + # index 2: (0, 1) + {Fq.from_integer(0), Fq.from_integer(1)}, + # index 3 + {Fq.from_integer( + 1_028_732_146_235_106_349_975_324_479_215_795_277_384_839_936_929_757_896_155_643_118_032_610_843_298_655_225_875_571_310_552_543_014_690_878_354_869_257 + ), + Fq.from_integer( + 1_028_732_146_235_106_349_975_324_479_215_795_277_384_839_936_929_757_896_155_643_118_032_610_843_298_655_225_875_571_310_552_543_014_690_878_354_869_257 + )} + ] + + defp modular_squareroot_in_fq2(value) do + # candidate_squareroot = value ** ((FQ2_ORDER + 8) // 16) + exponent = div(@fq2_order + 8, 16) + candidate_squareroot = pow(value, exponent) + + # check = candidate_squareroot**2 / value + candidate_squared = square(candidate_squareroot) + + case inv(value) do + {:ok, value_inv} -> + check = mul(candidate_squared, value_inv) + + # if check in EIGHTH_ROOTS_OF_UNITY[::2]: + if check_eighth_root(check) do + # Find which eighth root it is and compute the result + compute_sqrt_result(candidate_squareroot, check) + else + {:error, :no_sqrt} + end + + {:error, _} -> + {:error, :no_sqrt} + end + end + + defp check_eighth_root(check) do + # Check if check is in any of our check eighth roots (every other one) + Enum.any?(@eighth_roots_check, fn root -> eq?(check, root) end) + end + + defp compute_sqrt_result(candidate_squareroot, check) do + # Find which eighth root it matches and get its index + check_index = Enum.find_index(@eighth_roots_check, fn root -> eq?(check, root) end) || 0 + + # Map check indices to original indices: 0->0, 1->2, 2->4, 3->6 + original_index = check_index * 2 + + # x1 = candidate_squareroot / EIGHTH_ROOTS_OF_UNITY[original_index // 2] + # 0->0, 2->1, 4->2, 6->3 + divisor_index = div(original_index, 2) + divisor = Enum.at(@eighth_roots_divisors, divisor_index) + + case inv(divisor) do + {:ok, divisor_inv} -> + x1 = mul(candidate_squareroot, divisor_inv) + x2 = neg(x1) + + # Return x1 if (x1_im > x2_im or (x1_im == x2_im and x1_re > x2_re)) else x2 + {x1_re, x1_im} = x1 + {x2_re, x2_im} = x2 + + x1_re_int = Fq.to_integer(x1_re) + x1_im_int = Fq.to_integer(x1_im) + x2_re_int = Fq.to_integer(x2_re) + x2_im_int = Fq.to_integer(x2_im) + + result = + if x1_im_int > x2_im_int or (x1_im_int == x2_im_int and x1_re_int > x2_re_int) do + x1 + else + x2 + end + + {:ok, result} + + {:error, _} -> + {:error, :no_sqrt} + end + end + + @doc """ + Sign function sgn0(x) = 1 when x is 'negative'; otherwise, sgn0(x) = 0. + For Fq2, this is optimized for m = 2 as defined in the hash-to-curve spec. + """ + @spec sgn0(t()) :: 0 | 1 + def sgn0({a, b}) do + a_int = :binary.decode_unsigned(a) + b_int = :binary.decode_unsigned(b) + + sign_0 = rem(a_int, 2) + zero_0 = if a_int == 0, do: 1, else: 0 + sign_1 = rem(b_int, 2) + + # sign_0 OR (zero_0 AND sign_1) + case {sign_0, zero_0, sign_1} do + {1, _, _} -> 1 + {0, 1, 1} -> 1 + _ -> 0 + end + end +end diff --git a/lib/crypto/bls/fqp.ex b/lib/crypto/bls/fqp.ex new file mode 100644 index 0000000..e57653a --- /dev/null +++ b/lib/crypto/bls/fqp.ex @@ -0,0 +1,366 @@ +defmodule Tezex.Crypto.BLS.FqP do + @moduledoc """ + Polynomial extension field base class for BLS12-381. + This is the base for building higher-degree extension fields like Fq12. + """ + + alias Tezex.Crypto.BLS.Fq + + @type t :: %{coeffs: list(Fq.t()), modulus_coeffs: list(integer())} + + @zero Fq.zero() + @one Fq.one() + @modulus Fq.modulus() + + @doc """ + Creates a polynomial extension field element from coefficients. + """ + @spec new(list(Fq.t()), list(integer())) :: t() + def new(coeffs, modulus_coeffs) do + degree = length(modulus_coeffs) + + # Pad or truncate coefficients to match the field degree + normalized_coeffs = + coeffs + |> Enum.take(degree) + |> pad_to_length(degree) + + %{coeffs: normalized_coeffs, modulus_coeffs: modulus_coeffs} + end + + @doc """ + Adds two polynomial field elements. + """ + @spec add(t(), t()) :: t() + def add(%{coeffs: coeffs1, modulus_coeffs: modulus_coeffs}, %{ + coeffs: coeffs2, + modulus_coeffs: modulus_coeffs + }) do + coeffs = Enum.zip_with(coeffs1, coeffs2, &Fq.add/2) + new(coeffs, modulus_coeffs) + end + + @doc """ + Subtracts two polynomial field elements. + """ + @spec sub(t(), t()) :: t() + def sub(%{coeffs: coeffs1, modulus_coeffs: modulus_coeffs}, %{ + coeffs: coeffs2, + modulus_coeffs: modulus_coeffs + }) do + coeffs = Enum.zip_with(coeffs1, coeffs2, &Fq.sub/2) + new(coeffs, modulus_coeffs) + end + + @doc """ + Multiplies two polynomial field elements with modular reduction. + This implementation uses an optimized multiplication algorithm. + """ + @spec mul(t(), t()) :: t() + def mul(%{coeffs: coeffs1, modulus_coeffs: modulus_coeffs}, %{ + coeffs: coeffs2, + modulus_coeffs: modulus_coeffs + }) do + degree = length(modulus_coeffs) + + # Create b array of size degree * 2 - 1 + b_size = degree * 2 - 1 + + # Step 1: Polynomial multiplication - fill b array + # Use a Map to accumulate products efficiently + b_map = + for {c1, i} <- Enum.with_index(coeffs1), + {c2, j} <- Enum.with_index(coeffs2), + i + j < b_size, + reduce: %{} do + acc -> + product = Fq.mul(c1, c2) + Map.update(acc, i + j, product, &Fq.add(&1, product)) + end + + # Step 2: Polynomial reduction + # Pre-compute modulus coefficients as Fq elements for efficiency + mc_tuples = + modulus_coeffs + |> Enum.with_index() + |> Enum.reduce([], fn + {0, _i}, acc -> acc + {coeff, i}, acc -> [{i, Fq.from_integer(coeff)} | acc] + end) + |> Enum.reverse() + + # Step 3: Reduce from degree-2 down to 0 + final_b_map = + Enum.reduce((degree - 2)..0//-1, b_map, fn exp, acc_map -> + # Get coefficient at position degree + exp (the "top" term to reduce) + top_pos = degree + exp + top = Map.get(acc_map, top_pos, @zero) + + if Fq.is_zero?(top) do + acc_map + else + # Remove the high-degree term + acc_map = Map.delete(acc_map, top_pos) + + # Apply reduction: for each (i, c) in mc_tuples, b[exp + i] -= top * c + Enum.reduce(mc_tuples, acc_map, fn + {i, c}, inner_acc_map when exp + i < b_size -> + Map.update(inner_acc_map, exp + i, @zero, fn existing -> + Fq.sub(existing, Fq.mul(top, c)) + end) + + _, inner_acc_map -> + inner_acc_map + end) + end + end) + + # Step 4: Take first degree coefficients (with field modulus) + result_coeffs = + for i <- 0..(degree - 1) do + coeff = Map.get(final_b_map, i, @zero) + int_val = Fq.to_integer(coeff) + Fq.from_integer(rem(int_val, @modulus)) + end + + new(result_coeffs, modulus_coeffs) + end + + @doc """ + Negates a polynomial field element. + """ + @spec neg(t()) :: t() + def neg(%{coeffs: coeffs, modulus_coeffs: modulus_coeffs}) do + new(Enum.map(coeffs, &Fq.neg/1), modulus_coeffs) + end + + @doc """ + Returns the zero element. + """ + @spec zero(list(integer())) :: t() + def zero(modulus_coeffs) do + degree = length(modulus_coeffs) + zero_coeffs = List.duplicate(@zero, degree) + new(zero_coeffs, modulus_coeffs) + end + + @doc """ + Returns the one element. + """ + @spec one(list(integer())) :: t() + def one(modulus_coeffs) do + degree = length(modulus_coeffs) + one_coeffs = [@one | List.duplicate(@zero, degree - 1)] + new(one_coeffs, modulus_coeffs) + end + + @doc """ + Checks if the element is zero. + """ + @spec is_zero?(t()) :: boolean() + def is_zero?(%{coeffs: coeffs}) do + Enum.all?(coeffs, &Fq.is_zero?/1) + end + + @doc """ + Checks if two elements are equal. + """ + @spec eq?(t(), t()) :: boolean() + def eq?(%{coeffs: coeffs1, modulus_coeffs: modulus_coeffs}, %{ + coeffs: coeffs2, + modulus_coeffs: modulus_coeffs + }) do + Enum.zip(coeffs1, coeffs2) + |> Enum.all?(fn {a, b} -> Fq.eq?(a, b) end) + end + + @doc """ + Computes the modular inverse using the extended Euclidean algorithm. + Polynomial inversion using extended Euclidean algorithm. + """ + @spec inv(t()) :: t() + def inv(%{coeffs: coeffs, modulus_coeffs: modulus_coeffs} = elem) do + if is_zero?(elem) do + raise ArithmeticError, "Cannot invert zero element" + end + + polynomial_inv(coeffs, modulus_coeffs) + end + + # Helper functions + + defp pad_to_length(list, target_length) do + current_length = length(list) + + if current_length < target_length do + list ++ List.duplicate(@zero, target_length - current_length) + else + list + end + end + + defp polynomial_inv(coeffs, modulus_coeffs) do + degree = length(modulus_coeffs) + + lm = [@one | List.duplicate(@zero, degree)] + hm = List.duplicate(@zero, degree + 1) + low = coeffs ++ [@zero] + high = Enum.map(modulus_coeffs, &Fq.from_integer/1) ++ [@one] + + {final_lm, final_low} = extended_gcd_loop(lm, hm, low, high, degree) + + low0_int = Fq.to_integer(hd(final_low)) + inv_low0_int = prime_field_inv(low0_int, @modulus) + + coeffs = + final_lm + |> Enum.take(degree) + |> Enum.map(fn coeff -> + coeff_int = Fq.to_integer(coeff) + result_int = rem(coeff_int * inv_low0_int, @modulus) + Fq.from_integer(result_int) + end) + + new(coeffs, modulus_coeffs) + end + + # Extended GCD loop + defp extended_gcd_loop(lm, hm, low, high, degree) do + case deg(low) do + 0 -> + {lm, low} + + _ -> + r = optimized_poly_rounded_div(high, low) + r_padded = r ++ List.duplicate(@zero, degree + 1 - length(r)) + + {nm, new} = + Enum.reduce(0..degree, {hm, high}, fn i, {nm_acc, acc} -> + Enum.reduce(0..(degree - i), {nm_acc, acc}, fn + j, {nm_inner, inner} when i + j <= degree -> + nm_val = Enum.at(nm_inner, i + j) + lm_val = Enum.at(lm, i) + r_val = Enum.at(r_padded, j) + + nm_val = Fq.sub(nm_val, Fq.mul(lm_val, r_val)) + + val = Enum.at(inner, i + j) + low_val = Enum.at(low, i) + val = Fq.sub(val, Fq.mul(low_val, r_val)) + + {List.replace_at(nm_inner, i + j, nm_val), List.replace_at(inner, i + j, val)} + + _j, {nm_inner, inner} -> + {nm_inner, inner} + end) + end) + + nm_mod = + Enum.map(nm, fn x -> + Fq.from_integer(rem(Fq.to_integer(x), @modulus)) + end) + + mod = + Enum.map(new, fn x -> + Fq.from_integer(rem(Fq.to_integer(x), @modulus)) + end) + + extended_gcd_loop(nm_mod, lm, mod, low, degree) + end + end + + defp deg(p) do + deg_helper(p, length(p) - 1) + end + + defp deg_helper(_p, 0), do: 0 + + defp deg_helper(p, d) do + if Fq.is_zero?(Enum.at(p, d)) and d > 0 do + deg_helper(p, d - 1) + else + d + end + end + + defp optimized_poly_rounded_div(a_list, b_list) do + deg_a = deg(a_list) + deg_b = deg(b_list) + + tmp = Enum.map(a_list, & &1) + o = List.duplicate(@zero, length(a_list)) + + {o, _} = + Enum.reduce((deg_a - deg_b)..0//-1, {o, tmp}, fn i, {o_acc, tmp_acc} -> + if i >= 0 and deg_b + i < length(tmp_acc) and deg_b < length(b_list) do + tmp_val = Enum.at(tmp_acc, deg_b + i) + b_val = Enum.at(b_list, deg_b) + + b_int = + Fq.to_integer(b_val) + |> prime_field_inv(@modulus) + + o_val = + (Fq.to_integer(Enum.at(o_acc, i)) + Fq.to_integer(tmp_val) * b_int) + |> Fq.from_integer() + + o = List.replace_at(o_acc, i, o_val) + + tmp = + Enum.reduce(0..deg_b, tmp_acc, fn c, tmp_inner -> + if c + i < length(tmp_inner) and c < length(o) do + tmp_c_i = Enum.at(tmp_inner, c + i) + o_c = Enum.at(o, c) + List.replace_at(tmp_inner, c + i, Fq.sub(tmp_c_i, o_c)) + else + tmp_inner + end + end) + + {o, tmp} + else + {o_acc, tmp_acc} + end + end) + + o + |> Enum.take(deg(o) + 1) + |> Enum.map(fn x -> + int_val = Fq.to_integer(x) + Fq.from_integer(rem(int_val, @modulus)) + end) + end + + # prime_field_inv function + defp prime_field_inv(a, n) do + # To address a == n edge case. + a = rem(a, n) + + if a == 0 do + 0 + else + {lm, _low} = prime_field_inv_loop(1, 0, rem(a, n), n) + + # Ensure the result is positive + result = rem(lm, n) + + if result < 0 do + result + n + else + result + end + end + end + + defp prime_field_inv_loop(lm, hm, low, high) do + if low > 1 do + r = div(high, low) + nm = hm - lm * r + new = high - low * r + # lm, low, hm, high = nm, new, lm, low + prime_field_inv_loop(nm, lm, new, low) + else + {lm, low} + end + end +end diff --git a/lib/crypto/bls/fr.ex b/lib/crypto/bls/fr.ex new file mode 100644 index 0000000..acb8ff2 --- /dev/null +++ b/lib/crypto/bls/fr.ex @@ -0,0 +1,82 @@ +defmodule Tezex.Crypto.BLS.Fr do + @moduledoc """ + Scalar field Fr for BLS12-381 (curve order). + + This is the field of scalars used for private keys and in elliptic curve operations. + """ + + alias Tezex.Crypto.BLS.Constants + + # BLS12-381 scalar field order (r) + @order Constants.curve_order() + + @type t :: binary() + + @doc """ + Creates a field element from an integer. + """ + @spec from_integer(non_neg_integer()) :: t() + def from_integer(n) when is_integer(n) and n >= 0 do + # Reduce modulo the field order and convert to 32-byte big-endian + reduced = rem(n, @order) + <> + end + + @doc """ + Creates a field element from a binary (32 bytes, big-endian). + """ + @spec from_bytes(binary()) :: {:ok, t()} | {:error, :invalid_size} + def from_bytes(bytes) when byte_size(bytes) == 32 do + <> = bytes + + if value < @order do + {:ok, bytes} + else + # Reduce if needed + {:ok, from_integer(value)} + end + end + + def from_bytes(_), do: {:error, :invalid_size} + + @doc """ + Converts a field element to integer. + """ + @spec to_integer(t()) :: non_neg_integer() + def to_integer(fr) when byte_size(fr) == 32 do + <> = fr + value + end + + @doc """ + Converts a field element to 32-byte big-endian binary. + """ + @spec to_bytes(t()) :: binary() + def to_bytes(fr) when byte_size(fr) == 32 do + fr + end + + @doc """ + Zero element of the field. + """ + @spec zero() :: t() + def zero do + <<0::big-unsigned-integer-size(256)>> + end + + @doc """ + One element of the field. + """ + @spec one() :: t() + def one do + <<1::big-unsigned-integer-size(256)>> + end + + @doc """ + Checks if a field element is zero. + """ + @spec is_zero?(t()) :: boolean() + def is_zero?(fr) do + fr == zero() + end +end diff --git a/lib/crypto/bls/g1.ex b/lib/crypto/bls/g1.ex new file mode 100644 index 0000000..1f79f42 --- /dev/null +++ b/lib/crypto/bls/g1.ex @@ -0,0 +1,361 @@ +defmodule Tezex.Crypto.BLS.G1 do + @moduledoc """ + G1 elliptic curve group for BLS12-381. + + This is the elliptic curve E(Fq): y² = x³ + 4 over the base field Fq. + G1 is used for public keys in BLS signatures. + + Points are represented in projective coordinates (X, Y, Z) where: + - Affine coordinates: (X/Z, Y/Z) - standard projective coordinate system + - Point at infinity: (1, 1, 0) + """ + + import Bitwise + alias Tezex.Crypto.BLS.Fq + alias Tezex.Crypto.BLS.Fr + + @type t :: %{x: Fq.t(), y: Fq.t(), z: Fq.t()} + + # Curve coefficient B = 4 + @curve_b_int 4 + @i_curve_b_int Fq.from_integer(@curve_b_int) + + # Generator point coordinates (affine) + @generator_x_int 3_685_416_753_713_387_016_781_088_315_183_077_757_961_620_795_782_546_409_894_578_378_688_607_592_378_376_318_836_054_947_676_345_821_548_104_185_464_507 + @generator_y_int 1_339_506_544_944_476_473_020_471_379_941_921_221_584_933_875_938_349_620_426_543_736_416_511_423_956_333_506_472_724_655_353_366_534_992_391_756_441_569 + + @zero Fq.zero() + @one Fq.one() + @generator_x Fq.from_integer(@generator_x_int) + @generator_y Fq.from_integer(@generator_y_int) + @modulus Fq.modulus() + + @i_2 Fq.from_integer(2) + @i_3 Fq.from_integer(3) + @i_4 Fq.from_integer(4) + @i_8 Fq.from_integer(8) + + @doc """ + Creates a G1 point from Jacobian coordinates. + """ + @spec new(Fq.t(), Fq.t(), Fq.t()) :: t() + def new(x, y, z) do + %{x: x, y: y, z: z} + end + + @doc """ + Returns the point at infinity (identity element). + """ + @spec zero() :: t() + def zero do + new(@one, @one, @zero) + end + + @doc """ + Returns the generator point for G1. + """ + @spec generator() :: t() + def generator do + x = @generator_x + y = @generator_y + z = @one + new(x, y, z) + end + + @doc """ + Checks if a point is the point at infinity. + """ + @spec is_zero?(t()) :: boolean() + def is_zero?(%{z: z}) do + Fq.is_zero?(z) + end + + @doc """ + Alias for is_zero?/1 for compatibility. + """ + @spec is_infinity?(t()) :: boolean() + def is_infinity?(point), do: is_zero?(point) + + @doc """ + Checks if a point is on the curve using Jacobian coordinates. + Standard curve equation: y² * z = x³ + b * z³ + """ + @spec is_on_curve?(t()) :: boolean() + def is_on_curve?(point) do + if is_zero?(point) do + true + else + %{x: x, y: y, z: z} = point + + # Check curve equation in Jacobian coordinates: y² * z = x³ + b * z³ + # This avoids the precision issues of converting to affine coordinates + y_squared = Fq.square(y) + y_squared_times_z = Fq.mul(y_squared, z) + + x_cubed = Fq.mul(Fq.square(x), x) + z_cubed = Fq.mul(Fq.square(z), z) + b_times_z_cubed = Fq.mul(@i_curve_b_int, z_cubed) + x_cubed_plus_b_z_cubed = Fq.add(x_cubed, b_times_z_cubed) + + Fq.eq?(y_squared_times_z, x_cubed_plus_b_z_cubed) + end + end + + @doc """ + Doubles a G1 point. + """ + @spec double(t()) :: t() + def double(point) do + if is_zero?(point) do + zero() + else + %{x: x, y: y, z: z} = point + + w = Fq.mul(@i_3, Fq.square(x)) + s = Fq.mul(y, z) + b = Fq.mul(Fq.mul(x, y), s) + h = Fq.sub(Fq.square(w), Fq.mul(@i_8, b)) + s_squared = Fq.square(s) + + x = Fq.mul(Fq.mul(@i_2, h), s) + + y = + Fq.sub( + Fq.mul(w, Fq.sub(Fq.mul(@i_4, b), h)), + Fq.mul(Fq.mul(@i_8, Fq.square(y)), s_squared) + ) + + z = Fq.mul(@i_8, Fq.mul(s, s_squared)) + + new(x, y, z) + end + end + + @doc """ + Adds two G1 points using standard elliptic curve addition. + """ + @spec add(t(), t()) :: t() + def add(p1, p2) do + cond do + is_zero?(p1) -> p2 + is_zero?(p2) -> p1 + true -> add_projective_algorithm(p1, p2) + end + end + + # Projective addition algorithm implementation + defp add_projective_algorithm(p1, p2) do + %{x: x1, y: y1, z: z1} = p1 + %{x: x2, y: y2, z: z2} = p2 + + u1 = Fq.mul(y2, z1) + u2 = Fq.mul(y1, z2) + v1 = Fq.mul(x2, z1) + v2 = Fq.mul(x1, z2) + + cond do + Fq.eq?(v1, v2) and Fq.eq?(u1, u2) -> + double(p1) + + Fq.eq?(v1, v2) -> + # Point at infinity + zero() + + true -> + u = Fq.sub(u1, u2) + v = Fq.sub(v1, v2) + v_squared = Fq.square(v) + v_squared_times_v2 = Fq.mul(v_squared, v2) + v_cubed = Fq.mul(v, v_squared) + w = Fq.mul(z1, z2) + + a = + Fq.sub( + Fq.sub(Fq.mul(Fq.square(u), w), v_cubed), + Fq.mul(@i_2, v_squared_times_v2) + ) + + x = Fq.mul(v, a) + y = Fq.sub(Fq.mul(u, Fq.sub(v_squared_times_v2, a)), Fq.mul(v_cubed, u2)) + z = Fq.mul(v_cubed, w) + + new(x, y, z) + end + end + + @doc """ + Negates a G1 point. + """ + @spec negate(t()) :: t() + def negate(point) do + if is_zero?(point) do + zero() + else + new(point.x, Fq.neg(point.y), point.z) + end + end + + @doc """ + Multiplies a G1 point by a scalar using binary "double-and-add". + """ + @spec mul(t(), Fr.t()) :: t() + def mul(point, scalar) when byte_size(scalar) == 32 do + scalar_int = Fr.to_integer(scalar) + mul_recursive(point, scalar_int) + end + + # Recursive scalar multiplication + defp mul_recursive(_point, 0) do + # Point at infinity + zero() + end + + defp mul_recursive(point, 1) do + point + end + + defp mul_recursive(point, n) when rem(n, 2) == 0 do + doubled = double(point) + mul_recursive(doubled, div(n, 2)) + end + + defp mul_recursive(point, n) do + doubled = double(point) + multiplied = mul_recursive(doubled, div(n, 2)) + add(multiplied, point) + end + + @doc """ + Checks if two G1 points are equal. + """ + @spec eq?(t(), t()) :: boolean() + def eq?(p1, p2) do + cond do + is_zero?(p1) and is_zero?(p2) -> true + is_zero?(p1) or is_zero?(p2) -> false + true -> eq_different?(p1, p2) + end + end + + # Check equality for non-zero points + defp eq_different?(p1, p2) do + %{x: x1, y: y1, z: z1} = p1 + %{x: x2, y: y2, z: z2} = p2 + + # x1*z2 == x2*z1 and y1*z2 == y2*z1 + x1_cross = Fq.mul(x1, z2) + x2_cross = Fq.mul(x2, z1) + + if Fq.eq?(x1_cross, x2_cross) do + y1_cross = Fq.mul(y1, z2) + y2_cross = Fq.mul(y2, z1) + Fq.eq?(y1_cross, y2_cross) + else + false + end + end + + @doc """ + Converts a G1 point to affine coordinates using projective coordinate system + where affine = (x/z, y/z), not Jacobian (x/z^2, y/z^3). + """ + @spec to_affine(t()) :: {:ok, {Fq.t(), Fq.t()}} | {:error, :point_at_infinity} + def to_affine(point) do + with false <- is_zero?(point), + %{x: x, y: y, z: z} = point, + {:ok, z_inv} <- Fq.inv(z) do + affine_x = Fq.mul(x, z_inv) + affine_y = Fq.mul(y, z_inv) + + {:ok, {affine_x, affine_y}} + else + _ -> {:error, :point_at_infinity} + end + end + + @doc """ + Creates a G1 point from affine coordinates. + """ + @spec from_affine(Fq.t(), Fq.t()) :: t() + def from_affine(x, y) do + new(x, y, @one) + end + + # Bit shift constants for compressed encoding + @pow_2_381 1 <<< 381 + @pow_2_382 1 <<< 382 + @pow_2_383 1 <<< 383 + @infinity <<@pow_2_383 + @pow_2_382::big-integer-size(384)>> + + @doc """ + Serializes a G1 point to compressed format (48 bytes). + """ + @spec to_compressed_bytes(t()) :: binary() + def to_compressed_bytes(point) do + with false <- is_zero?(point), + {:ok, {x, y}} <- to_affine(point) do + x_int = Fq.to_integer(x) + y_int = Fq.to_integer(y) + field_modulus = @modulus + + a_flag = if 2 * y_int >= field_modulus, do: 1, else: 0 + + compressed_value = x_int + a_flag * @pow_2_381 + @pow_2_383 + + <> + else + _ -> + # Point at infinity fallback + @infinity + end + end + + @doc """ + Deserializes a G1 point from compressed format (48 bytes). + """ + @spec from_compressed_bytes(binary()) :: {:ok, t()} | {:error, :invalid_point} + def from_compressed_bytes(bytes) when byte_size(bytes) == 48 do + <> = bytes + + # Check compression flag + with true <- (first_byte &&& 0x80) != 0, + # Point at infinity check + true <- (first_byte &&& 0x40) == 0 or :maybe_inf, + # Extract x coordinate and y parity + y_parity = (first_byte &&& 0x20) >>> 5, + x_first_byte = first_byte &&& 0x1F, + x_bytes = <> <> rest, + {:ok, x} <- Fq.from_bytes(x_bytes), + # Compute y coordinate from curve equation: y² = x³ + 4 + x_cubed = Fq.mul(Fq.square(x), x), + y_squared = Fq.add(x_cubed, @i_curve_b_int), + {:ok, y} <- Fq.sqrt(y_squared) do + # Choose correct y based on lexicographically larger convention + # a_flag = 1 if y > q/2, 0 otherwise + y_int = Fq.to_integer(y) + field_modulus = @modulus + computed_a_flag = if 2 * y_int >= field_modulus, do: 1, else: 0 + + if computed_a_flag == y_parity do + point = from_affine(x, y) + {:ok, point} + else + neg_y = Fq.neg(y) + point = from_affine(x, neg_y) + {:ok, point} + end + else + :maybe_inf -> + if bytes == <<0xC0, 0::376>> do + {:ok, zero()} + else + {:error, :invalid_point} + end + + _ -> + {:error, :invalid_point} + end + end + + def from_compressed_bytes(_), do: {:error, :invalid_point} +end diff --git a/lib/crypto/bls/g2.ex b/lib/crypto/bls/g2.ex new file mode 100644 index 0000000..6a2aca5 --- /dev/null +++ b/lib/crypto/bls/g2.ex @@ -0,0 +1,826 @@ +defmodule Tezex.Crypto.BLS.G2 do + @moduledoc """ + G2 elliptic curve group for BLS12-381. + + This is the elliptic curve E'(Fq2): y² = x³ + 4(1 + u) over the quadratic extension Fq2. + G2 is used for signatures in BLS signatures. + + Points are represented in Jacobian coordinates (X, Y, Z) where: + - Affine coordinates: (X/Z², Y/Z³) over Fq2 + - Point at infinity: (1, 1, 0) + """ + + import Bitwise + alias Tezex.Crypto.BLS.Constants + alias Tezex.Crypto.BLS.Fq + alias Tezex.Crypto.BLS.Fq2 + alias Tezex.Crypto.BLS.Fr + alias Tezex.Crypto.BLS.HashToField + + @type t :: %{x: Fq2.t(), y: Fq2.t(), z: Fq2.t()} + + @zero Fq2.zero() + @one Fq2.one() + + # Curve coefficient B = 4(1 + u) in Fq2 + @curve_b_fq2 {Fq.from_integer(4), Fq.from_integer(4)} + + # SSWU constants for 3-isogeny curve mapping + @iso_3_a Fq2.from_integers(0, 240) + @iso_3_b Fq2.from_integers(1012, 1012) + @iso_3_z Fq2.from_integers(-2, -1) + + # P_MINUS_9_DIV_16 for sqrt_division_FQ2 + @p_minus_9_div_16 1_001_205_140_483_106_588_246_484_290_269_935_788_605_945_006_208_159_541_241_399_033_561_623_546_780_709_821_462_541_004_956_387_089_373_434_649_096_260_670_658_193_992_783_731_681_621_012_512_651_314_777_238_193_313_314_641_988_297_376_025_498_093_520_728_838_658_813_979_860_931_248_214_124_593_092_835 + + # ETAS for SSWU mapping + @eta_1 Fq2.from_integers( + 1_015_919_005_498_129_635_886_032_702_454_337_503_112_659_152_043_614_931_979_881_174_103_627_376_789_972_962_005_013_361_970_813_319_613_593_700_736_144, + 1_244_231_661_155_348_484_223_428_017_511_856_347_821_538_750_986_231_559_855_759_541_903_146_219_579_071_812_422_210_818_684_355_842_447_591_283_616_181 + ) + @eta_2 Fq2.from_integers( + -1_244_231_661_155_348_484_223_428_017_511_856_347_821_538_750_986_231_559_855_759_541_903_146_219_579_071_812_422_210_818_684_355_842_447_591_283_616_181, + 1_015_919_005_498_129_635_886_032_702_454_337_503_112_659_152_043_614_931_979_881_174_103_627_376_789_972_962_005_013_361_970_813_319_613_593_700_736_144 + ) + @eta_3 Fq2.from_integers( + 1_646_015_993_121_829_755_895_883_253_076_789_309_308_090_876_275_172_350_194_834_453_434_199_515_639_474_951_814_226_234_213_676_147_507_404_483_718_679, + 1_637_752_706_019_426_886_789_797_193_293_828_301_565_549_384_974_986_623_510_918_743_054_325_021_588_194_075_665_960_171_838_131_772_227_885_159_387_073 + ) + @eta_4 Fq2.from_integers( + -1_637_752_706_019_426_886_789_797_193_293_828_301_565_549_384_974_986_623_510_918_743_054_325_021_588_194_075_665_960_171_838_131_772_227_885_159_387_073, + 1_646_015_993_121_829_755_895_883_253_076_789_309_308_090_876_275_172_350_194_834_453_434_199_515_639_474_951_814_226_234_213_676_147_507_404_483_718_679 + ) + @etas [@eta_1, @eta_2, @eta_3, @eta_4] + + # Positive eighth roots of unity for sqrt_division_FQ2 + @rv1 1_028_732_146_235_106_349_975_324_479_215_795_277_384_839_936_929_757_896_155_643_118_032_610_843_298_655_225_875_571_310_552_543_014_690_878_354_869_257 + @root_1 Fq2.from_integers(1, 0) + @root_2 Fq2.from_integers(0, 1) + @root_3 Fq2.from_integers(@rv1, @rv1) + @root_4 Fq2.from_integers(@rv1, -@rv1) + @eighth_roots [@root_1, @root_2, @root_3, @root_4] + + # Isogeny map coefficients for 3-isogeny mapping + # X Numerator coefficients + @iso_3_k_1_0_val 889_424_345_604_814_976_315_064_405_719_089_812_568_196_182_208_668_418_962_679_585_805_340_366_775_741_747_653_930_584_250_892_369_786_198_727_235_542 + @iso_3_k_1_0 Fq2.from_integers(@iso_3_k_1_0_val, @iso_3_k_1_0_val) + @iso_3_k_1_1 Fq2.from_integers( + 0, + 2_668_273_036_814_444_928_945_193_217_157_269_437_704_588_546_626_005_256_888_038_757_416_021_100_327_225_242_961_791_752_752_677_109_358_596_181_706_522 + ) + @iso_3_k_1_2 Fq2.from_integers( + 2_668_273_036_814_444_928_945_193_217_157_269_437_704_588_546_626_005_256_888_038_757_416_021_100_327_225_242_961_791_752_752_677_109_358_596_181_706_526, + 1_334_136_518_407_222_464_472_596_608_578_634_718_852_294_273_313_002_628_444_019_378_708_010_550_163_612_621_480_895_876_376_338_554_679_298_090_853_261 + ) + @iso_3_k_1_3 Fq2.from_integers( + 3_557_697_382_419_259_905_260_257_622_876_359_250_272_784_728_834_673_675_850_718_343_221_361_467_102_966_990_615_722_337_003_569_479_144_794_908_942_033, + 0 + ) + @iso_3_x_numerator [@iso_3_k_1_0, @iso_3_k_1_1, @iso_3_k_1_2, @iso_3_k_1_3] + + # X Denominator coefficients + @iso_3_k_2_0 Fq2.from_integers( + 0, + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_559_715 + ) + @iso_3_k_2_1 Fq2.from_integers( + 12, + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_559_775 + ) + @iso_3_k_2_2 @one + @iso_3_k_2_3 @zero + @iso_3_x_denominator [@iso_3_k_2_0, @iso_3_k_2_1, @iso_3_k_2_2, @iso_3_k_2_3] + + # Y Numerator coefficients + @iso_3_k_3_0_val 3_261_222_600_550_988_246_488_569_487_636_662_646_083_386_001_431_784_202_863_158_481_286_248_011_511_053_074_731_078_808_919_938_689_216_061_999_863_558 + @iso_3_k_3_0 Fq2.from_integers(@iso_3_k_3_0_val, @iso_3_k_3_0_val) + @iso_3_k_3_1 Fq2.from_integers( + 0, + 889_424_345_604_814_976_315_064_405_719_089_812_568_196_182_208_668_418_962_679_585_805_340_366_775_741_747_653_930_584_250_892_369_786_198_727_235_518 + ) + @iso_3_k_3_2 Fq2.from_integers( + 2_668_273_036_814_444_928_945_193_217_157_269_437_704_588_546_626_005_256_888_038_757_416_021_100_327_225_242_961_791_752_752_677_109_358_596_181_706_524, + 1_334_136_518_407_222_464_472_596_608_578_634_718_852_294_273_313_002_628_444_019_378_708_010_550_163_612_621_480_895_876_376_338_554_679_298_090_853_263 + ) + @iso_3_k_3_3 Fq2.from_integers( + 2_816_510_427_748_580_758_331_037_284_777_117_739_799_287_910_327_449_993_381_818_688_383_577_828_123_182_200_904_113_516_794_492_504_322_962_636_245_776, + 0 + ) + @iso_3_y_numerator [@iso_3_k_3_0, @iso_3_k_3_1, @iso_3_k_3_2, @iso_3_k_3_3] + + # Y Denominator coefficients + @iso_3_k_4_0_val 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_559_355 + @iso_3_k_4_0 Fq2.from_integers(@iso_3_k_4_0_val, @iso_3_k_4_0_val) + @iso_3_k_4_1 Fq2.from_integers( + 0, + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_559_571 + ) + @iso_3_k_4_2 Fq2.from_integers( + 18, + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_559_769 + ) + @iso_3_k_4_3 @one + @iso_3_y_denominator [@iso_3_k_4_0, @iso_3_k_4_1, @iso_3_k_4_2, @iso_3_k_4_3] + + @iso_3_map_coefficients [ + @iso_3_x_numerator, + @iso_3_x_denominator, + @iso_3_y_numerator, + @iso_3_y_denominator + ] + + # Efficient cofactor for G2 (H_EFF_G2) + @h_eff_g2 209_869_847_837_335_686_905_080_341_498_658_477_663_839_067_235_703_451_875_306_851_526_599_783_796_572_738_804_459_333_109_033_834_234_622_528_588_876_978_987_822_447_936_461_846_631_641_690_358_257_586_228_683_615_991_308_971_558_879_306_463_436_166_481 + + # Generator point coordinates in affine form (from ZCash BLS12-381 spec) + # x.c0 = 352701069587466618187139116011060144890029952792775240219908644239793785735715026873347600343865175952761926303160 + @generator_x_c0_int 352_701_069_587_466_618_187_139_116_011_060_144_890_029_952_792_775_240_219_908_644_239_793_785_735_715_026_873_347_600_343_865_175_952_761_926_303_160 + # x.c1 = 3059144344244213709971259814753781636986470325476647558659373206291635324768958432433509563104347017837885763365758 + @generator_x_c1_int 3_059_144_344_244_213_709_971_259_814_753_781_636_986_470_325_476_647_558_659_373_206_291_635_324_768_958_432_433_509_563_104_347_017_837_885_763_365_758 + # y.c0 = 1985150602287291935568054521177171638300868978215655730859378665066344726373823718423869104263333984641494340347905 + @generator_y_c0_int 1_985_150_602_287_291_935_568_054_521_177_171_638_300_868_978_215_655_730_859_378_665_066_344_726_373_823_718_423_869_104_263_333_984_641_494_340_347_905 + # y.c1 = 927553665492332455747201965776037880757740193453592970025027978793976877002675564980949289727957565575433344219582 + @generator_y_c1_int 927_553_665_492_332_455_747_201_965_776_037_880_757_740_193_453_592_970_025_027_978_793_976_877_002_675_564_980_949_289_727_957_565_575_433_344_219_582 + + @generator_x {Fq.from_integer(@generator_x_c0_int), Fq.from_integer(@generator_x_c1_int)} + @generator_y {Fq.from_integer(@generator_y_c0_int), Fq.from_integer(@generator_y_c1_int)} + + @doc """ + Creates a G2 point from Jacobian coordinates. + """ + @spec new(Fq2.t(), Fq2.t(), Fq2.t()) :: t() + def new(x, y, z) do + %{x: x, y: y, z: z} + end + + @doc """ + Returns the point at infinity (identity element). + """ + @spec zero() :: t() + def zero do + new(@one, @one, @zero) + end + + @doc """ + Returns the generator point for G2. + """ + @spec generator() :: t() + def generator do + x = @generator_x + y = @generator_y + z = @one + + new(x, y, z) + end + + @doc """ + Checks if a point is the point at infinity. + """ + @spec is_zero?(t()) :: boolean() + def is_zero?(%{z: z}) do + Fq2.is_zero?(z) + end + + @doc """ + Alias for is_zero?/1 for compatibility. + """ + @spec is_infinity?(t()) :: boolean() + def is_infinity?(point), do: is_zero?(point) + + @doc """ + Checks if a point is on the curve. + """ + @spec is_on_curve?(t()) :: boolean() + def is_on_curve?(point) do + if is_zero?(point) do + true + else + %{x: x, y: y, z: z} = point + + # Check curve equation in Jacobian coordinates: + # y^2 * z - x^3 == b * z^3 + y_squared = Fq2.square(y) + y_squared_z = Fq2.mul(y_squared, z) + + x_cubed = Fq2.mul(Fq2.square(x), x) + + z_cubed = Fq2.mul(Fq2.square(z), z) + b_z_cubed = Fq2.mul(@curve_b_fq2, z_cubed) + + # Check: y^2 * z - x^3 == b * z^3 + # we rearranged: y^2 * z == x^3 + b * z^3 + + Fq2.eq?(y_squared_z, Fq2.add(x_cubed, b_z_cubed)) + end + end + + @doc """ + Doubles a G2 point. + """ + @spec double(t()) :: t() + def double(point) do + if is_zero?(point) do + zero() + else + %{x: x, y: y, z: z} = point + + # Point doubling algorithm: + # W = 3 * x * x + w = Fq2.mul_scalar(Fq2.square(x), 3) + + # S = y * z + s = Fq2.mul(y, z) + + # B = x * y * S + b = Fq2.mul(Fq2.mul(x, y), s) + + # H = W * W - 8 * B + eight_b = Fq2.mul_scalar(b, 8) + h = Fq2.sub(Fq2.square(w), eight_b) + + # S_squared = S * S + s_squared = Fq2.square(s) + + # newx = 2 * H * S + newx = Fq2.mul(Fq2.mul_scalar(h, 2), s) + + # newy = W * (4 * B - H) - 8 * y * y * S_squared + four_b = Fq2.mul_scalar(b, 4) + four_b_minus_h = Fq2.sub(four_b, h) + y_squared = Fq2.square(y) + eight_y_squared_s_squared = Fq2.mul_scalar(Fq2.mul(y_squared, s_squared), 8) + newy = Fq2.sub(Fq2.mul(w, four_b_minus_h), eight_y_squared_s_squared) + + # newz = 8 * S * S_squared + newz = Fq2.mul_scalar(Fq2.mul(s, s_squared), 8) + + new(newx, newy, newz) + end + end + + @doc """ + Adds two G2 points. + """ + @spec add(t(), t()) :: t() + def add(p1, p2) do + cond do + is_zero?(p1) -> p2 + is_zero?(p2) -> p1 + eq?(p1, p2) -> double(p1) + true -> add_different(p1, p2) + end + end + + # Helper for adding two different points + # Optimized addition algorithm + defp add_different(p1, p2) do + %{x: x1, y: y1, z: z1} = p1 + %{x: x2, y: y2, z: z2} = p2 + + # Addition algorithm: + # U1 = y2 * z1 + u1 = Fq2.mul(y2, z1) + + # U2 = y1 * z2 + u2 = Fq2.mul(y1, z2) + + # V1 = x2 * z1 + v1 = Fq2.mul(x2, z1) + + # V2 = x1 * z2 + v2 = Fq2.mul(x1, z2) + + # Check if points are the same or inverses + if Fq2.eq?(v1, v2) do + if Fq2.eq?(u1, u2) do + # Same point + double(p1) + else + # Inverse points (return point at infinity) + zero() + end + else + # U = U1 - U2 + u = Fq2.sub(u1, u2) + + # V = V1 - V2 + v = Fq2.sub(v1, v2) + + # V_squared = V * V + v_squared = Fq2.square(v) + + # V_squared_times_V2 = V_squared * V2 + v_squared_times_v2 = Fq2.mul(v_squared, v2) + + # V_cubed = V * V_squared + v_cubed = Fq2.mul(v, v_squared) + + # W = z1 * z2 + w = Fq2.mul(z1, z2) + + # A = U * U * W - V_cubed - 2 * V_squared_times_V2 + u_squared = Fq2.square(u) + u_squared_w = Fq2.mul(u_squared, w) + two_v_squared_times_v2 = Fq2.add(v_squared_times_v2, v_squared_times_v2) + a = Fq2.sub(Fq2.sub(u_squared_w, v_cubed), two_v_squared_times_v2) + + # newx = V * A + newx = Fq2.mul(v, a) + + # newy = U * (V_squared_times_V2 - A) - V_cubed * U2 + newy = + Fq2.sub( + Fq2.mul(u, Fq2.sub(v_squared_times_v2, a)), + Fq2.mul(v_cubed, u2) + ) + + # newz = V_cubed * W + newz = Fq2.mul(v_cubed, w) + + new(newx, newy, newz) + end + end + + @doc """ + Negates a G2 point. + """ + @spec negate(t()) :: t() + def negate(point) do + if is_zero?(point) do + zero() + else + new(point.x, Fq2.neg(point.y), point.z) + end + end + + @doc """ + Hash-to-curve for G2 - maps a message to a point on G2. + Implements hash-to-curve following IRTF standards. + """ + @spec hash_to_curve(binary(), binary()) :: t() + def hash_to_curve(message, ciphersuite) when is_binary(message) and is_binary(ciphersuite) do + # Proper hash-to-curve implementation following IRTF standard + HashToField.hash_to_g2(message, ciphersuite) + end + + @doc """ + Multiplies a G2 point by a scalar using double-and-add algorithm. + """ + @spec mul(t(), Fr.t()) :: t() + def mul(point, scalar) when byte_size(scalar) == 32 do + if is_zero?(point) do + zero() + else + # Convert scalar to integer for bit operations + scalar_int = Fr.to_integer(scalar) + scalar_multiplication(point, scalar_int) + end + end + + @doc """ + Multiplies a G2 point by a large integer (for cofactor clearing). + """ + @spec mul_integer(t(), non_neg_integer()) :: t() + def mul_integer(point, scalar_int) when is_integer(scalar_int) do + if is_zero?(point) do + zero() + else + scalar_multiplication(point, scalar_int) + end + end + + # Scalar multiplication algorithm + defp scalar_multiplication(_point, 0) do + zero() + end + + defp scalar_multiplication(point, 1) do + point + end + + defp scalar_multiplication(point, n) when rem(n, 2) == 0 do + scalar_multiplication(double(point), div(n, 2)) + end + + defp scalar_multiplication(point, n) do + doubled_result = scalar_multiplication(double(point), div(n, 2)) + add(doubled_result, point) + end + + @doc """ + Checks if two G2 points are equal. + """ + @spec eq?(t(), t()) :: boolean() + def eq?(p1, p2) do + cond do + is_zero?(p1) and is_zero?(p2) -> true + is_zero?(p1) or is_zero?(p2) -> false + true -> eq_different?(p1, p2) + end + end + + # Check equality for non-zero points in projective coordinates + # Check: x1 * z2 == x2 * z1 and y1 * z2 == y2 * z1 + defp eq_different?(p1, p2) do + %{x: x1, y: y1, z: z1} = p1 + %{x: x2, y: y2, z: z2} = p2 + + # Check X coordinate equality: X1*Z2 == X2*Z1 + x1_cross = Fq2.mul(x1, z2) + x2_cross = Fq2.mul(x2, z1) + + if Fq2.eq?(x1_cross, x2_cross) do + # Check Y coordinate equality: Y1*Z2 == Y2*Z1 + y1_cross = Fq2.mul(y1, z2) + y2_cross = Fq2.mul(y2, z1) + Fq2.eq?(y1_cross, y2_cross) + else + false + end + end + + @doc """ + Converts a G2 point to affine coordinates. + """ + @spec to_affine(t()) :: {:ok, {Fq2.t(), Fq2.t()}} | {:error, :point_at_infinity} + def to_affine(point) do + if is_zero?(point) do + {:error, :point_at_infinity} + else + %{x: x, y: y, z: z} = point + + case Fq2.inv(z) do + {:ok, z_inv} -> + # Use projective coordinates: (x/z, y/z) instead of Jacobian (x/z^2, y/z^3) + affine_x = Fq2.mul(x, z_inv) + affine_y = Fq2.mul(y, z_inv) + + {:ok, {affine_x, affine_y}} + + {:error, _} -> + {:error, :point_at_infinity} + end + end + end + + @doc """ + Creates a G2 point from affine coordinates. + """ + @spec from_affine(Fq2.t(), Fq2.t()) :: t() + def from_affine(x, y) do + new(x, y, @one) + end + + # Bit shift constants for compressed encoding + @pow_2_381 1 <<< 381 + @pow_2_382 1 <<< 382 + @pow_2_383 1 <<< 383 + # Point at infinity - compression and infinity flags in x_c1 first + # x_c1 has 0xC0 as first byte, then x_c0 is all zeros + @infinity <<0xC0, 0::376, 0::384>> + + @doc """ + Serializes a G2 point to compressed format (96 bytes). + """ + @spec to_compressed_bytes(t()) :: binary() + def to_compressed_bytes(point) do + if is_zero?(point) do + @infinity + else + case to_affine(point) do + {:ok, {x, y}} -> + # x is an Fq2 element {x_c0, x_c1} + {x_c0, x_c1} = x + + # Serialize x coordinates (48 bytes each) + x_c0_bytes = Fq.to_bytes(x_c0) + x_c1_bytes = Fq.to_bytes(x_c1) + + # Determine y coordinate parity for compression + # Parity calculation: (y_im * 2) + {y_c0, y_c1} = y + y_c0_int = Fq.to_integer(y_c0) + y_c1_int = Fq.to_integer(y_c1) + + field_modulus = Constants.field_modulus() + + y_parity = + if y_c1_int > 0 do + div(y_c1_int * 2, field_modulus) + else + div(y_c0_int * 2, field_modulus) + end + + # Set compression flag and parity bit on x_c1 (imaginary part) + <> = x_c1_bytes + # Clear the top 3 bits and set compression flag (0x80) and y parity (0x20 if odd) + compressed_first_byte = (first_byte &&& 0x1F) ||| 0x80 ||| y_parity <<< 5 + + # Store in standard format: x_c1 (imaginary) first, then x_c0 (real) + <> <> rest <> x_c0_bytes + + {:error, _} -> + @infinity + end + end + end + + @doc """ + Deserializes a G2 point from compressed format (96 bytes). + """ + @spec from_compressed_bytes(binary()) :: {:ok, t()} | {:error, :invalid_point} + def from_compressed_bytes(bytes) when byte_size(bytes) == 96 do + # Parse as two 48-byte big-endian integers (z1, z2) + <> = bytes + + z1 = :binary.decode_unsigned(z1_bytes, :big) + z2 = :binary.decode_unsigned(z2_bytes, :big) + + # Extract flags from z1 (top 3 bits) + # Compression flag (should be 1) + c_flag1 = z1 >>> 383 &&& 1 + # Infinity flag + b_flag1 = z1 >>> 382 &&& 1 + # Y parity flag + a_flag1 = z1 >>> 381 &&& 1 + + # Check compression flag + if c_flag1 != 1 do + {:error, :invalid_point} + else + # Point at infinity check + if b_flag1 == 1 do + # Point at infinity should have a_flag1 == 0 and specific pattern + if a_flag1 == 0 do + # Check for proper infinity encoding + # 0xC0 prefix + expected_z1 = @pow_2_383 ||| @pow_2_382 + + if z1 == expected_z1 and z2 == 0 do + {:ok, zero()} + else + {:error, :invalid_point} + end + else + {:error, :invalid_point} + end + else + # Extract x coordinate (clear top 3 bits from z1) + # x1 is imaginary part + x1 = z1 &&& @pow_2_381 - 1 + # x2 is real part + x2 = z2 + + # Check field bounds + field_modulus = Constants.field_modulus() + + if x1 >= field_modulus or x2 >= field_modulus do + {:error, :invalid_point} + else + # Create Fq elements and build x coordinate + x_real = Fq.from_integer(x2) + x_imag = Fq.from_integer(x1) + # x = x2 + x1*u (real + imaginary*u) + x = {x_real, x_imag} + + # Compute y coordinate from curve equation: y² = x³ + 4(1 + u) + x_cubed = Fq2.mul(Fq2.square(x), x) + y_squared = Fq2.add(x_cubed, @curve_b_fq2) + + {is_valid, y} = sqrt_division_fq2(y_squared, @one) + + if is_valid do + # Choose correct y based on parity + {y_real, y_imag} = y + y_real_int = Fq.to_integer(y_real) + y_imag_int = Fq.to_integer(y_imag) + + computed_parity = + if y_imag_int > 0 do + div(y_imag_int * 2, field_modulus) + else + div(y_real_int * 2, field_modulus) + end + + final_y = + if computed_parity == a_flag1 do + y + else + Fq2.neg(y) + end + + point = from_affine(x, final_y) + + # Verify the point is on the curve + if is_on_curve?(point) do + {:ok, point} + else + {:error, :invalid_point} + end + else + {:error, :invalid_point} + end + end + end + end + end + + def from_compressed_bytes(_), do: {:error, :invalid_point} + + @doc """ + Optimized SSWU Map for G2. + Maps an FQ2 element to a point on the 3-isogenous curve. + """ + @spec sswu_map(Fq2.t()) :: {Fq2.t(), Fq2.t(), Fq2.t()} + def sswu_map(t) do + # t^2 + t2 = Fq2.square(t) + + # ISO_3_Z * t^2 + iso_3_z_t2 = Fq2.mul(@iso_3_z, t2) + + # Z * t^2 + Z^2 * t^4 + temp = Fq2.add(iso_3_z_t2, Fq2.square(iso_3_z_t2)) + + # -a(Z * t^2 + Z^2 * t^4) + denominator = Fq2.neg(Fq2.mul(@iso_3_a, temp)) + + # Z * t^2 + Z^2 * t^4 + 1 + temp = Fq2.add(temp, @one) + + # b(Z * t^2 + Z^2 * t^4 + 1) + numerator = Fq2.mul(@iso_3_b, temp) + + # Exceptional case: if denominator is zero + denominator = + if Fq2.is_zero?(denominator) do + Fq2.mul(@iso_3_z, @iso_3_a) + else + denominator + end + + # v = D^3 + v = Fq2.pow(denominator, 3) + + # u = N^3 + a * N * D^2 + b * D^3 + numerator_cubed = Fq2.pow(numerator, 3) + denominator_squared = Fq2.square(denominator) + term1 = Fq2.mul(@iso_3_a, Fq2.mul(numerator, denominator_squared)) + term2 = Fq2.mul(@iso_3_b, v) + u = Fq2.add(Fq2.add(numerator_cubed, term1), term2) + + # Attempt y = sqrt(u / v) + {success, sqrt_candidate} = sqrt_division_fq2(u, v) + y = sqrt_candidate + + # Handle case where (u / v) is not square + # sqrt_candidate(x1) = sqrt_candidate(x0) * t^3 + sqrt_candidate = Fq2.mul(sqrt_candidate, Fq2.pow(t, 3)) + + # u(x1) = Z^3 * t^6 * u(x0) + u = Fq2.mul(Fq2.pow(iso_3_z_t2, 3), u) + + success_2 = check_eta_candidates(sqrt_candidate, v, u) + + y = + if success_2 do + find_eta_solution(sqrt_candidate, v, u) + else + y + end + + if not success and not success_2 do + raise "Hash to Curve - Optimized SWU failure" + end + + numerator = + if not success do + Fq2.mul(numerator, iso_3_z_t2) + else + numerator + end + + # Sign correction + y = + if Fq2.sgn0(t) != Fq2.sgn0(y) do + Fq2.neg(y) + else + y + end + + y = Fq2.mul(y, denominator) + + {numerator, y, denominator} + end + + # Square root division for FQ2 + defp sqrt_division_fq2(u, v) do + temp1 = Fq2.mul(u, Fq2.pow(v, 7)) + temp2 = Fq2.mul(temp1, Fq2.pow(v, 8)) + + # gamma = uv^7 * (uv^15)^((p^2 - 9) / 16) + gamma = Fq2.mul(Fq2.pow(temp2, @p_minus_9_div_16), temp1) + + # Check for valid root using eighth roots of unity + {is_valid_root, result} = check_eighth_roots(gamma, v, u) + + {is_valid_root, result} + end + + defp check_eighth_roots(gamma, v, u) do + Enum.reduce_while(@eighth_roots, {false, gamma}, fn root, {is_valid, result} -> + if is_valid do + {:halt, {is_valid, result}} + else + sqrt_candidate = Fq2.mul(root, gamma) + temp = Fq2.sub(Fq2.mul(Fq2.square(sqrt_candidate), v), u) + + if Fq2.is_zero?(temp) do + {:halt, {true, sqrt_candidate}} + else + {:cont, {false, gamma}} + end + end + end) + end + + defp check_eta_candidates(sqrt_candidate, v, u) do + Enum.any?(@etas, fn eta -> + eta_sqrt_candidate = Fq2.mul(eta, sqrt_candidate) + temp = Fq2.sub(Fq2.mul(Fq2.square(eta_sqrt_candidate), v), u) + Fq2.is_zero?(temp) + end) + end + + defp find_eta_solution(sqrt_candidate, v, u) do + Enum.reduce_while(@etas, nil, fn eta, _acc -> + eta_sqrt_candidate = Fq2.mul(eta, sqrt_candidate) + temp = Fq2.sub(Fq2.mul(Fq2.square(eta_sqrt_candidate), v), u) + + if Fq2.is_zero?(temp) do + {:halt, eta_sqrt_candidate} + else + {:cont, nil} + end + end) + end + + @doc """ + Isogeny mapping from 3-isogenous curve to G2. + 3-isogeny mapping from E'(Fq2) to E(Fq2). + """ + @spec iso_map_g2(Fq2.t(), Fq2.t(), Fq2.t()) :: {Fq2.t(), Fq2.t(), Fq2.t()} + def iso_map_g2(x, y, z) do + # x-numerator, x-denominator, y-numerator, y-denominator + mapped_values = [@zero, @zero, @zero, @zero] + z_powers = [z, Fq2.square(z), Fq2.pow(z, 3)] + + # Horner Polynomial Evaluation for each coefficient set + mapped_values = + Enum.with_index(@iso_3_map_coefficients) + |> Enum.reduce(mapped_values, fn {k_i, i}, acc -> + # Start with the highest degree coefficient (last element) + result = List.last(k_i) + + # Evaluate polynomial using Horner's method + result = + k_i + |> Enum.reverse() + # Skip the last element we already used + |> Enum.drop(1) + |> Enum.with_index() + |> Enum.reduce(result, fn {k_i_j, j}, poly_acc -> + z_power = Enum.at(z_powers, j) + term = Fq2.mul(z_power, k_i_j) + Fq2.add(Fq2.mul(poly_acc, x), term) + end) + + List.replace_at(acc, i, result) + end) + + [x_num, x_den, y_num, y_den] = mapped_values + + # y-numerator * y and y-denominator * z + y_num = Fq2.mul(y_num, y) + y_den = Fq2.mul(y_den, z) + + # Final coordinates + # x-denominator * y-denominator + z_g2 = Fq2.mul(x_den, y_den) + # x-numerator * y-denominator + x_g2 = Fq2.mul(x_num, y_den) + # x-denominator * y-numerator + y_g2 = Fq2.mul(x_den, y_num) + + {x_g2, y_g2, z_g2} + end + + @doc """ + Clear the cofactor of a G2 point to map it to the prime-order subgroup. + Uses the efficient cofactor H_EFF_G2. + """ + @spec clear_cofactor(t()) :: t() + def clear_cofactor(point) do + mul_integer(point, @h_eff_g2) + end +end diff --git a/lib/crypto/bls/hash_to_field.ex b/lib/crypto/bls/hash_to_field.ex new file mode 100644 index 0000000..966373b --- /dev/null +++ b/lib/crypto/bls/hash_to_field.ex @@ -0,0 +1,111 @@ +defmodule Tezex.Crypto.BLS.HashToField do + @moduledoc """ + Hash-to-Field implementation for BLS12-381 + + Implements the IRTF standard hash-to-curve specification: + https://tools.ietf.org/html/draft-irtf-cfrg-hash-to-curve-09 + """ + + alias Tezex.Crypto.BLS.Fq + alias Tezex.Crypto.BLS.Fq2 + alias Tezex.Crypto.BLS.G2 + + # L parameter for hash-to-field (64 bytes for BLS12-381) + @hash_to_field_l 64 + @modulus Fq.modulus() + + @doc """ + Expand message using XMD (expand message XOR with MD) + Implementation of expand_message_xmd from IRTF spec section 5.4.1 + """ + @spec expand_message_xmd(binary(), binary(), non_neg_integer()) :: binary() + def expand_message_xmd(msg, dst, len_in_bytes) when byte_size(dst) <= 255 do + # SHA-256 digest size + b_in_bytes = 32 + # SHA-256 block size + r_in_bytes = 64 + + # Ceiling division + ell = div(len_in_bytes + b_in_bytes - 1, b_in_bytes) + + if ell > 255 do + raise ArgumentError, "len_in_bytes too large for hash function" + end + + dst_prime = dst <> <> + z_pad = :binary.copy(<<0>>, r_in_bytes) + l_i_b_str = <> + + b_0 = :crypto.hash(:sha256, z_pad <> msg <> l_i_b_str <> <<0>> <> dst_prime) + b_1 = :crypto.hash(:sha256, b_0 <> <<1>> <> dst_prime) + b_list = [b_1] + + b_list = + if ell > 1 do + Enum.reduce(2..ell, b_list, fn i, [b_prev | _rest] = acc -> + xor_result = :crypto.exor(b_0, b_prev) + b_i = :crypto.hash(:sha256, xor_result <> <> <> dst_prime) + [b_i | acc] + end) + |> Enum.reverse() + else + b_list + end + + binary_part(IO.iodata_to_binary(b_list), 0, len_in_bytes) + end + + @doc """ + Hash to field FQ2 - maps a message to FQ2 elements + """ + @spec hash_to_field_fq2(binary(), non_neg_integer(), binary()) :: list(Fq2.t()) + def hash_to_field_fq2(message, count, dst) do + # Extension degree of FQ2 + m = 2 + len_in_bytes = count * m * @hash_to_field_l + pseudo_random_bytes = expand_message_xmd(message, dst, len_in_bytes) + + for i <- 0..(count - 1) do + coeffs = + for j <- 0..(m - 1) do + elem_offset = @hash_to_field_l * (j + i * m) + tv = binary_part(pseudo_random_bytes, elem_offset, @hash_to_field_l) + + # Convert bytes to integer + int_val = :binary.decode_unsigned(tv, :big) + rem(int_val, @modulus) + end + + [c0, c1] = coeffs + Fq2.from_integers(c0, c1) + end + end + + @doc """ + Complete hash-to-curve implementation for G2 following IRTF standard + """ + @spec hash_to_g2(binary(), binary()) :: G2.t() + def hash_to_g2(message, dst \\ "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_") do + # 1. Hash to field - get 2 FQ2 elements + field_elements = hash_to_field_fq2(message, 2, dst) + [u0, u1] = field_elements + + # 2. Map each field element to isogenous curve using SSWU + {x0, y0, z0} = G2.sswu_map(u0) + {x1, y1, z1} = G2.sswu_map(u1) + + # 3. Map from isogenous curve to target curve using isogeny + {x0_mapped, y0_mapped, z0_mapped} = G2.iso_map_g2(x0, y0, z0) + {x1_mapped, y1_mapped, z1_mapped} = G2.iso_map_g2(x1, y1, z1) + + # 4. Create G2 points from projective coordinates + p0 = G2.new(x0_mapped, y0_mapped, z0_mapped) + p1 = G2.new(x1_mapped, y1_mapped, z1_mapped) + + # 5. Add the two points + result = G2.add(p0, p1) + + # 6. Clear cofactor + G2.clear_cofactor(result) + end +end diff --git a/lib/crypto/bls/pairing.ex b/lib/crypto/bls/pairing.ex new file mode 100644 index 0000000..256cc77 --- /dev/null +++ b/lib/crypto/bls/pairing.ex @@ -0,0 +1,345 @@ +defmodule Tezex.Crypto.BLS.Pairing do + @moduledoc """ + BLS12-381 pairing operations implementing the Optimal ATE pairing. + + This module provides the pairing function e: G1 × G2 → GT used for BLS signature verification. + The pairing satisfies the bilinearity property: e(aP, bQ) = e(P, Q)^(ab). + + This is a full implementation of the BLS12-381 pairing using Miller's algorithm + and final exponentiation. + """ + + alias Tezex.Crypto.BLS.Constants + alias Tezex.Crypto.BLS.Fq + alias Tezex.Crypto.BLS.Fq12 + alias Tezex.Crypto.BLS.G1 + alias Tezex.Crypto.BLS.G2 + + @type gt :: Fq12.t() + + @zero Fq.zero() + + @doc """ + Computes the optimal ATE pairing of a G1 point and G2 point. + Returns an element in GT = Fq12. + + This implements the full BLS12-381 pairing algorithm: + 1. Miller loop computation + 2. Final exponentiation + """ + @spec pairing(G1.t(), G2.t()) :: gt() + def pairing(g1_point, g2_point) do + pairing(g1_point, g2_point, true) + end + + @doc """ + Computes the pairing with optional final exponentiation. + """ + @spec pairing(G1.t(), G2.t(), boolean()) :: gt() + def pairing(g1_point, g2_point, final_exponentiate) do + # Handle special cases + if G1.is_zero?(g1_point) or G2.is_zero?(g2_point) do + Fq12.one() + else + # Verify points are on the correct curves + unless G1.is_on_curve?(g1_point) do + raise ArgumentError, "G1 point is not on curve" + end + + unless G2.is_on_curve?(g2_point) do + raise ArgumentError, "G2 point is not on curve" + end + + # Compute Miller loop + result = miller_loop(g2_point, g1_point, final_exponentiate) + + if final_exponentiate do + final_exponentiation(result) + else + result + end + end + end + + @doc """ + Implements the Miller loop for pairing computation. + This is the core of the pairing algorithm. + + Core algorithm for optimal ATE pairing computation. + """ + @spec miller_loop(G2.t(), G1.t(), boolean()) :: gt() + def miller_loop(q_point, p_point, _final_exponentiate) do + # Cast P to Fq12 for line function evaluations + cast_p = cast_point_to_fq12(p_point) + + # Initialize Miller loop variables + # twist_R = twist_Q = twist(Q) + twist_q = apply_twist(q_point) + r_point = q_point + f_num = Fq12.one() + f_den = Fq12.one() + + # Main Miller loop + # for v in pseudo_binary_encoding[62::-1]: + Constants.pseudo_binary_encoding() + # Take first 63 elements (0..62) + |> Enum.take(63) + # Reverse to match [62::-1] + |> Enum.reverse() + |> Enum.reduce({r_point, f_num, f_den}, fn bit, {r, f_n, f_d} -> + # Apply twist to current R for line function + twist_r = apply_twist(r) + + # _n, _d = linefunc(twist_R, twist_R, cast_P) + {line_num, line_den} = line_function_fq12(twist_r, twist_r, cast_p) + + # f_num = f_num * f_num * _n + # f_den = f_den * f_den * _d + new_f_n = Fq12.mul(Fq12.mul(f_n, f_n), line_num) + new_f_d = Fq12.mul(Fq12.mul(f_d, f_d), line_den) + + # R = double(R) + doubled_r = G2.double(r) + + # twist_R = twist(R) + doubled_twist_r = apply_twist(doubled_r) + + # Add step if bit is 1 + if bit == 1 do + # _n, _d = linefunc(twist_R, twist_Q, cast_P) + # Note: using doubled_twist_r which is twist(doubled_R) + {add_line_num, add_line_den} = line_function_fq12(doubled_twist_r, twist_q, cast_p) + + # f_num = f_num * _n + # f_den = f_den * _d + final_f_n = Fq12.mul(new_f_n, add_line_num) + final_f_d = Fq12.mul(new_f_d, add_line_den) + + # R = add(R, Q) + added_r = G2.add(doubled_r, q_point) + + {added_r, final_f_n, final_f_d} + else + {doubled_r, new_f_n, new_f_d} + end + end) + |> then(fn {_final_r, final_f_n, final_f_d} -> + # f = f_num / f_den + Fq12.field_div(final_f_n, final_f_d) + end) + end + + @doc """ + Implements the final exponentiation step of the pairing. + Uses the optimized BLS12-381 specific algorithm instead of naive exponentiation. + """ + @spec final_exponentiation(gt()) :: gt() + def final_exponentiation(miller_result) do + Fq12.optimized_final_exponentiation(miller_result) + end + + # Helper functions + + @doc """ + Casts a G1 point to Fq12 coordinates for line function evaluation. + """ + @spec cast_point_to_fq12(G1.t()) :: %{x: Fq12.t(), y: Fq12.t(), z: Fq12.t()} + def cast_point_to_fq12(%{x: x, y: y, z: z}) do + # Convert Fq coordinates to Fq12 by embedding in the base field + %{ + x: embed_fq_to_fq12(x), + y: embed_fq_to_fq12(y), + z: embed_fq_to_fq12(z) + } + end + + defp embed_fq_to_fq12(fq_element) do + # Embed Fq element as the first coefficient of Fq12 + zero = @zero + Fq12.new([fq_element, zero, zero, zero, zero, zero, zero, zero, zero, zero, zero, zero]) + end + + @doc """ + Computes the line function for Miller's algorithm using Fq12 coordinates. + This version works with twisted points that have Fq12 coordinates. + Returns {numerator, denominator} to avoid unnecessary divisions. + """ + @spec line_function_fq12( + %{x: Fq12.t(), y: Fq12.t(), z: Fq12.t()}, + %{x: Fq12.t(), y: Fq12.t(), z: Fq12.t()}, + %{x: Fq12.t(), y: Fq12.t(), z: Fq12.t()} + ) :: + {Fq12.t(), Fq12.t()} + def line_function_fq12(p1, p2, t) do + %{x: x1, y: y1, z: z1} = p1 + %{x: x2, y: y2, z: z2} = p2 + %{x: xt, y: yt, z: zt} = t + + # Compute slope: m = (y2/z2 - y1/z1) / (x2/z2 - x1/z1) + # Multiply by z1*z2 to avoid divisions + m_num = Fq12.sub(Fq12.mul(y2, z1), Fq12.mul(y1, z2)) + m_den = Fq12.sub(Fq12.mul(x2, z1), Fq12.mul(x1, z2)) + + cond do + not Fq12.is_zero?(m_den) -> + # Regular case: different points + # Line equation: m * (xt - x1) - (yt - y1) = 0 + # Evaluated at T: m * (xt/zt - x1/z1) - (yt/zt - y1/z1) + line_val = + Fq12.sub( + Fq12.mul(m_num, Fq12.sub(Fq12.mul(xt, z1), Fq12.mul(x1, zt))), + Fq12.mul(m_den, Fq12.sub(Fq12.mul(yt, z1), Fq12.mul(y1, zt))) + ) + + denominator = Fq12.mul(Fq12.mul(m_den, zt), z1) + {line_val, denominator} + + Fq12.is_zero?(m_num) -> + # Doubling case: same point or point and its negative + # Tangent slope: m = 3x1^2 / (2y1*z1) + three = embed_fq12_from_integer(3) + two = embed_fq12_from_integer(2) + three_x1_sq = Fq12.mul(three, Fq12.mul(x1, x1)) + two_y1_z1 = Fq12.mul(two, Fq12.mul(y1, z1)) + + line_val = + Fq12.sub( + Fq12.mul(three_x1_sq, Fq12.sub(Fq12.mul(xt, z1), Fq12.mul(x1, zt))), + Fq12.mul(two_y1_z1, Fq12.sub(Fq12.mul(yt, z1), Fq12.mul(y1, zt))) + ) + + denominator = Fq12.mul(Fq12.mul(two_y1_z1, zt), z1) + {line_val, denominator} + + true -> + # Vertical line case + line_val = Fq12.sub(Fq12.mul(xt, z1), Fq12.mul(x1, zt)) + denominator = Fq12.mul(z1, zt) + {line_val, denominator} + end + end + + defp embed_fq12_from_integer(n) do + fq_element = Fq.from_integer(n) + embed_fq_to_fq12(fq_element) + end + + @doc """ + Applies the twist isomorphism to convert G2 points to FQ12. + Field isomorphism from Z[p] / x**2 to Z[p] / x**2 - 2*x + 2 + """ + @spec apply_twist(G2.t()) :: %{x: Fq12.t(), y: Fq12.t(), z: Fq12.t()} + def apply_twist(%{x: x, y: y, z: z}) do + # G2 coordinates are Fq2 elements: {a, b} = a + b*u + {x_a, x_b} = x + {y_a, y_b} = y + {z_a, z_b} = z + + x_coeffs = [Fq.sub(x_a, x_b), x_b] + y_coeffs = [Fq.sub(y_a, y_b), y_b] + z_coeffs = [Fq.sub(z_a, z_b), z_b] + + [x0, x1] = x_coeffs + [y0, y1] = y_coeffs + [z0, z1] = z_coeffs + + nx_coeffs = [@zero, x0, @zero, @zero, @zero, @zero, @zero, x1, @zero, @zero, @zero, @zero] + ny_coeffs = [y0, @zero, @zero, @zero, @zero, @zero, y1, @zero, @zero, @zero, @zero, @zero] + nz_coeffs = [@zero, @zero, @zero, z0, @zero, @zero, @zero, @zero, @zero, z1, @zero, @zero] + + %{ + x: Fq12.new(nx_coeffs), + y: Fq12.new(ny_coeffs), + z: Fq12.new(nz_coeffs) + } + end + + @doc """ + Returns the identity element in GT. + """ + @spec gt_identity() :: gt() + def gt_identity do + Fq12.one() + end + + @doc """ + Checks if a GT element is the identity. + """ + @spec is_identity?(gt()) :: boolean() + def is_identity?(gt_element) do + Fq12.is_one?(gt_element) + end + + @doc """ + Multiplies two GT elements. + """ + @spec gt_mul(gt(), gt()) :: gt() + def gt_mul(a, b) do + Fq12.mul(a, b) + end + + @doc """ + Inverts a GT element. + """ + @spec gt_inv(gt()) :: gt() + def gt_inv(gt_element) do + Fq12.inv(gt_element) + end + + @doc """ + Checks if two GT elements are equal. + """ + @spec gt_eq?(gt(), gt()) :: boolean() + def gt_eq?(a, b) do + Fq12.eq?(a, b) + end + + @doc """ + Performs a pairing check for BLS signature verification. + + Implements the actual BLS verification equation: + e(signature, G1_generator) == e(H(msg), pubkey) + + This is equivalent to checking: + e(signature, G1_generator) * e(H(msg), -pubkey) == 1 + + Uses the full cryptographically secure pairing implementation. + """ + @spec pairing_check(G1.t(), G2.t(), G1.t(), G2.t()) :: boolean() + def pairing_check(pubkey_point, h_msg_point, g1_generator, signature_point) do + # First, ensure all points are valid (not zero and on curve) + valid_points = + not G1.is_zero?(pubkey_point) and + not G2.is_zero?(h_msg_point) and + not G2.is_zero?(signature_point) and + not G1.is_zero?(g1_generator) and + G1.is_on_curve?(pubkey_point) and + G2.is_on_curve?(h_msg_point) and + G2.is_on_curve?(signature_point) and + G1.is_on_curve?(g1_generator) + + if not valid_points do + false + else + try do + # Compute the two pairings for BLS verification + # e1 = e(signature, G1_generator) + e1 = pairing(g1_generator, signature_point, false) + + # e2 = e(H(msg), pubkey) + e2 = pairing(pubkey_point, h_msg_point, false) + + # Final exponentiate the product: (e1 * e2^(-1)) + # This is equivalent to checking e1 == e2 + e2_inv = Fq12.inv(e2) + product = Fq12.mul(e1, e2_inv) + final_result = final_exponentiation(product) + + # Check if the result is 1 (identity in GT) + Fq12.is_one?(final_result) + rescue + _ -> false + end + end + end +end diff --git a/lib/crypto/ecdsa.ex b/lib/crypto/ecdsa.ex index 5841161..c3bbc01 100644 --- a/lib/crypto/ecdsa.ex +++ b/lib/crypto/ecdsa.ex @@ -44,7 +44,8 @@ defmodule Tezex.Crypto.ECDSA do Returns: - public_key [`t:Tezex.Crypto.PublicKey.t/0`]: a struct containing the public point and the curve; """ - @spec decode_public_key(nonempty_binary, :prime256v1 | :secp256k1 | Curve.t()) :: PublicKey.t() + @spec decode_public_key(nonempty_binary(), :prime256v1 | :secp256k1 | Curve.t()) :: + PublicKey.t() def decode_public_key(compressed_pubkey, curve_name) when is_atom(curve_name) do curve = KnownCurves.get_curve_by_name(curve_name) decode_public_key(compressed_pubkey, curve) @@ -54,7 +55,7 @@ defmodule Tezex.Crypto.ECDSA do %PublicKey{point: decode_point(compressed_pubkey, curve), curve: curve} end - @spec decode_point(nonempty_binary, Curve.t()) :: Point.t() + @spec decode_point(nonempty_binary(), Curve.t()) :: Point.t() def decode_point(compressed_pubkey, %Curve{name: :prime256v1} = curve) do prime = curve."P" b = curve."B" @@ -138,8 +139,8 @@ defmodule Tezex.Crypto.ECDSA do Returns: - verified [`t:boolean/0`]: true if message, public key and signature are compatible, false otherwise """ - @spec verify?(nonempty_binary, Signature.t(), PublicKey.t(), list()) :: boolean() - @spec verify?(nonempty_binary, Signature.t(), PublicKey.t()) :: boolean() + @spec verify?(nonempty_binary(), Signature.t(), PublicKey.t(), list()) :: boolean() + @spec verify?(nonempty_binary(), Signature.t(), PublicKey.t()) :: boolean() def verify?(message, signature, public_key, options \\ []) do %{hashfunc: hashfunc} = Enum.into(options, %{hashfunc: fn msg -> :crypto.hash(:sha256, msg) end}) diff --git a/lib/crypto/hmacdrbg.ex b/lib/crypto/hmacdrbg.ex index 176be09..57715e3 100644 --- a/lib/crypto/hmacdrbg.ex +++ b/lib/crypto/hmacdrbg.ex @@ -67,7 +67,7 @@ defmodule Tezex.Crypto.HMACDRBG do %{state | count: 1} end - @spec update(t(), binary | nil) :: t() + @spec update(t(), binary() | nil) :: t() defp update(state, seed) do seed = if is_nil(seed), do: nil, else: :binary.decode_hex(seed) diff --git a/lib/crypto/known_curves.ex b/lib/crypto/known_curves.ex index e27cb33..6a24136 100644 --- a/lib/crypto/known_curves.ex +++ b/lib/crypto/known_curves.ex @@ -9,12 +9,14 @@ defmodule Tezex.Crypto.KnownCurves do @secp256k1name :secp256k1 @prime256v1name :prime256v1 + @p256name :p256 - @spec get_curve_by_name(:prime256v1 | :secp256k1) :: Curve.t() + @spec get_curve_by_name(:prime256v1 | :secp256k1 | :p256) :: Curve.t() def get_curve_by_name(name) do case name do @secp256k1name -> secp256k1() @prime256v1name -> prime256v1() + @p256name -> prime256v1() end end diff --git a/lib/crypto/math.ex b/lib/crypto/math.ex index a21c2cd..e63f8c3 100644 --- a/lib/crypto/math.ex +++ b/lib/crypto/math.ex @@ -2,6 +2,8 @@ defmodule Tezex.Crypto.Math do @moduledoc false + import Bitwise + alias Tezex.Crypto.Point alias Tezex.Crypto.Utils @@ -17,7 +19,7 @@ defmodule Tezex.Crypto.Math do Returns: - `point` [`t:Tezex.Crypto.Point.t/0`]: point that represents the sum of First and Second Point """ - @spec multiply(Point.t(), integer, integer, integer, integer) :: Point.t() + @spec multiply(Point.t(), integer(), integer(), integer(), integer()) :: Point.t() def multiply(p, n, c_n, c_a, c_p) do p |> to_jacobian() @@ -36,7 +38,7 @@ defmodule Tezex.Crypto.Math do Returns: - `point` [`t:Tezex.Crypto.Point.t/0`]: point that represents the sum of First and Second Point """ - @spec add(Point.t(), Point.t(), integer, integer) :: Point.t() + @spec add(Point.t(), Point.t(), integer(), integer()) :: Point.t() def add(p, q, c_a, c_p) do jacobian_add(to_jacobian(p), to_jacobian(q), c_a, c_p) |> from_jacobian(c_p) @@ -60,6 +62,61 @@ defmodule Tezex.Crypto.Math do |> Utils.mod(n) end + @doc """ + Computes modular inverse using extended Euclidean algorithm. + Returns {:ok, inverse} or {:error, :not_invertible}. + """ + @spec mod_inverse(integer(), integer()) :: {:ok, integer()} | {:error, :not_invertible} + def mod_inverse(a, m) do + case extended_gcd(a, m) do + {1, x, _} -> {:ok, rem(x + m, m)} + _ -> {:error, :not_invertible} + end + end + + @doc """ + Extended Euclidean algorithm implementation. + Returns {gcd, x, y} where gcd = a*x + b*y. + """ + @spec extended_gcd(integer(), integer()) :: {integer(), integer(), integer()} + def extended_gcd(a, b) do + extended_gcd(a, b, 1, 0, 0, 1) + end + + @doc """ + Fast modular exponentiation. + """ + @spec mod_pow(integer(), integer(), integer()) :: integer() + def mod_pow(base, exp, mod) do + mod_pow(base, exp, mod, 1) + end + + @doc """ + Generic binary exponentiation using a multiplication function. + Useful for field elements or other algebraic structures. + + - `base`: The base element + - `exp`: The exponent (non-negative integer) + - `one`: The multiplicative identity element + - `mul_fn`: Function to multiply two elements + + Returns: base^exp + """ + @spec binary_pow(any(), non_neg_integer(), any(), (any(), any() -> any())) :: any() + def binary_pow(_base, 0, one, _mul_fn), do: one + def binary_pow(base, 1, _one, _mul_fn), do: base + + def binary_pow(base, exp, one, mul_fn) when rem(exp, 2) == 0 do + squared = mul_fn.(base, base) + binary_pow(squared, div(exp, 2), one, mul_fn) + end + + def binary_pow(base, exp, one, mul_fn) do + squared = mul_fn.(base, base) + half_result = binary_pow(squared, div(exp, 2), one, mul_fn) + mul_fn.(base, half_result) + end + defp inv_operator(lm, hm, low, high) when low > 1 do r = div(high, low) @@ -70,18 +127,48 @@ defmodule Tezex.Crypto.Math do lm end - # Converts point back from Jacobian coordinates + defp extended_gcd(0, b, _, _, u1, v1), do: {b, u1, v1} - # - `p` [`t:Tezex.Crypto.Point.t/0`]: Point you want to add - # - `c_p` [`t:integer/0`]: Prime number in the module of the equation Y^2 = X^3 + c_a*X + B (mod p) + defp extended_gcd(a, b, u0, v0, u1, v1) do + q = div(b, a) + r = rem(b, a) + extended_gcd(r, a, u1 - q * u0, v1 - q * v0, u0, v0) + end - # Returns: - # - `point` [`t:Tezex.Crypto.Point.t/0`]: point in default coordinates - defp to_jacobian(p) do + defp mod_pow(_, 0, _, acc), do: acc + + defp mod_pow(base, exp, mod, acc) do + if (exp &&& 1) == 1 do + mod_pow(rem(base * base, mod), exp >>> 1, mod, rem(acc * base, mod)) + else + mod_pow(rem(base * base, mod), exp >>> 1, mod, acc) + end + end + + @doc """ + Converts a point from affine to Jacobian coordinates. + + - `p` [`t:Tezex.Crypto.Point.t/0`]: Point in affine coordinates (z = 0) + + Returns: + - `point` [`t:Tezex.Crypto.Point.t/0`]: point in Jacobian coordinates (z = 1) + """ + @spec to_jacobian(Point.t()) :: Point.t() + def to_jacobian(p) do %Point{x: p.x, y: p.y, z: 1} end - defp from_jacobian(p, c_p) do + @doc """ + Converts a point from Jacobian to affine coordinates. + + - `p` [`t:Tezex.Crypto.Point.t/0`]: Point in Jacobian coordinates + - `c_p` [`t:integer/0`]: Prime number in the module of the equation Y^2 = X^3 + c_a*X + B (mod p) + + Returns: + - `point` [`t:Tezex.Crypto.Point.t/0`]: point in affine coordinates (z = 0) + """ + @spec from_jacobian(Point.t(), integer()) :: Point.t() + def from_jacobian(p, c_p) do z = inv(p.z, c_p) %Point{ @@ -90,17 +177,20 @@ defmodule Tezex.Crypto.Math do } end - # Doubles a point in elliptic curves + @doc """ + Doubles a point in elliptic curves using Jacobian coordinates. - # - `p` [`t:Tezex.Crypto.Point.t/0`]: Point you want to double - # - `c_p` [`t:integer/0`]: Prime number in the module of the equation Y^2 = X^3 + c_a*X + B (mod p) - # - `c_a` [`t:integer/0`]: Coefficient of the first-order term of the equation Y^2 = X^3 + c_a*X + B (mod p) + - `p` [`t:Tezex.Crypto.Point.t/0`]: Point you want to double (in Jacobian coordinates) + - `c_a` [`t:integer/0`]: Coefficient of the first-order term of the equation Y^2 = X^3 + c_a*X + B (mod p) + - `c_p` [`t:integer/0`]: Prime number in the module of the equation Y^2 = X^3 + c_a*X + B (mod p) - # Returns: - # - `point` [`t:Tezex.Crypto.Point.t/0`]: point that represents the sum of First and Second Point - defp jacobian_double(%Point{y: 0}, _c_a, _c_p), do: %Point{x: 0, y: 0, z: 0} + Returns: + - `point` [`t:Tezex.Crypto.Point.t/0`]: doubled point in Jacobian coordinates + """ + @spec jacobian_double(Point.t(), integer(), integer()) :: Point.t() + def jacobian_double(%Point{y: 0}, _c_a, _c_p), do: %Point{x: 0, y: 0, z: 0} - defp jacobian_double(p, c_a, c_p) do + def jacobian_double(p, c_a, c_p) do ysq = Utils.ipow(p.y, 2) |> Utils.mod(c_p) @@ -128,19 +218,22 @@ defmodule Tezex.Crypto.Math do %Point{x: nx, y: ny, z: nz} end - # Adds two points in the elliptic curve - # - `p` [`t:Tezex.Crypto.Point.t/0`]: First Point you want to add - # - `q` [`t:Tezex.Crypto.Point.t/0`]: Second Point you want to add - # - `c_p` [`t:integer/0`]: Prime number in the module of the equation Y^2 = X^3 + c_a*X + B (mod p) - # - `c_a` [`t:integer/0`]: Coefficient of the first-order term of the equation Y^2 = X^3 + c_a*X + B (mod p) + @doc """ + Adds two points in elliptic curves using Jacobian coordinates. - # Returns: - # - `point` [`t:Tezex.Crypto.Point.t/0`]: point that represents the sum of first and second Point + - `p` [`t:Tezex.Crypto.Point.t/0`]: First Point you want to add (in Jacobian coordinates) + - `q` [`t:Tezex.Crypto.Point.t/0`]: Second Point you want to add (in Jacobian coordinates) + - `c_a` [`t:integer/0`]: Coefficient of the first-order term of the equation Y^2 = X^3 + c_a*X + B (mod p) + - `c_p` [`t:integer/0`]: Prime number in the module of the equation Y^2 = X^3 + c_a*X + B (mod p) + + Returns: + - `point` [`t:Tezex.Crypto.Point.t/0`]: point that represents the sum of first and second Point in Jacobian coordinates + """ @spec jacobian_add(Point.t(), Point.t(), integer(), integer()) :: Point.t() - defp jacobian_add(%Point{y: 0}, q, _c_a, _c_p), do: q - defp jacobian_add(p, %Point{y: 0}, _c_a, _c_p), do: p + def jacobian_add(%Point{y: 0}, q, _c_a, _c_p), do: q + def jacobian_add(p, %Point{y: 0}, _c_a, _c_p), do: p - defp jacobian_add(p, q, c_a, c_p) do + def jacobian_add(p, q, c_a, c_p) do u1 = (p.x * Utils.ipow(q.z, 2)) |> Utils.mod(c_p) @@ -206,11 +299,11 @@ defmodule Tezex.Crypto.Math do # Returns: # - `point` [`t:Tezex.Crypto.Point.t/0`]: point that represents the sum of First and Second Point @spec jacobian_multiply(Point.t(), integer(), integer(), integer(), integer()) :: Point.t() - defp jacobian_multiply(_p, 0, _c_n, _c_a, _c_p) do + def jacobian_multiply(_p, 0, _c_n, _c_a, _c_p) do %Point{x: 0, y: 0, z: 1} end - defp jacobian_multiply(p, 1, _c_n, _c_a, _c_p) do + def jacobian_multiply(p, 1, _c_n, _c_a, _c_p) do if p.y == 0 do %Point{x: 0, y: 0, z: 1} else @@ -218,7 +311,7 @@ defmodule Tezex.Crypto.Math do end end - defp jacobian_multiply(p, n, c_n, c_a, c_p) when n < 0 or n >= c_n do + def jacobian_multiply(p, n, c_n, c_a, c_p) when n < 0 or n >= c_n do if p.y == 0 do %Point{x: 0, y: 0, z: 1} else @@ -226,16 +319,16 @@ defmodule Tezex.Crypto.Math do end end - defp jacobian_multiply(%Point{y: 0}, _n, _c_n, _c_a, _c_p) do + def jacobian_multiply(%Point{y: 0}, _n, _c_n, _c_a, _c_p) do %Point{x: 0, y: 0, z: 1} end - defp jacobian_multiply(p, n, c_n, c_a, c_p) when rem(n, 2) == 0 do + def jacobian_multiply(p, n, c_n, c_a, c_p) when rem(n, 2) == 0 do jacobian_multiply(p, div(n, 2), c_n, c_a, c_p) |> jacobian_double(c_a, c_p) end - defp jacobian_multiply(p, n, c_n, c_a, c_p) do + def jacobian_multiply(p, n, c_n, c_a, c_p) do jacobian_multiply(p, div(n, 2), c_n, c_a, c_p) |> jacobian_double(c_a, c_p) |> jacobian_add(p, c_a, c_p) diff --git a/lib/crypto/nacl.ex b/lib/crypto/nacl.ex new file mode 100644 index 0000000..1e720e1 --- /dev/null +++ b/lib/crypto/nacl.ex @@ -0,0 +1,44 @@ +defmodule Tezex.Crypto.NaCl do + @moduledoc """ + NaCl-compatible cryptographic functions for Tezos encrypted key handling. + + This module implements the crypto_secretbox_open functionality needed to decrypt + Tezos encrypted private keys that use the NaCl/Sodium encryption standard. + """ + + @doc """ + Opens (decrypts) a NaCl secretbox using XSalsa20-Poly1305. + + ## Parameters + + - `ciphertext` - The encrypted data (includes auth tag) + - `nonce` - 24-byte nonce (usually zeros for Tezos key encryption) + - `key` - 32-byte encryption key derived from passphrase + + ## Returns + + - `{:ok, plaintext}` - Successfully decrypted data + - `{:error, reason}` - Decryption failed + """ + @spec crypto_secretbox_open(binary(), binary(), binary()) :: + {:ok, binary()} | {:error, :decryption_failed} + def crypto_secretbox_open(_ciphertext, nonce, _key) when byte_size(nonce) != 24 do + {:error, :invalid_nonce_length} + end + + def crypto_secretbox_open(_ciphertext, _nonce, key) when byte_size(key) != 32 do + {:error, :invalid_key_length} + end + + def crypto_secretbox_open(ciphertext, nonce, key) do + try do + # Use kcl library for proper NaCl secretbox decryption + case Kcl.secretunbox(ciphertext, nonce, key) do + plaintext when is_binary(plaintext) -> {:ok, plaintext} + :error -> {:error, :decryption_failed} + end + rescue + _ -> {:error, :decryption_failed} + end + end +end diff --git a/lib/crypto/point.ex b/lib/crypto/point.ex index 6e16f2e..31e3eca 100644 --- a/lib/crypto/point.ex +++ b/lib/crypto/point.ex @@ -10,9 +10,9 @@ defmodule Tezex.Crypto.Point do defstruct [:x, :y, z: 0] @type t :: %__MODULE__{ - x: non_neg_integer, - y: non_neg_integer, - z: non_neg_integer + x: non_neg_integer(), + y: non_neg_integer(), + z: non_neg_integer() } @spec is_at_infinity?(__MODULE__.t()) :: boolean() diff --git a/lib/crypto/private_key.ex b/lib/crypto/private_key.ex index 44a77f1..a0cd6d5 100644 --- a/lib/crypto/private_key.ex +++ b/lib/crypto/private_key.ex @@ -9,9 +9,11 @@ defmodule Tezex.Crypto.PrivateKey do - `:curve` [`t:Tezex.Crypto.Curve.t/0`]: public key curve information. """ + alias Tezex.Crypto.Base58Check alias Tezex.Crypto.Curve alias Tezex.Crypto.KnownCurves alias Tezex.Crypto.Math + alias Tezex.Crypto.NaCl alias Tezex.Crypto.PrivateKey alias Tezex.Crypto.PublicKey alias Tezex.Crypto.Utils @@ -117,4 +119,213 @@ defmodule Tezex.Crypto.PrivateKey do curve: curve } end + + @doc """ + Creates a private key from a Tezos encoded key string. + + Supports both encrypted and unencrypted private keys in the formats: + - edsk... (Ed25519 seed or secret key) + - edesk... (Ed25519 encrypted seed) + - spsk... (Secp256k1 secret key) + - spesk... (Secp256k1 encrypted secret key) + - p2sk... (P256 secret key) + - p2esk... (P256 encrypted secret key) + - BLsk... (BLS12-381 secret key) + - BLesk... (BLS12-381 encrypted secret key) + + ## Parameters + + - `encoded_key` - Base58-encoded private key string + - `passphrase` - Passphrase for encrypted keys (optional) + + ## Returns + + - `{:ok, private_key}` - Successfully parsed private key + - `{:error, reason}` - Parsing failed + """ + @spec from_encoded_key(String.t(), String.t() | nil) :: {:ok, t()} | {:error, atom()} + def from_encoded_key(encoded_key, passphrase \\ nil) when is_binary(encoded_key) do + try do + {:ok, from_encoded_key!(encoded_key, passphrase)} + rescue + e in RuntimeError -> {:error, String.to_atom(e.message)} + _ -> {:error, :invalid_key} + end + end + + @doc """ + Creates a private key from a Tezos encoded key string (raises on error). + + ## Parameters + + - `encoded_key` - Base58-encoded private key string + - `passphrase` - Passphrase for encrypted keys (optional) + + ## Returns + + - `private_key` - Successfully parsed private key + + ## Raises + + - `RuntimeError` - When key parsing fails + """ + @spec from_encoded_key!(String.t(), String.t() | nil) :: t() + def from_encoded_key!(encoded_key, passphrase \\ nil) when is_binary(encoded_key) do + encoded_key_bytes = String.to_charlist(encoded_key) |> :erlang.list_to_binary() + + # Parse key format + curve_prefix = binary_part(encoded_key_bytes, 0, 2) + + curve = + case curve_prefix do + "ed" -> :ed25519 + "sp" -> :secp256k1 + "p2" -> :p256 + "BL" -> :bls12_381 + _ -> raise "invalid_curve_prefix" + end + + # Check if encrypted + encrypted? = + case binary_part(encoded_key_bytes, 2, 1) do + "e" -> true + _ -> false + end + + # Check if this is a secret key + key_type = + if encrypted? do + binary_part(encoded_key_bytes, 3, 2) + else + binary_part(encoded_key_bytes, 2, 2) + end + + unless key_type == "sk" do + raise "not_secret_key" + end + + # Validate key length + expected_length = + if encrypted? do + 88 + else + case curve do + # can be 54 (seed) or 98 (full key) + :ed25519 -> 54 + :secp256k1 -> 54 + :p256 -> 54 + :bls12_381 -> 54 + end + end + + unless byte_size(encoded_key_bytes) in [expected_length, 98] do + raise "invalid_key_length" + end + + # Decode from base58 and strip prefix + decoded_key = + try do + # Use decode58! which includes checksum, then validate and remove it + decoded_with_prefix_and_checksum = Base58Check.decode58!(encoded_key_bytes) + + # Find the prefix and expected data length from the encoding table + prefix_info = find_encoding_info(encoded_key) + prefix_len = byte_size(prefix_info.d_prefix) + expected_data_len = prefix_info.d_len + + # Validate total length (prefix + data + 4-byte checksum) + expected_total_len = prefix_len + expected_data_len + 4 + + if byte_size(decoded_with_prefix_and_checksum) != expected_total_len do + raise "invalid_length" + end + + # Extract prefix + data (without checksum) + prefix_and_data = + binary_part(decoded_with_prefix_and_checksum, 0, prefix_len + expected_data_len) + + checksum = + binary_part(decoded_with_prefix_and_checksum, prefix_len + expected_data_len, 4) + + # Validate checksum + computed_checksum = + :crypto.hash(:sha256, :crypto.hash(:sha256, prefix_and_data)) + |> binary_part(0, 4) + + if checksum != computed_checksum do + raise "invalid_checksum" + end + + # Strip the prefix to get just the key data + binary_part(prefix_and_data, prefix_len, expected_data_len) + rescue + _ -> raise "invalid_base58" + end + + # Extract secret key bytes + secret_key_bytes = + if encrypted? do + unless passphrase do + raise "passphrase_required" + end + + decrypt_key(decoded_key, passphrase) + else + decoded_key + end + + # Create private key with appropriate curve + curve_struct = + case curve do + :secp256k1 -> KnownCurves.get_curve_by_name(:secp256k1) + :p256 -> KnownCurves.get_curve_by_name(:p256) + :ed25519 -> raise "ed25519_not_supported" + :bls12_381 -> raise "bls12_381_not_supported" + end + + %PrivateKey{ + secret: secret_key_bytes, + curve: curve_struct + } + end + + # Find encoding information for a given key prefix + defp find_encoding_info(encoded_key) do + # Base58 encoding configurations (from Forge module) + encodings = [ + %{e_prefix: "edsk", e_len: 54, d_prefix: <<13, 15, 58, 7>>, d_len: 32}, + %{e_prefix: "edesk", e_len: 88, d_prefix: <<7, 90, 60, 179, 41>>, d_len: 56}, + %{e_prefix: "spsk", e_len: 54, d_prefix: <<17, 162, 224, 201>>, d_len: 32}, + %{e_prefix: "spesk", e_len: 88, d_prefix: <<9, 237, 241, 174, 150>>, d_len: 56}, + %{e_prefix: "p2sk", e_len: 54, d_prefix: <<16, 81, 238, 189>>, d_len: 32}, + %{e_prefix: "p2esk", e_len: 88, d_prefix: <<9, 48, 57, 115, 171>>, d_len: 56}, + %{e_prefix: "edsk", e_len: 98, d_prefix: <<43, 246, 78, 7>>, d_len: 64}, + %{e_prefix: "BLsk", e_len: 54, d_prefix: <<3, 150, 192, 40>>, d_len: 32}, + %{e_prefix: "BLesk", e_len: 88, d_prefix: <<2, 5, 30, 53, 25>>, d_len: 56} + ] + + Enum.find(encodings, fn encoding -> + byte_size(encoded_key) == encoding.e_len and + String.starts_with?(encoded_key, encoding.e_prefix) + end) || raise "unsupported_key_format" + end + + # Decrypt an encrypted private key using PBKDF2 + NaCl secretbox + defp decrypt_key(encrypted_data, passphrase) do + # Extract salt (first 8 bytes) and encrypted key (remaining 48 bytes) + salt = binary_part(encrypted_data, 0, 8) + encrypted_sk = binary_part(encrypted_data, 8, byte_size(encrypted_data) - 8) + + # Derive encryption key using PBKDF2-HMAC-SHA512 + encryption_key = :crypto.pbkdf2_hmac(:sha512, passphrase, salt, 32768, 32) + + # Decrypt using NaCl secretbox with zero nonce + # 24 bytes of zeros + nonce = <<0::192>> + + case NaCl.crypto_secretbox_open(encrypted_sk, nonce, encryption_key) do + {:ok, decrypted} -> decrypted + {:error, _} -> raise "decryption_failed" + end + end end diff --git a/lib/crypto/utils.ex b/lib/crypto/utils.ex index 6290c16..2e48288 100644 --- a/lib/crypto/utils.ex +++ b/lib/crypto/utils.ex @@ -3,7 +3,7 @@ defmodule Tezex.Crypto.Utils do @moduledoc false import Bitwise - @spec mod(integer, integer) :: non_neg_integer + @spec mod(integer(), integer()) :: non_neg_integer() def mod(x, n) do case rem(x, n) do r when r < 0 -> r + n @@ -11,27 +11,30 @@ defmodule Tezex.Crypto.Utils do end end - @spec mod_add(integer, integer, integer) :: non_neg_integer + @spec mod_add(integer(), integer(), integer()) :: non_neg_integer() def mod_add(left, right, modulus) do mod(left + right, modulus) end - @spec mod_sub(integer, integer, integer) :: non_neg_integer + @spec mod_sub(integer(), integer(), integer()) :: non_neg_integer() def mod_sub(left, right, modulus) do mod(left - right, modulus) end - def ipow(base, p, acc \\ 1) + @spec ipow(integer(), integer()) :: integer() + def ipow(base, p) when is_integer(base) and is_integer(p) do + ipow(base, p, 1) + end - def ipow(base, p, acc) when p > 0 do + defp ipow(base, p, acc) when p > 0 do ipow(base, p - 1, base * acc) end - def ipow(_base, _p, acc) do + defp ipow(_base, _p, acc) do acc end - @spec number_from_string(binary) :: integer + @spec number_from_string(binary()) :: integer() def number_from_string(string) do {parsed_int, ""} = string diff --git a/lib/forge.ex b/lib/forge.ex index e5d065d..49d4c05 100644 --- a/lib/forge.ex +++ b/lib/forge.ex @@ -47,7 +47,7 @@ defmodule Tezex.Forge do # p256 pkh %{e_prefix: "tz3", e_len: 36, d_prefix: <<6, 161, 164>>, d_len: 20}, # BLS-MinPk - %{e_prefix: "tz4", e_len: 36, d_prefix: <<6, 161, 16>>, d_len: 20}, + %{e_prefix: "tz4", e_len: 36, d_prefix: <<6, 161, 166>>, d_len: 20}, # originated address %{e_prefix: "KT1", e_len: 36, d_prefix: <<2, 90, 121>>, d_len: 20}, # tx_rollup_l2_address diff --git a/lib/micheline.ex b/lib/micheline.ex index 2864982..fd2d6ef 100644 --- a/lib/micheline.ex +++ b/lib/micheline.ex @@ -83,7 +83,8 @@ defmodule Tezex.Micheline do |> binary_slice(12..-1//1) :key_hash -> - ("00" <> binary_slice(hex_value, 12..-1//1)) + hex_value + |> binary_slice(12..-1//1) |> Forge.unforge_address(:hex) :address -> diff --git a/mix.exs b/mix.exs index 78c49b4..f345dad 100644 --- a/mix.exs +++ b/mix.exs @@ -77,6 +77,7 @@ defmodule Tezex.MixProject do {:excoveralls, "~> 0.15.1", only: :test}, {:finch, "~> 0.10"}, {:jason, "~> 1.4"}, + {:kcl, "~> 1.4"}, {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false}, {:ssl_verify_fun, "~> 1.1.0", [env: :prod, hex: "ssl_verify_fun", repo: "hexpm"]} ] diff --git a/mix.lock b/mix.lock index e5caf07..7e639e7 100644 --- a/mix.lock +++ b/mix.lock @@ -1,33 +1,39 @@ %{ "base_58_check": {:hex, :base_58_check, "1.0.0", "bbe527bb00ffd9c5b03abdf970aefd9ca36bd3967dcd75efa8a3c2ed1dcec154", [:mix], [], "hexpm", "06b538938722a04680210e889edaf96f4c967251218fbfbafa19ec2702ba489c"}, "blake2": {:hex, :blake2, "1.0.4", "8263c69a191142922bc2510f1ffc0de0ae96e8c3bd5e2ad3fac7e87aed94c8b1", [:mix], [], "hexpm", "e9f4120d163ba14d86304195e50745fa18483e6ad2be94c864ae449bbdd6a189"}, - "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, - "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, - "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.40", "f3534689f6b58f48aa3a9ac850d4f05832654fe257bf0549c08cc290035f70d5", [:mix], [], "hexpm", "cdb34f35892a45325bad21735fadb88033bcb7c4c296a999bde769783f53e46a"}, + "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, + "chacha20": {:hex, :chacha20, "1.0.4", "0359d8f9a32269271044c1b471d5cf69660c362a7c61a98f73a05ef0b5d9eb9e", [:mix], [], "hexpm", "2027f5d321ae9903f1f0da7f51b0635ad6b8819bc7fe397837930a2011bc2349"}, + "curve25519": {:hex, :curve25519, "1.0.5", "f801179424e4012049fcfcfcda74ac04f65d0ffceeb80e7ef1d3352deb09f5bb", [:mix], [], "hexpm", "0fba3ad55bf1154d4d5fc3ae5fb91b912b77b13f0def6ccb3a5d58168ff4192d"}, + "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "ed25519": {:hex, :ed25519, "1.4.3", "d1422c643fb691f8efc65e66c733bcc92338485858a9469f24a528b915809377", [:mix], [], "hexpm", "37f9de6be4a0e67d56f1b69ec2b79d4d96fea78365f45f5d5d344c48cf81d487"}, + "equivalex": {:hex, :equivalex, "1.0.3", "170d9a82ae066e0020dfe1cf7811381669565922eb3359f6c91d7e9a1124ff74", [:mix], [], "hexpm", "46fa311adb855117d36e461b9c0ad2598f72110ad17ad73d7533c78020e045fc"}, "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, - "ex_doc": {:hex, :ex_doc, "0.34.1", "9751a0419bc15bc7580c73fde506b17b07f6402a1e5243be9e0f05a68c723368", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "d441f1a86a235f59088978eff870de2e815e290e44a8bd976fe5d64470a4c9d2"}, + "ex_doc": {:hex, :ex_doc, "0.38.2", "504d25eef296b4dec3b8e33e810bc8b5344d565998cd83914ffe1b8503737c02", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "732f2d972e42c116a70802f9898c51b54916e542cc50968ac6980512ec90f42b"}, "ex_unit_notifier": {:hex, :ex_unit_notifier, "1.3.1", "5f93a9e5c3340c21eeb1b97cd15f9a1734a18a8b5566ec392d7035a1a0b1c1b0", [:mix], [], "hexpm", "87eb1cea911ed1753e1cc046cbf1c7f86af9058e30672a355f0699b41e5e119d"}, "excoveralls": {:hex, :excoveralls, "0.15.3", "54bb54043e1cf5fe431eb3db36b25e8fd62cf3976666bafe491e3fa5e29eba47", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f8eb5d8134d84c327685f7bb8f1db4147f1363c3c9533928234e496e3070114e"}, - "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, - "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, - "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, - "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, + "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, + "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.4.3", "d3f984eeb96fe53b85d20e0b049f03e57d075b5acda3ac8d465c969a2536c17b", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "9a90e868927f7c777689baa16d86f4d0e086d968db5c05d917ccff6d443e58a3"}, - "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "kcl": {:hex, :kcl, "1.4.3", "5e7dcc1e6d70b467cbeabd1ca2a574605233996eb02acf70fe8a651a72e9ef13", [:mix], [{:curve25519, ">= 1.0.4", [hex: :curve25519, repo: "hexpm", optional: false]}, {:ed25519, "~> 1.3", [hex: :ed25519, repo: "hexpm", optional: false]}, {:poly1305, "~> 1.0", [hex: :poly1305, repo: "hexpm", optional: false]}, {:salsa20, "~> 1.0", [hex: :salsa20, repo: "hexpm", optional: false]}], "hexpm", "45be516de04bae67c31ea08099406c86cbedad18a3ded5b931a513e74d4e9ba3"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, - "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, - "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, - "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, + "mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mix_test_watch": {:hex, :mix_test_watch, "1.3.0", "2ffc9f72b0d1f4ecf0ce97b044e0e3c607c3b4dc21d6228365e8bc7c2856dc77", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f9e5edca976857ffac78632e635750d158df14ee2d6185a15013844af7570ffe"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "poly1305": {:hex, :poly1305, "1.0.4", "7cdc8961a0a6e00a764835918cdb8ade868044026df8ef5d718708ea6cc06611", [:mix], [{:chacha20, "~> 1.0", [hex: :chacha20, repo: "hexpm", optional: false]}, {:equivalex, "~> 1.0", [hex: :equivalex, repo: "hexpm", optional: false]}], "hexpm", "e14e684661a5195e149b3139db4a1693579d4659d65bba115a307529c47dbc3b"}, + "salsa20": {:hex, :salsa20, "1.0.4", "404cbea1fa8e68a41bcc834c0a2571ac175580fec01cc38cc70c0fb9ffc87e9b", [:mix], [], "hexpm", "745ddcd8cfa563ddb0fd61e7ce48d5146279a2cf7834e1da8441b369fdc58ac6"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, - "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, - "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, } diff --git a/test/crypto/bls/fq12_test.exs b/test/crypto/bls/fq12_test.exs new file mode 100644 index 0000000..662d2fd --- /dev/null +++ b/test/crypto/bls/fq12_test.exs @@ -0,0 +1,683 @@ +defmodule Tezex.Crypto.BLS.Fq12Test do + use ExUnit.Case, async: true + + alias Tezex.Crypto.BLS.Constants + alias Tezex.Crypto.BLS.Fq + alias Tezex.Crypto.BLS.Fq12 + alias Tezex.Crypto.BLS.Fq2 + + describe "Fq Properties" do + test "pow one on random element equals the same element" do + for _ <- 1..100 do + e = Fq.random() + assert Fq.eq?(Fq.pow(e, 1), e) + end + end + + test "pow element to small powers works correctly" do + # Test basic exponentiation properties instead of complex field theory + for _ <- 1..20 do + x = Fq.random() + + # Test x^2 = x * x + x_squared_pow = Fq.pow(x, 2) + x_squared_mul = Fq.mul(x, x) + assert Fq.eq?(x_squared_pow, x_squared_mul) + + # Test x^3 = x * x * x + x_cubed_pow = Fq.pow(x, 3) + x_cubed_mul = Fq.mul(Fq.mul(x, x), x) + assert Fq.eq?(x_cubed_pow, x_cubed_mul) + end + end + + test "pow element to larger powers works" do + # Test that exponentiation is consistent + for _ <- 1..10 do + x = Fq.random() + + unless Fq.is_zero?(x) do + # Test that x^a * x^b = x^(a+b) + a = :rand.uniform(20) + b = :rand.uniform(20) + + x_pow_a = Fq.pow(x, a) + x_pow_b = Fq.pow(x, b) + product = Fq.mul(x_pow_a, x_pow_b) + + x_pow_sum = Fq.pow(x, a + b) + assert Fq.eq?(product, x_pow_sum) + end + end + end + + test "pow with exponent zero equals one" do + for _ <- 1..50 do + x = Fq.random() + result = Fq.pow(x, 0) + assert Fq.eq?(result, Fq.one()) + end + end + + test "pow inverse relationship" do + # Test x * x^(-1) = 1 using pow and inv + for _ <- 1..20 do + x = Fq.random() + + unless Fq.is_zero?(x) do + case Fq.inv(x) do + {:ok, x_inv} -> + # Test that x^1 * x^(-1) = 1 + x_pow_1 = Fq.pow(x, 1) + product = Fq.mul(x_pow_1, x_inv) + assert Fq.eq?(product, Fq.one()) + + {:error, _} -> + # Skip if inverse doesn't exist + :ok + end + end + end + end + end + + describe "Fq2 Properties" do + test "pow one on random Fq2 element equals the same element" do + for _ <- 1..100 do + e = random_fq2() + assert Fq2.eq?(Fq2.pow(e, 1), e) + end + end + + test "Fq2 multiplicative properties" do + for _ <- 1..50 do + a = random_fq2() + b = random_fq2() + + unless Fq2.is_zero?(a) or Fq2.is_zero?(b) do + # Test that (a * b) * inv(b) = a (when possible) + product = Fq2.mul(a, b) + + case Fq2.inv(b) do + {:ok, b_inv} -> + result = Fq2.mul(product, b_inv) + assert Fq2.eq?(result, a) + + {:error, _} -> + # Skip if b is not invertible + :ok + end + end + end + end + + test "Fq2 additive properties" do + for _ <- 1..100 do + a = random_fq2() + b = random_fq2() + + # Test commutativity: a + b = b + a + sum1 = Fq2.add(a, b) + sum2 = Fq2.add(b, a) + assert Fq2.eq?(sum1, sum2) + + # Test identity: a + 0 = a + sum_zero = Fq2.add(a, Fq2.zero()) + assert Fq2.eq?(sum_zero, a) + + # Test inverse: a + (-a) = 0 + neg_a = Fq2.neg(a) + sum_neg = Fq2.add(a, neg_a) + assert Fq2.eq?(sum_neg, Fq2.zero()) + end + end + + test "Fq2 norm and conjugate properties" do + for _ <- 1..50 do + a = random_fq2() + + # Test that norm(a) = a * conj(a) + norm_a = Fq2.norm(a) + conj_a = Fq2.conjugate(a) + product = Fq2.mul(a, conj_a) + + # norm returns Fq element, product is Fq2, so we compare the real part + {real_part, imag_part} = product + assert Fq.eq?(norm_a, real_part) + assert Fq2.is_zero?({Fq.zero(), imag_part}) or Fq.is_zero?(imag_part) + + # Test conjugate of conjugate: conj(conj(a)) = a + conj_conj_a = Fq2.conjugate(conj_a) + assert Fq2.eq?(conj_conj_a, a) + end + end + end + + describe "is_one tests" do + test "is_one with random Fq value" do + for _ <- 1..100 do + random_fq = Fq.random() + # Random values should almost never be one + refute Fq.is_one?(random_fq) + end + end + + test "is_one with zero" do + refute Fq.is_one?(Fq.zero()) + refute Fq2.is_one?(Fq2.zero()) + end + + test "is_one with actual one" do + assert Fq.is_one?(Fq.one()) + assert Fq2.is_one?(Fq2.one()) + end + + test "Fq2 is_one with random value" do + for _ <- 1..100 do + random_fq2 = random_fq2() + # Random Fq2 values should almost never be one + refute Fq2.is_one?(random_fq2) + end + end + end + + describe "is_zero tests" do + test "is_zero with random Fq value" do + for _ <- 1..100 do + random_fq = Fq.random() + # Random values should almost never be zero + refute Fq.is_zero?(random_fq) + end + end + + test "is_zero with actual zero" do + assert Fq.is_zero?(Fq.zero()) + assert Fq2.is_zero?(Fq2.zero()) + end + + test "is_zero with one" do + refute Fq.is_zero?(Fq.one()) + refute Fq2.is_zero?(Fq2.one()) + end + + test "Fq2 is_zero with random value" do + for _ <- 1..100 do + random_fq2 = random_fq2() + # Random Fq2 values should almost never be zero + refute Fq2.is_zero?(random_fq2) + end + end + end + + describe "Fq field operations" do + test "field axioms - additive group" do + a = Fq.random() + b = Fq.random() + c = Fq.random() + + # Associativity: (a + b) + c = a + (b + c) + left = Fq.add(Fq.add(a, b), c) + right = Fq.add(a, Fq.add(b, c)) + assert Fq.eq?(left, right) + + # Commutativity: a + b = b + a + sum1 = Fq.add(a, b) + sum2 = Fq.add(b, a) + assert Fq.eq?(sum1, sum2) + + # Identity: a + 0 = a + assert Fq.eq?(Fq.add(a, Fq.zero()), a) + + # Inverse: a + (-a) = 0 + assert Fq.eq?(Fq.add(a, Fq.neg(a)), Fq.zero()) + end + + test "field axioms - multiplicative group" do + a = Fq.random() + b = Fq.random() + c = Fq.random() + + # Associativity: (a * b) * c = a * (b * c) + left = Fq.mul(Fq.mul(a, b), c) + right = Fq.mul(a, Fq.mul(b, c)) + assert Fq.eq?(left, right) + + # Commutativity: a * b = b * a + prod1 = Fq.mul(a, b) + prod2 = Fq.mul(b, a) + assert Fq.eq?(prod1, prod2) + + # Identity: a * 1 = a + assert Fq.eq?(Fq.mul(a, Fq.one()), a) + + # Distributivity: a * (b + c) = a * b + a * c + sum_bc = Fq.add(b, c) + left_dist = Fq.mul(a, sum_bc) + right_dist = Fq.add(Fq.mul(a, b), Fq.mul(a, c)) + assert Fq.eq?(left_dist, right_dist) + end + + test "modular inverse properties" do + for _ <- 1..50 do + a = Fq.random() + + unless Fq.is_zero?(a) do + case Fq.inv(a) do + {:ok, a_inv} -> + # a * a^(-1) = 1 + product = Fq.mul(a, a_inv) + assert Fq.eq?(product, Fq.one()) + + # (a^(-1))^(-1) = a + case Fq.inv(a_inv) do + {:ok, a_inv_inv} -> + assert Fq.eq?(a_inv_inv, a) + + {:error, _} -> + flunk("Double inverse should exist") + end + + {:error, _} -> + flunk("Non-zero element should have inverse") + end + end + end + end + + test "square root properties when they exist" do + for _ <- 1..20 do + a = Fq.random() + + case Fq.sqrt(a) do + {:ok, sqrt_a} -> + # sqrt(a)^2 = a + square = Fq.square(sqrt_a) + assert Fq.eq?(square, a) + + {:error, :no_sqrt} -> + # Not all elements have square roots + :ok + end + end + end + end + + describe "Fq2 extension field operations" do + test "Fq2 quadratic extension properties" do + # Test that u^2 = -1 in Fq2 construction + # Since Fq2 = Fq[u]/(u^2 + 1), we have u^2 + 1 = 0, so u^2 = -1 + + # u is represented as (0, 1) in Fq2 + u = Fq2.new(Fq.zero(), Fq.one()) + u_squared = Fq2.square(u) + minus_one = Fq2.new(Fq.neg(Fq.one()), Fq.zero()) + + assert Fq2.eq?(u_squared, minus_one) + end + + test "Fq2 multiplication formula" do + # (a + bu)(c + du) = (ac - bd) + (ad + bc)u + for _ <- 1..20 do + a = Fq.random() + b = Fq.random() + c = Fq.random() + d = Fq.random() + + fq2_1 = Fq2.new(a, b) + fq2_2 = Fq2.new(c, d) + + product = Fq2.mul(fq2_1, fq2_2) + + # Calculate expected result manually + ac = Fq.mul(a, c) + bd = Fq.mul(b, d) + ad = Fq.mul(a, d) + bc = Fq.mul(b, c) + + expected_real = Fq.sub(ac, bd) + expected_imag = Fq.add(ad, bc) + expected = Fq2.new(expected_real, expected_imag) + + assert Fq2.eq?(product, expected) + end + end + + test "Fq2 from_integers helper" do + a_int = 12345 + b_int = 67890 + + fq2_elem = Fq2.from_integers(a_int, b_int) + {real_part, imag_part} = fq2_elem + + assert Fq.eq?(real_part, Fq.from_integer(a_int)) + assert Fq.eq?(imag_part, Fq.from_integer(b_int)) + end + end + + describe "Fq py_ecc compatibility" do + test "basic arithmetic matches py_ecc" do + # py_ecc: FQ(5) + FQ(3) = 8 + a = Fq.from_integer(5) + b = Fq.from_integer(3) + result = Fq.add(a, b) + assert Fq.to_integer(result) == 8 + + # py_ecc: FQ(5) * FQ(3) = 15 + result = Fq.mul(a, b) + assert Fq.to_integer(result) == 15 + + # py_ecc: FQ(5) - FQ(3) = 2 + result = Fq.sub(a, b) + assert Fq.to_integer(result) == 2 + + # py_ecc: FQ(15) / FQ(3) = 5 + a15 = Fq.from_integer(15) + {:ok, result} = Fq.inv(b) + result = Fq.mul(a15, result) + assert Fq.to_integer(result) == 5 + end + + test "inverse operations match py_ecc" do + # py_ecc: FQ(3) inverse = specific value + b = Fq.from_integer(3) + {:ok, inv_b} = Fq.inv(b) + + expected_inv = + 2_668_273_036_814_444_928_945_193_217_157_269_437_704_588_546_626_005_256_888_038_757_416_021_100_327_225_242_961_791_752_752_677_109_358_596_181_706_525 + + assert Fq.to_integer(inv_b) == expected_inv + + # py_ecc: FQ(3) * inv(FQ(3)) = 1 + result = Fq.mul(b, inv_b) + assert Fq.to_integer(result) == 1 + end + end + + describe "Fq2 py_ecc compatibility" do + test "basic arithmetic matches py_ecc" do + # py_ecc: FQ2([1, 2]) + FQ2([3, 4]) = (4, 6) + a = Fq2.from_integers(1, 2) + b = Fq2.from_integers(3, 4) + result = Fq2.add(a, b) + {real, imag} = Fq2.to_integers(result) + assert {real, imag} == {4, 6} + + # py_ecc: FQ2([1, 2]) - FQ2([3, 4]) = (p-2, p-2) where p is field modulus + result = Fq2.sub(a, b) + {real, imag} = Fq2.to_integers(result) + p = Fq.modulus() + expected_val = p - 2 + assert {real, imag} == {expected_val, expected_val} + end + + test "multiplication matches py_ecc" do + # py_ecc: FQ2([1, 2]) * FQ2([3, 4]) + # = (1*3 - 2*4) + (1*4 + 2*3)*u + # = (3 - 8) + (4 + 6)*u + # = -5 + 10*u + # = (p-5, 10) where p is field modulus + a = Fq2.from_integers(1, 2) + b = Fq2.from_integers(3, 4) + result = Fq2.mul(a, b) + {real, imag} = Fq2.to_integers(result) + + p = Fq.modulus() + # -5 mod p + expected_real = p - 5 + expected_imag = 10 + + assert {real, imag} == {expected_real, expected_imag} + end + + test "division and inverse match py_ecc" do + # py_ecc: FQ2([1, 2]) / FQ2([1, 2]) = (1, 0) + a = Fq2.from_integers(1, 2) + {:ok, inv_a} = Fq2.inv(a) + result = Fq2.mul(a, inv_a) + {real, imag} = Fq2.to_integers(result) + assert {real, imag} == {1, 0} + + # py_ecc: FQ2([1, 2]) / FQ2([3, 4]) gives specific values + b = Fq2.from_integers(3, 4) + {:ok, inv_b} = Fq2.inv(b) + result = Fq2.mul(a, inv_b) + + # Verify by multiplying back + verify = Fq2.mul(result, b) + {verify_real, verify_imag} = Fq2.to_integers(verify) + assert {verify_real, verify_imag} == {1, 2} + end + end + + describe "Fq12 py_ecc compatibility" do + alias Tezex.Crypto.BLS.Fq12 + + # BLS12-381 FQ12 modulus coefficients from py_ecc + @fq12_modulus_coeffs [2, 0, 0, 0, 0, 0, -2, 0, 0, 0, 0, 0] + + test "5 * (1/5) should equal 1" do + # Create element representing 5 + five = Fq12.new([Fq.from_integer(5) | List.duplicate(Fq.zero(), 11)], @fq12_modulus_coeffs) + + # Compute inverse + five_inv = Fq12.inv(five) + + # Multiply should give 1 + result = Fq12.mul(five, five_inv) + expected = Fq12.one(@fq12_modulus_coeffs) + + assert Fq12.eq?(result, expected), "5 * (1/5) should equal 1" + end + + test "x^6 * x^6 = x^12 should reduce correctly" do + # Create element representing x^6: [0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0] + x6_coeffs = List.duplicate(Fq.zero(), 6) ++ [Fq.one()] ++ List.duplicate(Fq.zero(), 5) + x6 = Fq12.new(x6_coeffs, @fq12_modulus_coeffs) + + # x^6 * x^6 = x^12, which should reduce to -2 + 2*x^6 + result = Fq12.mul(x6, x6) + + # Expected: [-2, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0] (mod field_modulus) + expected_coeffs = [ + # -2 at position 0 + Fq.neg(Fq.from_integer(2)), + Fq.zero(), + Fq.zero(), + Fq.zero(), + Fq.zero(), + Fq.zero(), + # 2 at position 6 + Fq.from_integer(2), + Fq.zero(), + Fq.zero(), + Fq.zero(), + Fq.zero(), + Fq.zero() + ] + + expected = Fq12.new(expected_coeffs, @fq12_modulus_coeffs) + + assert Fq12.eq?(result, expected), "x^6 * x^6 should reduce to -2 + 2*x^6" + end + + test "complex polynomial multiplication matches py_ecc" do + # Test case: A = [1, 2, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0] + # B = [4, 5, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0] + # Expected result from py_ecc: [4, 13, 28, 27, 18, 0, 0, 0, 0, 0, 0, 0] + + a_coeffs = + [Fq.from_integer(1), Fq.from_integer(2), Fq.from_integer(3)] ++ + List.duplicate(Fq.zero(), 9) + + b_coeffs = + [Fq.from_integer(4), Fq.from_integer(5), Fq.from_integer(6)] ++ + List.duplicate(Fq.zero(), 9) + + a = Fq12.new(a_coeffs, @fq12_modulus_coeffs) + b = Fq12.new(b_coeffs, @fq12_modulus_coeffs) + + result = Fq12.mul(a, b) + + expected_coeffs = + [ + Fq.from_integer(4), + Fq.from_integer(13), + Fq.from_integer(28), + Fq.from_integer(27), + Fq.from_integer(18) + ] ++ List.duplicate(Fq.zero(), 7) + + expected = Fq12.new(expected_coeffs, @fq12_modulus_coeffs) + + assert Fq12.eq?(result, expected), "Complex multiplication should match py_ecc result" + end + + test "multiplication requiring multiple reductions" do + # Test case from py_ecc trace: A = [0]*10 + [1, 2], B = [0]*8 + [3, 0, 0, 4] + # This will produce terms up to degree 22 that need reduction + + a_coeffs = List.duplicate(Fq.zero(), 10) ++ [Fq.from_integer(1), Fq.from_integer(2)] + + b_coeffs = + List.duplicate(Fq.zero(), 8) ++ + [Fq.from_integer(3), Fq.zero(), Fq.zero(), Fq.from_integer(4)] + + a = Fq12.new(a_coeffs, @fq12_modulus_coeffs) + b = Fq12.new(b_coeffs, @fq12_modulus_coeffs) + + result = Fq12.mul(a, b) + + # The exact expected result from py_ecc (after modular reduction): + # [-12, -24, 0, -16, -32, 0, 6, 12, 0, 8, 16, 0] (all mod field_modulus) + expected_coeffs = [ + # -12 + Fq.neg(Fq.from_integer(12)), + # -24 + Fq.neg(Fq.from_integer(24)), + Fq.zero(), + # -16 + Fq.neg(Fq.from_integer(16)), + # -32 + Fq.neg(Fq.from_integer(32)), + Fq.zero(), + Fq.from_integer(6), + Fq.from_integer(12), + Fq.zero(), + Fq.from_integer(8), + Fq.from_integer(16), + Fq.zero() + ] + + expected = Fq12.new(expected_coeffs, @fq12_modulus_coeffs) + + assert Fq12.eq?(result, expected), + "High-degree multiplication should match py_ecc reduction algorithm" + end + end + + describe "Fq12 field operations" do + test "basic arithmetic operations" do + # x = (1, 0, 0, ..., 0) + x_coeffs = [1] ++ List.duplicate(0, 11) + x = Fq12.from_integers(x_coeffs) + + # f = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12) + f_coeffs = Enum.to_list(1..12) + f = Fq12.from_integers(f_coeffs) + + # fpx = (2, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12) + fpx_coeffs = [2, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] + fpx = Fq12.from_integers(fpx_coeffs) + + # one = 1 + one = Fq12.one() + + # assert x + f == fpx + assert Fq12.eq?(Fq12.add(x, f), fpx) + + # assert f / f == one + f_div_f = Fq12.field_div(f, f) + assert Fq12.eq?(f_div_f, one) + + # assert one / f + x / f == (one + x) / f + one_div_f = Fq12.field_div(one, f) + x_div_f = Fq12.field_div(x, f) + one_plus_x = Fq12.add(one, x) + one_plus_x_div_f = Fq12.field_div(one_plus_x, f) + + assert Fq12.eq?(Fq12.add(one_div_f, x_div_f), one_plus_x_div_f) + + # assert one * f + x * f == (one + x) * f + one_times_f = Fq12.mul(one, f) + x_times_f = Fq12.mul(x, f) + one_plus_x_times_f = Fq12.mul(one_plus_x, f) + + assert Fq12.eq?(Fq12.add(one_times_f, x_times_f), one_plus_x_times_f) + + # Test negative coefficients are positive + neg_coeffs = List.duplicate(-1, 12) + neg_fq12 = Fq12.from_integers(neg_coeffs) + %{coeffs: coeffs} = neg_fq12 + + coeffs + |> Enum.map(&Fq.to_integer/1) + |> Enum.each(fn z -> assert z > 0 end) + end + end + + describe "Fq field operations - known value tests" do + test "basic arithmetic operations with specific values" do + # Test values + a = Fq.from_integer(123) + b = Fq.from_integer(456) + + # Addition: 123 + 456 = 579 + assert Fq.to_integer(Fq.add(a, b)) == 579 + + # Multiplication: 123 * 456 = 56088 + assert Fq.to_integer(Fq.mul(a, b)) == 56088 + + # Subtraction: 123 - 456 (with modular arithmetic) + expected_sub = + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_559_454 + + assert Fq.to_integer(Fq.sub(a, b)) == expected_sub + + # Squaring: 123 ** 2 = 15129 + assert Fq.to_integer(Fq.square(a)) == 15129 + end + + test "field modulus matches constants" do + expected_modulus = Constants.field_modulus() + assert Fq.modulus() == expected_modulus + end + + test "negation operation" do + b = Fq.from_integer(456) + + # -456 in field + expected_neg = + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_559_331 + + assert Fq.to_integer(Fq.neg(b)) == expected_neg + end + + test "zero and one elements" do + assert Fq.to_integer(Fq.zero()) == 0 + assert Fq.to_integer(Fq.one()) == 1 + end + + test "large number multiplication" do + large1 = Fq.from_integer(123_456_789_012_345_678_901_234_567_890) + large2 = Fq.from_integer(987_654_321_098_765_432_109_876_543_210) + # Expected result: 121932631137021795226185032733622923332237463801111263526900 + expected = 121_932_631_137_021_795_226_185_032_733_622_923_332_237_463_801_111_263_526_900 + assert Fq.to_integer(Fq.mul(large1, large2)) == expected + end + end + + # Helper function to generate random Fq2 elements + defp random_fq2 do + Fq2.new(Fq.random(), Fq.random()) + end +end diff --git a/test/crypto/bls/fq2_test.exs b/test/crypto/bls/fq2_test.exs new file mode 100644 index 0000000..430863e --- /dev/null +++ b/test/crypto/bls/fq2_test.exs @@ -0,0 +1,382 @@ +defmodule Tezex.Crypto.BLS.Fq2Test do + use ExUnit.Case, async: true + + alias Tezex.Crypto.BLS.Constants + alias Tezex.Crypto.BLS.Fq + alias Tezex.Crypto.BLS.Fq2 + + @field_modulus Constants.field_modulus() + + describe "Fq2 field operations" do + test "basic arithmetic operations" do + # x = (1, 0) + x = Fq2.from_integers(1, 0) + + # f = (1, 2) + f = Fq2.from_integers(1, 2) + + # fpx = (2, 2) + fpx = Fq2.from_integers(2, 2) + + # one = 1 + one = Fq2.one() + + # assert x + f == fpx + assert Fq2.eq?(Fq2.add(x, f), fpx) + + # assert f / f == one + {:ok, f_inv} = Fq2.inv(f) + f_div_f = Fq2.mul(f, f_inv) + assert Fq2.eq?(f_div_f, one) + + # assert one / f + x / f == (one + x) / f + one_div_f = Fq2.mul(one, f_inv) + x_div_f = Fq2.mul(x, f_inv) + one_plus_x = Fq2.add(one, x) + one_plus_x_div_f = Fq2.mul(one_plus_x, f_inv) + + assert Fq2.eq?(Fq2.add(one_div_f, x_div_f), one_plus_x_div_f) + + # assert one * f + x * f == (one + x) * f + one_times_f = Fq2.mul(one, f) + x_times_f = Fq2.mul(x, f) + one_plus_x_times_f = Fq2.mul(one_plus_x, f) + + assert Fq2.eq?(Fq2.add(one_times_f, x_times_f), one_plus_x_times_f) + + # assert x ** (field_modulus**2 - 1) == one + field_modulus_squared_minus_1 = @field_modulus * @field_modulus - 1 + x_pow = Fq2.pow(x, field_modulus_squared_minus_1) + assert Fq2.eq?(x_pow, one) + + # Test negative coefficients are positive + neg_fq2 = Fq2.from_integers(-1, -1) + {z1, z2} = neg_fq2 + z1_int = Fq.to_integer(z1) + z2_int = Fq.to_integer(z2) + assert z1_int > 0 + assert z2_int > 0 + end + end + + describe "Fq2 py_ecc compatibility" do + test "basic construction and coefficient access" do + # Test basic construction matching py_ecc + a = Fq2.from_integers(1, 2) + b = Fq2.from_integers(3, 4) + zero = Fq2.zero() + one = Fq2.one() + + # Check coefficients match py_ecc format + assert Fq2.to_integers(a) == {1, 2} + assert Fq2.to_integers(b) == {3, 4} + assert Fq2.to_integers(zero) == {0, 0} + assert Fq2.to_integers(one) == {1, 0} + end + + test "arithmetic operations match py_ecc" do + # (1, 2) + a = Fq2.from_integers(1, 2) + # (3, 4) + b = Fq2.from_integers(3, 4) + + # Addition: py_ecc gives (4, 6) + add_result = Fq2.add(a, b) + assert Fq2.to_integers(add_result) == {4, 6} + + # Subtraction: Should be equivalent to (1-3, 2-4) = (-2, -2) mod p + sub_result = Fq2.sub(a, b) + {sub_0, sub_1} = Fq2.to_integers(sub_result) + p = Fq2.modulus() + expected_neg_2 = p - 2 + assert sub_0 == expected_neg_2 + assert sub_1 == expected_neg_2 + + # Multiplication: For (1+2i)(3+4i) = 3+4i+6i+8i^2 = 3+10i-8 = -5+10i mod p + mul_result = Fq2.mul(a, b) + {mul_0, mul_1} = Fq2.to_integers(mul_result) + expected_neg_5 = p - 5 + assert mul_0 == expected_neg_5 + assert mul_1 == 10 + + # Scalar multiplication: py_ecc gives (2, 4) + scalar_2 = Fq2.mul_scalar(a, 2) + assert Fq2.to_integers(scalar_2) == {2, 4} + end + + test "equality works correctly" do + a = Fq2.from_integers(1, 2) + b = Fq2.from_integers(3, 4) + a_copy = Fq2.from_integers(1, 2) + zero = Fq2.zero() + zero_copy = Fq2.zero() + one = Fq2.one() + one_copy = Fq2.from_integers(1, 0) + + # Self equality + assert Fq2.eq?(a, a) + assert Fq2.eq?(a, a_copy) + + # Different values not equal + refute Fq2.eq?(a, b) + + # Zero equality + assert Fq2.eq?(zero, zero) + assert Fq2.eq?(zero, zero_copy) + + # One equality + assert Fq2.eq?(one, one_copy) + + # Cross checks + refute Fq2.eq?(zero, one) + refute Fq2.eq?(a, zero) + end + + test "negation matches py_ecc" do + a = Fq2.from_integers(1, 2) + zero = Fq2.zero() + + # Negation of (1, 2) + neg_a = Fq2.neg(a) + {neg_0, neg_1} = Fq2.to_integers(neg_a) + p = Fq2.modulus() + assert neg_0 == p - 1 + assert neg_1 == p - 2 + + # Negation of zero should be zero + neg_zero = Fq2.neg(zero) + assert Fq2.to_integers(neg_zero) == {0, 0} + + # Double negation should equal original + double_neg_a = Fq2.neg(neg_a) + assert Fq2.eq?(double_neg_a, a) + end + + test "large number arithmetic matches py_ecc" do + large_a = Fq2.from_integers(123_456_789, 987_654_321) + large_b = Fq2.from_integers(111_111_111, 222_222_222) + + # Addition: py_ecc gives (234567900, 1209876543) + add_result = Fq2.add(large_a, large_b) + assert Fq2.to_integers(add_result) == {234_567_900, 1_209_876_543} + + # Multiplication: py_ecc gives specific large result + mul_result = Fq2.mul(large_a, large_b) + {mul_0, mul_1} = Fq2.to_integers(mul_result) + + # The exact values from py_ecc + expected_mul_0 = + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_458_276_577_478_321_104 + + expected_mul_1 = 137_174_210_862_825_789 + + assert mul_0 == expected_mul_0 + assert mul_1 == expected_mul_1 + end + + test "field properties are correct" do + # Field modulus should match py_ecc + expected_modulus = Constants.field_modulus() + + assert Fq2.modulus() == expected_modulus + end + + test "additive and multiplicative identity" do + a = Fq2.from_integers(42, 17) + zero = Fq2.zero() + one = Fq2.one() + + # Additive identity + assert Fq2.eq?(Fq2.add(a, zero), a) + assert Fq2.eq?(Fq2.add(zero, a), a) + + # Multiplicative identity + assert Fq2.eq?(Fq2.mul(a, one), a) + assert Fq2.eq?(Fq2.mul(one, a), a) + + # Zero property + assert Fq2.eq?(Fq2.mul(a, zero), zero) + assert Fq2.eq?(Fq2.mul(zero, a), zero) + end + end + + describe "Fq2 advanced operations" do + test "square operation" do + a = Fq2.from_integers(2, 3) + squared = Fq2.square(a) + manual_square = Fq2.mul(a, a) + + assert Fq2.eq?(squared, manual_square) + end + + test "inverse operation when implemented" do + # Skip if inverse not implemented yet + if function_exported?(Fq2, :inv, 1) do + a = Fq2.from_integers(3, 4) + + case Fq2.inv(a) do + {:ok, a_inv} -> + # a * a^(-1) should equal 1 + product = Fq2.mul(a, a_inv) + assert Fq2.eq?(product, Fq2.one()) + + {:error, _} -> + # If no inverse exists, that's also valid + :ok + end + end + end + + test "square root operation when implemented" do + # Skip if sqrt not implemented yet + if function_exported?(Fq2, :sqrt, 1) do + # Test with a perfect square + # Should have square root (2, 0) + a = Fq2.from_integers(4, 0) + + case Fq2.sqrt(a) do + {:ok, sqrt_a} -> + # sqrt(a)^2 should equal a + squared_back = Fq2.square(sqrt_a) + assert Fq2.eq?(squared_back, a) + + {:error, _} -> + # Some values may not have square roots + :ok + end + end + end + end + + describe "Fq2 field operations with known values" do + test "basic arithmetic operations with specific values" do + # Test values + c = Fq2.from_integers(123, 456) + d = Fq2.from_integers(789, 101_112) + + # Addition: (123, 456) + (789, 101112) = (912, 101568) + {add_real, add_imag} = Fq2.to_integers(Fq2.add(c, d)) + assert add_real == 912 + assert add_imag == 101_568 + + # Multiplication: (123, 456) * (789, 101112) + {mul_real, mul_imag} = Fq2.to_integers(Fq2.mul(c, d)) + + expected_mul_real = + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_226_549_762 + + expected_mul_imag = 12_796_560 + assert mul_real == expected_mul_real + assert mul_imag == expected_mul_imag + + # Subtraction: (123, 456) - (789, 101112) + {sub_real, sub_imag} = Fq2.to_integers(Fq2.sub(c, d)) + + expected_sub_real = + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_559_121 + + expected_sub_imag = + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_459_131 + + assert sub_real == expected_sub_real + assert sub_imag == expected_sub_imag + end + + test "squaring operation with known values" do + c = Fq2.from_integers(123, 456) + + # (123, 456) ** 2 + {square_real, square_imag} = Fq2.to_integers(Fq2.square(c)) + + expected_square_real = + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_366_980 + + expected_square_imag = 112_176 + assert square_real == expected_square_real + assert square_imag == expected_square_imag + end + + test "conjugate operation with known values" do + c = Fq2.from_integers(123, 456) + + # conjugate((123, 456)) + {conj_real, conj_imag} = Fq2.to_integers(Fq2.conjugate(c)) + expected_conj_real = 123 + + expected_conj_imag = + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_559_331 + + assert conj_real == expected_conj_real + assert conj_imag == expected_conj_imag + end + + test "zero and one elements with exact values" do + # zero() = (0, 0) + {zero_real, zero_imag} = Fq2.to_integers(Fq2.zero()) + assert zero_real == 0 + assert zero_imag == 0 + + # one() = (1, 0) + {one_real, one_imag} = Fq2.to_integers(Fq2.one()) + assert one_real == 1 + assert one_imag == 0 + end + + test "simple multiplication cases with known results" do + # (1, 2) * (3, 4) = (-5, 10) mod p + a = Fq2.from_integers(1, 2) + b = Fq2.from_integers(3, 4) + {result_real, result_imag} = Fq2.to_integers(Fq2.mul(a, b)) + + expected_real = + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_559_782 + + expected_imag = 10 + assert result_real == expected_real + assert result_imag == expected_imag + end + + test "multiplication with zero and one identities" do + a = Fq2.from_integers(1, 2) + zero = Fq2.zero() + one = Fq2.one() + + # (1, 2) * zero = (0, 0) + {zero_result_real, zero_result_imag} = Fq2.to_integers(Fq2.mul(a, zero)) + assert zero_result_real == 0 + assert zero_result_imag == 0 + + # (1, 2) * one = (1, 2) + {one_result_real, one_result_imag} = Fq2.to_integers(Fq2.mul(a, one)) + assert one_result_real == 1 + assert one_result_imag == 2 + end + + test "extension field properties u^2 = -1" do + # Manual verification: (a + bu)(c + du) = (ac - bd) + (ad + bc)u + # where u^2 = -1 + a_val = 123 + b_val = 456 + c_val = 789 + d_val = 101_112 + + ac = rem(a_val * c_val, Fq.modulus()) + bd = rem(b_val * d_val, Fq.modulus()) + ad = rem(a_val * d_val, Fq.modulus()) + bc = rem(b_val * c_val, Fq.modulus()) + + # Real part: ac - bd (with proper modular arithmetic) + expected_real = rem(ac - bd + Fq.modulus(), Fq.modulus()) + # Imaginary part: ad + bc + expected_imag = rem(ad + bc, Fq.modulus()) + + # Test with our implementation + fq2_a = Fq2.from_integers(a_val, b_val) + fq2_b = Fq2.from_integers(c_val, d_val) + {result_real, result_imag} = Fq2.to_integers(Fq2.mul(fq2_a, fq2_b)) + + assert result_real == expected_real + assert result_imag == expected_imag + end + end +end diff --git a/test/crypto/bls/g1_test.exs b/test/crypto/bls/g1_test.exs new file mode 100644 index 0000000..9486c10 --- /dev/null +++ b/test/crypto/bls/g1_test.exs @@ -0,0 +1,305 @@ +defmodule Tezex.Crypto.BLS.G1Test do + use ExUnit.Case, async: true + import Bitwise + + alias Tezex.Crypto.BLS.Constants + alias Tezex.Crypto.BLS.Fq + alias Tezex.Crypto.BLS.Fr + alias Tezex.Crypto.BLS.G1 + + describe "G1 generator" do + test "has correct coordinates matching py_ecc" do + generator = G1.generator() + + assert {:ok, {x, y}} = G1.to_affine(generator) + + x_int = Fq.to_integer(x) + y_int = Fq.to_integer(y) + + # These values come from py_ecc.optimized_bls12_381.G1 + expected_x = + 3_685_416_753_713_387_016_781_088_315_183_077_757_961_620_795_782_546_409_894_578_378_688_607_592_378_376_318_836_054_947_676_345_821_548_104_185_464_507 + + expected_y = + 1_339_506_544_944_476_473_020_471_379_941_921_221_584_933_875_938_349_620_426_543_736_416_511_423_956_333_506_472_724_655_353_366_534_992_391_756_441_569 + + assert x_int == expected_x + assert y_int == expected_y + end + + test "is on curve" do + generator = G1.generator() + assert G1.is_on_curve?(generator) + end + + test "is not zero" do + generator = G1.generator() + refute G1.is_infinity?(generator) + end + end + + describe "G1 scalar multiplication" do + test "1 * generator equals generator" do + generator = G1.generator() + one = Fr.one() + + result = G1.mul(generator, one) + + assert G1.eq?(result, generator) + end + + test "0 * generator equals zero" do + generator = G1.generator() + zero = Fr.zero() + + result = G1.mul(generator, zero) + + assert G1.is_infinity?(result) + end + + test "2 * generator equals generator + generator" do + generator = G1.generator() + two = Fr.from_integer(2) + + double_via_mul = G1.mul(generator, two) + double_via_add = G1.add(generator, generator) + + assert G1.eq?(double_via_mul, double_via_add) + end + end + + describe "G1 compression/decompression" do + test "round-trip compression preserves point" do + generator = G1.generator() + + compressed = G1.to_compressed_bytes(generator) + assert byte_size(compressed) == 48 + + assert {:ok, decompressed} = G1.from_compressed_bytes(compressed) + assert G1.eq?(generator, decompressed) + end + + test "compressed generator has expected format" do + generator = G1.generator() + compressed = G1.to_compressed_bytes(generator) + + # First byte should have compression flag set (0x80) + <> = compressed + assert (first_byte &&& 0x80) != 0 + end + + test "point at infinity compresses correctly" do + infinity = G1.zero() + compressed = G1.to_compressed_bytes(infinity) + + # Should be 0xC0 followed by zeros + assert compressed == <<0xC0>> <> <<0::376>> + + assert {:ok, decompressed} = G1.from_compressed_bytes(compressed) + assert G1.is_infinity?(decompressed) + end + end + + describe "Fr field operations" do + test "uses big-endian encoding" do + # Test that Fr.from_integer(1) produces big-endian encoding + one = Fr.from_integer(1) + assert one == <<0::248, 1::8>> + + # Test conversion back + assert Fr.to_integer(one) == 1 + end + + test "from_bytes handles big-endian correctly" do + # Big-endian representation of 1 + bytes = <<0::248, 1::8>> + assert {:ok, fr} = Fr.from_bytes(bytes) + assert Fr.to_integer(fr) == 1 + end + end + + describe "G1 equality regression tests" do + test "equality handles different Jacobian representations of same point" do + # This test prevents regression of the equality bug where + # different computation paths to 4G produced different Jacobian coordinates + # but should be recognized as equal points + g1 = G1.generator() + + # Path 1: double(double(G1)) = 4G via doubling + double_g1 = G1.double(g1) + four_g_via_doubling = G1.double(double_g1) + + # Path 2: add(add(double(G1), G1), G1) = 4G via addition + three_g = G1.add(double_g1, g1) + four_g_via_addition = G1.add(three_g, g1) + + # Both should be on curve + assert G1.is_on_curve?(four_g_via_doubling) + assert G1.is_on_curve?(four_g_via_addition) + + # Most importantly: they should be equal despite having different Jacobian coordinates + assert G1.eq?(four_g_via_doubling, four_g_via_addition), + "4G computed via doubling and addition should be equal" + + # Verify they actually have different coordinates (this validates the test) + %{x: x1, y: y1, z: z1} = four_g_via_doubling + %{x: x2, y: y2, z: z2} = four_g_via_addition + + # At least one coordinate should be different (proving they have different representations) + different_coords = not (Fq.eq?(x1, x2) and Fq.eq?(y1, y2) and Fq.eq?(z1, z2)) + assert different_coords, "Test should use points with different Jacobian coordinates" + end + end + + describe "py_ecc compatibility" do + test "scalar multiplication with scalar 5 matches py_ecc" do + # Expected result from py_ecc for 5 * G1 + py_ecc_result_x = + 2_601_793_266_141_653_880_357_945_339_922_727_723_793_268_013_331_457_916_525_213_050_197_274_797_722_760_296_318_099_993_752_923_714_935_161_798_464_476 + + py_ecc_result_y = + 3_498_096_627_312_022_583_321_348_410_616_510_759_186_251_088_555_060_790_999_813_363_211_667_535_344_132_702_692_445_545_590_448_314_959_259_020_805_858 + + seed = <<5::size(256)>> + {:ok, fr_element} = Fr.from_bytes(seed) + + generator = G1.generator() + result = G1.mul(generator, fr_element) + {:ok, {x_aff, y_aff}} = G1.to_affine(result) + + x_int = Fq.to_integer(x_aff) + y_int = Fq.to_integer(y_aff) + + assert x_int == py_ecc_result_x, "Scalar mult result X should match py_ecc" + assert y_int == py_ecc_result_y, "Scalar mult result Y should match py_ecc" + end + + test "compressed public key matches py_ecc format" do + # py_ecc compressed pubkey for privkey=5 + expected_compressed = + <<0xB0, 0xE7, 0x79, 0x1F, 0xB9, 0x72, 0xFE, 0x01, 0x41, 0x59, 0xAA, 0x33, 0xA9, 0x86, + 0x22, 0xDA, 0x3C, 0xDC, 0x98, 0xFF, 0x70, 0x79, 0x65, 0xE5, 0x36, 0xD8, 0x63, 0x6B, + 0x5F, 0xCC, 0x5A, 0xC7, 0xA9, 0x1A, 0x8C, 0x46, 0xE5, 0x9A, 0x00, 0xDC, 0xA5, 0x75, + 0xAF, 0x0F, 0x18, 0xFB, 0x13, 0xDC>> + + seed = <<5::size(256)>> + {:ok, fr_element} = Fr.from_bytes(seed) + + generator = G1.generator() + result = G1.mul(generator, fr_element) + compressed = G1.to_compressed_bytes(result) + + assert compressed == expected_compressed, "Compressed format should match py_ecc" + end + end + + describe "G1 elliptic curve operations" do + test "basic group operations" do + g1 = G1.generator() + + # assert eq(add(add(double(G1), G1), G1), double(double(G1))) + double_g1 = G1.double(g1) + double_double_g1 = G1.double(double_g1) + add_double_g1_g1 = G1.add(double_g1, g1) + add_add_double_g1_g1_g1 = G1.add(add_double_g1_g1, g1) + + assert G1.eq?(add_add_double_g1_g1_g1, double_double_g1) + + # assert not eq(double(G1), G1) + refute G1.eq?(double_g1, g1) + + # assert eq(add(multiply(G1, 9), multiply(G1, 5)), add(multiply(G1, 12), multiply(G1, 2))) + nine = Fr.from_integer(9) + five = Fr.from_integer(5) + twelve = Fr.from_integer(12) + two = Fr.from_integer(2) + + mul_g1_9 = G1.mul(g1, nine) + mul_g1_5 = G1.mul(g1, five) + mul_g1_12 = G1.mul(g1, twelve) + mul_g1_2 = G1.mul(g1, two) + + add_9_5 = G1.add(mul_g1_9, mul_g1_5) + add_12_2 = G1.add(mul_g1_12, mul_g1_2) + + assert G1.eq?(add_9_5, add_12_2) + + # assert is_inf(multiply(G1, curve_order)) + curve_order = Constants.curve_order() + curve_order_fr = Fr.from_integer(curve_order) + mul_g1_order = G1.mul(g1, curve_order_fr) + assert G1.is_infinity?(mul_g1_order) + end + end + + describe "Zero points (identity elements)" do + test "G1 zero point properties" do + g1 = G1.generator() + z1 = G1.zero() + + # assert eq(G1, add(G1, Z1)) + assert G1.eq?(g1, G1.add(g1, z1)) + + # assert eq(Z1, double(Z1)) + assert G1.eq?(z1, G1.double(z1)) + + # assert eq(Z1, multiply(Z1, 0)) + zero = Fr.zero() + one = Fr.one() + two = Fr.from_integer(2) + three = Fr.from_integer(3) + + assert G1.eq?(z1, G1.mul(z1, zero)) + assert G1.eq?(z1, G1.mul(z1, one)) + assert G1.eq?(z1, G1.mul(z1, two)) + assert G1.eq?(z1, G1.mul(z1, three)) + + # assert is_inf(neg(Z1)) + neg_z1 = G1.negate(z1) + assert G1.is_infinity?(neg_z1) + end + end + + describe "Coordinate system operations" do + test "projective coordinate doubling produces known values" do + # Expected Jacobian coordinates for doubled generator + expected_x = + 904_218_658_502_494_312_590_159_247_105_554_874_787_512_476_566_126_303_635_147_587_402_923_829_641_584_622_770_449_558_420_966_780_868_207_684_298_157 + + expected_y = + 3_215_784_584_818_841_779_393_380_451_963_501_913_485_474_208_604_528_267_666_791_755_940_205_624_806_737_197_312_591_247_682_567_521_662_969_376_111_781 + + expected_z = + 645_039_760_431_336_131_708_729_805_154_580_111_675_108_263_123_058_424_684_373_273_230_141_681_090_362_840_405_422_204_836_635_344_492_771_939_114_786 + + # Expected affine coordinates after normalization + expected_normalized_x = + 838_589_206_289_216_005_799_424_730_305_866_328_161_735_431_124_665_289_961_769_162_861_615_689_790_485_775_997_575_391_185_127_590_486_775_437_397_838 + + expected_normalized_y = + 3_450_209_970_729_243_429_733_164_009_999_191_867_485_184_320_918_914_219_895_632_678_707_687_208_996_709_678_363_578_245_114_137_957_452_475_385_814_312 + + # Test our doubling produces the correct Jacobian coordinates + generator = G1.generator() + doubled = G1.double(generator) + %{x: x, y: y, z: z} = doubled + + x_int = Fq.to_integer(x) + y_int = Fq.to_integer(y) + z_int = Fq.to_integer(z) + + assert x_int == expected_x, "Jacobian X should match known value" + assert y_int == expected_y, "Jacobian Y should match known value" + assert z_int == expected_z, "Jacobian Z should match known value" + + # Test our projective coordinate affine conversion + {:ok, {affine_x, affine_y}} = G1.to_affine(doubled) + affine_x_int = Fq.to_integer(affine_x) + affine_y_int = Fq.to_integer(affine_y) + + # Should match normalized coordinates + assert affine_x_int == expected_normalized_x, "Affine X should match known value" + assert affine_y_int == expected_normalized_y, "Affine Y should match known value" + end + end +end diff --git a/test/crypto/bls/g2_test.exs b/test/crypto/bls/g2_test.exs new file mode 100644 index 0000000..4516e06 --- /dev/null +++ b/test/crypto/bls/g2_test.exs @@ -0,0 +1,387 @@ +defmodule Tezex.Crypto.BLS.G2Test do + use ExUnit.Case, async: true + + alias Tezex.Crypto.BLS.Constants + alias Tezex.Crypto.BLS.Fq2 + alias Tezex.Crypto.BLS.Fr + alias Tezex.Crypto.BLS.G2 + + describe "G2 generator" do + test "is on curve" do + generator = G2.generator() + assert G2.is_on_curve?(generator) + end + + test "is not zero" do + generator = G2.generator() + refute G2.is_infinity?(generator) + end + end + + describe "G2 scalar multiplication" do + test "1 * generator equals generator" do + generator = G2.generator() + one = Fr.one() + + result = G2.mul(generator, one) + + assert G2.eq?(result, generator) + end + + test "0 * generator equals zero" do + generator = G2.generator() + zero = Fr.zero() + + result = G2.mul(generator, zero) + + assert G2.is_infinity?(result) + end + end + + describe "G2 compression/decompression" do + test "round-trip compression preserves generator" do + generator = G2.generator() + + compressed = G2.to_compressed_bytes(generator) + assert byte_size(compressed) == 96 + + case G2.from_compressed_bytes(compressed) do + {:ok, decompressed} -> + assert G2.eq?(generator, decompressed) + + {:error, reason} -> + flunk("Failed to decompress G2 generator: #{reason}") + end + end + + test "point at infinity compresses correctly" do + infinity = G2.zero() + compressed = G2.to_compressed_bytes(infinity) + + # Should be 0xC0 in x_c1 followed by zeros, then zeros for x_c0 + assert compressed == <<0xC0, 0::376, 0::384>> + + assert {:ok, decompressed} = G2.from_compressed_bytes(compressed) + assert G2.is_infinity?(decompressed) + end + end + + describe "Fq2 square root" do + test "square root of 1 is 1" do + one = Fq2.one() + + # For debugging - let's check if sqrt is implemented + case Fq2.sqrt(one) do + {:ok, result} -> + # sqrt(1) should be 1 + assert Fq2.eq?(result, one) + + {:error, reason} -> + flunk("Failed to find square root of 1: #{reason}") + end + end + + test "square root verification" do + # Test with a simple value + test_val = Fq2.from_integers(4, 0) + + case Fq2.sqrt(test_val) do + {:ok, sqrt_val} -> + # Verify that sqrt^2 == original + squared = Fq2.square(sqrt_val) + assert Fq2.eq?(squared, test_val) + + {:error, _} -> + # Some values may not have square roots, that's ok + :ok + end + end + end + + describe "hash to curve" do + test "produces valid G2 points" do + test_message = "test message" + ciphersuite = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_AUG_" + + point = G2.hash_to_curve(test_message, ciphersuite) + + assert G2.is_on_curve?(point) + refute G2.is_infinity?(point) + end + end + + describe "G2 equality regression tests" do + test "equality handles different Jacobian representations of same point" do + # This test prevents regression of the equality bug where + # different computation paths produce different Jacobian coordinates + # but should be recognized as equal points (same as G1 test) + g2 = G2.generator() + + # Path 1: double(double(G2)) = 4G via doubling + double_g2 = G2.double(g2) + four_g_via_doubling = G2.double(double_g2) + + # Path 2: add(add(double(G2), G2), G2) = 4G via addition + three_g = G2.add(double_g2, g2) + four_g_via_addition = G2.add(three_g, g2) + + # Both should be on curve + assert G2.is_on_curve?(four_g_via_doubling) + assert G2.is_on_curve?(four_g_via_addition) + + # Most importantly: they should be equal despite having different Jacobian coordinates + assert G2.eq?(four_g_via_doubling, four_g_via_addition), + "4G computed via doubling and addition should be equal" + + # Verify they actually have different coordinates (this validates the test) + %{x: x1, y: y1, z: z1} = four_g_via_doubling + %{x: x2, y: y2, z: z2} = four_g_via_addition + + # At least one coordinate should be different (proving they have different representations) + different_coords = not (Fq2.eq?(x1, x2) and Fq2.eq?(y1, y2) and Fq2.eq?(z1, z2)) + assert different_coords, "Test should use points with different Jacobian coordinates" + end + end + + describe "py_ecc compatibility" do + test "generator coordinates match py_ecc exactly" do + g2 = G2.generator() + + # py_ecc G2 generator values + expected_x_c0 = + 352_701_069_587_466_618_187_139_116_011_060_144_890_029_952_792_775_240_219_908_644_239_793_785_735_715_026_873_347_600_343_865_175_952_761_926_303_160 + + expected_x_c1 = + 3_059_144_344_244_213_709_971_259_814_753_781_636_986_470_325_476_647_558_659_373_206_291_635_324_768_958_432_433_509_563_104_347_017_837_885_763_365_758 + + expected_y_c0 = + 1_985_150_602_287_291_935_568_054_521_177_171_638_300_868_978_215_655_730_859_378_665_066_344_726_373_823_718_423_869_104_263_333_984_641_494_340_347_905 + + expected_y_c1 = + 927_553_665_492_332_455_747_201_965_776_037_880_757_740_193_453_592_970_025_027_978_793_976_877_002_675_564_980_949_289_727_957_565_575_433_344_219_582 + + {x_c0, x_c1} = Fq2.to_integers(g2.x) + {y_c0, y_c1} = Fq2.to_integers(g2.y) + {z_c0, z_c1} = Fq2.to_integers(g2.z) + + assert x_c0 == expected_x_c0, "Generator x_c0 should match py_ecc" + assert x_c1 == expected_x_c1, "Generator x_c1 should match py_ecc" + assert y_c0 == expected_y_c0, "Generator y_c0 should match py_ecc" + assert y_c1 == expected_y_c1, "Generator y_c1 should match py_ecc" + assert z_c0 == 1, "Generator z_c0 should be 1" + assert z_c1 == 0, "Generator z_c1 should be 0" + end + + test "double(G2) Jacobian coordinates match py_ecc exactly" do + g2 = G2.generator() + doubled = G2.double(g2) + + # py_ecc double(G2) Jacobian values + expected_x_c0 = + 2_004_569_552_561_385_659_566_932_407_633_616_698_939_912_674_197_491_321_901_037_400_001_042_336_021_538_860_336_682_240_104_624_979_660_689_237_563_240 + + expected_x_c1 = + 3_955_604_752_108_186_662_342_584_665_293_438_104_124_851_975_447_411_601_471_797_343_177_761_394_177_049_673_802_376_047_736_772_242_152_530_202_962_941 + + expected_y_c0 = + 978_142_457_653_236_052_983_988_388_396_292_566_217_089_069_272_380_812_666_116_929_298_652_861_694_202_207_333_864_830_606_577_192_738_105_844_024_927 + + expected_y_c1 = + 2_248_711_152_455_689_790_114_026_331_322_133_133_284_196_260_289_964_969_465_268_080_325_775_757_898_907_753_181_154_992_709_229_860_715_480_504_777_099 + + expected_z_c0 = + 3_145_673_658_656_250_241_340_817_105_688_138_628_074_744_674_635_286_712_244_193_301_767_486_380_727_788_868_972_774_468_795_689_607_869_551_989_918_920 + + expected_z_c1 = + 968_254_395_890_002_185_853_925_600_926_112_283_510_369_004_782_031_018_144_050_081_533_668_188_797_348_331_621_250_985_545_304_947_843_412_000_516_197 + + %{x: {x_c0, x_c1}, y: {y_c0, y_c1}, z: {z_c0, z_c1}} = doubled + + assert Tezex.Crypto.BLS.Fq.to_integer(x_c0) == expected_x_c0 + assert Tezex.Crypto.BLS.Fq.to_integer(x_c1) == expected_x_c1 + assert Tezex.Crypto.BLS.Fq.to_integer(y_c0) == expected_y_c0 + assert Tezex.Crypto.BLS.Fq.to_integer(y_c1) == expected_y_c1 + assert Tezex.Crypto.BLS.Fq.to_integer(z_c0) == expected_z_c0 + assert Tezex.Crypto.BLS.Fq.to_integer(z_c1) == expected_z_c1 + end + + test "double(G2) affine coordinates match py_ecc" do + g2 = G2.generator() + two_g = G2.double(g2) + + # py_ecc 2G affine values + expected_affine_x_c0 = + 3_419_974_069_068_927_546_093_595_533_691_935_972_093_267_703_063_689_549_934_039_433_172_037_728_172_434_967_174_817_854_768_758_291_501_458_544_631_891 + + expected_affine_x_c1 = + 1_586_560_233_067_062_236_092_888_871_453_626_466_803_933_380_746_149_805_590_083_683_748_120_990_227_823_365_075_019_078_675_272_292_060_187_343_402_359 + + expected_affine_y_c0 = + 678_774_053_046_495_337_979_740_195_232_911_687_527_971_909_891_867_263_302_465_188_023_833_943_429_943_242_788_645_503_130_663_197_220_262_587_963_545 + + expected_affine_y_c1 = + 2_374_407_843_478_705_782_611_042_739_236_452_317_510_200_146_460_567_463_070_514_850_492_917_978_226_342_495_167_066_333_366_894_448_569_891_658_583_283 + + assert {:ok, {affine_x, affine_y}} = G2.to_affine(two_g) + + {x_c0, x_c1} = Fq2.to_integers(affine_x) + {y_c0, y_c1} = Fq2.to_integers(affine_y) + + assert x_c0 == expected_affine_x_c0 + assert x_c1 == expected_affine_x_c1 + assert y_c0 == expected_affine_y_c0 + assert y_c1 == expected_affine_y_c1 + end + + test "3*G2 coordinates match py_ecc exactly" do + g2 = G2.generator() + g2_2 = G2.double(g2) + g2_3 = G2.add(g2_2, g2) + + # py_ecc 3*G2 Jacobian values + expected_x_c0 = + 2_260_316_515_795_278_483_227_354_417_550_273_673_937_385_151_660_885_802_822_200_676_798_473_320_332_386_191_812_885_909_324_314_180_009_401_590_033_496 + + expected_x_c1 = + 3_157_705_674_295_752_746_643_045_744_187_038_651_144_673_626_385_096_899_515_739_718_638_356_953_289_853_357_506_730_468_806_346_866_010_850_469_607_484 + + expected_y_c0 = + 3_116_406_908_094_559_010_983_016_654_096_953_279_342_014_296_159_903_648_784_769_141_704_444_407_188_785_914_041_577_477_129_027_384_530_629_024_324_101 + + expected_y_c1 = + 624_739_198_846_365_065_958_511_422_206_549_337_298_084_868_949_577_950_118_937_104_460_230_094_422_413_163_466_712_508_875_838_914_229_203_179_007_739 + + expected_z_c0 = + 1_372_365_362_697_527_824_661_960_056_804_989_242_334_959_973_433_633_343_888_520_294_361_286_317_391_588_271_032_081_626_721_722_944_066_233_963_018_813 + + expected_z_c1 = + 135_340_553_306_575_460_225_879_133_388_402_231_094_623_862_625_345_515_492_709_522_456_301_372_944_095_308_361_691_014_711_792_956_665_222_682_354_141 + + %{x: {x_c0, x_c1}, y: {y_c0, y_c1}, z: {z_c0, z_c1}} = g2_3 + + assert Tezex.Crypto.BLS.Fq.to_integer(x_c0) == expected_x_c0 + assert Tezex.Crypto.BLS.Fq.to_integer(x_c1) == expected_x_c1 + assert Tezex.Crypto.BLS.Fq.to_integer(y_c0) == expected_y_c0 + assert Tezex.Crypto.BLS.Fq.to_integer(y_c1) == expected_y_c1 + assert Tezex.Crypto.BLS.Fq.to_integer(z_c0) == expected_z_c0 + assert Tezex.Crypto.BLS.Fq.to_integer(z_c1) == expected_z_c1 + end + + test "4G affine coordinates from different computation paths match py_ecc" do + g2 = G2.generator() + + # Path 1: double(double(G)) = 4G via doubling + two_g = G2.double(g2) + four_g_double = G2.double(two_g) + + # Path 2: add(add(double(G), G), G) = 4G via addition + three_g = G2.add(two_g, g2) + four_g_add = G2.add(three_g, g2) + + # Both should produce same affine coordinates + assert {:ok, {affine_x1, affine_y1}} = G2.to_affine(four_g_double) + assert {:ok, {affine_x2, affine_y2}} = G2.to_affine(four_g_add) + + assert Fq2.eq?(affine_x1, affine_x2), "Affine X coordinates should match" + assert Fq2.eq?(affine_y1, affine_y2), "Affine Y coordinates should match" + + # Expected affine coordinates for 4G from py_ecc + expected_affine_x_c0 = + 2_228_261_016_665_467_246_533_331_009_551_956_115_370_380_172_154_746_298_363_804_663_874_927_447_724_251_086_262_215_215_805_607_611_831_416_839_456_087 + + expected_affine_x_c1 = + 1_078_694_598_252_689_404_244_396_341_813_443_419_551_535_268_687_482_491_181_036_693_120_116_966_808_940_002_268_138_449_409_757_966_646_650_205_799_154 + + expected_affine_y_c0 = + 1_078_130_147_839_725_710_638_550_237_346_893_815_189_184_147_577_339_719_832_678_031_476_197_662_920_373_232_750_257_163_212_369_151_522_627_886_861_080 + + expected_affine_y_c1 = + 1_156_012_089_965_243_131_925_155_359_058_270_233_117_178_570_263_500_297_221_609_542_882_736_610_397_356_630_132_228_324_008_545_337_607_514_631_821_497 + + {actual_x_c0, actual_x_c1} = Fq2.to_integers(affine_x1) + {actual_y_c0, actual_y_c1} = Fq2.to_integers(affine_y1) + + assert actual_x_c0 == expected_affine_x_c0 + assert actual_x_c1 == expected_affine_x_c1 + assert actual_y_c0 == expected_affine_y_c0 + assert actual_y_c1 == expected_affine_y_c1 + end + end + + describe "G2 elliptic curve operations" do + test "basic group operations" do + g2 = G2.generator() + + # assert eq(add(add(double(G2), G2), G2), double(double(G2))) + double_g2 = G2.double(g2) + double_double_g2 = G2.double(double_g2) + add_double_g2_g2 = G2.add(double_g2, g2) + add_add_double_g2_g2_g2 = G2.add(add_double_g2_g2, g2) + + assert G2.eq?(add_add_double_g2_g2_g2, double_double_g2) + + # assert not eq(double(G2), G2) + refute G2.eq?(double_g2, g2) + + # assert eq(add(multiply(G2, 9), multiply(G2, 5)), add(multiply(G2, 12), multiply(G2, 2))) + nine = Fr.from_integer(9) + five = Fr.from_integer(5) + twelve = Fr.from_integer(12) + two = Fr.from_integer(2) + + mul_g2_9 = G2.mul(g2, nine) + mul_g2_5 = G2.mul(g2, five) + mul_g2_12 = G2.mul(g2, twelve) + mul_g2_2 = G2.mul(g2, two) + + add_9_5 = G2.add(mul_g2_9, mul_g2_5) + add_12_2 = G2.add(mul_g2_12, mul_g2_2) + + assert G2.eq?(add_9_5, add_12_2) + + # assert is_inf(multiply(G2, curve_order)) + curve_order = Constants.curve_order() + curve_order_fr = Fr.from_integer(curve_order) + mul_g2_order = G2.mul(g2, curve_order_fr) + assert G2.is_infinity?(mul_g2_order) + + # assert not is_inf(multiply(G2, 2 * field_modulus - curve_order)) + field_modulus = Constants.field_modulus() + special_scalar = 2 * field_modulus - curve_order + special_scalar_fr = Fr.from_integer(special_scalar) + mul_g2_special = G2.mul(g2, special_scalar_fr) + refute G2.is_infinity?(mul_g2_special) + + # assert is_on_curve(multiply(G2, 9), b2) + assert G2.is_on_curve?(mul_g2_9) + end + end + + describe "Zero points (identity elements)" do + test "G2 zero point properties" do + g2 = G2.generator() + z2 = G2.zero() + + # assert eq(G2, add(G2, Z2)) + assert G2.eq?(g2, G2.add(g2, z2)) + + # assert eq(Z2, double(Z2)) + assert G2.eq?(z2, G2.double(z2)) + + # assert eq(Z2, multiply(Z2, 0)) + zero = Fr.zero() + one = Fr.one() + two = Fr.from_integer(2) + three = Fr.from_integer(3) + + assert G2.eq?(z2, G2.mul(z2, zero)) + assert G2.eq?(z2, G2.mul(z2, one)) + assert G2.eq?(z2, G2.mul(z2, two)) + assert G2.eq?(z2, G2.mul(z2, three)) + + # assert is_inf(neg(Z2)) + neg_z2 = G2.negate(z2) + assert G2.is_infinity?(neg_z2) + end + end +end diff --git a/test/crypto/bls/hash_to_curve_mapping_test.exs b/test/crypto/bls/hash_to_curve_mapping_test.exs new file mode 100644 index 0000000..700d50e --- /dev/null +++ b/test/crypto/bls/hash_to_curve_mapping_test.exs @@ -0,0 +1,168 @@ +defmodule Tezex.Crypto.BLS.HashToCurveMappingTest do + use ExUnit.Case, async: true + + alias Tezex.Crypto.BLS.Fq2 + alias Tezex.Crypto.BLS.G2 + + describe "SSWU mapping operations" do + test "SSWU mapping for specific field element u0" do + # Input field element u0 from hash_to_field_FQ2("hello world", 2, dst) + u0_c0 = + 1_717_481_801_022_890_320_930_535_539_684_145_078_363_416_826_664_358_709_416_530_433_123_807_602_906_499_236_696_635_892_172_619_530_864_997_493_102_515 + + u0_c1 = + 1_687_859_148_152_866_701_283_814_945_283_565_263_051_278_093_529_837_438_184_411_703_051_446_645_270_323_793_737_296_154_376_182_401_617_592_571_421_593 + + u0 = Fq2.from_integers(u0_c0, u0_c1) + + # Expected SSWU(u0) result + expected_x_c0 = + 796_173_875_720_834_253_623_727_506_271_078_041_020_251_392_772_031_672_345_317_992_429_015_938_707_504_145_184_699_842_548_681_111_121_477_231_464_588 + + expected_x_c1 = + 2_553_240_928_850_692_148_144_609_726_821_978_673_910_622_315_327_992_197_547_050_968_824_001_489_123_743_897_267_269_834_754_689_946_017_196_982_133_401 + + expected_y_c0 = + 2_309_192_382_432_807_183_166_084_529_647_111_454_597_739_575_294_565_093_368_416_237_116_772_620_221_902_878_563_567_248_863_546_919_357_777_429_902_675 + + expected_y_c1 = + 2_586_493_671_439_743_965_770_770_303_452_682_393_515_563_815_845_127_441_996_901_006_879_933_639_046_896_211_788_453_535_264_525_976_650_333_670_026_608 + + expected_z_c0 = + 809_500_295_226_557_698_756_926_798_397_199_118_323_607_410_412_494_527_267_471_140_176_137_427_791_063_365_262_052_291_197_955_969_487_397_529_159_432 + + expected_z_c1 = + 3_684_344_747_470_531_207_307_271_375_680_124_786_338_931_052_574_163_313_513_438_459_614_267_363_603_552_283_607_329_320_143_023_358_132_871_525_346_032 + + # Apply SSWU mapping + {actual_x, actual_y, actual_z} = G2.sswu_map(u0) + + # Extract coefficients and convert to integers + {actual_x_c0, actual_x_c1} = actual_x + {actual_y_c0, actual_y_c1} = actual_y + {actual_z_c0, actual_z_c1} = actual_z + + actual_x_c0_int = :binary.decode_unsigned(actual_x_c0) + actual_x_c1_int = :binary.decode_unsigned(actual_x_c1) + actual_y_c0_int = :binary.decode_unsigned(actual_y_c0) + actual_y_c1_int = :binary.decode_unsigned(actual_y_c1) + actual_z_c0_int = :binary.decode_unsigned(actual_z_c0) + actual_z_c1_int = :binary.decode_unsigned(actual_z_c1) + + assert actual_x_c0_int == expected_x_c0 + assert actual_x_c1_int == expected_x_c1 + assert actual_y_c0_int == expected_y_c0 + assert actual_y_c1_int == expected_y_c1 + assert actual_z_c0_int == expected_z_c0 + assert actual_z_c1_int == expected_z_c1 + end + + test "SSWU mapping for simple field element (1, 0)" do + # Simple test case: (1, 0) + u = Fq2.from_integers(1, 0) + + # Expected SSWU result + expected_x_c0 = + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_558_775 + + expected_x_c1 = 5060 + + expected_y_c0 = + 1_347_507_234_798_921_329_944_701_265_542_592_282_430_463_344_696_112_603_091_539_605_429_516_992_934_832_530_571_627_687_441_447_322_864_595_111_169_093 + + expected_y_c1 = + 3_260_065_998_889_756_212_591_550_323_019_334_687_872_981_459_637_068_827_486_240_396_953_606_841_488_756_160_755_458_572_297_306_998_566_838_745_359_909 + + expected_z_c0 = 720 + + expected_z_c1 = + 4_002_409_555_221_667_393_417_789_825_735_904_156_556_882_819_939_007_885_332_058_136_124_031_650_490_837_864_442_687_629_129_015_664_037_894_272_559_547 + + # Apply SSWU mapping + {actual_x, actual_y, actual_z} = G2.sswu_map(u) + + # Extract coefficients and convert to integers + {actual_x_c0, actual_x_c1} = actual_x + {actual_y_c0, actual_y_c1} = actual_y + {actual_z_c0, actual_z_c1} = actual_z + + actual_x_c0_int = :binary.decode_unsigned(actual_x_c0) + actual_x_c1_int = :binary.decode_unsigned(actual_x_c1) + actual_y_c0_int = :binary.decode_unsigned(actual_y_c0) + actual_y_c1_int = :binary.decode_unsigned(actual_y_c1) + actual_z_c0_int = :binary.decode_unsigned(actual_z_c0) + actual_z_c1_int = :binary.decode_unsigned(actual_z_c1) + + assert actual_x_c0_int == expected_x_c0 + assert actual_x_c1_int == expected_x_c1 + assert actual_y_c0_int == expected_y_c0 + assert actual_y_c1_int == expected_y_c1 + assert actual_z_c0_int == expected_z_c0 + assert actual_z_c1_int == expected_z_c1 + end + end + + describe "Isogeny mapping operations" do + test "isogeny mapping for SSWU result" do + # SSWU(u0) input coordinates + x = + Fq2.from_integers( + 796_173_875_720_834_253_623_727_506_271_078_041_020_251_392_772_031_672_345_317_992_429_015_938_707_504_145_184_699_842_548_681_111_121_477_231_464_588, + 2_553_240_928_850_692_148_144_609_726_821_978_673_910_622_315_327_992_197_547_050_968_824_001_489_123_743_897_267_269_834_754_689_946_017_196_982_133_401 + ) + + y = + Fq2.from_integers( + 2_309_192_382_432_807_183_166_084_529_647_111_454_597_739_575_294_565_093_368_416_237_116_772_620_221_902_878_563_567_248_863_546_919_357_777_429_902_675, + 2_586_493_671_439_743_965_770_770_303_452_682_393_515_563_815_845_127_441_996_901_006_879_933_639_046_896_211_788_453_535_264_525_976_650_333_670_026_608 + ) + + z = + Fq2.from_integers( + 809_500_295_226_557_698_756_926_798_397_199_118_323_607_410_412_494_527_267_471_140_176_137_427_791_063_365_262_052_291_197_955_969_487_397_529_159_432, + 3_684_344_747_470_531_207_307_271_375_680_124_786_338_931_052_574_163_313_513_438_459_614_267_363_603_552_283_607_329_320_143_023_358_132_871_525_346_032 + ) + + # Expected iso_map_G2 result + expected_x_c0 = + 3_730_664_267_803_065_519_618_473_115_339_978_425_435_001_599_053_657_516_966_273_455_168_703_835_273_385_086_642_066_111_321_238_676_472_873_550_507_039 + + expected_x_c1 = + 1_932_440_596_784_524_961_460_414_449_764_135_756_352_604_187_345_853_799_388_230_578_663_492_691_730_253_677_451_536_899_503_095_193_998_583_157_737_099 + + expected_y_c0 = + 1_656_072_553_096_042_706_776_461_214_075_085_543_862_859_506_917_151_958_604_436_876_295_264_117_186_425_009_932_841_051_353_229_290_421_096_634_314_976 + + expected_y_c1 = + 2_089_695_700_261_254_513_897_468_258_954_057_892_839_808_228_112_975_110_805_050_045_020_610_872_870_988_769_385_835_380_007_831_958_320_430_051_424_747 + + expected_z_c0 = + 662_252_190_560_844_014_192_464_224_524_991_544_006_079_962_628_184_881_423_967_610_720_178_819_254_590_922_389_857_451_430_147_824_546_612_699_622_552 + + expected_z_c1 = + 2_070_086_394_022_424_304_750_141_371_165_332_799_051_026_006_317_898_229_867_514_783_937_637_466_152_146_237_099_451_683_820_803_245_921_772_054_002_608 + + # Apply isogeny mapping + {actual_x, actual_y, actual_z} = G2.iso_map_g2(x, y, z) + + # Extract coefficients and convert to integers + {actual_x_c0, actual_x_c1} = actual_x + {actual_y_c0, actual_y_c1} = actual_y + {actual_z_c0, actual_z_c1} = actual_z + + actual_x_c0_int = :binary.decode_unsigned(actual_x_c0) + actual_x_c1_int = :binary.decode_unsigned(actual_x_c1) + actual_y_c0_int = :binary.decode_unsigned(actual_y_c0) + actual_y_c1_int = :binary.decode_unsigned(actual_y_c1) + actual_z_c0_int = :binary.decode_unsigned(actual_z_c0) + actual_z_c1_int = :binary.decode_unsigned(actual_z_c1) + + assert actual_x_c0_int == expected_x_c0 + assert actual_x_c1_int == expected_x_c1 + assert actual_y_c0_int == expected_y_c0 + assert actual_y_c1_int == expected_y_c1 + assert actual_z_c0_int == expected_z_c0 + assert actual_z_c1_int == expected_z_c1 + end + end +end diff --git a/test/crypto/bls/hash_to_field_test.exs b/test/crypto/bls/hash_to_field_test.exs new file mode 100644 index 0000000..d7586a8 --- /dev/null +++ b/test/crypto/bls/hash_to_field_test.exs @@ -0,0 +1,221 @@ +defmodule Tezex.Crypto.BLS.HashToFieldTest do + @moduledoc """ + Test hash-to-field implementation against known values + """ + + use ExUnit.Case + alias Tezex.Crypto.BLS.G2 + alias Tezex.Crypto.BLS.HashToField + + describe "expand_message_xmd" do + test "produces correct result for 32 bytes" do + message = "hello world" + dst = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_" + + result = HashToField.expand_message_xmd(message, dst, 32) + + # Expected result + expected = "15f4bab703bacbb4725f88d1dceebd8d941cccd4e78d66ce1e271d3078e3c5e3" + actual = Base.encode16(result, case: :lower) + + assert actual == expected, "expand_message_xmd should produce correct result for 32 bytes" + end + + test "produces correct result for 256 bytes" do + message = "hello world" + dst = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_" + + result = HashToField.expand_message_xmd(message, dst, 256) + + # Expected first 64 bytes + expected_first_64 = + "05b218499b13a8c18014761ff123e27dc065772e666777698b2fa50993d4ee9e51ca6a11fb19730bda59e684533593ab9833842e69d0ebc0231d25038d72b6b1" + + actual_first_64 = Base.encode16(binary_part(result, 0, 64), case: :lower) + + assert byte_size(result) == 256 + + assert actual_first_64 == expected_first_64, + "expand_message_xmd should produce correct first 64 bytes" + end + + test "works with various test vectors" do + # Test vector 1: empty message + result1 = HashToField.expand_message_xmd("", "QUUX-V01-CS02-with-expander-SHA256-128", 32) + # Add expected value when we verify it works + assert byte_size(result1) == 32 + + # Test vector 2: different DST + result2 = + HashToField.expand_message_xmd("abc", "QUUX-V01-CS02-with-expander-SHA256-128", 32) + + assert byte_size(result2) == 32 + + # Results should be different + refute result1 == result2 + end + end + + describe "hash_to_field_fq2" do + test "produces correct result for hello world with count=1" do + message = "hello world" + dst = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_" + + result = HashToField.hash_to_field_fq2(message, 1, dst) + + assert length(result) == 1 + + # Expected values + expected_c0 = + 3_953_521_847_463_685_842_027_004_280_720_592_171_087_789_117_488_196_218_495_143_707_177_230_643_750_427_891_830_749_563_649_524_744_212_399_943_579_710 + + expected_c1 = + 3_055_054_897_935_053_448_916_591_188_660_263_956_389_391_216_799_965_250_549_689_108_207_704_046_646_708_235_167_491_387_016_572_797_962_721_328_341_549 + + {actual_c0, actual_c1} = Enum.at(result, 0) + actual_c0_int = :binary.decode_unsigned(actual_c0) + actual_c1_int = :binary.decode_unsigned(actual_c1) + + assert actual_c0_int == expected_c0 + assert actual_c1_int == expected_c1 + end + + test "produces correct result for hello world with count=2" do + message = "hello world" + dst = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_" + + result = HashToField.hash_to_field_fq2(message, 2, dst) + + assert length(result) == 2 + + # Expected values - known correct results + expected_0_c0 = + 1_717_481_801_022_890_320_930_535_539_684_145_078_363_416_826_664_358_709_416_530_433_123_807_602_906_499_236_696_635_892_172_619_530_864_997_493_102_515 + + expected_0_c1 = + 1_687_859_148_152_866_701_283_814_945_283_565_263_051_278_093_529_837_438_184_411_703_051_446_645_270_323_793_737_296_154_376_182_401_617_592_571_421_593 + + expected_1_c0 = + 3_901_330_900_121_520_942_627_673_142_142_849_187_437_938_066_868_251_070_015_462_714_176_845_045_056_507_979_389_850_416_409_317_326_499_549_712_481_614 + + expected_1_c1 = + 2_175_242_699_958_839_247_752_417_837_689_044_546_354_080_322_814_340_310_702_597_681_413_282_346_860_771_490_707_418_120_088_238_564_234_612_358_749_630 + + {actual_0_c0, actual_0_c1} = Enum.at(result, 0) + actual_0_c0_int = :binary.decode_unsigned(actual_0_c0) + actual_0_c1_int = :binary.decode_unsigned(actual_0_c1) + + {actual_1_c0, actual_1_c1} = Enum.at(result, 1) + actual_1_c0_int = :binary.decode_unsigned(actual_1_c0) + actual_1_c1_int = :binary.decode_unsigned(actual_1_c1) + + assert actual_0_c0_int == expected_0_c0 + assert actual_0_c1_int == expected_0_c1 + assert actual_1_c0_int == expected_1_c0 + assert actual_1_c1_int == expected_1_c1 + end + + test "produces correct result for test message" do + message = "test" + dst = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_" + + result = HashToField.hash_to_field_fq2(message, 1, dst) + + assert length(result) == 1 + + # Expected values + expected_c0 = + 3_535_058_899_757_572_561_417_273_661_736_812_582_163_214_854_283_886_200_950_085_587_678_672_134_917_564_981_097_203_652_298_787_502_167_092_805_260_296 + + expected_c1 = + 3_638_938_364_795_184_900_703_046_711_520_950_876_176_833_374_715_311_617_602_086_474_128_845_885_923_931_415_454_445_068_484_712_548_218_201_064_420_152 + + {actual_c0, actual_c1} = Enum.at(result, 0) + actual_c0_int = :binary.decode_unsigned(actual_c0) + actual_c1_int = :binary.decode_unsigned(actual_c1) + + assert actual_c0_int == expected_c0 + assert actual_c1_int == expected_c1 + end + + test "produces deterministic results" do + message = "test message" + dst = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_" + + result1 = HashToField.hash_to_field_fq2(message, 2, dst) + result2 = HashToField.hash_to_field_fq2(message, 2, dst) + + assert result1 == result2, "hash_to_field_fq2 should be deterministic" + end + + test "produces different results for different messages" do + dst = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_" + + result1 = HashToField.hash_to_field_fq2("message1", 2, dst) + result2 = HashToField.hash_to_field_fq2("message2", 2, dst) + + refute result1 == result2, "Different messages should produce different field elements" + end + + test "respects count parameter" do + message = "hello world" + dst = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_" + + result_1 = HashToField.hash_to_field_fq2(message, 1, dst) + result_2 = HashToField.hash_to_field_fq2(message, 2, dst) + result_3 = HashToField.hash_to_field_fq2(message, 3, dst) + + assert length(result_1) == 1 + assert length(result_2) == 2 + assert length(result_3) == 3 + + # First elements should be DIFFERENT when count changes (correct behavior per spec) + assert Enum.at(result_1, 0) != Enum.at(result_2, 0) + assert Enum.at(result_1, 0) != Enum.at(result_3, 0) + end + + test "count parameter affects field element generation correctly" do + message = "hello world" + dst = "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_" + + result_1 = HashToField.hash_to_field_fq2(message, 1, dst) + result_2 = HashToField.hash_to_field_fq2(message, 2, dst) + + # The first elements should be DIFFERENT (this is correct behavior per spec) + assert Enum.at(result_1, 0) != Enum.at(result_2, 0) + end + end + + describe "hash_to_g2" do + test "produces valid points on the curve" do + message = "hello world" + + result = HashToField.hash_to_g2(message) + + # For now, just verify it produces a valid G2 point + # Once we fix the implementation, we can add exact value checks + assert %{x: _, y: _, z: _} = result + + # The point should be on the curve (when implementation is correct) + assert G2.is_on_curve?(result) + end + + test "is deterministic" do + message = "test message" + + result1 = HashToField.hash_to_g2(message) + result2 = HashToField.hash_to_g2(message) + + # Should produce the same result every time + assert G2.eq?(result1, result2) + end + + test "produces different results for different messages" do + result1 = HashToField.hash_to_g2("message1") + result2 = HashToField.hash_to_g2("message2") + + # Should produce different results + refute G2.eq?(result1, result2) + end + end +end diff --git a/test/crypto/bls/optimized_final_exp_test.exs b/test/crypto/bls/optimized_final_exp_test.exs new file mode 100644 index 0000000..d381f04 --- /dev/null +++ b/test/crypto/bls/optimized_final_exp_test.exs @@ -0,0 +1,93 @@ +defmodule Tezex.Crypto.BLS.OptimizedFinalExpTest do + use ExUnit.Case + + alias Tezex.Crypto.BLS.Fq12 + + describe "optimized final exponentiation" do + test "matches known results for test vector 1" do + input = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] + + expected = [ + 1_565_770_805_593_483_334_740_033_720_056_239_251_891_003_086_862_123_447_303_846_180_265_065_947_791_270_144_646_640_507_709_182_700_934_648_531_018_371, + 3_761_181_558_896_926_562_518_743_792_862_190_057_312_404_791_985_904_111_737_810_570_470_116_339_165_679_612_197_633_826_667_149_010_514_235_469_705_822, + 2_172_445_552_423_636_405_668_361_441_500_868_630_352_941_319_817_858_303_120_229_971_070_353_311_874_682_633_424_946_970_638_369_511_393_250_203_581_209, + 1_637_628_371_351_057_383_852_607_647_516_041_107_258_191_279_004_458_255_348_501_557_982_495_908_471_949_624_297_076_243_263_151_908_986_878_243_877_840, + 2_767_021_143_801_548_053_730_089_536_526_177_003_654_175_667_438_210_810_628_727_884_336_723_173_932_912_887_243_387_618_284_799_176_147_114_440_160_578, + 76_258_773_250_001_726_312_095_751_242_930_035_228_727_981_785_544_299_836_405_499_935_206_995_927_251_709_883_151_220_156_520_884_423_580_035_559_912, + 771_084_045_614_206_124_417_532_040_652_860_864_868_622_088_028_055_619_881_516_583_589_946_890_884_685_815_527_275_399_260_234_894_254_093_581_735_094, + 2_424_691_780_023_488_826_558_669_487_682_357_945_266_946_131_270_761_011_204_152_527_519_809_636_500_021_122_317_253_318_251_411_699_685_842_910_740_717, + 1_395_593_207_668_658_360_540_991_328_934_684_679_438_288_613_720_238_615_055_983_176_836_203_215_697_969_378_001_198_599_512_276_434_816_830_505_000_913, + 2_545_670_972_278_621_802_854_967_086_767_280_357_505_629_742_706_337_577_323_937_538_006_703_582_877_235_878_291_310_070_660_234_655_894_687_309_093_431, + 2_463_276_713_424_541_798_218_288_273_347_423_241_629_233_568_397_114_436_521_564_859_538_244_862_170_311_209_841_031_724_882_152_688_206_432_053_867_747, + 3_046_000_863_836_088_893_463_790_360_432_401_172_581_384_534_154_593_655_754_064_183_669_015_268_976_687_736_497_630_687_765_388_187_638_248_960_395_918 + ] + + input_fq12 = Fq12.from_integers(input) + result = Fq12.optimized_final_exponentiation(input_fq12) + expected_fq12 = Fq12.from_integers(expected) + + assert Fq12.eq?(result, expected_fq12) + end + + test "matches known results for test vector 2" do + input = [12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1] + + expected = [ + 3_175_952_546_402_343_827_157_681_035_299_693_506_866_799_532_438_280_057_964_437_249_982_845_241_258_929_482_666_549_180_627_040_127_775_428_486_047_359, + 543_724_150_812_453_429_874_716_326_907_257_078_799_612_609_997_239_565_498_519_431_179_547_902_684_241_351_373_015_916_480_923_430_360_758_505_885_282, + 1_757_097_995_296_065_999_273_464_855_345_888_956_921_452_219_871_749_654_418_184_801_498_592_623_860_238_086_724_849_932_798_316_761_818_352_336_884_000, + 459_755_648_294_186_315_118_148_080_781_901_872_093_836_890_920_011_585_182_420_209_774_901_664_826_847_422_127_818_888_857_605_794_194_684_972_057_313, + 3_341_876_501_439_517_282_231_076_607_687_196_002_024_795_175_877_477_759_441_165_716_820_865_215_810_394_717_268_231_508_669_060_683_689_842_685_578_875, + 3_977_072_163_128_273_719_914_035_031_111_611_438_253_696_881_723_977_227_878_470_759_141_262_496_364_145_554_550_519_287_382_543_542_125_399_354_553_523, + 1_981_984_666_539_308_534_504_815_444_815_554_271_437_414_609_552_494_921_292_975_166_021_819_359_978_646_134_925_689_484_100_308_798_467_388_485_058_937, + 3_812_870_633_699_711_593_937_028_800_350_682_746_044_449_436_144_227_570_421_576_367_364_339_538_240_375_821_536_922_456_612_473_315_923_506_409_580_467, + 2_941_769_962_594_448_968_993_024_704_324_907_158_735_248_584_997_151_557_634_027_933_085_962_694_147_225_474_380_568_525_143_315_477_644_519_510_341_266, + 2_021_013_527_875_719_839_726_167_410_639_727_236_532_928_643_580_437_123_054_661_310_539_005_709_706_625_009_481_429_439_756_593_058_986_789_273_538_675, + 2_372_338_157_143_111_851_895_962_905_751_990_912_658_738_073_106_775_310_086_096_775_526_038_588_005_021_816_266_162_374_621_672_771_810_247_584_377_238, + 855_956_307_651_427_604_697_264_820_725_580_896_113_213_646_938_476_255_194_837_667_095_590_512_547_236_879_588_801_661_891_426_253_258_453_018_288_967 + ] + + input_fq12 = Fq12.from_integers(input) + result = Fq12.optimized_final_exponentiation(input_fq12) + expected_fq12 = Fq12.from_integers(expected) + + assert Fq12.eq?(result, expected_fq12) + end + + test "identity element maps to identity" do + input = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + expected = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + + input_fq12 = Fq12.from_integers(input) + result = Fq12.optimized_final_exponentiation(input_fq12) + expected_fq12 = Fq12.from_integers(expected) + + assert Fq12.eq?(result, expected_fq12) + end + + test "basis element [0,1,0,...] final exponentiation" do + input = [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + expected = [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + + input_fq12 = Fq12.from_integers(input) + result = Fq12.optimized_final_exponentiation(input_fq12) + expected_fq12 = Fq12.from_integers(expected) + + assert Fq12.eq?(result, expected_fq12) + end + end + + describe "frobenius endomorphism" do + test "frobenius of identity is identity" do + input = Fq12.one() + result = Fq12.frobenius(input) + assert Fq12.eq?(result, input) + end + + test "frobenius applied 12 times is identity" do + input = Fq12.from_integers([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) + result = Enum.reduce(1..12, input, fn _i, acc -> Fq12.frobenius(acc) end) + assert Fq12.eq?(result, input) + end + end +end diff --git a/test/crypto/bls/pairing_test.exs b/test/crypto/bls/pairing_test.exs new file mode 100644 index 0000000..f17abbe --- /dev/null +++ b/test/crypto/bls/pairing_test.exs @@ -0,0 +1,118 @@ +defmodule Tezex.Crypto.BLS.PairingTest do + use ExUnit.Case, async: true + + alias Tezex.Crypto.BLS.Constants + alias Tezex.Crypto.BLS.Fq12 + alias Tezex.Crypto.BLS.Fr + alias Tezex.Crypto.BLS.G1 + alias Tezex.Crypto.BLS.G2 + alias Tezex.Crypto.BLS.Pairing + + @curve_order Constants.curve_order() + + describe "Pairing operations" do + test "pairing with negative G1" do + g1 = G1.generator() + g2 = G2.generator() + + # p1 = pairing(G1, G2) + p1 = Pairing.pairing(g1, g2) + + # pn1 = pairing(neg(G1), G2) + neg_g1 = G1.negate(g1) + pn1 = Pairing.pairing(neg_g1, g2) + + # assert p1 * pn1 == 1 + product = Fq12.mul(p1, pn1) + assert Fq12.is_one?(product) + end + + test "pairing output order" do + g1 = G1.generator() + g2 = G2.generator() + + # p1 = pairing(G1, G2) + p1 = Pairing.pairing(g1, g2) + + # assert p1**curve_order == 1 + p1_pow_order = Fq12.pow(p1, @curve_order) + assert Fq12.is_one?(p1_pow_order) + end + + test "pairing bilinearity on G1" do + g1 = G1.generator() + g2 = G2.generator() + two = Fr.from_integer(2) + + # p1 = pairing(G1, G2) + p1 = Pairing.pairing(g1, g2) + + # p2 = pairing(multiply(G1, 2), G2) + g1_times_2 = G1.mul(g1, two) + p2 = Pairing.pairing(g1_times_2, g2) + + # assert p1 * p1 == p2 + p1_squared = Fq12.mul(p1, p1) + assert Fq12.eq?(p1_squared, p2) + end + + test "pairing is non-degenerate" do + g1 = G1.generator() + g2 = G2.generator() + two = Fr.from_integer(2) + + # p1 = pairing(G1, G2) + p1 = Pairing.pairing(g1, g2) + + # p2 = pairing(multiply(G1, 2), G2) + g1_times_2 = G1.mul(g1, two) + p2 = Pairing.pairing(g1_times_2, g2) + + # np1 = pairing(G1, neg(G2)) + neg_g2 = G2.negate(g2) + np1 = Pairing.pairing(g1, neg_g2) + + # assert p1 != p2 and p1 != np1 and p2 != np1 + refute Fq12.eq?(p1, p2) + refute Fq12.eq?(p1, np1) + refute Fq12.eq?(p2, np1) + end + + test "pairing bilinearity on G2" do + g1 = G1.generator() + g2 = G2.generator() + two = Fr.from_integer(2) + + # p1 = pairing(G1, G2) + p1 = Pairing.pairing(g1, g2) + + # po2 = pairing(G1, multiply(G2, 2)) + g2_times_2 = G2.mul(g2, two) + po2 = Pairing.pairing(g1, g2_times_2) + + # assert p1 * p1 == po2 + p1_squared = Fq12.mul(p1, p1) + assert Fq12.eq?(p1_squared, po2) + end + + test "pairing composite check" do + g1 = G1.generator() + g2 = G2.generator() + twenty_seven = Fr.from_integer(27) + thirty_seven = Fr.from_integer(37) + nine_ninety_nine = Fr.from_integer(999) + + # p3 = pairing(multiply(G1, 37), multiply(G2, 27)) + g2_times_27 = G2.mul(g2, twenty_seven) + g1_times_37 = G1.mul(g1, thirty_seven) + p3 = Pairing.pairing(g1_times_37, g2_times_27) + + # po3 = pairing(multiply(G1, 999), G2) + g1_times_999 = G1.mul(g1, nine_ninety_nine) + po3 = Pairing.pairing(g1_times_999, g2) + + # assert p3 == po3 + assert Fq12.eq?(p3, po3) + end + end +end diff --git a/test/crypto/bls_test.exs b/test/crypto/bls_test.exs new file mode 100644 index 0000000..248aeaf --- /dev/null +++ b/test/crypto/bls_test.exs @@ -0,0 +1,493 @@ +defmodule Tezex.Crypto.BLSTest do + use ExUnit.Case, async: true + + doctest Tezex.Crypto.BLS, import: true + + alias Tezex.Crypto.BLS + alias Tezex.Crypto.BLS.Fq + alias Tezex.Crypto.BLS.G1 + alias Tezex.Crypto.BLS.G2 + alias Tezex.Crypto.BLS.Pairing + + describe "BLS key generation" do + test "generates valid BLS key from seed" do + seed = :crypto.strong_rand_bytes(32) + assert {:ok, bls_key} = BLS.from_seed(seed) + assert %BLS{} = bls_key + end + + test "rejects invalid seed sizes" do + short_seed = :crypto.strong_rand_bytes(31) + long_seed = :crypto.strong_rand_bytes(33) + + assert {:error, :invalid_seed} = BLS.from_seed(short_seed) + assert {:error, :invalid_seed} = BLS.from_seed(long_seed) + end + + test "rejects zero seed" do + zero_seed = <<0::size(256)>> + assert {:error, :invalid_seed} = BLS.from_seed(zero_seed) + end + + test "generates valid BLS key using HKDF key generation" do + ikm = :crypto.strong_rand_bytes(32) + assert {:ok, bls_key} = BLS.key_gen(ikm) + assert %BLS{} = bls_key + end + + test "key_gen with different key_info produces different keys" do + ikm = :crypto.strong_rand_bytes(32) + {:ok, key1} = BLS.key_gen(ikm, "") + {:ok, key2} = BLS.key_gen(ikm, "test") + + pubkey1 = BLS.get_public_key(key1) + pubkey2 = BLS.get_public_key(key2) + + refute pubkey1 == pubkey2 + end + + test "rejects insufficient IKM for key_gen" do + short_ikm = :crypto.strong_rand_bytes(31) + assert {:error, :invalid_ikm} = BLS.key_gen(short_ikm) + end + + test "creates BLS key from secret exponent (binary)" do + secret_bytes = :crypto.strong_rand_bytes(32) + assert {:ok, bls_key} = BLS.from_secret_exponent(secret_bytes) + assert %BLS{} = bls_key + end + + test "creates BLS key from secret exponent (integer)" do + secret_int = 12345 + assert {:ok, bls_key} = BLS.from_secret_exponent(secret_int) + assert %BLS{} = bls_key + end + + test "rejects zero secret exponent" do + assert {:error, :invalid_secret} = BLS.from_secret_exponent(0) + end + end + + describe "BLS public key derivation" do + test "derives 48-byte public key" do + seed = :crypto.strong_rand_bytes(32) + {:ok, bls_key} = BLS.from_seed(seed) + + public_key = BLS.get_public_key(bls_key) + assert byte_size(public_key) == 48 + assert <<_flag, _rest::binary-size(47)>> = public_key + end + + test "public keys are deterministic" do + seed = :crypto.strong_rand_bytes(32) + {:ok, bls_key1} = BLS.from_seed(seed) + {:ok, bls_key2} = BLS.from_seed(seed) + + public_key1 = BLS.get_public_key(bls_key1) + public_key2 = BLS.get_public_key(bls_key2) + + assert public_key1 == public_key2 + end + end + + describe "BLS signing and verification" do + test "signs and verifies messages successfully with default ciphersuite" do + seed = :crypto.strong_rand_bytes(32) + {:ok, bls_key} = BLS.from_seed(seed) + + message = "test message" + public_key = BLS.get_public_key(bls_key) + signature = BLS.sign(bls_key, message) + + assert byte_size(signature) == 96 + assert BLS.verify(signature, message, public_key) + end + + test "signs and verifies with message augmentation ciphersuite" do + seed = :crypto.strong_rand_bytes(32) + {:ok, bls_key} = BLS.from_seed(seed) + + message = "test message" + public_key = BLS.get_public_key(bls_key) + signature = BLS.sign(bls_key, message, :message_augmentation) + + assert byte_size(signature) == 96 + assert BLS.verify(signature, message, public_key, :message_augmentation) + end + + test "signs and verifies with basic ciphersuite" do + seed = :crypto.strong_rand_bytes(32) + {:ok, bls_key} = BLS.from_seed(seed) + + message = "test message" + public_key = BLS.get_public_key(bls_key) + signature = BLS.sign(bls_key, message, :basic) + + assert byte_size(signature) == 96 + assert BLS.verify(signature, message, public_key, :basic) + end + + test "signs and verifies with proof-of-possession ciphersuite" do + seed = :crypto.strong_rand_bytes(32) + {:ok, bls_key} = BLS.from_seed(seed) + + message = "test message" + public_key = BLS.get_public_key(bls_key) + signature = BLS.sign(bls_key, message, :proof_of_possession) + + assert byte_size(signature) == 96 + assert BLS.verify(signature, message, public_key, :proof_of_possession) + end + + test "different ciphersuites produce different signatures" do + seed = :crypto.strong_rand_bytes(32) + {:ok, bls_key} = BLS.from_seed(seed) + + message = "test message" + sig_aug = BLS.sign(bls_key, message, :message_augmentation) + sig_basic = BLS.sign(bls_key, message, :basic) + sig_pop = BLS.sign(bls_key, message, :proof_of_possession) + + refute sig_aug == sig_basic + refute sig_aug == sig_pop + refute sig_basic == sig_pop + end + + test "signatures are deterministic within ciphersuite" do + seed = :crypto.strong_rand_bytes(32) + {:ok, bls_key} = BLS.from_seed(seed) + + message = "test message" + signature1 = BLS.sign(bls_key, message, :basic) + signature2 = BLS.sign(bls_key, message, :basic) + + assert signature1 == signature2 + end + + test "verification fails for wrong ciphersuite" do + seed = :crypto.strong_rand_bytes(32) + {:ok, bls_key} = BLS.from_seed(seed) + + message = "test message" + public_key = BLS.get_public_key(bls_key) + signature = BLS.sign(bls_key, message, :basic) + + refute BLS.verify(signature, message, public_key, :message_augmentation) + end + + test "verification fails for wrong message" do + seed = :crypto.strong_rand_bytes(32) + {:ok, bls_key} = BLS.from_seed(seed) + + message = "test message" + wrong_message = "wrong message" + public_key = BLS.get_public_key(bls_key) + signature = BLS.sign(bls_key, message) + + refute BLS.verify(signature, wrong_message, public_key) + end + + test "verification fails for wrong public key" do + seed1 = :crypto.strong_rand_bytes(32) + seed2 = :crypto.strong_rand_bytes(32) + {:ok, bls_key1} = BLS.from_seed(seed1) + {:ok, bls_key2} = BLS.from_seed(seed2) + + message = "test message" + public_key2 = BLS.get_public_key(bls_key2) + signature1 = BLS.sign(bls_key1, message) + + refute BLS.verify(signature1, message, public_key2) + end + end + + describe "serialization" do + test "serializes and deserializes secret keys" do + seed = :crypto.strong_rand_bytes(32) + {:ok, bls_key} = BLS.from_seed(seed) + + serialized = BLS.serialize_secret_key(bls_key) + assert byte_size(serialized) == 32 + + {:ok, deserialized_key} = BLS.deserialize_secret_key(serialized) + + # Keys should produce the same public key + original_pubkey = BLS.get_public_key(bls_key) + deserialized_pubkey = BLS.get_public_key(deserialized_key) + assert original_pubkey == deserialized_pubkey + end + end + + describe "proof-of-possession" do + test "generates and verifies proof-of-possession" do + seed = :crypto.strong_rand_bytes(32) + {:ok, bls_key} = BLS.from_seed(seed) + + public_key = BLS.get_public_key(bls_key) + proof = BLS.pop_prove(bls_key) + + assert byte_size(proof) == 96 + assert BLS.pop_verify(public_key, proof) + end + + test "proof-of-possession verification fails for wrong public key" do + seed1 = :crypto.strong_rand_bytes(32) + seed2 = :crypto.strong_rand_bytes(32) + {:ok, bls_key1} = BLS.from_seed(seed1) + {:ok, bls_key2} = BLS.from_seed(seed2) + + public_key2 = BLS.get_public_key(bls_key2) + proof1 = BLS.pop_prove(bls_key1) + + refute BLS.pop_verify(public_key2, proof1) + end + + test "proof-of-possession is deterministic" do + seed = :crypto.strong_rand_bytes(32) + {:ok, bls_key} = BLS.from_seed(seed) + + proof1 = BLS.pop_prove(bls_key) + proof2 = BLS.pop_prove(bls_key) + + assert proof1 == proof2 + end + end + + describe "validation functions" do + test "validates valid public keys" do + seed = :crypto.strong_rand_bytes(32) + {:ok, bls_key} = BLS.from_seed(seed) + public_key = BLS.get_public_key(bls_key) + + assert BLS.validate_public_key(public_key) + end + + test "validates valid signatures" do + seed = :crypto.strong_rand_bytes(32) + {:ok, bls_key} = BLS.from_seed(seed) + message = "test message" + signature = BLS.sign(bls_key, message) + + assert BLS.validate_signature(signature) + end + + test "rejects invalid public key sizes" do + invalid_pubkey = :crypto.strong_rand_bytes(47) + refute BLS.validate_public_key(invalid_pubkey) + end + + test "rejects invalid signature sizes" do + invalid_signature = :crypto.strong_rand_bytes(95) + refute BLS.validate_signature(invalid_signature) + end + + test "rejects uncompressed public keys (c_flag = 0)" do + # Create a 48-byte public key with c_flag = 0 (bit 7 = 0) + invalid_pubkey = <<0x00, :crypto.strong_rand_bytes(47)::binary>> + refute BLS.validate_public_key(invalid_pubkey) + end + + test "rejects uncompressed signatures (c_flag = 0)" do + # Create a 96-byte signature with c_flag = 0 (bit 7 = 0) + invalid_signature = <<0x00, :crypto.strong_rand_bytes(95)::binary>> + refute BLS.validate_signature(invalid_signature) + end + + test "rejects infinity public key (pytezos/py_ecc behavior)" do + # pytezos/py_ecc reject infinity points for public keys (KeyValidate requirement) + infinity_pubkey = <<0xC0, 0::size(376)>> + refute BLS.validate_public_key(infinity_pubkey) + end + + test "validates infinity signature with correct flags" do + # Infinity point: c_flag=1, b_flag=1, a_flag=0, rest=0 + infinity_signature = <<0xC0, 0::size(760)>> + # pytezos/py_ecc allow infinity signatures, so this should pass + # If this fails, it means our G2.from_compressed_bytes doesn't handle infinity properly + assert BLS.validate_signature(infinity_signature) + end + + test "rejects infinity public key with wrong a_flag" do + # Infinity point with a_flag=1 (invalid) + invalid_infinity = <<0xE0, 0::size(376)>> + refute BLS.validate_public_key(invalid_infinity) + end + + test "rejects infinity signature with wrong a_flag" do + # Infinity point with a_flag=1 (invalid) + invalid_infinity = <<0xE0, 0::size(760)>> + refute BLS.validate_signature(invalid_infinity) + end + + test "rejects infinity public key with non-zero data" do + # Infinity point with non-zero trailing data + invalid_infinity = <<0xC0, 1::size(376)>> + refute BLS.validate_public_key(invalid_infinity) + end + + test "rejects infinity signature with non-zero data" do + # Infinity point with non-zero trailing data + invalid_infinity = <<0xC0, 1::size(760)>> + refute BLS.validate_signature(invalid_infinity) + end + end + + describe "ciphersuite information" do + test "returns supported ciphersuites" do + ciphersuites = BLS.ciphersuites() + + assert is_map(ciphersuites) + assert Map.has_key?(ciphersuites, :basic) + assert Map.has_key?(ciphersuites, :message_augmentation) + assert Map.has_key?(ciphersuites, :proof_of_possession) + assert Map.has_key?(ciphersuites, :pop_tag) + + assert ciphersuites.basic == "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_" + assert ciphersuites.message_augmentation == "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_AUG_" + assert ciphersuites.proof_of_possession == "BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_" + assert ciphersuites.pop_tag == "BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_" + end + end + + describe "utility functions" do + test "returns correct sizes" do + sizes = BLS.sizes() + assert sizes.secret_key == 32 + assert sizes.public_key == 48 + assert sizes.signature == 96 + end + + test "hex conversion works" do + binary_data = :crypto.strong_rand_bytes(32) + hex_string = BLS.to_hex(binary_data) + {:ok, ^binary_data} = BLS.from_hex(hex_string) + end + end + + describe "compatibility tests" do + test "point parsing from compressed bytes with known coordinates" do + # Test parsing known values + pubkey_hex = + "a0ec3e71a719a25208adc97106b122809210faf45a17db24f10ffb1ac014fac1ab95a4a1967e55b185d4df622685b9e8" + + signature_hex = + "8ad7a4eabe11532403a1ab37ccbe74c7fe7d6bc27007bfcb95044895f87e475f9e61ddaa4d0279632b65cd1a59373f80039fa2d07a2120c2ad1a2b8383461bcecee9dbdd3c6591e04bcd82993622b6eb2b07dd8342ae946dad92db4a56ffc316" + + pubkey_bytes = Base.decode16!(pubkey_hex, case: :lower) + signature_bytes = Base.decode16!(signature_hex, case: :lower) + + # Parse and verify public key + {:ok, parsed_pubkey} = G1.from_compressed_bytes(pubkey_bytes) + assert G1.is_on_curve?(parsed_pubkey), "Parsed public key should be on G1 curve" + + # Parse and verify signature + {:ok, parsed_signature} = G2.from_compressed_bytes(signature_bytes) + assert G2.is_on_curve?(parsed_signature), "Parsed signature should be on G2 curve" + + # Test round-trip serialization + serialized_pubkey = G1.to_compressed_bytes(parsed_pubkey) + serialized_signature = G2.to_compressed_bytes(parsed_signature) + + assert serialized_pubkey == pubkey_bytes, "Public key round-trip should be identical" + assert serialized_signature == signature_bytes, "Signature round-trip should be identical" + end + + test "low-level pairing verification with known values" do + # Use exact coordinates for empty message verification + pubkey_x = + 142_036_200_970_556_624_605_073_339_739_716_744_617_077_682_387_984_972_381_936_269_453_125_935_753_630_950_483_159_198_151_608_064_417_555_529_710_056 + + pubkey_y = + 2_483_494_160_399_800_422_427_930_336_203_951_581_951_052_469_458_383_510_716_081_548_592_681_084_410_931_468_919_725_454_864_933_967_710_957_791_304_295 + + sig_x_a = + 557_719_713_869_100_899_792_735_884_412_423_612_752_925_881_945_432_348_574_028_740_116_863_323_339_684_350_929_614_457_611_309_439_314_165_214_724_886 + + sig_x_b = + 1_668_791_965_312_059_807_836_457_317_548_672_608_113_440_586_303_859_886_296_761_628_901_343_970_400_435_590_790_624_209_989_472_464_715_037_160_193_920 + + sig_y_a = + 3_073_558_251_484_980_343_618_784_409_303_188_474_526_020_343_185_103_665_020_440_010_927_948_686_879_511_955_294_538_475_807_051_866_826_701_497_014_912 + + sig_y_b = + 778_661_423_249_852_692_826_483_507_760_768_582_493_998_384_356_189_540_302_297_378_665_649_876_608_360_426_793_842_453_334_594_919_142_738_701_066_797 + + msg_x_a = + 609_541_815_135_480_221_679_491_242_259_596_245_016_291_916_671_741_893_976_316_083_730_115_841_428_606_231_710_189_903_049_358_019_248_894_441_560_542 + + msg_x_b = + 186_747_868_399_426_064_906_369_644_332_149_454_294_179_749_493_253_257_167_219_271_286_836_336_506_747_557_358_696_764_962_145_021_492_044_539_904_337 + + msg_y_a = + 2_944_887_354_525_313_774_795_512_408_027_209_769_132_902_684_354_376_758_581_454_117_346_493_697_901_743_995_034_341_243_540_233_875_062_030_159_838_087 + + msg_y_b = + 1_196_298_788_400_668_221_187_358_257_261_487_369_865_394_703_637_276_346_955_320_329_475_990_617_992_721_247_881_887_960_289_674_012_968_460_753_206_253 + + msg_z_a = + 1_254_755_152_323_327_664_477_040_668_423_524_855_088_184_820_139_927_603_845_787_718_063_604_803_235_971_326_362_296_876_378_755_690_202_205_033_458_489 + + msg_z_b = + 3_124_154_259_619_641_509_686_987_122_807_511_096_998_334_884_882_650_306_578_241_574_630_753_320_394_444_748_947_866_047_601_657_825_697_043_248_941_337 + + # Build the points + pubkey_point = %{ + x: Fq.from_integer(pubkey_x), + y: Fq.from_integer(pubkey_y), + z: Fq.from_integer(1) + } + + signature_point = %{ + x: {Fq.from_integer(sig_x_a), Fq.from_integer(sig_x_b)}, + y: {Fq.from_integer(sig_y_a), Fq.from_integer(sig_y_b)}, + z: {Fq.from_integer(1), Fq.from_integer(0)} + } + + message_point = %{ + x: {Fq.from_integer(msg_x_a), Fq.from_integer(msg_x_b)}, + y: {Fq.from_integer(msg_y_a), Fq.from_integer(msg_y_b)}, + z: {Fq.from_integer(msg_z_a), Fq.from_integer(msg_z_b)} + } + + # Verify points are valid + assert G1.is_on_curve?(pubkey_point), "Public key point should be on G1 curve" + assert G2.is_on_curve?(signature_point), "Signature point should be on G2 curve" + assert G2.is_on_curve?(message_point), "Message point should be on G2 curve" + + # Compute pairing verification: e(G1, signature) == e(pubkey, H(msg)) + g1_generator = G1.generator() + e1 = Pairing.pairing(g1_generator, signature_point) + e2 = Pairing.pairing(pubkey_point, message_point) + + assert Tezex.Crypto.BLS.Fq12.eq?(e1, e2), "Pairings should be equal for valid BLS signature" + end + + test "empty message verification" do + seed = <<123::size(256)>> + message = "" + + expected_pubkey_hex = + "a0ec3e71a719a25208adc97106b122809210faf45a17db24f10ffb1ac014fac1ab95a4a1967e55b185d4df622685b9e8" + + expected_signature_hex = + "8ad7a4eabe11532403a1ab37ccbe74c7fe7d6bc27007bfcb95044895f87e475f9e61ddaa4d0279632b65cd1a59373f80039fa2d07a2120c2ad1a2b8383461bcecee9dbdd3c6591e04bcd82993622b6eb2b07dd8342ae946dad92db4a56ffc316" + + expected_pubkey = Base.decode16!(expected_pubkey_hex, case: :lower) + expected_signature = Base.decode16!(expected_signature_hex, case: :lower) + + # Generate using our implementation + {:ok, bls_key} = BLS.from_seed(seed) + actual_pubkey = BLS.get_public_key(bls_key) + actual_signature = BLS.sign(bls_key, message) + + verification_result = BLS.verify(expected_signature, message, expected_pubkey) + verification_result_ours = BLS.verify(actual_signature, message, actual_pubkey) + + # Both should be true + assert expected_pubkey == actual_pubkey + assert expected_signature == actual_signature + assert verification_result + assert verification_result_ours + end + end +end diff --git a/test/crypto/crypto_encrypted_integration_test.exs b/test/crypto/crypto_encrypted_integration_test.exs new file mode 100644 index 0000000..bf1c80e --- /dev/null +++ b/test/crypto/crypto_encrypted_integration_test.exs @@ -0,0 +1,62 @@ +defmodule Tezex.Crypto.EncryptedIntegrationTest do + use ExUnit.Case, async: true + + alias Tezex.Crypto + + describe "encrypted key integration with main Crypto module" do + test "signs messages with encrypted P256 key" do + encrypted_key = + "p2esk2TFqgNcoT4u99ut5doGTUFNwo9x4nNvkpM6YMLqXrt4SbFdQnqLM3hoAXLMB2uZYazj6LZGvcoYzk16H6Et" + + passphrase = "test1234" + + # Test signing a message with the encrypted key + message = "hello world" + + signature = Crypto.sign_message({encrypted_key, passphrase}, message) + + # The signature should be valid and in P256 format + assert String.starts_with?(signature, "p2sig") + # P256 signature length in base58 + assert String.length(signature) == 98 + end + + test "signs operations with encrypted P256 key" do + encrypted_key = + "p2esk2TFqgNcoT4u99ut5doGTUFNwo9x4nNvkpM6YMLqXrt4SbFdQnqLM3hoAXLMB2uZYazj6LZGvcoYzk16H6Et" + + passphrase = "test1234" + + # Test signing an operation with the encrypted key + operation_bytes = "030000000000000000000000000000000000000000000000000000000000000000" + + signature = Crypto.sign_operation({encrypted_key, passphrase}, operation_bytes) + + # The signature should be valid and in P256 format + assert String.starts_with?(signature, "p2sig") + # P256 signature length in base58 + assert String.length(signature) == 98 + end + + test "fails to sign without passphrase for encrypted key" do + encrypted_key = + "p2esk2TFqgNcoT4u99ut5doGTUFNwo9x4nNvkpM6YMLqXrt4SbFdQnqLM3hoAXLMB2uZYazj6LZGvcoYzk16H6Et" + + # Should throw an error when trying to use encrypted key without passphrase + assert catch_throw(Crypto.sign_message(encrypted_key, "hello world")) == + "missing passphrase" + end + + test "works with unencrypted P256 key for backward compatibility" do + unencrypted_key = "p2sk3eRQXajR4mYdScB16aZU3q6Kxo9YvvaXqiPSRsWmxQP3vkmqQn" + + # Test signing with unencrypted key (should still work) + message = "hello world" + signature = Crypto.sign_message(unencrypted_key, message) + + # The signature should be valid and in P256 format + assert String.starts_with?(signature, "p2sig") + assert String.length(signature) == 98 + end + end +end diff --git a/test/crypto/encrypted_key_test.exs b/test/crypto/encrypted_key_test.exs new file mode 100644 index 0000000..1424e2f --- /dev/null +++ b/test/crypto/encrypted_key_test.exs @@ -0,0 +1,107 @@ +defmodule Tezex.Crypto.EncryptedKeyTest do + use ExUnit.Case, async: true + + alias Tezex.Crypto.PrivateKey + + describe "from_encoded_key/2" do + test "decrypts P256 encrypted key with correct passphrase" do + encrypted_key = + "p2esk2TFqgNcoT4u99ut5doGTUFNwo9x4nNvkpM6YMLqXrt4SbFdQnqLM3hoAXLMB2uZYazj6LZGvcoYzk16H6Et" + + passphrase = "test1234" + + assert {:ok, private_key} = PrivateKey.from_encoded_key(encrypted_key, passphrase) + assert private_key.curve.name == :prime256v1 + + # The decrypted secret should be 32 bytes + assert byte_size(private_key.secret) == 32 + + # Expected decrypted key from pytezos reference + expected_secret = "37a6ff1868a581d09c60377239a95601dc73cd659ecf5d3cee14461c8b8efc9e" + expected_secret_bytes = Base.decode16!(String.upcase(expected_secret)) + + assert private_key.secret == expected_secret_bytes + end + + test "fails to decrypt P256 encrypted key with wrong passphrase" do + encrypted_key = + "p2esk2TFqgNcoT4u99ut5doGTUFNwo9x4nNvkpM6YMLqXrt4SbFdQnqLM3hoAXLMB2uZYazj6LZGvcoYzk16H6Et" + + wrong_passphrase = "wrongpassword" + + assert {:error, :decryption_failed} = + PrivateKey.from_encoded_key(encrypted_key, wrong_passphrase) + end + + test "fails to decrypt encrypted key without passphrase" do + encrypted_key = + "p2esk2TFqgNcoT4u99ut5doGTUFNwo9x4nNvkpM6YMLqXrt4SbFdQnqLM3hoAXLMB2uZYazj6LZGvcoYzk16H6Et" + + assert {:error, :passphrase_required} = PrivateKey.from_encoded_key(encrypted_key) + end + + test "decrypts unencrypted P256 key" do + # This is a test P256 private key (not encrypted) + unencrypted_key = "p2sk3eRQXajR4mYdScB16aZU3q6Kxo9YvvaXqiPSRsWmxQP3vkmqQn" + + assert {:ok, private_key} = PrivateKey.from_encoded_key(unencrypted_key) + assert private_key.curve.name == :prime256v1 + assert byte_size(private_key.secret) == 32 + end + + test "handles invalid base58 encoding" do + # Use a key with correct length but invalid base58 characters + invalid_key = + "p2esk2TFqgNcoT4u99ut5doGTUFNwo9x4nNvkpM6YMLqXrt4SbFdQnqLM3hoAXLMB2uZYazj6LZGvcoYzk16H6E0" + + assert {:error, :invalid_base58} = PrivateKey.from_encoded_key(invalid_key, "test1234") + end + + test "handles unsupported key format" do + unsupported_key = "xyz123" + + assert {:error, :invalid_curve_prefix} = PrivateKey.from_encoded_key(unsupported_key) + end + + test "handles public key instead of secret key" do + public_key = "p2pk65zwHGP9MdvANKkp267F4VzoKqL8DMNpPfTHUNKbm8S9DUqqdpw" + + assert {:error, :not_secret_key} = PrivateKey.from_encoded_key(public_key) + end + + test "handles wrong key length" do + # Truncated key + short_key = "p2esk2TFqgNcoT4u99ut5doGTUFNwo9x4nNvkpM6YMLqXrt4SbFdQnqLM3hoA" + + assert {:error, :invalid_key_length} = PrivateKey.from_encoded_key(short_key, "test1234") + end + end + + describe "NaCl decryption" do + test "correctly decrypts test data matching pytezos" do + # Test data from pytezos implementation + encrypted_sk = + Base.decode16!( + "970AD820D4790BA150A03D1EB291D3F4958A75A7BA61E66FAC47F7DDFA1E73F32B8021AAF392B62F845D6E6844C26D76" + ) + + salt = Base.decode16!("E52FC24E6528891E") + passphrase = "test1234" + + expected_plaintext = + Base.decode16!("37A6FF1868A581D09C60377239A95601DC73CD659ECF5D3CEE14461C8B8EFC9E") + + # Derive encryption key using PBKDF2-HMAC-SHA512 + encryption_key = :crypto.pbkdf2_hmac(:sha512, passphrase, salt, 32768, 32) + + # Decrypt using NaCl secretbox with zero nonce + # 24 bytes of zeros + nonce = <<0::192>> + + assert {:ok, decrypted} = + Tezex.Crypto.NaCl.crypto_secretbox_open(encrypted_sk, nonce, encryption_key) + + assert decrypted == expected_plaintext + end + end +end diff --git a/test/crypto_test.exs b/test/crypto_test.exs index 7501437..0605bb6 100644 --- a/test/crypto_test.exs +++ b/test/crypto_test.exs @@ -8,6 +8,7 @@ defmodule Tezex.CryptoTest do describe "check_signature/4" do @msg_sig_pubkey [ %{ + "test_name" => "tz1_ed25519_message_1", "address" => "tz1LKpeN8ZSSFNyTWiBNaE4u4sjaq7J1Vz2z", "message" => "0554657a6f73205369676e6564204d6573736167653a2074657a6f732d746573742d642e61707020323032312d30382d31365430383a35343a34362e3638395a206c6f6c", @@ -16,6 +17,7 @@ defmodule Tezex.CryptoTest do "edsigtqqVg7CM2ynDXRHUfVEdL4LxKAJ1xrM1poMPXZdwVQ3SQ6YBkqPcAuaGYbeqUTrg374dDNFvJKCVfMA7T1Vrotg91Hsuam" }, %{ + "test_name" => "tz1_ed25519_message_2", "address" => "tz1LKpeN8ZSSFNyTWiBNaE4u4sjaq7J1Vz2z", "message" => "0554657a6f73205369676e6564204d6573736167653a2074657a6f732d746573742d642e61707020323032312d30382d31365432303a34353a35342e3034365a206c6f6c", @@ -24,6 +26,7 @@ defmodule Tezex.CryptoTest do "edsigtv33HXjUxCSgs8SQ3DMpmJ1eVtAShY3RcSd1L9K1Y5pJnUm3Fg5eF2fHgX1NdaSKc5yGo5T5C6VYpj7nUJvi62TWdf8Fco" }, %{ + "test_name" => "tz1_ed25519_different_address_1", "address" => "tz1L4TadX36D5bqmmBQKg1g4CmvjHtk67toL", "message" => "0554657a6f73205369676e6564204d6573736167653a2074657a6f732d746573742d642e61707020323032312d30382d31365432303a34393a34382e3232335a206c6f6c", @@ -32,6 +35,7 @@ defmodule Tezex.CryptoTest do "edsigtieZWEzUjmuUeLveR2sz7vN1MmAPQt6XNREuTLGtvLg92w8k2Uo5kdZ217KF6mojP3BfoB2adXTuXEv9a8j543tSRNT9zQ" }, %{ + "test_name" => "tz1_ed25519_different_address_2", "address" => "tz1L4TadX36D5bqmmBQKg1g4CmvjHtk67toL", "message" => "0554657a6f73205369676e6564204d6573736167653a2074657a6f732d746573742d642e61707020323032312d30382d31365432303a35303a33392e3339365a206c6f6c", @@ -40,6 +44,7 @@ defmodule Tezex.CryptoTest do "edsigtbrU2NdAJDjdLNZV7sFSFpTpHGzCbmeirDzkJRVatnkwDDyr79Q9LVEb3v4FJrnm2ofsdhXCox3SoknNtKtU1f6PwX3EvG" }, %{ + "test_name" => "tz1_ed25519_third_address", "address" => "tz1QGCWjNpYmcS6T9qFGYSam25e36WeFUCK4", "message" => "0554657a6f73205369676e6564204d6573736167653a2074657a6f732d746573742d642e61707020323032312d30382d32335431353a32363a33312e3036315a206c6f6c", @@ -48,6 +53,7 @@ defmodule Tezex.CryptoTest do "edsigtivh3xjQpj1XfR2WUMS9vyTtzJxKiGphFcDDUs5Wej1EwAvPNWnnQaPb1hq2QxiChkEh4SjnZopqAv3paCCwfzupochfpB" }, %{ + "test_name" => "tz2_secp256k1_objkt_signin", "address" => "tz2BC83pvEAag6r2ZV7kPghNAbjFoiqhCvZx", "message" => "05010000007154657a6f73205369676e6564204d6573736167653a207369676e206d6520696e20617320747a32424338337076454161673672325a56376b5067684e41626a466f69716843765a78206f6e206f626a6b742e636f6d20617420323032312d31302d30345431383a35393a31332e3939305a", @@ -56,6 +62,7 @@ defmodule Tezex.CryptoTest do "spsig1ZNQaUKNERZSiEiNviqa5EAPkcNASXhfkXtxRatZTDZAnUB4Ra2Jus8b1oEpFnPx8Z6g28pd8vK3R8nPK29JDU5FiSLH5T" }, %{ + "test_name" => "tz2_secp256k1_identity_confirmation_1", "address" => "tz2TDtxBkfAy6nyNWnTE7xasWWQppkyKZjDE", "message" => "05010000008654657a6f73205369676e6564204d6573736167653a20436f6e6669726d696e67206d79206964656e7469747920617320747a3254447478426b664179366e794e576e54453778617357575170706b794b5a6a4445206f6e206f626a6b742e636f6d2c207369673a4647553039744c3158356b79753665756b487958345a6849376371696c5463", @@ -64,6 +71,7 @@ defmodule Tezex.CryptoTest do "spsig1EjqdcNBd2jn2QB8Btn9ZujBBi3pUULPRh8nTx4ACPSQLZKSHh4ihM9BdQ7uPCx5MaZd6gpErzXnUijRSd3E8BqcK5XHrg" }, %{ + "test_name" => "tz2_secp256k1_identity_confirmation_2", "address" => "tz2MVBy9uE95nYoigrX8wE5w58bQWMNyt6jT", "message" => "05010000008654657a6f73205369676e6564204d6573736167653a20436f6e6669726d696e67206d79206964656e7469747920617320747a324d56427939754539356e596f69677258387745357735386251574d4e7974366a54206f6e206f626a6b742e636f6d2c207369673a6279644244343078753546746d6b3466336652646558333855784f5f2d3870", @@ -72,6 +80,7 @@ defmodule Tezex.CryptoTest do "spsig1W2NuFBUoFgPcATxbcHKkRqqakqwzYcunA84XPPnUK8UJydRFr8tChq87YTYpyknba5Bzj7jenYcKVAYRPgqXpy9QtPYNT" }, %{ + "test_name" => "tz3_p256_message_1", "address" => "tz3bPFa6mGv8m4Ppn7w5KSDyAbEPwbJNpC9p", "message" => "0554657a6f73205369676e6564204d6573736167653a2074657a6f732d746573742d642e61707020323032312d31302d30345431333a30303a34332e3830305a206c6f6c", @@ -80,17 +89,28 @@ defmodule Tezex.CryptoTest do "p2sigNdsHrSzfc7oQMFjrbXFiZe3SM4329j1VY6GYY7kRG6Q4L4Ljw1gT4wKRrRqDECxQwTfs967gpe4EDQJo6cSgB79TmFCfg" }, %{ + "test_name" => "tz3_p256_identity_confirmation", "address" => "tz3bPFa6mGv8m4Ppn7w5KSDyAbEPwbJNpC9p", "message" => "05010000008654657a6f73205369676e6564204d6573736167653a20436f6e6669726d696e67206d79206964656e7469747920617320747a3362504661366d4776386d3450706e3777354b5344794162455077624a4e70433970206f6e206f626a6b742e636f6d2c207369673a484d675a79764d51697579684b4171304f7835374963795841344f6a456348", "pubkey" => "p2pk65yRxCX65k6qRPrbqGWvfW5JnLB1p3dn1oM5o9cyqLKPPhJaBMa", "signature" => "p2sigkXYeimNtVJDr8X3me62Rig7cPogEQ3NPYHcGgnGuuFFy2BqttXHnst3mYyJ68k3jdAE92TKbyGC9hcmmVy1YbxyTJXAc1" + }, + %{ + "test_name" => "tz4_bls_message", + "address" => "tz4NWRt3aFyFU2Ydah917Eehxv6uf97j8tpZ", + "message" => + "0554657a6f73205369676e6564204d6573736167653a2074657a6f732d746573742d642e61707020323032312d30382d31365430383a35343a34362e3638395a206c6f6c", + "pubkey" => + "BLpk1nuQ6M54P2zN2gX2maUUzRa3QVX2eSnXZa184g2sfu2qB2XCGYJdZezkgb7ozbbfYUny1gcP", + "signature" => + "BLsigBCNyUiH3Z5rtuWLyeRutnjeDSiZTcC1owGxXBEJUnfcAaCTciHLjH3asupDYbeu5NvmuUdKyV9r6DHhXQF2w1qiYo4tPVX11v9HnrracZ9DeZdeo8bKyHDDYDVVh4neP36Rs5iM26" } ] test "verifies valid signatures" do - for {params, idx} <- Enum.with_index(@msg_sig_pubkey, 1) do + for params <- @msg_sig_pubkey do try do assert :ok = Crypto.check_signature( @@ -100,9 +120,8 @@ defmodule Tezex.CryptoTest do params["pubkey"] ) rescue - err -> - IO.inspect("sig check failed", label: idx) - raise err + _err -> + flunk(params["test_name"] <> ": sig check failed") end end end @@ -122,7 +141,7 @@ defmodule Tezex.CryptoTest do test "does not verify invalid signatures" do # modify one character in the valid signed message to make it invalid - for {params, idx} <- Enum.with_index(@msg_sig_pubkey, 1) do + for params <- @msg_sig_pubkey do try do msg = replace_nth_character(params["message"]) @@ -134,14 +153,13 @@ defmodule Tezex.CryptoTest do params["pubkey"] ) rescue - err -> - IO.inspect("sig check should have failed", label: idx) - raise err + _ -> + flunk(params["test_name"] <> ": sig check should have failed") end end # modify one character in the valid signature to make it invalid - for {params, idx} <- Enum.with_index(@msg_sig_pubkey, 1) do + for params <- @msg_sig_pubkey do try do sig = replace_nth_character(params["signature"]) @@ -153,14 +171,13 @@ defmodule Tezex.CryptoTest do params["pubkey"] ) rescue - err -> - IO.inspect("sig check should have failed", label: idx) - raise err + _ -> + flunk(params["test_name"] <> ": sig check should have failed") end end # modify one character in the valid pubkey to make it invalid - for {params, idx} <- Enum.with_index(@msg_sig_pubkey, 1) do + for params <- @msg_sig_pubkey do try do pubkey = replace_nth_character(params["pubkey"]) @@ -172,14 +189,13 @@ defmodule Tezex.CryptoTest do pubkey ) rescue - err -> - IO.inspect("sig check should have failed", label: idx) - raise err + _ -> + flunk(params["test_name"] <> ": sig check should have failed") end end # modify one character in the address to make it invalid - for {params, idx} <- Enum.with_index(@msg_sig_pubkey, 1) do + for params <- @msg_sig_pubkey do try do address = replace_nth_character(params["address"]) @@ -191,9 +207,8 @@ defmodule Tezex.CryptoTest do params["pubkey"] ) rescue - err -> - IO.inspect("sig check should have failed", label: idx) - raise err + _ -> + flunk(params["test_name"] <> ": sig check should have failed") end end end @@ -207,7 +222,9 @@ defmodule Tezex.CryptoTest do {"tz2BC83pvEAag6r2ZV7kPghNAbjFoiqhCvZx", "sppk7aBerAEA6tv4wzg6FnK7i5YrGtEGFVvNjWhc2QX8bhzpouBVFSW"}, {"tz1LKpeN8ZSSFNyTWiBNaE4u4sjaq7J1Vz2z", - "edpktsPhZ8weLEXqf4Fo5FS9Qx8ZuX4QpEBEwe63L747G8iDjTAF6w"} + "edpktsPhZ8weLEXqf4Fo5FS9Qx8ZuX4QpEBEwe63L747G8iDjTAF6w"}, + {"tz4NWRt3aFyFU2Ydah917Eehxv6uf97j8tpZ", + "BLpk1nuQ6M54P2zN2gX2maUUzRa3QVX2eSnXZa184g2sfu2qB2XCGYJdZezkgb7ozbbfYUny1gcP"} ] for {{pubkeyhash, pubkey}, idx} <- Enum.with_index(hashes, 1) do @@ -320,40 +337,87 @@ defmodule Tezex.CryptoTest do assert true == sign_and_verify(encoded_private_key, pubkey) end - # test "Tz3 Encrypted" do - # encoded_private_key = - # "p2esk2TFqgNcoT4u99ut5doGTUFNwo9x4nNvkpM6YMLqXrt4SbFdQnqLM3hoAXLMB2uZYazj6LZGvcoYzk16H6Et" + test "Tz3 Encrypted" do + encoded_private_key = + "p2esk2TFqgNcoT4u99ut5doGTUFNwo9x4nNvkpM6YMLqXrt4SbFdQnqLM3hoAXLMB2uZYazj6LZGvcoYzk16H6Et" + + passphrase = "test1234" + pubkey = "p2pk65zwHGP9MdvANKkp267F4VzoKqL8DMNpPfTHUNKbm8S9DUqqdpw" + pubkeyhash = "tz3hFR7NZtjT2QtzgMQnWb4xMuD6yt2YzXUt" + secret_key = "p2sk2mJNRYqs3UXJzzF44Ym6jk38RVDPVSuLCfNd5ShE5zyVdu8Au9" + + bytes = "1234" + watermark = <<3>> + + signature = + "p2sigZ2hYCrFfEfRUgXCh6Ea7v6LaTvb5biLBhXgzpVZ8AAZ1r8bdxdyiEh2ia7kKJVXAJ19VwdHPosdNTK76PjneDtTUG3tWU" + + assert Crypto.derive_address(pubkey) == {:ok, pubkeyhash} + assert signature == Crypto.sign({encoded_private_key, passphrase}, bytes, watermark) + assert signature == Crypto.sign({secret_key, passphrase}, bytes, watermark) + end + + test "Tz3 Encrypted with bytes producing signature that needs padding" do + encoded_private_key = "p2sk2ke47zhFz3znRZj39TW5KKS9VgfU1Hax7KeErgnShNe9oQFQUP" + _pubkeyhash = "tz3bBDnPj3Bvek1DeJtsTvicBUPEoTpm2ySt" + secret_key = "p2sk2ke47zhFz3znRZj39TW5KKS9VgfU1Hax7KeErgnShNe9oQFQUP" - # passphrase = "test1234" - # pubkey = "p2pk65zwHGP9MdvANKkp267F4VzoKqL8DMNpPfTHUNKbm8S9DUqqdpw" - # pubkeyhash = "tz3hFR7NZtjT2QtzgMQnWb4xMuD6yt2YzXUt" - # secret_key = "p2sk2mJNRYqs3UXJzzF44Ym6jk38RVDPVSuLCfNd5ShE5zyVdu8Au9" + bytes = + "03051d7ba791fbe8ccfb6f83dd9c760db5642358909eede2a915a26275e6880b9a6c02a2dea17733a2ef2685e5511bd3f160fd510fea7db50edd8122997800c0843d016910882a9436c31ce1d51570e21ae277bb8d91b800006c02a2dea17733a2ef2685e5511bd3f160fd510fea7df416de812294cd010000016910882a9436c31ce1d51570e21ae277bb8d91b800ff020000004602000000410320053d036d0743035d0100000024747a31655935417161316b5844466f6965624c3238656d7958466f6e65416f5667317a68031e0743036a0032034f034d031b6c02a2dea17733a2ef2685e5511bd3f160fd510fea7dd016df8122a6ca010000016910882a9436c31ce1d51570e21ae277bb8d91b800ff020000003e02000000390320053d036d0743035d0100000024747a3161575850323337424c774e484a6343443462334475744365766871713254315a390346034e031b6c02a2dea17733a2ef2685e5511bd3f160fd510fea7dc916e08122dec9010000016910882a9436c31ce1d51570e21ae277bb8d91b800ff0200000013020000000e0320053d036d053e035d034e031b" - # bytes = "1234" - # watermark = <<3>> + signature = + "p2sigMMsHbzzKh6Eg3cDxfLURiUpTMkyjyPWd7RFtBUH7ZyGBzBqMZH9xZc16akQWZNKkCMHnf1vYjjckPEfru456ikHaFWXFD" - # signature = - # "sigZiUh7khZmjP1kGSSNe3LQdZC5GMpWHuyFkqcR37pwiGUJrpKaatUxWcRPBE5sHwqfydUsPM4JvK14dBMoHbCxC7VHdMZC" + assert signature == Crypto.sign(encoded_private_key, bytes) + assert signature == Crypto.sign(secret_key, bytes) + end - # assert Crypto.derive_address(pubkey) == {:ok, pubkeyhash} - # assert signature == Crypto.sign({encoded_private_key, passphrase}, bytes, watermark) - # assert signature == Crypto.sign({secret_key, passphrase}, bytes, watermark) - # end + test "Tz4 BLS key derivation and basic operations" do + pubkey = "BLpk1nuQ6M54P2zN2gX2maUUzRa3QVX2eSnXZa184g2sfu2qB2XCGYJdZezkgb7ozbbfYUny1gcP" + pubkeyhash = "tz4NWRt3aFyFU2Ydah917Eehxv6uf97j8tpZ" - # test "Tz3 Encrypted with bytes producing signature that needs padding" do - # encoded_private_key = "p2sk2ke47zhFz3znRZj39TW5KKS9VgfU1Hax7KeErgnShNe9oQFQUP" - # pubkeyhash = "tz3bBDnPj3Bvek1DeJtsTvicBUPEoTpm2ySt" - # secret_key = "p2sk2ke47zhFz3znRZj39TW5KKS9VgfU1Hax7KeErgnShNe9oQFQUP" + assert Crypto.derive_address(pubkey) == {:ok, pubkeyhash} + assert Crypto.validate_address(pubkeyhash) == :ok + assert Crypto.check_address(pubkeyhash, pubkey) == :ok + {:ok, extracted_key} = Crypto.extract_pubkey(pubkey) + assert byte_size(extracted_key) == 48 + end - # bytes = - # "03051d7ba791fbe8ccfb6f83dd9c760db5642358909eede2a915a26275e6880b9a6c02a2dea17733a2ef2685e5511bd3f160fd510fea7db50edd8122997800c0843d016910882a9436c31ce1d51570e21ae277bb8d91b800006c02a2dea17733a2ef2685e5511bd3f160fd510fea7df416de812294cd010000016910882a9436c31ce1d51570e21ae277bb8d91b800ff020000004602000000410320053d036d0743035d0100000024747a31655935417161316b5844466f6965624c3238656d7958466f6e65416f5667317a68031e0743036a0032034f034d031b6c02a2dea17733a2ef2685e5511bd3f160fd510fea7dd016df8122a6ca010000016910882a9436c31ce1d51570e21ae277bb8d91b800ff020000003e02000000390320053d036d0743035d0100000024747a3161575850323337424c774e484a6343443462334475744365766871713254315a390346034e031b6c02a2dea17733a2ef2685e5511bd3f160fd510fea7dc916e08122dec9010000016910882a9436c31ce1d51570e21ae277bb8d91b800ff0200000013020000000e0320053d036d053e035d034e031b" + test "Tz4 BLS signature operations" do + privkey = "BLsk2hQz7rrgdKcEoH1ppX9jLDhgjApqEqMxLHbBfAx2chjLDnYnLe" + pubkey = "BLpk1nuQ6M54P2zN2gX2maUUzRa3QVX2eSnXZa184g2sfu2qB2XCGYJdZezkgb7ozbbfYUny1gcP" + pubkeyhash = "tz4NWRt3aFyFU2Ydah917Eehxv6uf97j8tpZ" - # signature = - # "p2sigMMsHbzzKh6Eg3cDxfLURiUpTMkyjyPWd7RFtBUH7ZyGBzBqMZH9xZc16akQWZNKkCMHnf1vYjjckPEfru456ikHaFWXFD" + signature = + "BLsigBCNyUiH3Z5rtuWLyeRutnjeDSiZTcC1owGxXBEJUnfcAaCTciHLjH3asupDYbeu5NvmuUdKyV9r6DHhXQF2w1qiYo4tPVX11v9HnrracZ9DeZdeo8bKyHDDYDVVh4neP36Rs5iM26" - # assert signature == Crypto.sign(encoded_private_key, bytes) - # assert signature == Crypto.sign(secret_key, bytes) - # end + message = + "0554657a6f73205369676e6564204d6573736167653a2074657a6f732d746573742d642e61707020323032312d30382d31365430383a35343a34362e3638395a206c6f6c" + + {:ok, decoded_sig} = Crypto.decode_signature(signature) + + assert byte_size(decoded_sig) == 96 + + assert Crypto.check_address(pubkeyhash, pubkey) == :ok + + new_signature = Crypto.sign(privkey, message) + assert String.starts_with?(new_signature, "BLsig") + assert {:ok, decoded_new_sig} = Crypto.decode_signature(new_signature) + assert byte_size(decoded_new_sig) == 96 + + assert Crypto.verify_signature(signature, message, pubkey) + end + + test "Tz4 BLS sign message" do + privkey = "BLsk2hQz7rrgdKcEoH1ppX9jLDhgjApqEqMxLHbBfAx2chjLDnYnLe" + test_message = "Hello Tezos BLS!" + + signature = Crypto.sign_message(privkey, test_message) + assert String.starts_with?(signature, "BLsig") + + assert {:ok, decoded_sig} = Crypto.decode_signature(signature) + assert byte_size(decoded_sig) == 96 + end end defp sign_and_verify(encoded_private_key, pubkey) do diff --git a/test/micheline_test.exs b/test/micheline_test.exs index 1ac3cb2..8c25d41 100644 --- a/test/micheline_test.exs +++ b/test/micheline_test.exs @@ -131,4 +131,48 @@ defmodule Tezex.MichelineTest do assert result == %{"prim" => "Pair", "args" => [%{"int" => "1"}, %{"int" => "12"}]} end end + + describe "tz4 address support" do + test "pack and unpack tz4 addresses" do + tz4_address = "tz4NWRt3aFyFU2Ydah917Eehxv6uf97j8tpZ" + + # Test :address type + packed_address = Micheline.pack(tz4_address, :address) + unpacked_address = Micheline.unpack(packed_address, :address) + assert unpacked_address == tz4_address + + # Test :key_hash type (this was the main issue that was fixed) + packed_key_hash = Micheline.pack(tz4_address, :key_hash) + unpacked_key_hash = Micheline.unpack(packed_key_hash, :key_hash) + assert unpacked_key_hash == tz4_address + + # Both should produce the same packed format + assert packed_address == packed_key_hash + assert packed_address == "050a000000160003941e4aef9917ba8e31fc38a2abaec2f8a5c7d9ea" + end + + test "round-trip all tezos address types with both address and key_hash" do + test_addresses = [ + {"tz1LKpeN8ZSSFNyTWiBNaE4u4sjaq7J1Vz2z", "Ed25519"}, + {"tz2BC83pvEAag6r2ZV7kPghNAbjFoiqhCvZx", "secp256k1"}, + {"tz3bPFa6mGv8m4Ppn7w5KSDyAbEPwbJNpC9p", "P256"}, + {"tz4NWRt3aFyFU2Ydah917Eehxv6uf97j8tpZ", "BLS"} + ] + + for {address, curve_name} <- test_addresses do + # Test :address type + packed_address = Micheline.pack(address, :address) + unpacked_address = Micheline.unpack(packed_address, :address) + assert unpacked_address == address, "#{curve_name} address round-trip failed" + + # Test :key_hash type + packed_key_hash = Micheline.pack(address, :key_hash) + unpacked_key_hash = Micheline.unpack(packed_key_hash, :key_hash) + assert unpacked_key_hash == address, "#{curve_name} key_hash round-trip failed" + + # Both should produce the same packed format for implicit accounts + assert packed_address == packed_key_hash, "#{curve_name} address/key_hash mismatch" + end + end + end end diff --git a/test/test_helper.exs b/test/test_helper.exs index 2b286bb..29baa94 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1,3 @@ -ExUnit.configure(exclude: [:tezos]) +ExUnit.configure(formatters: [ExUnit.CLIFormatter, ExUnitNotifier], exclude: [:tezos]) ExUnit.start()