Skip to content

Commit 45d9661

Browse files
alisinabhclaude
andcommitted
Add Ethers.Siwe.verify/3 end-to-end SIWE verification
The one-call backend flow: parse the raw EIP-4361 message (or accept an already-parsed struct), run stateless field validation, then check the signature via Ethers.Signature.verify_message/4 — EOA signatures verify locally with zero RPC round-trips while ERC-1271 and ERC-6492 smart-contract wallets are verified with a single eth_call. When given a string, the signature is verified over the exact original input. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 907cfbe commit 45d9661

3 files changed

Lines changed: 223 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
and counterfactual, not-yet-deployed wallets ([ERC-6492](https://eips.ethereum.org/EIPS/eip-6492)) —
1414
against a digest, an EIP-191 personal message or EIP-712 typed data with
1515
`Ethers.Signature.verify_hash/4`, `verify_message/4` and `verify_typed_data/4`
16+
- Add Sign-In with Ethereum ([EIP-4361](https://eips.ethereum.org/EIPS/eip-4361)) support with
17+
`Ethers.Siwe`: build (`new/1`), render (`to_message/1`), parse (`parse/1`) and validate
18+
(`validate/2`) SIWE messages, generate session nonces (`generate_nonce/0`), and verify a
19+
message and its signature end-to-end with `Ethers.Siwe.verify/3` — smart-contract wallets
20+
included via the universal signature verification above
1621
- Add [EIP-712](https://eips.ethereum.org/EIPS/eip-712) typed data support: construct typed
1722
structured data with `Ethers.TypedData`, hash it (`encode_type`, `type_hash`, `hash_struct`,
1823
`domain_separator`, `hash`), sign it via `Ethers.sign_typed_data/2` with the `Ethers.Signer.Local`

lib/ethers/siwe.ex

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ defmodule Ethers.Siwe do
1313
- `to_message/1` - render the EIP-4361 string for the wallet to sign
1414
- `parse/1` - parse a message string received from a client
1515
- `validate/2` - stateless validation (validity window, domain/nonce/address binding)
16+
- `verify/3` - the one-call backend flow: parse, validate and check the signature
17+
(including ERC-1271/ERC-6492 smart-contract wallets)
1618
1719
See the [Sign-In with Ethereum guide](siwe.html) for a complete Phoenix integration recipe.
1820
@@ -32,6 +34,7 @@ defmodule Ethers.Siwe do
3234
"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"
3335
"""
3436

37+
alias Ethers.Signature
3538
alias Ethers.Siwe.Message
3639
alias Ethers.Utils
3740

@@ -262,6 +265,65 @@ defmodule Ethers.Siwe do
262265
end
263266
end
264267

268+
@doc """
269+
Verifies a SIWE message end-to-end: parse (when given a string), validate the fields and
270+
check the signature.
271+
272+
This is the one-call backend flow. The signature check uses
273+
`Ethers.Signature.verify_message/4`, so EOA signatures verify locally without any RPC
274+
round-trip while smart-contract wallets ([ERC-1271](https://eips.ethereum.org/EIPS/eip-1271),
275+
deployed or not — [ERC-6492](https://eips.ethereum.org/EIPS/eip-6492)) are verified with a
276+
single `eth_call`.
277+
278+
## Parameters
279+
280+
- `message`: The raw EIP-4361 message string received from the client, or an already-parsed
281+
`Ethers.Siwe.Message`. When given a string, the signature is verified over that exact
282+
string.
283+
- `signature`: The signature as a `0x`-prefixed hex string or raw binary. ERC-6492-wrapped
284+
signatures are supported.
285+
- `opts`: Options.
286+
287+
## Options
288+
289+
All of `validate/2`'s options (`:time`, `:domain`, `:scheme`, `:nonce`, `:address`) plus
290+
the RPC options forwarded to `Ethers.Signature.verify_hash/4` for smart-wallet
291+
verification: `:rpc_client`, `:rpc_opts` and `:block`.
292+
293+
## Returns
294+
295+
- `{:ok, message}` with the parsed/validated `Ethers.Siwe.Message` on success — trust
296+
`message.address` afterwards.
297+
- `{:error, :invalid_signature}` if the signature does not verify for `message.address`.
298+
- `{:error, reason}` for parse errors, `validate/2` errors or RPC transport failures.
299+
"""
300+
@spec verify(String.t() | Message.t(), binary(), Keyword.t()) ::
301+
{:ok, Message.t()} | {:error, term()}
302+
def verify(message, signature, opts \\ [])
303+
304+
def verify(%Message{} = message, signature, opts) do
305+
do_verify(message, to_message(message), signature, opts)
306+
end
307+
308+
def verify(raw_message, signature, opts) when is_binary(raw_message) do
309+
with {:ok, message} <- parse(raw_message) do
310+
do_verify(message, raw_message, signature, opts)
311+
end
312+
end
313+
314+
defp do_verify(%Message{} = message, raw_message, signature, opts) do
315+
signature_opts = Keyword.take(opts, [:rpc_client, :rpc_opts, :block])
316+
317+
with :ok <- validate(message, opts),
318+
{:ok, true} <-
319+
Signature.verify_message(raw_message, signature, message.address, signature_opts) do
320+
{:ok, message}
321+
else
322+
{:ok, false} -> {:error, :invalid_signature}
323+
{:error, reason} -> {:error, reason}
324+
end
325+
end
326+
265327
## Message rendering helpers
266328

267329
defp scheme_prefix(nil), do: ""

test/ethers/siwe_verify_test.exs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
defmodule Ethers.SiweVerifyTest.RaisingRpcModule do
2+
@moduledoc false
3+
# Used to prove code paths that must not hit the network.
4+
5+
def eth_call(_params, _block, _opts) do
6+
raise "eth_call must not be called in this code path"
7+
end
8+
end
9+
10+
defmodule Ethers.SiweVerifyTest.ErrorRpcModule do
11+
@moduledoc false
12+
# Simulates an RPC transport failure.
13+
14+
def eth_call(_params, _block, _opts), do: {:error, :nxdomain}
15+
end
16+
17+
defmodule Ethers.Contract.Test.SiweERC1271WalletContract do
18+
@moduledoc false
19+
use Ethers.Contract, abi_file: "tmp/erc1271_wallet_abi.json"
20+
end
21+
22+
defmodule Ethers.SiweVerifyTest do
23+
use ExUnit.Case
24+
25+
import Ethers.TestHelpers
26+
27+
alias Ethers.Contract.Test.SiweERC1271WalletContract
28+
alias Ethers.Siwe
29+
alias Ethers.Siwe.Message
30+
alias Ethers.SiweVerifyTest.ErrorRpcModule
31+
alias Ethers.SiweVerifyTest.RaisingRpcModule
32+
33+
# 0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1 (same key used across the suite)
34+
@owner_private_key "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d"
35+
@owner "0x90F8bf6A479f320ead074411a4B0e7944Ea8c9C1"
36+
# First anvil dev account (funded, unlocked) — used to send deployment transactions
37+
@from "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
38+
@other_private_key "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"
39+
40+
defp build_message(overrides \\ []) do
41+
Siwe.new!(
42+
Keyword.merge(
43+
[
44+
domain: "example.com",
45+
address: @owner,
46+
statement: "Sign in to Example",
47+
uri: "https://example.com/login",
48+
chain_id: 1,
49+
nonce: "32891756",
50+
issued_at: "2021-09-30T16:25:24.000Z"
51+
],
52+
overrides
53+
)
54+
)
55+
end
56+
57+
defp sign(raw_message, private_key) do
58+
Ethers.personal_sign!(raw_message,
59+
signer: Ethers.Signer.Local,
60+
signer_opts: [private_key: private_key]
61+
)
62+
end
63+
64+
describe "verify/3 with EOA signatures" do
65+
test "verifies a raw message string without any RPC call" do
66+
raw = Siwe.to_message(build_message())
67+
signature = sign(raw, @owner_private_key)
68+
69+
assert {:ok, %Message{address: @owner, domain: "example.com"}} =
70+
Siwe.verify(raw, signature,
71+
domain: "example.com",
72+
nonce: "32891756",
73+
rpc_client: RaisingRpcModule
74+
)
75+
end
76+
77+
test "verifies an already-parsed message struct" do
78+
message = build_message()
79+
signature = sign(Siwe.to_message(message), @owner_private_key)
80+
81+
assert {:ok, ^message} = Siwe.verify(message, signature, rpc_client: RaisingRpcModule)
82+
end
83+
84+
test "rejects a signature by a different key" do
85+
raw = Siwe.to_message(build_message())
86+
signature = sign(raw, @other_private_key)
87+
88+
assert {:error, :invalid_signature} = Siwe.verify(raw, signature)
89+
end
90+
91+
test "rejects a signature over a different message" do
92+
raw = Siwe.to_message(build_message())
93+
signature = sign("something else entirely", @owner_private_key)
94+
95+
assert {:error, :invalid_signature} = Siwe.verify(raw, signature)
96+
end
97+
end
98+
99+
describe "verify/3 validation errors" do
100+
test "propagates field validation errors before checking the signature" do
101+
message = build_message(expiration_time: "2021-09-30T17:00:00Z")
102+
raw = Siwe.to_message(message)
103+
signature = sign(raw, @owner_private_key)
104+
105+
# rpc_client would raise if the signature check ran — validation fails first
106+
assert {:error, :expired} =
107+
Siwe.verify(raw, signature,
108+
time: ~U[2022-01-01 00:00:00Z],
109+
rpc_client: RaisingRpcModule
110+
)
111+
112+
assert {:error, :domain_mismatch} =
113+
Siwe.verify(raw, signature,
114+
domain: "evil.com",
115+
time: ~U[2021-09-30 16:30:00Z],
116+
rpc_client: RaisingRpcModule
117+
)
118+
119+
assert {:error, :nonce_mismatch} =
120+
Siwe.verify(raw, signature,
121+
nonce: "deadbeef",
122+
time: ~U[2021-09-30 16:30:00Z],
123+
rpc_client: RaisingRpcModule
124+
)
125+
end
126+
127+
test "propagates parse errors" do
128+
assert {:error, :invalid_message_format} = Siwe.verify("not a siwe message", "0x1234")
129+
end
130+
131+
test "propagates RPC transport errors from the signature check" do
132+
raw = Siwe.to_message(build_message())
133+
signature = sign(raw, @other_private_key)
134+
135+
assert {:error, :nxdomain} = Siwe.verify(raw, signature, rpc_client: ErrorRpcModule)
136+
end
137+
end
138+
139+
describe "verify/3 with a smart-contract wallet (ERC-1271)" do
140+
test "verifies a wallet-owner signature against the wallet address" do
141+
encoded_constructor = SiweERC1271WalletContract.constructor(@owner)
142+
143+
wallet =
144+
deploy(SiweERC1271WalletContract, encoded_constructor: encoded_constructor, from: @from)
145+
146+
raw = Siwe.to_message(build_message(address: wallet))
147+
owner_signature = sign(raw, @owner_private_key)
148+
other_signature = sign(raw, @other_private_key)
149+
150+
assert {:ok, %Message{address: address}} = Siwe.verify(raw, owner_signature)
151+
assert String.downcase(address) == String.downcase(wallet)
152+
153+
assert {:error, :invalid_signature} = Siwe.verify(raw, other_signature)
154+
end
155+
end
156+
end

0 commit comments

Comments
 (0)