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 sign in with Ethereum #5

Merged
merged 12 commits into from
Jun 9, 2022
Merged
149 changes: 15 additions & 134 deletions clientapi/auth/login_publickey_ethereum.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,51 +16,28 @@ package auth

import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"regexp"
"strings"

"github.com/ethereum/go-ethereum/accounts"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/crypto"
"github.com/matrix-org/dendrite/clientapi/jsonerror"
"github.com/matrix-org/dendrite/clientapi/userutil"
"github.com/matrix-org/dendrite/setup/config"
userapi "github.com/matrix-org/dendrite/userapi/api"
"github.com/tidwall/gjson"
"github.com/spruceid/siwe-go"
)

type LoginPublicKeyEthereum struct {
// https://github.com/tak-hntlabs/matrix-spec-proposals/blob/main/proposals/3782-matrix-publickey-login-spec.md#client-sends-login-request-with-authentication-data
Type string `json:"type"`
Address string `json:"address"`
Session string `json:"session"`
Message string `json:"message"`
Signature string `json:"signature"`
HashFields publicKeyEthereumHashFields `json:"hashFields"`
HashFieldsRaw string // Raw base64 encoded string of MessageFields for hash verification
Type string `json:"type"`
Address string `json:"address"`
Session string `json:"session"`
Message string `json:"message"`
Signature string `json:"signature"`

userAPI userapi.ClientUserAPI
config *config.ClientAPI
}

type publicKeyEthereumHashFields struct {
// Todo: See https://...
Domain string `json:"domain"` // home server domain
Address string `json:"address"` // Ethereum address. 0x...
Nonce string `json:"nonce"` // session ID
Version string `json:"version"` // version of the Matrix public key spec that the client is complying with
ChainId string `json:"chainId"` // blockchain network ID.
}

type publicKeyEthereumRequiredFields struct {
From string // Sender
To string // Recipient
Hash string // Hash of JSON representation of the message fields
}

