From 324dce3f84c1727f0f239cf430f80549cd0d96a1 Mon Sep 17 00:00:00 2001 From: shwoop Date: Wed, 25 Jun 2025 17:08:59 +0200 Subject: [PATCH] wip: starting to add tests --- cmd/clef/main.go | 23 ++-- signer/core/api.go | 5 +- signer/core/api_test.go | 9 +- signer/core/signed_data.go | 11 ++ signer/core/signed_data_test.go | 48 +++++++- signer/core/siwevalidation.go | 107 ++++++++++++++++++ signer/core/testdata/siwe/eip1271.txt | 1 + signer/core/testdata/siwe/eip1271_2.txt | 1 + .../testdata/siwe/expwarn_malformed_1.txt | 1 + .../testdata/siwe/no_optional_fields.txt. | 1 + .../testdata/siwe/with_optional_fields.txt | 1 + 11 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 signer/core/siwevalidation.go create mode 100644 signer/core/testdata/siwe/eip1271.txt create mode 100644 signer/core/testdata/siwe/eip1271_2.txt create mode 100644 signer/core/testdata/siwe/expwarn_malformed_1.txt create mode 100644 signer/core/testdata/siwe/no_optional_fields.txt. create mode 100644 signer/core/testdata/siwe/with_optional_fields.txt diff --git a/cmd/clef/main.go b/cmd/clef/main.go index dde4ae853f..5a347e7f87 100644 --- a/cmd/clef/main.go +++ b/cmd/clef/main.go @@ -136,6 +136,11 @@ var ( Name: "stdio-ui-test", Usage: "Mechanism to test interface between Clef and UI. Requires 'stdio-ui'.", } + validateSIWEFlag = &cli.BoolFlag{ + Name: "validate-siwe", + Usage: "Validate Sign-In with Etherium messages (ERC-4361)", + Value: true, + } initCommand = &cli.Command{ Action: initializeSecrets, Name: "init", @@ -283,6 +288,7 @@ func init() { ruleFlag, stdiouiFlag, testFlag, + validateSIWEFlag, advancedMode, acceptFlag, } @@ -409,7 +415,7 @@ func initInternalApi(c *cli.Context) (*core.UIServerAPI, core.UIClientAPI, error lightKdf = c.Bool(utils.LightKDFFlag.Name) ) am := core.StartClefAccountManager(ksLoc, true, lightKdf, "") - api := core.NewSignerAPI(am, 0, true, ui, nil, false, pwStorage) + api := core.NewSignerAPI(am, 0, true, ui, nil, false, pwStorage, false) internalApi := core.NewUIServerAPI(api) return internalApi, ui, nil } @@ -694,18 +700,19 @@ func signer(c *cli.Context) error { } } var ( - chainId = c.Int64(chainIdFlag.Name) - ksLoc = c.String(keystoreFlag.Name) - lightKdf = c.Bool(utils.LightKDFFlag.Name) - advanced = c.Bool(advancedMode.Name) - nousb = c.Bool(utils.NoUSBFlag.Name) - scpath = c.String(utils.SmartCardDaemonPathFlag.Name) + chainId = c.Int64(chainIdFlag.Name) + ksLoc = c.String(keystoreFlag.Name) + lightKdf = c.Bool(utils.LightKDFFlag.Name) + advanced = c.Bool(advancedMode.Name) + nousb = c.Bool(utils.NoUSBFlag.Name) + scpath = c.String(utils.SmartCardDaemonPathFlag.Name) + validateSIWEFlag = c.Bool(validateSIWEFlag.Name) ) log.Info("Starting signer", "chainid", chainId, "keystore", ksLoc, "light-kdf", lightKdf, "advanced", advanced) am := core.StartClefAccountManager(ksLoc, nousb, lightKdf, scpath) defer am.Close() - apiImpl := core.NewSignerAPI(am, chainId, nousb, ui, db, advanced, pwStorage) + apiImpl := core.NewSignerAPI(am, chainId, nousb, ui, db, advanced, pwStorage, validateSIWEFlag) // Establish the bidirectional communication, by creating a new UI backend and registering // it with the UI. diff --git a/signer/core/api.go b/signer/core/api.go index 12acf925f0..4cd8d3736f 100644 --- a/signer/core/api.go +++ b/signer/core/api.go @@ -118,6 +118,7 @@ type SignerAPI struct { UI UIClientAPI validator Validator rejectMode bool + validateSIWEMode bool credentials storage.Storage } @@ -281,11 +282,11 @@ var ErrRequestDenied = errors.New("request denied") // key that is generated when a new Account is created. // noUSB disables USB support that is required to support hardware devices such as // ledger and trezor. -func NewSignerAPI(am *accounts.Manager, chainID int64, noUSB bool, ui UIClientAPI, validator Validator, advancedMode bool, credentials storage.Storage) *SignerAPI { +func NewSignerAPI(am *accounts.Manager, chainID int64, noUSB bool, ui UIClientAPI, validator Validator, advancedMode bool, credentials storage.Storage, validateSIWEMode bool) *SignerAPI { if advancedMode { log.Info("Clef is in advanced mode: will warn instead of reject") } - signer := &SignerAPI{big.NewInt(chainID), am, ui, validator, !advancedMode, credentials} + signer := &SignerAPI{big.NewInt(chainID), am, ui, validator, !advancedMode, validateSIWEMode, credentials} if !noUSB { signer.startUSBListener() } diff --git a/signer/core/api_test.go b/signer/core/api_test.go index 69229dadaf..054afde5da 100644 --- a/signer/core/api_test.go +++ b/signer/core/api_test.go @@ -115,16 +115,17 @@ func tmpDirName(t *testing.T) string { return d } -func setup(t *testing.T) (*core.SignerAPI, *headlessUi) { +func setup(t *testing.T, enableSIWE bool) (*core.SignerAPI, *headlessUi) { db, err := fourbyte.New() if err != nil { t.Fatal(err.Error()) } ui := &headlessUi{make(chan string, 20), make(chan string, 20)} am := core.StartClefAccountManager(tmpDirName(t), true, true, "") - api := core.NewSignerAPI(am, 1337, true, ui, db, true, &storage.NoStorage{}) + api := core.NewSignerAPI(am, 1337, true, ui, db, true, &storage.NoStorage{}, enableSIWE) return api, ui } + func createAccount(ui *headlessUi, api *core.SignerAPI, t *testing.T) { ui.approveCh <- "Y" ui.inputCh <- "a_long_password" @@ -170,7 +171,7 @@ func list(ui *headlessUi, api *core.SignerAPI, t *testing.T) ([]common.Address, func TestNewAcc(t *testing.T) { t.Parallel() - api, control := setup(t) + api, control := setup(t, false) verifyNum := func(num int) { list, err := list(control, api, t) if err != nil { @@ -243,7 +244,7 @@ func TestSignTx(t *testing.T) { err error ) - api, control := setup(t) + api, control := setup(t, false) createAccount(control, api, t) control.approveCh <- "A" list, err = api.List(context.Background()) diff --git a/signer/core/signed_data.go b/signer/core/signed_data.go index c62b513145..63a01ad470 100644 --- a/signer/core/signed_data.go +++ b/signer/core/signed_data.go @@ -38,6 +38,17 @@ import ( // Note, the produced signature conforms to the secp256k1 curve R, S and V values, // where the V value will be 27 or 28 for legacy reasons, if legacyV==true. func (api *SignerAPI) sign(req *SignDataRequest, legacyV bool) (hexutil.Bytes, error) { + if api.validateSIWEMode { + // perform SIWE validation + err := validateSIWE(req) + if err != nil { + if errors.Is(err, ErrMalformedSIWEMEssage) { + api.UI.ShowInfo(err.Error()) + } else { + return nil, err + } + } + } // We make the request prior to looking up if we actually have the account, to prevent // account-enumeration via the API res, err := api.UI.ApproveSignData(req) diff --git a/signer/core/signed_data_test.go b/signer/core/signed_data_test.go index 001f6b6838..4c4d7c95cd 100644 --- a/signer/core/signed_data_test.go +++ b/signer/core/signed_data_test.go @@ -34,6 +34,7 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/signer/core" "github.com/ethereum/go-ethereum/signer/core/apitypes" + "github.com/stretchr/testify/require" ) var typesStandard = apitypes.Types{ @@ -184,7 +185,7 @@ var typedData = apitypes.TypedData{ func TestSignData(t *testing.T) { t.Parallel() - api, control := setup(t) + api, control := setup(t, false) //Create two accounts createAccount(control, api, t) createAccount(control, api, t) @@ -1060,3 +1061,48 @@ func TestEncodeDataRecursiveBytes(t *testing.T) { t.Fatalf("got err %v", err) } } + +func TestSignInWithEtherium(t *testing.T) { + t.Parallel() + + testFiles, err := os.ReadDir(filepath.Join("testdata", "siwe")) + require.NoError(t, err) + + for _, fInfo := range testFiles { + if !strings.HasSuffix(fInfo.Name(), "txt") { + continue + } + t.Run(fInfo.Name(), func(t *testing.T) { + t.Parallel() + expectedFailure := strings.HasPrefix(fInfo.Name(), "expfail") + expectedWarning := strings.HasPrefix(fInfo.Name(), "expwarn") + message, err := os.ReadFile(filepath.Join("testdata", "siwe", fInfo.Name())) + require.NoError(t, err) + + api, control := setup(t, true) + createAccount(control, api, t) + createAccount(control, api, t) + control.approveCh <- "1" + list, err := api.List(context.Background()) + require.NoError(t, err) + a := common.NewMixedcaseAddress(list[0]) + + control.approveCh <- "Y" + control.inputCh <- "a_long_password" + signature, err := api.SignData(context.Background(), apitypes.TextPlain.Mime, a, hexutil.Encode([]byte(message))) + + if expectedFailure { + require.Error(t, err) + return + } else { + require.NoError(t, err) + } + if expectedWarning { + // todo: monitor for warning + } + if signature == nil || len(signature) != 65 { + t.Errorf("Expected 65 byte signature (got %d bytes)", len(signature)) + } + }) + } +} diff --git a/signer/core/siwevalidation.go b/signer/core/siwevalidation.go new file mode 100644 index 0000000000..73867f7b31 --- /dev/null +++ b/signer/core/siwevalidation.go @@ -0,0 +1,107 @@ +// Copyright 2025 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package core + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +// Regular expression to match SIWE messages +// Scheme (optional) +var scheme = `(?:[a-zA-Z][a-zA-Z0-9+\-.]*://)?` + +// Domain +var domain = `(?:[^ ]+)` + +// Static message +var wantsMsg = ` wants you to sign in with your Ethereum account:\n` + +// Ethereum address (with basic 0x prefix + 40 hex digits) +var address = `0x[a-fA-F0-9]{40}\n` + +// Optional statement (any line not containing "\n\n") +var statement = `(?:[^\n]+\n)?` + +// URI +var uri = `URI: (.+)\n` + +// Version +var version = `Version: 1\n` + +// Chain ID +var chainID = `Chain ID: (\d+)\n` + +// Nonce (min 8 alphanumeric characters) +var nonce = `Nonce: [a-zA-Z0-9]{8,}\n` + +// Issued At (RFC 3339 date-time) +var issuedAt = `Issued At: ([0-9T:\-+.Z]+)` + +// Optional Expiration Time +var expiration = `(?:\nExpiration Time: ([0-9T:\-+.Z]+))?` + +// Optional Not Before +var notBefore = `(?:\nNot Before: ([0-9T:\-+.Z]+))?` + +// Optional Request ID +var requestID = `(?:\nRequest ID: ([^\n]+))?` + +// Optional Resources +var resources = `(?:\nResources:(?:\n- .+)+)?` + +// SIWE Message Regex +var siweMessageRegex = regexp.MustCompile(`(?m)^` + scheme + domain + wantsMsg + + address + `\n` + statement + `\n` + + uri + version + chainID + nonce + issuedAt + + expiration + notBefore + requestID + resources + `$`) + +var ErrMalformedSIWEMEssage = errors.New("the message is asking to sign in with Ethereum but does not conform to EIP-4361") + +func validateSIWE(req *SignDataRequest) error { + for _, message := range req.Messages { + s, ok := message.Value.(string) + if !ok { + continue + } + if !strings.Contains(s, "wants you to sign in with your Ethereum account") { + continue + } + patterns := siweMessageRegex.FindStringSubmatch(s) + if len(patterns) != 14 { + return ErrMalformedSIWEMEssage + } + scheme := "https" + if patterns[0] != "" { + scheme = patterns[0] + } + if err := validateDomain(req, scheme, patterns[1]); err != nil { + return err + } + } + return nil +} + +func validateDomain(request *SignDataRequest, scheme, domain string) error { + siweOrigin := fmt.Sprintf("%s://%s", scheme, domain) + if siweOrigin != request.Meta.Origin { + return fmt.Errorf("sign in request domain (%s) does not match source: %s", siweOrigin, request.Meta.Origin) + } + return nil +} diff --git a/signer/core/testdata/siwe/eip1271.txt b/signer/core/testdata/siwe/eip1271.txt new file mode 100644 index 0000000000..543a0bb51c --- /dev/null +++ b/signer/core/testdata/siwe/eip1271.txt @@ -0,0 +1 @@ +localhost:4361 wants you to sign in with your Ethereum account:\n0xa5b3A53800cD49669F34DE80f2C569c6D4Ca3009\n\nSIWE Notepad Example\n\nURI: http://localhost:4361\nVersion: 1\nChain ID: 1\nNonce: FbYd6TNB4m0IUHDG7\nIssued At: 2022-04-19T18:55:04.444Z \ No newline at end of file diff --git a/signer/core/testdata/siwe/eip1271_2.txt b/signer/core/testdata/siwe/eip1271_2.txt new file mode 100644 index 0000000000..a02ea2aecb --- /dev/null +++ b/signer/core/testdata/siwe/eip1271_2.txt @@ -0,0 +1 @@ +localhost:4361 wants you to sign in with your Ethereum account:\n0x0e565A6dFc43DE21455a67bbF196f7F7b15447A7\n\nSIWE Notepad Example\n\nURI: http://localhost:4361\nVersion: 1\nChain ID: 1\nNonce: b19JyMHnM0Jdm20as\nIssued At: 2022-04-19T18:57:09.490Z \ No newline at end of file diff --git a/signer/core/testdata/siwe/expwarn_malformed_1.txt b/signer/core/testdata/siwe/expwarn_malformed_1.txt new file mode 100644 index 0000000000..4e6327a3a6 --- /dev/null +++ b/signer/core/testdata/siwe/expwarn_malformed_1.txt @@ -0,0 +1 @@ +wants you to sign in with your Ethereum account \ No newline at end of file diff --git a/signer/core/testdata/siwe/no_optional_fields.txt. b/signer/core/testdata/siwe/no_optional_fields.txt. new file mode 100644 index 0000000000..430985ac6d --- /dev/null +++ b/signer/core/testdata/siwe/no_optional_fields.txt. @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/signer/core/testdata/siwe/with_optional_fields.txt b/signer/core/testdata/siwe/with_optional_fields.txt new file mode 100644 index 0000000000..f1360244ef --- /dev/null +++ b/signer/core/testdata/siwe/with_optional_fields.txt @@ -0,0 +1 @@ +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 \ No newline at end of file