diff --git a/CHANGELOG.md b/CHANGELOG.md index f6cc7d8..f22c3f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ and counterfactual, not-yet-deployed wallets ([ERC-6492](https://eips.ethereum.org/EIPS/eip-6492)) — against a digest, an EIP-191 personal message or EIP-712 typed data with `Ethers.Signature.verify_hash/4`, `verify_message/4` and `verify_typed_data/4` +- Add Sign-In with Ethereum ([EIP-4361](https://eips.ethereum.org/EIPS/eip-4361)) support with + `Ethers.Siwe`: build (`new/1`), render (`to_message/1`), parse (`parse/1`) and validate + (`validate/2`) SIWE messages, generate session nonces (`generate_nonce/0`), and verify a + message and its signature end-to-end with `Ethers.Siwe.verify/3` — smart-contract wallets + included via the universal signature verification above - Add [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed data support: construct typed structured data with `Ethers.TypedData`, hash it (`encode_type`, `type_hash`, `hash_struct`, `domain_separator`, `hash`), sign it via `Ethers.sign_typed_data/2` with the `Ethers.Signer.Local` diff --git a/guides/siwe.md b/guides/siwe.md new file mode 100644 index 0000000..761dd9c --- /dev/null +++ b/guides/siwe.md @@ -0,0 +1,121 @@ +# Sign-In with Ethereum + +[EIP-4361](https://eips.ethereum.org/EIPS/eip-4361) (Sign-In with Ethereum, or SIWE) is the +standard way for a wallet to authenticate to an off-chain service. The backend issues a +single-use nonce, the wallet signs a structured plaintext message containing it, and the +backend verifies the returned message and signature to establish a session — no passwords, no +OAuth provider, the Ethereum account *is* the identity. + +Ethers models the message with `Ethers.Siwe.Message` and covers the whole lifecycle in +`Ethers.Siwe`: building (`new/1`), rendering (`to_message/1`), parsing (`parse/1`), stateless +validation (`validate/2`) and full verification including the signature (`verify/3`). + +## The flow + +1. The client asks your backend for a nonce. The backend generates one with + `Ethers.Siwe.generate_nonce/0` and stores it in the session. +2. The client builds an EIP-4361 message containing that nonce and asks the wallet to sign it + with `personal_sign`. +3. The client posts the message and signature back. The backend calls `Ethers.Siwe.verify/3`, + which parses the message, validates its fields (validity window, expected domain and nonce) + and verifies the signature — including EIP-1271 and ERC-6492 smart-contract wallets. +4. On success, the backend trusts `message.address` and stores it in the session. + +## Phoenix example + +Issue the nonce: + +```elixir +def nonce(conn, _params) do + nonce = Ethers.Siwe.generate_nonce() + + conn + |> put_session(:siwe_nonce, nonce) + |> json(%{nonce: nonce}) +end +``` + +Verify the callback: + +```elixir +def verify(conn, %{"message" => raw_message, "signature" => signature}) do + case Ethers.Siwe.verify(raw_message, signature, + domain: "example.com", + nonce: get_session(conn, :siwe_nonce) + ) do + {:ok, message} -> + conn + |> delete_session(:siwe_nonce) + |> put_session(:address, message.address) + |> json(%{ok: true, address: message.address}) + + {:error, reason} -> + conn + |> put_status(401) + |> json(%{error: inspect(reason)}) + end +end +``` + +`verify/3` needs no RPC round-trip for regular (EOA) wallets. To also support +smart-contract wallets (Safe, Coinbase Smart Wallet, ERC-4337 accounts — deployed or +counterfactual), pass `rpc_opts:` so the ERC-6492 universal validator can be called on the +chain the message is bound to: + +```elixir +Ethers.Siwe.verify(raw_message, signature, + domain: "example.com", + nonce: get_session(conn, :siwe_nonce), + rpc_opts: [url: "https://eth.llamarpc.com"] +) +``` + +## Building a message server-side + +If your backend constructs the message itself (instead of a JS client library): + +```elixir +message = + Ethers.Siwe.new!( + domain: "example.com", + address: user_address, + statement: "Sign in to Example", + uri: "https://example.com", + chain_id: 1, + nonce: Ethers.Siwe.generate_nonce(), + expiration_time: DateTime.add(DateTime.utc_now(), 300) + ) + +raw = Ethers.Siwe.to_message(message) +# => "example.com wants you to sign in with your Ethereum account:\n0x..." +``` + +The rendered string is what the wallet signs. `issued_at` defaults to the current time and +`version` to `"1"`. + +## Validating without verifying + +`Ethers.Siwe.validate/2` performs only the stateless field checks — validity window +(`expiration_time` is an exclusive bound, `not_before` inclusive), expected `domain:`, +`nonce:`, `address:` and `scheme:` — without touching the signature. This is useful in tests +(inject `time:`) or when the signature was already checked elsewhere: + +```elixir +:ok = + Ethers.Siwe.validate(message, + domain: "example.com", + nonce: session_nonce, + time: DateTime.utc_now() + ) +``` + +## Security notes + +- **Always check the nonce** against the one your backend issued and invalidate it after use — + it is the replay protection. +- **Always pin the domain** to your origin. A message signed for another site must not + authenticate here. +- Set a short `expiration_time` when you build messages server-side. +- `parse/1` is strict: field order, EIP-55 checksummed addresses, RFC 3339 timestamps and + RFC 3986 domain/URIs are enforced, and a parsed message re-renders byte-for-byte identical — + so verifying the re-rendered message is safe. diff --git a/lib/ethers/siwe.ex b/lib/ethers/siwe.ex new file mode 100644 index 0000000..47e1ce0 --- /dev/null +++ b/lib/ethers/siwe.ex @@ -0,0 +1,637 @@ +defmodule Ethers.Siwe do + @moduledoc """ + Sign-In with Ethereum ([EIP-4361](https://eips.ethereum.org/EIPS/eip-4361)) messages. + + SIWE is the standard way for a wallet to authenticate to an off-chain service: the backend + issues a nonce, the wallet signs a structured plaintext message containing it, and the + backend parses, validates and verifies the returned message and signature. + + This module covers the message lifecycle: + + - `new/1` / `new!/1` - build an `Ethers.Siwe.Message` + - `generate_nonce/0` - generate a cryptographically random nonce + - `to_message/1` - render the EIP-4361 string for the wallet to sign + - `parse/1` - parse a message string received from a client + - `validate/2` - stateless validation (validity window, domain/nonce/address binding) + - `verify/3` - the one-call backend flow: parse, validate and check the signature + (including ERC-1271/ERC-6492 smart-contract wallets) + + See the [Sign-In with Ethereum guide](siwe.html) for a complete Phoenix integration recipe. + + ## Example + + iex> {:ok, message} = + ...> Ethers.Siwe.new( + ...> domain: "example.com", + ...> address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + ...> statement: "Sign in to Example", + ...> uri: "https://example.com/login", + ...> chain_id: 1, + ...> nonce: "32891756", + ...> issued_at: "2021-09-30T16:25:24.000Z" + ...> ) + iex> Ethers.Siwe.to_message(message) + "example.com wants you to sign in with your Ethereum account:\\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\\n\\nSign in to Example\\n\\nURI: https://example.com/login\\nVersion: 1\\nChain ID: 1\\nNonce: 32891756\\nIssued At: 2021-09-30T16:25:24.000Z" + """ + + alias Ethers.Signature + alias Ethers.Siwe.Message + alias Ethers.Utils + + @preamble " wants you to sign in with your Ethereum account:" + + # RFC 3986 character classes used to validate the `domain` (authority), `scheme` and + # `request_id` fields. + @unreserved_or_sub_delims "A-Za-z0-9\\-._~!$&'()*+,;=" + @pct_encoded "%[0-9A-Fa-f]{2}" + @userinfo "(?:[#{@unreserved_or_sub_delims}:]|#{@pct_encoded})*" + @reg_name "(?:[#{@unreserved_or_sub_delims}]|#{@pct_encoded})+" + @ip_literal "\\[(?:[0-9A-Fa-f:.]+|v[0-9A-Fa-f]+\\.[#{@unreserved_or_sub_delims}:]+)\\]" + + @authority_regex ~r/^(?:#{@userinfo}@)?(?:#{@ip_literal}|#{@reg_name})(?::[0-9]+)?$/ + @scheme_regex ~r/^[A-Za-z][A-Za-z0-9+\-.]*$/ + @nonce_regex ~r/^[a-zA-Z0-9]{8,}$/ + @request_id_regex ~r/^(?:[#{@unreserved_or_sub_delims}:@]|#{@pct_encoded})*$/ + @pct_encoding_regex ~r/^(?:[^%]|%[0-9A-Fa-f]{2})*$/ + @rfc3339_regex ~r/^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$/ + + @doc """ + Builds a new `Ethers.Siwe.Message` from a keyword list or map. + + Accepts atom keys (`:chain_id`) or string keys (`"chain_id"`). Required fields are + `:domain`, `:address`, `:uri`, `:chain_id` and `:nonce`. `:version` defaults to `"1"` + (the only defined version) and `:issued_at` defaults to the current UTC time. + + The address is normalized to its EIP-55 checksummed form. Timestamp fields accept either + `DateTime` structs or RFC 3339 strings (kept verbatim). + + Returns `{:error, reason}` with a descriptive atom (e.g. `:missing_domain`, + `:invalid_nonce`) when a field is missing or invalid. + + ## Examples + + iex> {:ok, message} = + ...> Ethers.Siwe.new( + ...> domain: "example.com", + ...> address: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + ...> uri: "https://example.com", + ...> chain_id: 1, + ...> nonce: Ethers.Siwe.generate_nonce() + ...> ) + iex> message.address + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + iex> message.version + "1" + + iex> Ethers.Siwe.new(domain: "example.com") + {:error, :missing_address} + """ + @spec new(Keyword.t() | map()) :: {:ok, Message.t()} | {:error, atom()} + def new(params) when is_list(params), do: params |> Map.new() |> new() + + def new(params) when is_map(params) do + with {:ok, scheme} <- validate_scheme(fetch(params, :scheme)), + {:ok, domain} <- validate_domain(fetch(params, :domain)), + {:ok, address} <- normalize_address(fetch(params, :address)), + {:ok, statement} <- validate_statement(fetch(params, :statement)), + {:ok, uri} <- validate_uri(fetch(params, :uri)), + {:ok, version} <- validate_version(fetch(params, :version) || "1"), + {:ok, chain_id} <- validate_chain_id(fetch(params, :chain_id)), + {:ok, nonce} <- validate_nonce(fetch(params, :nonce)), + {:ok, issued_at} <- + validate_timestamp(fetch(params, :issued_at) || DateTime.utc_now(), :invalid_issued_at), + {:ok, expiration_time} <- + validate_timestamp(fetch(params, :expiration_time), :invalid_expiration_time), + {:ok, not_before} <- validate_timestamp(fetch(params, :not_before), :invalid_not_before), + {:ok, request_id} <- validate_request_id(fetch(params, :request_id)), + {:ok, resources} <- validate_resources(fetch(params, :resources)) do + {:ok, + %Message{ + scheme: scheme, + domain: domain, + address: address, + statement: statement, + uri: uri, + version: version, + chain_id: chain_id, + nonce: nonce, + issued_at: issued_at, + expiration_time: expiration_time, + not_before: not_before, + request_id: request_id, + resources: resources + }} + end + end + + @doc """ + Same as `new/1` but raises `ArgumentError` on error. + """ + @spec new!(Keyword.t() | map()) :: Message.t() | no_return() + def new!(params) do + case new(params) do + {:ok, message} -> message + {:error, reason} -> raise ArgumentError, "could not build SIWE message: #{inspect(reason)}" + end + end + + @doc """ + Generates a cryptographically random alphanumeric nonce with at least 8 characters. + + Uses 128 bits of entropy from `:crypto.strong_rand_bytes/1` encoded in base 36. + """ + @spec generate_nonce() :: String.t() + def generate_nonce do + :crypto.strong_rand_bytes(16) + |> :binary.decode_unsigned() + |> Integer.to_string(36) + |> String.pad_leading(8, "0") + end + + @doc """ + Renders the message as the EIP-4361 string the wallet will sign. + + The `String.Chars` protocol is also implemented for `Ethers.Siwe.Message`, so + `to_string/1` and string interpolation work as well. + """ + @spec to_message(Message.t()) :: String.t() + def to_message(%Message{} = message) do + header = "#{scheme_prefix(message.scheme)}#{message.domain}#{@preamble}" + + lines = + [header, message.address, ""] ++ + statement_lines(message.statement) ++ + [""] ++ + required_field_lines(message) ++ + optional_field_lines(message) ++ + resource_lines(message.resources) + + Enum.join(lines, "\n") + end + + @doc """ + Parses an EIP-4361 message string into an `Ethers.Siwe.Message`. + + Parsing is strict: field order, the EIP-55 checksummed address, the nonce format, RFC 3339 + timestamps and RFC 3986 domain/URIs are all enforced. A successfully parsed message + re-renders byte-for-byte identical via `to_message/1`. + + ## Examples + + iex> raw = + ...> "example.com wants you to sign in with your Ethereum account:\\n" <> + ...> "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\\n\\n\\n" <> + ...> "URI: https://example.com\\nVersion: 1\\nChain ID: 1\\n" <> + ...> "Nonce: 32891756\\nIssued At: 2021-09-30T16:25:24.000Z" + iex> {:ok, message} = Ethers.Siwe.parse(raw) + iex> {message.domain, message.chain_id, message.statement} + {"example.com", 1, nil} + """ + @spec parse(String.t()) :: {:ok, Message.t()} | {:error, atom()} + def parse(message) when is_binary(message) do + lines = String.split(message, "\n") + + with {:ok, {raw_scheme, raw_domain}, lines} <- parse_header(lines), + {:ok, raw_address, lines} <- pop_line(lines), + {:ok, raw_statement, lines} <- parse_statement(lines), + {:ok, fields, lines} <- parse_fields(lines), + :ok <- ensure_consumed(lines), + {:ok, scheme} <- validate_scheme(raw_scheme), + {:ok, domain} <- validate_domain(raw_domain), + {:ok, address} <- validate_checksummed_address(raw_address), + {:ok, statement} <- validate_statement(raw_statement), + {:ok, uri} <- validate_uri(fields.uri), + {:ok, version} <- validate_version(fields.version), + {:ok, chain_id} <- parse_chain_id(fields.chain_id), + {:ok, nonce} <- validate_nonce(fields.nonce), + {:ok, issued_at} <- validate_timestamp(fields.issued_at, :invalid_issued_at), + {:ok, expiration_time} <- + validate_timestamp(fields.expiration_time, :invalid_expiration_time), + {:ok, not_before} <- validate_timestamp(fields.not_before, :invalid_not_before), + {:ok, request_id} <- validate_request_id(fields.request_id), + {:ok, resources} <- validate_resources(fields.resources) do + {:ok, + %Message{ + scheme: scheme, + domain: domain, + address: address, + statement: statement, + uri: uri, + version: version, + chain_id: chain_id, + nonce: nonce, + issued_at: issued_at, + expiration_time: expiration_time, + not_before: not_before, + request_id: request_id, + resources: resources + }} + end + end + + @doc """ + Statelessly validates a message against the current time and the expected binding values. + + This performs no signature verification and no RPC requests - it only checks the message + fields themselves. + + ## Options + + - `:time` - the `DateTime` to check the validity window against. Defaults to + `DateTime.utc_now/0`. The message is invalid at and after `expiration_time` (exclusive + upper bound) and before `not_before` (inclusive lower bound). + - `:domain` - the domain this backend expects (exact match). + - `:scheme` - the scheme this backend expects (exact match). + - `:nonce` - the nonce this backend issued for the session (exact match). + - `:address` - the expected signing address (case-insensitive comparison). + + Omitted options are not checked. + + Returns `:ok` or `{:error, reason}` where reason is one of `:invalid_version`, `:expired`, + `:not_yet_valid`, `:invalid_expiration_time`, `:invalid_not_before`, `:domain_mismatch`, + `:scheme_mismatch`, `:nonce_mismatch`, `:address_mismatch` or `:invalid_address`. + """ + @spec validate(Message.t(), Keyword.t()) :: :ok | {:error, atom()} + def validate(%Message{} = message, opts \\ []) do + time = Keyword.get(opts, :time) || DateTime.utc_now() + + with :ok <- check_version(message), + :ok <- check_expiration_time(message, time), + :ok <- check_not_before(message, time), + :ok <- check_exact(message.domain, Keyword.get(opts, :domain), :domain_mismatch), + :ok <- check_exact(message.scheme, Keyword.get(opts, :scheme), :scheme_mismatch), + :ok <- check_exact(message.nonce, Keyword.get(opts, :nonce), :nonce_mismatch) do + check_address(message, Keyword.get(opts, :address)) + end + end + + @doc """ + Verifies a SIWE message end-to-end: parse (when given a string), validate the fields and + check the signature. + + This is the one-call backend flow. The signature check uses + `Ethers.Signature.verify_message/4`, so EOA signatures verify locally without any RPC + round-trip while smart-contract wallets ([ERC-1271](https://eips.ethereum.org/EIPS/eip-1271), + deployed or not — [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492)) are verified with a + single `eth_call`. + + ## Parameters + + - `message`: The raw EIP-4361 message string received from the client, or an already-parsed + `Ethers.Siwe.Message`. When given a string, the signature is verified over that exact + string. + - `signature`: The signature as a `0x`-prefixed hex string or raw binary. ERC-6492-wrapped + signatures are supported. + - `opts`: Options. + + ## Options + + All of `validate/2`'s options (`:time`, `:domain`, `:scheme`, `:nonce`, `:address`) plus + the RPC options forwarded to `Ethers.Signature.verify_hash/4` for smart-wallet + verification: `:rpc_client`, `:rpc_opts` and `:block`. + + ## Returns + + - `{:ok, message}` with the parsed/validated `Ethers.Siwe.Message` on success — trust + `message.address` afterwards. + - `{:error, :invalid_signature}` if the signature does not verify for `message.address`. + - `{:error, reason}` for parse errors, `validate/2` errors or RPC transport failures. + """ + @spec verify(String.t() | Message.t(), binary(), Keyword.t()) :: + {:ok, Message.t()} | {:error, term()} + def verify(message, signature, opts \\ []) + + def verify(%Message{} = message, signature, opts) do + do_verify(message, to_message(message), signature, opts) + end + + def verify(raw_message, signature, opts) when is_binary(raw_message) do + with {:ok, message} <- parse(raw_message) do + do_verify(message, raw_message, signature, opts) + end + end + + defp do_verify(%Message{} = message, raw_message, signature, opts) do + signature_opts = Keyword.take(opts, [:rpc_client, :rpc_opts, :block]) + + with :ok <- validate(message, opts), + {:ok, true} <- + Signature.verify_message(raw_message, signature, message.address, signature_opts) do + {:ok, message} + else + {:ok, false} -> {:error, :invalid_signature} + {:error, reason} -> {:error, reason} + end + end + + ## Message rendering helpers + + defp scheme_prefix(nil), do: "" + defp scheme_prefix(scheme), do: "#{scheme}://" + + defp statement_lines(nil), do: [] + defp statement_lines(statement), do: [statement] + + defp required_field_lines(%Message{} = message) do + [ + "URI: #{message.uri}", + "Version: #{message.version}", + "Chain ID: #{message.chain_id}", + "Nonce: #{message.nonce}", + "Issued At: #{message.issued_at}" + ] + end + + defp optional_field_lines(%Message{} = message) do + for {label, value} <- [ + {"Expiration Time", message.expiration_time}, + {"Not Before", message.not_before}, + {"Request ID", message.request_id} + ], + not is_nil(value) do + "#{label}: #{value}" + end + end + + defp resource_lines([]), do: [] + defp resource_lines(resources), do: ["Resources:" | Enum.map(resources, &("- " <> &1))] + + ## Parsing helpers + + defp parse_header([line | rest]) do + if String.ends_with?(line, @preamble) do + line + |> binary_part(0, byte_size(line) - byte_size(@preamble)) + |> split_scheme() + |> then(&{:ok, &1, rest}) + else + {:error, :invalid_message_format} + end + end + + defp split_scheme(authority) do + case String.split(authority, "://", parts: 2) do + [domain] -> {nil, domain} + [scheme, domain] -> {scheme, domain} + end + end + + defp pop_line([line | rest]), do: {:ok, line, rest} + defp pop_line([]), do: {:error, :invalid_message_format} + + defp parse_statement(["", "" | rest]), do: {:ok, nil, rest} + + defp parse_statement(["", statement, "" | rest]) when statement != "", + do: {:ok, statement, rest} + + defp parse_statement(_lines), do: {:error, :invalid_message_format} + + defp parse_fields(lines) do + with {:ok, uri, rest} <- take_required(lines, "URI: "), + {:ok, version, rest} <- take_required(rest, "Version: "), + {:ok, chain_id, rest} <- take_required(rest, "Chain ID: "), + {:ok, nonce, rest} <- take_required(rest, "Nonce: "), + {:ok, issued_at, rest} <- take_required(rest, "Issued At: ") do + {expiration_time, rest} = take_optional(rest, "Expiration Time: ") + {not_before, rest} = take_optional(rest, "Not Before: ") + {request_id, rest} = take_optional(rest, "Request ID: ") + {resources, rest} = take_resources(rest) + + fields = %{ + uri: uri, + version: version, + chain_id: chain_id, + nonce: nonce, + issued_at: issued_at, + expiration_time: expiration_time, + not_before: not_before, + request_id: request_id, + resources: resources + } + + {:ok, fields, rest} + end + end + + defp take_required([line | rest], prefix) do + if String.starts_with?(line, prefix) do + {:ok, String.replace_prefix(line, prefix, ""), rest} + else + {:error, :invalid_message_format} + end + end + + defp take_required([], _prefix), do: {:error, :invalid_message_format} + + defp take_optional([line | rest] = lines, prefix) do + if String.starts_with?(line, prefix) do + {String.replace_prefix(line, prefix, ""), rest} + else + {nil, lines} + end + end + + defp take_optional([], _prefix), do: {nil, []} + + defp take_resources(["Resources:" | rest]) do + {resource_lines, rest} = Enum.split_while(rest, &String.starts_with?(&1, "- ")) + {Enum.map(resource_lines, &String.replace_prefix(&1, "- ", "")), rest} + end + + defp take_resources(lines), do: {nil, lines} + + defp ensure_consumed([]), do: :ok + defp ensure_consumed(_lines), do: {:error, :invalid_message_format} + + ## Field validation helpers (shared between new/1 and parse/1) + + defp validate_scheme(nil), do: {:ok, nil} + + defp validate_scheme(scheme) when is_binary(scheme) do + if Regex.match?(@scheme_regex, scheme), do: {:ok, scheme}, else: {:error, :invalid_scheme} + end + + defp validate_scheme(_scheme), do: {:error, :invalid_scheme} + + defp validate_domain(nil), do: {:error, :missing_domain} + + defp validate_domain(domain) when is_binary(domain) do + if Regex.match?(@authority_regex, domain), do: {:ok, domain}, else: {:error, :invalid_domain} + end + + defp validate_domain(_domain), do: {:error, :invalid_domain} + + defp normalize_address(nil), do: {:error, :missing_address} + + defp normalize_address(<>) when prefix in ["0x", "0X"] do + case Utils.hex_decode(hex) do + {:ok, _address_bin} -> {:ok, Utils.to_checksum_address("0x" <> hex)} + :error -> {:error, :invalid_address} + end + end + + defp normalize_address(_address), do: {:error, :invalid_address} + + defp validate_checksummed_address(address) do + case normalize_address(address) do + {:ok, ^address} -> {:ok, address} + {:ok, _other} -> {:error, :invalid_address} + {:error, reason} -> {:error, reason} + end + end + + defp validate_statement(nil), do: {:ok, nil} + + defp validate_statement(statement) when is_binary(statement) do + if String.contains?(statement, ["\n", "\r"]) do + {:error, :invalid_statement} + else + {:ok, statement} + end + end + + defp validate_statement(_statement), do: {:error, :invalid_statement} + + defp validate_uri(nil), do: {:error, :missing_uri} + + defp validate_uri(uri) do + if valid_uri?(uri), do: {:ok, uri}, else: {:error, :invalid_uri} + end + + defp valid_uri?(uri) when is_binary(uri) do + case URI.new(uri) do + {:ok, %URI{scheme: scheme}} when is_binary(scheme) -> Regex.match?(@pct_encoding_regex, uri) + _other -> false + end + end + + defp valid_uri?(_uri), do: false + + defp validate_version("1"), do: {:ok, "1"} + defp validate_version(_version), do: {:error, :invalid_version} + + defp validate_chain_id(nil), do: {:error, :missing_chain_id} + + defp validate_chain_id(chain_id) when is_integer(chain_id) and chain_id >= 0, + do: {:ok, chain_id} + + defp validate_chain_id(_chain_id), do: {:error, :invalid_chain_id} + + defp parse_chain_id(chain_id) do + if Regex.match?(~r/^[0-9]+$/, chain_id) do + {:ok, String.to_integer(chain_id)} + else + {:error, :invalid_chain_id} + end + end + + defp validate_nonce(nil), do: {:error, :missing_nonce} + + defp validate_nonce(nonce) when is_binary(nonce) do + if Regex.match?(@nonce_regex, nonce), do: {:ok, nonce}, else: {:error, :invalid_nonce} + end + + defp validate_nonce(_nonce), do: {:error, :invalid_nonce} + + defp validate_timestamp(nil, _error), do: {:ok, nil} + + defp validate_timestamp(%DateTime{} = timestamp, _error), + do: {:ok, DateTime.to_iso8601(timestamp)} + + defp validate_timestamp(timestamp, error) when is_binary(timestamp) do + with true <- Regex.match?(@rfc3339_regex, timestamp), + {:ok, _datetime, _offset} <- DateTime.from_iso8601(timestamp) do + {:ok, timestamp} + else + _other -> {:error, error} + end + end + + defp validate_timestamp(_timestamp, error), do: {:error, error} + + defp validate_request_id(nil), do: {:ok, nil} + + defp validate_request_id(request_id) when is_binary(request_id) do + if Regex.match?(@request_id_regex, request_id) do + {:ok, request_id} + else + {:error, :invalid_request_id} + end + end + + defp validate_request_id(_request_id), do: {:error, :invalid_request_id} + + defp validate_resources(nil), do: {:ok, []} + + defp validate_resources(resources) when is_list(resources) do + if Enum.all?(resources, &valid_uri?/1) do + {:ok, resources} + else + {:error, :invalid_resources} + end + end + + defp validate_resources(_resources), do: {:error, :invalid_resources} + + ## Validation (validate/2) helpers + + defp check_version(%Message{version: "1"}), do: :ok + defp check_version(%Message{}), do: {:error, :invalid_version} + + defp check_expiration_time(%Message{expiration_time: nil}, _time), do: :ok + + defp check_expiration_time(%Message{expiration_time: expiration_time}, time) do + case DateTime.from_iso8601(expiration_time) do + {:ok, expiration, _offset} -> + if DateTime.compare(time, expiration) == :lt, do: :ok, else: {:error, :expired} + + {:error, _reason} -> + {:error, :invalid_expiration_time} + end + end + + defp check_not_before(%Message{not_before: nil}, _time), do: :ok + + defp check_not_before(%Message{not_before: not_before}, time) do + case DateTime.from_iso8601(not_before) do + {:ok, not_before_time, _offset} -> + if DateTime.compare(time, not_before_time) == :lt do + {:error, :not_yet_valid} + else + :ok + end + + {:error, _reason} -> + {:error, :invalid_not_before} + end + end + + defp check_exact(_actual, nil, _error), do: :ok + defp check_exact(actual, expected, _error) when actual == expected, do: :ok + defp check_exact(_actual, _expected, error), do: {:error, error} + + defp check_address(%Message{}, nil), do: :ok + + defp check_address(%Message{address: address}, expected) do + with {:ok, expected_bin} <- decode_address(expected), + {:ok, address_bin} <- decode_address(address) do + if expected_bin == address_bin, do: :ok, else: {:error, :address_mismatch} + end + end + + defp decode_address(<>) when prefix in ["0x", "0X"] do + case Utils.hex_decode(hex) do + {:ok, address_bin} -> {:ok, address_bin} + :error -> {:error, :invalid_address} + end + end + + defp decode_address(_address), do: {:error, :invalid_address} + + defp fetch(params, key) do + case Map.fetch(params, key) do + {:ok, value} -> value + :error -> Map.get(params, Atom.to_string(key)) + end + end +end diff --git a/lib/ethers/siwe/message.ex b/lib/ethers/siwe/message.ex new file mode 100644 index 0000000..3874875 --- /dev/null +++ b/lib/ethers/siwe/message.ex @@ -0,0 +1,71 @@ +defmodule Ethers.Siwe.Message do + @moduledoc """ + A Sign-In with Ethereum ([EIP-4361](https://eips.ethereum.org/EIPS/eip-4361)) message. + + Use `Ethers.Siwe.new/1` to build a message, `Ethers.Siwe.parse/1` to parse one from its + string form and `Ethers.Siwe.to_message/1` (or `to_string/1`) to render it. + + Timestamps (`issued_at`, `expiration_time` and `not_before`) are stored as RFC 3339 strings + exactly as they appear in the message. This guarantees that a parsed message re-renders + byte-for-byte identical to its source - which is required for signature verification - even + for timestamps with non-UTC offsets or unusual sub-second precision. `Ethers.Siwe.new/1` + accepts `DateTime` values and converts them for you; `Ethers.Siwe.validate/2` parses them + back when checking the validity window. + """ + + defstruct [ + :scheme, + :domain, + :address, + :statement, + :uri, + :version, + :chain_id, + :nonce, + :issued_at, + :expiration_time, + :not_before, + :request_id, + resources: [] + ] + + @typedoc """ + An EIP-4361 message. + + - `scheme` - optional URI scheme of the origin of the request (e.g. `"https"`). + - `domain` - the RFC 3986 authority requesting the signing (e.g. `"example.com"`). + - `address` - the EIP-55 checksummed Ethereum address performing the signing. + - `statement` - optional human-readable assertion to sign (must not contain newlines). + - `uri` - RFC 3986 URI referring to the resource that is the subject of the signing. + - `version` - current version of the SIWE message, always `"1"`. + - `chain_id` - the EIP-155 chain id to which the session is bound. + - `nonce` - randomized token (at least 8 alphanumeric characters), see + `Ethers.Siwe.generate_nonce/0`. + - `issued_at` - RFC 3339 timestamp of when the message was generated. + - `expiration_time` - optional RFC 3339 timestamp at (and after) which the message is + considered expired. + - `not_before` - optional RFC 3339 timestamp before which the message is not yet valid. + - `request_id` - optional system-specific request identifier. + - `resources` - list of RFC 3986 URIs the user wishes to have resolved as part of + authentication. + """ + @type t :: %__MODULE__{ + scheme: String.t() | nil, + domain: String.t(), + address: Ethers.Types.t_address(), + statement: String.t() | nil, + uri: String.t(), + version: String.t(), + chain_id: non_neg_integer(), + nonce: String.t(), + issued_at: String.t(), + expiration_time: String.t() | nil, + not_before: String.t() | nil, + request_id: String.t() | nil, + resources: [String.t()] + } + + defimpl String.Chars do + def to_string(message), do: Ethers.Siwe.to_message(message) + end +end diff --git a/mix.exs b/mix.exs index 6ada0a0..9c7fe75 100644 --- a/mix.exs +++ b/mix.exs @@ -70,6 +70,7 @@ defmodule Ethers.MixProject do "CHANGELOG.md": [title: "Changelog"], "guides/typed-arguments.md": [title: "Typed Arguments"], "guides/eip-712.md": [title: "EIP-712 Typed Data"], + "guides/siwe.md": [title: "Sign-In with Ethereum"], "guides/configuration.md": [title: "Configuration"], "guides/upgrading.md": [title: "Upgrading"] ], @@ -93,6 +94,10 @@ defmodule Ethers.MixProject do ~r/^Ethers\.Signer\.[A-Za-z0-9.]+$/, ~r/^Ethers\.Signer$/ ], + "Sign-In with Ethereum": [ + "Ethers.Siwe", + "Ethers.Siwe.Message" + ], "Typed Data": [ "Ethers.TypedData", "Ethers.TypedData.Schema", diff --git a/test/ethers/siwe_test.exs b/test/ethers/siwe_test.exs new file mode 100644 index 0000000..5219de6 --- /dev/null +++ b/test/ethers/siwe_test.exs @@ -0,0 +1,433 @@ +defmodule Ethers.SiweTest do + use ExUnit.Case, async: true + + alias Ethers.Siwe + alias Ethers.Siwe.Message + + doctest Ethers.Siwe + + @fixtures_path "test/support/fixtures/siwe" + + @valid_params [ + domain: "example.com", + address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + statement: "Sign in to Example", + uri: "https://example.com/login", + chain_id: 1, + nonce: "32891756", + issued_at: "2021-09-30T16:25:24.000Z" + ] + + defp fixture(name) do + [@fixtures_path, name] + |> Path.join() + |> File.read!() + |> Jason.decode!() + end + + defp fields_to_params(fields) do + mapping = %{ + "scheme" => :scheme, + "domain" => :domain, + "address" => :address, + "statement" => :statement, + "uri" => :uri, + "version" => :version, + "chainId" => :chain_id, + "nonce" => :nonce, + "issuedAt" => :issued_at, + "expirationTime" => :expiration_time, + "notBefore" => :not_before, + "requestId" => :request_id, + "resources" => :resources + } + + Map.new(fields, fn {key, value} -> {Map.fetch!(mapping, key), value} end) + end + + describe "new/1" do + test "builds a message from valid params" do + assert {:ok, %Message{} = message} = Siwe.new(@valid_params) + + assert message.domain == "example.com" + assert message.address == "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + assert message.statement == "Sign in to Example" + assert message.uri == "https://example.com/login" + assert message.version == "1" + assert message.chain_id == 1 + assert message.nonce == "32891756" + assert message.issued_at == "2021-09-30T16:25:24.000Z" + assert message.expiration_time == nil + assert message.not_before == nil + assert message.request_id == nil + assert message.resources == [] + end + + test "accepts a map with atom keys" do + assert {:ok, %Message{domain: "example.com"}} = Siwe.new(Map.new(@valid_params)) + end + + test "checksums the address" do + params = Keyword.put(@valid_params, :address, "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2") + + assert {:ok, %Message{address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"}} = + Siwe.new(params) + end + + test "defaults issued_at to now" do + params = Keyword.delete(@valid_params, :issued_at) + + assert {:ok, %Message{issued_at: issued_at}} = Siwe.new(params) + assert {:ok, issued_at_dt, 0} = DateTime.from_iso8601(issued_at) + assert DateTime.diff(DateTime.utc_now(), issued_at_dt) < 5 + end + + test "accepts DateTime values for timestamps" do + params = + Keyword.merge(@valid_params, + issued_at: ~U[2021-09-30 16:25:24Z], + expiration_time: ~U[2021-09-30 16:30:24.000Z], + not_before: ~U[2021-09-30 16:20:24Z] + ) + + assert {:ok, %Message{} = message} = Siwe.new(params) + assert message.issued_at == "2021-09-30T16:25:24Z" + assert message.expiration_time == "2021-09-30T16:30:24.000Z" + assert message.not_before == "2021-09-30T16:20:24Z" + end + + test "accepts optional scheme, request_id and resources" do + params = + Keyword.merge(@valid_params, + scheme: "https", + request_id: "some_id", + resources: ["ipfs://Qme7ss3ARVgxv6rXqVPiikMJ8u2NLgmgszg13pYrDKEoiu"] + ) + + assert {:ok, %Message{} = message} = Siwe.new(params) + assert message.scheme == "https" + assert message.request_id == "some_id" + assert message.resources == ["ipfs://Qme7ss3ARVgxv6rXqVPiikMJ8u2NLgmgszg13pYrDKEoiu"] + end + + test "returns error for missing required fields" do + for {key, error} <- [ + domain: :missing_domain, + address: :missing_address, + uri: :missing_uri, + chain_id: :missing_chain_id, + nonce: :missing_nonce + ] do + params = Keyword.delete(@valid_params, key) + assert {:error, ^error} = Siwe.new(params) + end + end + + test "returns error for invalid fields" do + for {key, value, error} <- [ + {:domain, "#nope", :invalid_domain}, + {:domain, "example.com:", :invalid_domain}, + {:domain, 42, :invalid_domain}, + {:scheme, "1https", :invalid_scheme}, + {:scheme, 42, :invalid_scheme}, + {:address, "0x1234", :invalid_address}, + {:address, "not an address", :invalid_address}, + {:address, "0x" <> String.duplicate("zx", 20), :invalid_address}, + {:statement, "line\nbreak", :invalid_statement}, + {:statement, 42, :invalid_statement}, + {:uri, ":not_a_uri", :invalid_uri}, + {:uri, "no_scheme", :invalid_uri}, + {:version, "2", :invalid_version}, + {:chain_id, "one", :invalid_chain_id}, + {:chain_id, -1, :invalid_chain_id}, + {:nonce, "1234567", :invalid_nonce}, + {:nonce, "with spaces!", :invalid_nonce}, + {:nonce, 12_345_678, :invalid_nonce}, + {:issued_at, "Wed Oct 05 2011", :invalid_issued_at}, + {:issued_at, 42, :invalid_issued_at}, + {:expiration_time, "not-a-date", :invalid_expiration_time}, + {:not_before, "2021-13-40T00:00:00Z", :invalid_not_before}, + {:request_id, "new\nline", :invalid_request_id}, + {:request_id, 42, :invalid_request_id}, + {:resources, [":bad_uri"], :invalid_resources}, + {:resources, [42], :invalid_resources}, + {:resources, "not-a-list", :invalid_resources} + ] do + params = Keyword.put(@valid_params, key, value) + assert {:error, ^error} = Siwe.new(params), "expected #{error} for #{key}" + end + end + + test "rejects reference negative object vectors" do + # `missing version` and `missing issuedAt` are excluded: `new/1` deliberately defaults + # those fields. `address not EIP-55` is excluded: `new/1` normalizes the address to its + # checksummed form instead of rejecting (strict rejection applies to `parse/1`). + excluded = ["missing version", "missing issuedAt", "address not EIP-55"] + + for {name, fields} <- fixture("parsing_negative_objects.json"), name not in excluded do + params = fields_to_params(fields) + assert {:error, _reason} = Siwe.new(params), "expected error for #{inspect(name)}" + end + end + end + + describe "new!/1" do + test "returns the message struct" do + assert %Message{domain: "example.com"} = Siwe.new!(@valid_params) + end + + test "raises on invalid params" do + assert_raise ArgumentError, ~r/invalid_nonce/, fn -> + Siwe.new!(Keyword.put(@valid_params, :nonce, "short")) + end + end + end + + describe "generate_nonce/0" do + test "generates alphanumeric nonces of at least 8 characters" do + for _ <- 1..100 do + nonce = Siwe.generate_nonce() + assert nonce =~ ~r/^[a-zA-Z0-9]{8,}$/ + end + end + + test "generates unique nonces" do + nonces = for _ <- 1..100, do: Siwe.generate_nonce() + assert length(Enum.uniq(nonces)) == 100 + end + end + + describe "to_message/1" do + test "renders a message with all fields" do + message = + Siwe.new!( + scheme: "https", + domain: "example.com", + address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + statement: "Sign in to Example", + uri: "https://example.com/login", + chain_id: 1, + nonce: "32891756", + issued_at: "2021-09-30T16:25:24.000Z", + expiration_time: "2021-10-30T16:25:24.000Z", + not_before: "2021-09-29T16:25:24.000Z", + request_id: "some_id", + resources: [ + "ipfs://Qme7ss3ARVgxv6rXqVPiikMJ8u2NLgmgszg13pYrDKEoiu", + "https://example.com/claim.json" + ] + ) + + assert Siwe.to_message(message) == """ + https://example.com wants you to sign in with your Ethereum account: + 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 + + Sign in to Example + + URI: https://example.com/login + Version: 1 + Chain ID: 1 + Nonce: 32891756 + Issued At: 2021-09-30T16:25:24.000Z + Expiration Time: 2021-10-30T16:25:24.000Z + Not Before: 2021-09-29T16:25:24.000Z + Request ID: some_id + Resources: + - ipfs://Qme7ss3ARVgxv6rXqVPiikMJ8u2NLgmgszg13pYrDKEoiu + - https://example.com/claim.json\ + """ + end + + test "renders a message without optional fields" do + message = Siwe.new!(Keyword.delete(@valid_params, :statement)) + + assert Siwe.to_message(message) == """ + example.com wants you to sign in with your Ethereum account: + 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2 + + + URI: https://example.com/login + Version: 1 + Chain ID: 1 + Nonce: 32891756 + Issued At: 2021-09-30T16:25:24.000Z\ + """ + end + + test "implements String.Chars" do + message = Siwe.new!(@valid_params) + assert to_string(message) == Siwe.to_message(message) + end + end + + describe "parse/1" do + test "parses and round-trips the reference positive vectors" do + for {name, %{"message" => raw, "fields" => fields}} <- fixture("parsing_positive.json") do + assert {:ok, %Message{} = message} = Siwe.parse(raw), "failed to parse #{inspect(name)}" + + assert Siwe.to_message(message) == raw, "round-trip failed for #{inspect(name)}" + + for {key, expected} <- fields_to_params(fields) do + assert Map.fetch!(message, key) == expected, + "field #{key} mismatch for #{inspect(name)}" + end + end + end + + test "rejects the reference negative vectors" do + for {name, raw} <- fixture("parsing_negative.json") do + assert {:error, _reason} = Siwe.parse(raw), "expected error for #{inspect(name)}" + end + end + + test "rejects truncated messages" do + header = "example.com wants you to sign in with your Ethereum account:" + address = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + + assert {:error, :invalid_message_format} = Siwe.parse(header) + assert {:error, :invalid_message_format} = Siwe.parse("#{header}\n#{address}\n\n") + assert {:error, :invalid_message_format} = Siwe.parse("#{header}\n#{address}\n\n\n") + assert {:error, :invalid_message_format} = Siwe.parse("not a siwe message") + end + + test "rejects messages with trailing newline" do + message = Siwe.new!(@valid_params) + assert {:error, _reason} = Siwe.parse(Siwe.to_message(message) <> "\n") + end + + test "rejects non-checksummed addresses" do + raw = + @valid_params + |> Siwe.new!() + |> Siwe.to_message() + |> String.replace( + "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + ) + + assert {:error, :invalid_address} = Siwe.parse(raw) + end + + test "round-trips a message built with new!/1" do + message = + Siwe.new!( + Keyword.merge(@valid_params, + expiration_time: "2021-10-30T16:25:24Z", + resources: ["https://example.com/claim.json"] + ) + ) + + assert {:ok, parsed} = message |> Siwe.to_message() |> Siwe.parse() + assert parsed == message + end + end + + describe "validate/2" do + setup do + message = + Siwe.new!( + Keyword.merge(@valid_params, + not_before: "2021-09-30T16:00:00Z", + expiration_time: "2021-09-30T17:00:00Z" + ) + ) + + %{message: message} + end + + test "returns :ok for a valid message", %{message: message} do + assert :ok = + Siwe.validate(message, + domain: "example.com", + nonce: "32891756", + address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + time: ~U[2021-09-30 16:30:00Z] + ) + end + + test "returns :ok without match options", %{message: message} do + assert :ok = Siwe.validate(message, time: ~U[2021-09-30 16:30:00Z]) + end + + test "defaults time to now" do + message = Siwe.new!(@valid_params) + assert :ok = Siwe.validate(message) + end + + test "expiration time is exclusive", %{message: message} do + assert {:error, :expired} = Siwe.validate(message, time: ~U[2021-09-30 17:00:00Z]) + assert {:error, :expired} = Siwe.validate(message, time: ~U[2021-09-30 18:00:00Z]) + assert :ok = Siwe.validate(message, time: ~U[2021-09-30 16:59:59Z]) + end + + test "not before is inclusive", %{message: message} do + assert {:error, :not_yet_valid} = Siwe.validate(message, time: ~U[2021-09-30 15:59:59Z]) + assert :ok = Siwe.validate(message, time: ~U[2021-09-30 16:00:00Z]) + end + + test "detects domain mismatch", %{message: message} do + assert {:error, :domain_mismatch} = + Siwe.validate(message, domain: "evil.com", time: ~U[2021-09-30 16:30:00Z]) + end + + test "detects scheme mismatch", %{message: message} do + assert {:error, :scheme_mismatch} = + Siwe.validate(message, scheme: "https", time: ~U[2021-09-30 16:30:00Z]) + + https_message = %{message | scheme: "https"} + + assert :ok = + Siwe.validate(https_message, scheme: "https", time: ~U[2021-09-30 16:30:00Z]) + + assert {:error, :scheme_mismatch} = + Siwe.validate(https_message, scheme: "http", time: ~U[2021-09-30 16:30:00Z]) + end + + test "detects nonce mismatch", %{message: message} do + assert {:error, :nonce_mismatch} = + Siwe.validate(message, nonce: "12341234", time: ~U[2021-09-30 16:30:00Z]) + end + + test "compares addresses case-insensitively", %{message: message} do + assert :ok = + Siwe.validate(message, + address: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + time: ~U[2021-09-30 16:30:00Z] + ) + end + + test "detects address mismatch", %{message: message} do + assert {:error, :address_mismatch} = + Siwe.validate(message, + address: "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1", + time: ~U[2021-09-30 16:30:00Z] + ) + end + + test "rejects invalid expected address", %{message: message} do + assert {:error, :invalid_address} = + Siwe.validate(message, address: "nope", time: ~U[2021-09-30 16:30:00Z]) + + assert {:error, :invalid_address} = + Siwe.validate(message, + address: "0x" <> String.duplicate("zx", 20), + time: ~U[2021-09-30 16:30:00Z] + ) + end + + test "rejects unsupported versions" do + %Message{} = message = Siwe.new!(@valid_params) + assert {:error, :invalid_version} = Siwe.validate(%Message{message | version: "2"}) + end + + test "rejects unparseable timestamps in the struct" do + %Message{} = message = Siwe.new!(@valid_params) + + assert {:error, :invalid_expiration_time} = + Siwe.validate(%Message{message | expiration_time: "not-a-date"}) + + assert {:error, :invalid_not_before} = + Siwe.validate(%Message{message | not_before: "not-a-date"}) + end + end +end diff --git a/test/ethers/siwe_verify_test.exs b/test/ethers/siwe_verify_test.exs new file mode 100644 index 0000000..b9d284f --- /dev/null +++ b/test/ethers/siwe_verify_test.exs @@ -0,0 +1,156 @@ +defmodule Ethers.SiweVerifyTest.RaisingRpcModule do + @moduledoc false + # Used to prove code paths that must not hit the network. + + def eth_call(_params, _block, _opts) do + raise "eth_call must not be called in this code path" + end +end + +defmodule Ethers.SiweVerifyTest.ErrorRpcModule do + @moduledoc false + # Simulates an RPC transport failure. + + def eth_call(_params, _block, _opts), do: {:error, :nxdomain} +end + +defmodule Ethers.Contract.Test.SiweERC1271WalletContract do + @moduledoc false + use Ethers.Contract, abi_file: "tmp/erc1271_wallet_abi.json" +end + +defmodule Ethers.SiweVerifyTest do + use ExUnit.Case + + import Ethers.TestHelpers + + alias Ethers.Contract.Test.SiweERC1271WalletContract + alias Ethers.Siwe + alias Ethers.Siwe.Message + alias Ethers.SiweVerifyTest.ErrorRpcModule + alias Ethers.SiweVerifyTest.RaisingRpcModule + + # 0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1 (same key used across the suite) + @owner_private_key "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d" + @owner "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1" + # First anvil dev account (funded, unlocked) — used to send deployment transactions + @from "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + @other_private_key "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + + defp build_message(overrides \\ []) do + Siwe.new!( + Keyword.merge( + [ + domain: "example.com", + address: @owner, + statement: "Sign in to Example", + uri: "https://example.com/login", + chain_id: 1, + nonce: "32891756", + issued_at: "2021-09-30T16:25:24.000Z" + ], + overrides + ) + ) + end + + defp sign(raw_message, private_key) do + Ethers.personal_sign!(raw_message, + signer: Ethers.Signer.Local, + signer_opts: [private_key: private_key] + ) + end + + describe "verify/3 with EOA signatures" do + test "verifies a raw message string without any RPC call" do + raw = Siwe.to_message(build_message()) + signature = sign(raw, @owner_private_key) + + assert {:ok, %Message{address: @owner, domain: "example.com"}} = + Siwe.verify(raw, signature, + domain: "example.com", + nonce: "32891756", + rpc_client: RaisingRpcModule + ) + end + + test "verifies an already-parsed message struct" do + message = build_message() + signature = sign(Siwe.to_message(message), @owner_private_key) + + assert {:ok, ^message} = Siwe.verify(message, signature, rpc_client: RaisingRpcModule) + end + + test "rejects a signature by a different key" do + raw = Siwe.to_message(build_message()) + signature = sign(raw, @other_private_key) + + assert {:error, :invalid_signature} = Siwe.verify(raw, signature) + end + + test "rejects a signature over a different message" do + raw = Siwe.to_message(build_message()) + signature = sign("something else entirely", @owner_private_key) + + assert {:error, :invalid_signature} = Siwe.verify(raw, signature) + end + end + + describe "verify/3 validation errors" do + test "propagates field validation errors before checking the signature" do + message = build_message(expiration_time: "2021-09-30T17:00:00Z") + raw = Siwe.to_message(message) + signature = sign(raw, @owner_private_key) + + # rpc_client would raise if the signature check ran — validation fails first + assert {:error, :expired} = + Siwe.verify(raw, signature, + time: ~U[2022-01-01 00:00:00Z], + rpc_client: RaisingRpcModule + ) + + assert {:error, :domain_mismatch} = + Siwe.verify(raw, signature, + domain: "evil.com", + time: ~U[2021-09-30 16:30:00Z], + rpc_client: RaisingRpcModule + ) + + assert {:error, :nonce_mismatch} = + Siwe.verify(raw, signature, + nonce: "deadbeef", + time: ~U[2021-09-30 16:30:00Z], + rpc_client: RaisingRpcModule + ) + end + + test "propagates parse errors" do + assert {:error, :invalid_message_format} = Siwe.verify("not a siwe message", "0x1234") + end + + test "propagates RPC transport errors from the signature check" do + raw = Siwe.to_message(build_message()) + signature = sign(raw, @other_private_key) + + assert {:error, :nxdomain} = Siwe.verify(raw, signature, rpc_client: ErrorRpcModule) + end + end + + describe "verify/3 with a smart-contract wallet (ERC-1271)" do + test "verifies a wallet-owner signature against the wallet address" do + encoded_constructor = SiweERC1271WalletContract.constructor(@owner) + + wallet = + deploy(SiweERC1271WalletContract, encoded_constructor: encoded_constructor, from: @from) + + raw = Siwe.to_message(build_message(address: wallet)) + owner_signature = sign(raw, @owner_private_key) + other_signature = sign(raw, @other_private_key) + + assert {:ok, %Message{address: address}} = Siwe.verify(raw, owner_signature) + assert String.downcase(address) == String.downcase(wallet) + + assert {:error, :invalid_signature} = Siwe.verify(raw, other_signature) + end + end +end diff --git a/test/support/fixtures/siwe/parsing_negative.json b/test/support/fixtures/siwe/parsing_negative.json new file mode 100644 index 0000000..d4f2e65 --- /dev/null +++ b/test/support/fixtures/siwe/parsing_negative.json @@ -0,0 +1,31 @@ +{ + "missing domain": " wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "missing address": "service.org wants you to sign in with your Ethereum account:\n\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "missing uri": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\n\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "missing version": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\n\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "missing chainId": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\n\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "missing nonce": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\n\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "missing issuedAt": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\n\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "out of order uri": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nVersion: 1\nURI: https://service.org/login\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "out of order version": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nChain ID: 1\nVersion: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "out of order chainId": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nNonce: 12341234Chain ID: 1\n\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "out of order nonce": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nIssued At: 2022-03-17T12:45:13.610Z\nNonce: 12341234\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "out of order issuedAt": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nExpiration Time: 2023-03-17T12:45:13.610Z\nIssued At: 2022-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "out of order expirationTime": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "out of order notBefore": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nRequest ID: some_id\nNot Before: 2022-03-17T12:45:13.610Z\nResources:\n- https://service.org/login", + "out of order requestId": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nResources:\n- https://service.org/login\nRequest ID: some_id", + "out of order resources": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nResources:\n- https://service.org/login\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id", + "domain not RFC4501 authority": "#notrfc4501 wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "address not EIP-55": "service.org wants you to sign in with your Ethereum account:\n0xe5a12547fe4e872d192e3ececb76f2ce1aea4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "statement has line break": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: \nhttps://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "uri is non-RFC 3986": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: :not_a_rfc3986_valid_uri_\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "version not 1": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 3\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "not a valid chainId": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: ?\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "nonce with less than 8 chars": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 1234567\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "non-ISO 8601 issuedAt": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: Wed Oct 05 2011 16:48:00 GMT+0200 (CEST)\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "non-ISO 8601 expirationTime": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: Wed Oct 05 2011 16:48:00 GMT+0200 (CEST)\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login", + "non-ISO 8601 notBefore": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: Wed Oct 05 2011 16:48:00 GMT+0200 (CEST)\nRequest ID: some_id\nResources:\n- https://service.org/login", + "resources not separated by line break": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login - https://service.org/login/2", + "first resource not-RFC 3986": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- :not_a_rfc3986_valid_uri_\n- https://service.org/login", + "second resource is not-RFC3986": "service.org wants you to sign in with your Ethereum account:\n0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 12341234\nIssued At: 2022-03-17T12:45:13.610Z\nExpiration Time: 2023-03-17T12:45:13.610Z\nNot Before: 2022-03-17T12:45:13.610Z\nRequest ID: some_id\nResources:\n- https://service.org/login\n- :not_a_rfc3986_valid_uri_" +} diff --git a/test/support/fixtures/siwe/parsing_negative_objects.json b/test/support/fixtures/siwe/parsing_negative_objects.json new file mode 100644 index 0000000..68bf24a --- /dev/null +++ b/test/support/fixtures/siwe/parsing_negative_objects.json @@ -0,0 +1,285 @@ +{ + "missing domain": { + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "12341234", + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "missing address": { + "domain": "service.org", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "12341234", + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "missing uri": { + "domain": "service.org", + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "version": "1", + "chainId": 1, + "nonce": "12341234", + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "missing version": { + "domain": "service.org", + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "chainId": 1, + "nonce": "12341234", + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "missing chainId": { + "domain": "service.org", + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "nonce": "12341234", + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "missing nonce": { + "domain": "service.org", + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "missing issuedAt": { + "domain": "service.org", + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "12341234", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "domain not RFC4501 authority": { + "domain": "#notrfc4501", + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "12341234", + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "address not EIP-55": { + "domain": "service.org", + "address": "0xE5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "12341234", + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "uri is non-RFC 3986": { + "domain": "service.org", + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": ":not_a_rfc3986_valid_uri_", + "version": "1", + "chainId": 1, + "nonce": "12341234", + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "version not 1": { + "domain": "service.org", + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "3", + "chainId": 1, + "nonce": "12341234", + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "not a valid chainId": { + "domain": "service.org", + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": "?", + "nonce": "12341234", + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "nonce with less than 8 chars": { + "domain": "service.org", + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "1234567", + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "non-ISO 8601 issuedAt": { + "domain": "service.org", + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "12341234", + "issuedAt": "Wed Oct 05 2011 16:48:00 GMT+0200 (CEST)", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "non-ISO 8601 expirationTime": { + "domain": "service.org", + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "12341234", + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "Wed Oct 05 2011 16:48:00 GMT+0200 (CEST)", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "non-ISO 8601 notBefore": { + "domain": "service.org", + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "12341234", + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "Wed Oct 05 2011 16:48:00 GMT+0200 (CEST)", + "requestId": "some_id", + "resources": [ + "https://service.org/login" + ] + }, + "first resource not-RFC 3986": { + "domain": "service.org", + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "12341234", + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + ":not_a_rfc3986_valid_uri_", + "https://service.org/login" + ] + }, + "second resource is not-RFC3986": { + "domain": "service.org", + "address": "0xe5A12547fe4E872D192E3eCecb76F2Ce1aeA4946", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "12341234", + "issuedAt": "2022-03-17T12:45:13.610Z", + "expirationTime": "2023-03-17T12:45:13.610Z", + "notBefore": "2022-03-17T12:45:13.610Z", + "requestId": "some_id", + "resources": [ + "https://service.org/login", + ":not_a_rfc3986_valid_uri_" + ] + } +} \ No newline at end of file diff --git a/test/support/fixtures/siwe/parsing_positive.json b/test/support/fixtures/siwe/parsing_positive.json new file mode 100644 index 0000000..948e2f8 --- /dev/null +++ b/test/support/fixtures/siwe/parsing_positive.json @@ -0,0 +1,248 @@ +{ + "couple of optional fields": { + "message": "service.org wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z\nResources:\n- ipfs://Qme7ss3ARVgxv6rXqVPiikMJ8u2NLgmgszg13pYrDKEoiu\n- https://example.com/my-web2-claim.json", + "fields": { + "domain": "service.org", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24.000Z", + "resources": [ + "ipfs://Qme7ss3ARVgxv6rXqVPiikMJ8u2NLgmgszg13pYrDKEoiu", + "https://example.com/my-web2-claim.json" + ] + } + }, + "no optional field": { + "message": "service.org wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z", + "fields": { + "domain": "service.org", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24.000Z" + } + }, + "timestamp without microseconds": { + "message": "service.org wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24Z", + "fields": { + "domain": "service.org", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24Z" + } + }, + "timezone not utc": { + "message": "service.org wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24-02:00", + "fields": { + "domain": "service.org", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24-02:00" + } + }, + "domain is RFC 3986 authority with IP": { + "message": "127.0.0.1 wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z", + "fields": { + "domain": "127.0.0.1", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24.000Z" + } + }, + "domain is RFC 3986 authority with userinfo": { + "message": "test@127.0.0.1 wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z", + "fields": { + "domain": "test@127.0.0.1", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24.000Z" + } + }, + "domain is RFC 3986 authority with port": { + "message": "127.0.0.1:8080 wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z", + "fields": { + "domain": "127.0.0.1:8080", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24.000Z" + } + }, + "domain is localhost authority with port": { + "message": "localhost:8080 wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z", + "fields": { + "domain": "localhost:8080", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24.000Z" + } + }, + "domain is RFC 3986 authority with userinfo and port": { + "message": "test@127.0.0.1:8080 wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ServiceOrg Terms of Service: https://service.org/tos\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z", + "fields": { + "domain": "test@127.0.0.1:8080", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "statement": "I accept the ServiceOrg Terms of Service: https://service.org/tos", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24.000Z" + } + }, + "no statement": { + "message": "service.org wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z", + "fields": { + "domain": "service.org", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24.000Z" + } + }, + "domain ipv6": { + "message": "[::cafe] wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z", + "fields": { + "domain": "[::cafe]", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "uri": "https://service.org/login", + "version": "1", + "chainId": 1, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24.000Z" + } + }, + "uri ipv6": { + "message": "service.org wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\n\nURI: https://[::cafe]\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z", + "fields": { + "domain": "service.org", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "uri": "https://[::cafe]", + "version": "1", + "chainId": 1, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24.000Z" + } + }, + "uri ipv4": { + "message": "service.org wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\n\nURI: https://127.0.0.1\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z", + "fields": { + "domain": "service.org", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "uri": "https://127.0.0.1", + "version": "1", + "chainId": 1, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24.000Z" + } + }, + "uri with port": { + "message": "service.org wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\n\nURI: https://127.0.0.1:4361\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z", + "fields": { + "domain": "service.org", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "uri": "https://127.0.0.1:4361", + "version": "1", + "chainId": 1, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24.000Z" + } + }, + "uri ipv4 query params and fragment": { + "message": "service.org wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\n\nURI: https://127.0.0.1/?query=one#begin\nVersion: 1\nChain ID: 1\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z", + "fields": { + "domain": "service.org", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "uri": "https://127.0.0.1/?query=one#begin", + "version": "1", + "chainId": 1, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24.000Z" + } + }, + "chainId not 1": { + "message": "service.org wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\n\nURI: https://service.org/login\nVersion: 1\nChain ID: 4\nNonce: 32891757\nIssued At: 2021-09-30T16:25:24.000Z", + "fields": { + "domain": "service.org", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "uri": "https://service.org/login", + "version": "1", + "chainId": 4, + "nonce": "32891757", + "issuedAt": "2021-09-30T16:25:24.000Z" + } + }, + "recovery byte starting at 0": { + "message": "www.tally.xyz wants you to sign in with your Ethereum account:\n0xc95EB884FE852e241D409234bfC7045CB9E31BD7\n\nSign in with Ethereum to Tally\n\nURI: https://tally.xyz\nVersion: 1\nChain ID: 1\nNonce: 15050747\nIssued At: 2022-06-30T14:08:51.382Z", + "fields": { + "domain": "www.tally.xyz", + "address": "0xc95EB884FE852e241D409234bfC7045CB9E31BD7", + "statement": "Sign in with Ethereum to Tally", + "uri": "https://tally.xyz", + "version": "1", + "chainId": 1, + "nonce": "15050747", + "issuedAt": "2022-06-30T14:08:51.382Z" + } + }, + "domain contains optional scheme": { + "message": "https://example.com wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ExampleOrg Terms of Service: https://example.com/tos\n\nURI: https://example.com/login\nVersion: 1\nChain ID: 1\nNonce: 32891756\nIssued At: 2021-09-30T16:25:24Z", + "fields": { + "scheme": "https", + "domain": "example.com", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "statement": "I accept the ExampleOrg Terms of Service: https://example.com/tos", + "uri": "https://example.com/login", + "version": "1", + "chainId": 1, + "nonce": "32891756", + "issuedAt": "2021-09-30T16:25:24Z" + } + }, + "scheme is not parsed from elsewhere in message": { + "message": "localhost:3030 wants you to sign in with your Ethereum account:\n0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2\n\nI accept the ExampleOrg Terms of Service: http://localhost:3030/tos\n\nURI: http://localhost:3030/login\nVersion: 1\nChain ID: 1\nNonce: 32891756\nIssued At: 2021-09-30T16:25:24Z", + "fields": { + "scheme": null, + "domain": "localhost:3030", + "address": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", + "statement": "I accept the ExampleOrg Terms of Service: http://localhost:3030/tos", + "uri": "http://localhost:3030/login", + "version": "1", + "chainId": 1, + "nonce": "32891756", + "issuedAt": "2021-09-30T16:25:24Z" + } + } +}