Skip to content

Commit 16faddb

Browse files
fix(siwe): normalize Ethereum address to lowercase to prevent duplicate identities
Ethereum addresses are protocol-level case-insensitive: 0xABC... and 0xabc... refer to the same wallet. EIP-55 mixed-case is a visual checksum, not a distinct identifier. Before this change, internal/utilities/siwe/parser.go stored the address verbatim from the SIWE message body. internal/api/web3.go then composed the identity provider_id directly from that string, and models.FindIdentityByIdAndProvider does case-sensitive equality on provider_id. The result: the same wallet signing in with different case variants of its address produced two distinct auth.identities rows pointing to two separate users. Fixes #2264. The fix is a single strings.ToLower call at parser entry, immediately after the address pattern matches. Normalizing at the parser layer gives every caller a single canonical form with no extra knowledge required, and matches the existing email lowercase-normalization precedent in internal/models/identity.go (BeforeUpdate). VerifySignature already used strings.EqualFold, so signature recovery is unaffected. Scope is intentionally limited to SIWE (Ethereum). The SIWS (Solana) parser is left untouched: Solana addresses are base58, which is case-sensitive, and lowercasing them would corrupt valid addresses. Operational note: this fix prevents NEW duplicate identities. Existing rows with mixed-case provider_id values are not migrated by this patch; operators who have already accumulated duplicates will need a separate backfill that lowercases auth.identities.provider_id for provider='ethereum' rows and merges the resulting collisions. Flagged as out of scope here. Signed-off-by: Manas Srivastava <mastermanas805@gmail.com>
1 parent 7f88985 commit 16faddb

2 files changed

Lines changed: 60 additions & 1 deletion

File tree

internal/utilities/siwe/parser.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ func ParseMessage(raw string) (*SIWEMessage, error) {
5757
return nil, ErrInvalidAddress
5858
}
5959

60+
// Ethereum addresses are protocol-level case-insensitive: 0xABC... and
61+
// 0xabc... are the same wallet. EIP-55 mixed-case is a visual checksum,
62+
// not a distinct identifier. We normalize to lowercase here so callers
63+
// (notably web3GrantEthereum, which uses Address as part of the
64+
// identity provider_id) see a single canonical form and the same
65+
// wallet cannot create duplicate auth.identities rows by signing in
66+
// with different case variants. See issue #2264. This mirrors the
67+
// existing email lowercase-normalization in models.Identity.BeforeUpdate.
68+
address = strings.ToLower(address)
69+
6070
msg := &SIWEMessage{
6171
Raw: raw,
6272
Domain: domain,

internal/utilities/siwe/parser_test.go

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,10 @@ func TestParseMessage(t *testing.T) {
116116

117117
require.Nil(t, err)
118118
require.Equal(t, "example.com", parsed.Domain)
119-
require.Equal(t, "0x196a28d05bA75C8dC35B0F6e71DD622D1aC82b7E", parsed.Address)
119+
// Address is normalized to lowercase regardless of input casing.
120+
// Ethereum addresses are protocol-level case-insensitive; EIP-55
121+
// mixed-case is a visual checksum, not a distinct identifier.
122+
require.Equal(t, "0x196a28d05ba75c8dc35b0f6e71dd622d1ac82b7e", parsed.Address)
120123

121124
if i == 0 {
122125
require.Equal(t, "Sign in to Example App", *parsed.Statement)
@@ -134,3 +137,49 @@ func TestParseMessage(t *testing.T) {
134137
})
135138
}
136139
}
140+
141+
// TestParseMessageAddressNormalization asserts that ParseMessage lowercases
142+
// the Ethereum address regardless of the input casing. Ethereum addresses
143+
// are protocol-level case-insensitive (EIP-55 mixed-case is a visual checksum
144+
// only), so storing them verbatim allowed the same wallet signing in with
145+
// different casings to create duplicate auth.identities rows. See #2264.
146+
func TestParseMessageAddressNormalization(t *testing.T) {
147+
const lowerAddress = "0x196a28d05ba75c8dc35b0f6e71dd622d1ac82b7e"
148+
const upperHexAddress = "0x196A28D05BA75C8DC35B0F6E71DD622D1AC82B7E"
149+
const checksumAddress = "0x196a28d05bA75C8dC35B0F6e71DD622D1aC82b7E"
150+
151+
makeMessage := func(addr string) string {
152+
return "example.com wants you to sign in with your Ethereum account:\n" +
153+
addr +
154+
"\n\nURI: https://example.com\nVersion: 1\nChain ID: 1\nNonce: 12345678\nIssued At: 2025-01-01T00:00:00.000Z"
155+
}
156+
157+
cases := []struct {
158+
name string
159+
input string
160+
}{
161+
{name: "lowercase", input: lowerAddress},
162+
{name: "uppercase hex", input: upperHexAddress},
163+
{name: "EIP-55 checksum mixed case", input: checksumAddress},
164+
}
165+
166+
parsedAddresses := make([]string, 0, len(cases))
167+
for _, tc := range cases {
168+
t.Run(tc.name, func(t *testing.T) {
169+
parsed, err := ParseMessage(makeMessage(tc.input))
170+
require.Nil(t, err)
171+
require.Equal(t, lowerAddress, parsed.Address,
172+
"parser must lowercase Ethereum address so case-variant sign-ins do not create duplicate identities")
173+
})
174+
// Re-parse outside the subtest to collect for the equality assertion below.
175+
parsed, err := ParseMessage(makeMessage(tc.input))
176+
require.Nil(t, err)
177+
parsedAddresses = append(parsedAddresses, parsed.Address)
178+
}
179+
180+
// All case variants of the same wallet must yield the exact same Address.
181+
for i := 1; i < len(parsedAddresses); i++ {
182+
require.Equal(t, parsedAddresses[0], parsedAddresses[i],
183+
"all case variants of the same Ethereum address must parse to the same value")
184+
}
185+
}

0 commit comments

Comments
 (0)