diff --git a/README.md b/README.md index e3fc10f..a8dfe96 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Go-NEB is a [Matrix](https://matrix.org) bot written in Go. It is the successor * [Configuring clients](#configuring-clients) * [Configuring services](#configuring-services) * [Configuring realms](#configuring-realms) + * [SAS verification](#sas-verification) * [Developing](#developing) * [Architecture](#architecture) * [API Docs](#viewing-the-api-docs) @@ -178,6 +179,22 @@ Authentication via the config file: - [Github](https://matrix-org.github.io/go-neb/pkg/github.com/matrix-org/go-neb/realms/github/index.html#Session) - [JIRA](https://matrix-org.github.io/go-neb/pkg/github.com/matrix-org/go-neb/realms/jira/index.html#Session) +## SAS verification +Go-NEB supports SAS verification using the decimal method. Another user can start a verification transaction with Go-NEB using their client, and it will be accepted. In order to confirm the devices, the 3 SAS integers must then be sent to Go-NEB, to the endpoint '/verifySAS' so that it can mark the device as trusted. + +For example, if your user ID is `@user:localhost` and your device ID is `ABCD`, you start a SAS verification with Go-NEB and get the SAS "1111 2222 3333". You can perform the following curl request to let Go-NEB know the SAS integers so that it can match them with its own: + +```bash +curl -X POST --header 'Content-Type: application/json' -d '{ + "UserID": "@neb:localhost", + "OtherUserID": "@user:localhost", + "OtherDeviceID": "ABCD", + "SAS": [1111,2222,3333] +}' 'http://localhost:4050/verifySAS' +``` + +If the SAS match and you also confirm that via the other device's client, the verification should finish successfully. + # Contributing Before submitting pull requests, please read the [Matrix.org contribution guidelines](https://github.com/matrix-org/synapse/blob/develop/CONTRIBUTING.md#sign-off) regarding sign-off of your work. diff --git a/api/api.go b/api/api.go index 2b2f6de..7b5b240 100644 --- a/api/api.go +++ b/api/api.go @@ -80,6 +80,18 @@ type ClientConfig struct { DisplayName string } +// A IncomingDecimalSAS contains the decimal SAS as displayed on another device. The SAS consists of three numbers. +type IncomingDecimalSAS struct { + // The matrix User ID of the user that Neb uses in the verification process. E.g. @alice:matrix.org + UserID id.UserID + // The three numbers that the SAS consists of. + SAS [3]uint + // The matrix User ID of the other user whose device is being verified. + OtherUserID id.UserID + // The matrix Device ID of the other device that is being verified. + OtherDeviceID id.DeviceID +} + // Session contains the complete auth session information for a given user on a given realm. // They are created for use with ConfigFile. type Session struct { @@ -132,6 +144,14 @@ func (c *ClientConfig) Check() error { return nil } +// Check that the received SAS data contains the correct fields. +func (c *IncomingDecimalSAS) Check() error { + if c.UserID == "" || c.OtherUserID == "" || c.OtherDeviceID == "" { + return errors.New(`Must supply a "UserID", an "OtherUserID", and an "OtherDeviceID"`) + } + return nil +} + // Check that the request is valid. func (r *RequestAuthSessionRequest) Check() error { if r.UserID == "" || r.RealmID == "" || r.Config == nil { diff --git a/api/handlers/client.go b/api/handlers/client.go index 3e00a20..b906a90 100644 --- a/api/handlers/client.go +++ b/api/handlers/client.go @@ -7,6 +7,7 @@ import ( "github.com/matrix-org/go-neb/api" "github.com/matrix-org/go-neb/clients" "github.com/matrix-org/util" + "maunium.net/go/mautrix/crypto" ) // ConfigureClient represents an HTTP handler capable of processing /admin/configureClient requests. @@ -67,3 +68,55 @@ func (s *ConfigureClient) OnIncomingRequest(req *http.Request) util.JSONResponse }{oldClient, body}, } } + +// VerifySAS represents an HTTP handler capable of processing /verifySAS requests. +type VerifySAS struct { + Clients *clients.Clients +} + +// OnIncomingRequest handles POST requests to /verifySAS. The JSON object provided +// is of type "api.IncomingDecimalSAS". +// +// The request should contain the three decimal SAS numbers as displayed on the other device that is being verified, +// as well as that device's user and device ID. +// It should also contain the user ID that Go-NEB's client is using. +// +// Request: +// POST /verifySAS +// { +// "UserID": "@my_bot:localhost", // Neb's user ID +// "OtherUserID": "@user:localhost", // User ID of device we're verifying with +// "OtherDeviceID": "ABCDEFG", // Device ID of device we're verifying with +// "SAS": [1111, 2222, 3333] // SAS displayed on device we're verifying with +// } +// +// Response: +// HTTP/1.1 200 OK +// {} +func (s *VerifySAS) OnIncomingRequest(req *http.Request) util.JSONResponse { + if req.Method != "POST" { + return util.MessageResponse(405, "Unsupported Method") + } + + var body api.IncomingDecimalSAS + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + return util.MessageResponse(400, "Error parsing request JSON") + } + + if err := body.Check(); err != nil { + return util.MessageResponse(400, "Error parsing client config") + } + + client, err := s.Clients.Client(body.UserID) + if err != nil { + util.GetLogger(req.Context()).WithError(err).WithField("body", body).Error("Failed to load client") + return util.MessageResponse(500, "Error storing SAS") + } + + client.SubmitDecimalSAS(body.OtherUserID, body.OtherDeviceID, crypto.DecimalSASData(body.SAS)) + + return util.JSONResponse{ + Code: 200, + JSON: struct{}{}, + } +} diff --git a/clients/bot_client.go b/clients/bot_client.go index 2c95e5c..1c0d972 100644 --- a/clients/bot_client.go +++ b/clients/bot_client.go @@ -1,6 +1,7 @@ package clients import ( + "sync" "time" "github.com/matrix-org/go-neb/api" @@ -9,6 +10,7 @@ import ( log "github.com/sirupsen/logrus" "maunium.net/go/mautrix" "maunium.net/go/mautrix/crypto" + "maunium.net/go/mautrix/event" mevt "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -18,9 +20,10 @@ import ( // the client has joined. type BotClient struct { *mautrix.Client - config api.ClientConfig - olmMachine *crypto.OlmMachine - stateStore *NebStateStore + config api.ClientConfig + olmMachine *crypto.OlmMachine + stateStore *NebStateStore + verificationSAS *sync.Map } // InitOlmMachine initializes a BotClient's internal OlmMachine given a client object and a Neb store, @@ -54,6 +57,9 @@ func (botClient *BotClient) InitOlmMachine(client *mautrix.Client, nebStore *mat botClient.stateStore = &NebStateStore{&nebStore.InMemoryStore} olmMachine := crypto.NewOlmMachine(client, cryptoLogger, cryptoStore, botClient.stateStore) + olmMachine.AcceptVerificationFrom = func(_ string, _ *crypto.DeviceIdentity) (crypto.VerificationRequestResponse, crypto.VerificationHooks) { + return crypto.AcceptRequest, botClient + } if err = olmMachine.Load(); err != nil { return } @@ -142,3 +148,69 @@ func (botClient *BotClient) Sync() { } } } + +// VerifySASMatch returns whether the received SAS matches the SAS that the bot generated. +// It retrieves the SAS of the other device from the bot client's SAS sync map, where it was stored by the `SubmitDecimalSAS` function. +func (botClient *BotClient) VerifySASMatch(otherDevice *crypto.DeviceIdentity, sas crypto.SASData) bool { + log.WithFields(log.Fields{ + "otherUser": otherDevice.UserID, + "otherDevice": otherDevice.DeviceID, + }).Infof("Waiting for SAS") + if sas.Type() != event.SASDecimal { + log.Warnf("Unsupported SAS type: %v", sas.Type()) + return false + } + key := otherDevice.UserID.String() + ":" + otherDevice.DeviceID.String() + sasChan, loaded := botClient.verificationSAS.LoadOrStore(key, make(chan crypto.DecimalSASData)) + if !loaded { + // if we created the chan, delete it after the timeout duration + defer botClient.verificationSAS.Delete(key) + } + select { + case otherSAS := <-sasChan.(chan crypto.DecimalSASData): + ourSAS := sas.(crypto.DecimalSASData) + log.WithFields(log.Fields{ + "otherUser": otherDevice.UserID, + "otherDevice": otherDevice.DeviceID, + }).Warnf("Our SAS: %v, Received SAS: %v, Match: %v", ourSAS, otherSAS, ourSAS == otherSAS) + return ourSAS == otherSAS + case <-time.After(botClient.olmMachine.DefaultSASTimeout): + log.Warnf("Timed out while waiting for SAS from device %v", otherDevice.DeviceID) + } + return false +} + +// SubmitDecimalSAS stores the received decimal SAS from another device to compare to the local one. +// It stores the SAS in the bot client's SAS sync map to be retrieved from the `VerifySASMatch` function. +func (botClient *BotClient) SubmitDecimalSAS(otherUser id.UserID, otherDevice id.DeviceID, sas crypto.DecimalSASData) { + key := otherUser.String() + ":" + otherDevice.String() + sasChan, loaded := botClient.verificationSAS.LoadOrStore(key, make(chan crypto.DecimalSASData)) + go func() { + if !loaded { + // if we created the chan, delete it after the timeout duration + defer botClient.verificationSAS.Delete(key) + } + // insert to channel in goroutine to avoid blocking if we are not expecting a SAS for this user/device right now + select { + case sasChan.(chan crypto.DecimalSASData) <- crypto.DecimalSASData(sas): + case <-time.After(botClient.olmMachine.DefaultSASTimeout): + log.Warnf("Timed out while trying to send SAS for device %v", otherDevice) + } + }() +} + +// VerificationMethods returns the supported SAS verification methods. +// As a bot we only support decimal as it's easier to understand. +func (botClient *BotClient) VerificationMethods() []crypto.VerificationMethod { + return []crypto.VerificationMethod{ + crypto.VerificationMethodDecimal{}, + } +} + +// OnCancel is called when a SAS verification is canceled. +func (botClient *BotClient) OnCancel(cancelledByUs bool, reason string, reasonCode event.VerificationCancelCode) { +} + +// OnSuccess is called when a SAS verification is successful. +func (botClient *BotClient) OnSuccess() { +} diff --git a/clients/clients.go b/clients/clients.go index 33afd5f..07272fe 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -340,6 +340,7 @@ func (c *Clients) initClient(botClient *BotClient) error { log.Warn("Device ID is not set which will result in E2E encryption/decryption not working") } botClient.Client = client + botClient.verificationSAS = &sync.Map{} syncer := client.Syncer.(*mautrix.DefaultSyncer) diff --git a/clients/clients_test.go b/clients/clients_test.go index bb1267f..e4b7ce0 100644 --- a/clients/clients_test.go +++ b/clients/clients_test.go @@ -4,11 +4,14 @@ import ( "fmt" "net/http" "reflect" + "sync" "testing" + "time" "github.com/matrix-org/go-neb/database" "github.com/matrix-org/go-neb/types" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto" mevt "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" ) @@ -103,3 +106,48 @@ func TestCommandParsing(t *testing.T) { } } + +func TestSASVerificationHandling(t *testing.T) { + botClient := BotClient{verificationSAS: &sync.Map{}} + botClient.olmMachine = &crypto.OlmMachine{ + DefaultSASTimeout: time.Minute, + } + otherUserID := id.UserID("otherUser") + otherDeviceID := id.DeviceID("otherDevice") + otherDevice := &crypto.DeviceIdentity{ + UserID: otherUserID, + DeviceID: otherDeviceID, + } + botClient.SubmitDecimalSAS(otherUserID, otherDeviceID, crypto.DecimalSASData([3]uint{4, 5, 6})) + matched := botClient.VerifySASMatch(otherDevice, crypto.DecimalSASData([3]uint{1, 2, 3})) + if matched { + t.Error("SAS matched when they shouldn't have") + } + + botClient.SubmitDecimalSAS(otherUserID, otherDeviceID, crypto.DecimalSASData([3]uint{1, 2, 3})) + matched = botClient.VerifySASMatch(otherDevice, crypto.DecimalSASData([3]uint{1, 2, 3})) + if !matched { + t.Error("Expected SAS to match but they didn't") + } + + botClient.SubmitDecimalSAS(otherUserID+"wrong", otherDeviceID, crypto.DecimalSASData([3]uint{4, 5, 6})) + finished := make(chan bool) + go func() { + matched := botClient.VerifySASMatch(otherDevice, crypto.DecimalSASData([3]uint{1, 2, 3})) + finished <- true + if !matched { + t.Error("SAS didn't match when it should have (receiving SAS after calling verification func)") + } + }() + select { + case <-finished: + t.Error("Verification finished before receiving the SAS from the correct user") + default: + } + botClient.SubmitDecimalSAS(otherUserID, otherDeviceID, crypto.DecimalSASData([3]uint{1, 2, 3})) + select { + case <-finished: + case <-time.After(10 * time.Second): + t.Error("Verification did not finish after receiving the SAS from the correct user") + } +} diff --git a/go.mod b/go.mod index 4735a48..6e253ae 100644 --- a/go.mod +++ b/go.mod @@ -51,5 +51,5 @@ require ( gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect gopkg.in/yaml.v2 v2.3.0 - maunium.net/go/mautrix v0.6.0 + maunium.net/go/mautrix v0.7.0 ) diff --git a/goneb.go b/goneb.go index 8812af6..8d36565 100644 --- a/goneb.go +++ b/goneb.go @@ -189,6 +189,8 @@ func setup(e envVars, mux *http.ServeMux, matrixClient *http.Client) { rh := &handlers.RealmRedirect{db} mux.HandleFunc("/realms/redirects/", prometheus.InstrumentHandlerFunc("realmRedirectHandler", util.Protect(rh.Handle))) + mux.Handle("/verifySAS", prometheus.InstrumentHandler("verifySAS", util.MakeJSONAPI(&handlers.VerifySAS{matrixClients}))) + // Read exclusively from the config file if one was supplied. // Otherwise, add HTTP listeners for new Services/Sessions/Clients/etc. if e.ConfigFile != "" {