go-ethereum/signer/core/siwe.go
murraystewart96 0b5d47ad55 signer/clef: implement EIP-4361 Sign-In With Ethereum (SIWE) support
When Clef receives a text/plain signing request, it now detects and
validates EIP-4361 (SIWE) messages:

- Parses the full ABNF structure (domain, address, statement, URI,
  version, chain ID, nonce, issued-at, and all optional fields) with
  strict field ordering and RFC 3339/3986 format validation
- Warns the user when a message looks like SIWE but fails to parse
- For HTTP connections, compares the HTTP Origin header against the
  domain claimed in the message; a mismatch is a CRIT-level warning
  that causes hard rejection in non-advanced mode (--advanced flag
  suppresses rejection to a warning instead)
- Renders parsed SIWE fields as structured labelled entries in the
  signing prompt rather than a raw text blob
- Adds EIP-55 address checksum validation per spec requirement

Test vectors are sourced from the reference SIWE implementation.
2026-05-16 15:43:35 +01:00

410 lines
13 KiB
Go

// Copyright 2024 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 <http://www.gnu.org/licenses/>.
package core
import (
"errors"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/signer/core/apitypes"
)
// SIWEMessage represents a parsed EIP-4361 Sign-In with Ethereum message.
type SIWEMessage struct {
Scheme string // optional
Domain string // required
Address string // required
Statement string // optional
URI string // required
Version string // required
ChainID uint64 // required
Nonce string // required
IssuedAt time.Time // required
ExpirationTime *time.Time // optional
NotBefore *time.Time // optional
RequestID string // optional
Resources []string // optional
}
// SIWEWantedPrefix is the phrase that identifies a SIWE message. Wallet
// implementers SHOULD warn users if this appears in any EIP-191 signing
// request that does not fully conform to EIP-4361.
const (
SIWEWantedPrefix = "wants you to sign in with your Ethereum account"
siweHeaderSuffix = " wants you to sign in with your Ethereum account:"
)
var (
siweSchemeRegexp = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+\-.]*$`)
siweNonceRegexp = regexp.MustCompile(`^[a-zA-Z0-9]{8,}$`)
)
// validateSIWEMessage inspects a text/plain signing request for EIP-4361 content.
// If the text contains the SIWE phrase it attempts a full parse and domain check,
// returning structured display messages and any validation warnings or errors.
// Returns nil, nil when the text is not SIWE-related.
func validateSIWEMessage(text string, meta Metadata) ([]*apitypes.NameValueType, []apitypes.ValidationInfo) {
if !strings.Contains(text, SIWEWantedPrefix) {
return nil, nil
}
siweMsg, err := parseSIWEMessage(text)
if err != nil {
return nil, []apitypes.ValidationInfo{{
Typ: apitypes.WARN,
Message: fmt.Sprintf("message appears to be Sign-In With Ethereum but is not valid: %v", err),
}}
}
var callInfo []apitypes.ValidationInfo
switch meta.Scheme {
case "ipc":
// no browser involved, no origin to verify
case "http":
if meta.Origin == "" {
callInfo = append(callInfo, apitypes.ValidationInfo{
Typ: apitypes.WARN,
Message: "could not verify domain: request has no Origin header",
})
} else {
origin, err := url.Parse(meta.Origin)
if err == nil && origin.Host != siweMsg.Domain {
callInfo = append(callInfo, apitypes.ValidationInfo{
Typ: apitypes.CRIT,
Message: fmt.Sprintf("domain mismatch: message claims %q but request origin is %q",
siweMsg.Domain, origin.Host),
})
}
}
}
return siweToAPIMessages(siweMsg), callInfo
}
// parseSIWEMessage parses a Sign-In with Ethereum message as defined by EIP-4361.
// It validates structure, field order, and the format of each field value.
func parseSIWEMessage(msg string) (*SIWEMessage, error) {
lines := strings.Split(msg, "\n")
// Minimum: header, address, blank, blank, URI, Version, Chain ID, Nonce, Issued At
if len(lines) < 9 {
return nil, errors.New("message too short to be a valid SIWE message")
}
cursor := 0
scheme, domain, err := parseSIWEHeader(lines[cursor])
if err != nil {
return nil, err
}
cursor++
if err := validateSIWEAddress(lines[cursor]); err != nil {
return nil, err
}
cursor++
if lines[cursor] != "" {
return nil, errors.New("expected empty line after address")
}
cursor++
statement, err := parseSIWEStatement(lines, &cursor)
if err != nil {
return nil, err
}
uri, version, chainID, nonce, issuedAt, err := parseSIWERequiredFields(lines, &cursor)
if err != nil {
return nil, err
}
siwe := &SIWEMessage{
Scheme: scheme,
Domain: domain,
Address: lines[1],
Statement: statement,
URI: uri,
Version: version,
ChainID: chainID,
Nonce: nonce,
IssuedAt: issuedAt,
}
if err := parseSIWEOptionalFields(lines, &cursor, siwe); err != nil {
return nil, err
}
if cursor < len(lines) {
return nil, fmt.Errorf("unexpected content after SIWE fields: %q", lines[cursor])
}
return siwe, nil
}
// parseSIWEHeader extracts the scheme (optional) and domain from line 0.
func parseSIWEHeader(line string) (scheme, domain string, err error) {
if !strings.HasSuffix(line, siweHeaderSuffix) {
return "", "", errors.New("first line must end with \" wants you to sign in with your Ethereum account:\"")
}
prefix := strings.TrimSuffix(line, siweHeaderSuffix)
if i := strings.Index(prefix, "://"); i != -1 {
scheme = prefix[:i]
domain = prefix[i+3:]
if !siweSchemeRegexp.MatchString(scheme) {
return "", "", fmt.Errorf("invalid URI scheme %q", scheme)
}
} else {
domain = prefix
}
if err := validateSIWEDomain(domain); err != nil {
return "", "", err
}
return scheme, domain, nil
}
// validateSIWEAddress checks that s is a valid hex Ethereum address with EIP-55 checksum.
func validateSIWEAddress(s string) error {
if !common.IsHexAddress(s) {
return errors.New("invalid Ethereum address")
}
if common.HexToAddress(s).Hex() != s {
return errors.New("address does not conform to EIP-55 checksum encoding")
}
return nil
}
// parseSIWEStatement reads the optional statement and the blank line that follows it.
// cursor is left pointing at the first key-value field line.
func parseSIWEStatement(lines []string, cursor *int) (string, error) {
if lines[*cursor] == "" {
*cursor++
return "", nil
}
statement := lines[*cursor]
*cursor++
if *cursor >= len(lines) || lines[*cursor] != "" {
return "", errors.New("expected empty line after statement")
}
*cursor++
return statement, nil
}
// parseSIWERequiredFields reads URI through Issued At in strict order.
func parseSIWERequiredFields(lines []string, cursor *int) (uri, version string, chainID uint64, nonce string, issuedAt time.Time, err error) {
uri, err = parseSIWEField(lines, cursor, "URI: ")
if err != nil {
return
}
if err = validateSIWEURI(uri); err != nil {
return
}
version, err = parseSIWEField(lines, cursor, "Version: ")
if err != nil {
return
}
if version != "1" {
err = fmt.Errorf("unsupported SIWE version %q, must be \"1\"", version)
return
}
var chainIDStr string
chainIDStr, err = parseSIWEField(lines, cursor, "Chain ID: ")
if err != nil {
return
}
chainID, err = strconv.ParseUint(chainIDStr, 10, 64)
if err != nil {
err = fmt.Errorf("invalid Chain ID %q: must be a positive integer", chainIDStr)
return
}
nonce, err = parseSIWEField(lines, cursor, "Nonce: ")
if err != nil {
return
}
if !siweNonceRegexp.MatchString(nonce) {
err = errors.New("nonce must be at least 8 alphanumeric characters")
return
}
var issuedAtStr string
issuedAtStr, err = parseSIWEField(lines, cursor, "Issued At: ")
if err != nil {
return
}
issuedAt, err = parseSIWEDateTime(issuedAtStr)
if err != nil {
err = fmt.Errorf("invalid Issued At: %w", err)
}
return
}
// parseSIWEOptionalFields reads Expiration Time, Not Before, Request ID, and
// Resources in strict order. Any unrecognised line is left for the caller to
// detect as unexpected content.
func parseSIWEOptionalFields(lines []string, cursor *int, siwe *SIWEMessage) error {
if err := parseSIWEOptionalTime(lines, cursor, "Expiration Time: ", &siwe.ExpirationTime); err != nil {
return err
}
if err := parseSIWEOptionalTime(lines, cursor, "Not Before: ", &siwe.NotBefore); err != nil {
return err
}
if *cursor < len(lines) && strings.HasPrefix(lines[*cursor], "Request ID: ") {
siwe.RequestID = strings.TrimPrefix(lines[*cursor], "Request ID: ")
(*cursor)++
}
return parseSIWEResources(lines, cursor, siwe)
}
// parseSIWEOptionalTime parses an optional datetime field if its prefix is present.
func parseSIWEOptionalTime(lines []string, cursor *int, prefix string, dst **time.Time) error {
if *cursor >= len(lines) || !strings.HasPrefix(lines[*cursor], prefix) {
return nil
}
val := strings.TrimPrefix(lines[*cursor], prefix)
t, err := parseSIWEDateTime(val)
if err != nil {
return err
}
*dst = &t
(*cursor)++
return nil
}
// parseSIWEResources reads the Resources section if present.
func parseSIWEResources(lines []string, cursor *int, siwe *SIWEMessage) error {
if *cursor >= len(lines) || lines[*cursor] != "Resources:" {
return nil
}
(*cursor)++
for *cursor < len(lines) {
if !strings.HasPrefix(lines[*cursor], "- ") {
return fmt.Errorf("invalid resource line %q: must start with \"- \"", lines[*cursor])
}
resource := strings.TrimPrefix(lines[*cursor], "- ")
if err := validateSIWEURI(resource); err != nil {
return err
}
siwe.Resources = append(siwe.Resources, resource)
(*cursor)++
}
return nil
}
// parseSIWEField reads the line at *cursor, strips the expected prefix, advances
// the cursor, and returns the value. Returns an error if the line is missing or
// does not start with prefix.
func parseSIWEField(lines []string, cursor *int, prefix string) (string, error) {
if *cursor >= len(lines) {
return "", fmt.Errorf("missing required field %q", strings.TrimRight(prefix, " "))
}
if !strings.HasPrefix(lines[*cursor], prefix) {
return "", fmt.Errorf("expected field %q, got %q", strings.TrimRight(prefix, " "), lines[*cursor])
}
val := strings.TrimPrefix(lines[*cursor], prefix)
(*cursor)++
return val, nil
}
// parseSIWEDateTime parses an RFC 3339 datetime string, with or without
// sub-second precision.
func parseSIWEDateTime(s string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t, nil
}
return time.Time{}, fmt.Errorf("not a valid RFC 3339 datetime: %q", s)
}
// validateSIWEURI checks that s is a valid RFC 3986 absolute URI.
// url.Parse is too lenient (accepts raw spaces); we check for whitespace
// explicitly since RFC 3986 requires spaces to be percent-encoded.
func validateSIWEURI(s string) error {
if strings.ContainsAny(s, " \t") {
return fmt.Errorf("URI %q contains invalid whitespace", s)
}
u, err := url.Parse(s)
if err != nil || !u.IsAbs() {
return fmt.Errorf("URI %q is not a valid RFC 3986 absolute URI", s)
}
return nil
}
// validateSIWEDomain checks that s is a valid RFC 3986 authority (host[:port]).
// Per EIP-4361: domain = authority = [ userinfo "@" ] host [ ":" port ]
func validateSIWEDomain(s string) error {
if s == "" {
return errors.New("domain is empty")
}
u, err := url.Parse("http://" + s)
if err != nil {
return fmt.Errorf("domain %q is not a valid RFC 3986 authority", s)
}
// Go splits userinfo into u.User and u.Host, so reconstruct the full
// authority to verify it round-trips without modification.
authority := u.Host
if u.User != nil {
authority = u.User.String() + "@" + u.Host
}
if authority != s {
return fmt.Errorf("domain %q is not a valid RFC 3986 authority", s)
}
return nil
}
func siweToAPIMessages(m *SIWEMessage) []*apitypes.NameValueType {
nvts := []*apitypes.NameValueType{
{Name: "Domain", Typ: "domain", Value: m.Domain},
{Name: "Address", Typ: "address", Value: m.Address},
}
if m.Statement != "" {
nvts = append(nvts, &apitypes.NameValueType{Name: "Statement", Typ: "string", Value: m.Statement})
}
nvts = append(nvts,
&apitypes.NameValueType{Name: "URI", Typ: "uri", Value: m.URI},
&apitypes.NameValueType{Name: "Version", Typ: "uint", Value: m.Version},
&apitypes.NameValueType{Name: "Chain ID", Typ: "uint", Value: fmt.Sprintf("%d", m.ChainID)},
&apitypes.NameValueType{Name: "Nonce", Typ: "string", Value: m.Nonce},
&apitypes.NameValueType{Name: "Issued At", Typ: "datetime", Value: m.IssuedAt.String()},
)
if m.ExpirationTime != nil {
nvts = append(nvts, &apitypes.NameValueType{Name: "Expiration Time", Typ: "datetime", Value: m.ExpirationTime.String()})
}
if m.NotBefore != nil {
nvts = append(nvts, &apitypes.NameValueType{Name: "Not Before", Typ: "datetime", Value: m.NotBefore.String()})
}
if m.RequestID != "" {
nvts = append(nvts, &apitypes.NameValueType{Name: "Request ID", Typ: "string", Value: m.RequestID})
}
if len(m.Resources) > 0 {
res := make([]*apitypes.NameValueType, len(m.Resources))
for i, r := range m.Resources {
res[i] = &apitypes.NameValueType{Name: fmt.Sprintf("%d", i+1), Typ: "uri", Value: r}
}
nvts = append(nvts, &apitypes.NameValueType{Name: "Resources", Typ: "list", Value: res})
}
return nvts
}