diff --git a/.travis.yml b/.travis.yml index 5f53f5b..4c428da 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,8 +4,9 @@ language: go go: - 1.14 install: + - echo 'deb http://archive.ubuntu.com/ubuntu/ focal universe' | sudo tee -a /etc/apt/sources.list - sudo apt-get update - - sudo apt-get -y install libolm2 libolm-dev + - sudo apt-get -y install libolm3 libolm-dev - go get golang.org/x/lint/golint - go get github.com/fzipp/gocyclo diff --git a/README.md b/README.md index e3fc10f..ef396e7 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. @@ -185,7 +202,7 @@ Before submitting pull requests, please read the [Matrix.org contribution guidel # Developing This project depends on `libolm` for the end-to-end encryption. Therefore, -you need to install `libolm2` and `libolm-dev` on Ubuntu / `libolm-devel` on +you need to install `libolm3` and `libolm-dev` on Ubuntu / `libolm-devel` on CentOS to be able to build and run it. There's a bunch more tools this project uses when developing in order to do diff --git a/api/api.go b/api/api.go index 2b2f6de..a49fefa 100644 --- a/api/api.go +++ b/api/api.go @@ -78,6 +78,22 @@ type ClientConfig struct { // The desired display name for this client. // This does not automatically set the display name for this client. See /configureClient. DisplayName string + // A list of regexes that control which users are allowed to start a SAS verification with this client. + // When a user starts a new SAS verification with us, their user ID has to match one of these regexes + // for the verification process to start. + AcceptVerificationFromUsers []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. @neb:localhost + 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. @@ -132,6 +148,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..d057b64 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: "+err.Error()) + } + + if err := body.Check(); err != nil { + return util.MessageResponse(400, "Request error: "+err.Error()) + } + + 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..6dbaea2 100644 --- a/clients/bot_client.go +++ b/clients/bot_client.go @@ -1,26 +1,38 @@ package clients import ( + "errors" + "regexp" + "sync" + "sync/atomic" "time" "github.com/matrix-org/go-neb/api" "github.com/matrix-org/go-neb/database" "github.com/matrix-org/go-neb/matrix" log "github.com/sirupsen/logrus" + "golang.org/x/net/context" "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" ) +// maximumVerifications is the number of maximum ongoing SAS verifications at a time. +// After this limit we start ignoring verification requests. +const maximumVerifications = 100 + // BotClient represents one of the bot's sessions, with a specific User and Device ID. // It can be used for sending messages and retrieving information about the rooms that // 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 + ongoingVerificationCount int32 } // InitOlmMachine initializes a BotClient's internal OlmMachine given a client object and a Neb store, @@ -54,6 +66,31 @@ func (botClient *BotClient) InitOlmMachine(client *mautrix.Client, nebStore *mat botClient.stateStore = &NebStateStore{&nebStore.InMemoryStore} olmMachine := crypto.NewOlmMachine(client, cryptoLogger, cryptoStore, botClient.stateStore) + + regexes := make([]*regexp.Regexp, 0, len(botClient.config.AcceptVerificationFromUsers)) + for _, userRegex := range botClient.config.AcceptVerificationFromUsers { + regex, err := regexp.Compile(userRegex) + if err != nil { + cryptoLogger.Error("Error compiling regex %v: %v", userRegex, err) + } else { + regexes = append(regexes, regex) + } + } + olmMachine.AcceptVerificationFrom = func(_ string, otherDevice *crypto.DeviceIdentity) (crypto.VerificationRequestResponse, crypto.VerificationHooks) { + for _, regex := range regexes { + if regex.MatchString(otherDevice.UserID.String()) { + if atomic.LoadInt32(&botClient.ongoingVerificationCount) >= maximumVerifications { + cryptoLogger.Trace("User ID %v matches regex %v but we are currently at maximum verifications, ignoring...", otherDevice.UserID, regex) + return crypto.IgnoreRequest, botClient + } + cryptoLogger.Trace("User ID %v matches regex %v, accepting SAS request", otherDevice.UserID, regex) + atomic.AddInt32(&botClient.ongoingVerificationCount, 1) + return crypto.AcceptRequest, botClient + } + } + cryptoLogger.Trace("User ID %v does not match any regex, rejecting SAS request", otherDevice.UserID) + return crypto.RejectRequest, botClient + } if err = olmMachine.Load(); err != nil { return } @@ -142,3 +179,140 @@ 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) { + atomic.AddInt32(&botClient.ongoingVerificationCount, -1) + log.Tracef("Verification cancelled with reason: %v", reason) +} + +// OnSuccess is called when a SAS verification is successful. +func (botClient *BotClient) OnSuccess() { + atomic.AddInt32(&botClient.ongoingVerificationCount, -1) + log.Trace("Verification was successful") +} + +// InvalidateRoomSession invalidates the outbound group session for the given room. +func (botClient *BotClient) InvalidateRoomSession(roomID id.RoomID) (id.SessionID, error) { + outbound, err := botClient.olmMachine.CryptoStore.GetOutboundGroupSession(roomID) + if err != nil { + return "", err + } + if outbound == nil { + return "", errors.New("No group session found for this room") + } + return outbound.ID(), botClient.olmMachine.CryptoStore.RemoveOutboundGroupSession(roomID) +} + +// StartSASVerification starts a new SAS verification with the given user and device ID and returns the transaction ID if successful. +func (botClient *BotClient) StartSASVerification(userID id.UserID, deviceID id.DeviceID) (string, error) { + device, err := botClient.olmMachine.GetOrFetchDevice(userID, deviceID) + if err != nil { + return "", err + } + return botClient.olmMachine.NewSimpleSASVerificationWith(device, botClient) +} + +// SendRoomKeyRequest sends a room key request to another device. +func (botClient *BotClient) SendRoomKeyRequest(userID id.UserID, deviceID id.DeviceID, roomID id.RoomID, + senderKey id.SenderKey, sessionID id.SessionID, timeout time.Duration) (chan bool, error) { + + ctx, _ := context.WithTimeout(context.Background(), timeout) + return botClient.olmMachine.RequestRoomKey(ctx, userID, deviceID, roomID, senderKey, sessionID) +} + +// ForwardRoomKeyToDevice sends a room key to another device. +func (botClient *BotClient) ForwardRoomKeyToDevice(userID id.UserID, deviceID id.DeviceID, roomID id.RoomID, senderKey id.SenderKey, + sessionID id.SessionID) error { + + device, err := botClient.olmMachine.GetOrFetchDevice(userID, deviceID) + if err != nil { + return err + } + + igs, err := botClient.olmMachine.CryptoStore.GetGroupSession(roomID, senderKey, sessionID) + if err != nil { + return err + } else if igs == nil { + return errors.New("Group session not found") + } + + exportedKey, err := igs.Internal.Export(igs.Internal.FirstKnownIndex()) + if err != nil { + return err + } + + forwardedRoomKey := event.Content{ + Parsed: &event.ForwardedRoomKeyEventContent{ + RoomKeyEventContent: event.RoomKeyEventContent{ + Algorithm: id.AlgorithmMegolmV1, + RoomID: igs.RoomID, + SessionID: igs.ID(), + SessionKey: exportedKey, + }, + SenderKey: senderKey, + ForwardingKeyChain: igs.ForwardingChains, + SenderClaimedKey: igs.SigningKey, + }, + } + + return botClient.olmMachine.SendEncryptedToDevice(device, forwardedRoomKey) +} diff --git a/clients/clients.go b/clients/clients.go index 33afd5f..0e5816c 100644 --- a/clients/clients.go +++ b/clients/clients.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" "net/http" + "reflect" "strings" "sync" @@ -111,7 +112,7 @@ func (c *Clients) updateClientInDB(newConfig api.ClientConfig) (new, old BotClie defer c.dbMutex.Unlock() old = c.getClient(newConfig.UserID) - if old.Client != nil && old.config == newConfig { + if old.Client != nil && reflect.DeepEqual(old.config, newConfig) { // Already have a client with that config. new = old return @@ -340,6 +341,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/config.sample.yaml b/config.sample.yaml index 00c10c2..e48c2ea 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -26,6 +26,7 @@ clients: Sync: true AutoJoinRooms: true DisplayName: "Go-NEB!" + AcceptVerificationFromUsers: [":localhost:8008"] - UserID: "@another_goneb:localhost" AccessToken: "MDASDASJDIASDJASDAFGFRGER" @@ -34,6 +35,7 @@ clients: Sync: false AutoJoinRooms: false DisplayName: "Go-NEB!" + AcceptVerificationFromUsers: ["^@admin:localhost:8008$"] # The list of realms which Go-NEB is aware of. # Delete or modify this list as appropriate. 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/go.sum b/go.sum index 75005e6..e79dd50 100644 --- a/go.sum +++ b/go.sum @@ -272,5 +272,7 @@ maunium.net/go/mautrix v0.5.5 h1:e0Pql1FdxoNUudx2oXo1gZHMrqIh5MC72cdXEPIrYLA= maunium.net/go/mautrix v0.5.5/go.mod h1:FLbMANzwqlsX2Fgm7SDe+E4I3wSa4UxJRKqS5wGkCwA= maunium.net/go/mautrix v0.6.0 h1:V32l4aygKk2XcH3fi8Yd0pFeSyYZJNRIvr8vdA2GtC8= maunium.net/go/mautrix v0.6.0/go.mod h1:Va/74MijqaS0DQ3aUqxmFO54/PMfr1LVsCOcGRHbYmo= +maunium.net/go/mautrix v0.7.0 h1:9Wxs5S4Wl4S99dbBwfLZYAe/sP7VKaFikw9Ocf88kfk= +maunium.net/go/mautrix v0.7.0/go.mod h1:Va/74MijqaS0DQ3aUqxmFO54/PMfr1LVsCOcGRHbYmo= maunium.net/go/mauview v0.1.1/go.mod h1:3QBUiuLct9moP1LgDhCGIg0Ovxn38Bd2sGndnUOuj4o= maunium.net/go/tcell v0.2.0/go.mod h1:9Apcb3lNNS6C6lCqKT9UFp7BTRzHXfWE+/tgufsAMho= diff --git a/goneb.go b/goneb.go index 8812af6..831d924 100644 --- a/goneb.go +++ b/goneb.go @@ -21,6 +21,7 @@ import ( _ "github.com/matrix-org/go-neb/realms/jira" _ "github.com/matrix-org/go-neb/services/alertmanager" + _ "github.com/matrix-org/go-neb/services/cryptotest" _ "github.com/matrix-org/go-neb/services/echo" _ "github.com/matrix-org/go-neb/services/giphy" _ "github.com/matrix-org/go-neb/services/github" @@ -189,6 +190,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 != "" { diff --git a/services/cryptotest/cryptotest.go b/services/cryptotest/cryptotest.go new file mode 100644 index 0000000..de80ed2 --- /dev/null +++ b/services/cryptotest/cryptotest.go @@ -0,0 +1,353 @@ +// Package cryptotest implements a Service which provides several commands for testing the e2e functionalities of other devices. +package cryptotest + +import ( + "fmt" + "math/rand" + "strconv" + "time" + + "github.com/matrix-org/go-neb/clients" + "github.com/matrix-org/go-neb/types" + log "github.com/sirupsen/logrus" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto" + mevt "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// ServiceType of the Cryptotest service +const ServiceType = "cryptotest" + +var expectedString map[id.RoomID]string + +var helpMsgs = map[string]string{ + "crypto_help": ": Displays the help message", + "crypto_challenge": "[prefix] : The bot sets a random challenge for the room and echoes it. " + + "The client tested should respond with \"!crypto_response challenge\"." + + "Alternatively the prefix that the challenge will be echoed with can be set.", + "crypto_response": " : Should repeat the crypto_challenge's challenge code.", + "crypto_new_session": ": Asks the bot to invalidate its current outgoing group session and create a new one.", + "sas_verify_me": " : Asks the bot to start a decimal SAS verification transaction with the sender's specified device.", + "sas_decimal_code": " : Sends the device's generated decimal SAS code for the bot to verify, " + + "after a \"!sas_verify_me\" command.", + "request_my_room_key": " : Asks the bot to request the room key for the current room " + + "and given sender key and session ID from the sender's given device.", + "forward_me_room_key": " : Asks the bot to send the room key for the current room " + + "and given sender key and session ID to the sender's given device.", +} + +// Service represents the Cryptotest service. It has no Config fields. +type Service struct { + types.DefaultService + Rooms []id.RoomID `json:"rooms"` +} + +func randomString() (res string) { + for i := 0; i < 10; i++ { + res += string(rune(rand.Intn('Z'-'A') + 'A')) + } + return +} + +func (s *Service) inRoom(roomID id.RoomID) bool { + for _, joinedRoomID := range s.Rooms { + if joinedRoomID == roomID { + return true + } + } + return false +} + +func (s *Service) handleEventMessage(source mautrix.EventSource, evt *mevt.Event) { + log.Infof("got a %v", evt.Content.AsMessage().Body) +} + +func (s *Service) cmdCryptoHelp(roomID id.RoomID) (interface{}, error) { + if s.inRoom(roomID) { + helpTxt := "Supported crypto test methods:\n\n" + for cmd, helpMsg := range helpMsgs { + helpTxt += fmt.Sprintf("!%v %v\n\n", cmd, helpMsg) + } + return mevt.MessageEventContent{MsgType: mevt.MsgText, Body: helpTxt}, nil + } + return nil, nil +} + +func (s *Service) cmdCryptoChallenge(roomID id.RoomID, arguments []string) (interface{}, error) { + if s.inRoom(roomID) { + randStr := randomString() + log.Infof("Setting challenge for room %v: %v", roomID, expectedString) + expectedString[roomID] = randStr + prefix := "!challenge" + if len(arguments) > 0 { + prefix = arguments[0] + } + return mevt.MessageEventContent{MsgType: mevt.MsgText, Body: fmt.Sprintf("%v %v", prefix, randStr)}, nil + } + return nil, nil +} + +func (s *Service) cmdCryptoResponse(userID id.UserID, roomID id.RoomID, arguments []string) (interface{}, error) { + if s.inRoom(roomID) { + if len(arguments) != 1 { + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: "!crypto_response " + helpMsgs["crypto_response"], + }, nil + } + if arguments[0] == expectedString[roomID] { + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: fmt.Sprintf("Correct response received from %v", userID.String()), + }, nil + } + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: fmt.Sprintf("Incorrect response received from %v", userID.String()), + }, nil + } + return nil, nil +} + +func (s *Service) cmdCryptoNewSession(botClient *clients.BotClient, roomID id.RoomID) (interface{}, error) { + if s.inRoom(roomID) { + sessionID, err := botClient.InvalidateRoomSession(roomID) + if err != nil { + log.WithField("room_id", roomID).Errorf("Error invalidating session ID: %v", err) + return mevt.MessageEventContent{MsgType: mevt.MsgText, Body: fmt.Sprintf("Error invalidating session ID: %v", sessionID)}, nil + } + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: fmt.Sprintf("Invalidated previous session ID (%v)", sessionID), + }, nil + } + return nil, nil +} + +func (s *Service) cmdSASVerifyMe(botClient *clients.BotClient, roomID id.RoomID, userID id.UserID, arguments []string) (interface{}, error) { + if s.inRoom(roomID) { + if len(arguments) != 1 { + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: "sas_verify_me " + helpMsgs["sas_verify_me"], + }, nil + } + deviceID := id.DeviceID(arguments[0]) + transaction, err := botClient.StartSASVerification(userID, deviceID) + if err != nil { + log.WithFields(log.Fields{"user_id": userID, "device_id": deviceID}).WithError(err).Error("Error starting SAS verification") + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: fmt.Sprintf("Error starting SAS verification: %v", err), + }, nil + } + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: fmt.Sprintf("Started SAS verification with user %v device %v: transaction %v", userID, deviceID, transaction), + }, nil + } + return nil, nil +} + +func (s *Service) cmdSASVerifyDecimalCode(botClient *clients.BotClient, roomID id.RoomID, userID id.UserID, arguments []string) (interface{}, error) { + if s.inRoom(roomID) { + if len(arguments) != 4 { + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: "sas_decimal_code " + helpMsgs["sas_decimal_code"], + }, nil + } + deviceID := id.DeviceID(arguments[0]) + var decimalSAS crypto.DecimalSASData + for i := 0; i < 3; i++ { + sasCode, err := strconv.Atoi(arguments[i+1]) + if err != nil { + log.WithFields(log.Fields{"user_id": userID, "device_id": deviceID}).WithError(err).Error("Error reading SAS code") + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: fmt.Sprintf("Error reading SAS code: %v", err), + }, nil + } + decimalSAS[i] = uint(sasCode) + } + botClient.SubmitDecimalSAS(userID, deviceID, decimalSAS) + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: fmt.Sprintf("Read SAS code from user %v device %v: %v", userID, deviceID, decimalSAS), + }, nil + } + return nil, nil +} + +func (s *Service) cmdRequestRoomKey(botClient *clients.BotClient, roomID id.RoomID, userID id.UserID, arguments []string) (interface{}, error) { + if s.inRoom(roomID) { + if len(arguments) != 3 { + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: "request_my_room_key " + helpMsgs["request_my_room_key"], + }, nil + } + deviceID := id.DeviceID(arguments[0]) + senderKey := id.SenderKey(arguments[1]) + sessionID := id.SessionID(arguments[2]) + receivedChan, err := botClient.SendRoomKeyRequest(userID, deviceID, roomID, senderKey, sessionID, time.Minute) + if err != nil { + log.WithFields(log.Fields{ + "user_id": userID, + "device_id": deviceID, + "sender_key": senderKey, + "session_id": sessionID, + }).WithError(err).Error("Error requesting room key") + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: fmt.Sprintf("Error requesting room key for session %v: %v", sessionID, err), + }, nil + } + go func() { + var result string + received := <-receivedChan + if received { + result = "Key received successfully!" + } else { + result = "Key was not received in the time limit" + } + content := mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: fmt.Sprintf("Room key request for session %v result: %v", sessionID, result), + } + if _, err := botClient.SendMessageEvent(roomID, mevt.EventMessage, content); err != nil { + log.WithFields(log.Fields{ + "room_id": roomID, + "content": content, + }).WithError(err).Error("Failed to send room key request result to room") + } + }() + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: fmt.Sprintf("Sent room key request for session %v to device %v", sessionID, deviceID), + }, nil + } + return nil, nil +} + +func (s *Service) cmdForwardRoomKey(botClient *clients.BotClient, roomID id.RoomID, userID id.UserID, arguments []string) (interface{}, error) { + if s.inRoom(roomID) { + if len(arguments) != 3 { + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: "forward_me_room_key " + helpMsgs["forward_me_room_key"], + }, nil + } + deviceID := id.DeviceID(arguments[0]) + senderKey := id.SenderKey(arguments[1]) + sessionID := id.SessionID(arguments[2]) + err := botClient.ForwardRoomKeyToDevice(userID, deviceID, roomID, senderKey, sessionID) + if err != nil { + log.WithFields(log.Fields{ + "user_id": userID, + "device_id": deviceID, + "sender_key": senderKey, + "session_id": sessionID, + }).WithError(err).Error("Error forwarding room key") + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: fmt.Sprintf("Error forwarding room key for session %v: %v", sessionID, err), + }, nil + } + return mevt.MessageEventContent{ + MsgType: mevt.MsgText, + Body: fmt.Sprintf("Forwarded room key for session %v to device %v", sessionID, deviceID), + }, nil + } + return nil, nil +} + +// Commands supported: +// !crypto_help Displays a help string +// !crypto_challenge Sets a challenge for a room which clients should reply to with !crypto_response +// !crypto_response Used by the client to repeat the room challenge +// !crypto_new_session Invalidates the bot's current outgoing session +// !sas_verify_me Asks the bot to verify the sender +// !sas_decimal_code Sends the sender's SAS code to the bot for verification +// !request_my_room_key Asks the bot to request a room key from the sender +// !forward_me_room_key Asks the bot to forward a room key to the sender +// This service can be used for testing other clients by writing the commands above in a room where this service is enabled. +func (s *Service) Commands(cli types.MatrixClient) []types.Command { + botClient := cli.(*clients.BotClient) + return []types.Command{ + { + Path: []string{"crypto_help"}, + Command: func(roomID id.RoomID, userID id.UserID, arguments []string) (interface{}, error) { + return s.cmdCryptoHelp(roomID) + }, + }, + { + Path: []string{"crypto_challenge"}, + Command: func(roomID id.RoomID, userID id.UserID, arguments []string) (interface{}, error) { + return s.cmdCryptoChallenge(roomID, arguments) + }, + }, + { + Path: []string{"crypto_response"}, + Command: func(roomID id.RoomID, userID id.UserID, arguments []string) (interface{}, error) { + return s.cmdCryptoResponse(userID, roomID, arguments) + }, + }, + { + Path: []string{"crypto_new_session"}, + Command: func(roomID id.RoomID, userID id.UserID, arguments []string) (interface{}, error) { + return s.cmdCryptoNewSession(botClient, roomID) + }, + }, + { + Path: []string{"sas_verify_me"}, + Command: func(roomID id.RoomID, userID id.UserID, arguments []string) (interface{}, error) { + return s.cmdSASVerifyMe(botClient, roomID, userID, arguments) + }, + }, + { + Path: []string{"sas_decimal_code"}, + Command: func(roomID id.RoomID, userID id.UserID, arguments []string) (interface{}, error) { + return s.cmdSASVerifyDecimalCode(botClient, roomID, userID, arguments) + }, + }, + { + Path: []string{"request_my_room_key"}, + Command: func(roomID id.RoomID, userID id.UserID, arguments []string) (interface{}, error) { + return s.cmdRequestRoomKey(botClient, roomID, userID, arguments) + }, + }, + { + Path: []string{"forward_me_room_key"}, + Command: func(roomID id.RoomID, userID id.UserID, arguments []string) (interface{}, error) { + return s.cmdForwardRoomKey(botClient, roomID, userID, arguments) + }, + }, + } +} + +// Register registers +func (s *Service) Register(oldService types.Service, client types.MatrixClient) error { + botClient := client.(*clients.BotClient) + botClient.Syncer.(mautrix.ExtensibleSyncer).OnEventType(mevt.EventMessage, s.handleEventMessage) + for _, roomID := range s.Rooms { + if _, err := client.JoinRoom(roomID.String(), "", nil); err != nil { + log.WithFields(log.Fields{ + log.ErrorKey: err, + "room_id": roomID, + }).Error("Failed to join room") + } + } + return nil +} + +func init() { + expectedString = make(map[id.RoomID]string) + types.RegisterService(func(serviceID string, serviceUserID id.UserID, webhookEndpointURL string) types.Service { + s := &Service{ + DefaultService: types.NewDefaultService(serviceID, serviceUserID, ServiceType), + } + return s + }) +}