Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement EIP-4361 support with SIWS message handling and verification #1918

Open
wants to merge 10 commits into
base: master
Choose a base branch
from

Conversation

Bewinxed
Copy link

@Bewinxed Bewinxed commented Jan 18, 2025

What kind of change does this PR introduce?

EIP4361 Auth on the backend via SIWS That can easily be extended further for any other EIP4361 compliant sign-in method.

What is the current behavior?

No onchain auth available.

What is the new behavior?

I've added a new grant type ?grant_type=eip4361, which takes the siws message and uses that to create/authenticate users using the existing methods, I've avoided modifying existing structures as much as possible unless necessary.

In the root folder, there is a external_eip4361_siws_example.go
uncomment the last 3 lines and run it using go run and it will provide an example SIWS message you can test.

built it, spun up the server:

await fetch("http://localhost:9999/token?grant_type=eip4361", {
    method: "POST",
    headers: {
        "Content-Type": "application/json"
    },
    body: JSON.stringify({"address":"J79fnBJGPeizHNYR1AGqRFRiWMQAEJLNu3ePoSWA7Zb3","chain":"solana:mainnet","grant_type":"eip4361","message":"localhost:9999 wants you to sign in with your Solana account:\nJ79fnBJGPeizHNYR1AGqRFRiWMQAEJLNu3ePoSWA7Zb3\n\nSign in with your Solana account\nURI: https://example.com\nVersion: 1\nNonce: 90cf4f06e021297d80363477774ce7f7\nIssued At: 2025-01-17T18:30:31Z\n","signature":"iWW0I/rbGwXwxj6v8oBTraQ39f84Oewo7bk7OyHwkwTx8FmswtpU+eR4gAZGrqRtrEDtXyFPuDbmYZzoEz2DDg=="})})
    .then(response => response.json())
    .then(data => console.log("Response:", data))
    .catch(error => console.error("Error:", error));

Response:

    {
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIzNzJiMmZiYi02YTM4LTQ4ZTUtOWQ0ZC0xOGJlMzMwMjFjYTIiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzM3MTQyMzc2LCJpYXQiOjE3MzcxMzg3NzYsImVtYWlsIjoiIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlaXA0MzYxIiwicHJvdmlkZXJzIjpbImVpcDQzNjEiXX0sInVzZXJfbWV0YWRhdGEiOnsiY3VzdG9tX2NsYWltcyI6eyJhZGRyZXNzIjoiSjc5Zm5CSkdQZWl6SE5ZUjFBR3FSRlJpV01RQUVKTE51M2VQb1NXQTdaYjMiLCJjaGFpbiI6InNvbGFuYTptYWlubmV0Iiwicm9sZSI6ImF1dGhlbnRpY2F0ZWQifSwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInN1YiI6InNvbGFuYTptYWlubmV0Oko3OWZuQkpHUGVpekhOWVIxQUdxUkZSaVdNUUFFSkxOdTNlUG9TV0E3WmIzIn0sInJvbGUiOiJhdXRoZW50aWNhdGVkIiwiYWFsIjoiYWFsMSIsImFtciI6W3sibWV0aG9kIjoid2ViMyIsInRpbWVzdGFtcCI6MTczNzEzODc3Nn1dLCJzZXNzaW9uX2lkIjoiMTY0NWU4MTItMzE0OC00Mjg2LTljOTItNzQyNGE0OGM3OWMzIiwiaXNfYW5vbnltb3VzIjpmYWxzZX0.yizc-IeK73DDz8xw3UBO_pLI8a_ttjldAuDokCF8y_k",
    "token_type": "bearer",
    "expires_in": 3600,
    "expires_at": 1737142376,
    "refresh_token": "J2EY1jvVd2SF343fIgVwyw",
    "user": {
        "id": "372b2fbb-6a38-48e5-9d4d-18be33021ca2",
        "aud": "authenticated",
        "role": "authenticated",
        "email": "",
        "email_confirmed_at": "2025-01-17T21:30:41.607863+03:00",
        "phone": "",
        "confirmed_at": "2025-01-17T21:30:41.607863+03:00",
        "last_sign_in_at": "2025-01-17T21:32:56.1850242+03:00",
        "app_metadata": {
            "provider": "eip4361",
            "providers": [
                "eip4361"
            ]
        },
        "user_metadata": {
            "custom_claims": {
                "address": "J79fnBJGPeizHNYR1AGqRFRiWMQAEJLNu3ePoSWA7Zb3",
                "chain": "solana:mainnet",
                "role": "authenticated"
            },
            "email_verified": false,
            "phone_verified": false,
            "sub": "solana:mainnet:J79fnBJGPeizHNYR1AGqRFRiWMQAEJLNu3ePoSWA7Zb3"
        },
        "identities": [
            {
                "identity_id": "567bf4c0-65f4-4917-aa35-96a80b6fcc5c",
                "id": "solana:mainnet:J79fnBJGPeizHNYR1AGqRFRiWMQAEJLNu3ePoSWA7Zb3",
                "user_id": "372b2fbb-6a38-48e5-9d4d-18be33021ca2",
                "identity_data": {
                    "custom_claims": {
                        "address": "J79fnBJGPeizHNYR1AGqRFRiWMQAEJLNu3ePoSWA7Zb3",
                        "chain": "solana:mainnet",
                        "role": "authenticated"
                    },
                    "email_verified": false,
                    "phone_verified": false,
                    "sub": "solana:mainnet:J79fnBJGPeizHNYR1AGqRFRiWMQAEJLNu3ePoSWA7Zb3"
                },
                "provider": "eip4361",
                "last_sign_in_at": "2025-01-17T21:30:41.589565+03:00",
                "created_at": "2025-01-17T21:30:41.590111+03:00",
                "updated_at": "2025-01-17T21:30:41.590111+03:00"
            }
        ],
        "created_at": "2025-01-17T21:30:41.579424+03:00",
        "updated_at": "2025-01-17T21:32:56.19047+03:00",
        "is_anonymous": false
    }
}

