From 77a1514c900a0d422a616a548496a48cf6baf35f Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 24 Jul 2020 21:47:22 +0300 Subject: Add device list and legacy verification commands --- matrix/matrix.go | 6 +- matrix/rooms/roomcache.go | 13 ++++ ui/command-processor.go | 59 ++++++++++++++ ui/commands.go | 195 +++++++++++++++++++++++++++++++++++++++++++++- ui/room-view.go | 188 ++++++++++++++++++++++++++------------------ ui/view-main.go | 18 +---- 6 files changed, 385 insertions(+), 94 deletions(-) diff --git a/matrix/matrix.go b/matrix/matrix.go index b07fa82..f7f308b 100644 --- a/matrix/matrix.go +++ b/matrix/matrix.go @@ -162,6 +162,8 @@ func (c *Container) PasswordLogin(user, password string) error { }, Password: password, InitialDeviceDisplayName: "gomuks", + + StoreCredentials: true, }) if err != nil { return err @@ -171,8 +173,6 @@ func (c *Container) PasswordLogin(user, password string) error { } func (c *Container) finishLogin(resp *mautrix.RespLogin) { - c.client.SetCredentials(resp.UserID, resp.AccessToken) - c.client.DeviceID = resp.DeviceID c.config.UserID = resp.UserID c.config.DeviceID = resp.DeviceID c.config.AccessToken = resp.AccessToken @@ -218,6 +218,8 @@ func (c *Container) SingleSignOn() error { Type: "m.login.token", Token: loginToken, InitialDeviceDisplayName: "gomuks", + + StoreCredentials: true, }) if err != nil { respondHTML(w, http.StatusForbidden, err.Error()) diff --git a/matrix/rooms/roomcache.go b/matrix/rooms/roomcache.go index 5af0c5b..168278b 100644 --- a/matrix/rooms/roomcache.go +++ b/matrix/rooms/roomcache.go @@ -73,6 +73,19 @@ func (cache *RoomCache) IsEncrypted(roomID id.RoomID) bool { return room != nil && room.Encrypted } +func (cache *RoomCache) GetEncryptionEvent(roomID id.RoomID) *event.EncryptionEventContent { + room := cache.Get(roomID) + evt := room.GetStateEvent(event.StateEncryption, "") + if evt == nil { + return nil + } + content, ok := evt.Content.Parsed.(*event.EncryptionEventContent) + if !ok { + return nil + } + return content +} + func (cache *RoomCache) FindSharedRooms(userID id.UserID) (shared []id.RoomID) { // FIXME this disables unloading so TouchNode wouldn't try to double-lock cache.DisableUnloading() diff --git a/ui/command-processor.go b/ui/command-processor.go index b8f41a2..1b30cbe 100644 --- a/ui/command-processor.go +++ b/ui/command-processor.go @@ -20,6 +20,8 @@ import ( "fmt" "strings" + "github.com/mattn/go-runewidth" + "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" "maunium.net/go/gomuks/interface" @@ -45,6 +47,8 @@ type Command struct { OrigText string } +type CommandAutocomplete Command + func (cmd *Command) Reply(message string, args ...interface{}) { cmd.Room.AddServiceMessage(fmt.Sprintf(message, args...)) cmd.UI.Render() @@ -60,12 +64,15 @@ func (alias *Alias) Process(cmd *Command) *Command { } type CommandHandler func(cmd *Command) +type CommandAutocompleter func(cmd *CommandAutocomplete) (completions []string, newText string) type CommandProcessor struct { gomuksPointerContainer aliases map[string]*Alias commands map[string]CommandHandler + + autocompleters map[string]CommandAutocompleter } func NewCommandProcessor(parent *MainView) *CommandProcessor { @@ -97,6 +104,12 @@ func NewCommandProcessor(parent *MainView) *CommandProcessor { "dl": {"download"}, "o": {"open"}, }, + autocompleters: map[string]CommandAutocompleter{ + "devices": autocompleteDevice, + "device": autocompleteDevice, + "verify": autocompleteDevice, + "unverify": autocompleteDevice, + }, commands: map[string]CommandHandler{ "unknown-command": cmdUnknownCommand, @@ -140,6 +153,11 @@ func NewCommandProcessor(parent *MainView) *CommandProcessor { "trace": cmdTrace, "fingerprint": cmdFingerprint, + "devices": cmdDevices, + "verify": cmdVerify, + "device": cmdDevice, + "unverify": cmdUnverify, + "blacklist": cmdBlacklist, }, } } @@ -169,6 +187,47 @@ func (ch *CommandProcessor) ParseCommand(roomView *RoomView, text string) *Comma } } +func (ch *CommandProcessor) Autocomplete(roomView *RoomView, text string, cursorOffset int) ([]string, string, bool) { + var completions []string + if cmd := (*CommandAutocomplete)(ch.ParseCommand(roomView, text)); cmd == nil { + return completions, text, false + } else if handler, ok := ch.autocompleters[cmd.Command]; !ok { + return completions, text, false + } else if cursorOffset != runewidth.StringWidth(text) { + return completions, text, false + } else { + completions, newText := handler(cmd) + if newText == "" { + newText = text + } + return completions, newText, true + } +} + +func (ch *CommandProcessor) AutocompleteCommand(word string) (completions []string) { + if word[0] != '/' { + return + } + word = word[1:] + for alias := range ch.aliases { + if alias == word { + return []string{"/" + alias} + } + if strings.HasPrefix(alias, word) { + completions = append(completions, "/"+alias) + } + } + for command := range ch.commands { + if command == word { + return []string{"/" + command} + } + if strings.HasPrefix(command, word) { + completions = append(completions, "/"+command) + } + } + return +} + func (ch *CommandProcessor) HandleCommand(cmd *Command) { defer debug.Recover() if cmd == nil { diff --git a/ui/commands.go b/ui/commands.go index 49bff0f..b406278 100644 --- a/ui/commands.go +++ b/ui/commands.go @@ -37,6 +37,7 @@ import ( "github.com/russross/blackfriday/v2" "maunium.net/go/mautrix" + "maunium.net/go/mautrix/crypto" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" @@ -365,6 +366,187 @@ func cmdFingerprint(cmd *Command) { } } +// region TODO these four functions currently use the crypto internals directly. switch to interfaces before releasing + +func autocompleteDeviceUserID(cmd *CommandAutocomplete) (completions []string, newText string) { + userCompletions := cmd.Room.AutocompleteUser(cmd.Args[0]) + if len(userCompletions) == 1 { + newText = fmt.Sprintf("/%s %s ", cmd.OrigCommand, userCompletions[0].id) + } else { + completions = make([]string, len(userCompletions)) + for i, completion := range userCompletions { + completions[i] = completion.id + } + } + return +} + +func autocompleteDeviceDeviceID(cmd *CommandAutocomplete) (completions []string, newText string) { + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + devices, err := mach.CryptoStore.GetDevices(id.UserID(cmd.Args[0])) + if len(devices) == 0 || err != nil { + return + } + var completedDeviceID id.DeviceID + if len(cmd.Args) > 1 { + existingID := strings.ToUpper(cmd.Args[1]) + for _, device := range devices { + deviceIDStr := string(device.DeviceID) + if deviceIDStr == existingID { + // We don't want to do any autocompletion if there's already a full device ID there. + return []string{}, "" + } else if strings.HasPrefix(strings.ToUpper(device.Name), existingID) || strings.HasPrefix(deviceIDStr, existingID) { + completedDeviceID = device.DeviceID + completions = append(completions, fmt.Sprintf("%s (%s)", device.DeviceID, device.Name)) + } + } + } else { + completions = make([]string, len(devices)) + i := 0 + for _, device := range devices { + completedDeviceID = device.DeviceID + completions[i] = fmt.Sprintf("%s (%s)", device.DeviceID, device.Name) + i++ + } + } + if len(completions) == 1 { + newText = fmt.Sprintf("/%s %s %s ", cmd.OrigCommand, cmd.Args[0], completedDeviceID) + } + return +} + +func autocompleteDevice(cmd *CommandAutocomplete) ([]string, string) { + if len(cmd.Args) == 0 { + return []string{}, "" + } else if len(cmd.Args) == 1 && !unicode.IsSpace(rune(cmd.RawArgs[len(cmd.RawArgs)-1])) { + return autocompleteDeviceUserID(cmd) + } else if cmd.Command != "devices" { + return autocompleteDeviceDeviceID(cmd) + } + return []string{}, "" +} + +func getDevice(cmd *Command) *crypto.DeviceIdentity { + if len(cmd.Args) < 2 { + cmd.Reply("Usage: /%s [fingerprint]", cmd.Command) + return nil + } + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + device, err := mach.GetOrFetchDevice(id.UserID(cmd.Args[0]), id.DeviceID(cmd.Args[1])) + if err != nil { + cmd.Reply("Failed to get device: %v", err) + return nil + } + return device +} + +func putDevice(cmd *Command, device *crypto.DeviceIdentity, action string) { + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + err := mach.CryptoStore.PutDevice(device.UserID, device) + if err != nil { + cmd.Reply("Failed to save device: %v", err) + } else { + cmd.Reply("Successfully %s %s/%s (%s)", action, device.UserID, device.DeviceID, device.Name) + } + mach.OnDevicesChanged(device.UserID) +} + +func cmdDevices(cmd *Command) { + if len(cmd.Args) == 0 { + cmd.Reply("Usage: /devices ") + return + } + userID := id.UserID(cmd.Args[0]) + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + devices, err := mach.CryptoStore.GetDevices(userID) + if err != nil { + cmd.Reply("Failed to get device list: %v", err) + } + if len(devices) == 0 { + cmd.Reply("Fetching device list from server...") + devices = mach.LoadDevices(userID) + } + if len(devices) == 0 { + cmd.Reply("No devices found for %s", userID) + return + } + var buf strings.Builder + for _, device := range devices { + _, _ = fmt.Fprintf(&buf, "%s (%s) - %s - %s\n", device.DeviceID, device.Name, device.Trust.String(), device.Fingerprint()) + } + resp := buf.String() + cmd.Reply(resp[:len(resp)-1]) +} + +func cmdDevice(cmd *Command) { + device := getDevice(cmd) + if device == nil { + return + } + deviceType := "Device" + if device.Deleted { + deviceType = "Deleted device" + } + cmd.Reply("%s %s of %s\nFingerprint: %s\nIdentity key: %s\nDevice name: %s\nTrust state: %s", + deviceType, device.DeviceID, device.UserID, + device.Fingerprint(), device.IdentityKey, + device.Name, device.Trust.String()) +} + +func cmdVerify(cmd *Command) { + device := getDevice(cmd) + if device == nil { + return + } + if len(cmd.Args) == 2 { + cmd.Reply("Interactive verification UI is not yet implemented") + } else { + fingerprint := strings.Join(cmd.Args[2:], "") + if string(device.SigningKey) != fingerprint { + cmd.Reply("Mismatching fingerprint") + return + } + action := "verified" + if device.Trust == crypto.TrustStateBlacklisted { + action = "unblacklisted and verified" + } + device.Trust = crypto.TrustStateVerified + putDevice(cmd, device, action) + } +} + +func cmdUnverify(cmd *Command) { + device := getDevice(cmd) + if device == nil { + return + } + if device.Trust == crypto.TrustStateUnset { + cmd.Reply("That device is already not verified") + return + } + action := "unverified" + if device.Trust == crypto.TrustStateBlacklisted { + action = "unblacklisted" + } + device.Trust = crypto.TrustStateUnset + putDevice(cmd, device, action) +} + +func cmdBlacklist(cmd *Command) { + device := getDevice(cmd) + if device == nil { + return + } + action := "blacklisted" + if device.Trust == crypto.TrustStateVerified { + action = "unverified and blacklisted" + } + device.Trust = crypto.TrustStateBlacklisted + putDevice(cmd, device, action) +} + +// endregion + func cmdHeapProfile(cmd *Command) { if len(cmd.Args) == 0 || cmd.Args[0] != "nogc" { runtime.GC() @@ -449,7 +631,18 @@ Things: rooms, users, baremessages, images, typingnotif /rainbowme - Send rainbow text in an emote. /reply [text] - Reply to the selected message. /react - React to the selected message. -/redact [reason] - Redact the selected message. +/redact [reason] - Redact the selected message. + +# Encryption +/fingerprint - View the fingerprint of your device. + +/devices - View the device list of a user. +/device - Show info about a specific device. +/unverify - Un-verify a device. +/blacklist - Blacklist a device. +/verify [fingerprint] + - Verify a device. If the fingerprint is not provided, + interactive emoji verification will be started. # Rooms /pm <...> - Create a private chat with the given user(s). diff --git a/ui/room-view.go b/ui/room-view.go index dbdaccc..38a561c 100644 --- a/ui/room-view.go +++ b/ui/room-view.go @@ -22,15 +22,18 @@ import ( "sort" "strings" "time" + "unicode" "github.com/kyokomi/emoji" "github.com/mattn/go-runewidth" "github.com/zyedidia/clipboard" - "maunium.net/go/mautrix/crypto/attachment" "maunium.net/go/mauview" "maunium.net/go/tcell" + "maunium.net/go/gomuks/lib/util" + "maunium.net/go/mautrix/crypto/attachment" + "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -39,7 +42,6 @@ import ( "maunium.net/go/gomuks/debug" "maunium.net/go/gomuks/interface" "maunium.net/go/gomuks/lib/open" - "maunium.net/go/gomuks/lib/util" "maunium.net/go/gomuks/matrix/muksevt" "maunium.net/go/gomuks/matrix/rooms" "maunium.net/go/gomuks/ui/messages" @@ -420,65 +422,6 @@ func (view *RoomView) SetTyping(users []id.UserID) { } } -type completion struct { - displayName string - id string -} - -func (view *RoomView) autocompleteUser(existingText string) (completions []completion) { - textWithoutPrefix := strings.TrimPrefix(existingText, "@") - for userID, user := range view.Room.GetMembers() { - if user.Displayname == textWithoutPrefix || string(userID) == existingText { - // Exact match, return that. - return []completion{{user.Displayname, string(userID)}} - } - - if strings.HasPrefix(user.Displayname, textWithoutPrefix) || strings.HasPrefix(string(userID), existingText) { - completions = append(completions, completion{user.Displayname, string(userID)}) - } - } - return -} - -func (view *RoomView) autocompleteRoom(existingText string) (completions []completion) { - for _, room := range view.parent.rooms { - alias := string(room.Room.GetCanonicalAlias()) - if alias == existingText { - // Exact match, return that. - return []completion{{alias, string(room.Room.ID)}} - } - if strings.HasPrefix(alias, existingText) { - completions = append(completions, completion{alias, string(room.Room.ID)}) - continue - } - } - return -} - -func (view *RoomView) autocompleteEmoji(word string) (completions []string) { - if len(word) == 0 || word[0] != ':' { - return - } - var valueCompletion1 string - var manyValues bool - for name, value := range emoji.CodeMap() { - if name == word { - return []string{value} - } else if strings.HasPrefix(name, word) { - completions = append(completions, name) - if valueCompletion1 == "" { - valueCompletion1 = value - } else if valueCompletion1 != value { - manyValues = true - } - } - } - if !manyValues && len(completions) > 0 { - return []string{emoji.CodeMap()[completions[0]]} - } - return -} - func (view *RoomView) SetEditing(evt *muksevt.Event) { if evt == nil { view.editing = nil @@ -584,23 +527,90 @@ func (view *RoomView) SelectPrevious() { } } -func (view *RoomView) InputTabComplete(text string, cursorOffset int) { - debug.Print("Tab completing", cursorOffset, text) - str := runewidth.Truncate(text, cursorOffset, "") - word := findWordToTabComplete(str) - startIndex := len(str) - len(word) +type completion struct { + displayName string + id string +} - var strCompletions []string - var strCompletion string +func (view *RoomView) AutocompleteUser(existingText string) (completions []completion) { + textWithoutPrefix := strings.TrimPrefix(existingText, "@") + for userID, user := range view.Room.GetMembers() { + if user.Displayname == textWithoutPrefix || string(userID) == existingText { + // Exact match, return that. + return []completion{{user.Displayname, string(userID)}} + } - completions := view.autocompleteUser(word) - completions = append(completions, view.autocompleteRoom(word)...) + if strings.HasPrefix(user.Displayname, textWithoutPrefix) || strings.HasPrefix(string(userID), existingText) { + completions = append(completions, completion{user.Displayname, string(userID)}) + } + } + return +} + +func (view *RoomView) AutocompleteRoom(existingText string) (completions []completion) { + for _, room := range view.parent.rooms { + alias := string(room.Room.GetCanonicalAlias()) + if alias == existingText { + // Exact match, return that. + return []completion{{alias, string(room.Room.ID)}} + } + if strings.HasPrefix(alias, existingText) { + completions = append(completions, completion{alias, string(room.Room.ID)}) + continue + } + } + return +} + +func (view *RoomView) AutocompleteEmoji(word string) (completions []string) { + if word[0] != ':' { + return + } + var valueCompletion1 string + var manyValues bool + for name, value := range emoji.CodeMap() { + if name == word { + return []string{value} + } else if strings.HasPrefix(name, word) { + completions = append(completions, name) + if valueCompletion1 == "" { + valueCompletion1 = value + } else if valueCompletion1 != value { + manyValues = true + } + } + } + if !manyValues && len(completions) > 0 { + return []string{emoji.CodeMap()[completions[0]]} + } + return +} + +func findWordToTabComplete(text string) string { + output := "" + runes := []rune(text) + for i := len(runes) - 1; i >= 0; i-- { + if unicode.IsSpace(runes[i]) { + break + } + output = string(runes[i]) + output + } + return output +} + +func (view *RoomView) defaultAutocomplete(word string, startIndex int) (strCompletions []string, strCompletion string) { + if len(word) == 0 { + return []string{}, "" + } + + completions := view.AutocompleteUser(word) + completions = append(completions, view.AutocompleteRoom(word)...) if len(completions) == 1 { completion := completions[0] strCompletion = fmt.Sprintf("[%s](https://matrix.to/#/%s)", completion.displayName, completion.id) - if startIndex == 0 { - strCompletion = strCompletion + ": " + if startIndex == 0 && completion.id[0] == '@' { + strCompletion = strCompletion + ":" } } else if len(completions) > 1 { for _, completion := range completions { @@ -608,18 +618,44 @@ func (view *RoomView) InputTabComplete(text string, cursorOffset int) { } } - strCompletions = append(strCompletions, view.autocompleteEmoji(word)...) + strCompletions = append(strCompletions, view.parent.cmdProcessor.AutocompleteCommand(word)...) + strCompletions = append(strCompletions, view.AutocompleteEmoji(word)...) + + return +} + +func (view *RoomView) InputTabComplete(text string, cursorOffset int) { + if len(text) == 0 { + return + } + + debug.Print("Tab completing", cursorOffset, text) + + str := runewidth.Truncate(text, cursorOffset, "") + word := findWordToTabComplete(str) + startIndex := len(str) - len(word) + + var strCompletion string + + strCompletions, newText, ok := view.parent.cmdProcessor.Autocomplete(view, text, cursorOffset) + if !ok { + strCompletions, strCompletion = view.defaultAutocomplete(word, startIndex) + } if len(strCompletions) > 0 { strCompletion = util.LongestCommonPrefix(strCompletions) sort.Sort(sort.StringSlice(strCompletions)) } + if len(strCompletion) > 0 && len(strCompletions) < 2 { + strCompletion += " " + strCompletions = []string{} + } - if len(strCompletion) > 0 { - text = str[0:startIndex] + strCompletion + text[len(str):] + if len(strCompletion) > 0 && newText == text { + newText = str[0:startIndex] + strCompletion + text[len(str):] } - view.input.SetTextAndMoveCursor(text) + view.input.SetTextAndMoveCursor(newText) view.SetCompletions(strCompletions) } diff --git a/ui/view-main.go b/ui/view-main.go index 0f6098c..fc1b9c9 100644 --- a/ui/view-main.go +++ b/ui/view-main.go @@ -22,15 +22,15 @@ import ( "os" "sync/atomic" "time" - "unicode" sync "github.com/sasha-s/go-deadlock" - "maunium.net/go/gomuks/ui/messages" - "maunium.net/go/mautrix/id" "maunium.net/go/mauview" "maunium.net/go/tcell" + "maunium.net/go/gomuks/ui/messages" + "maunium.net/go/mautrix/id" + "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" "maunium.net/go/gomuks/interface" @@ -139,18 +139,6 @@ func (view *MainView) InputChanged(roomView *RoomView, text string) { } } -func findWordToTabComplete(text string) string { - output := "" - runes := []rune(text) - for i := len(runes) - 1; i >= 0; i-- { - if unicode.IsSpace(runes[i]) { - break - } - output = string(runes[i]) + output - } - return output -} - func (view *MainView) ShowBare(roomView *RoomView) { if roomView == nil { return -- cgit v1.2.3