func CreatePublicKeyEthereumHandler(
reqBytes []byte,
userAPI userapi.ClientUserAPI,
Expand All @@ -71,15 +48,8 @@ func CreatePublicKeyEthereumHandler(
return nil, jsonerror.BadJSON("auth")
}

hashFields := gjson.GetBytes(reqBytes, "hashFields")
if !hashFields.Exists() {
return nil, jsonerror.BadJSON("auth.hashFields")
}

pk.config = config
pk.userAPI = userAPI
// Save raw bytes for hash verification later.
pk.HashFieldsRaw = hashFields.Raw
// Case-insensitive
pk.Address = strings.ToLower(pk.Address)

Expand Down Expand Up @@ -116,41 +86,20 @@ func (pk LoginPublicKeyEthereum) AccountExists(ctx context.Context) (string, *js
}

func (pk LoginPublicKeyEthereum) ValidateLoginResponse() (bool, *jsonerror.MatrixError) {
// Check signature to verify message was not tempered
isVerified := verifySignature(pk.Address, []byte(pk.Message), pk.Signature)
if !isVerified {
return false, jsonerror.InvalidSignature("")
}

// Extract the required message fields for validation
requiredFields, err := extractRequiredMessageFields(pk.Message)
// Parse the message to extract all the fields.
message, err := siwe.ParseMessage(pk.Message)
if err != nil {
return false, jsonerror.MissingParam("message does not contain domain, address, or hash")
}

// Verify that the hash is valid for the message fields.
if !verifyHash(pk.HashFieldsRaw, requiredFields.Hash) {
return false, jsonerror.Forbidden("error verifying message hash")
return false, jsonerror.InvalidParam("auth.message")
}

// Unmarshal the hashFields for further validation
var authData publicKeyEthereumHashFields
if err := json.Unmarshal([]byte(pk.HashFieldsRaw), &authData); err != nil {
return false, jsonerror.BadJSON("auth.hashFields")
}

// Error if the message is not from the expected public address
if pk.Address != requiredFields.From || requiredFields.From != pk.HashFields.Address {
return false, jsonerror.Forbidden("address")
}

// Error if the message is not for the home server
if requiredFields.To != pk.HashFields.Domain {
return false, jsonerror.Forbidden("domain")
// Check signature to verify message was not tempered
_, err = message.Verify(pk.Signature, (*string)(&pk.config.Matrix.ServerName), nil, nil)
if err != nil {
return false, jsonerror.InvalidSignature(err.Error())
}

// Error if the chainId is not supported by the server.
if !contains(pk.config.PublicKeyAuthentication.Ethereum.ChainIDs, authData.ChainId) {
if !contains(pk.config.PublicKeyAuthentication.Ethereum.ChainIDs, message.GetChainID()) {
return false, jsonerror.Forbidden("chainId")
}

Expand All @@ -169,75 +118,7 @@ func (pk LoginPublicKeyEthereum) CreateLogin() *Login {
return &login
}

// The required fields in the signed message are:
// 1. Domain -- home server. First non-whitespace characters in the first line.
// 2. Address -- public address of the user. Starts with 0x... in the second line on its own.
// 3. Hash -- Base64-encoded hash string of the metadata that represents the message.
// The rest of the fields are informational, and will be used in the future.
var regexpAuthority = regexp.MustCompile(`^\S+`)
var regexpAddress = regexp.MustCompile(`\n(?P<address>0x\w+)\n`)
var regexpHash = regexp.MustCompile(`\nHash: (?P<hash>.*)\n`)

func extractRequiredMessageFields(message string) (*publicKeyEthereumRequiredFields, error) {
var requiredFields publicKeyEthereumRequiredFields
/*
service.org wants you to sign in with your account:
0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2

I accept the ServiceOrg Terms of Service: https://service.org/tos

Hash: yfSIwarByPfKFxeYSCWN3XoIgNgeEFJffbwFA+JxYbA=
*/

requiredFields.To = regexpAuthority.FindString(message)

from := regexpAddress.FindStringSubmatch(message)
if len(from) == 2 {
requiredFields.From = from[1]
}

hash := regexpHash.FindStringSubmatch(message)
if len(hash) == 2 {
requiredFields.Hash = hash[1]
}

if len(requiredFields.To) == 0 || len(requiredFields.From) == 0 || len(requiredFields.Hash) == 0 {
return nil, errors.New("required message fields are missing")
}

// Make these fields case-insensitive
requiredFields.From = strings.ToLower(requiredFields.From)
requiredFields.To = strings.ToLower(requiredFields.To)

return &requiredFields, nil
}

func verifySignature(from string, message []byte, signature string) bool {
decodedSig := hexutil.MustDecode(signature)

message = accounts.TextHash(message)
// Issue: https://stackoverflow.com/questions/49085737/geth-ecrecover-invalid-signature-recovery-id
// Fix: https://gist.github.com/dcb9/385631846097e1f59e3cba3b1d42f3ed#file-eth_sign_verify-go
decodedSig[crypto.RecoveryIDOffset] -= 27 // Transform yellow paper V from 27/28 to 0/1

recovered, err := crypto.SigToPub(message, decodedSig)
if err != nil {
return false
}

recoveredAddr := crypto.PubkeyToAddress(*recovered)

addressStr := strings.ToLower(recoveredAddr.Hex())
return from == addressStr
}

func verifyHash(rawStr string, expectedHash string) bool {
hash := crypto.Keccak256([]byte(rawStr))
hashStr := base64.StdEncoding.EncodeToString(hash)
return expectedHash == hashStr
}

func contains(list []string, element string) bool {
func contains(list []int, element int) bool {
for _, i := range list {
if i == element {
return true
Expand Down
18 changes: 8 additions & 10 deletions clientapi/auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,10 @@ func TestLoginFromJSONReader(t *testing.T) {
},
}
userInteractive := UserInteractive{
Completed: []string{},
Flows: []userInteractiveFlow{},
Types: make(map[string]Type),
Sessions: make(map[string][]string),
Params: make(map[string]interface{}),
Flows: []userInteractiveFlow{},
Types: make(map[string]Type),
Sessions: make(map[string][]string),
Params: make(map[string]interface{}),
}

for _, tst := range tsts {
Expand Down Expand Up @@ -148,11 +147,10 @@ func TestBadLoginFromJSONReader(t *testing.T) {
},
}
userInteractive := UserInteractive{
Completed: []string{},
Flows: []userInteractiveFlow{},
Types: make(map[string]Type),
Sessions: make(map[string][]string),
Params: make(map[string]interface{}),
Flows: []userInteractiveFlow{},
Types: make(map[string]Type),
Sessions: make(map[string][]string),
Params: make(map[string]interface{}),
}

for _, tst := range tsts {
Expand Down
55 changes: 40 additions & 15 deletions clientapi/auth/user_interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"net/http"

"github.com/matrix-org/dendrite/clientapi/jsonerror"
"github.com/matrix-org/dendrite/internal/mapsutil"
"github.com/matrix-org/dendrite/setup/config"
"github.com/matrix-org/dendrite/userapi/api"
"github.com/matrix-org/util"
Expand Down Expand Up @@ -102,8 +103,6 @@ type userInteractiveFlow struct {
// the user already has a valid access token, but we want to double-check
// that it isn't stolen by re-authenticating them.
type UserInteractive struct {
Completed []string

Flows []userInteractiveFlow
// Map of login type to implementation
Types map[string]Type
Expand All @@ -118,24 +117,17 @@ func NewUserInteractive(
cfg *config.ClientAPI,
) *UserInteractive {
userInteractive := UserInteractive{
Completed: []string{},
Flows: []userInteractiveFlow{},
Types: make(map[string]Type),
Sessions: make(map[string][]string),
Params: make(map[string]interface{}),
Flows: []userInteractiveFlow{},
Types: make(map[string]Type),
Sessions: make(map[string][]string),
Params: make(map[string]interface{}),
}

if !cfg.PasswordAuthenticationDisabled {
typePassword := &LoginTypePassword{
GetAccountByPassword: userAccountAPI.QueryAccountByPassword,
Config: cfg,
}

userInteractive.Flows = append(userInteractive.Flows, userInteractiveFlow{
Stages: []string{typePassword.Name()},
},
)
userInteractive.Types[typePassword.Name()] = typePassword
typePassword.AddFLows(&userInteractive)
}

Expand Down Expand Up @@ -179,13 +171,29 @@ type Challenge struct {

// Challenge returns an HTTP 401 with the supported flows for authenticating
func (u *UserInteractive) Challenge(sessionID string) *util.JSONResponse {
paramsCopy := mapsutil.MapCopy(u.Params)
for key, element := range paramsCopy {
p := getAuthParams(element)
if p != nil {
// If an auth flow has params,
// send it as part of the challenge.
paramsCopy[key] = p

// If an auth flow generated a nonce, track it as well.
nonce := getAuthParamNonce(p)
if nonce != "" {
u.Sessions[sessionID] = append(u.Sessions[sessionID], nonce)
}
}
}

return &util.JSONResponse{
Code: 401,
JSON: Challenge{
Completed: u.Sessions[sessionID],
Flows: u.Flows,
Session: sessionID,
Params: u.Params,
Params: paramsCopy,
},
}
}
Expand Down Expand Up @@ -229,7 +237,7 @@ func (u *UserInteractive) ResponseWithChallenge(sessionID string, response inter
// Verify returns an error/challenge response to send to the client, or nil if the user is authenticated.
// `bodyBytes` is the HTTP request body which must contain an `auth` key.
// Returns the login that was verified for additional checks if required.
func (u *UserInteractive) Verify(ctx context.Context, bodyBytes []byte) (*Login, *util.JSONResponse) {
func (u *UserInteractive) Verify(ctx context.Context, bodyBytes []byte, device *api.Device) (*Login, *util.JSONResponse) {
// TODO: rate limit

// "A client should first make a request with no auth parameter. The homeserver returns an HTTP 401 response, with a JSON body"
Expand Down Expand Up @@ -271,3 +279,20 @@ func (u *UserInteractive) Verify(ctx context.Context, bodyBytes []byte) (*Login,
// TODO: Check if there's more stages to go and return an error
return login, nil
}

func getAuthParams(params interface{}) interface{} {
v, ok := params.(config.AuthParams)
if ok {
p := v.GetParams()
return p
}
return nil
}

func getAuthParamNonce(p interface{}) string {
v, ok := p.(config.AuthParams)
if ok {
return v.GetNonce()
}
return ""
}
13 changes: 9 additions & 4 deletions clientapi/auth/user_interactive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ var (
serverName = gomatrixserverlib.ServerName("example.com")
// space separated localpart+password -> account
lookup = make(map[string]*api.Account)
device = &api.Device{
AccessToken: "flibble",
DisplayName: "My Device",
ID: "device_id_goes_here",
}
)

type fakeAccountDatabase struct {
Expand Down Expand Up @@ -55,7 +60,7 @@ func setup() *UserInteractive {
func TestUserInteractiveChallenge(t *testing.T) {
uia := setup()
// no auth key results in a challenge
_, errRes := uia.Verify(ctx, []byte(`{}`))
_, errRes := uia.Verify(ctx, []byte(`{}`), device)
if errRes == nil {
t.Fatalf("Verify succeeded with {} but expected failure")
}
Expand Down Expand Up @@ -95,7 +100,7 @@ func TestUserInteractivePasswordLogin(t *testing.T) {
}`),
}
for _, tc := range testCases {
_, errRes := uia.Verify(ctx, tc)
_, errRes := uia.Verify(ctx, tc, device)
if errRes != nil {
t.Errorf("Verify failed but expected success for request: %s - got %+v", string(tc), errRes)
}
Expand Down Expand Up @@ -176,7 +181,7 @@ func TestUserInteractivePasswordBadLogin(t *testing.T) {
},
}
for _, tc := range testCases {
_, errRes := uia.Verify(ctx, tc.body)
_, errRes := uia.Verify(ctx, tc.body, device)
if errRes == nil {
t.Errorf("Verify succeeded but expected failure for request: %s", string(tc.body))
continue
Expand Down Expand Up @@ -209,7 +214,7 @@ func TestUserInteractive_AddCompletedStage(t *testing.T) {
ctx := context.Background()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, resp := u.Verify(ctx, []byte("{}"))
_, resp := u.Verify(ctx, []byte("{}"), nil)
challenge, ok := resp.JSON.(Challenge)
if !ok {
t.Fatalf("expected a Challenge, got %T", resp.JSON)
Expand Down
2 changes: 1 addition & 1 deletion clientapi/routing/deactivate.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func Deactivate(
}
}

login, errRes := userInteractiveAuth.Verify(ctx, bodyBytes)
login, errRes := userInteractiveAuth.Verify(ctx, bodyBytes, deviceAPI)
if errRes != nil {
return *errRes
}
Expand Down
Loading