Additional context

To support this, and for it to be easily extendable, I've added this to the .env.example

# 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"

since siws/siwe use eip4361, i added the grant type eip4361, which can extend eth/sol and any compatible network, which can be specified in the .env

then based on the chosen network e.g solana:mainnet the appropriate validation will be used.

I've implemented a siws package inside internal/utilities/siws that implement many of the necessary siws functions (need to review them to double check the validations).

for solana, I tried a no-dependency validation however i added the btc base58 package

as for ethereum, I'm, using the ethereum-go package, which is widely supported, but perhaps we can omit and try to implement a native validation without the dep.

I haven't worked much with GO specifically, but I'm the author of https://deauth.vercel.app/, which has won 4th place in the Infra track of the Solana Hyperdrive Hackathon before.

Further considerations

  • Add more handlers and add more strict verification for the message, as the SIWS protocol uses an ABNF message protocol, we can use a parser lib like https://github.com/pandatix/go-abnf which is much cleaner than regex.
  • Add account fetching/onchain verification (for example, perhaps you don't want users who make fresh, unfunded accounts) so you can probably get the balance/history of the user, and avoid blacklisted wallets via a 3rd party check).

Willing to hear your opinions, and revise the conventions. 🙏

@Bewinxed Bewinxed requested a review from a team as a code owner January 18, 2025 14:31
Comment on lines 12 to 20
// 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
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move this to crypto package / re-use something in it?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the siws folder was supposed to be a separate package that could be deployed separately, I just switched it to use nonce := crypto.SecureToken() would that work?

"github.com/ethereum/go-ethereum/crypto"
)

func VerifySignature(message string, signature string, address string) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likewise, 100% coverage needed here.

internal/conf/configuration.go Outdated Show resolved Hide resolved
Copy link
Contributor

@hf hf left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really good! Great direction!

A few general notes:

  • We need to think a bit about how the config is encoded. I get that EIP<numbers> makes a lot of sense for people deep in this matter, but it's not very descriptive for regular programmers that are self-hosting Supabase Auth. I'd say let's just do GOTRUE_EXTERNAL_WEB3_<chain> or something. If there's a new EIP later on, we'll think about its config options then.
  • Similarly POST /token?grant_type=<this should be readable by regular programmers>. siws, web3, siwe, things of this sort are better than eip<numbers>. This value will also be mapped into auth-js as well.
  • POST /token?grant_type=<web3 type> should only take in JSON, not URL forms. All APIs currently only use JSON, so this is a weird exception. Is there a particular reason we can't use JSON here?
  • utilities/siws is a weird name for a package that does eip<numbers>. Maybe just move this under /internal/web3 and it will house this and any future additions.

What is the best way to test this? Any simple test app you can host somewhere?

@Bewinxed
Copy link
Author

This is really good! Great direction!

