From c905b1c09dc6d0d74442ce54a2a224812a083488 Mon Sep 17 00:00:00 2001 From: Tak Wai Wong Date: Thu, 14 Jul 2022 14:23:13 -0700 Subject: [PATCH] Add login_publickey tests, and register_publickey tests. Fixed a bug in login, and a bug in register --- clientapi/auth/login_publickey.go | 2 +- clientapi/auth/login_publickey_ethereum.go | 12 +- .../auth/login_publickey_ethereum_test.go | 472 ++++++++++++++++++ clientapi/auth/login_publickey_test.go | 161 ++++++ clientapi/auth/user_interactive.go | 6 +- clientapi/routing/register.go | 18 +- clientapi/routing/register_publickey.go | 2 +- clientapi/routing/register_publickey_test.go | 386 ++++++++++++++ go.mod | 2 +- setup/config/config_clientapi.go | 2 +- setup/config/config_publickey.go | 12 +- test/publickey_utils.go | 108 ++++ 12 files changed, 1162 insertions(+), 21 deletions(-) create mode 100644 clientapi/auth/login_publickey_ethereum_test.go create mode 100644 clientapi/auth/login_publickey_test.go create mode 100644 clientapi/routing/register_publickey_test.go create mode 100644 test/publickey_utils.go diff --git a/clientapi/auth/login_publickey.go b/clientapi/auth/login_publickey.go index e999edeb7..8194df963 100644 --- a/clientapi/auth/login_publickey.go +++ b/clientapi/auth/login_publickey.go @@ -30,10 +30,10 @@ import ( type LoginPublicKeyHandler interface { AccountExists(ctx context.Context) (string, *jsonerror.MatrixError) - IsValidUserIdForRegistration(userId string) bool CreateLogin() *Login GetSession() string GetType() string + IsValidUserId(userId string) bool ValidateLoginResponse() (bool, *jsonerror.MatrixError) } diff --git a/clientapi/auth/login_publickey_ethereum.go b/clientapi/auth/login_publickey_ethereum.go index a3201a269..90de33d2b 100644 --- a/clientapi/auth/login_publickey_ethereum.go +++ b/clientapi/auth/login_publickey_ethereum.go @@ -73,6 +73,10 @@ func (pk LoginPublicKeyEthereum) AccountExists(ctx context.Context) (string, *js return "", jsonerror.Forbidden("the address is incorrect, or the account does not exist.") } + if !pk.IsValidUserId(localPart) { + return "", jsonerror.InvalidUsername("the username is not valid.") + } + res := userapi.QueryAccountAvailabilityResponse{} if err := pk.userAPI.QueryAccountAvailability(ctx, &userapi.QueryAccountAvailabilityRequest{ Localpart: localPart, @@ -80,7 +84,7 @@ func (pk LoginPublicKeyEthereum) AccountExists(ctx context.Context) (string, *js return "", jsonerror.Unknown("failed to check availability: " + err.Error()) } - if res.Available { + if localPart == "" || res.Available { return "", jsonerror.Forbidden("the address is incorrect, account does not exist") } @@ -89,7 +93,7 @@ func (pk LoginPublicKeyEthereum) AccountExists(ctx context.Context) (string, *js var validChainAgnosticIdRegex = regexp.MustCompile("^eip155=3a[0-9]+=3a0x[0-9a-fA-F]+$") -func (pk LoginPublicKeyEthereum) IsValidUserIdForRegistration(userId string) bool { +func (pk LoginPublicKeyEthereum) IsValidUserId(userId string) bool { // Verify that the user ID is a valid one according to spec. // https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-10.md @@ -100,9 +104,9 @@ func (pk LoginPublicKeyEthereum) IsValidUserIdForRegistration(userId string) boo isValid := validChainAgnosticIdRegex.MatchString(userId) - // In addition, double check that the user ID for registration + // In addition, double check that the user ID // matches the authentication data in the request. - return isValid && userId == pk.UserId + return isValid && strings.ToLower(userId) == pk.UserId } func (pk LoginPublicKeyEthereum) ValidateLoginResponse() (bool, *jsonerror.MatrixError) { diff --git a/clientapi/auth/login_publickey_ethereum_test.go b/clientapi/auth/login_publickey_ethereum_test.go new file mode 100644 index 000000000..12fae2654 --- /dev/null +++ b/clientapi/auth/login_publickey_ethereum_test.go @@ -0,0 +1,472 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + + "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/test" + uapi "github.com/matrix-org/dendrite/userapi/api" + "github.com/stretchr/testify/assert" +) + +type loginContext struct { + config *config.ClientAPI + userInteractive *UserInteractive +} + +func createLoginContext(t *testing.T) *loginContext { + chainIds := []int{4} + + cfg := &config.ClientAPI{ + Matrix: &config.Global{ + ServerName: test.TestServerName, + }, + Derived: &config.Derived{}, + PasswordAuthenticationDisabled: true, + PublicKeyAuthentication: config.PublicKeyAuthentication{ + Ethereum: config.EthereumAuthConfig{ + Enabled: true, + Version: 1, + ChainIDs: chainIds, + }, + }, + } + + pkFlows := cfg.PublicKeyAuthentication.GetPublicKeyRegistrationFlows() + cfg.Derived.Registration.Flows = append(cfg.Derived.Registration.Flows, pkFlows...) + pkParams := cfg.PublicKeyAuthentication.GetPublicKeyRegistrationParams() + cfg.Derived.Registration.Params = mapsutil.MapsUnion(cfg.Derived.Registration.Params, pkParams) + + var userAPI fakePublicKeyUserApi + var loginApi uapi.UserLoginAPI + + userInteractive := NewUserInteractive( + loginApi, + &userAPI, + cfg) + + return &loginContext{ + config: cfg, + userInteractive: userInteractive, + } + +} + +type fakePublicKeyUserApi struct { + UserInternalAPIForLogin + uapi.UserLoginAPI + uapi.ClientUserAPI + DeletedTokens []string +} + +func (ua *fakePublicKeyUserApi) QueryAccountAvailability(ctx context.Context, req *uapi.QueryAccountAvailabilityRequest, res *uapi.QueryAccountAvailabilityResponse) error { + if req.Localpart == "does_not_exist" { + res.Available = true + return nil + } + + res.Available = false + return nil +} + +func (ua *fakePublicKeyUserApi) QueryAccountByPassword(ctx context.Context, req *uapi.QueryAccountByPasswordRequest, res *uapi.QueryAccountByPasswordResponse) error { + if req.PlaintextPassword == "invalidpassword" { + res.Account = nil + return nil + } + res.Exists = true + res.Account = &uapi.Account{} + return nil +} + +func (ua *fakePublicKeyUserApi) PerformLoginTokenDeletion(ctx context.Context, req *uapi.PerformLoginTokenDeletionRequest, res *uapi.PerformLoginTokenDeletionResponse) error { + ua.DeletedTokens = append(ua.DeletedTokens, req.Token) + return nil +} + +func (ua *fakePublicKeyUserApi) PerformLoginTokenCreation(ctx context.Context, req *uapi.PerformLoginTokenCreationRequest, res *uapi.PerformLoginTokenCreationResponse) error { + return nil +} + +func (*fakePublicKeyUserApi) QueryLoginToken(ctx context.Context, req *uapi.QueryLoginTokenRequest, res *uapi.QueryLoginTokenResponse) error { + if req.Token == "invalidtoken" { + return nil + } + + res.Data = &uapi.LoginTokenData{UserID: "@auser:example.com"} + return nil +} + +func publicKeyTestSession( + ctx *context.Context, + cfg *config.ClientAPI, + userInteractive *UserInteractive, + userAPI *fakePublicKeyUserApi, + +) string { + emptyAuth := struct { + Body string + }{ + Body: `{ + "type": "m.login.publickey" + }`, + } + + _, cleanup, err := LoginFromJSONReader( + *ctx, + strings.NewReader(emptyAuth.Body), + userAPI, + userAPI, + userAPI, + userInteractive, + cfg) + + if cleanup != nil { + cleanup(*ctx, nil) + } + + json := err.JSON.(Challenge) + return json.Session +} + +func TestLoginPublicKeyEthereum(t *testing.T) { + // Setup + var userAPI fakePublicKeyUserApi + ctx := context.Background() + loginContext := createLoginContext(t) + wallet, _ := test.CreateTestAccount() + message, _ := test.CreateEip4361TestMessage(wallet.PublicAddress) + signature, _ := test.SignMessage(message.String(), wallet.PrivateKey) + sessionId := publicKeyTestSession( + &ctx, + loginContext.config, + loginContext.userInteractive, + &userAPI, + ) + + // Escape \t and \n. Work around for marshalling and unmarshalling message. + msgStr := test.FromEip4361MessageToString(message) + body := fmt.Sprintf(`{ + "type": "m.login.publickey", + "auth": { + "type": "m.login.publickey.ethereum", + "session": "%v", + "user_id": "%v", + "message": "%v", + "signature": "%v" + } + }`, + sessionId, + wallet.Eip155UserId, + msgStr, + signature, + ) + test := struct { + Body string + }{ + Body: body, + } + + // Test + login, cleanup, err := LoginFromJSONReader( + ctx, + strings.NewReader(test.Body), + &userAPI, + &userAPI, + &userAPI, + loginContext.userInteractive, + loginContext.config) + + if cleanup != nil { + cleanup(ctx, nil) + } + + // Asserts + assert := assert.New(t) + assert.Nilf(err, "err actual: %v, expected: nil", err) + assert.NotNil(login, "login: actual: nil, expected: not nil") + assert.Truef( + login.Identifier.Type == "m.id.decentralizedid", + "login.Identifier.Type actual: %v, expected: %v", login.Identifier.Type, "m.id.decentralizedid") + walletAddress := strings.ToLower(wallet.Eip155UserId) + assert.Truef( + login.Identifier.User == walletAddress, + "login.Identifier.User actual: %v, expected: %v", login.Identifier.User, walletAddress) +} + +func TestLoginPublicKeyEthereumMissingSignature(t *testing.T) { + // Setup + var userAPI fakePublicKeyUserApi + ctx := context.Background() + loginContext := createLoginContext(t) + wallet, _ := test.CreateTestAccount() + message, _ := test.CreateEip4361TestMessage(wallet.PublicAddress) + sessionId := publicKeyTestSession( + &ctx, + loginContext.config, + loginContext.userInteractive, + &userAPI, + ) + + // Escape \t and \n. Work around for marshalling and unmarshalling message. + msgStr := test.FromEip4361MessageToString(message) + body := fmt.Sprintf(`{ + "type": "m.login.publickey", + "auth": { + "type": "m.login.publickey.ethereum", + "session": "%v", + "user_id": "%v", + "message": "%v" + } + }`, + sessionId, + wallet.Eip155UserId, + msgStr, + ) + test := struct { + Body string + }{ + Body: body, + } + + // Test + _, cleanup, err := LoginFromJSONReader( + ctx, + strings.NewReader(test.Body), + &userAPI, + &userAPI, + &userAPI, + loginContext.userInteractive, + loginContext.config) + + if cleanup != nil { + cleanup(ctx, nil) + } + + // Asserts + assert := assert.New(t) + assert.Truef( + err.Code == http.StatusUnauthorized, + "err.Code actual: %v, expected: %v", err.Code, http.StatusUnauthorized) + json := err.JSON.(*jsonerror.MatrixError) + expectedErr := jsonerror.InvalidSignature("") + assert.Truef( + json.ErrCode == expectedErr.ErrCode, + "err.JSON.ErrCode actual: %v, expected: %v", json.ErrCode, expectedErr.ErrCode) +} + +func TestLoginPublicKeyEthereumEmptyMessage(t *testing.T) { + // Setup + var userAPI fakePublicKeyUserApi + ctx := context.Background() + loginContext := createLoginContext(t) + wallet, _ := test.CreateTestAccount() + sessionId := publicKeyTestSession( + &ctx, + loginContext.config, + loginContext.userInteractive, + &userAPI, + ) + + body := fmt.Sprintf(`{ + "type": "m.login.publickey", + "auth": { + "type": "m.login.publickey.ethereum", + "session": "%v", + "user_id": "%v" + } + }`, sessionId, wallet.Eip155UserId) + test := struct { + Body string + }{ + Body: body, + } + + // Test + _, cleanup, err := LoginFromJSONReader( + ctx, + strings.NewReader(test.Body), + &userAPI, + &userAPI, + &userAPI, + loginContext.userInteractive, + loginContext.config) + + if cleanup != nil { + cleanup(ctx, nil) + } + + // Asserts + assert := assert.New(t) + assert.Truef( + err.Code == http.StatusUnauthorized, + "err.Code actual: %v, expected: %v", err.Code, http.StatusUnauthorized) + json := err.JSON.(*jsonerror.MatrixError) + expectedErr := jsonerror.InvalidParam("") + assert.Truef( + json.ErrCode == expectedErr.ErrCode, + "err.JSON.ErrCode actual: %v, expected: %v", json.ErrCode, expectedErr.ErrCode) +} + +func TestLoginPublicKeyEthereumWrongUserId(t *testing.T) { + // Setup + var userAPI fakePublicKeyUserApi + ctx := context.Background() + loginContext := createLoginContext(t) + wallet, _ := test.CreateTestAccount() + sessionId := publicKeyTestSession( + &ctx, + loginContext.config, + loginContext.userInteractive, + &userAPI, + ) + + body := fmt.Sprintf(`{ + "type": "m.login.publickey", + "auth": { + "type": "m.login.publickey.ethereum", + "session": "%v", + "user_id": "%v" + } + }`, + sessionId, + wallet.PublicAddress) + test := struct { + Body string + }{ + Body: body, + } + + // Test + _, cleanup, err := LoginFromJSONReader( + ctx, + strings.NewReader(test.Body), + &userAPI, + &userAPI, + &userAPI, + loginContext.userInteractive, + loginContext.config) + + if cleanup != nil { + cleanup(ctx, nil) + } + + // Asserts + assert := assert.New(t) + assert.Truef( + err.Code == http.StatusForbidden, + "err.Code actual: %v, expected: %v", err.Code, http.StatusForbidden) +} + +func TestLoginPublicKeyEthereumMissingUserId(t *testing.T) { + // Setup + var userAPI fakePublicKeyUserApi + ctx := context.Background() + loginContext := createLoginContext(t) + sessionId := publicKeyTestSession( + &ctx, + loginContext.config, + loginContext.userInteractive, + &userAPI, + ) + + body := fmt.Sprintf(`{ + "type": "m.login.publickey", + "auth": { + "type": "m.login.publickey.ethereum", + "session": "%v" + } + }`, sessionId) + test := struct { + Body string + }{ + Body: body, + } + + // Test + _, cleanup, err := LoginFromJSONReader( + ctx, + strings.NewReader(test.Body), + &userAPI, + &userAPI, + &userAPI, + loginContext.userInteractive, + loginContext.config) + + if cleanup != nil { + cleanup(ctx, nil) + } + + // Asserts + assert := assert.New(t) + assert.Truef( + err.Code == http.StatusForbidden, + "err.Code actual: %v, expected: %v", err.Code, http.StatusForbidden) +} + +func TestLoginPublicKeyEthereumAccountNotAvailable(t *testing.T) { + // Setup + var userAPI fakePublicKeyUserApi + ctx := context.Background() + loginContext := createLoginContext(t) + sessionId := publicKeyTestSession( + &ctx, + loginContext.config, + loginContext.userInteractive, + &userAPI, + ) + + body := fmt.Sprintf(`{ + "type": "m.login.publickey", + "auth": { + "type": "m.login.publickey.ethereum", + "session": "%v", + "user_id": "does_not_exist" + } + }`, sessionId) + test := struct { + Body string + }{ + Body: body, + } + + // Test + _, cleanup, err := LoginFromJSONReader( + ctx, + strings.NewReader(test.Body), + &userAPI, + &userAPI, + &userAPI, + loginContext.userInteractive, + loginContext.config) + + if cleanup != nil { + cleanup(ctx, nil) + } + + // Asserts + assert := assert.New(t) + assert.Truef( + err.Code == http.StatusForbidden, + "err.Code actual: %v, expected: %v", err.Code, http.StatusForbidden) +} diff --git a/clientapi/auth/login_publickey_test.go b/clientapi/auth/login_publickey_test.go new file mode 100644 index 000000000..321d8eb6c --- /dev/null +++ b/clientapi/auth/login_publickey_test.go @@ -0,0 +1,161 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "context" + "net/http" + "strings" + "testing" + + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/setup/config" + "github.com/stretchr/testify/assert" +) + +func TestLoginPublicKeyNewSession(t *testing.T) { + // Setup + var userAPI fakePublicKeyUserApi + ctx := context.Background() + loginContext := createLoginContext(t) + + test := struct { + Body string + }{ + Body: `{ "type": "m.login.publickey" }`, + } + + // Test + login, cleanup, err := LoginFromJSONReader( + ctx, + strings.NewReader(test.Body), + &userAPI, + &userAPI, + &userAPI, + loginContext.userInteractive, + loginContext.config) + + if cleanup != nil { + cleanup(ctx, nil) + } + + // Asserts + assert := assert.New(t) + assert.NotNilf( + err, + "err actual: not nil returned %+v, expected: nil", login) + assert.Truef( + err.Code == http.StatusUnauthorized, + "err.Code actual: %v, expected: %v", err.Code, http.StatusUnauthorized) + challenge := err.JSON.(Challenge) + assert.NotEmptyf(challenge.Session, "challenge.Session") + assert.NotEmptyf(challenge.Completed, "challenge.Completed") + assert.Truef( + authtypes.LoginTypePublicKeyEthereum == challenge.Flows[0].Stages[0], + "challenge.Flows[0].Stages[0] actual: %v, expected: %v", challenge.Flows[0].Stages[0], authtypes.LoginTypePublicKeyEthereum) + params := challenge.Params[authtypes.LoginTypePublicKeyEthereum] + assert.NotEmptyf( + params, + "challenge.Params[\"%v\"] actual %v, expected %v", + authtypes.LoginTypePublicKeyEthereum, + params, + "[object]") + ethParams := params.(config.EthereumAuthParams) + assert.NotEmptyf(ethParams.ChainIDs, "ChainIDs actual: empty, expected not empty") + assert.NotEmptyf(ethParams.Nonce, "Nonce actual: \"\", expected: not empty") + assert.NotEmptyf(ethParams.Version, "Version actual: \"\", expected: not empty") +} + +func TestLoginPublicKeyInvalidSessionId(t *testing.T) { + // Setup + var userAPI fakePublicKeyUserApi + ctx := context.Background() + loginContext := createLoginContext(t) + + test := struct { + Body string + }{ + Body: `{ + "type": "m.login.publickey", + "auth": { + "type": "m.login.publickey.ethereum", + "session": "invalid_session_id" + } + }`, + } + + // Test + _, cleanup, err := LoginFromJSONReader( + ctx, + strings.NewReader(test.Body), + &userAPI, + &userAPI, + &userAPI, + loginContext.userInteractive, + loginContext.config) + + if cleanup != nil { + cleanup(ctx, nil) + } + + // Asserts + assert := assert.New(t) + assert.Truef( + err.Code == http.StatusUnauthorized, + "err.Code actual %v, expected %v", err.Code, http.StatusUnauthorized) +} + +func TestLoginPublicKeyInvalidAuthType(t *testing.T) { + // Setup + var userAPI fakePublicKeyUserApi + ctx := context.Background() + loginContext := createLoginContext(t) + + test := struct { + Body string + }{ + Body: `{ + "type": "m.login.publickey", + "auth": { + "type": "m.login.publickey.someAlgo" + } + }`, + } + + // Test + _, cleanup, err := LoginFromJSONReader( + ctx, + strings.NewReader(test.Body), + &userAPI, + &userAPI, + &userAPI, + loginContext.userInteractive, + loginContext.config) + + if cleanup != nil { + cleanup(ctx, nil) + } + + // Asserts + assert := assert.New(t) + assert.NotNil(err, "Expected an err response.actual: nil") + assert.Truef( + err.Code == http.StatusUnauthorized, + "err.Code actual %v, expected %v", err.Code, http.StatusUnauthorized) + _, ok := err.JSON.(Challenge) + assert.False( + ok, + "should not return a Challenge response") +} diff --git a/clientapi/auth/user_interactive.go b/clientapi/auth/user_interactive.go index 4dbf23320..ccf991f86 100644 --- a/clientapi/auth/user_interactive.go +++ b/clientapi/auth/user_interactive.go @@ -173,13 +173,13 @@ type Challenge struct { func (u *UserInteractive) Challenge(sessionID string) *util.JSONResponse { paramsCopy := mapsutil.MapCopy(u.Params) for key, element := range paramsCopy { - p := getAuthParams(element) + 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. + // If an auth flow generated a nonce, add it to the session. nonce := getAuthParamNonce(p) if nonce != "" { u.Sessions[sessionID] = append(u.Sessions[sessionID], nonce) @@ -280,7 +280,7 @@ func (u *UserInteractive) Verify(ctx context.Context, bodyBytes []byte, device * return login, nil } -func getAuthParams(params interface{}) interface{} { +func GetAuthParams(params interface{}) interface{} { v, ok := params.(config.AuthParams) if ok { p := v.GetParams() diff --git a/clientapi/routing/register.go b/clientapi/routing/register.go index af8d14f13..b08e5a047 100644 --- a/clientapi/routing/register.go +++ b/clientapi/routing/register.go @@ -45,6 +45,7 @@ import ( "github.com/matrix-org/dendrite/clientapi/httputil" "github.com/matrix-org/dendrite/clientapi/jsonerror" "github.com/matrix-org/dendrite/clientapi/userutil" + "github.com/matrix-org/dendrite/internal/mapsutil" userapi "github.com/matrix-org/dendrite/userapi/api" ) @@ -247,7 +248,7 @@ type authDict struct { } // http://matrix.org/speculator/spec/HEAD/client_server/unstable.html#user-interactive-authentication-api -type userInteractiveResponse struct { +type UserInteractiveResponse struct { Flows []authtypes.Flow `json:"flows"` Completed []authtypes.LoginType `json:"completed"` Params map[string]interface{} `json:"params"` @@ -260,9 +261,18 @@ func newUserInteractiveResponse( sessionID string, fs []authtypes.Flow, params map[string]interface{}, -) userInteractiveResponse { - return userInteractiveResponse{ - fs, sessions.getCompletedStages(sessionID), params, sessionID, +) UserInteractiveResponse { + paramsCopy := mapsutil.MapCopy(params) + for key, element := range paramsCopy { + p := auth.GetAuthParams(element) + if p != nil { + // If an auth flow has params, make a new copy + // and send it as part of the response. + paramsCopy[key] = p + } + } + return UserInteractiveResponse{ + fs, sessions.getCompletedStages(sessionID), paramsCopy, sessionID, } } diff --git a/clientapi/routing/register_publickey.go b/clientapi/routing/register_publickey.go index f5c972bb1..119065981 100644 --- a/clientapi/routing/register_publickey.go +++ b/clientapi/routing/register_publickey.go @@ -69,7 +69,7 @@ func handlePublicKeyRegistration( } } - isValidUserId := authHandler.IsValidUserIdForRegistration(r.Username) + isValidUserId := authHandler.IsValidUserId(r.Username) if !isValidUserId { return false, "", &util.JSONResponse{ Code: http.StatusUnauthorized, diff --git a/clientapi/routing/register_publickey_test.go b/clientapi/routing/register_publickey_test.go new file mode 100644 index 000000000..688769e89 --- /dev/null +++ b/clientapi/routing/register_publickey_test.go @@ -0,0 +1,386 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package routing + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/matrix-org/dendrite/clientapi/auth" + "github.com/matrix-org/dendrite/clientapi/auth/authtypes" + "github.com/matrix-org/dendrite/internal/mapsutil" + "github.com/matrix-org/dendrite/setup/config" + "github.com/matrix-org/dendrite/test" + "github.com/matrix-org/dendrite/userapi/api" + uapi "github.com/matrix-org/dendrite/userapi/api" + "github.com/matrix-org/util" + "github.com/stretchr/testify/assert" +) + +const testCaip10UserId = "eip155=3a1=3a0xab16a96d359ec26a11e2c2b3d8f8b8942d5bfcdb" + +type registerContext struct { + config *config.ClientAPI + userInteractive *auth.UserInteractive +} + +func createRegisterContext(t *testing.T) *registerContext { + chainIds := []int{4} + + cfg := &config.ClientAPI{ + Matrix: &config.Global{ + ServerName: test.TestServerName, + }, + Derived: &config.Derived{}, + PasswordAuthenticationDisabled: true, + PublicKeyAuthentication: config.PublicKeyAuthentication{ + Ethereum: config.EthereumAuthConfig{ + Enabled: true, + Version: 1, + ChainIDs: chainIds, + }, + }, + } + + pkFlows := cfg.PublicKeyAuthentication.GetPublicKeyRegistrationFlows() + cfg.Derived.Registration.Flows = append(cfg.Derived.Registration.Flows, pkFlows...) + pkParams := cfg.PublicKeyAuthentication.GetPublicKeyRegistrationParams() + cfg.Derived.Registration.Params = mapsutil.MapsUnion(cfg.Derived.Registration.Params, pkParams) + + var userAPI fakePublicKeyUserApi + var loginApi uapi.UserLoginAPI + + userInteractive := auth.NewUserInteractive( + loginApi, + &userAPI, + cfg) + + return ®isterContext{ + config: cfg, + userInteractive: userInteractive, + } + +} + +type fakeHttpRequest struct { + request *http.Request + body []byte + registerRequest registerRequest +} + +func createFakeHttpRequest(body string) *fakeHttpRequest { + var r registerRequest + req, _ := http.NewRequest(http.MethodPost, "", strings.NewReader(body)) + reqBody := []byte(body) + json.Unmarshal([]byte(body), &r) + + return &fakeHttpRequest{ + request: req, + body: reqBody, + registerRequest: r, + } +} + +type fakePublicKeyUserApi struct { + auth.UserInternalAPIForLogin + uapi.UserLoginAPI + uapi.ClientUserAPI + DeletedTokens []string +} + +func (ua *fakePublicKeyUserApi) QueryAccountAvailability(ctx context.Context, req *uapi.QueryAccountAvailabilityRequest, res *uapi.QueryAccountAvailabilityResponse) error { + if req.Localpart == "does_not_exist" { + res.Available = true + return nil + } + + res.Available = false + return nil +} + +func (ua *fakePublicKeyUserApi) QueryAccountByPassword(ctx context.Context, req *uapi.QueryAccountByPasswordRequest, res *uapi.QueryAccountByPasswordResponse) error { + if req.PlaintextPassword == "invalidpassword" { + res.Account = nil + return nil + } + res.Exists = true + res.Account = &uapi.Account{} + return nil +} + +func (ua *fakePublicKeyUserApi) PerformDeviceCreation( + ctx context.Context, + req *uapi.PerformDeviceCreationRequest, + res *uapi.PerformDeviceCreationResponse) error { + res.DeviceCreated = true + res.Device = &api.Device{ + ID: "device_id", + UserID: req.Localpart, + AccessToken: req.AccessToken, + } + return nil +} + +func (ua *fakePublicKeyUserApi) PerformAccountCreation( + ctx context.Context, + req *uapi.PerformAccountCreationRequest, + res *uapi.PerformAccountCreationResponse) error { + res.AccountCreated = true + res.Account = &api.Account{ + AppServiceID: req.AppServiceID, + Localpart: req.Localpart, + ServerName: test.TestServerName, + UserID: fmt.Sprintf("@%s:%s", req.Localpart, test.TestServerName), + AccountType: req.AccountType, + } + return nil +} + +func (ua *fakePublicKeyUserApi) PerformLoginTokenDeletion(ctx context.Context, req *uapi.PerformLoginTokenDeletionRequest, res *uapi.PerformLoginTokenDeletionResponse) error { + ua.DeletedTokens = append(ua.DeletedTokens, req.Token) + return nil +} + +func (ua *fakePublicKeyUserApi) PerformLoginTokenCreation(ctx context.Context, req *uapi.PerformLoginTokenCreationRequest, res *uapi.PerformLoginTokenCreationResponse) error { + return nil +} + +func (*fakePublicKeyUserApi) QueryLoginToken(ctx context.Context, req *uapi.QueryLoginTokenRequest, res *uapi.QueryLoginTokenResponse) error { + if req.Token == "invalidtoken" { + return nil + } + + res.Data = &uapi.LoginTokenData{UserID: "@auser:example.com"} + return nil +} + +func newRegistrationSession( + t *testing.T, + userId string, + cfg *config.ClientAPI, + userInteractive *auth.UserInteractive, + userAPI *fakePublicKeyUserApi, +) string { + body := fmt.Sprintf(`{ + "auth": { + "type": "m.login.publickey", + "username": "%v" + } + }`, + userId) + + test := struct { + Body string + }{ + Body: body, + } + + fakeReq := createFakeHttpRequest(test.Body) + sessionID := util.RandomString(sessionIDLength) + registerContext := createRegisterContext(t) + + // Test + response := handleRegistrationFlow( + fakeReq.request, + fakeReq.body, + fakeReq.registerRequest, + sessionID, + registerContext.config, + userAPI, + "", + nil, + ) + + json := response.JSON.(UserInteractiveResponse) + return json.Session +} + +func TestRegisterEthereum(t *testing.T) { + // Setup + var userAPI fakePublicKeyUserApi + wallet, _ := test.CreateTestAccount() + message, _ := test.CreateEip4361TestMessage(wallet.PublicAddress) + signature, _ := test.SignMessage(message.String(), wallet.PrivateKey) + registerContext := createRegisterContext(t) + sessionId := newRegistrationSession( + t, + wallet.Eip155UserId, + registerContext.config, + registerContext.userInteractive, + &userAPI, + ) + + // Escape \t and \n. Work around for marshalling and unmarshalling message. + msgStr := test.FromEip4361MessageToString(message) + body := fmt.Sprintf(`{ + "username": "%v", + "auth": { + "type": "m.login.publickey", + "session": "%v", + "public_key_response": { + "type": "m.login.publickey.ethereum", + "session": "%v", + "user_id": "%v", + "message": "%v", + "signature": "%v" + } + } + }`, + wallet.Eip155UserId, + sessionId, + sessionId, + wallet.Eip155UserId, + msgStr, + signature, + ) + test := struct { + Body string + }{ + Body: body, + } + + fakeReq := createFakeHttpRequest(test.Body) + + // Test + response := handleRegistrationFlow( + fakeReq.request, + fakeReq.body, + fakeReq.registerRequest, + sessionId, + registerContext.config, + &userAPI, + "", + nil, + ) + + // Asserts + assert := assert.New(t) + assert.NotNil(response, "response actual: nil, expected: not nil") + registerRes := response.JSON.(registerResponse) + assert.Truef( + registerRes.UserID == wallet.Eip155UserId, + "registerRes.UserID actual: %v, expected: %v", registerRes.UserID, wallet.Eip155UserId) + assert.NotEmptyf( + registerRes.AccessToken, + "registerRes.AccessToken actual: empty, expected: not empty") +} + +func TestNewRegistrationSession(t *testing.T) { + // Setup + var userAPI fakePublicKeyUserApi + + body := fmt.Sprintf(`{ + "auth": { + "type": "m.login.publickey", + "username": "%v" + } + }`, + testCaip10UserId) + + test := struct { + Body string + }{ + Body: body, + } + + fakeReq := createFakeHttpRequest(test.Body) + sessionID := util.RandomString(sessionIDLength) + registerContext := createRegisterContext(t) + + // Test + response := handleRegistrationFlow( + fakeReq.request, + fakeReq.body, + fakeReq.registerRequest, + sessionID, + registerContext.config, + &userAPI, + "", + nil, + ) + + // Asserts + assert := assert.New(t) + assert.NotNilf(response, "response not nil") + assert.Truef( + response.Code == http.StatusUnauthorized, + "response.Code actual %v, expected %v", response.Code, http.StatusUnauthorized) + json := response.JSON.(UserInteractiveResponse) + assert.NotEmptyf(json.Session, "response.Session") + assert.NotEmptyf(json.Completed, "response.Completed") + assert.Truef( + json.Completed[0] == authtypes.LoginStagePublicKeyNewRegistration, + "response.Completed[0] actual %v, expected %v", json.Completed[0], authtypes.LoginStagePublicKeyNewRegistration) + assert.Truef( + authtypes.LoginTypePublicKeyEthereum == json.Flows[0].Stages[0], + "response.Flows[0].Stages[0] actual: %v, expected: %v", json.Flows[0].Stages[0], authtypes.LoginTypePublicKeyEthereum) + + params := json.Params[authtypes.LoginTypePublicKeyEthereum] + assert.NotEmptyf( + params, + "response.Params[\"%v\"] actual %v, expected %v", + authtypes.LoginTypePublicKeyEthereum, + params, + "[object]") + ethParams := params.(config.EthereumAuthParams) + assert.NotEmptyf(ethParams.ChainIDs, "ChainIDs actual: empty, expected not empty") + assert.NotEmptyf(ethParams.Nonce, "Nonce actual: \"\", expected: not empty") + assert.NotEmptyf(ethParams.Version, "Version actual: \"\", expected: not empty") +} + +func TestRegistrationUnimplementedAlgo(t *testing.T) { + // Setup + var userAPI fakePublicKeyUserApi + body := fmt.Sprintf(`{ + "auth": { + "type": "m.login.publickey.someAlgo", + "username": "%v" + } + }`, + testCaip10UserId) + + test := struct { + Body string + }{ + Body: body, + } + + fakeReq := createFakeHttpRequest(test.Body) + sessionID := util.RandomString(sessionIDLength) + registerContext := createRegisterContext(t) + + // Test + response := handleRegistrationFlow( + fakeReq.request, + fakeReq.body, + fakeReq.registerRequest, + sessionID, + registerContext.config, + &userAPI, + "", + nil, + ) + + // Asserts + assert := assert.New(t) + assert.NotNilf(response, "response not nil") + assert.Truef( + response.Code == http.StatusNotImplemented, + "response.Code actual %v, expected %v", response.Code, http.StatusNotImplemented) +} diff --git a/go.mod b/go.mod index 415893151..c9170a861 100644 --- a/go.mod +++ b/go.mod @@ -59,7 +59,7 @@ require ( nhooyr.io/websocket v1.8.7 ) -require github.com/ethereum/go-ethereum v1.10.15 // indirect +require github.com/ethereum/go-ethereum v1.10.15 require ( github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect diff --git a/setup/config/config_clientapi.go b/setup/config/config_clientapi.go index 7d8dc764b..ac7a5b04b 100644 --- a/setup/config/config_clientapi.go +++ b/setup/config/config_clientapi.go @@ -54,7 +54,7 @@ type ClientAPI struct { PasswordAuthenticationDisabled bool `yaml:"password_authentication_disabled"` // Public key authentication - PublicKeyAuthentication publicKeyAuthentication `yaml:"public_key_authentication"` + PublicKeyAuthentication PublicKeyAuthentication `yaml:"public_key_authentication"` } func (c *ClientAPI) Defaults(generate bool) { diff --git a/setup/config/config_publickey.go b/setup/config/config_publickey.go index 9820a5969..ae19d16f6 100644 --- a/setup/config/config_publickey.go +++ b/setup/config/config_publickey.go @@ -32,21 +32,21 @@ func (p EthereumAuthParams) GetNonce() string { return p.Nonce } -type ethereumAuthConfig struct { +type EthereumAuthConfig struct { Enabled bool `yaml:"enabled"` Version uint `yaml:"version"` ChainIDs []int `yaml:"chain_ids"` } -type publicKeyAuthentication struct { - Ethereum ethereumAuthConfig `yaml:"ethereum"` +type PublicKeyAuthentication struct { + Ethereum EthereumAuthConfig `yaml:"ethereum"` } -func (pk *publicKeyAuthentication) Enabled() bool { +func (pk *PublicKeyAuthentication) Enabled() bool { return pk.Ethereum.Enabled } -func (pk *publicKeyAuthentication) GetPublicKeyRegistrationFlows() []authtypes.Flow { +func (pk *PublicKeyAuthentication) GetPublicKeyRegistrationFlows() []authtypes.Flow { var flows []authtypes.Flow if pk.Ethereum.Enabled { flows = append(flows, authtypes.Flow{Stages: []authtypes.LoginType{authtypes.LoginTypePublicKeyEthereum}}) @@ -55,7 +55,7 @@ func (pk *publicKeyAuthentication) GetPublicKeyRegistrationFlows() []authtypes.F return flows } -func (pk *publicKeyAuthentication) GetPublicKeyRegistrationParams() map[string]interface{} { +func (pk *PublicKeyAuthentication) GetPublicKeyRegistrationParams() map[string]interface{} { params := make(map[string]interface{}) if pk.Ethereum.Enabled { p := EthereumAuthParams{ diff --git a/test/publickey_utils.go b/test/publickey_utils.go new file mode 100644 index 000000000..6d3a67186 --- /dev/null +++ b/test/publickey_utils.go @@ -0,0 +1,108 @@ +// Copyright 2022 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package test + +import ( + "crypto/ecdsa" + "errors" + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/spruceid/siwe-go" +) + +const EthereumTestNetworkId = 4 // Rinkeby test network ID +const TestServerName = "localhost" + +type EthereumTestWallet struct { + Eip155UserId string + PublicAddress string + PrivateKey *ecdsa.PrivateKey +} + +// https://goethereumbook.org/wallet-generate/ +func CreateTestAccount() (*EthereumTestWallet, error) { + // Create a new public / private key pair. + privateKey, err := crypto.GenerateKey() + if err != nil { + return nil, err + } + + // Get the public key + publicKey := privateKey.Public() + + // Transform public key to the Ethereum address + publicKeyEcdsa, ok := publicKey.(*ecdsa.PublicKey) + if !ok { + return nil, errors.New("error casting public key to ECDSA") + } + + address := crypto.PubkeyToAddress(*publicKeyEcdsa).Hex() + eip155UserId := fmt.Sprintf("eip155=3a%d=3a%s", EthereumTestNetworkId, address) + + return &EthereumTestWallet{ + PublicAddress: address, + PrivateKey: privateKey, + Eip155UserId: eip155UserId, + }, + nil +} + +func CreateEip4361TestMessage( + publicAddress string, +) (*siwe.Message, error) { + options := make(map[string]interface{}) + options["chainId"] = 4 // Rinkeby test network + options["statement"] = "This is a test statement" + message, err := siwe.InitMessage( + TestServerName, + publicAddress, + "https://localhost/login", + siwe.GenerateNonce(), + options, + ) + + if err != nil { + return nil, err + } + + return message, nil +} + +func FromEip4361MessageToString(message *siwe.Message) string { + // Escape the formatting characters to + // prevent unmarshal exceptions. + str := strings.ReplaceAll(message.String(), "\n", "\\n") + str = strings.ReplaceAll(str, "\t", "\\t") + return str +} + +// https://goethereumbook.org/signature-generate/ +func SignMessage(message string, privateKey *ecdsa.PrivateKey) (string, error) { + msg := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message) + data := []byte(msg) + hash := crypto.Keccak256Hash(data) + + signature, err := crypto.Sign(hash.Bytes(), privateKey) + if err != nil { + return "", err + } + + // https://github.com/ethereum/go-ethereum/blob/55599ee95d4151a2502465e0afc7c47bd1acba77/internal/ethapi/api.go#L442 + signature[64] += 27 // Transform V from 0/1 to 27/28 according to the yellow paper + return hexutil.Encode(signature), nil +}