From 9e42bb2f28f32ffff45e9baf9a2c2f34b79263b6 Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Fri, 17 Jan 2025 21:35:22 +0300 Subject: [PATCH 01/10] feat: implement EIP-4361 support with SIWS message handling and verification --- example.env | 11 ++ external_eip4361_siws_example.go | 75 ++++++++ go.mod | 19 +- go.sum | 50 ++++++ internal/api/external_eip4361_siws_test.go | 86 +++++++++ internal/api/helpers.go | 2 + internal/api/provider/eip4361.go | 192 +++++++++++++++++++++ internal/api/token.go | 72 ++++++++ internal/api/web3.go | 8 + internal/conf/configuration.go | 62 +++++++ internal/models/factor.go | 6 + internal/models/web3.go | 4 + internal/reloader/testdata/50_example.env | 13 +- internal/utilities/siws/go.mod.bak | 5 + internal/utilities/siws/go.sum.bak | 35 ++++ internal/utilities/siws/helpers.go | 72 ++++++++ internal/utilities/siws/message.go | 40 +++++ internal/utilities/siws/parser.go | 99 +++++++++++ internal/utilities/siws/siws_test.go | 107 ++++++++++++ internal/utilities/siws/types.go | 28 +++ internal/utilities/siws/verify.go | 58 +++++++ internal/utilities/web3/ethereum/verify.go | 57 ++++++ 22 files changed, 1092 insertions(+), 9 deletions(-) create mode 100644 external_eip4361_siws_example.go create mode 100644 internal/api/external_eip4361_siws_test.go create mode 100644 internal/api/provider/eip4361.go create mode 100644 internal/api/web3.go create mode 100644 internal/models/web3.go create mode 100644 internal/utilities/siws/go.mod.bak create mode 100644 internal/utilities/siws/go.sum.bak create mode 100644 internal/utilities/siws/helpers.go create mode 100644 internal/utilities/siws/message.go create mode 100644 internal/utilities/siws/parser.go create mode 100644 internal/utilities/siws/siws_test.go create mode 100644 internal/utilities/siws/types.go create mode 100644 internal/utilities/siws/verify.go create mode 100644 internal/utilities/web3/ethereum/verify.go diff --git a/example.env b/example.env index e645c96e9c..2708710395 100644 --- a/example.env +++ b/example.env @@ -168,6 +168,17 @@ GOTRUE_EXTERNAL_ZOOM_CLIENT_ID="" GOTRUE_EXTERNAL_ZOOM_SECRET="" GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="http://localhost:9999/callback" +# EIP-4361 OAuth config +GOTRUE_EXTERNAL_EIP4361_ENABLED="true" +GOTRUE_EXTERNAL_EIP4361_STATEMENT="Sign this message to verify your identity" +GOTRUE_EXTERNAL_EIP4361_VERSION="1" +GOTRUE_EXTERNAL_EIP4361_TIMEOUT="300s" +GOTRUE_EXTERNAL_EIP4361_DOMAIN="localhost:9999" + +# Supported Chains Configuration +GOTRUE_EXTERNAL_EIP4361_SUPPORTED_CHAINS="ethereum:1,ethereum:137,solana:mainnet,solana:devnet" +GOTRUE_EXTERNAL_EIP4361_DEFAULT_CHAIN="ethereum:1" + # Anonymous auth config GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED="false" diff --git a/external_eip4361_siws_example.go b/external_eip4361_siws_example.go new file mode 100644 index 0000000000..9d9325b573 --- /dev/null +++ b/external_eip4361_siws_example.go @@ -0,0 +1,75 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "time" + + "github.com/btcsuite/btcutil/base58" + siws "github.com/supabase/auth/internal/utilities/siws" +) + +func LogSIWSExample() { + // Configuration + domain := "localhost:9999" + statement := "Sign in with your Solana account" + version := "1" + chain := "solana:mainnet" + + // Generate keys + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + fmt.Println("Error generating keys:", err) + return + } + pubKeyBase58 := base58.Encode(pubKey) + + // Generate nonce + nonce, err := siws.GenerateNonce() + if err != nil { + fmt.Println("Error generating nonce:", err) + return + } + + // Create SIWS message + msg := siws.SIWSMessage{ + Domain: domain, + Address: pubKeyBase58, + Statement: statement, + URI: "https://example.com", + Version: version, + Nonce: nonce, + IssuedAt: time.Now().UTC(), + } + + rawMessage := siws.ConstructMessage(msg) + + // Sign the message + signature := ed25519.Sign(privKey, []byte(rawMessage)) + signatureBase64 := base64.StdEncoding.EncodeToString(signature) + + // Generate JSON payload + payload := map[string]string{ + "grant_type": "eip4361", + "message": rawMessage, + "signature": signatureBase64, + "address": pubKeyBase58, + "chain": chain, + } + + payloadJSON, err := json.Marshal(payload) + if err != nil { + fmt.Println("Error generating payload JSON:", err) + return + } + + // Print JavaScript fetch code + fmt.Println(string(payloadJSON)) +} + +// func main() { +// LogSIWSExample() +// } diff --git a/go.mod b/go.mod index a99b2b688c..232cc1a81b 100644 --- a/go.mod +++ b/go.mod @@ -34,14 +34,17 @@ require ( ) require ( - github.com/bits-and-blooms/bitset v1.10.0 // indirect + github.com/bits-and-blooms/bitset v1.13.0 // indirect + github.com/btcsuite/btcutil v1.0.2 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect + github.com/ethereum/go-ethereum v1.14.12 // indirect github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-jose/go-jose/v3 v3.0.3 // indirect github.com/go-webauthn/x v0.1.12 // indirect github.com/gobuffalo/nulls v0.4.2 // indirect github.com/goccy/go-json v0.10.3 // indirect github.com/google/go-tpm v0.9.1 // indirect + github.com/holiman/uint256 v1.3.1 // indirect github.com/jackc/pgx/v4 v4.18.2 // indirect github.com/lestrrat-go/blackmagic v1.0.2 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect @@ -98,10 +101,10 @@ require ( github.com/beevik/etree v1.1.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/crewjam/httperr v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/fatih/color v1.13.0 // indirect + github.com/fatih/color v1.16.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -130,7 +133,7 @@ require ( github.com/luna-duclos/instrumentedsql v1.1.3 // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.16 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -146,15 +149,15 @@ require ( github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/objx v0.5.2 // indirect go.opentelemetry.io/proto/otlp v1.2.0 // indirect - golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb - golang.org/x/net v0.23.0 // indirect + golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa + golang.org/x/net v0.24.0 // indirect golang.org/x/sync v0.10.0 golang.org/x/sys v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect - golang.org/x/time v0.0.0-20220411224347-583f2d630306 // indirect + golang.org/x/time v0.5.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/grpc v1.63.2 // indirect - google.golang.org/protobuf v1.33.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 827144acd1..217cd60e38 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,7 @@ github.com/XSAM/otelsql v0.26.0 h1:UhAGVBD34Ctbh2aYcm/JAdL+6T6ybrP+YMWYkHqCdmo= github.com/XSAM/otelsql v0.26.0/go.mod h1:5ciw61eMSh+RtTPN8spvPEPLJpAErZw8mFFPNfYiaxA= github.com/aaronarduino/goqrsvg v0.0.0-20220419053939-17e843f1dd40 h1:uz4N2yHL4MF8vZX+36n+tcxeUf8D/gL4aJkyouhDw4A= github.com/aaronarduino/goqrsvg v0.0.0-20220419053939-17e843f1dd40/go.mod h1:dytw+5qs+pdi61fO/S4OmXR7AuEq/HvNCuG03KxQHT4= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/ajstarks/deck v0.0.0-20200831202436-30c9fc6549a9/go.mod h1:JynElWSGnm/4RlzPXRlREEwqTHAN3T56Bv2ITsFT3gY= github.com/ajstarks/deck/generate v0.0.0-20210309230005-c3f852c02e19/go.mod h1:T13YZdzov6OU0A1+RfKZiZN9ca6VeKdBdyDV+BY97Tk= github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw= @@ -22,6 +23,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.10.0 h1:ePXTeiPEazB5+opbv5fr8umg2R/1NlzgDsyepwsSr88= github.com/bits-and-blooms/bitset v1.10.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= +github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bits-and-blooms/bloom/v3 v3.6.0 h1:dTU0OVLJSoOhz9m68FTXMFfA39nR8U/nTCs1zb26mOI= github.com/bits-and-blooms/bloom/v3 v3.6.0/go.mod h1:VKlUSvp0lFIYqxJjzdnSsZEw4iHb1kOL2tfHTgyJBHg= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= @@ -29,10 +32,22 @@ github.com/bombsimon/logrusr/v3 v3.0.0 h1:tcAoLfuAhKP9npBxWzSdpsvKPQt1XV02nSf2lZ github.com/bombsimon/logrusr/v3 v3.0.0/go.mod h1:PksPPgSFEL2I52pla2glgCyyd2OqOHAnFF5E+g8Ixco= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts= +github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/coreos/go-oidc/v3 v3.6.0 h1:AKVxfYw1Gmkn/w96z0DbT/B/xFnzTd3MkZvWLjF4n/o= @@ -46,6 +61,7 @@ github.com/crewjam/httperr v0.2.0 h1:b2BfXR8U3AlIHwNeFFvZ+BV1LFvKLlzMjzaTnZMybNo github.com/crewjam/httperr v0.2.0/go.mod h1:Jlz+Sg/XqBQhyMjdDiC+GNNRzZTD7x39Gu3pglZ5oH4= github.com/crewjam/saml v0.4.14 h1:g9FBNx62osKusnFzs3QTN5L9CVA/Egfgm+stJShzw/c= github.com/crewjam/saml v0.4.14/go.mod h1:UVSZCf18jJkk6GpWNVqcyQJMD5HsRugBPf4I1nl2mME= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -55,12 +71,17 @@ github.com/deepmap/oapi-codegen v1.12.4 h1:pPmn6qI9MuOtCz82WY2Xaw46EQjgvxednXXrP github.com/deepmap/oapi-codegen v1.12.4/go.mod h1:3lgHGMu6myQ2vqbbTXH2H1o4eXFTGnFiDaOaKKl5yas= github.com/didip/tollbooth/v5 v5.1.1 h1:QpKFg56jsbNuQ6FFj++Z1gn2fbBsvAc1ZPLUaDOYW5k= github.com/didip/tollbooth/v5 v5.1.1/go.mod h1:d9rzwOULswrD3YIrAQmP3bfjxab32Df4IaO6+D25l9g= +github.com/ethereum/go-ethereum v1.14.12 h1:8hl57x77HSUo+cXExrURjU/w1VhL+ShCTJrTwcCQSe4= +github.com/ethereum/go-ethereum v1.14.12/go.mod h1:RAC2gVMWJ6FkxSPESfbshrcKpIokgQKsVKmAuqdekDY= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= @@ -123,6 +144,7 @@ github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQg github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -142,6 +164,9 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0Q github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= +github.com/holiman/uint256 v1.3.1 h1:JfTzmih28bittyHM8z360dCjIA9dbPIBlcTI6lmctQs= +github.com/holiman/uint256 v1.3.1/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= @@ -198,18 +223,21 @@ github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0f github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -255,6 +283,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= @@ -269,6 +299,9 @@ github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450 h1:j2kD3MT1z4PXCiUll github.com/mrjones/oauth v0.0.0-20190623134757-126b35219450/go.mod h1:skjdDftzkFALcuGzYSklqYd8gvat6F1gZJ4YPVbkZpM= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/patrickmn/go-cache v0.0.0-20170418232947-7ac151875ffb/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= @@ -404,11 +437,13 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9E go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= @@ -420,6 +455,8 @@ golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb h1:PaBZQdo+iSDyHT053FjUCgZQ/9uqVwPOcl7KSWhKn6w= golang.org/x/exp v0.0.0-20230213192124-5e25df0256eb/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= +golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= @@ -429,6 +466,7 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20161007143504-f4b625ec9b21/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -443,8 +481,11 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -453,6 +494,7 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -474,6 +516,7 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= @@ -500,6 +543,8 @@ golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.0.0-20160926182426-711ca1cb8763/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w= golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -532,6 +577,8 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -540,11 +587,14 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/internal/api/external_eip4361_siws_test.go b/internal/api/external_eip4361_siws_test.go new file mode 100644 index 0000000000..4fbdded5d2 --- /dev/null +++ b/internal/api/external_eip4361_siws_test.go @@ -0,0 +1,86 @@ +package api + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "time" + + "github.com/btcsuite/btcutil/base58" + "github.com/supabase/auth/internal/conf" + siws "github.com/supabase/auth/internal/utilities/siws" +) + +const ( + siwsValidUser string = `{"address":"12345abcde","chain":"solana:mainnet"}` + siwsWrongChain string = `{"address":"12345abcde","chain":"ethereum:1"}` + siwsInvalidUser string = `{"address":"","chain":"solana:mainnet"}` +) + +func SIWSTestSignupSetup(ts *ExternalTestSuite) { + ts.Config.External.EIP4361 = conf.EIP4361Configuration{ + Enabled: true, + Domain: "test.example.com", + Statement: "Sign in with your Solana account", + Version: "1", + Timeout: 5 * time.Minute, + SupportedChains: "solana:mainnet", + DefaultChain: "solana:mainnet", + } +} + +func (ts *ExternalTestSuite) TestSignupExternalSIWS() { + SIWSTestSignupSetup(ts) + ts.Config.DisableSignup = false + + // Generate test keys for Solana + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + ts.Require().NoError(err) + pubKeyBase58 := base58.Encode(pubKey) + + nonce, err := siws.GenerateNonce() + ts.Require().NoError(err) + + // Create test message + msg := siws.SIWSMessage{ + Domain: ts.Config.External.EIP4361.Domain, + Address: pubKeyBase58, + Statement: ts.Config.External.EIP4361.Statement, + URI: "https://example.com", + Version: ts.Config.External.EIP4361.Version, + Nonce: nonce, + IssuedAt: time.Now().UTC(), + } + + rawMessage := siws.ConstructMessage(msg) + signature := ed25519.Sign(privKey, []byte(rawMessage)) + signatureBase64 := base64.StdEncoding.EncodeToString(signature) + + formData := url.Values{} + formData.Set("grant_type", "eip4361") + formData.Set("message", rawMessage) + formData.Set("signature", signatureBase64) + formData.Set("address", pubKeyBase58) + formData.Set("chain", "solana:mainnet") + + req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(formData.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + ts.Require().Equal(http.StatusOK, w.Code) + + var token AccessTokenResponse + ts.Require().NoError(json.NewDecoder(w.Body).Decode(&token)) + + ts.Require().NotEmpty(token.Token) + ts.Require().NotEmpty(token.RefreshToken) + ts.Require().Equal("bearer", token.TokenType) + ts.Require().NotNil(token.User) +} diff --git a/internal/api/helpers.go b/internal/api/helpers.go index 8a9f3267ed..2318013792 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -74,6 +74,7 @@ type RequestParams interface { SignupParams | SingleSignOnParams | SmsParams | + Web3GrantParams | UserUpdateParams | VerifyFactorParams | VerifyParams | @@ -81,6 +82,7 @@ type RequestParams interface { adminUserDeleteParams | security.GotrueRequest | ChallengeFactorParams | + struct { Email string `json:"email"` Phone string `json:"phone"` diff --git a/internal/api/provider/eip4361.go b/internal/api/provider/eip4361.go new file mode 100644 index 0000000000..7766a6db06 --- /dev/null +++ b/internal/api/provider/eip4361.go @@ -0,0 +1,192 @@ +package provider + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/utilities/siws" + "github.com/supabase/auth/internal/utilities/web3/ethereum" + "golang.org/x/oauth2" +) + +const ( + BlockchainEthereum = "ethereum" + BlockchainSolana = "solana" +) + +// EIP4361Provider implements Web3 authentication following EIP-4361 spec +type EIP4361Provider struct { + config conf.EIP4361Configuration + chains map[string]conf.BlockchainConfig + defaultChain string +} + +type SignedMessage struct { + Message string `json:"message"` + Signature string `json:"signature"` + Address string `json:"address"` + Chain string `json:"chain"` +} + +func NewEIP4361Provider(ctx context.Context, config conf.EIP4361Configuration) (*EIP4361Provider, error) { + if !config.Enabled { + return nil, errors.New("EIP4361 provider is not enabled") + } + + // Parse chains + chains, err := config.ParseSupportedChains() + if err != nil { + return nil, err + } + + // Validate default chain + if config.DefaultChain != "" { + if _, ok := chains[config.DefaultChain]; !ok { + return nil, fmt.Errorf("default chain %s not in supported chains", config.DefaultChain) + } + } + + return &EIP4361Provider{ + config: config, + chains: chains, + defaultChain: config.DefaultChain, + }, nil +} + +func (p *EIP4361Provider) AuthCodeURL(state string, args ...oauth2.AuthCodeOption) string { + return "" // Web3 auth doesn't use OAuth flow +} + +func (p *EIP4361Provider) GetOAuthToken(code string) (*oauth2.Token, error) { + return nil, errors.New("GetOAuthToken not implemented for EIP4361") +} + +func (p *EIP4361Provider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + return nil, errors.New("GetUserData not implemented for EIP4361") +} + +// VerifySignedMessage verifies a signed Web3 message based on the blockchain +func (p *EIP4361Provider) VerifySignedMessage(msg *SignedMessage) (*UserProvidedData, error) { + chain, ok := p.chains[msg.Chain] + if !ok { + return nil, fmt.Errorf("unsupported blockchain: %s", msg.Chain) + } + + var err error + switch chain.NetworkName { + case BlockchainEthereum: + err = p.verifyEthereumSignature(msg) + case BlockchainSolana: + err = p.verifySolanaSignature(msg) + default: + return nil, fmt.Errorf("signature verification not implemented for %s", chain.NetworkName) + } + + if err != nil { + return nil, fmt.Errorf("signature verification failed: %w", err) + } + + // Construct the provider_id as chain:address to make it unique + providerId := fmt.Sprintf("%s:%s", msg.Chain, msg.Address) + + return &UserProvidedData{ + Metadata: &Claims{ + CustomClaims: map[string]interface{}{ + "address": msg.Address, + "chain": msg.Chain, + "role": "authenticated", + }, + Subject: providerId, // This becomes the provider_id in the identity + }, + Emails: []Email{}, + }, nil +} + +func (p *EIP4361Provider) verifyEthereumSignature(msg *SignedMessage) error { + return ethereum.VerifySignature(msg.Message, msg.Signature, msg.Address) +} + +func (p *EIP4361Provider) verifySolanaSignature(msg *SignedMessage) error { + parsedMessage, err := siws.ParseSIWSMessage(msg.Message) + if err != nil { + return fmt.Errorf("failed to parse SIWS message: %w", err) + } + + // Decode base64 signature into bytes + sigBytes, err := base64.StdEncoding.DecodeString(msg.Signature) + if err != nil { + return fmt.Errorf("invalid signature encoding: %w", err) + } + + params := siws.SIWSVerificationParams{ + ExpectedDomain: p.config.Domain, + CheckTime: true, + TimeDuration: p.config.Timeout, + } + + if err := siws.VerifySIWS(msg.Message, sigBytes, parsedMessage, params); err != nil { + return fmt.Errorf("SIWS verification failed: %w", err) + } + + return nil +} + +func (p *EIP4361Provider) GenerateSignMessage(address string, chain string, uri string) (string, error) { + if chain == "" { + chain = p.defaultChain + } + + chainCfg, ok := p.chains[chain] + if !ok { + return "", fmt.Errorf("unsupported chain: %s", chain) + } + + // Generate nonce for message uniqueness + nonce, err := siws.GenerateNonce() + if err != nil { + return "", fmt.Errorf("failed to generate nonce: %w", err) + } + + now := time.Now().UTC() + + switch chainCfg.NetworkName { + case BlockchainSolana: + msg := siws.SIWSMessage{ + Domain: p.config.Domain, + Address: address, + Statement: p.config.Statement, + URI: uri, + Version: p.config.Version, + Nonce: nonce, + IssuedAt: now, + } + return siws.ConstructMessage(msg), nil + + case BlockchainEthereum: + return fmt.Sprintf(`%s wants you to sign in with your %s account: +%s + +URI: %s +Version: %s +Chain ID: %s +Nonce: %d +Issued At: %s +Expiration Time: %s`, + p.config.Domain, + chainCfg.NetworkName, + address, + uri, + p.config.Version, + chainCfg.ChainID, + now.UnixNano(), + now.Format(time.RFC3339), + now.Add(p.config.Timeout).Format(time.RFC3339)), nil + + default: + return "", fmt.Errorf("message generation not implemented for %s", chainCfg.NetworkName) + } +} diff --git a/internal/api/token.go b/internal/api/token.go index cc945f2e13..5dee090334 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -13,6 +13,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/xeipuuv/gojsonschema" + "github.com/supabase/auth/internal/api/provider" "github.com/supabase/auth/internal/hooks" "github.com/supabase/auth/internal/metering" "github.com/supabase/auth/internal/models" @@ -79,6 +80,7 @@ const InvalidLoginMessage = "Invalid login credentials" func (a *API) Token(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() grantType := r.FormValue("grant_type") + switch grantType { case "password": return a.ResourceOwnerPasswordGrant(ctx, w, r) @@ -88,6 +90,8 @@ func (a *API) Token(w http.ResponseWriter, r *http.Request) error { return a.IdTokenGrant(ctx, w, r) case "pkce": return a.PKCE(ctx, w, r) + case "eip4361": + return a.EIP4361Grant(ctx, w, r) default: return badRequestError(ErrorCodeInvalidCredentials, "unsupported_grant_type") } @@ -307,6 +311,74 @@ func (a *API) PKCE(ctx context.Context, w http.ResponseWriter, r *http.Request) return sendJSON(w, http.StatusOK, token) } +func (a *API) EIP4361Grant(ctx context.Context, w http.ResponseWriter, r *http.Request) error { + db := a.db.WithContext(ctx) + + params := &Web3GrantParams{} + if err := retrieveRequestParams(r, params); err != nil { + return err + } + + web3Provider, err := provider.NewEIP4361Provider(ctx, a.config.External.EIP4361) + if err != nil { + return err + } + + // Convert params to SignedMessage + msg := &provider.SignedMessage{ + Message: params.Message, + Signature: params.Signature, + Address: params.Address, + Chain: params.Chain, + } + + userData, err := web3Provider.VerifySignedMessage(msg) + + if err != nil { + return oauthError("invalid_grant", "Signature verification failed").WithInternalError(err) + } + + var token *AccessTokenResponse + var grantParams models.GrantParams + grantParams.FillGrantParams(r) + + err = db.Transaction(func(tx *storage.Connection) error { + user, terr := a.createAccountFromExternalIdentity(tx, r, userData, "eip4361") + if terr != nil { + return terr + } + + // Log the auth attempt + if terr := models.NewAuditLogEntry(r, tx, user, models.LoginAction, "", map[string]interface{}{ + "provider": "eip4361", + "chain": msg.Chain, + "address": msg.Address, + }); terr != nil { + return terr + } + + token, terr = a.issueRefreshToken(r, tx, user, models.Web3, grantParams) + if terr != nil { + return terr + } + + return nil + }) + + if err != nil { + switch err.(type) { + case *storage.CommitWithError: + return err + case *HTTPError: + return err + default: + return oauthError("server_error", "Internal Server Error").WithInternalError(err) + } + } + + return sendJSON(w, http.StatusOK, token) +} + func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user *models.User, sessionId *uuid.UUID, authenticationMethod models.AuthenticationMethod) (string, int64, error) { config := a.config if sessionId == nil { diff --git a/internal/api/web3.go b/internal/api/web3.go new file mode 100644 index 0000000000..958d86693d --- /dev/null +++ b/internal/api/web3.go @@ -0,0 +1,8 @@ +package api + +type Web3GrantParams struct { + Message string `json:"message"` + Signature string `json:"signature"` + Address string `json:"address"` + Chain string `json:"chain"` +} diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index c4d910d991..e19bf92570 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "math/big" "net/url" "os" "path/filepath" @@ -19,6 +20,7 @@ import ( "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" "github.com/lestrrat-go/jwx/v2/jwk" + siws "github.com/supabase/auth/internal/utilities/siws" "gopkg.in/gomail.v2" ) @@ -339,6 +341,66 @@ type ProviderConfiguration struct { RedirectURL string `json:"redirect_url"` AllowedIdTokenIssuers []string `json:"allowed_id_token_issuers" split_words:"true"` FlowStateExpiryDuration time.Duration `json:"flow_state_expiry_duration" split_words:"true"` + EIP4361 EIP4361Configuration `json:"eip4361" envconfig:"EIP4361"` +} + +type EIP4361Configuration struct { + Enabled bool `json:"enabled" default:"false" split_words:"true"` + Domain string `json:"domain" required:"true" split_words:"true"` + Statement string `json:"statement" split_words:"true"` + Version string `json:"version" default:"1" split_words:"true"` + Timeout time.Duration `json:"timeout" default:"300s" split_words:"true"` + + // Comma-separated list of supported chains (e.g. "ethereum:1,ethereum:137,solana:mainnet") + SupportedChains string `json:"supported_chains" split_words:"true"` + DefaultChain string `json:"default_chain" split_words:"true"` +} + +type BlockchainConfig struct { + ChainID string + NetworkName string +} + +// ParseSupportedChains processes and validates the SupportedChains string. +func (c *EIP4361Configuration) ParseSupportedChains() (map[string]BlockchainConfig, error) { + chainMap := make(map[string]BlockchainConfig) + + // Split comma-separated chains + chains := strings.Split(c.SupportedChains, ",") + for _, chain := range chains { + chain = strings.TrimSpace(chain) + parts := strings.Split(chain, ":") + + // Ensure proper : format + if len(parts) != 2 { + return nil, fmt.Errorf("invalid chain format: %s, expected :", chain) + } + + network := strings.ToLower(parts[0]) + chainID := parts[1] + + // Validate network type + switch network { + case "ethereum": + if _, ok := new(big.Int).SetString(chainID, 10); !ok { + return nil, fmt.Errorf("invalid Ethereum chain ID: %s", chainID) + } + case "solana": + if !siws.IsValidSolanaNetwork(chainID) { + return nil, fmt.Errorf("invalid Solana network: %s", chainID) + } + default: + return nil, fmt.Errorf("unsupported network: %s", network) + } + + // Add to chain map + chainMap[chain] = BlockchainConfig{ + NetworkName: network, + ChainID: chainID, + } + } + + return chainMap, nil } type SMTPConfiguration struct { diff --git a/internal/models/factor.go b/internal/models/factor.go index a88874d734..72309a8557 100644 --- a/internal/models/factor.go +++ b/internal/models/factor.go @@ -54,6 +54,7 @@ const ( EmailChange TokenRefresh Anonymous + Web3 ) func (authMethod AuthenticationMethod) String() string { @@ -86,6 +87,8 @@ func (authMethod AuthenticationMethod) String() string { return "mfa/phone" case MFAWebAuthn: return "mfa/webauthn" + case Web3: + return "web3" } return "" } @@ -121,6 +124,9 @@ func ParseAuthenticationMethod(authMethod string) (AuthenticationMethod, error) return MFAPhone, nil case "mfa/webauthn": return MFAWebAuthn, nil + case "web3": + return Web3, nil + } return 0, fmt.Errorf("unsupported authentication method %q", authMethod) } diff --git a/internal/models/web3.go b/internal/models/web3.go new file mode 100644 index 0000000000..6fc274e16d --- /dev/null +++ b/internal/models/web3.go @@ -0,0 +1,4 @@ +package models + +const Web3Provider = "web3" +const Web3Grant = "web3" diff --git a/internal/reloader/testdata/50_example.env b/internal/reloader/testdata/50_example.env index 1002d8be17..8f4d4949c9 100644 --- a/internal/reloader/testdata/50_example.env +++ b/internal/reloader/testdata/50_example.env @@ -10,7 +10,7 @@ GOTRUE_JWT_ADMIN_ROLES="supabase_admin,service_role" # Database & API connection details GOTRUE_DB_DRIVER="postgres" DB_NAMESPACE="auth" -DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5432/postgres" +DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5433/postgres" API_EXTERNAL_URL="http://localhost:9999" GOTRUE_API_HOST="localhost" PORT="9999" @@ -168,6 +168,17 @@ GOTRUE_EXTERNAL_ZOOM_CLIENT_ID="" GOTRUE_EXTERNAL_ZOOM_SECRET="" GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="http://localhost:9999/callback" +# EIP-4361 OAuth config +GOTRUE_EXTERNAL_EIP4361_ENABLED="true" +GOTRUE_EXTERNAL_EIP4361_STATEMENT="Sign this message to verify your identity" +GOTRUE_EXTERNAL_EIP4361_VERSION="1" +GOTRUE_EXTERNAL_EIP4361_TIMEOUT="300s" +GOTRUE_EXTERNAL_EIP4361_DOMAIN="localhost:9999" + +# Supported Chains Configuration +GOTRUE_EXTERNAL_EIP4361_SUPPORTED_CHAINS="ethereum:1,ethereum:137,solana:mainnet,solana:devnet" +GOTRUE_EXTERNAL_EIP4361_DEFAULT_CHAIN="ethereum:1" + # Anonymous auth config GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED="false" diff --git a/internal/utilities/siws/go.mod.bak b/internal/utilities/siws/go.mod.bak new file mode 100644 index 0000000000..125bdd6632 --- /dev/null +++ b/internal/utilities/siws/go.mod.bak @@ -0,0 +1,5 @@ +module github.com/utilities/siwsgo + +go 1.20 + +require github.com/btcsuite/btcutil v1.0.2 diff --git a/internal/utilities/siws/go.sum.bak b/internal/utilities/siws/go.sum.bak new file mode 100644 index 0000000000..eadd72e564 --- /dev/null +++ b/internal/utilities/siws/go.sum.bak @@ -0,0 +1,35 @@ +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts= +github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/utilities/siws/helpers.go b/internal/utilities/siws/helpers.go new file mode 100644 index 0000000000..7a3b8b2798 --- /dev/null +++ b/internal/utilities/siws/helpers.go @@ -0,0 +1,72 @@ +package siws + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net/url" + "strings" +) + +// GenerateNonce creates a random 16-byte nonce, returning a hex-encoded string. +func GenerateNonce() (string, error) { + b := make([]byte, 16) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// ValidateDomain checks if a domain is valid or not. This can be expanded with +// real domain validation logic. Here, we do a simple parse check. +func ValidateDomain(domain string) error { + u, err := url.Parse("https://" + domain) + if err != nil || u.Hostname() == "" { + return errors.New("invalid domain") + } + return nil +} + +// IsBase58PubKey checks if the input is a plausible base58 Solana public key. +// Typically Solana addresses are ~44 characters in base58. This is a naive check. +func IsBase58PubKey(address string) bool { + address = strings.TrimSpace(address) + if len(address) < 32 { + return false + } + // Optionally, you could decode with base58 and check for 32 bytes. + return true +} + +// Add these functions to your existing helpers.go +func IsValidSolanaNetwork(network string) bool { + validNetworks := map[string]bool{ + "mainnet": true, + "devnet": true, + "testnet": true, + } + return validNetworks[strings.ToLower(network)] +} + +// ValidateChainConfig ensures the Solana network configuration is valid +func ValidateChainConfig(chainStr string) error { + if chainStr == "" { + return errors.New("chain configuration cannot be empty") + } + + network := strings.TrimSpace(strings.ToLower(chainStr)) + if !IsValidSolanaNetwork(network) { + return fmt.Errorf("invalid Solana network: %s", network) + } + + return nil +} + +// Add these error types +var ( + ErrInvalidSolanaSignature = errors.New("invalid Solana signature") + ErrInvalidSolanaAddress = errors.New("invalid Solana address format") + ErrExpiredMessage = errors.New("SIWS message has expired") +) diff --git a/internal/utilities/siws/message.go b/internal/utilities/siws/message.go new file mode 100644 index 0000000000..faa50450d5 --- /dev/null +++ b/internal/utilities/siws/message.go @@ -0,0 +1,40 @@ +package siws + +import ( + "fmt" + "strings" + "time" +) + +// ConstructMessage creates the textual message to be signed, following +// an ABNF-like structure for "Sign in with Solana." +func ConstructMessage(msg SIWSMessage) string { + var sb strings.Builder + + // 1) Domain request line + sb.WriteString(fmt.Sprintf("%s wants you to sign in with your Solana account:\n", msg.Domain)) + + // 2) Address + sb.WriteString(fmt.Sprintf("%s\n", msg.Address)) + + // 3) Optional statement + if msg.Statement != "" { + sb.WriteString(fmt.Sprintf("\n%s\n", msg.Statement)) + } + + // 4) Additional metadata (URI, Version, Nonce, IssuedAt) + if msg.URI != "" { + sb.WriteString(fmt.Sprintf("URI: %s\n", msg.URI)) + } + if msg.Version != "" { + sb.WriteString(fmt.Sprintf("Version: %s\n", msg.Version)) + } + if msg.Nonce != "" { + sb.WriteString(fmt.Sprintf("Nonce: %s\n", msg.Nonce)) + } + if !msg.IssuedAt.IsZero() { + sb.WriteString(fmt.Sprintf("Issued At: %s\n", msg.IssuedAt.UTC().Format(time.RFC3339))) + } + + return sb.String() +} diff --git a/internal/utilities/siws/parser.go b/internal/utilities/siws/parser.go new file mode 100644 index 0000000000..7d727986f0 --- /dev/null +++ b/internal/utilities/siws/parser.go @@ -0,0 +1,99 @@ +package siws + +import ( + "errors" + "fmt" + "regexp" + "strings" + "time" +) + +// ParseSIWSMessage parses a raw SIWS message into an SIWSMessage struct, +// performing robust checks to ensure correct formatting. +func ParseSIWSMessage(raw string) (*SIWSMessage, error) { + lines := strings.Split(raw, "\n") + + // Remove empty lines at the end or accidental trailing newlines. + // Some wallets or frameworks may add them. + var cleaned []string + for _, line := range lines { + l := strings.TrimSpace(line) + if l != "" { + cleaned = append(cleaned, l) + } + } + if len(cleaned) < 2 { + return nil, errors.New("message is too short or improperly formatted") + } + + // 1) First line should match " wants you to sign in with your Solana account:" + // Use a regex to capture the domain. + domainRegex := regexp.MustCompile(`^([^ ]+)\s+wants you to sign in with your Solana account:$`) + matches := domainRegex.FindStringSubmatch(cleaned[0]) + if matches == nil || len(matches) < 2 { + return nil, errors.New("first line does not match expected format for domain request") + } + domain := matches[1] + + // 2) Second line is the base58-encoded public key + address := strings.TrimSpace(cleaned[1]) + if address == "" { + return nil, errors.New("missing address line") + } + + // The third line might be blank or might be the statement. We can handle that carefully. + statement := "" + lineIndex := 2 + if lineIndex < len(cleaned) { + // If the line is blank, skip it; otherwise, treat it as statement + if strings.HasPrefix(cleaned[lineIndex], "URI:") || + strings.HasPrefix(cleaned[lineIndex], "Version:") || + strings.HasPrefix(cleaned[lineIndex], "Nonce:") || + strings.HasPrefix(cleaned[lineIndex], "Issued At:") { + // No statement + } else { + // We assume this line is statement + statement = cleaned[lineIndex] + lineIndex++ + } + } + + var uri, version, nonce string + var issuedAt time.Time + + // 3) Parse optional lines in the form "URI: ...", "Version: ...", "Nonce: ...", "Issued At: ..." + for lineIndex < len(cleaned) { + line := cleaned[lineIndex] + switch { + case strings.HasPrefix(line, "URI: "): + uri = strings.TrimSpace(strings.TrimPrefix(line, "URI:")) + case strings.HasPrefix(line, "Version: "): + version = strings.TrimSpace(strings.TrimPrefix(line, "Version:")) + case strings.HasPrefix(line, "Nonce: "): + nonce = strings.TrimSpace(strings.TrimPrefix(line, "Nonce:")) + case strings.HasPrefix(line, "Issued At: "): + tsString := strings.TrimSpace(strings.TrimPrefix(line, "Issued At:")) + var err error + issuedAt, err = time.Parse(time.RFC3339, tsString) + if err != nil { + return nil, fmt.Errorf("failed to parse Issued At time: %w", err) + } + default: + return nil, fmt.Errorf("unrecognized line: %s", line) + } + lineIndex++ + } + + // Construct the final message struct + msg := &SIWSMessage{ + Domain: domain, + Address: address, + Statement: statement, + URI: uri, + Version: version, + Nonce: nonce, + IssuedAt: issuedAt, + } + + return msg, nil +} diff --git a/internal/utilities/siws/siws_test.go b/internal/utilities/siws/siws_test.go new file mode 100644 index 0000000000..35855e74c9 --- /dev/null +++ b/internal/utilities/siws/siws_test.go @@ -0,0 +1,107 @@ +package siws + +import ( + "crypto/ed25519" + "crypto/rand" + "testing" + "time" + + btcbase58 "github.com/btcsuite/btcutil/base58" +) + +func TestSIWSFlow(t *testing.T) { + // 1) Generate Ed25519 key pair + pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("failed to generate ed25519 key: %v", err) + } + pubKeyBase58 := btcbase58.Encode(pubKey) + + // 2) Build the SIWS message text (as the user would see) + // - Typically you'd store the nonce in DB now + issuedAt := time.Now().UTC().Truncate(time.Second) + rawMessage := `example.com wants you to sign in with your Solana account: +` + pubKeyBase58 + ` + +This is a test statement + +URI: https://example.com +Version: 1 +Nonce: ABCDEF123456 +Issued At: ` + issuedAt.Format(time.RFC3339) + + // 3) Parse the message (robust approach) + msg, parseErr := ParseSIWSMessage(rawMessage) + if parseErr != nil { + t.Fatalf("failed to parse message: %v", parseErr) + } + + // 4) Sign the raw message + signature := ed25519.Sign(privKey, []byte(rawMessage)) + + // 5) Verify + params := SIWSVerificationParams{ + ExpectedDomain: "example.com", + CheckTime: true, + TimeDuration: 5 * time.Minute, + } + + if err := VerifySIWS(rawMessage, signature, msg, params); err != nil { + t.Fatalf("verification failed: %v", err) + } +} + +func TestBadDomain(t *testing.T) { + pubKey, privKey, _ := ed25519.GenerateKey(rand.Reader) + pubKeyBase58 := btcbase58.Encode(pubKey) + + rawMessage := `wrong-domain.com wants you to sign in with your Solana account: +` + pubKeyBase58 + ` + +URI: https://example.com +Version: 1 +Nonce: SOME_NONCE +Issued At: 2025-01-01T00:00:00Z` + + msg, _ := ParseSIWSMessage(rawMessage) + signature := ed25519.Sign(privKey, []byte(rawMessage)) + + params := SIWSVerificationParams{ + ExpectedDomain: "example.com", + CheckTime: false, + } + + err := VerifySIWS(rawMessage, signature, msg, params) + if err == nil { + t.Error("expected domain mismatch error, got nil") + } +} + +func TestBadSignature(t *testing.T) { + _, privKey1, _ := ed25519.GenerateKey(rand.Reader) + pubKey2, _, _ := ed25519.GenerateKey(rand.Reader) + pubKey2Base58 := btcbase58.Encode(pubKey2) + + rawMessage := `example.com wants you to sign in with your Solana account: +` + pubKey2Base58 + ` + +Statement + +Version: 1 +Nonce: AAA +Issued At: 2025-01-01T00:00:00Z` + + msg, _ := ParseSIWSMessage(rawMessage) + // Sign with privKey1 but the message references a different public key (pubKey2). + signature := ed25519.Sign(privKey1, []byte(rawMessage)) + + params := SIWSVerificationParams{ + ExpectedDomain: "example.com", + CheckTime: false, + } + + err := VerifySIWS(rawMessage, signature, msg, params) + if err == nil { + t.Error("expected signature verification to fail, got success") + } +} diff --git a/internal/utilities/siws/types.go b/internal/utilities/siws/types.go new file mode 100644 index 0000000000..80ad80ce83 --- /dev/null +++ b/internal/utilities/siws/types.go @@ -0,0 +1,28 @@ +package siws + +import ( + "time" +) + +// SIWSMessage is the final structured form of a parsed SIWS message. +type SIWSMessage struct { + Domain string // e.g. "example.com" + Address string // base58-encoded Solana public key + Statement string // optional + URI string // optional + Version string // recommended (e.g. "1") + Nonce string // random nonce + IssuedAt time.Time // "Issued At" timestamp + // ExpirationTime is optional. If set, it should be checked against the current time. + ExpirationTime time.Time +} + +// SIWSVerificationParams holds parameters needed to verify an SIWS message. +type SIWSVerificationParams struct { + // The domain we expect. Must match message.Domain. + ExpectedDomain string + + // Whether or not to enforce time validity (IssuedAt <= now <= IssuedAt + TimeDuration). + CheckTime bool + TimeDuration time.Duration +} diff --git a/internal/utilities/siws/verify.go b/internal/utilities/siws/verify.go new file mode 100644 index 0000000000..a485bf7768 --- /dev/null +++ b/internal/utilities/siws/verify.go @@ -0,0 +1,58 @@ +package siws + +import ( + "crypto/ed25519" + "errors" + "time" + + "github.com/btcsuite/btcutil/base58" +) + +// VerifySIWS fully verifies: +// - The domain in msg matches expected domain +// - The ed25519 signature matches the parsed SIWS message text +// - The base58-encoded public key is valid +// - The message is within the allowed time window (if requested) +func VerifySIWS( + rawMessage string, // the original textual message + signature []byte, // signature returned by the client + msg *SIWSMessage, // the parsed SIWS message (from ParseSIWSMessage) + params SIWSVerificationParams, +) error { + // 1) Domain check + if params.ExpectedDomain == "" { + return errors.New("expected domain is not specified") + } + if msg.Domain != params.ExpectedDomain { + return errors.New("domain mismatch") + } + + // 2) Base58 decode -> ed25519.PublicKey + pubKey := base58.Decode(msg.Address) + if len(pubKey) != ed25519.PublicKeySize { + return errors.New("invalid base58 public key or wrong size (must be 32 bytes)") + } + + // 3) Verify signature + // The message to verify must be exactly the raw text that was originally signed. + if !ed25519.Verify(pubKey, []byte(rawMessage), signature) { + return errors.New("signature verification failed") + } + + // 4) Time check if requested + if params.CheckTime && params.TimeDuration > 0 { + if msg.IssuedAt.IsZero() { + return errors.New("issuedAt not set, but time check requested") + } + now := time.Now().UTC() + expiry := msg.IssuedAt.Add(params.TimeDuration) + if now.Before(msg.IssuedAt) { + return errors.New("message is issued in the future") + } + if now.After(expiry) { + return errors.New("message is expired") + } + } + + return nil +} diff --git a/internal/utilities/web3/ethereum/verify.go b/internal/utilities/web3/ethereum/verify.go new file mode 100644 index 0000000000..9d6cb7a8fa --- /dev/null +++ b/internal/utilities/web3/ethereum/verify.go @@ -0,0 +1,57 @@ +package ethereum + +import ( + "encoding/hex" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +func VerifySignature(message string, signature string, address string) error { + // Remove 0x prefix if present + signature = removeHexPrefix(signature) + address = removeHexPrefix(address) + + // Convert signature hex to bytes + sigBytes, err := hex.DecodeString(signature) + if err != nil { + return fmt.Errorf("invalid signature hex: %w", err) + } + + // Adjust V value in signature (Ethereum specific) + if len(sigBytes) != 65 { + return fmt.Errorf("invalid signature length") + } + if sigBytes[64] < 27 { + sigBytes[64] += 27 + } + + // Hash the message according to EIP-191 + prefixedMessage := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message) + hash := crypto.Keccak256Hash([]byte(prefixedMessage)) + + // Recover public key from signature + pubKey, err := crypto.SigToPub(hash.Bytes(), sigBytes) + if err != nil { + return fmt.Errorf("error recovering public key: %w", err) + } + + // Derive Ethereum address from public key + recoveredAddr := crypto.PubkeyToAddress(*pubKey) + checkAddr := common.HexToAddress(address) + + // Compare addresses + if recoveredAddr != checkAddr { + return fmt.Errorf("signature not from expected address") + } + + return nil +} + +func removeHexPrefix(s string) string { + if len(s) > 2 && s[0:2] == "0x" { + return s[2:] + } + return s +} From fd8b16dd6838e53bc836831766a195013c105ddf Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Sat, 18 Jan 2025 20:34:45 +0300 Subject: [PATCH 02/10] refactor: migrate SIWS utilities to web3/solana and remove deprecated code --- external_eip4361_siws_example.go | 9 ++- internal/api/provider/eip4361.go | 10 ++-- internal/conf/configuration.go | 2 +- internal/crypto/crypto.go | 56 ++++++++++++++++++ internal/reloader/testdata/50_example.env | 2 +- internal/utilities/siws/verify.go | 58 ------------------- .../{siws => web3/solana}/go.mod.bak | 0 .../{siws => web3/solana}/go.sum.bak | 0 .../{siws => web3/solana}/helpers.go | 33 ++++++----- .../{siws => web3/solana}/message.go | 0 .../utilities/{siws => web3/solana}/parser.go | 26 ++++++--- .../{siws => web3/solana}/siws_test.go | 0 .../utilities/{siws => web3/solana}/types.go | 0 13 files changed, 104 insertions(+), 92 deletions(-) delete mode 100644 internal/utilities/siws/verify.go rename internal/utilities/{siws => web3/solana}/go.mod.bak (100%) rename internal/utilities/{siws => web3/solana}/go.sum.bak (100%) rename internal/utilities/{siws => web3/solana}/helpers.go (65%) rename internal/utilities/{siws => web3/solana}/message.go (100%) rename internal/utilities/{siws => web3/solana}/parser.go (77%) rename internal/utilities/{siws => web3/solana}/siws_test.go (100%) rename internal/utilities/{siws => web3/solana}/types.go (100%) diff --git a/external_eip4361_siws_example.go b/external_eip4361_siws_example.go index 9d9325b573..96639ec75a 100644 --- a/external_eip4361_siws_example.go +++ b/external_eip4361_siws_example.go @@ -9,7 +9,7 @@ import ( "time" "github.com/btcsuite/btcutil/base58" - siws "github.com/supabase/auth/internal/utilities/siws" + siws "github.com/supabase/auth/internal/utilities/web3/solana" ) func LogSIWSExample() { @@ -69,7 +69,6 @@ func LogSIWSExample() { // Print JavaScript fetch code fmt.Println(string(payloadJSON)) } - -// func main() { -// LogSIWSExample() -// } +func main() { + LogSIWSExample() +} diff --git a/internal/api/provider/eip4361.go b/internal/api/provider/eip4361.go index 7766a6db06..3a8c8684b7 100644 --- a/internal/api/provider/eip4361.go +++ b/internal/api/provider/eip4361.go @@ -8,8 +8,9 @@ import ( "time" "github.com/supabase/auth/internal/conf" - "github.com/supabase/auth/internal/utilities/siws" + "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/utilities/web3/ethereum" + siws "github.com/supabase/auth/internal/utilities/web3/solana" "golang.org/x/oauth2" ) @@ -128,7 +129,7 @@ func (p *EIP4361Provider) verifySolanaSignature(msg *SignedMessage) error { TimeDuration: p.config.Timeout, } - if err := siws.VerifySIWS(msg.Message, sigBytes, parsedMessage, params); err != nil { + if err := crypto.VerifySIWS(msg.Message, sigBytes, parsedMessage, params); err != nil { return fmt.Errorf("SIWS verification failed: %w", err) } @@ -146,10 +147,7 @@ func (p *EIP4361Provider) GenerateSignMessage(address string, chain string, uri } // Generate nonce for message uniqueness - nonce, err := siws.GenerateNonce() - if err != nil { - return "", fmt.Errorf("failed to generate nonce: %w", err) - } + nonce := crypto.SecureToken() now := time.Now().UTC() diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index e19bf92570..2ee3fcdc7d 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -20,7 +20,7 @@ import ( "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" "github.com/lestrrat-go/jwx/v2/jwk" - siws "github.com/supabase/auth/internal/utilities/siws" + siws "github.com/supabase/auth/internal/utilities/web3/solana" "gopkg.in/gomail.v2" ) diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 6fc2b71ace..b19a888bc9 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -14,7 +14,14 @@ import ( "strconv" "strings" + "crypto/ed25519" + "errors" + "time" + "golang.org/x/crypto/hkdf" + + "github.com/btcsuite/btcutil/base58" + siws "github.com/supabase/auth/internal/utilities/web3/solana" ) // SecureToken creates a new random token @@ -157,3 +164,52 @@ func NewEncryptedString(id string, data []byte, keyID string, keyBase64URL strin return &es, nil } + +// VerifySIWS fully verifies: +// - The domain in msg matches expected domain +// - The ed25519 signature matches the parsed SIWS message text +// - The base58-encoded public key is valid +// - The message is within the allowed time window (if requested) +func VerifySIWS( + rawMessage string, // the original textual message + signature []byte, // signature returned by the client + msg *siws.SIWSMessage, // the parsed SIWS message (from ParseSIWSMessage) + params siws.SIWSVerificationParams, +) error { + // 1) Domain check + if params.ExpectedDomain == "" { + return errors.New("expected domain is not specified") + } + if msg.Domain != params.ExpectedDomain { + return errors.New("domain mismatch") + } + + // 2) Base58 decode -> ed25519.PublicKey + pubKey := base58.Decode(msg.Address) + if !siws.IsBase58PubKey(msg.Address) { + return errors.New("invalid base58 public key or wrong size (must be 32 bytes)") + } + + // 3) Verify signature + // The message to verify must be exactly the raw text that was originally signed. + if !ed25519.Verify(pubKey, []byte(rawMessage), signature) { + return errors.New("signature verification failed") + } + + // 4) Time check if requested + if params.CheckTime && params.TimeDuration > 0 { + if msg.IssuedAt.IsZero() { + return errors.New("issuedAt not set, but time check requested") + } + now := time.Now().UTC() + expiry := msg.IssuedAt.Add(params.TimeDuration) + if now.Before(msg.IssuedAt) { + return errors.New("message is issued in the future") + } + if now.After(expiry) { + return errors.New("message is expired") + } + } + + return nil +} diff --git a/internal/reloader/testdata/50_example.env b/internal/reloader/testdata/50_example.env index 8f4d4949c9..b8510861e4 100644 --- a/internal/reloader/testdata/50_example.env +++ b/internal/reloader/testdata/50_example.env @@ -10,7 +10,7 @@ GOTRUE_JWT_ADMIN_ROLES="supabase_admin,service_role" # Database & API connection details GOTRUE_DB_DRIVER="postgres" DB_NAMESPACE="auth" -DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5433/postgres" +DATABASE_URL="postgres://supabase_auth_admin:root@localhost:5432/postgres" API_EXTERNAL_URL="http://localhost:9999" GOTRUE_API_HOST="localhost" PORT="9999" diff --git a/internal/utilities/siws/verify.go b/internal/utilities/siws/verify.go deleted file mode 100644 index a485bf7768..0000000000 --- a/internal/utilities/siws/verify.go +++ /dev/null @@ -1,58 +0,0 @@ -package siws - -import ( - "crypto/ed25519" - "errors" - "time" - - "github.com/btcsuite/btcutil/base58" -) - -// VerifySIWS fully verifies: -// - The domain in msg matches expected domain -// - The ed25519 signature matches the parsed SIWS message text -// - The base58-encoded public key is valid -// - The message is within the allowed time window (if requested) -func VerifySIWS( - rawMessage string, // the original textual message - signature []byte, // signature returned by the client - msg *SIWSMessage, // the parsed SIWS message (from ParseSIWSMessage) - params SIWSVerificationParams, -) error { - // 1) Domain check - if params.ExpectedDomain == "" { - return errors.New("expected domain is not specified") - } - if msg.Domain != params.ExpectedDomain { - return errors.New("domain mismatch") - } - - // 2) Base58 decode -> ed25519.PublicKey - pubKey := base58.Decode(msg.Address) - if len(pubKey) != ed25519.PublicKeySize { - return errors.New("invalid base58 public key or wrong size (must be 32 bytes)") - } - - // 3) Verify signature - // The message to verify must be exactly the raw text that was originally signed. - if !ed25519.Verify(pubKey, []byte(rawMessage), signature) { - return errors.New("signature verification failed") - } - - // 4) Time check if requested - if params.CheckTime && params.TimeDuration > 0 { - if msg.IssuedAt.IsZero() { - return errors.New("issuedAt not set, but time check requested") - } - now := time.Now().UTC() - expiry := msg.IssuedAt.Add(params.TimeDuration) - if now.Before(msg.IssuedAt) { - return errors.New("message is issued in the future") - } - if now.After(expiry) { - return errors.New("message is expired") - } - } - - return nil -} diff --git a/internal/utilities/siws/go.mod.bak b/internal/utilities/web3/solana/go.mod.bak similarity index 100% rename from internal/utilities/siws/go.mod.bak rename to internal/utilities/web3/solana/go.mod.bak diff --git a/internal/utilities/siws/go.sum.bak b/internal/utilities/web3/solana/go.sum.bak similarity index 100% rename from internal/utilities/siws/go.sum.bak rename to internal/utilities/web3/solana/go.sum.bak diff --git a/internal/utilities/siws/helpers.go b/internal/utilities/web3/solana/helpers.go similarity index 65% rename from internal/utilities/siws/helpers.go rename to internal/utilities/web3/solana/helpers.go index 7a3b8b2798..533ca0df07 100644 --- a/internal/utilities/siws/helpers.go +++ b/internal/utilities/web3/solana/helpers.go @@ -1,12 +1,15 @@ package siws import ( + "crypto/ed25519" "crypto/rand" "encoding/hex" "errors" "fmt" "net/url" "strings" + + "github.com/btcsuite/btcutil/base58" ) // GenerateNonce creates a random 16-byte nonce, returning a hex-encoded string. @@ -24,36 +27,38 @@ func GenerateNonce() (string, error) { func ValidateDomain(domain string) error { u, err := url.Parse("https://" + domain) if err != nil || u.Hostname() == "" { - return errors.New("invalid domain") + return errors.New("siws: invalid domain") } return nil } // IsBase58PubKey checks if the input is a plausible base58 Solana public key. -// Typically Solana addresses are ~44 characters in base58. This is a naive check. func IsBase58PubKey(address string) bool { address = strings.TrimSpace(address) - if len(address) < 32 { + + // Basic length check before trying to decode + if len(address) == 0 { return false } - // Optionally, you could decode with base58 and check for 32 bytes. - return true + + decoded := base58.Decode(address) + return len(decoded) == ed25519.PublicKeySize // ed25519.PublicKeySize is 32 } // Add these functions to your existing helpers.go func IsValidSolanaNetwork(network string) bool { - validNetworks := map[string]bool{ - "mainnet": true, - "devnet": true, - "testnet": true, + switch network { + case "mainnet", "devnet", "testnet": + return true + default: + return false } - return validNetworks[strings.ToLower(network)] } // ValidateChainConfig ensures the Solana network configuration is valid func ValidateChainConfig(chainStr string) error { if chainStr == "" { - return errors.New("chain configuration cannot be empty") + return errors.New("siws: chain configuration cannot be empty") } network := strings.TrimSpace(strings.ToLower(chainStr)) @@ -66,7 +71,7 @@ func ValidateChainConfig(chainStr string) error { // Add these error types var ( - ErrInvalidSolanaSignature = errors.New("invalid Solana signature") - ErrInvalidSolanaAddress = errors.New("invalid Solana address format") - ErrExpiredMessage = errors.New("SIWS message has expired") + ErrInvalidSolanaSignature = errors.New("siws: invalid Solana signature") + ErrInvalidSolanaAddress = errors.New("siws: invalid Solana address format") + ErrExpiredMessage = errors.New("siws: SIWS message has expired") ) diff --git a/internal/utilities/siws/message.go b/internal/utilities/web3/solana/message.go similarity index 100% rename from internal/utilities/siws/message.go rename to internal/utilities/web3/solana/message.go diff --git a/internal/utilities/siws/parser.go b/internal/utilities/web3/solana/parser.go similarity index 77% rename from internal/utilities/siws/parser.go rename to internal/utilities/web3/solana/parser.go index 7d727986f0..0e67bf0d58 100644 --- a/internal/utilities/siws/parser.go +++ b/internal/utilities/web3/solana/parser.go @@ -8,6 +8,18 @@ import ( "time" ) +var ( + // domainRegex matches the first line of a SIWS message containing the domain + domainRegex = regexp.MustCompile(`^([^ ]+)\s+wants you to sign in with your Solana account:$`) +) + +// Common errors +var ( + ErrMessageTooShort = errors.New("siws: message is too short or improperly formatted") + ErrInvalidDomainFormat = errors.New("siws: first line does not match expected format for domain request") + ErrMissingAddress = errors.New("siws: missing address line") +) + // ParseSIWSMessage parses a raw SIWS message into an SIWSMessage struct, // performing robust checks to ensure correct formatting. func ParseSIWSMessage(raw string) (*SIWSMessage, error) { @@ -22,28 +34,28 @@ func ParseSIWSMessage(raw string) (*SIWSMessage, error) { cleaned = append(cleaned, l) } } + if len(cleaned) < 2 { - return nil, errors.New("message is too short or improperly formatted") + return nil, ErrMessageTooShort } // 1) First line should match " wants you to sign in with your Solana account:" - // Use a regex to capture the domain. - domainRegex := regexp.MustCompile(`^([^ ]+)\s+wants you to sign in with your Solana account:$`) matches := domainRegex.FindStringSubmatch(cleaned[0]) if matches == nil || len(matches) < 2 { - return nil, errors.New("first line does not match expected format for domain request") + return nil, ErrInvalidDomainFormat } domain := matches[1] // 2) Second line is the base58-encoded public key address := strings.TrimSpace(cleaned[1]) if address == "" { - return nil, errors.New("missing address line") + return nil, ErrMissingAddress } // The third line might be blank or might be the statement. We can handle that carefully. statement := "" lineIndex := 2 + if lineIndex < len(cleaned) { // If the line is blank, skip it; otherwise, treat it as statement if strings.HasPrefix(cleaned[lineIndex], "URI:") || @@ -76,10 +88,10 @@ func ParseSIWSMessage(raw string) (*SIWSMessage, error) { var err error issuedAt, err = time.Parse(time.RFC3339, tsString) if err != nil { - return nil, fmt.Errorf("failed to parse Issued At time: %w", err) + return nil, fmt.Errorf("siws: failed to parse Issued At time: %w", err) } default: - return nil, fmt.Errorf("unrecognized line: %s", line) + return nil, fmt.Errorf("siws: unrecognized line: %s", line) } lineIndex++ } diff --git a/internal/utilities/siws/siws_test.go b/internal/utilities/web3/solana/siws_test.go similarity index 100% rename from internal/utilities/siws/siws_test.go rename to internal/utilities/web3/solana/siws_test.go diff --git a/internal/utilities/siws/types.go b/internal/utilities/web3/solana/types.go similarity index 100% rename from internal/utilities/siws/types.go rename to internal/utilities/web3/solana/types.go From 6e4c871834dbc0273c22f13dbe361927f8c526d5 Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Sat, 18 Jan 2025 20:41:16 +0300 Subject: [PATCH 03/10] refactor: consolidate Ethereum signature verification into crypto package and remove legacy code --- internal/api/provider/eip4361.go | 3 +- internal/crypto/crypto.go | 52 ++++++++++++++++++++ internal/utilities/web3/ethereum/verify.go | 57 ---------------------- 3 files changed, 53 insertions(+), 59 deletions(-) diff --git a/internal/api/provider/eip4361.go b/internal/api/provider/eip4361.go index 3a8c8684b7..90fea13893 100644 --- a/internal/api/provider/eip4361.go +++ b/internal/api/provider/eip4361.go @@ -9,7 +9,6 @@ import ( "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/crypto" - "github.com/supabase/auth/internal/utilities/web3/ethereum" siws "github.com/supabase/auth/internal/utilities/web3/solana" "golang.org/x/oauth2" ) @@ -108,7 +107,7 @@ func (p *EIP4361Provider) VerifySignedMessage(msg *SignedMessage) (*UserProvided } func (p *EIP4361Provider) verifyEthereumSignature(msg *SignedMessage) error { - return ethereum.VerifySignature(msg.Message, msg.Signature, msg.Address) + return crypto.VerifyEthereumSignature(msg.Message, msg.Signature, msg.Address) } func (p *EIP4361Provider) verifySolanaSignature(msg *SignedMessage) error { diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index b19a888bc9..ae448558d6 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -20,7 +20,11 @@ import ( "golang.org/x/crypto/hkdf" + "encoding/hex" + "github.com/btcsuite/btcutil/base58" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" siws "github.com/supabase/auth/internal/utilities/web3/solana" ) @@ -213,3 +217,51 @@ func VerifySIWS( return nil } + +func VerifyEthereumSignature(message string, signature string, address string) error { + // Remove 0x prefix if present + signature = removeHexPrefix(signature) + address = removeHexPrefix(address) + + // Convert signature hex to bytes + sigBytes, err := hex.DecodeString(signature) + if err != nil { + return fmt.Errorf("invalid signature hex: %w", err) + } + + // Adjust V value in signature (Ethereum specific) + if len(sigBytes) != 65 { + return fmt.Errorf("invalid signature length") + } + if sigBytes[64] < 27 { + sigBytes[64] += 27 + } + + // Hash the message according to EIP-191 + prefixedMessage := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message) + hash := crypto.Keccak256Hash([]byte(prefixedMessage)) + + // Recover public key from signature + pubKey, err := crypto.SigToPub(hash.Bytes(), sigBytes) + if err != nil { + return fmt.Errorf("error recovering public key: %w", err) + } + + // Derive Ethereum address from public key + recoveredAddr := crypto.PubkeyToAddress(*pubKey) + checkAddr := common.HexToAddress(address) + + // Compare addresses + if recoveredAddr != checkAddr { + return fmt.Errorf("signature not from expected address") + } + + return nil +} + +func removeHexPrefix(signature string) string { + if strings.HasPrefix(signature, "0x") { + return strings.TrimPrefix(signature, "0x") + } + return signature +} diff --git a/internal/utilities/web3/ethereum/verify.go b/internal/utilities/web3/ethereum/verify.go index 9d6cb7a8fa..e69de29bb2 100644 --- a/internal/utilities/web3/ethereum/verify.go +++ b/internal/utilities/web3/ethereum/verify.go @@ -1,57 +0,0 @@ -package ethereum - -import ( - "encoding/hex" - "fmt" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" -) - -func VerifySignature(message string, signature string, address string) error { - // Remove 0x prefix if present - signature = removeHexPrefix(signature) - address = removeHexPrefix(address) - - // Convert signature hex to bytes - sigBytes, err := hex.DecodeString(signature) - if err != nil { - return fmt.Errorf("invalid signature hex: %w", err) - } - - // Adjust V value in signature (Ethereum specific) - if len(sigBytes) != 65 { - return fmt.Errorf("invalid signature length") - } - if sigBytes[64] < 27 { - sigBytes[64] += 27 - } - - // Hash the message according to EIP-191 - prefixedMessage := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message) - hash := crypto.Keccak256Hash([]byte(prefixedMessage)) - - // Recover public key from signature - pubKey, err := crypto.SigToPub(hash.Bytes(), sigBytes) - if err != nil { - return fmt.Errorf("error recovering public key: %w", err) - } - - // Derive Ethereum address from public key - recoveredAddr := crypto.PubkeyToAddress(*pubKey) - checkAddr := common.HexToAddress(address) - - // Compare addresses - if recoveredAddr != checkAddr { - return fmt.Errorf("signature not from expected address") - } - - return nil -} - -func removeHexPrefix(s string) string { - if len(s) > 2 && s[0:2] == "0x" { - return s[2:] - } - return s -} From 118eba45a261da46e40f984cac74ea468e27f66b Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Sat, 18 Jan 2025 20:42:12 +0300 Subject: [PATCH 04/10] refactor: migrate Solana utilities to a dedicated package and remove legacy web3 references --- external_eip4361_siws_example.go | 2 +- internal/api/provider/eip4361.go | 2 +- internal/conf/configuration.go | 2 +- internal/crypto/crypto.go | 2 +- internal/utilities/{web3 => }/ethereum/verify.go | 0 internal/utilities/{web3 => }/solana/go.mod.bak | 0 internal/utilities/{web3 => }/solana/go.sum.bak | 0 internal/utilities/{web3 => }/solana/helpers.go | 0 internal/utilities/{web3 => }/solana/message.go | 0 internal/utilities/{web3 => }/solana/parser.go | 0 internal/utilities/{web3 => }/solana/siws_test.go | 0 internal/utilities/{web3 => }/solana/types.go | 0 12 files changed, 4 insertions(+), 4 deletions(-) rename internal/utilities/{web3 => }/ethereum/verify.go (100%) rename internal/utilities/{web3 => }/solana/go.mod.bak (100%) rename internal/utilities/{web3 => }/solana/go.sum.bak (100%) rename internal/utilities/{web3 => }/solana/helpers.go (100%) rename internal/utilities/{web3 => }/solana/message.go (100%) rename internal/utilities/{web3 => }/solana/parser.go (100%) rename internal/utilities/{web3 => }/solana/siws_test.go (100%) rename internal/utilities/{web3 => }/solana/types.go (100%) diff --git a/external_eip4361_siws_example.go b/external_eip4361_siws_example.go index 96639ec75a..6fb8a4f680 100644 --- a/external_eip4361_siws_example.go +++ b/external_eip4361_siws_example.go @@ -9,7 +9,7 @@ import ( "time" "github.com/btcsuite/btcutil/base58" - siws "github.com/supabase/auth/internal/utilities/web3/solana" + siws "github.com/supabase/auth/internal/utilities/solana" ) func LogSIWSExample() { diff --git a/internal/api/provider/eip4361.go b/internal/api/provider/eip4361.go index 90fea13893..f8ad466a99 100644 --- a/internal/api/provider/eip4361.go +++ b/internal/api/provider/eip4361.go @@ -9,7 +9,7 @@ import ( "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/crypto" - siws "github.com/supabase/auth/internal/utilities/web3/solana" + siws "github.com/supabase/auth/internal/utilities/solana" "golang.org/x/oauth2" ) diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 2ee3fcdc7d..5a70089735 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -20,7 +20,7 @@ import ( "github.com/joho/godotenv" "github.com/kelseyhightower/envconfig" "github.com/lestrrat-go/jwx/v2/jwk" - siws "github.com/supabase/auth/internal/utilities/web3/solana" + siws "github.com/supabase/auth/internal/utilities/solana" "gopkg.in/gomail.v2" ) diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index ae448558d6..1e3f3074bf 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -25,7 +25,7 @@ import ( "github.com/btcsuite/btcutil/base58" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" - siws "github.com/supabase/auth/internal/utilities/web3/solana" + siws "github.com/supabase/auth/internal/utilities/solana" ) // SecureToken creates a new random token diff --git a/internal/utilities/web3/ethereum/verify.go b/internal/utilities/ethereum/verify.go similarity index 100% rename from internal/utilities/web3/ethereum/verify.go rename to internal/utilities/ethereum/verify.go diff --git a/internal/utilities/web3/solana/go.mod.bak b/internal/utilities/solana/go.mod.bak similarity index 100% rename from internal/utilities/web3/solana/go.mod.bak rename to internal/utilities/solana/go.mod.bak diff --git a/internal/utilities/web3/solana/go.sum.bak b/internal/utilities/solana/go.sum.bak similarity index 100% rename from internal/utilities/web3/solana/go.sum.bak rename to internal/utilities/solana/go.sum.bak diff --git a/internal/utilities/web3/solana/helpers.go b/internal/utilities/solana/helpers.go similarity index 100% rename from internal/utilities/web3/solana/helpers.go rename to internal/utilities/solana/helpers.go diff --git a/internal/utilities/web3/solana/message.go b/internal/utilities/solana/message.go similarity index 100% rename from internal/utilities/web3/solana/message.go rename to internal/utilities/solana/message.go diff --git a/internal/utilities/web3/solana/parser.go b/internal/utilities/solana/parser.go similarity index 100% rename from internal/utilities/web3/solana/parser.go rename to internal/utilities/solana/parser.go diff --git a/internal/utilities/web3/solana/siws_test.go b/internal/utilities/solana/siws_test.go similarity index 100% rename from internal/utilities/web3/solana/siws_test.go rename to internal/utilities/solana/siws_test.go diff --git a/internal/utilities/web3/solana/types.go b/internal/utilities/solana/types.go similarity index 100% rename from internal/utilities/web3/solana/types.go rename to internal/utilities/solana/types.go From d8cdba4a5c93c950c50a9bda4892e301e366c233 Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Sat, 18 Jan 2025 20:47:50 +0300 Subject: [PATCH 05/10] feat: add domain validation for SIWS messages and refactor domain check logic --- internal/crypto/crypto.go | 4 ++++ internal/utilities/ethereum/verify.go | 0 internal/utilities/solana/helpers.go | 13 ++++++------- 3 files changed, 10 insertions(+), 7 deletions(-) delete mode 100644 internal/utilities/ethereum/verify.go diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 1e3f3074bf..30fc37c754 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -184,6 +184,10 @@ func VerifySIWS( if params.ExpectedDomain == "" { return errors.New("expected domain is not specified") } + + if !siws.IsValidDomain(msg.Domain) { + return errors.New("invalid domain") + } if msg.Domain != params.ExpectedDomain { return errors.New("domain mismatch") } diff --git a/internal/utilities/ethereum/verify.go b/internal/utilities/ethereum/verify.go deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/internal/utilities/solana/helpers.go b/internal/utilities/solana/helpers.go index 533ca0df07..8bf72b1f2d 100644 --- a/internal/utilities/solana/helpers.go +++ b/internal/utilities/solana/helpers.go @@ -6,7 +6,7 @@ import ( "encoding/hex" "errors" "fmt" - "net/url" + "regexp" "strings" "github.com/btcsuite/btcutil/base58" @@ -24,12 +24,11 @@ func GenerateNonce() (string, error) { // ValidateDomain checks if a domain is valid or not. This can be expanded with // real domain validation logic. Here, we do a simple parse check. -func ValidateDomain(domain string) error { - u, err := url.Parse("https://" + domain) - if err != nil || u.Hostname() == "" { - return errors.New("siws: invalid domain") - } - return nil +func IsValidDomain(domain string) bool { + // Regular expression to validate domain name + regex := `^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$` + match, _ := regexp.MatchString(regex, domain) + return match } // IsBase58PubKey checks if the input is a plausible base58 Solana public key. From 311291a5fe4eb11d5e2bd41b665449e0f0f43613 Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Sat, 18 Jan 2025 20:56:29 +0300 Subject: [PATCH 06/10] refactor: rename EIP-4361 references to Web3 and update related configurations --- example.env | 14 ++++---- external_eip4361_siws_example.go | 9 ++--- internal/api/external_eip4361_siws_test.go | 42 ++++++++++++++-------- internal/api/provider/eip4361.go | 30 ++++++++-------- internal/api/token.go | 12 +++---- internal/conf/configuration.go | 6 ++-- internal/reloader/testdata/50_example.env | 14 ++++---- internal/utilities/solana/go.mod.bak | 5 --- internal/utilities/solana/go.sum.bak | 35 ------------------ 9 files changed, 70 insertions(+), 97 deletions(-) delete mode 100644 internal/utilities/solana/go.mod.bak delete mode 100644 internal/utilities/solana/go.sum.bak diff --git a/example.env b/example.env index 2708710395..448ad3da51 100644 --- a/example.env +++ b/example.env @@ -169,15 +169,15 @@ GOTRUE_EXTERNAL_ZOOM_SECRET="" GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="http://localhost:9999/callback" # EIP-4361 OAuth config -GOTRUE_EXTERNAL_EIP4361_ENABLED="true" -GOTRUE_EXTERNAL_EIP4361_STATEMENT="Sign this message to verify your identity" -GOTRUE_EXTERNAL_EIP4361_VERSION="1" -GOTRUE_EXTERNAL_EIP4361_TIMEOUT="300s" -GOTRUE_EXTERNAL_EIP4361_DOMAIN="localhost:9999" +GOTRUE_EXTERNAL_WEB3_ENABLED="true" +GOTRUE_EXTERNAL_WEB3_STATEMENT="Sign this message to verify your identity" +GOTRUE_EXTERNAL_WEB3_VERSION="1" +GOTRUE_EXTERNAL_WEB3_TIMEOUT="300s" +GOTRUE_EXTERNAL_WEB3_DOMAIN="localhost:9999" # Supported Chains Configuration -GOTRUE_EXTERNAL_EIP4361_SUPPORTED_CHAINS="ethereum:1,ethereum:137,solana:mainnet,solana:devnet" -GOTRUE_EXTERNAL_EIP4361_DEFAULT_CHAIN="ethereum:1" +GOTRUE_EXTERNAL_WEB3_SUPPORTED_CHAINS="ethereum:1,ethereum:137,solana:mainnet,solana:devnet" +GOTRUE_EXTERNAL_WEB3_DEFAULT_CHAIN="ethereum:1" # Anonymous auth config GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED="false" diff --git a/external_eip4361_siws_example.go b/external_eip4361_siws_example.go index 6fb8a4f680..ceed90a370 100644 --- a/external_eip4361_siws_example.go +++ b/external_eip4361_siws_example.go @@ -53,7 +53,7 @@ func LogSIWSExample() { // Generate JSON payload payload := map[string]string{ - "grant_type": "eip4361", + "grant_type": "web3", "message": rawMessage, "signature": signatureBase64, "address": pubKeyBase58, @@ -69,6 +69,7 @@ func LogSIWSExample() { // Print JavaScript fetch code fmt.Println(string(payloadJSON)) } -func main() { - LogSIWSExample() -} + +// func main() { +// LogSIWSExample() +// } diff --git a/internal/api/external_eip4361_siws_test.go b/internal/api/external_eip4361_siws_test.go index 4fbdded5d2..927fe7b553 100644 --- a/internal/api/external_eip4361_siws_test.go +++ b/internal/api/external_eip4361_siws_test.go @@ -1,19 +1,18 @@ package api import ( + "bytes" "crypto/ed25519" "crypto/rand" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" - "net/url" - "strings" "time" "github.com/btcsuite/btcutil/base58" "github.com/supabase/auth/internal/conf" - siws "github.com/supabase/auth/internal/utilities/siws" + siws "github.com/supabase/auth/internal/utilities/solana" ) const ( @@ -23,7 +22,7 @@ const ( ) func SIWSTestSignupSetup(ts *ExternalTestSuite) { - ts.Config.External.EIP4361 = conf.EIP4361Configuration{ + ts.Config.External.Web3 = conf.Web3Configuration{ Enabled: true, Domain: "test.example.com", Statement: "Sign in with your Solana account", @@ -34,6 +33,14 @@ func SIWSTestSignupSetup(ts *ExternalTestSuite) { } } +type TokenRequest struct { + GrantType string `json:"grant_type"` + Message string `json:"message"` + Signature string `json:"signature"` + Address string `json:"address"` + Chain string `json:"chain"` +} + func (ts *ExternalTestSuite) TestSignupExternalSIWS() { SIWSTestSignupSetup(ts) ts.Config.DisableSignup = false @@ -48,11 +55,11 @@ func (ts *ExternalTestSuite) TestSignupExternalSIWS() { // Create test message msg := siws.SIWSMessage{ - Domain: ts.Config.External.EIP4361.Domain, + Domain: ts.Config.External.Web3.Domain, Address: pubKeyBase58, - Statement: ts.Config.External.EIP4361.Statement, + Statement: ts.Config.External.Web3.Statement, URI: "https://example.com", - Version: ts.Config.External.EIP4361.Version, + Version: ts.Config.External.Web3.Version, Nonce: nonce, IssuedAt: time.Now().UTC(), } @@ -61,15 +68,20 @@ func (ts *ExternalTestSuite) TestSignupExternalSIWS() { signature := ed25519.Sign(privKey, []byte(rawMessage)) signatureBase64 := base64.StdEncoding.EncodeToString(signature) - formData := url.Values{} - formData.Set("grant_type", "eip4361") - formData.Set("message", rawMessage) - formData.Set("signature", signatureBase64) - formData.Set("address", pubKeyBase58) - formData.Set("chain", "solana:mainnet") + // Create JSON request body + tokenRequest := TokenRequest{ + GrantType: "web3", + Message: rawMessage, + Signature: signatureBase64, + Address: pubKeyBase58, + Chain: "solana:mainnet", + } + + jsonBody, err := json.Marshal(tokenRequest) + ts.Require().NoError(err) - req := httptest.NewRequest(http.MethodPost, "/token", strings.NewReader(formData.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req := httptest.NewRequest(http.MethodPost, "/token", bytes.NewReader(jsonBody)) + req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) diff --git a/internal/api/provider/eip4361.go b/internal/api/provider/eip4361.go index f8ad466a99..95cf1ff6ba 100644 --- a/internal/api/provider/eip4361.go +++ b/internal/api/provider/eip4361.go @@ -18,9 +18,9 @@ const ( BlockchainSolana = "solana" ) -// EIP4361Provider implements Web3 authentication following EIP-4361 spec -type EIP4361Provider struct { - config conf.EIP4361Configuration +// Web3Provider implements Web3 authentication following EIP-4361 spec +type Web3Provider struct { + config conf.Web3Configuration chains map[string]conf.BlockchainConfig defaultChain string } @@ -32,9 +32,9 @@ type SignedMessage struct { Chain string `json:"chain"` } -func NewEIP4361Provider(ctx context.Context, config conf.EIP4361Configuration) (*EIP4361Provider, error) { +func NewWeb3Provider(ctx context.Context, config conf.Web3Configuration) (*Web3Provider, error) { if !config.Enabled { - return nil, errors.New("EIP4361 provider is not enabled") + return nil, errors.New("Web3 provider is not enabled") } // Parse chains @@ -50,27 +50,27 @@ func NewEIP4361Provider(ctx context.Context, config conf.EIP4361Configuration) ( } } - return &EIP4361Provider{ + return &Web3Provider{ config: config, chains: chains, defaultChain: config.DefaultChain, }, nil } -func (p *EIP4361Provider) AuthCodeURL(state string, args ...oauth2.AuthCodeOption) string { +func (p *Web3Provider) AuthCodeURL(state string, args ...oauth2.AuthCodeOption) string { return "" // Web3 auth doesn't use OAuth flow } -func (p *EIP4361Provider) GetOAuthToken(code string) (*oauth2.Token, error) { - return nil, errors.New("GetOAuthToken not implemented for EIP4361") +func (p *Web3Provider) GetOAuthToken(code string) (*oauth2.Token, error) { + return nil, errors.New("GetOAuthToken not implemented for Web3") } -func (p *EIP4361Provider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { - return nil, errors.New("GetUserData not implemented for EIP4361") +func (p *Web3Provider) GetUserData(ctx context.Context, tok *oauth2.Token) (*UserProvidedData, error) { + return nil, errors.New("GetUserData not implemented for Web3") } // VerifySignedMessage verifies a signed Web3 message based on the blockchain -func (p *EIP4361Provider) VerifySignedMessage(msg *SignedMessage) (*UserProvidedData, error) { +func (p *Web3Provider) VerifySignedMessage(msg *SignedMessage) (*UserProvidedData, error) { chain, ok := p.chains[msg.Chain] if !ok { return nil, fmt.Errorf("unsupported blockchain: %s", msg.Chain) @@ -106,11 +106,11 @@ func (p *EIP4361Provider) VerifySignedMessage(msg *SignedMessage) (*UserProvided }, nil } -func (p *EIP4361Provider) verifyEthereumSignature(msg *SignedMessage) error { +func (p *Web3Provider) verifyEthereumSignature(msg *SignedMessage) error { return crypto.VerifyEthereumSignature(msg.Message, msg.Signature, msg.Address) } -func (p *EIP4361Provider) verifySolanaSignature(msg *SignedMessage) error { +func (p *Web3Provider) verifySolanaSignature(msg *SignedMessage) error { parsedMessage, err := siws.ParseSIWSMessage(msg.Message) if err != nil { return fmt.Errorf("failed to parse SIWS message: %w", err) @@ -135,7 +135,7 @@ func (p *EIP4361Provider) verifySolanaSignature(msg *SignedMessage) error { return nil } -func (p *EIP4361Provider) GenerateSignMessage(address string, chain string, uri string) (string, error) { +func (p *Web3Provider) GenerateSignMessage(address string, chain string, uri string) (string, error) { if chain == "" { chain = p.defaultChain } diff --git a/internal/api/token.go b/internal/api/token.go index 5dee090334..8fc98cca7b 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -90,8 +90,8 @@ func (a *API) Token(w http.ResponseWriter, r *http.Request) error { return a.IdTokenGrant(ctx, w, r) case "pkce": return a.PKCE(ctx, w, r) - case "eip4361": - return a.EIP4361Grant(ctx, w, r) + case "web3": + return a.Web3Grant(ctx, w, r) default: return badRequestError(ErrorCodeInvalidCredentials, "unsupported_grant_type") } @@ -311,7 +311,7 @@ func (a *API) PKCE(ctx context.Context, w http.ResponseWriter, r *http.Request) return sendJSON(w, http.StatusOK, token) } -func (a *API) EIP4361Grant(ctx context.Context, w http.ResponseWriter, r *http.Request) error { +func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Request) error { db := a.db.WithContext(ctx) params := &Web3GrantParams{} @@ -319,7 +319,7 @@ func (a *API) EIP4361Grant(ctx context.Context, w http.ResponseWriter, r *http.R return err } - web3Provider, err := provider.NewEIP4361Provider(ctx, a.config.External.EIP4361) + web3Provider, err := provider.Web3Provider(ctx, a.config.External.Web3) if err != nil { return err } @@ -343,14 +343,14 @@ func (a *API) EIP4361Grant(ctx context.Context, w http.ResponseWriter, r *http.R grantParams.FillGrantParams(r) err = db.Transaction(func(tx *storage.Connection) error { - user, terr := a.createAccountFromExternalIdentity(tx, r, userData, "eip4361") + user, terr := a.createAccountFromExternalIdentity(tx, r, userData, "web3") if terr != nil { return terr } // Log the auth attempt if terr := models.NewAuditLogEntry(r, tx, user, models.LoginAction, "", map[string]interface{}{ - "provider": "eip4361", + "provider": "web3", "chain": msg.Chain, "address": msg.Address, }); terr != nil { diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index 5a70089735..3538fbf6ca 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -341,10 +341,10 @@ type ProviderConfiguration struct { RedirectURL string `json:"redirect_url"` AllowedIdTokenIssuers []string `json:"allowed_id_token_issuers" split_words:"true"` FlowStateExpiryDuration time.Duration `json:"flow_state_expiry_duration" split_words:"true"` - EIP4361 EIP4361Configuration `json:"eip4361" envconfig:"EIP4361"` + Web3 Web3Configuration `json:"web3" envconfig:"WEB3"` } -type EIP4361Configuration struct { +type Web3Configuration struct { Enabled bool `json:"enabled" default:"false" split_words:"true"` Domain string `json:"domain" required:"true" split_words:"true"` Statement string `json:"statement" split_words:"true"` @@ -362,7 +362,7 @@ type BlockchainConfig struct { } // ParseSupportedChains processes and validates the SupportedChains string. -func (c *EIP4361Configuration) ParseSupportedChains() (map[string]BlockchainConfig, error) { +func (c *Web3Configuration) ParseSupportedChains() (map[string]BlockchainConfig, error) { chainMap := make(map[string]BlockchainConfig) // Split comma-separated chains diff --git a/internal/reloader/testdata/50_example.env b/internal/reloader/testdata/50_example.env index b8510861e4..3ec9c1fddc 100644 --- a/internal/reloader/testdata/50_example.env +++ b/internal/reloader/testdata/50_example.env @@ -169,15 +169,15 @@ GOTRUE_EXTERNAL_ZOOM_SECRET="" GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="http://localhost:9999/callback" # EIP-4361 OAuth config -GOTRUE_EXTERNAL_EIP4361_ENABLED="true" -GOTRUE_EXTERNAL_EIP4361_STATEMENT="Sign this message to verify your identity" -GOTRUE_EXTERNAL_EIP4361_VERSION="1" -GOTRUE_EXTERNAL_EIP4361_TIMEOUT="300s" -GOTRUE_EXTERNAL_EIP4361_DOMAIN="localhost:9999" +GOTRUE_EXTERNAL_WEB3_ENABLED="true" +GOTRUE_EXTERNAL_WEB3_STATEMENT="Sign this message to verify your identity" +GOTRUE_EXTERNAL_WEB3_VERSION="1" +GOTRUE_EXTERNAL_WEB3_TIMEOUT="300s" +GOTRUE_EXTERNAL_WEB3_DOMAIN="localhost:9999" # Supported Chains Configuration -GOTRUE_EXTERNAL_EIP4361_SUPPORTED_CHAINS="ethereum:1,ethereum:137,solana:mainnet,solana:devnet" -GOTRUE_EXTERNAL_EIP4361_DEFAULT_CHAIN="ethereum:1" +GOTRUE_EXTERNAL_WEB3_SUPPORTED_CHAINS="ethereum:1,ethereum:137,solana:mainnet,solana:devnet" +GOTRUE_EXTERNAL_WEB3_DEFAULT_CHAIN="ethereum:1" # Anonymous auth config GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED="false" diff --git a/internal/utilities/solana/go.mod.bak b/internal/utilities/solana/go.mod.bak deleted file mode 100644 index 125bdd6632..0000000000 --- a/internal/utilities/solana/go.mod.bak +++ /dev/null @@ -1,5 +0,0 @@ -module github.com/utilities/siwsgo - -go 1.20 - -require github.com/btcsuite/btcutil v1.0.2 diff --git a/internal/utilities/solana/go.sum.bak b/internal/utilities/solana/go.sum.bak deleted file mode 100644 index eadd72e564..0000000000 --- a/internal/utilities/solana/go.sum.bak +++ /dev/null @@ -1,35 +0,0 @@ -github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= -github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= -github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= -github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= -github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts= -github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts= -github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= -github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= -github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= -github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= -github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= -github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= -github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= From 0e96f8ab033207858eb01b75733b4101712f2162 Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Sat, 18 Jan 2025 21:05:56 +0300 Subject: [PATCH 07/10] refactor: enhance SIWS error handling with structured error types and status codes --- internal/crypto/crypto.go | 45 +++++++++++++++++----------- internal/utilities/solana/helpers.go | 21 +++++++++---- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 30fc37c754..3cc223a5f6 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -11,11 +11,11 @@ import ( "io" "math" "math/big" + "net/http" "strconv" "strings" "crypto/ed25519" - "errors" "time" "golang.org/x/crypto/hkdf" @@ -175,47 +175,58 @@ func NewEncryptedString(id string, data []byte, keyID string, keyBase64URL strin // - The base58-encoded public key is valid // - The message is within the allowed time window (if requested) func VerifySIWS( - rawMessage string, // the original textual message - signature []byte, // signature returned by the client - msg *siws.SIWSMessage, // the parsed SIWS message (from ParseSIWSMessage) + rawMessage string, + signature []byte, + msg *siws.SIWSMessage, params siws.SIWSVerificationParams, ) error { // 1) Domain check if params.ExpectedDomain == "" { - return errors.New("expected domain is not specified") + // Server misconfiguration + return siws.NewSIWSError("expected domain is not specified", http.StatusInternalServerError) } if !siws.IsValidDomain(msg.Domain) { - return errors.New("invalid domain") + // Malformed request + return siws.NewSIWSError("invalid domain", http.StatusBadRequest) } + if msg.Domain != params.ExpectedDomain { - return errors.New("domain mismatch") + // Per RFC 7235, 403 is more appropriate than 401 here since we're not requesting new credentials + return siws.NewSIWSError("domain mismatch", http.StatusForbidden) } // 2) Base58 decode -> ed25519.PublicKey pubKey := base58.Decode(msg.Address) if !siws.IsBase58PubKey(msg.Address) { - return errors.New("invalid base58 public key or wrong size (must be 32 bytes)") + // Malformed credentials + return siws.NewSIWSError("invalid base58 public key or wrong size (must be 32 bytes)", http.StatusBadRequest) } // 3) Verify signature - // The message to verify must be exactly the raw text that was originally signed. if !ed25519.Verify(pubKey, []byte(rawMessage), signature) { - return errors.New("signature verification failed") + // Per RFC 7235, 401 indicates the credentials were rejected and new ones should be provided + return siws.NewSIWSError("signature verification failed", http.StatusUnauthorized) } // 4) Time check if requested if params.CheckTime && params.TimeDuration > 0 { if msg.IssuedAt.IsZero() { - return errors.New("issuedAt not set, but time check requested") + // Malformed request + return siws.NewSIWSError("issuedAt not set, but time check requested", http.StatusBadRequest) } + now := time.Now().UTC() expiry := msg.IssuedAt.Add(params.TimeDuration) + if now.Before(msg.IssuedAt) { - return errors.New("message is issued in the future") + // Invalid timestamp in request + return siws.NewSIWSError("message is issued in the future", http.StatusBadRequest) } + if now.After(expiry) { - return errors.New("message is expired") + // Per RFC 7235, expired credentials should prompt for new ones + return siws.NewSIWSError("message is expired", http.StatusUnauthorized) } } @@ -230,12 +241,12 @@ func VerifyEthereumSignature(message string, signature string, address string) e // Convert signature hex to bytes sigBytes, err := hex.DecodeString(signature) if err != nil { - return fmt.Errorf("invalid signature hex: %w", err) + return fmt.Errorf("siwe: invalid signature hex: %w", err) } // Adjust V value in signature (Ethereum specific) if len(sigBytes) != 65 { - return fmt.Errorf("invalid signature length") + return fmt.Errorf("siwe: invalid signature length") } if sigBytes[64] < 27 { sigBytes[64] += 27 @@ -248,7 +259,7 @@ func VerifyEthereumSignature(message string, signature string, address string) e // Recover public key from signature pubKey, err := crypto.SigToPub(hash.Bytes(), sigBytes) if err != nil { - return fmt.Errorf("error recovering public key: %w", err) + return fmt.Errorf("siwe: error recovering public key: %w", err) } // Derive Ethereum address from public key @@ -257,7 +268,7 @@ func VerifyEthereumSignature(message string, signature string, address string) e // Compare addresses if recoveredAddr != checkAddr { - return fmt.Errorf("signature not from expected address") + return fmt.Errorf("siwe: signature not from expected address") } return nil diff --git a/internal/utilities/solana/helpers.go b/internal/utilities/solana/helpers.go index 8bf72b1f2d..e37a343a82 100644 --- a/internal/utilities/solana/helpers.go +++ b/internal/utilities/solana/helpers.go @@ -68,9 +68,18 @@ func ValidateChainConfig(chainStr string) error { return nil } -// Add these error types -var ( - ErrInvalidSolanaSignature = errors.New("siws: invalid Solana signature") - ErrInvalidSolanaAddress = errors.New("siws: invalid Solana address format") - ErrExpiredMessage = errors.New("siws: SIWS message has expired") -) +type SIWSError struct { + Message string + StatusCode int +} + +func (e *SIWSError) Error() string { + return e.Message +} + +func NewSIWSError(message string, statusCode int) *SIWSError { + return &SIWSError{ + Message: fmt.Sprintf("siws: %s", message), + StatusCode: statusCode, + } +} \ No newline at end of file From 54fdc0a281770f7e4bb142147f05806ca6e80445 Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Sun, 19 Jan 2025 08:34:43 +0000 Subject: [PATCH 08/10] - streamline error messages & moved them to unified package. - harden siws verification. - remove redundant checks. - add /nonce? endpoint. - create new nonce table (nonce usage without db is incompliant with siws). --- internal/api/provider/eip4361.go | 1 + internal/api/token.go | 95 ++++++- internal/api/token_test.go | 2 + internal/api/web3.go | 1 + internal/crypto/crypto.go | 162 +++++++----- internal/crypto/crypto_test.go | 247 ++++++++++++++++++ internal/utilities/solana/helpers.go | 77 ++++-- internal/utilities/solana/parser.go | 199 +++++++------- internal/utilities/solana/siws_test.go | 107 -------- internal/utilities/solana/types.go | 4 + .../20250119111000_add_web3_nonce_table.sql | 26 ++ 11 files changed, 635 insertions(+), 286 deletions(-) delete mode 100644 internal/utilities/solana/siws_test.go create mode 100644 migrations/20250119111000_add_web3_nonce_table.sql diff --git a/internal/api/provider/eip4361.go b/internal/api/provider/eip4361.go index 95cf1ff6ba..0afbbd1e2f 100644 --- a/internal/api/provider/eip4361.go +++ b/internal/api/provider/eip4361.go @@ -187,3 +187,4 @@ Expiration Time: %s`, return "", fmt.Errorf("message generation not implemented for %s", chainCfg.NetworkName) } } + diff --git a/internal/api/token.go b/internal/api/token.go index 8fc98cca7b..4323303b7b 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -14,11 +14,13 @@ import ( "github.com/xeipuuv/gojsonschema" "github.com/supabase/auth/internal/api/provider" + "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/hooks" "github.com/supabase/auth/internal/metering" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/observability" "github.com/supabase/auth/internal/storage" + siws "github.com/supabase/auth/internal/utilities/solana" ) // AccessTokenClaims is a struct thats used for JWT claims @@ -311,6 +313,87 @@ func (a *API) PKCE(ctx context.Context, w http.ResponseWriter, r *http.Request) return sendJSON(w, http.StatusOK, token) } +type StoredNonce struct { + ID uuid.UUID `db:"id"` + Nonce string `db:"nonce"` + Address string `db:"address"` // Optional: can be empty until signature verification + CreatedAt time.Time `db:"created_at"` + ExpiresAt time.Time `db:"expires_at"` + Used bool `db:"used"` +} + +const NonceExpiration = 5 * time.Minute + +// GetNonce handles nonce generation requests +func (a *API) GetNonce(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + + nonce := crypto.SecureToken() + + storedNonce := &StoredNonce{ + ID: uuid.Must(uuid.NewV4()), + Nonce: nonce, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(NonceExpiration), + Used: false, + } + + err := db.Transaction(func(tx *storage.Connection) error { + // Store the nonce + _, err := tx.TX.Exec(` + INSERT INTO auth.nonces (id, nonce, created_at, expires_at, used) + VALUES ($1, $2, $3, $4, $5) + `, storedNonce.ID, storedNonce.Nonce, storedNonce.CreatedAt, + storedNonce.ExpiresAt, storedNonce.Used) + return err + }) + + if err != nil { + return internalServerError("Error storing nonce").WithInternalError(err) + } + + return sendJSON(w, http.StatusOK, map[string]interface{}{ + "nonce": nonce, + "expiresAt": storedNonce.ExpiresAt, + }) +} + +func (a *API) verifyAndConsumeNonce(ctx context.Context, nonce string, address string) error { + db := a.db.WithContext(ctx) + + var storedNonce StoredNonce + err := db.Transaction(func(tx *storage.Connection) error { + // Find the nonce + err := tx.TX.QueryRow(` + SELECT id, nonce, address, created_at, expires_at, used + FROM auth.nonces + WHERE nonce = $1 AND used = false + `, nonce).Scan(&storedNonce.ID, &storedNonce.Nonce, + &storedNonce.Address, &storedNonce.CreatedAt, + &storedNonce.ExpiresAt, &storedNonce.Used) + if err != nil { + return err + } + + // Check expiration + if time.Now().After(storedNonce.ExpiresAt) { + return fmt.Errorf("nonce expired") + } + + // Mark as used + _, err = tx.TX.Exec(` + UPDATE auth.nonces + SET used = true, address = $1 + WHERE id = $2 + `, address, storedNonce.ID) + return err + }) + + return err +} + + func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Request) error { db := a.db.WithContext(ctx) @@ -319,7 +402,12 @@ func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Requ return err } - web3Provider, err := provider.Web3Provider(ctx, a.config.External.Web3) + // Verify and consume nonce first + if err := a.verifyAndConsumeNonce(ctx, params.Nonce, params.Address); err != nil { + return siws.ErrorCodeInvalidNonce + } + + web3Provider, err := provider.NewWeb3Provider(ctx, a.config.External.Web3) if err != nil { return err } @@ -333,7 +421,6 @@ func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Requ } userData, err := web3Provider.VerifySignedMessage(msg) - if err != nil { return oauthError("invalid_grant", "Signature verification failed").WithInternalError(err) } @@ -348,7 +435,6 @@ func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Requ return terr } - // Log the auth attempt if terr := models.NewAuditLogEntry(r, tx, user, models.LoginAction, "", map[string]interface{}{ "provider": "web3", "chain": msg.Chain, @@ -379,6 +465,7 @@ func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Requ return sendJSON(w, http.StatusOK, token) } + func (a *API) generateAccessToken(r *http.Request, tx *storage.Connection, user *models.User, sessionId *uuid.UUID, authenticationMethod models.AuthenticationMethod) (string, int64, error) { config := a.config if sessionId == nil { @@ -576,3 +663,5 @@ func validateTokenClaims(outputClaims map[string]interface{}) error { return nil } + + diff --git a/internal/api/token_test.go b/internal/api/token_test.go index fc89d4f8bf..42f0006384 100644 --- a/internal/api/token_test.go +++ b/internal/api/token_test.go @@ -855,3 +855,5 @@ $$;` }) } } + + diff --git a/internal/api/web3.go b/internal/api/web3.go index 958d86693d..f53bc66419 100644 --- a/internal/api/web3.go +++ b/internal/api/web3.go @@ -5,4 +5,5 @@ type Web3GrantParams struct { Signature string `json:"signature"` Address string `json:"address"` Chain string `json:"chain"` + Nonce string `json:"nonce"` // Added nonce field } diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 3cc223a5f6..ec4b6ecc62 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -6,12 +6,13 @@ import ( "crypto/rand" "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "io" "math" "math/big" - "net/http" + "net/url" "strconv" "strings" @@ -20,8 +21,6 @@ import ( "golang.org/x/crypto/hkdf" - "encoding/hex" - "github.com/btcsuite/btcutil/base58" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" @@ -169,68 +168,103 @@ func NewEncryptedString(id string, data []byte, keyID string, keyBase64URL strin return &es, nil } -// VerifySIWS fully verifies: -// - The domain in msg matches expected domain -// - The ed25519 signature matches the parsed SIWS message text -// - The base58-encoded public key is valid -// - The message is within the allowed time window (if requested) func VerifySIWS( - rawMessage string, - signature []byte, - msg *siws.SIWSMessage, - params siws.SIWSVerificationParams, + rawMessage string, + signature []byte, + msg *siws.SIWSMessage, + params siws.SIWSVerificationParams, ) error { - // 1) Domain check - if params.ExpectedDomain == "" { - // Server misconfiguration - return siws.NewSIWSError("expected domain is not specified", http.StatusInternalServerError) - } - - if !siws.IsValidDomain(msg.Domain) { - // Malformed request - return siws.NewSIWSError("invalid domain", http.StatusBadRequest) - } - - if msg.Domain != params.ExpectedDomain { - // Per RFC 7235, 403 is more appropriate than 401 here since we're not requesting new credentials - return siws.NewSIWSError("domain mismatch", http.StatusForbidden) - } - - // 2) Base58 decode -> ed25519.PublicKey - pubKey := base58.Decode(msg.Address) - if !siws.IsBase58PubKey(msg.Address) { - // Malformed credentials - return siws.NewSIWSError("invalid base58 public key or wrong size (must be 32 bytes)", http.StatusBadRequest) - } - - // 3) Verify signature - if !ed25519.Verify(pubKey, []byte(rawMessage), signature) { - // Per RFC 7235, 401 indicates the credentials were rejected and new ones should be provided - return siws.NewSIWSError("signature verification failed", http.StatusUnauthorized) - } - - // 4) Time check if requested - if params.CheckTime && params.TimeDuration > 0 { - if msg.IssuedAt.IsZero() { - // Malformed request - return siws.NewSIWSError("issuedAt not set, but time check requested", http.StatusBadRequest) - } - - now := time.Now().UTC() - expiry := msg.IssuedAt.Add(params.TimeDuration) - - if now.Before(msg.IssuedAt) { - // Invalid timestamp in request - return siws.NewSIWSError("message is issued in the future", http.StatusBadRequest) - } - - if now.After(expiry) { - // Per RFC 7235, expired credentials should prompt for new ones - return siws.NewSIWSError("message is expired", http.StatusUnauthorized) - } - } - - return nil + // 1) Basic input validation + if rawMessage == "" { + return siws.ErrEmptyRawMessage + } + if len(signature) == 0 { + return siws.ErrEmptySignature + } + if msg == nil { + return siws.ErrNilMessage + } + + // 2) Domain validation + if params.ExpectedDomain == "" { + return siws.ErrMissingDomain + } + if !siws.IsValidDomain(msg.Domain) { + return siws.ErrInvalidDomainFormat + } + if msg.Domain != params.ExpectedDomain { + return siws.ErrDomainMismatch + } + + // 3) Address/Public Key validation (combined checks) + pubKey := base58.Decode(msg.Address) + if !siws.IsBase58PubKey(pubKey) { + return siws.ErrInvalidPubKeySize + } + + // 4) Version validation + if msg.Version != "1" { + return siws.ErrInvalidVersion + } + + // 5) Chain ID validation (using helper) + if msg.ChainID != "" { + if !siws.IsValidSolanaNetwork(msg.ChainID) { + + return siws.ErrInvalidChainID + } + } + + // 6) Nonce validation (consolidated) + if msg.Nonce != "" { + if len(msg.Nonce) < 8 { + return siws.ErrNonceTooShort + } + } + + // 7) URI and Resources validation + if msg.URI != "" { + if _, err := url.Parse(msg.URI); err != nil { + return siws.ErrInvalidURI + } + } + + for _, resource := range msg.Resources { + if _, err := url.Parse(resource); err != nil { + return siws.ErrInvalidResourceURI + } + } + + // 8) Signature verification + if !ed25519.Verify(pubKey, []byte(rawMessage), signature) { + return siws.ErrSignatureVerification + } + + // 9) Time validations (consolidated) + now := time.Now().UTC() + + if !msg.IssuedAt.IsZero() { + if now.Before(msg.IssuedAt) { + return siws.ErrFutureMessage + } + + if params.CheckTime && params.TimeDuration > 0 { + expiry := msg.IssuedAt.Add(params.TimeDuration) + if now.After(expiry) { + return siws.ErrMessageExpired + } + } + } + + if !msg.NotBefore.IsZero() && now.Before(msg.NotBefore) { + return siws.ErrNotYetValid + } + + if !msg.ExpirationTime.IsZero() && now.After(msg.ExpirationTime) { + return siws.ErrMessageExpired + } + + return nil } func VerifyEthereumSignature(message string, signature string, address string) error { @@ -280,3 +314,5 @@ func removeHexPrefix(signature string) string { } return signature } + + diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go index f1c8e67518..474e307ef2 100644 --- a/internal/crypto/crypto_test.go +++ b/internal/crypto/crypto_test.go @@ -1,10 +1,16 @@ package crypto import ( + "crypto/ed25519" + "fmt" + "strings" "testing" + "time" + "github.com/btcsuite/btcutil/base58" "github.com/gofrs/uuid" "github.com/stretchr/testify/assert" + siws "github.com/supabase/auth/internal/utilities/solana" ) func TestEncryptedStringPositive(t *testing.T) { @@ -106,3 +112,244 @@ func TestEncryptedStringDecryptNegative(t *testing.T) { func TestSecureToken(t *testing.T) { assert.Equal(t, len(SecureToken()), 22) } + + +// package crypto + + + +func TestVerifySIWS(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("Failed to generate keypair: %v", err) + } + + now := time.Now().UTC() + issuedAt := now.Add(-5 * time.Minute) + expiresAt := now.Add(55 * time.Minute) + + // Base test message + validMessage := fmt.Sprintf(`example.com wants you to sign in with your Solana account: +%s + +I accept the ServiceOrg Terms of Service + +URI: https://example.com/login +Version: 1 +Chain ID: solana:mainnet +Nonce: 8lb3dW3F +Issued At: %s +Expiration Time: %s +Resources: +- https://example.com/profile +- https://example.com/settings`, + base58.Encode(pub), + issuedAt.Format(time.RFC3339), + expiresAt.Format(time.RFC3339)) + + parsedMsg, err := siws.ParseSIWSMessage(validMessage) + if err != nil { + t.Fatalf("Failed to parse valid message: %v", err) + } + + validSignature := ed25519.Sign(priv, []byte(validMessage)) + + // Helper function to create a valid base message + createBaseMsg := func() *siws.SIWSMessage { + return &siws.SIWSMessage{ + Domain: "example.com", + Address: base58.Encode(pub), + Version: "1", + URI: "https://example.com/login", + ChainID: "solana:mainnet", + Nonce: "8lb3dW3F", + } + } + + params := siws.SIWSVerificationParams{ + ExpectedDomain: "example.com", + CheckTime: true, + TimeDuration: time.Hour, + } + + testCases := []struct { + name string + message string + signature []byte + msg *siws.SIWSMessage + params siws.SIWSVerificationParams + expectedErr string + }{ + { + name: "valid message", + message: validMessage, + signature: validSignature, + msg: parsedMsg, + params: params, + expectedErr: "", + }, + { + name: "empty message", + message: "", + signature: validSignature, + msg: parsedMsg, + params: params, + expectedErr: siws.ErrEmptyRawMessage.Message, + }, + { + name: "empty signature", + message: validMessage, + signature: []byte{}, + msg: parsedMsg, + params: params, + expectedErr: siws.ErrEmptySignature.Message, + }, + { + name: "nil message struct", + message: validMessage, + signature: validSignature, + msg: nil, + params: params, + expectedErr: siws.ErrNilMessage.Message, + }, + { + name: "invalid address characters", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + // Create a 32-character address with invalid characters + msg.Address = "Invalid@Address!123" + strings.Repeat("1", 19) + return msg + }(), + params: params, + expectedErr: siws.ErrInvalidPubKeySize.Message, + }, + { + name: "address too short", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.Address = "abc123" + return msg + }(), + params: params, + expectedErr: siws.ErrInvalidPubKeySize.Message, + }, + { + name: "invalid version", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.Version = "2" + return msg + }(), + params: params, + expectedErr: siws.ErrInvalidVersion.Message, + }, + { + name: "invalid chain ID", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.ChainID = "invalid-chain" + return msg + }(), + params: params, + expectedErr: siws.ErrInvalidChainID.Message, + }, + { + name: "short nonce", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.Nonce = "abc123" + return msg + }(), + params: params, + expectedErr: siws.ErrNonceTooShort.Message, + }, + + { + name: "invalid URI format", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.URI = "://invalid-uri-format" // Invalid URI scheme + return msg + }(), + params: params, + expectedErr: siws.ErrInvalidURI.Message, + }, + { + name: "invalid resource URI", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.Resources = []string{"://invalid-resource-uri"} // Invalid URI scheme + return msg + }(), + params: params, + expectedErr: siws.ErrInvalidResourceURI.Message, + }, + { + name: "future timestamp", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.IssuedAt = now.Add(10 * time.Minute) + return msg + }(), + params: params, + expectedErr: siws.ErrFutureMessage.Message, + }, + { + name: "expired message", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.IssuedAt = now.Add(-2 * time.Hour) + return msg + }(), + params: params, + expectedErr: siws.ErrMessageExpired.Message, + }, + { + name: "not yet valid", + message: validMessage, + signature: validSignature, + msg: func() *siws.SIWSMessage { + msg := createBaseMsg() + msg.NotBefore = now.Add(1 * time.Hour) + return msg + }(), + params: params, + expectedErr: siws.ErrNotYetValid.Message, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := VerifySIWS(tc.message, tc.signature, tc.msg, tc.params) + if tc.expectedErr == "" { + if err != nil { + t.Errorf("expected success, got error: %v", err) + } + } else { + if err == nil { + t.Errorf("expected error containing %q, got nil", tc.expectedErr) + } else if !strings.Contains(err.Error(), tc.expectedErr) { + t.Errorf("expected error containing %q, got %q", tc.expectedErr, err.Error()) + } + } + }) + } +} \ No newline at end of file diff --git a/internal/utilities/solana/helpers.go b/internal/utilities/solana/helpers.go index e37a343a82..4d82b2cab6 100644 --- a/internal/utilities/solana/helpers.go +++ b/internal/utilities/solana/helpers.go @@ -6,10 +6,58 @@ import ( "encoding/hex" "errors" "fmt" + "net/http" "regexp" "strings" +) - "github.com/btcsuite/btcutil/base58" +var ( + // Input validation errors + ErrEmptyRawMessage = NewSIWSError("empty raw message", http.StatusBadRequest) + ErrEmptySignature = NewSIWSError("empty signature", http.StatusBadRequest) + ErrNilMessage = NewSIWSError("nil message", http.StatusBadRequest) + + // Domain errors + ErrMissingDomain = NewSIWSError("expected domain is not specified", http.StatusInternalServerError) + ErrDomainMismatch = NewSIWSError("domain mismatch", http.StatusForbidden) + + // Address errors + ErrAddressLength = NewSIWSError("address length invalid", http.StatusBadRequest) + ErrAddressCharacter = NewSIWSError("invalid address character", http.StatusBadRequest) + ErrInvalidPubKeySize = NewSIWSError("invalid public key size", http.StatusBadRequest) + + // Version errors + ErrInvalidVersion = NewSIWSError("invalid version", http.StatusBadRequest) + + // Chain ID errors + ErrInvalidChainID = NewSIWSError("invalid chain ID", http.StatusBadRequest) + + // Nonce errors + ErrNonceTooShort = NewSIWSError("nonce too short", http.StatusBadRequest) + ErrInvalidNonceChar = NewSIWSError("invalid nonce character", http.StatusBadRequest) + + // URI errors + ErrInvalidURI = NewSIWSError("invalid URI", http.StatusBadRequest) + ErrInvalidResourceURI = NewSIWSError("invalid resource URI", http.StatusBadRequest) + + // Signature errors + ErrSignatureVerification = NewSIWSError("signature verification failed", http.StatusUnauthorized) + + // Time validation errors + ErrFutureMessage = NewSIWSError("message is issued in the future", http.StatusBadRequest) + ErrMessageExpired = NewSIWSError("message is expired", http.StatusUnauthorized) + ErrNotYetValid = NewSIWSError("message not yet valid", http.StatusUnauthorized) + ErrorCodeInvalidNonce = NewSIWSError("invalid nonce", http.StatusBadRequest) + ErrorCodeInvalidSignature = NewSIWSError("invalid signature", http.StatusBadRequest) + ErrorMalformedMessage = NewSIWSError("malformed message", http.StatusBadRequest) + ErrInvalidDomainFormat = NewSIWSError("invalid domain format", http.StatusBadRequest) + ErrInvalidStatementFormat = NewSIWSError("invalid statement format", http.StatusBadRequest) + ErrInvalidIssuedAtFormat = NewSIWSError("invalid issued at format", http.StatusBadRequest) + ErrInvalidExpirationTimeFormat = NewSIWSError("invalid expiration time format", http.StatusBadRequest) + ErrInvalidNotBeforeFormat = NewSIWSError("invalid not before format", http.StatusBadRequest) + ErrUnrecognizedLine = NewSIWSError("unrecognized line", http.StatusBadRequest) + + ) // GenerateNonce creates a random 16-byte nonce, returning a hex-encoded string. @@ -32,26 +80,21 @@ func IsValidDomain(domain string) bool { } // IsBase58PubKey checks if the input is a plausible base58 Solana public key. -func IsBase58PubKey(address string) bool { - address = strings.TrimSpace(address) - - // Basic length check before trying to decode - if len(address) == 0 { - return false - } - - decoded := base58.Decode(address) - return len(decoded) == ed25519.PublicKeySize // ed25519.PublicKeySize is 32 +func IsBase58PubKey(address []byte) bool { + return len(address) == ed25519.PublicKeySize // ed25519.PublicKeySize is 32 } // Add these functions to your existing helpers.go func IsValidSolanaNetwork(network string) bool { - switch network { - case "mainnet", "devnet", "testnet": - return true - default: - return false - } + // Handle optional "solana:" prefix + network = strings.TrimPrefix(network, "solana:") + + switch network { + case "mainnet", "devnet", "testnet", "localnet": + return true + default: + return false + } } // ValidateChainConfig ensures the Solana network configuration is valid diff --git a/internal/utilities/solana/parser.go b/internal/utilities/solana/parser.go index 0e67bf0d58..ed9e91413e 100644 --- a/internal/utilities/solana/parser.go +++ b/internal/utilities/solana/parser.go @@ -1,111 +1,118 @@ package siws import ( - "errors" - "fmt" - "regexp" "strings" "time" ) -var ( - // domainRegex matches the first line of a SIWS message containing the domain - domainRegex = regexp.MustCompile(`^([^ ]+)\s+wants you to sign in with your Solana account:$`) -) - -// Common errors -var ( - ErrMessageTooShort = errors.New("siws: message is too short or improperly formatted") - ErrInvalidDomainFormat = errors.New("siws: first line does not match expected format for domain request") - ErrMissingAddress = errors.New("siws: missing address line") -) - -// ParseSIWSMessage parses a raw SIWS message into an SIWSMessage struct, -// performing robust checks to ensure correct formatting. func ParseSIWSMessage(raw string) (*SIWSMessage, error) { - lines := strings.Split(raw, "\n") - - // Remove empty lines at the end or accidental trailing newlines. - // Some wallets or frameworks may add them. - var cleaned []string - for _, line := range lines { - l := strings.TrimSpace(line) - if l != "" { - cleaned = append(cleaned, l) - } - } + lines := strings.Split(raw, "\n") + + // Remove empty lines at the end + var cleaned []string + for _, line := range lines { + l := strings.TrimSpace(line) + if l != "" { + cleaned = append(cleaned, l) + } + } - if len(cleaned) < 2 { - return nil, ErrMessageTooShort - } + if len(cleaned) < 2 { + return nil, ErrorMalformedMessage + } - // 1) First line should match " wants you to sign in with your Solana account:" - matches := domainRegex.FindStringSubmatch(cleaned[0]) - if matches == nil || len(matches) < 2 { - return nil, ErrInvalidDomainFormat - } - domain := matches[1] + // Parse domain line + matches := strings.Split(cleaned[0], " wants you to sign in with your Solana account:") + if len(matches) != 2 || matches[0] == "" { + return nil, ErrInvalidDomainFormat + } + domain := matches[0] - // 2) Second line is the base58-encoded public key - address := strings.TrimSpace(cleaned[1]) - if address == "" { - return nil, ErrMissingAddress - } + // Parse address line + address := strings.TrimSpace(cleaned[1]) + + // Initialize message struct + msg := &SIWSMessage{ + Domain: domain, + Address: address, + } - // The third line might be blank or might be the statement. We can handle that carefully. - statement := "" - lineIndex := 2 + // Parse optional statement - must be preceded by double newline + lineIndex := 2 + if lineIndex+1 < len(cleaned) { + for i := 2; i < len(lines)-1; i++ { + if lines[i] == "" && lines[i+1] != "" && + !strings.Contains(lines[i+1], ": ") { + msg.Statement = cleaned[lineIndex+1] + lineIndex = lineIndex + 2 + break + } + } + } - if lineIndex < len(cleaned) { - // If the line is blank, skip it; otherwise, treat it as statement - if strings.HasPrefix(cleaned[lineIndex], "URI:") || - strings.HasPrefix(cleaned[lineIndex], "Version:") || - strings.HasPrefix(cleaned[lineIndex], "Nonce:") || - strings.HasPrefix(cleaned[lineIndex], "Issued At:") { - // No statement - } else { - // We assume this line is statement - statement = cleaned[lineIndex] - lineIndex++ - } - } + // Parse key-value fields + for lineIndex < len(cleaned) { + line := cleaned[lineIndex] + + switch { + case strings.HasPrefix(line, "URI: "): + msg.URI = strings.TrimSpace(strings.TrimPrefix(line, "URI:")) + + case strings.HasPrefix(line, "Version: "): + msg.Version = strings.TrimSpace(strings.TrimPrefix(line, "Version:")) + + case strings.HasPrefix(line, "Chain ID: "): + msg.ChainID = strings.TrimSpace(strings.TrimPrefix(line, "Chain ID:")) + + case strings.HasPrefix(line, "Nonce: "): + msg.Nonce = strings.TrimSpace(strings.TrimPrefix(line, "Nonce:")) + + case strings.HasPrefix(line, "Issued At: "): + tsString := strings.TrimSpace(strings.TrimPrefix(line, "Issued At:")) + ts, err := time.Parse(time.RFC3339, tsString) + if err != nil { + return nil, ErrInvalidIssuedAtFormat + } + msg.IssuedAt = ts + + case strings.HasPrefix(line, "Expiration Time: "): + tsString := strings.TrimSpace(strings.TrimPrefix(line, "Expiration Time:")) + ts, err := time.Parse(time.RFC3339, tsString) + if err != nil { + return nil, ErrInvalidExpirationTimeFormat + } + msg.ExpirationTime = ts + + case strings.HasPrefix(line, "Not Before: "): + tsString := strings.TrimSpace(strings.TrimPrefix(line, "Not Before:")) + ts, err := time.Parse(time.RFC3339, tsString) + if err != nil { + return nil, ErrInvalidNotBeforeFormat + } + msg.NotBefore = ts + + case strings.HasPrefix(line, "Request ID: "): + msg.RequestID = strings.TrimSpace(strings.TrimPrefix(line, "Request ID:")) + + case strings.HasPrefix(line, "Resources:"): + lineIndex++ + for lineIndex < len(cleaned) { + resourceLine := cleaned[lineIndex] + if !strings.HasPrefix(resourceLine, "- ") { + break + } + resource := strings.TrimSpace(strings.TrimPrefix(resourceLine, "-")) + msg.Resources = append(msg.Resources, resource) + lineIndex++ + } + continue + + default: + return nil, ErrUnrecognizedLine + } + lineIndex++ + } - var uri, version, nonce string - var issuedAt time.Time - - // 3) Parse optional lines in the form "URI: ...", "Version: ...", "Nonce: ...", "Issued At: ..." - for lineIndex < len(cleaned) { - line := cleaned[lineIndex] - switch { - case strings.HasPrefix(line, "URI: "): - uri = strings.TrimSpace(strings.TrimPrefix(line, "URI:")) - case strings.HasPrefix(line, "Version: "): - version = strings.TrimSpace(strings.TrimPrefix(line, "Version:")) - case strings.HasPrefix(line, "Nonce: "): - nonce = strings.TrimSpace(strings.TrimPrefix(line, "Nonce:")) - case strings.HasPrefix(line, "Issued At: "): - tsString := strings.TrimSpace(strings.TrimPrefix(line, "Issued At:")) - var err error - issuedAt, err = time.Parse(time.RFC3339, tsString) - if err != nil { - return nil, fmt.Errorf("siws: failed to parse Issued At time: %w", err) - } - default: - return nil, fmt.Errorf("siws: unrecognized line: %s", line) - } - lineIndex++ - } - - // Construct the final message struct - msg := &SIWSMessage{ - Domain: domain, - Address: address, - Statement: statement, - URI: uri, - Version: version, - Nonce: nonce, - IssuedAt: issuedAt, - } - - return msg, nil + return msg, nil } + diff --git a/internal/utilities/solana/siws_test.go b/internal/utilities/solana/siws_test.go deleted file mode 100644 index 35855e74c9..0000000000 --- a/internal/utilities/solana/siws_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package siws - -import ( - "crypto/ed25519" - "crypto/rand" - "testing" - "time" - - btcbase58 "github.com/btcsuite/btcutil/base58" -) - -func TestSIWSFlow(t *testing.T) { - // 1) Generate Ed25519 key pair - pubKey, privKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatalf("failed to generate ed25519 key: %v", err) - } - pubKeyBase58 := btcbase58.Encode(pubKey) - - // 2) Build the SIWS message text (as the user would see) - // - Typically you'd store the nonce in DB now - issuedAt := time.Now().UTC().Truncate(time.Second) - rawMessage := `example.com wants you to sign in with your Solana account: -` + pubKeyBase58 + ` - -This is a test statement - -URI: https://example.com -Version: 1 -Nonce: ABCDEF123456 -Issued At: ` + issuedAt.Format(time.RFC3339) - - // 3) Parse the message (robust approach) - msg, parseErr := ParseSIWSMessage(rawMessage) - if parseErr != nil { - t.Fatalf("failed to parse message: %v", parseErr) - } - - // 4) Sign the raw message - signature := ed25519.Sign(privKey, []byte(rawMessage)) - - // 5) Verify - params := SIWSVerificationParams{ - ExpectedDomain: "example.com", - CheckTime: true, - TimeDuration: 5 * time.Minute, - } - - if err := VerifySIWS(rawMessage, signature, msg, params); err != nil { - t.Fatalf("verification failed: %v", err) - } -} - -func TestBadDomain(t *testing.T) { - pubKey, privKey, _ := ed25519.GenerateKey(rand.Reader) - pubKeyBase58 := btcbase58.Encode(pubKey) - - rawMessage := `wrong-domain.com wants you to sign in with your Solana account: -` + pubKeyBase58 + ` - -URI: https://example.com -Version: 1 -Nonce: SOME_NONCE -Issued At: 2025-01-01T00:00:00Z` - - msg, _ := ParseSIWSMessage(rawMessage) - signature := ed25519.Sign(privKey, []byte(rawMessage)) - - params := SIWSVerificationParams{ - ExpectedDomain: "example.com", - CheckTime: false, - } - - err := VerifySIWS(rawMessage, signature, msg, params) - if err == nil { - t.Error("expected domain mismatch error, got nil") - } -} - -func TestBadSignature(t *testing.T) { - _, privKey1, _ := ed25519.GenerateKey(rand.Reader) - pubKey2, _, _ := ed25519.GenerateKey(rand.Reader) - pubKey2Base58 := btcbase58.Encode(pubKey2) - - rawMessage := `example.com wants you to sign in with your Solana account: -` + pubKey2Base58 + ` - -Statement - -Version: 1 -Nonce: AAA -Issued At: 2025-01-01T00:00:00Z` - - msg, _ := ParseSIWSMessage(rawMessage) - // Sign with privKey1 but the message references a different public key (pubKey2). - signature := ed25519.Sign(privKey1, []byte(rawMessage)) - - params := SIWSVerificationParams{ - ExpectedDomain: "example.com", - CheckTime: false, - } - - err := VerifySIWS(rawMessage, signature, msg, params) - if err == nil { - t.Error("expected signature verification to fail, got success") - } -} diff --git a/internal/utilities/solana/types.go b/internal/utilities/solana/types.go index 80ad80ce83..01fe3760ba 100644 --- a/internal/utilities/solana/types.go +++ b/internal/utilities/solana/types.go @@ -13,8 +13,12 @@ type SIWSMessage struct { Version string // recommended (e.g. "1") Nonce string // random nonce IssuedAt time.Time // "Issued At" timestamp + ChainID string // e.g. "solana:mainnet" + NotBefore time.Time // "Not Before" timestamp + RequestID string // optional // ExpirationTime is optional. If set, it should be checked against the current time. ExpirationTime time.Time + Resources []string // optional } // SIWSVerificationParams holds parameters needed to verify an SIWS message. diff --git a/migrations/20250119111000_add_web3_nonce_table.sql b/migrations/20250119111000_add_web3_nonce_table.sql new file mode 100644 index 0000000000..450c229f37 --- /dev/null +++ b/migrations/20250119111000_add_web3_nonce_table.sql @@ -0,0 +1,26 @@ +-- Add nonces table for Web3 authentication +create table if not exists {{ index .Options "Namespace" }}.nonces ( + id uuid primary key, + nonce text not null, + address text, + created_at timestamp with time zone not null default now(), + expires_at timestamp with time zone not null, + used boolean not null default false +); + +-- Create index for nonce lookup +create index if not exists idx_nonces_nonce on {{ index .Options "Namespace" }}.nonces (nonce); + +-- Create index for cleanup of expired nonces +create index if not exists idx_nonces_expires_at on {{ index .Options "Namespace" }}.nonces (expires_at); + +-- Add comment on table +comment on table {{ index .Options "Namespace" }}.nonces is 'Stores nonces for Web3 authentication'; + +-- Add comments on columns +comment on column {{ index .Options "Namespace" }}.nonces.id is 'Unique identifier for the nonce record'; +comment on column {{ index .Options "Namespace" }}.nonces.nonce is 'The actual nonce value used for authentication'; +comment on column {{ index .Options "Namespace" }}.nonces.address is 'The wallet address that used this nonce (set after use)'; +comment on column {{ index .Options "Namespace" }}.nonces.created_at is 'When this nonce was created'; +comment on column {{ index .Options "Namespace" }}.nonces.expires_at is 'When this nonce expires'; +comment on column {{ index .Options "Namespace" }}.nonces.used is 'Whether this nonce has been used'; \ No newline at end of file From efb21e74032de3de25f653c6ee049a1f58e6a9f6 Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Wed, 29 Jan 2025 03:17:41 +0000 Subject: [PATCH 09/10] - adjusted verification for some siws parameters. - mounted the /nonce endpoint to the router. - switched nonce to random OTP (slashes not allowed in wallet adapters). - adjusted migration file to add the nonce tables. --- example.env | 2 - internal/api/api.go | 4 + internal/api/provider/eip4361.go | 4 +- internal/api/token.go | 69 +++++--- internal/crypto/crypto.go | 64 +++++-- internal/reloader/testdata/50_example.env | 2 - internal/utilities/solana/helpers.go | 9 +- internal/utilities/solana/parser.go | 162 +++++++++--------- ...0250101111726_add_web3_nonce_table.up.sql} | 0 9 files changed, 187 insertions(+), 129 deletions(-) rename migrations/{20250119111000_add_web3_nonce_table.sql => 20250101111726_add_web3_nonce_table.up.sql} (100%) diff --git a/example.env b/example.env index 448ad3da51..58ea3c9d02 100644 --- a/example.env +++ b/example.env @@ -170,8 +170,6 @@ GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="http://localhost:9999/callback" # EIP-4361 OAuth config GOTRUE_EXTERNAL_WEB3_ENABLED="true" -GOTRUE_EXTERNAL_WEB3_STATEMENT="Sign this message to verify your identity" -GOTRUE_EXTERNAL_WEB3_VERSION="1" GOTRUE_EXTERNAL_WEB3_TIMEOUT="300s" GOTRUE_EXTERNAL_WEB3_DOMAIN="localhost:9999" diff --git a/internal/api/api.go b/internal/api/api.go index aafcff22f3..466aed0d60 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -133,8 +133,12 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne }) r.Route("/", func(r *router) { + + r.Use(api.isValidExternalHost) + r.Get("/nonce", api.GetNonce) + r.Get("/settings", api.Settings) r.Get("/authorize", api.ExternalProviderRedirect) diff --git a/internal/api/provider/eip4361.go b/internal/api/provider/eip4361.go index 0afbbd1e2f..715013236b 100644 --- a/internal/api/provider/eip4361.go +++ b/internal/api/provider/eip4361.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "time" + "log" "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/crypto" @@ -72,6 +73,7 @@ func (p *Web3Provider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Use // VerifySignedMessage verifies a signed Web3 message based on the blockchain func (p *Web3Provider) VerifySignedMessage(msg *SignedMessage) (*UserProvidedData, error) { chain, ok := p.chains[msg.Chain] + log.Printf("Verifying supported blockchain: %s", msg.Chain) if !ok { return nil, fmt.Errorf("unsupported blockchain: %s", msg.Chain) } @@ -129,7 +131,7 @@ func (p *Web3Provider) verifySolanaSignature(msg *SignedMessage) error { } if err := crypto.VerifySIWS(msg.Message, sigBytes, parsedMessage, params); err != nil { - return fmt.Errorf("SIWS verification failed: %w", err) + return err } return nil diff --git a/internal/api/token.go b/internal/api/token.go index 4323303b7b..335723a771 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -2,13 +2,14 @@ package api import ( "context" + "database/sql" + "fmt" + "log" "net/http" "net/url" "strconv" "time" - "fmt" - "github.com/gofrs/uuid" "github.com/golang-jwt/jwt/v5" "github.com/xeipuuv/gojsonschema" @@ -314,12 +315,12 @@ func (a *API) PKCE(ctx context.Context, w http.ResponseWriter, r *http.Request) } type StoredNonce struct { - ID uuid.UUID `db:"id"` - Nonce string `db:"nonce"` - Address string `db:"address"` // Optional: can be empty until signature verification - CreatedAt time.Time `db:"created_at"` - ExpiresAt time.Time `db:"expires_at"` - Used bool `db:"used"` + ID uuid.UUID `db:"id"` + Nonce string `db:"nonce"` + Address sql.NullString `db:"address"` // Changed this line + CreatedAt time.Time `db:"created_at"` + ExpiresAt time.Time `db:"expires_at"` + Used bool `db:"used"` } const NonceExpiration = 5 * time.Minute @@ -329,7 +330,7 @@ func (a *API) GetNonce(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) - nonce := crypto.SecureToken() + nonce := crypto.GenerateOtp(12) storedNonce := &StoredNonce{ ID: uuid.Must(uuid.NewV4()), @@ -353,6 +354,9 @@ func (a *API) GetNonce(w http.ResponseWriter, r *http.Request) error { return internalServerError("Error storing nonce").WithInternalError(err) } + log.Printf("Generated nonce: %s", nonce) + + return sendJSON(w, http.StatusOK, map[string]interface{}{ "nonce": nonce, "expiresAt": storedNonce.ExpiresAt, @@ -360,21 +364,28 @@ func (a *API) GetNonce(w http.ResponseWriter, r *http.Request) error { } func (a *API) verifyAndConsumeNonce(ctx context.Context, nonce string, address string) error { - db := a.db.WithContext(ctx) - var storedNonce StoredNonce - err := db.Transaction(func(tx *storage.Connection) error { - // Find the nonce - err := tx.TX.QueryRow(` - SELECT id, nonce, address, created_at, expires_at, used - FROM auth.nonces - WHERE nonce = $1 AND used = false - `, nonce).Scan(&storedNonce.ID, &storedNonce.Nonce, - &storedNonce.Address, &storedNonce.CreatedAt, - &storedNonce.ExpiresAt, &storedNonce.Used) - if err != nil { - return err - } + log.Printf("Starting nonce verification for: %s", nonce) + db := a.db.WithContext(ctx) + + var storedNonce StoredNonce + err := db.Transaction(func(tx *storage.Connection) error { + // Find the nonce + log.Printf("Executing query for nonce: %s", nonce) + err := tx.TX.QueryRow(` + SELECT id, nonce, address, created_at, expires_at, used + FROM auth.nonces + WHERE nonce = $1 AND used = false + `, nonce).Scan(&storedNonce.ID, &storedNonce.Nonce, + &storedNonce.Address, &storedNonce.CreatedAt, + &storedNonce.ExpiresAt, &storedNonce.Used) + if err != nil { + log.Printf("Error scanning nonce: %v", err) + return err + } + + log.Printf("Found nonce in DB: %+v", storedNonce) + // Check expiration if time.Now().After(storedNonce.ExpiresAt) { @@ -386,7 +397,7 @@ func (a *API) verifyAndConsumeNonce(ctx context.Context, nonce string, address s UPDATE auth.nonces SET used = true, address = $1 WHERE id = $2 - `, address, storedNonce.ID) + `, sql.NullString{String: address, Valid: true}, storedNonce.ID) return err }) @@ -402,8 +413,16 @@ func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Requ return err } + parsedMessage, err := siws.ParseSIWSMessage(params.Message) + + if err != nil { + return siws.ErrorMalformedMessage + } + + + // Verify and consume nonce first - if err := a.verifyAndConsumeNonce(ctx, params.Nonce, params.Address); err != nil { + if err := a.verifyAndConsumeNonce(ctx, parsedMessage.Nonce, parsedMessage.Address); err != nil { return siws.ErrorCodeInvalidNonce } diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index ec4b6ecc62..76ba219d97 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -10,6 +10,7 @@ import ( "encoding/json" "fmt" "io" + "log" "math" "math/big" "net/url" @@ -174,96 +175,137 @@ func VerifySIWS( msg *siws.SIWSMessage, params siws.SIWSVerificationParams, ) error { + log.Printf("[DEBUG] Starting SIWS verification - Signature length: %d", len(signature)) + // 1) Basic input validation if rawMessage == "" { + log.Printf("[ERROR] Empty raw message") return siws.ErrEmptyRawMessage } if len(signature) == 0 { + log.Printf("[ERROR] Empty signature") return siws.ErrEmptySignature } if msg == nil { + log.Printf("[ERROR] Nil message") return siws.ErrNilMessage } + log.Printf("[DEBUG] Basic validation passed - Message length: %d", len(rawMessage)) + // 2) Domain validation + log.Printf("[DEBUG] Validating domain - Expected: %s, Actual: %s", params.ExpectedDomain, msg.Domain) if params.ExpectedDomain == "" { + log.Printf("[ERROR] Missing expected domain") return siws.ErrMissingDomain } if !siws.IsValidDomain(msg.Domain) { + log.Printf("[ERROR] Invalid domain format: %s", msg.Domain) return siws.ErrInvalidDomainFormat } if msg.Domain != params.ExpectedDomain { + log.Printf("[ERROR] Domain mismatch - Expected: %s, Got: %s", params.ExpectedDomain, msg.Domain) return siws.ErrDomainMismatch } - // 3) Address/Public Key validation (combined checks) + // 3) Address/Public Key validation pubKey := base58.Decode(msg.Address) + log.Printf("[DEBUG] Validating public key - Address: %s, Decoded length: %d", msg.Address, len(pubKey)) if !siws.IsBase58PubKey(pubKey) { + log.Printf("[ERROR] Invalid public key size: %d", len(pubKey)) return siws.ErrInvalidPubKeySize } // 4) Version validation + log.Printf("[DEBUG] Checking version: %s", msg.Version) if msg.Version != "1" { + log.Printf("[ERROR] Invalid version: %s", msg.Version) return siws.ErrInvalidVersion } - // 5) Chain ID validation (using helper) + // 5) Chain ID validation if msg.ChainID != "" { - if !siws.IsValidSolanaNetwork(msg.ChainID) { - + log.Printf("[DEBUG] Validating chain ID: %s", msg.ChainID) + if !siws.IsValidSolanaNetwork(msg.ChainID) { + log.Printf("[ERROR] Invalid chain ID: %s", msg.ChainID) return siws.ErrInvalidChainID } } - // 6) Nonce validation (consolidated) + // 6) Nonce validation if msg.Nonce != "" { + log.Printf("[DEBUG] Checking nonce length: %d", len(msg.Nonce)) if len(msg.Nonce) < 8 { + log.Printf("[ERROR] Nonce too short: %d chars", len(msg.Nonce)) return siws.ErrNonceTooShort } } - // 7) URI and Resources validation + // 7) URI validation if msg.URI != "" { + log.Printf("[DEBUG] Validating URI: %s", msg.URI) if _, err := url.Parse(msg.URI); err != nil { + log.Printf("[ERROR] Invalid URI: %s - %v", msg.URI, err) return siws.ErrInvalidURI } } + // Resources validation for _, resource := range msg.Resources { + log.Printf("[DEBUG] Validating resource URI: %s", resource) if _, err := url.Parse(resource); err != nil { + log.Printf("[ERROR] Invalid resource URI: %s - %v", resource, err) return siws.ErrInvalidResourceURI } } // 8) Signature verification + log.Printf("[DEBUG] Verifying ed25519 signature") + log.Printf("[DEBUG] Verification inputs - Message bytes: %v", []byte(rawMessage)) + log.Printf("[DEBUG] Verification inputs - Signature bytes: %v", signature) if !ed25519.Verify(pubKey, []byte(rawMessage), signature) { + log.Printf("[ERROR] Signature verification failed") return siws.ErrSignatureVerification } - // 9) Time validations (consolidated) + // 9) Time validations now := time.Now().UTC() + log.Printf("[DEBUG] Time validation - Current time: %s", now) if !msg.IssuedAt.IsZero() { + log.Printf("[DEBUG] Checking issuedAt: %s", msg.IssuedAt) if now.Before(msg.IssuedAt) { + log.Printf("[ERROR] Message from future - IssuedAt: %s", msg.IssuedAt) return siws.ErrFutureMessage } if params.CheckTime && params.TimeDuration > 0 { expiry := msg.IssuedAt.Add(params.TimeDuration) + log.Printf("[DEBUG] Checking message expiry - Expiry: %s", expiry) if now.After(expiry) { + log.Printf("[ERROR] Message expired - Expiry: %s", expiry) return siws.ErrMessageExpired } } } - if !msg.NotBefore.IsZero() && now.Before(msg.NotBefore) { - return siws.ErrNotYetValid + if !msg.NotBefore.IsZero() { + log.Printf("[DEBUG] Checking notBefore: %s", msg.NotBefore) + if now.Before(msg.NotBefore) { + log.Printf("[ERROR] Message not yet valid - NotBefore: %s", msg.NotBefore) + return siws.ErrNotYetValid + } } - if !msg.ExpirationTime.IsZero() && now.After(msg.ExpirationTime) { - return siws.ErrMessageExpired + if !msg.ExpirationTime.IsZero() { + log.Printf("[DEBUG] Checking expirationTime: %s", msg.ExpirationTime) + if now.After(msg.ExpirationTime) { + log.Printf("[ERROR] Message expired - ExpirationTime: %s", msg.ExpirationTime) + return siws.ErrMessageExpired + } } + log.Printf("[INFO] SIWS verification successful") return nil } diff --git a/internal/reloader/testdata/50_example.env b/internal/reloader/testdata/50_example.env index 3ec9c1fddc..6c4fed22b9 100644 --- a/internal/reloader/testdata/50_example.env +++ b/internal/reloader/testdata/50_example.env @@ -170,8 +170,6 @@ GOTRUE_EXTERNAL_ZOOM_REDIRECT_URI="http://localhost:9999/callback" # EIP-4361 OAuth config GOTRUE_EXTERNAL_WEB3_ENABLED="true" -GOTRUE_EXTERNAL_WEB3_STATEMENT="Sign this message to verify your identity" -GOTRUE_EXTERNAL_WEB3_VERSION="1" GOTRUE_EXTERNAL_WEB3_TIMEOUT="300s" GOTRUE_EXTERNAL_WEB3_DOMAIN="localhost:9999" diff --git a/internal/utilities/solana/helpers.go b/internal/utilities/solana/helpers.go index 4d82b2cab6..8f82a8ee03 100644 --- a/internal/utilities/solana/helpers.go +++ b/internal/utilities/solana/helpers.go @@ -51,6 +51,7 @@ var ( ErrorCodeInvalidSignature = NewSIWSError("invalid signature", http.StatusBadRequest) ErrorMalformedMessage = NewSIWSError("malformed message", http.StatusBadRequest) ErrInvalidDomainFormat = NewSIWSError("invalid domain format", http.StatusBadRequest) + ErrMessageDomainMismatch = NewSIWSError("domain's header domain and body domain are mismatched.", http.StatusBadRequest) ErrInvalidStatementFormat = NewSIWSError("invalid statement format", http.StatusBadRequest) ErrInvalidIssuedAtFormat = NewSIWSError("invalid issued at format", http.StatusBadRequest) ErrInvalidExpirationTimeFormat = NewSIWSError("invalid expiration time format", http.StatusBadRequest) @@ -73,10 +74,10 @@ func GenerateNonce() (string, error) { // ValidateDomain checks if a domain is valid or not. This can be expanded with // real domain validation logic. Here, we do a simple parse check. func IsValidDomain(domain string) bool { - // Regular expression to validate domain name - regex := `^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$` - match, _ := regexp.MatchString(regex, domain) - return match + // Regular expression to validate domain name including localhost and ports + regex := `^(localhost|(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,})(?::\d{1,5})?$` + match, _ := regexp.MatchString(regex, domain) + return match } // IsBase58PubKey checks if the input is a plausible base58 Solana public key. diff --git a/internal/utilities/solana/parser.go b/internal/utilities/solana/parser.go index ed9e91413e..3290c8d18c 100644 --- a/internal/utilities/solana/parser.go +++ b/internal/utilities/solana/parser.go @@ -3,116 +3,110 @@ package siws import ( "strings" "time" + "fmt" ) func ParseSIWSMessage(raw string) (*SIWSMessage, error) { - lines := strings.Split(raw, "\n") - - // Remove empty lines at the end - var cleaned []string - for _, line := range lines { - l := strings.TrimSpace(line) - if l != "" { - cleaned = append(cleaned, l) - } - } - - if len(cleaned) < 2 { + // First split the message into lines + lines := strings.Split(strings.TrimSpace(raw), "\n") + if len(lines) < 2 { return nil, ErrorMalformedMessage } - // Parse domain line - matches := strings.Split(cleaned[0], " wants you to sign in with your Solana account:") - if len(matches) != 2 || matches[0] == "" { - return nil, ErrInvalidDomainFormat + // Parse first line exactly + header := lines[0] + if !strings.HasSuffix(header, " wants you to sign in with your Solana account:") { + return nil, ErrorMalformedMessage } - domain := matches[0] + domain := strings.TrimSuffix(header, " wants you to sign in with your Solana account:") - // Parse address line - address := strings.TrimSpace(cleaned[1]) - - // Initialize message struct msg := &SIWSMessage{ - Domain: domain, - Address: address, + Domain: domain, + Address: strings.TrimSpace(lines[1]), } - // Parse optional statement - must be preceded by double newline - lineIndex := 2 - if lineIndex+1 < len(cleaned) { - for i := 2; i < len(lines)-1; i++ { - if lines[i] == "" && lines[i+1] != "" && - !strings.Contains(lines[i+1], ": ") { - msg.Statement = cleaned[lineIndex+1] - lineIndex = lineIndex + 2 - break + // Look for statement (double newline section) + inResources := false + for i := 2; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + + if inResources { + if strings.HasPrefix(line, "- ") { + resource := strings.TrimSpace(strings.TrimPrefix(line, "- ")) + msg.Resources = append(msg.Resources, resource) + continue + } else { + inResources = false } } - } - // Parse key-value fields - for lineIndex < len(cleaned) { - line := cleaned[lineIndex] - - switch { - case strings.HasPrefix(line, "URI: "): - msg.URI = strings.TrimSpace(strings.TrimPrefix(line, "URI:")) - - case strings.HasPrefix(line, "Version: "): - msg.Version = strings.TrimSpace(strings.TrimPrefix(line, "Version:")) - - case strings.HasPrefix(line, "Chain ID: "): - msg.ChainID = strings.TrimSpace(strings.TrimPrefix(line, "Chain ID:")) - - case strings.HasPrefix(line, "Nonce: "): - msg.Nonce = strings.TrimSpace(strings.TrimPrefix(line, "Nonce:")) - - case strings.HasPrefix(line, "Issued At: "): - tsString := strings.TrimSpace(strings.TrimPrefix(line, "Issued At:")) - ts, err := time.Parse(time.RFC3339, tsString) + if line == "Resources:" { + inResources = true + continue + } + + if line == "" { + continue + } + + parts := strings.SplitN(line, ": ", 2) + if len(parts) != 2 { + // If we see a line without ": ", it might be a statement + if !strings.Contains(line, ":") { + msg.Statement = line + continue + } + continue + } + + key, value := parts[0], strings.TrimSpace(parts[1]) + switch key { + case "URI": + msg.URI = value + case "Version": + msg.Version = value + case "Chain ID": + msg.ChainID = value + case "Nonce": + msg.Nonce = value + case "Issued At": + ts, err := time.Parse(time.RFC3339, value) if err != nil { - return nil, ErrInvalidIssuedAtFormat + ts, err = time.Parse(time.RFC3339Nano, value) + if err != nil { + return nil, ErrInvalidIssuedAtFormat + } } msg.IssuedAt = ts - - case strings.HasPrefix(line, "Expiration Time: "): - tsString := strings.TrimSpace(strings.TrimPrefix(line, "Expiration Time:")) - ts, err := time.Parse(time.RFC3339, tsString) + case "Expiration Time": + ts, err := time.Parse(time.RFC3339, value) if err != nil { - return nil, ErrInvalidExpirationTimeFormat + ts, err = time.Parse(time.RFC3339Nano, value) + if err != nil { + return nil, ErrInvalidExpirationTimeFormat + } } msg.ExpirationTime = ts - - case strings.HasPrefix(line, "Not Before: "): - tsString := strings.TrimSpace(strings.TrimPrefix(line, "Not Before:")) - ts, err := time.Parse(time.RFC3339, tsString) + case "Not Before": + ts, err := time.Parse(time.RFC3339, value) if err != nil { - return nil, ErrInvalidNotBeforeFormat + ts, err = time.Parse(time.RFC3339Nano, value) + if err != nil { + return nil, ErrInvalidNotBeforeFormat + } } msg.NotBefore = ts - - case strings.HasPrefix(line, "Request ID: "): - msg.RequestID = strings.TrimSpace(strings.TrimPrefix(line, "Request ID:")) - - case strings.HasPrefix(line, "Resources:"): - lineIndex++ - for lineIndex < len(cleaned) { - resourceLine := cleaned[lineIndex] - if !strings.HasPrefix(resourceLine, "- ") { - break - } - resource := strings.TrimSpace(strings.TrimPrefix(resourceLine, "-")) - msg.Resources = append(msg.Resources, resource) - lineIndex++ + case "Request ID": + msg.RequestID = value + case "Domain": + // Debug prints + fmt.Printf("Header domain: '%s'\n", msg.Domain) + fmt.Printf("Field domain: '%s'\n", value) + if value != msg.Domain { + return nil, ErrMessageDomainMismatch } - continue - - default: - return nil, ErrUnrecognizedLine } - lineIndex++ } return msg, nil } - diff --git a/migrations/20250119111000_add_web3_nonce_table.sql b/migrations/20250101111726_add_web3_nonce_table.up.sql similarity index 100% rename from migrations/20250119111000_add_web3_nonce_table.sql rename to migrations/20250101111726_add_web3_nonce_table.up.sql From 15cbbe70279fd42538a52fbe740dffaa1c064874 Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Wed, 29 Jan 2025 06:27:09 +0000 Subject: [PATCH 10/10] refactor(api): change HTTP method for nonce endpoint to POST for security feat(api): add address field to nonce generation for targeted nonce use refactor(api): replace SignedMessage with Web3GrantParams for clarity feat(api): implement nonce verification in Web3Grant flow for security refactor(api): remove unused logging and clean up code for clarity fix(api): correct nonce storage to include address for targeted validation chore(api): remove Address field from Web3GrantParams as it's parsed from message feat(crypto.go): add base32 encoding and secure alphanumeric generation refactor(crypto.go): replace ethereum and btc libraries with uuid and internal storage for better modularity feat(crypto.go): implement nonce verification and consumption logic for enhanced security feat(migrations): enforce non-null constraint on address in nonces table for data integrity --- internal/api/api.go | 2 +- internal/api/helpers.go | 1 + internal/api/provider/eip4361.go | 66 +++++++------ internal/api/token.go | 92 +++++------------- internal/api/web3.go | 2 - internal/crypto/crypto.go | 96 ++++++++++++++++--- internal/utilities/solana/types.go | 1 + ...20250101111726_add_web3_nonce_table.up.sql | 2 +- 8 files changed, 146 insertions(+), 116 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 466aed0d60..ddda032916 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -137,7 +137,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.Use(api.isValidExternalHost) - r.Get("/nonce", api.GetNonce) + r.Post("/nonce", api.GetNonce) r.Get("/settings", api.Settings) diff --git a/internal/api/helpers.go b/internal/api/helpers.go index 2318013792..73d458d926 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -10,6 +10,7 @@ import ( "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/security" + "github.com/supabase/auth/internal/utilities" ) diff --git a/internal/api/provider/eip4361.go b/internal/api/provider/eip4361.go index 715013236b..1395f2b7d4 100644 --- a/internal/api/provider/eip4361.go +++ b/internal/api/provider/eip4361.go @@ -5,11 +5,13 @@ import ( "encoding/base64" "errors" "fmt" + "net/http" + "strings" "time" - "log" "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/crypto" + "github.com/supabase/auth/internal/storage" siws "github.com/supabase/auth/internal/utilities/solana" "golang.org/x/oauth2" ) @@ -26,10 +28,9 @@ type Web3Provider struct { defaultChain string } -type SignedMessage struct { +type Web3GrantParams struct { Message string `json:"message"` Signature string `json:"signature"` - Address string `json:"address"` Chain string `json:"chain"` } @@ -71,35 +72,52 @@ func (p *Web3Provider) GetUserData(ctx context.Context, tok *oauth2.Token) (*Use } // VerifySignedMessage verifies a signed Web3 message based on the blockchain -func (p *Web3Provider) VerifySignedMessage(msg *SignedMessage) (*UserProvidedData, error) { - chain, ok := p.chains[msg.Chain] - log.Printf("Verifying supported blockchain: %s", msg.Chain) - if !ok { - return nil, fmt.Errorf("unsupported blockchain: %s", msg.Chain) +func (p *Web3Provider) VerifySignedMessage(db *storage.Connection, params *Web3GrantParams) (*UserProvidedData, error) { + var err error + + parsedMessage, err := siws.ParseSIWSMessage(params.Message) + + if err != nil { + return nil, siws.ErrorMalformedMessage } + + - var err error - switch chain.NetworkName { + // Verify and consume nonce first + if err := crypto.VerifyAndConsumeNonce(db, parsedMessage.Nonce, parsedMessage.Address); err != nil { + return nil, siws.ErrorCodeInvalidNonce + } + network := strings.Split(params.Chain, ":") + if len(network) != 2 { + return nil, siws.ErrInvalidChainID + } + chain := network[0] + if chain == "" { + return nil, siws.ErrInvalidChainID + } + + switch chain { case BlockchainEthereum: - err = p.verifyEthereumSignature(msg) + return nil, httpError(http.StatusNotImplemented, "signature verification not implemented for %s", network) + case BlockchainSolana: - err = p.verifySolanaSignature(msg) + err = p.verifySolanaSignature(params.Signature, params.Message, parsedMessage) default: - return nil, fmt.Errorf("signature verification not implemented for %s", chain.NetworkName) + return nil, httpError(http.StatusNotImplemented, "signature verification not implemented for %s", network) } if err != nil { return nil, fmt.Errorf("signature verification failed: %w", err) } - // Construct the provider_id as chain:address to make it unique - providerId := fmt.Sprintf("%s:%s", msg.Chain, msg.Address) + // Construct the provider_id as network:chain:address to make it unique + providerId := fmt.Sprintf("%s:%s", params.Chain, parsedMessage.Address) return &UserProvidedData{ Metadata: &Claims{ CustomClaims: map[string]interface{}{ - "address": msg.Address, - "chain": msg.Chain, + "address": parsedMessage.Address, + "chain": parsedMessage.ChainID, "role": "authenticated", }, Subject: providerId, // This becomes the provider_id in the identity @@ -108,18 +126,10 @@ func (p *Web3Provider) VerifySignedMessage(msg *SignedMessage) (*UserProvidedDat }, nil } -func (p *Web3Provider) verifyEthereumSignature(msg *SignedMessage) error { - return crypto.VerifyEthereumSignature(msg.Message, msg.Signature, msg.Address) -} - -func (p *Web3Provider) verifySolanaSignature(msg *SignedMessage) error { - parsedMessage, err := siws.ParseSIWSMessage(msg.Message) - if err != nil { - return fmt.Errorf("failed to parse SIWS message: %w", err) - } +func (p *Web3Provider) verifySolanaSignature(signature string, rawMessage string, msg *siws.SIWSMessage) error { // Decode base64 signature into bytes - sigBytes, err := base64.StdEncoding.DecodeString(msg.Signature) + sigBytes, err := base64.StdEncoding.DecodeString(string(signature)) if err != nil { return fmt.Errorf("invalid signature encoding: %w", err) } @@ -130,7 +140,7 @@ func (p *Web3Provider) verifySolanaSignature(msg *SignedMessage) error { TimeDuration: p.config.Timeout, } - if err := crypto.VerifySIWS(msg.Message, sigBytes, parsedMessage, params); err != nil { + if err := crypto.VerifySIWS(rawMessage, sigBytes, msg, params); err != nil { return err } diff --git a/internal/api/token.go b/internal/api/token.go index 335723a771..1494fd2973 100644 --- a/internal/api/token.go +++ b/internal/api/token.go @@ -2,9 +2,8 @@ package api import ( "context" - "database/sql" + "encoding/json" "fmt" - "log" "net/http" "net/url" "strconv" @@ -21,7 +20,6 @@ import ( "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/observability" "github.com/supabase/auth/internal/storage" - siws "github.com/supabase/auth/internal/utilities/solana" ) // AccessTokenClaims is a struct thats used for JWT claims @@ -317,7 +315,7 @@ func (a *API) PKCE(ctx context.Context, w http.ResponseWriter, r *http.Request) type StoredNonce struct { ID uuid.UUID `db:"id"` Nonce string `db:"nonce"` - Address sql.NullString `db:"address"` // Changed this line + Address string `db:"address"` CreatedAt time.Time `db:"created_at"` ExpiresAt time.Time `db:"expires_at"` Used bool `db:"used"` @@ -330,10 +328,22 @@ func (a *API) GetNonce(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) - nonce := crypto.GenerateOtp(12) + var body struct { + Address string `json:"address"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + return badRequestError(ErrorCodeBadJSON, "Invalid request body: %v", err) + } + + if body.Address == "" { + return badRequestError(ErrorCodeBadJSON, "Missing required field: address") + } + + nonce := crypto.SecureAlphanumeric(12) storedNonce := &StoredNonce{ ID: uuid.Must(uuid.NewV4()), + Address: body.Address, Nonce: nonce, CreatedAt: time.Now(), ExpiresAt: time.Now().Add(NonceExpiration), @@ -343,66 +353,24 @@ func (a *API) GetNonce(w http.ResponseWriter, r *http.Request) error { err := db.Transaction(func(tx *storage.Connection) error { // Store the nonce _, err := tx.TX.Exec(` - INSERT INTO auth.nonces (id, nonce, created_at, expires_at, used) - VALUES ($1, $2, $3, $4, $5) - `, storedNonce.ID, storedNonce.Nonce, storedNonce.CreatedAt, - storedNonce.ExpiresAt, storedNonce.Used) + INSERT INTO auth.nonces (id, address, nonce, created_at, expires_at, used) + VALUES ($1, $2, $3, $4, $5, $6) + `, storedNonce.ID, storedNonce.Address, storedNonce.Nonce, + storedNonce.CreatedAt, storedNonce.ExpiresAt, storedNonce.Used) return err }) if err != nil { - return internalServerError("Error storing nonce").WithInternalError(err) + return internalServerError("DB error while storing nonce: %v", err) } - log.Printf("Generated nonce: %s", nonce) - - return sendJSON(w, http.StatusOK, map[string]interface{}{ "nonce": nonce, "expiresAt": storedNonce.ExpiresAt, }) } -func (a *API) verifyAndConsumeNonce(ctx context.Context, nonce string, address string) error { - - log.Printf("Starting nonce verification for: %s", nonce) - db := a.db.WithContext(ctx) - - var storedNonce StoredNonce - err := db.Transaction(func(tx *storage.Connection) error { - // Find the nonce - log.Printf("Executing query for nonce: %s", nonce) - err := tx.TX.QueryRow(` - SELECT id, nonce, address, created_at, expires_at, used - FROM auth.nonces - WHERE nonce = $1 AND used = false - `, nonce).Scan(&storedNonce.ID, &storedNonce.Nonce, - &storedNonce.Address, &storedNonce.CreatedAt, - &storedNonce.ExpiresAt, &storedNonce.Used) - if err != nil { - log.Printf("Error scanning nonce: %v", err) - return err - } - - log.Printf("Found nonce in DB: %+v", storedNonce) - - - // Check expiration - if time.Now().After(storedNonce.ExpiresAt) { - return fmt.Errorf("nonce expired") - } - - // Mark as used - _, err = tx.TX.Exec(` - UPDATE auth.nonces - SET used = true, address = $1 - WHERE id = $2 - `, sql.NullString{String: address, Valid: true}, storedNonce.ID) - return err - }) - return err -} func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Request) error { @@ -413,33 +381,19 @@ func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Requ return err } - parsedMessage, err := siws.ParseSIWSMessage(params.Message) - - if err != nil { - return siws.ErrorMalformedMessage - } - - - - // Verify and consume nonce first - if err := a.verifyAndConsumeNonce(ctx, parsedMessage.Nonce, parsedMessage.Address); err != nil { - return siws.ErrorCodeInvalidNonce - } - web3Provider, err := provider.NewWeb3Provider(ctx, a.config.External.Web3) if err != nil { return err } // Convert params to SignedMessage - msg := &provider.SignedMessage{ + msg := &provider.Web3GrantParams{ Message: params.Message, Signature: params.Signature, - Address: params.Address, Chain: params.Chain, } - userData, err := web3Provider.VerifySignedMessage(msg) + userData, err := web3Provider.VerifySignedMessage(db, msg) if err != nil { return oauthError("invalid_grant", "Signature verification failed").WithInternalError(err) } @@ -457,7 +411,7 @@ func (a *API) Web3Grant(ctx context.Context, w http.ResponseWriter, r *http.Requ if terr := models.NewAuditLogEntry(r, tx, user, models.LoginAction, "", map[string]interface{}{ "provider": "web3", "chain": msg.Chain, - "address": msg.Address, + "address": userData.Metadata.CustomClaims["address"], }); terr != nil { return terr } diff --git a/internal/api/web3.go b/internal/api/web3.go index f53bc66419..fd794484e3 100644 --- a/internal/api/web3.go +++ b/internal/api/web3.go @@ -3,7 +3,5 @@ package api type Web3GrantParams struct { Message string `json:"message"` Signature string `json:"signature"` - Address string `json:"address"` Chain string `json:"chain"` - Nonce string `json:"nonce"` // Added nonce field } diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 76ba219d97..6926bb1f18 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -5,6 +5,8 @@ import ( "crypto/cipher" "crypto/rand" "crypto/sha256" + "database/sql" + "encoding/base32" "encoding/base64" "encoding/hex" "encoding/json" @@ -23,8 +25,8 @@ import ( "golang.org/x/crypto/hkdf" "github.com/btcsuite/btcutil/base58" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" + "github.com/gofrs/uuid" + "github.com/supabase/auth/internal/storage" siws "github.com/supabase/auth/internal/utilities/solana" ) @@ -309,10 +311,10 @@ func VerifySIWS( return nil } -func VerifyEthereumSignature(message string, signature string, address string) error { +func VerifyEthereumSignature(message string, signature string) error { // Remove 0x prefix if present signature = removeHexPrefix(signature) - address = removeHexPrefix(address) + // address = removeHexPrefix(address) // Convert signature hex to bytes sigBytes, err := hex.DecodeString(signature) @@ -329,23 +331,23 @@ func VerifyEthereumSignature(message string, signature string, address string) e } // Hash the message according to EIP-191 - prefixedMessage := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message) - hash := crypto.Keccak256Hash([]byte(prefixedMessage)) + // prefixedMessage := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message) + // hash := crypto.Keccak256Hash([]byte(prefixedMessage)) // Recover public key from signature - pubKey, err := crypto.SigToPub(hash.Bytes(), sigBytes) - if err != nil { - return fmt.Errorf("siwe: error recovering public key: %w", err) - } + // pubKey, err := crypto.SigToPub(hash.Bytes(), sigBytes) + // if err != nil { + // return fmt.Errorf("siwe: error recovering public key: %w", err) + // } // Derive Ethereum address from public key - recoveredAddr := crypto.PubkeyToAddress(*pubKey) - checkAddr := common.HexToAddress(address) + // recoveredAddr := crypto.PubkeyToAddress(*pubKey) + // checkAddr := common.HexToAddress(address) // Compare addresses - if recoveredAddr != checkAddr { - return fmt.Errorf("siwe: signature not from expected address") - } + // if recoveredAddr != checkAddr { + // return fmt.Errorf("siwe: signature not from expected address") + // } return nil } @@ -357,4 +359,68 @@ func removeHexPrefix(signature string) string { return signature } +// SecureAlphanumeric generates a secure random alphanumeric string using standard library +func SecureAlphanumeric(length int) string { + if length < 8 { + length = 8 + } + + // Calculate bytes needed for desired length + // base32 encoding: 5 bytes -> 8 chars + numBytes := (length * 5 + 7) / 8 + + b := make([]byte, numBytes) + must(io.ReadFull(rand.Reader, b)) + + // Use standard library's base32 without padding + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b))[:length] +} + + +type StoredNonce struct { + ID uuid.UUID `db:"id"` + Nonce string `db:"nonce"` + Address string `db:"address"` + CreatedAt time.Time `db:"created_at"` + ExpiresAt time.Time `db:"expires_at"` + Used bool `db:"used"` +} + +func VerifyAndConsumeNonce(db *storage.Connection, nonce string, address string) error { + + log.Printf("Starting nonce verification for: %s", nonce) +var storedNonce StoredNonce +err := db.Transaction(func(tx *storage.Connection) error { + // Find the nonce + log.Printf("Executing query for nonce: %s", nonce) + err := tx.TX.QueryRow(` + SELECT id, nonce, address, created_at, expires_at, used + FROM auth.nonces + WHERE nonce = $1 AND used = false AND address = $2 + `, nonce, address).Scan(&storedNonce.ID, &storedNonce.Nonce, + &storedNonce.Address, &storedNonce.CreatedAt, + &storedNonce.ExpiresAt, &storedNonce.Used) + if err != nil { + log.Printf("Error scanning nonce: %v", err) + return err + } + + log.Printf("Found nonce in DB: %+v", storedNonce) + + + // Check expiration + if time.Now().After(storedNonce.ExpiresAt) { + return fmt.Errorf("nonce expired") + } + // Mark as used + _, err = tx.TX.Exec(` + UPDATE auth.nonces + SET used = true, address = $1 + WHERE id = $2 + `, sql.NullString{String: address, Valid: true}, storedNonce.ID) + return err +}) + +return err +} \ No newline at end of file diff --git a/internal/utilities/solana/types.go b/internal/utilities/solana/types.go index 01fe3760ba..e7e4a08608 100644 --- a/internal/utilities/solana/types.go +++ b/internal/utilities/solana/types.go @@ -30,3 +30,4 @@ type SIWSVerificationParams struct { CheckTime bool TimeDuration time.Duration } + diff --git a/migrations/20250101111726_add_web3_nonce_table.up.sql b/migrations/20250101111726_add_web3_nonce_table.up.sql index 450c229f37..0b81809544 100644 --- a/migrations/20250101111726_add_web3_nonce_table.up.sql +++ b/migrations/20250101111726_add_web3_nonce_table.up.sql @@ -2,7 +2,7 @@ create table if not exists {{ index .Options "Namespace" }}.nonces ( id uuid primary key, nonce text not null, - address text, + address text not null, created_at timestamp with time zone not null default now(), expires_at timestamp with time zone not null, used boolean not null default false