diff --git a/go.mod b/go.mod index 96dde05..66a0fab 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( golang.org/x/net v0.0.0-20200602114024-627f9648deb9 gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 gopkg.in/yaml.v2 v2.3.0 - maunium.net/go/mautrix v0.7.1 + maunium.net/go/mautrix v0.7.4 maunium.net/go/mauview v0.1.1 maunium.net/go/tcell v0.2.0 ) diff --git a/go.sum b/go.sum index 4f4e398..78ec8c3 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,7 @@ github.com/zyedidia/clipboard v0.0.0-20200421031010-7c45b8673834/go.mod h1:zykFn github.com/zyedidia/poller v1.0.1/go.mod h1:vZXJOHGDcuK08GXhF6IAY0ZFd2WcgOR5DOTp84Uk5eE= go.etcd.io/bbolt v1.3.4 h1:hi1bXHMVrlQh6WwxAy+qZCV/SYIlqo+Ushwdpa4tAKg= go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20200430140353-33d19683fad8 h1:6WW6V3x1P/jokJBpRQYUJnMHRP6isStQwCozxnU7XQw= @@ -118,6 +119,10 @@ 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/mautrix v0.7.1 h1:ctoAVWUPs0D1AADzuA3KLmaRONnSY61mdUoP2smNqe4= maunium.net/go/mautrix v0.7.1/go.mod h1:Va/74MijqaS0DQ3aUqxmFO54/PMfr1LVsCOcGRHbYmo= +maunium.net/go/mautrix v0.7.3 h1:yC287SXL0pTZzAtpGIvwtlxPHZsWMuq2DmI5/POTKFE= +maunium.net/go/mautrix v0.7.3/go.mod h1:Va/74MijqaS0DQ3aUqxmFO54/PMfr1LVsCOcGRHbYmo= +maunium.net/go/mautrix v0.7.4 h1:MDjrvDyHcu5ozKAa80ohcXmYAXZTHgHxrhiERtvkEdY= +maunium.net/go/mautrix v0.7.4/go.mod h1:Va/74MijqaS0DQ3aUqxmFO54/PMfr1LVsCOcGRHbYmo= maunium.net/go/mauview v0.1.1 h1:wfTXyPx3LGAGpTskh+UbBv/QItUWnEpaneHmywoYnfY= maunium.net/go/mauview v0.1.1/go.mod h1:3QBUiuLct9moP1LgDhCGIg0Ovxn38Bd2sGndnUOuj4o= maunium.net/go/tcell v0.2.0 h1:1Q0kN3wCOGAIGu1r3QHADsjSUOPDylKREvCv3EzJpVg= diff --git a/ui/command-processor.go b/ui/command-processor.go index 514d67b..ee2222f 100644 --- a/ui/command-processor.go +++ b/ui/command-processor.go @@ -105,10 +105,13 @@ func NewCommandProcessor(parent *MainView) *CommandProcessor { "o": {"open"}, }, autocompleters: map[string]CommandAutocompleter{ - "devices": autocompleteDevice, - "device": autocompleteDevice, - "verify": autocompleteDevice, - "unverify": autocompleteDevice, + "devices": autocompleteDevice, + "device": autocompleteDevice, + "verify": autocompleteDevice, + "unverify": autocompleteDevice, + "import": autocompleteFile, + "export": autocompleteFile, + "export-room": autocompleteFile, }, commands: map[string]CommandHandler{ "unknown-command": cmdUnknownCommand, @@ -159,6 +162,9 @@ func NewCommandProcessor(parent *MainView) *CommandProcessor { "unverify": cmdUnverify, "blacklist": cmdBlacklist, "reset-session": cmdResetSession, + "import": cmdImportKeys, + "export": cmdExportKeys, + "export-room": cmdExportRoomKeys, }, } } diff --git a/ui/commands.go b/ui/commands.go index d9469fc..3a77f67 100644 --- a/ui/commands.go +++ b/ui/commands.go @@ -461,6 +461,11 @@ Things: rooms, users, baremessages, images, typingnotif, unverified /verify [fingerprint] - Verify a device. If the fingerprint is not provided, interactive emoji verification will be started. +/reset-session - Reset the outbound Megolm session in the current room. + +/import - Import encryption keys +/export - Export encryption keys +/export-room - Export encryption keys for the current room. # Rooms /pm <...> - Create a private chat with the given user(s). diff --git a/ui/crypto-commands.go b/ui/crypto-commands.go index 232f508..8a9fbb1 100644 --- a/ui/crypto-commands.go +++ b/ui/crypto-commands.go @@ -20,6 +20,8 @@ package ui import ( "fmt" + "io/ioutil" + "path/filepath" "strings" "time" "unicode" @@ -229,3 +231,105 @@ func cmdResetSession(cmd *Command) { cmd.Reply("Removed outbound group session for this room") } } + +func autocompleteFile(cmd *CommandAutocomplete) (completions []string, newText string) { + inputPath, err := filepath.Abs(cmd.RawArgs) + if err != nil { + return + } + + var searchNamePrefix, searchDir string + if strings.HasSuffix(cmd.RawArgs, "/") { + searchDir = inputPath + } else { + searchNamePrefix = filepath.Base(inputPath) + searchDir = filepath.Dir(inputPath) + } + files, err := ioutil.ReadDir(searchDir) + if err != nil { + return + } + for _, file := range files { + name := file.Name() + if !strings.HasPrefix(name, searchNamePrefix) || (name[0] == '.' && searchNamePrefix == "") { + continue + } + fullPath := filepath.Join(searchDir, name) + if file.IsDir() { + fullPath += "/" + } + completions = append(completions, fullPath) + } + if len(completions) == 1 { + newText = fmt.Sprintf("/%s %s", cmd.OrigCommand, completions[0]) + } + return +} + +func cmdImportKeys(cmd *Command) { + path, err := filepath.Abs(cmd.RawArgs) + if err != nil { + cmd.Reply("Failed to get absolute path: %v", err) + return + } + data, err := ioutil.ReadFile(path) + if err != nil { + cmd.Reply("Failed to read %s: %v", path, err) + return + } + passphrase, ok := cmd.MainView.AskPassword("Key import", false) + if !ok { + cmd.Reply("Passphrase entry cancelled") + return + } + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + imported, total, err := mach.ImportKeys(passphrase, data) + if err != nil { + cmd.Reply("Failed to import sessions: %v", err) + } else { + cmd.Reply("Successfully imported %d/%d sessions", imported, total) + } +} + +func exportKeys(cmd *Command, sessions []*crypto.InboundGroupSession) { + path, err := filepath.Abs(cmd.RawArgs) + if err != nil { + cmd.Reply("Failed to get absolute path: %v", err) + return + } + passphrase, ok := cmd.MainView.AskPassword("Key export", true) + if !ok { + cmd.Reply("Passphrase entry cancelled") + return + } + export, err := crypto.ExportKeys(passphrase, sessions) + if err != nil { + cmd.Reply("Failed to export sessions: %v", err) + } + err = ioutil.WriteFile(path, export, 0400) + if err != nil { + cmd.Reply("Failed to write sessions to %s: %v", path, err) + } else { + cmd.Reply("Successfully exported %d sessions to %s", len(sessions), path) + } +} + +func cmdExportKeys(cmd *Command) { + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + sessions, err := mach.CryptoStore.GetAllGroupSessions() + if err != nil { + cmd.Reply("Failed to get sessions to export: %v", err) + return + } + exportKeys(cmd, sessions) +} + +func cmdExportRoomKeys(cmd *Command) { + mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) + sessions, err := mach.CryptoStore.GetGroupSessionsForRoom(cmd.Room.MxRoom().ID) + if err != nil { + cmd.Reply("Failed to get sessions to export: %v", err) + return + } + exportKeys(cmd, sessions) +} diff --git a/ui/no-crypto-commands.go b/ui/no-crypto-commands.go index dae85b4..781eb9f 100644 --- a/ui/no-crypto-commands.go +++ b/ui/no-crypto-commands.go @@ -22,6 +22,10 @@ func autocompleteDevice(cmd *CommandAutocomplete) ([]string, string) { return []string{}, "" } +func autocompleteFile(cmd *CommandAutocomplete) ([]string, string) { + return []string{}, "" +} + func cmdNoCrypto(cmd *Command) { cmd.Reply("This gomuks was built without encryption support") } @@ -33,4 +37,7 @@ var ( cmdUnverify = cmdNoCrypto cmdBlacklist = cmdNoCrypto cmdResetSession = cmdNoCrypto + cmdImportKeys = cmdNoCrypto + cmdExportKeys = cmdNoCrypto + cmdExportRoomKeys = cmdNoCrypto ) diff --git a/ui/password-modal.go b/ui/password-modal.go new file mode 100644 index 0000000..afda7b5 --- /dev/null +++ b/ui/password-modal.go @@ -0,0 +1,133 @@ +// gomuks - A terminal Matrix client written in Go. +// Copyright (C) 2020 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package ui + +import ( + "maunium.net/go/mauview" + "maunium.net/go/tcell" +) + +type PasswordModal struct { + mauview.Component + + outputChan chan string + cancelChan chan struct{} + + form *mauview.Form + + text *mauview.TextField + confirmText *mauview.TextField + + input *mauview.InputField + confirmInput *mauview.InputField + + cancel *mauview.Button + submit *mauview.Button + + parent *MainView +} + +func (view *MainView) AskPassword(title string, isNew bool) (string, bool) { + pwm := NewPasswordModal(view, title, isNew) + view.ShowModal(pwm) + return pwm.Wait() +} + +func NewPasswordModal(parent *MainView, title string, isNew bool) *PasswordModal { + pwm := &PasswordModal{ + parent: parent, + form: mauview.NewForm(), + outputChan: make(chan string, 1), + cancelChan: make(chan struct{}, 1), + } + + pwm.form. + SetColumns([]int{1, 20, 1, 20, 1}). + SetRows([]int{1, 1, 1, 0, 0, 0, 1, 1, 1}) + + width := 45 + height := 8 + + pwm.text = mauview.NewTextField() + if isNew { + pwm.text.SetText("Create a passphrase") + } else { + pwm.text.SetText("Enter the passphrase") + } + pwm.input = mauview.NewInputField(). + SetMaskCharacter('*'). + SetPlaceholder("correct horse battery staple") + pwm.form.AddComponent(pwm.text, 1, 1, 3, 1) + pwm.form.AddFormItem(pwm.input, 1, 2, 3, 1) + + if isNew { + height += 3 + pwm.confirmInput = mauview.NewInputField(). + SetMaskCharacter('*'). + SetPlaceholder("correct horse battery staple"). + SetChangedFunc(pwm.HandleChange) + pwm.input.SetChangedFunc(pwm.HandleChange) + pwm.confirmText = mauview.NewTextField().SetText("Confirm passphrase") + + pwm.form.SetRow(3, 1).SetRow(4, 1).SetRow(5, 1) + pwm.form.AddComponent(pwm.confirmText, 1, 4, 3, 1) + pwm.form.AddFormItem(pwm.confirmInput, 1, 5, 3, 1) + } + + pwm.cancel = mauview.NewButton("Cancel").SetOnClick(pwm.ClickCancel) + pwm.submit = mauview.NewButton("Submit").SetOnClick(pwm.ClickSubmit) + + pwm.form.AddFormItem(pwm.submit, 3, 7, 1, 1) + pwm.form.AddFormItem(pwm.cancel, 1, 7, 1, 1) + + box := mauview.NewBox(pwm.form).SetTitle(title) + center := mauview.Center(box, width, height).SetAlwaysFocusChild(true) + center.Focus() + pwm.form.FocusNextItem() + pwm.Component = center + + return pwm +} + +func (pwm *PasswordModal) HandleChange(_ string) { + if pwm.input.GetText() == pwm.confirmInput.GetText() { + pwm.submit.SetBackgroundColor(mauview.Styles.ContrastBackgroundColor) + } else { + pwm.submit.SetBackgroundColor(tcell.ColorDefault) + } +} + +func (pwm *PasswordModal) ClickCancel() { + pwm.parent.HideModal() + pwm.cancelChan <- struct{}{} +} + +func (pwm *PasswordModal) ClickSubmit() { + if pwm.confirmInput == nil || pwm.input.GetText() == pwm.confirmInput.GetText() { + pwm.parent.HideModal() + pwm.outputChan <- pwm.input.GetText() + } +} + +func (pwm *PasswordModal) Wait() (string, bool) { + select { + case result := <- pwm.outputChan: + return result, true + case <- pwm.cancelChan: + return "", false + } +}