A few general notes:

  • We need to think a bit about how the config is encoded. I get that EIP<numbers> makes a lot of sense for people deep in this matter, but it's not very descriptive for regular programmers that are self-hosting Supabase Auth. I'd say let's just do GOTRUE_EXTERNAL_WEB3_<chain> or something. If there's a new EIP later on, we'll think about its config options then.

This makes sense, originally i thought of this heirarchy:

Web3 > EIP4361 > Sign in with solana /sign in with ethereum, as there's maybe other web3 modes of auth that aren't eip4361 compliant, eth/sol go under eip4361, but what you said makes sense, I think web3 is friendly here.

  • Similarly POST /token?grant_type=<this should be readable by regular programmers>. siws, web3, siwe, things of this sort are better than eip<numbers>. This value will also be mapped into auth-js as well.
    Agreed
  • POST /token?grant_type=<web3 type> should only take in JSON, not URL forms. All APIs currently only use JSON, so this is a weird exception. Is there a particular reason we can't use JSON here?

I'm using JSON, is there something i missed?

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
	}
...
  • utilities/siws is a weird name for a package that does eip<numbers>. Maybe just move this under /internal/web3 and it will house this and any future additions.
    This package is verifying Solana mainly for now, Perhaps we can do utlities/web3/[siws/siwe] ? They are both eip4361 compliant but each have different address formats, and divulge slightly in implementation.

What is the best way to test this? Any simple test app you can host somewhere?

I'll look into it and get back to you.

@hf
Copy link
Contributor

hf commented Jan 18, 2025

I'm using JSON, is there something i missed?

I saw this which caught my eye. https://github.com/supabase/auth/pull/1918/files#diff-db4fba83e08f520d74d66c9283e5dcd938e1746de1c4e7c481cce8aff142b5a1R71-R73

This package is verifying Solana mainly for now, Perhaps we can do utlities/web3/[siws/siwe] ? They are both eip4361 compliant but each have different address formats, and divulge slightly in implementation.

No need to go into utilities IMO. Just have it be toplevel under /internal.

@Bewinxed
Copy link
Author

Bewinxed commented Jan 23, 2025

Hey guys, I pushed an update a few days ago with some things I missed:

  • Added a db table for nonces, these are required to prevent Replay Attacks.
  • Added robust verifications for each part of the SIWS message.
  • I've covered the SIWS handler in the crypto package with multiple test cases.
  • I initiated the error types in the helpers to avoid initiating them inline, to prevent overhead.

- 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.
…rity

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
Copy link
Contributor

@staaldraad staaldraad left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hey @Bewinxed 👋🏼 nice work! I gave this a first pass and left some comments. The replay detection looks correct, but it would be good to have a test that ensures it stays correct.

created_at timestamp with time zone not null default now(),
expires_at timestamp with time zone not null,
used boolean not null default false
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we add a constraint UNIQUE (address, nonce) ?

nonce text not null,
address text not null,
created_at timestamp with time zone not null default now(),
expires_at timestamp with time zone not null,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: maybe a check that expires_at > created_at.

return badRequestError(ErrorCodeBadJSON, "Missing required field: address")
}

nonce := crypto.SecureAlphanumeric(12)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

here the nonce is is crypto.SecureAlphanumeric(12) but the siws_example uses siws.GenerateNonce() and eip4361.go uses crypto.SecureToken().

All get us to something similar, but better to standardise on one.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have modified some things while getting the demo up, I will unify the tests accordingly.

the SecureAlphanumeric i added to the crypto package as the SecureToken generates symbols that Phantom/Other wallets don't support (even though it's valid).

The GenerateNonce is redundant now, I'll fix it.

pubKeyBase58 := base58.Encode(pubKey)

// Generate nonce
nonce, err := siws.GenerateNonce()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ref to comment about nonce generation varying across implementation

}

// Generate nonce for message uniqueness
nonce := crypto.SecureToken()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^ref to comment about nonce generation varying across implementation

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, just saw #1918 (comment) - still think it would be good that the 3 locations that produce a nonce value are consistent in how they derive the value and the charset/length.

Chain string `json:"chain"`
}

func (ts *ExternalTestSuite) TestSignupExternalSIWS() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would be nice to have a test verifying that replay attacks are prevented (testing crypto.VerifyAndConsumeNonce is being called correctly)

Comment on lines +380 to +387
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"`
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this go in models/web3.go ? unless there is some circular dependence issue

Comment on lines +315 to +322
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"`
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could this go in models/web3.go ? unless there is some circular dependence issue

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants