From f38d4eedc64288fffb667fe5656dbc17313a8f32 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Wed, 11 Aug 2021 10:21:38 -0500 Subject: [PATCH 1/7] In progress --- kpmenulib/config.go | 1 + kpmenulib/prompt.go | 75 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/kpmenulib/config.go b/kpmenulib/config.go index 7331781..6bea595 100644 --- a/kpmenulib/config.go +++ b/kpmenulib/config.go @@ -27,6 +27,7 @@ type ConfigurationGeneral struct { NoCache bool // Flag to do not cache master password CacheOneTime bool // Cache the password only the first time you write it CacheTimeout int // Timeout of cache + Autotype string // External autotype command } // ConfigurationExecutable is the sub-structure of the configuration related to tools executed by kpmenu diff --git a/kpmenulib/prompt.go b/kpmenulib/prompt.go index 85eacb4..0c346b8 100644 --- a/kpmenulib/prompt.go +++ b/kpmenulib/prompt.go @@ -332,6 +332,81 @@ func PromptFields(menu *Menu, entry *Entry) (string, ErrorPrompt) { return value, err } +// PromptAutotype executes an external application to select an entry +// Communication protocol: +// BNF-lite: +// optl : opts "\0" +// opts : opts "\n" opt | opt +// opt : Title "\t" Window match +// fieldl : fields "\0" +// fields : fields "\n" field | field +// field : KEY "\t" VALUE +// Protocol: +// -> optl +// <- Title +// -> fieldl +// <- EOF +func PromptAutotype(menu *Menu) (*Entry, ErrorPrompt) { + var entry Entry + var input strings.Builder + + // Prepare autotype command + var command []string + command = []string {menu.Configuration.General.Autotype} + + // Add custom arguments + if menu.Configuration.Style.ArgsEntry != "" { + command = append(command, strings.Split(menu.Configuration.Style.ArgsEntry, " ")...) + } + + // Prepare a list of entries + // Identified by the formatted title and the entry pointer + var listEntries []entryItem + reg, err := regexp.Compile(`{[a-zA-Z]+\}`) + if err != nil { + return &entry, ErrorPrompt{ + Cancelled: false, + Error: err, + } + } + for i, e := range menu.Database.Entries { + // Format entry + title := menu.Configuration.Style.FormatEntry + matches := reg.FindAllString(title, -1) + + // Replace every match + for _, match := range matches { + valueType := match[1 : len(match)-1] // Removes { and } + value := "" // By default empty value + vd := e.FullEntry.GetContent(valueType) + if vd != "" { + value = vd + } + title = strings.Replace(title, match, value, -1) + } + // Be sure to point on the right entry, do not point to the local e + listEntries = append(listEntries, entryItem{Title: title, Entry: &menu.Database.Entries[i]}) + } + + // Prepare input (dmenu items) + for _, e := range listEntries { + input.WriteString(e.Title + "\n") + } + + // Execute prompt + result, errPrompt := executePrompt(command, strings.NewReader(input.String())) + if errPrompt.Error == nil && !errPrompt.Cancelled { + // Get selected entry + for _, e := range listEntries { + if e.Title == result { + entry = *e.Entry + break + } + } + } + return &entry, errPrompt +} + func executePrompt(command []string, input *strings.Reader) (result string, errorPrompt ErrorPrompt) { var out bytes.Buffer var outErr bytes.Buffer From c57714eb65ee64863f8448f56a36fa65662ba87b Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Thu, 12 Aug 2021 11:12:40 -0500 Subject: [PATCH 2/7] Closes #8, adds support for external autotype handling. Include autotype key sequence header --- kpmenulib/clipboard.go | 2 +- kpmenulib/config.go | 37 ++++++++--- kpmenulib/kpmenulib.go | 7 ++ kpmenulib/prompt.go | 144 ++++++++++++++++++++++++----------------- 4 files changed, 119 insertions(+), 71 deletions(-) diff --git a/kpmenulib/clipboard.go b/kpmenulib/clipboard.go index 3003065..e35ef1d 100644 --- a/kpmenulib/clipboard.go +++ b/kpmenulib/clipboard.go @@ -7,7 +7,7 @@ import ( "os/exec" "strings" "time" - + "github.com/google/shlex" ) diff --git a/kpmenulib/config.go b/kpmenulib/config.go index 0ec96b9..41ffd91 100644 --- a/kpmenulib/config.go +++ b/kpmenulib/config.go @@ -28,18 +28,20 @@ type ConfigurationGeneral struct { CacheOneTime bool // Cache the password only the first time you write it CacheTimeout int // Timeout of cache NoOTP bool // Flag to do not handle OTPs - Autotype string // External autotype command + NoAutotype bool // Disable autotype } // ConfigurationExecutable is the sub-structure of the configuration related to tools executed by kpmenu type ConfigurationExecutable struct { - CustomPromptPassword string // Custom executable for prompt password - CustomPromptMenu string // Custom executable for prompt menu - CustomPromptEntries string // Custom executable for prompt entries - CustomPromptFields string // Custom executable for prompt fields - CustomClipboardCopy string // Custom executable for clipboard copy - CustomClipboardPaste string // Custom executable for clipboard paste - CustomClipboardClean string // Custom executable for clipboard clean + CustomPromptPassword string // Custom executable for prompt password + CustomPromptMenu string // Custom executable for prompt menu + CustomPromptEntries string // Custom executable for prompt entries + CustomPromptFields string // Custom executable for prompt fields + CustomClipboardCopy string // Custom executable for clipboard copy + CustomClipboardPaste string // Custom executable for clipboard paste + CustomClipboardClean string // Custom executable for clipboard clean + CustomAutotypeWindowID string // Custom executable for fetching title of active window + CustomAutotypeTyper string // Custom executable for typing results } // ConfigurationStyle is the sub-structure of the configuration related to style of dmenu @@ -68,8 +70,9 @@ type ConfigurationDatabase struct { // Flags is the sub-structure of the configuration used to handle flags that aren't into the config file type Flags struct { - Daemon bool - Version bool + Daemon bool + Version bool + Autotype bool } // Menu tools used for prompts @@ -87,6 +90,12 @@ const ( ClipboardToolCustom = "custom" ) +// Autotype default helpers +const ( + AutotypeWindowIdentifier = "xdotool getwindowfocus getwindowname" + AutotypeTyper = "quasiauto" +) + // NewConfiguration initializes a new Configuration pointer func NewConfiguration() *Configuration { return &Configuration{ @@ -108,6 +117,10 @@ func NewConfiguration() *Configuration { FieldOrder: "Password UserName URL", FillOtherFields: true, }, + Executable: ConfigurationExecutable{ + CustomAutotypeWindowID: AutotypeWindowIdentifier, + CustomAutotypeTyper: AutotypeTyper, + }, } } @@ -143,6 +156,7 @@ func (c *Configuration) InitializeFlags() { // Flags flag.BoolVar(&c.Flags.Daemon, "daemon", false, "Start kpmenu directly as daemon") flag.BoolVarP(&c.Flags.Version, "version", "v", false, "Show kpmenu version") + flag.BoolVar(&c.Flags.Autotype, "autotype", c.Flags.Autotype, "Initiate autotype") // General flag.StringVarP(&c.General.Menu, "menu", "m", c.General.Menu, "Choose which menu to use") @@ -152,6 +166,7 @@ func (c *Configuration) InitializeFlags() { flag.BoolVar(&c.General.CacheOneTime, "cacheOneTime", c.General.CacheOneTime, "Cache the database only the first time") flag.IntVar(&c.General.CacheTimeout, "cacheTimeout", c.General.CacheTimeout, "Timeout of cache in seconds") flag.BoolVar(&c.General.NoOTP, "nootp", c.General.NoOTP, "Disable OTP handling") + flag.BoolVar(&c.General.NoAutotype, "noautotype", c.General.NoAutotype, "Disable autotype handling") // Executable flag.StringVar(&c.Executable.CustomPromptPassword, "customPromptPassword", c.Executable.CustomPromptPassword, "Custom executable for prompt password") @@ -160,6 +175,8 @@ func (c *Configuration) InitializeFlags() { flag.StringVar(&c.Executable.CustomPromptFields, "customPromptFields", c.Executable.CustomPromptFields, "Custom executable for prompt fields") flag.StringVar(&c.Executable.CustomClipboardCopy, "customClipboardCopy", c.Executable.CustomClipboardCopy, "Custom executable for clipboard copy") flag.StringVar(&c.Executable.CustomClipboardPaste, "customClipboardPaste", c.Executable.CustomClipboardPaste, "Custom executable for clipboard paste") + flag.StringVar(&c.Executable.CustomAutotypeWindowID, "customAutotypeWindowID", c.Executable.CustomAutotypeWindowID, "Custom executable for identifying active window for autotype") + flag.StringVar(&c.Executable.CustomAutotypeTyper, "customAutotypeTyper", c.Executable.CustomAutotypeTyper, "Custom executable for autotype typer") // Style flag.StringVar(&c.Style.PasswordBackground, "passwordBackground", c.Style.PasswordBackground, "Color of dmenu background and text for password selection, used to hide password typing") diff --git a/kpmenulib/kpmenulib.go b/kpmenulib/kpmenulib.go index 4f70039..45ba884 100644 --- a/kpmenulib/kpmenulib.go +++ b/kpmenulib/kpmenulib.go @@ -46,6 +46,13 @@ func Execute(menu *Menu) bool { } } + if !menu.Configuration.General.NoAutotype && menu.Configuration.Flags.Autotype { + if err := PromptAutotype(menu); err.Error != nil { + log.Print(err.Error) + } + return false + } + // Open menu if err := menu.OpenMenu(); err != nil { log.Print(err) diff --git a/kpmenulib/prompt.go b/kpmenulib/prompt.go index c71a153..335b414 100644 --- a/kpmenulib/prompt.go +++ b/kpmenulib/prompt.go @@ -352,79 +352,103 @@ func PromptFields(menu *Menu, entry *Entry) (string, ErrorPrompt) { return value, err } -// PromptAutotype executes an external application to select an entry -// Communication protocol: -// BNF-lite: -// optl : opts "\0" -// opts : opts "\n" opt | opt -// opt : Title "\t" Window match -// fieldl : fields "\0" -// fields : fields "\n" field | field -// field : KEY "\t" VALUE -// Protocol: -// -> optl -// <- Title -// -> fieldl -// <- EOF -func PromptAutotype(menu *Menu) (*Entry, ErrorPrompt) { - var entry Entry - var input strings.Builder - +// PromptAutotype executes an external application to select an entry and then +// runs an autotype program with the entry's data. +// +// Field data is sent to the autotype child process on STDIN as TSV data. The +// first row is the key sequence. +// +// {USERNAME}{TAB}{PASSWORD}{ENTER} +// key value +// +// If KeepassXC attributes for autotype exist for the record, they're used. If +// they do not exist, username & password are used, and if OTP data exists for +// the record, that is sent as the third field. +// +// STDIN is closed when all fields have been sent. +// +// Note that it is currently not possible to get the key sequence from the DB, +// so it is always the default. +func PromptAutotype(menu *Menu) ErrorPrompt { // Prepare autotype command var command []string - command = []string {menu.Configuration.General.Autotype} - - // Add custom arguments - if menu.Configuration.Style.ArgsEntry != "" { - command = append(command, strings.Split(menu.Configuration.Style.ArgsEntry, " ")...) - } + command = strings.Split(menu.Configuration.Executable.CustomAutotypeWindowID, " ") - // Prepare a list of entries - // Identified by the formatted title and the entry pointer - var listEntries []entryItem - reg, err := regexp.Compile(`{[a-zA-Z]+\}`) - if err != nil { - return &entry, ErrorPrompt{ - Cancelled: false, - Error: err, - } + activeWindow, errPrompt := executePrompt(command, nil) + if errPrompt.Error != nil || errPrompt.Cancelled { + return errPrompt } - for i, e := range menu.Database.Entries { - // Format entry - title := menu.Configuration.Style.FormatEntry - matches := reg.FindAllString(title, -1) - // Replace every match - for _, match := range matches { - valueType := match[1 : len(match)-1] // Removes { and } - value := "" // By default empty value - vd := e.FullEntry.GetContent(valueType) - if vd != "" { - value = vd + // Only a pointer to allow the nil test for a positive find + var entry *Entry + for _, e := range menu.Database.Entries { + ms := e.FullEntry.GetContent("Title") + at := e.FullEntry.AutoType.Association + // Check if there's a window association, and if so, make sure it's a regexp + if at != nil && at.Window != "" { + // For regexp, remove the wrapping // and replace all star globs with .* + if !strings.HasPrefix(at.Window, "//") { + ms = strings.ReplaceAll(at.Window, "*", ".*") + if len(ms) > 2 { + ms = ms[2:] + } + if len(ms) > 2 { + ms = ms[:len(ms)-2] + } } - title = strings.Replace(title, match, value, -1) } - // Be sure to point on the right entry, do not point to the local e - listEntries = append(listEntries, entryItem{Title: title, Entry: &menu.Database.Entries[i]}) + reg, err := regexp.Compile(ms) + if err != nil { + continue + } + if reg.Match([]byte(activeWindow)) { + entry = &e + break + } } - // Prepare input (dmenu items) - for _, e := range listEntries { - input.WriteString(e.Title + "\n") + if entry == nil { + errPrompt.Error = fmt.Errorf("no autotype window match for %s", activeWindow) + return errPrompt } - // Execute prompt - result, errPrompt := executePrompt(command, strings.NewReader(input.String())) - if errPrompt.Error == nil && !errPrompt.Cancelled { - // Get selected entry - for _, e := range listEntries { - if e.Title == result { - entry = *e.Entry - break - } + var input strings.Builder + fe := entry.FullEntry + if fe.AutoType.Association != nil && fe.AutoType.Association.KeystrokeSequence != "" { + input.WriteString(fe.AutoType.Association.KeystrokeSequence) + input.WriteString("\n") + } else if !menu.Configuration.General.NoOTP && (fe.GetContent(OTP) != "" || fe.GetContent(TOTPSEED) != "") { + input.WriteString("{USERNAME}{TAB}{PASSWORD}{TAB}{TOTP}{ENTER}\n") + } else { + input.WriteString("{USERNAME}{TAB}{PASSWORD}{ENTER}\n") + } + input.WriteString("UserName") + input.WriteString("\t") + input.WriteString(entry.FullEntry.GetContent("UserName")) + input.WriteString("\n") + + input.WriteString("Password") + input.WriteString("\t") + input.WriteString(entry.FullEntry.GetContent("Password")) + input.WriteString("\n") + + if !menu.Configuration.General.NoOTP && (fe.GetContent(OTP) != "" || fe.GetContent(TOTPSEED) != "") { + value, err := CreateOTP(fe, time.Now().Unix()) + if err != nil { + errPrompt.Cancelled = true + errPrompt.Error = fmt.Errorf("failed to create otp: %s", err) + return errPrompt } + input.WriteString("OTP") + input.WriteString("\t") + input.WriteString(value) + input.WriteString("\n") } - return &entry, errPrompt + + command = strings.Split(menu.Configuration.Executable.CustomAutotypeTyper, " ") + _, errPrompt = executePrompt(command, strings.NewReader(input.String())) + + return errPrompt } func executePrompt(command []string, input *strings.Reader) (result string, errorPrompt ErrorPrompt) { From ba0fdc2f549dedd26e8e4af168fa5bb1feac06c1 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Thu, 12 Aug 2021 14:49:35 -0500 Subject: [PATCH 3/7] Logic reversed on regexp matching. --- kpmenulib/prompt.go | 1 + 1 file changed, 1 insertion(+) diff --git a/kpmenulib/prompt.go b/kpmenulib/prompt.go index 335b414..4e7e5ef 100644 --- a/kpmenulib/prompt.go +++ b/kpmenulib/prompt.go @@ -389,6 +389,7 @@ func PromptAutotype(menu *Menu) ErrorPrompt { // For regexp, remove the wrapping // and replace all star globs with .* if !strings.HasPrefix(at.Window, "//") { ms = strings.ReplaceAll(at.Window, "*", ".*") + } else { if len(ms) > 2 { ms = ms[2:] } From 39659c58baca0ae8b17a9d44a8d7fb9bda51792d Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Tue, 17 Aug 2021 09:25:51 -0500 Subject: [PATCH 4/7] Updates gokeepasslib; adds confirmation flag; handles multiple autotype matches; refactors command build. --- go.mod | 2 +- go.sum | 6 + kpmenulib/clientserver.go | 1 + kpmenulib/config.go | 2 + kpmenulib/prompt.go | 343 ++++++++++++++++++++------------------ 5 files changed, 189 insertions(+), 165 deletions(-) diff --git a/go.mod b/go.mod index f94174d..8b59232 100644 --- a/go.mod +++ b/go.mod @@ -6,5 +6,5 @@ require ( github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.8.1 - github.com/tobischo/gokeepasslib/v3 v3.1.0 + github.com/tobischo/gokeepasslib/v3 v3.2.2 ) diff --git a/go.sum b/go.sum index d56d296..ea4f841 100644 --- a/go.sum +++ b/go.sum @@ -308,6 +308,10 @@ github.com/tobischo/gokeepasslib/v3 v3.0.3 h1:c1yQChFnPdDAl0FB5HprXgbSSe/aCAPQDc github.com/tobischo/gokeepasslib/v3 v3.0.3/go.mod h1:SbRMQTuN5anbqQzWFS4NMcjVyyzgxt5owqvbNi1Vzsk= github.com/tobischo/gokeepasslib/v3 v3.1.0 h1:FUpIHQlgDCtKQ9VSHwqmW1PxUcT96wAvPjmPypbR6Wg= github.com/tobischo/gokeepasslib/v3 v3.1.0/go.mod h1:SbRMQTuN5anbqQzWFS4NMcjVyyzgxt5owqvbNi1Vzsk= +github.com/tobischo/gokeepasslib/v3 v3.2.1 h1:I9p0j3EiLtTk+hKHi+Ar0uvkGQEDQVAPtobttqq+Y/Y= +github.com/tobischo/gokeepasslib/v3 v3.2.1/go.mod h1:iwxOzUuk/ccA0mitrFC4MovT1p0IRY8EA35L4u1x/ug= +github.com/tobischo/gokeepasslib/v3 v3.2.2 h1:j7vHDJsyvQkjQn5EB+1skD1tfABiL/ZbbZ3OHOImoho= +github.com/tobischo/gokeepasslib/v3 v3.2.2/go.mod h1:iwxOzUuk/ccA0mitrFC4MovT1p0IRY8EA35L4u1x/ug= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= @@ -344,6 +348,7 @@ golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200429183012-4b2356b1ed79 h1:IaQbIIB2X/Mp/DKctl6ROxz1KyMlKp4uyvL6+kQ7C88= golang.org/x/crypto v0.0.0-20200429183012-4b2356b1ed79/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI= @@ -476,6 +481,7 @@ golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3 h1:5B6i6EAiSYyejWfvc5Rc9BbI3rzIsrrXfAQBWnYfn+w= golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200513112337-417ce2331b5c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/kpmenulib/clientserver.go b/kpmenulib/clientserver.go index 3d0bad7..a397e3e 100644 --- a/kpmenulib/clientserver.go +++ b/kpmenulib/clientserver.go @@ -51,6 +51,7 @@ func StartServer(m *Menu) (err error) { // Handle packet request handlePacket := func(packet Packet) bool { log.Printf("received a client call with args \"%v\"", packet.CliArguments) + m.Configuration.Flags.Autotype = false m.CliArguments = packet.CliArguments return Show(m) } diff --git a/kpmenulib/config.go b/kpmenulib/config.go index 41ffd91..8977085 100644 --- a/kpmenulib/config.go +++ b/kpmenulib/config.go @@ -29,6 +29,7 @@ type ConfigurationGeneral struct { CacheTimeout int // Timeout of cache NoOTP bool // Flag to do not handle OTPs NoAutotype bool // Disable autotype + AutotypeConfirm bool // User must always confirm } // ConfigurationExecutable is the sub-structure of the configuration related to tools executed by kpmenu @@ -167,6 +168,7 @@ func (c *Configuration) InitializeFlags() { flag.IntVar(&c.General.CacheTimeout, "cacheTimeout", c.General.CacheTimeout, "Timeout of cache in seconds") flag.BoolVar(&c.General.NoOTP, "nootp", c.General.NoOTP, "Disable OTP handling") flag.BoolVar(&c.General.NoAutotype, "noautotype", c.General.NoAutotype, "Disable autotype handling") + flag.BoolVar(&c.General.AutotypeConfirm, "autotypealwaysconfirm", c.General.AutotypeConfirm, "Always confirm autotype, even when there's only 1 selection") // Executable flag.StringVar(&c.Executable.CustomPromptPassword, "customPromptPassword", c.Executable.CustomPromptPassword, "Custom executable for prompt password") diff --git a/kpmenulib/prompt.go b/kpmenulib/prompt.go index 4e7e5ef..e8cc760 100644 --- a/kpmenulib/prompt.go +++ b/kpmenulib/prompt.go @@ -9,6 +9,7 @@ import ( "time" "github.com/google/shlex" + "github.com/tobischo/gokeepasslib/v3" ) // MenuSelection is an enum used for prompt menu selection @@ -42,40 +43,10 @@ type ErrorPrompt struct { // Returns the written password func PromptPassword(menu *Menu) (string, ErrorPrompt) { // Prepare dmenu/rofi - var command []string - switch menu.Configuration.General.Menu { - case "rofi": - command = []string{ - "rofi", - "-i", - "-dmenu", - "-p", menu.Configuration.Style.TextPassword, - "-password", - } - case "wofi": - command = []string{ - "wofi", - "-i", - "-d", - "-p", menu.Configuration.Style.TextPassword, - "--password", - } - case "dmenu": - command = []string{ - "dmenu", - "-i", - "-p", menu.Configuration.Style.TextPassword, - "-nb", menu.Configuration.Style.PasswordBackground, - "-nf", menu.Configuration.Style.PasswordBackground, - } - case "custom": - var err error - command, err = shlex.Split(menu.Configuration.Executable.CustomPromptPassword) - if err != nil { - var errorPrompt ErrorPrompt - errorPrompt.Error = fmt.Errorf("failed to parse custom prompt password, exiting") - return "", errorPrompt - } + command, err := getCommand(menu, menu.Configuration.Style.TextPassword, true, menu.Configuration.Executable.CustomPromptPassword) + ep := ErrorPrompt{} + if err != ep { + return "", err } // Add custom arguments @@ -94,37 +65,10 @@ func PromptMenu(menu *Menu) (MenuSelection, ErrorPrompt) { var input strings.Builder // Prepare dmenu/rofi - var command []string - switch menu.Configuration.General.Menu { - case "rofi": - command = []string{ - "rofi", - "-i", - "-dmenu", - "-p", menu.Configuration.Style.TextMenu, - } - case "wofi": - command = []string{ - "wofi", - "-i", - "-d", - "-p", menu.Configuration.Style.TextMenu, - } - case "dmenu": - command = []string{ - "dmenu", - "-i", - "-p", menu.Configuration.Style.TextMenu, - } - case "custom": - var err error - command, err = shlex.Split(menu.Configuration.Executable.CustomPromptMenu) - if err != nil { - var errorPrompt ErrorPrompt - errorPrompt.Cancelled = true - errorPrompt.Error = fmt.Errorf("failed to parse custom prompt menu, exiting") - return 0, errorPrompt - } + command, err := getCommand(menu, menu.Configuration.Style.TextMenu, false, menu.Configuration.Executable.CustomPromptMenu) + ep := ErrorPrompt{} + if err != ep { + return selection, err } // Add custom arguments @@ -158,37 +102,11 @@ func PromptEntries(menu *Menu) (*Entry, ErrorPrompt) { var entry Entry var input strings.Builder - // Prepare dmenu/rofi - var command []string - switch menu.Configuration.General.Menu { - case "rofi": - command = []string{ - "rofi", - "-i", - "-dmenu", - "-p", menu.Configuration.Style.TextEntry, - } - case "wofi": - command = []string{ - "wofi", - "-i", - "-d", - "-p", menu.Configuration.Style.TextEntry, - } - case "dmenu": - command = []string{ - "dmenu", - "-i", - "-p", menu.Configuration.Style.TextEntry, - } - case "custom": - var err error - command, err = shlex.Split(menu.Configuration.Executable.CustomPromptEntries) - if err != nil { - var errorPrompt ErrorPrompt - errorPrompt.Error = fmt.Errorf("failed to parse custom prompt entries, exiting") - return nil, errorPrompt - } + // Prepare autotype command + command, erp := getCommand(menu, menu.Configuration.Style.TextEntry, false, menu.Configuration.Executable.CustomPromptEntries) + ep := ErrorPrompt{} + if erp != ep { + return &entry, erp } // Add custom arguments @@ -250,37 +168,11 @@ func PromptFields(menu *Menu, entry *Entry) (string, ErrorPrompt) { var value string var input strings.Builder - // Prepare dmenu/rofi - var command []string - switch menu.Configuration.General.Menu { - case "rofi": - command = []string{ - "rofi", - "-i", - "-dmenu", - "-p", menu.Configuration.Style.TextEntry, - } - case "wofi": - command = []string{ - "wofi", - "-i", - "-d", - "-p", menu.Configuration.Style.TextEntry, - } - case "dmenu": - command = []string{ - "dmenu", - "-i", - "-p", menu.Configuration.Style.TextField, - } - case "custom": - var err error - command, err = shlex.Split(menu.Configuration.Executable.CustomPromptFields) - if err != nil { - var errorPrompt ErrorPrompt - errorPrompt.Error = fmt.Errorf("failed to parse custom prompt fields, exiting") - return "", errorPrompt - } + // Prepare autotype command + command, erp := getCommand(menu, menu.Configuration.Style.TextEntry, false, menu.Configuration.Executable.CustomPromptFields) + ep := ErrorPrompt{} + if erp != ep { + return value, erp } // Add custom arguments @@ -352,6 +244,35 @@ func PromptFields(menu *Menu, entry *Entry) (string, ErrorPrompt) { return value, err } +func PromptChoose(menu *Menu, items []string) (int, ErrorPrompt) { + var input strings.Builder + + // Prepare autotype command + command, erp := getCommand(menu, menu.Configuration.Style.TextEntry, false, menu.Configuration.Executable.CustomPromptFields) + ep := ErrorPrompt{} + if erp != ep { + return -1, erp + } + + // Prepare input (dmenu items) + for _, e := range items { + input.WriteString(e + "\n") + } + + // Execute prompt + result, err := executePrompt(command, strings.NewReader(input.String())) + if err.Error == nil && !err.Cancelled { + // Ensures selection is one of the items + for i, sel := range items { + // Match for entry title and selected entry + if result == sel { + return i, err + } + } + } + return -1, err +} + // PromptAutotype executes an external application to select an entry and then // runs an autotype program with the entry's data. // @@ -370,71 +291,116 @@ func PromptFields(menu *Menu, entry *Entry) (string, ErrorPrompt) { // Note that it is currently not possible to get the key sequence from the DB, // so it is always the default. func PromptAutotype(menu *Menu) ErrorPrompt { + // TODO user select entry instead of xdotool // Prepare autotype command - var command []string - command = strings.Split(menu.Configuration.Executable.CustomAutotypeWindowID, " ") + command := strings.Split(menu.Configuration.Executable.CustomAutotypeWindowID, " ") activeWindow, errPrompt := executePrompt(command, nil) if errPrompt.Error != nil || errPrompt.Cancelled { return errPrompt } - // Only a pointer to allow the nil test for a positive find - var entry *Entry + type pair struct { + reg string + ent Entry + seq string + } + matches := make([]pair, 0) for _, e := range menu.Database.Entries { - ms := e.FullEntry.GetContent("Title") - at := e.FullEntry.AutoType.Association - // Check if there's a window association, and if so, make sure it's a regexp - if at != nil && at.Window != "" { - // For regexp, remove the wrapping // and replace all star globs with .* - if !strings.HasPrefix(at.Window, "//") { - ms = strings.ReplaceAll(at.Window, "*", ".*") - } else { - if len(ms) > 2 { - ms = ms[2:] + defaultSequence := "{USERNAME}{TAB}{PASSWORD}{ENTER}" + if e.FullEntry.AutoType.DefaultSequence != "" { + defaultSequence = e.FullEntry.AutoType.DefaultSequence + } + // [Regexp, KeySequence] + mss := [][]string{{".*" + e.FullEntry.GetContent("Title") + ".*", defaultSequence}} + if e.FullEntry.AutoType.Associations != nil { + for _, at := range e.FullEntry.AutoType.Associations { + // Check if there's a window association, and if so, make sure it's a regexp + if at.Window == "" { + continue + } + ms := at.Window + if !strings.HasPrefix(at.Window, "//") { + // Replace all star globs with .* + ms = strings.ReplaceAll(ms, "*", ".*") + } else { + // For regexp, remove the wrapping + if len(ms) > 2 { + ms = ms[2:] + } + if len(ms) > 2 { + ms = ms[:len(ms)-2] + } } - if len(ms) > 2 { - ms = ms[:len(ms)-2] + seq := defaultSequence + if at.KeystrokeSequence != "" { + seq = at.KeystrokeSequence } + mss = append(mss, []string{ms, seq}) } } - reg, err := regexp.Compile(ms) - if err != nil { - continue - } - if reg.Match([]byte(activeWindow)) { - entry = &e - break + for _, ms := range mss { + reg, err := regexp.Compile(ms[0]) + if err != nil { + continue + } + if reg.Match([]byte(activeWindow)) { + matches = append(matches, pair{ms[0], e, ms[1]}) + } } } - if entry == nil { + var entry gokeepasslib.Entry + var keySeq string + switch len(matches) { + case 0: errPrompt.Error = fmt.Errorf("no autotype window match for %s", activeWindow) return errPrompt + case 1: + entry = matches[0].ent.FullEntry + keySeq = matches[0].seq + default: + items := make([]string, len(matches)) + for i, m := range matches { + items[i] = fmt.Sprintf("%s - %s - (%s)", m.ent.FullEntry.GetContent("Title"), m.reg, m.seq) + } + sel, err := PromptChoose(menu, items) + ep := ErrorPrompt{} + if err != ep || sel == -1 { + return ep + } + entry = matches[sel].ent.FullEntry + keySeq = matches[sel].seq } - var input strings.Builder - fe := entry.FullEntry - if fe.AutoType.Association != nil && fe.AutoType.Association.KeystrokeSequence != "" { - input.WriteString(fe.AutoType.Association.KeystrokeSequence) - input.WriteString("\n") - } else if !menu.Configuration.General.NoOTP && (fe.GetContent(OTP) != "" || fe.GetContent(TOTPSEED) != "") { - input.WriteString("{USERNAME}{TAB}{PASSWORD}{TAB}{TOTP}{ENTER}\n") - } else { - input.WriteString("{USERNAME}{TAB}{PASSWORD}{ENTER}\n") + if len(matches) == 1 && menu.Configuration.General.AutotypeConfirm { + sel, err := PromptChoose(menu, []string{ + "Auto-type " + matches[0].ent.FullEntry.GetContent("Title"), + "Cancel", + }) + nulErr := ErrorPrompt{} + if sel != 0 || err != nulErr { + errPrompt.Cancelled = true + return errPrompt + } } + + var input strings.Builder + input.WriteString(keySeq) + input.WriteString("\n") + // TODO sequence parser, send only sequence keys input.WriteString("UserName") input.WriteString("\t") - input.WriteString(entry.FullEntry.GetContent("UserName")) + input.WriteString(entry.GetContent("UserName")) input.WriteString("\n") input.WriteString("Password") input.WriteString("\t") - input.WriteString(entry.FullEntry.GetContent("Password")) + input.WriteString(entry.GetContent("Password")) input.WriteString("\n") - if !menu.Configuration.General.NoOTP && (fe.GetContent(OTP) != "" || fe.GetContent(TOTPSEED) != "") { - value, err := CreateOTP(fe, time.Now().Unix()) + if !menu.Configuration.General.NoOTP && (entry.GetContent(OTP) != "" || entry.GetContent(TOTPSEED) != "") { + value, err := CreateOTP(entry, time.Now().Unix()) if err != nil { errPrompt.Cancelled = true errPrompt.Error = fmt.Errorf("failed to create otp: %s", err) @@ -445,6 +411,7 @@ func PromptAutotype(menu *Menu) ErrorPrompt { input.WriteString(value) input.WriteString("\n") } + fmt.Printf("Sending:\n%s\n", input.String()) command = strings.Split(menu.Configuration.Executable.CustomAutotypeTyper, " ") _, errPrompt = executePrompt(command, strings.NewReader(input.String())) @@ -504,3 +471,51 @@ func contains(array []string, value string) bool { func (el MenuSelection) String() string { return menuSelections[el] } + +func getCommand(menu *Menu, style string, pass bool, custom string) ([]string, ErrorPrompt) { + var command []string + switch menu.Configuration.General.Menu { + case "rofi": + command = []string{ + "rofi", + "-i", + "-dmenu", + "-p", style, + } + if pass { + command = append(command, "-password") + } + case "wofi": + command = []string{ + "wofi", + "-i", + "-d", + "-p", style, + } + if pass { + command = append(command, "--password") + } + case "dmenu": + command = []string{ + "dmenu", + "-i", + "-p", style, + } + if pass { + command = append(command, []string{ + "-nb", menu.Configuration.Style.PasswordBackground, + "-nf", menu.Configuration.Style.PasswordBackground, + }...) + } + case "custom": + var err error + command, err = shlex.Split(custom) + if err != nil { + var errorPrompt ErrorPrompt + errorPrompt.Cancelled = true + errorPrompt.Error = fmt.Errorf("failed to parse custom prompt, exiting") + return []string{}, errorPrompt + } + } + return command, ErrorPrompt{} +} From 700761bdc0a9d52181b5487093f5a94f2324985e Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Tue, 17 Aug 2021 13:52:55 -0500 Subject: [PATCH 5/7] Adds support for sequence parsing --- kpmenulib/config.go | 6 +- kpmenulib/kpmenulib.go | 2 +- kpmenulib/prompt.go | 302 ++++++++++++++++++++++--------------- kpmenulib/sequence.go | 218 ++++++++++++++++++++++++++ kpmenulib/sequence_test.go | 108 +++++++++++++ 5 files changed, 512 insertions(+), 124 deletions(-) create mode 100644 kpmenulib/sequence.go create mode 100644 kpmenulib/sequence_test.go diff --git a/kpmenulib/config.go b/kpmenulib/config.go index 8977085..fd74831 100644 --- a/kpmenulib/config.go +++ b/kpmenulib/config.go @@ -28,8 +28,9 @@ type ConfigurationGeneral struct { CacheOneTime bool // Cache the password only the first time you write it CacheTimeout int // Timeout of cache NoOTP bool // Flag to do not handle OTPs - NoAutotype bool // Disable autotype + DisableAutotype bool // Disable autotype AutotypeConfirm bool // User must always confirm + AutotypeNoAuto bool // Always prompt user to select the entry to autotype } // ConfigurationExecutable is the sub-structure of the configuration related to tools executed by kpmenu @@ -167,8 +168,9 @@ func (c *Configuration) InitializeFlags() { flag.BoolVar(&c.General.CacheOneTime, "cacheOneTime", c.General.CacheOneTime, "Cache the database only the first time") flag.IntVar(&c.General.CacheTimeout, "cacheTimeout", c.General.CacheTimeout, "Timeout of cache in seconds") flag.BoolVar(&c.General.NoOTP, "nootp", c.General.NoOTP, "Disable OTP handling") - flag.BoolVar(&c.General.NoAutotype, "noautotype", c.General.NoAutotype, "Disable autotype handling") + flag.BoolVar(&c.General.DisableAutotype, "noautotype", c.General.DisableAutotype, "Disable autotype handling") flag.BoolVar(&c.General.AutotypeConfirm, "autotypealwaysconfirm", c.General.AutotypeConfirm, "Always confirm autotype, even when there's only 1 selection") + flag.BoolVar(&c.General.AutotypeNoAuto, "autotypeusersel", c.General.AutotypeNoAuto, "Prompt for autotype entry instead of trying to detect by active window title") // Executable flag.StringVar(&c.Executable.CustomPromptPassword, "customPromptPassword", c.Executable.CustomPromptPassword, "Custom executable for prompt password") diff --git a/kpmenulib/kpmenulib.go b/kpmenulib/kpmenulib.go index 45ba884..5f5657b 100644 --- a/kpmenulib/kpmenulib.go +++ b/kpmenulib/kpmenulib.go @@ -46,7 +46,7 @@ func Execute(menu *Menu) bool { } } - if !menu.Configuration.General.NoAutotype && menu.Configuration.Flags.Autotype { + if !menu.Configuration.General.DisableAutotype && menu.Configuration.Flags.Autotype { if err := PromptAutotype(menu); err.Error != nil { log.Print(err.Error) } diff --git a/kpmenulib/prompt.go b/kpmenulib/prompt.go index e8cc760..5f50cc3 100644 --- a/kpmenulib/prompt.go +++ b/kpmenulib/prompt.go @@ -282,138 +282,101 @@ func PromptChoose(menu *Menu, items []string) (int, ErrorPrompt) { // {USERNAME}{TAB}{PASSWORD}{ENTER} // key value // -// If KeepassXC attributes for autotype exist for the record, they're used. If -// they do not exist, username & password are used, and if OTP data exists for -// the record, that is sent as the third field. +// If Keepass attributes for autotype exist for the record, they're used. If +// they do not exist, username & password are used; if there is no default key +// sequence, `{username}{tab}{password}{tab}{totp}{enter}` is used if OTP is +// set and OTP is not disabled, or `{USERNAME}{TAB}{PASSWORD}{ENTER}` +// otherwise. The key sequence is parsed, and only the key/values defined in +// the sequence are sent. It is incumbent on the autotype callee to parse the +// sequence for meta entries such as DELAY and special characters. // // STDIN is closed when all fields have been sent. // -// Note that it is currently not possible to get the key sequence from the DB, -// so it is always the default. +// [Keepass match rules](https://keepass.info/help/base/autotype.html) are, in +// order of precedence: +// 1. Association string as a regexp (if the string is wrapped in `//`) +// 2. Association string as a simple glob +// 3. Entry title as a subset of the window title +// +// If multiple matches are found, the user is prompted to select one. +// +// If `--autotypenoauto` is set, the user will *always* be prompted run autotype, or cancel. +// +// `--noautotype` is handled by the caller -- this function does not perform that check. func PromptAutotype(menu *Menu) ErrorPrompt { - // TODO user select entry instead of xdotool - // Prepare autotype command - command := strings.Split(menu.Configuration.Executable.CustomAutotypeWindowID, " ") - - activeWindow, errPrompt := executePrompt(command, nil) - if errPrompt.Error != nil || errPrompt.Cancelled { - return errPrompt - } - - type pair struct { - reg string - ent Entry - seq string - } - matches := make([]pair, 0) - for _, e := range menu.Database.Entries { - defaultSequence := "{USERNAME}{TAB}{PASSWORD}{ENTER}" - if e.FullEntry.AutoType.DefaultSequence != "" { - defaultSequence = e.FullEntry.AutoType.DefaultSequence + var entry *Entry + // The rule for keepass(es) key sequence selection is: + // Assoc > Configured entry default > appl. default + var keySeq string + var errPrompt ErrorPrompt + if menu.Configuration.General.AutotypeNoAuto { + entry, errPrompt = PromptEntries(menu) + if entry == nil || errPrompt.Cancelled { + errPrompt.Cancelled = true + errPrompt.Error = fmt.Errorf("user cancelled") + return errPrompt } - // [Regexp, KeySequence] - mss := [][]string{{".*" + e.FullEntry.GetContent("Title") + ".*", defaultSequence}} - if e.FullEntry.AutoType.Associations != nil { - for _, at := range e.FullEntry.AutoType.Associations { - // Check if there's a window association, and if so, make sure it's a regexp - if at.Window == "" { - continue - } - ms := at.Window - if !strings.HasPrefix(at.Window, "//") { - // Replace all star globs with .* - ms = strings.ReplaceAll(ms, "*", ".*") - } else { - // For regexp, remove the wrapping - if len(ms) > 2 { - ms = ms[2:] - } - if len(ms) > 2 { - ms = ms[:len(ms)-2] + + // Try to guess the key sequence + keySeq = entry.FullEntry.AutoType.DefaultSequence + if keySeq == "" { + if entry.FullEntry.AutoType.Associations != nil { + for _, assoc := range entry.FullEntry.AutoType.Associations { + if assoc.KeystrokeSequence != "" { + keySeq = assoc.KeystrokeSequence + break } } - seq := defaultSequence - if at.KeystrokeSequence != "" { - seq = at.KeystrokeSequence - } - mss = append(mss, []string{ms, seq}) } } - for _, ms := range mss { - reg, err := regexp.Compile(ms[0]) - if err != nil { - continue - } - if reg.Match([]byte(activeWindow)) { - matches = append(matches, pair{ms[0], e, ms[1]}) - } - } - } - - var entry gokeepasslib.Entry - var keySeq string - switch len(matches) { - case 0: - errPrompt.Error = fmt.Errorf("no autotype window match for %s", activeWindow) - return errPrompt - case 1: - entry = matches[0].ent.FullEntry - keySeq = matches[0].seq - default: - items := make([]string, len(matches)) - for i, m := range matches { - items[i] = fmt.Sprintf("%s - %s - (%s)", m.ent.FullEntry.GetContent("Title"), m.reg, m.seq) - } - sel, err := PromptChoose(menu, items) - ep := ErrorPrompt{} - if err != ep || sel == -1 { - return ep - } - entry = matches[sel].ent.FullEntry - keySeq = matches[sel].seq - } - - if len(matches) == 1 && menu.Configuration.General.AutotypeConfirm { - sel, err := PromptChoose(menu, []string{ - "Auto-type " + matches[0].ent.FullEntry.GetContent("Title"), - "Cancel", - }) - nulErr := ErrorPrompt{} - if sel != 0 || err != nulErr { + } else { + entry, keySeq, errPrompt = identifyWindow(menu) + if entry == nil || errPrompt.Cancelled { errPrompt.Cancelled = true + errPrompt.Error = fmt.Errorf("no entry matched") return errPrompt } } + if keySeq == "" { + keySeq = "{USERNAME}{TAB}{PASSWORD}{ENTER}" + } + + fe := entry.FullEntry var input strings.Builder input.WriteString(keySeq) input.WriteString("\n") - // TODO sequence parser, send only sequence keys - input.WriteString("UserName") - input.WriteString("\t") - input.WriteString(entry.GetContent("UserName")) - input.WriteString("\n") - - input.WriteString("Password") - input.WriteString("\t") - input.WriteString(entry.GetContent("Password")) - input.WriteString("\n") - - if !menu.Configuration.General.NoOTP && (entry.GetContent(OTP) != "" || entry.GetContent(TOTPSEED) != "") { - value, err := CreateOTP(entry, time.Now().Unix()) - if err != nil { - errPrompt.Cancelled = true - errPrompt.Error = fmt.Errorf("failed to create otp: %s", err) - return errPrompt + seq := NewSequence() + seq.Parse(keySeq) + for _, k := range seq.SeqEntries { + if k.Type == FIELD { + var value string + if k.Token == "TOTP" { + // If the sequence asks for TOTP but the user has disabled it + // write a dummy code. **Not** writing it would break the + // sequence, which is ordered. + if menu.Configuration.General.NoOTP { + value = "000000" + } else { + var err error + value, err = CreateOTP(fe, time.Now().Unix()) + if err != nil { + errPrompt.Cancelled = true + errPrompt.Error = fmt.Errorf("failed to create otp: %s", err) + return errPrompt + } + } + } else { + value = getContent(fe, k.Token) + } + input.WriteString(k.Token) + input.WriteString("\t") + input.WriteString(value) + input.WriteString("\n") } - input.WriteString("OTP") - input.WriteString("\t") - input.WriteString(value) - input.WriteString("\n") } - fmt.Printf("Sending:\n%s\n", input.String()) - command = strings.Split(menu.Configuration.Executable.CustomAutotypeTyper, " ") + command := strings.Split(menu.Configuration.Executable.CustomAutotypeTyper, " ") _, errPrompt = executePrompt(command, strings.NewReader(input.String())) return errPrompt @@ -459,15 +422,6 @@ func executePrompt(command []string, input *strings.Reader) (result string, erro return } -func contains(array []string, value string) bool { - for _, n := range array { - if value == n { - return true - } - } - return false -} - func (el MenuSelection) String() string { return menuSelections[el] } @@ -519,3 +473,109 @@ func getCommand(menu *Menu, style string, pass bool, custom string) ([]string, E } return command, ErrorPrompt{} } + +func identifyWindow(menu *Menu) (*Entry, string, ErrorPrompt) { + // Prepare autotype command + command := strings.Split(menu.Configuration.Executable.CustomAutotypeWindowID, " ") + + activeWindow, errPrompt := executePrompt(command, nil) + if errPrompt.Error != nil || errPrompt.Cancelled { + return &Entry{}, "", errPrompt + } + + type pair struct { + reg string + ent Entry + seq string + } + matches := make([]pair, 0) + for _, e := range menu.Database.Entries { + defaultSequence := "{USERNAME}{TAB}{PASSWORD}{ENTER}" + if e.FullEntry.AutoType.DefaultSequence != "" { + defaultSequence = e.FullEntry.AutoType.DefaultSequence + } + // [Regexp, KeySequence] + mss := [][]string{{".*" + e.FullEntry.GetContent("Title") + ".*", defaultSequence}} + if e.FullEntry.AutoType.Associations != nil { + for _, at := range e.FullEntry.AutoType.Associations { + // Check if there's a window association, and if so, make sure it's a regexp + if at.Window == "" { + continue + } + ms := at.Window + if !strings.HasPrefix(at.Window, "//") { + // Replace all star globs with .* + ms = strings.ReplaceAll(ms, "*", ".*") + } else { + // For regexp, remove the wrapping + if len(ms) > 2 { + ms = ms[2:] + } + if len(ms) > 2 { + ms = ms[:len(ms)-2] + } + } + seq := defaultSequence + if at.KeystrokeSequence != "" { + seq = at.KeystrokeSequence + } + mss = append(mss, []string{ms, seq}) + } + } + for _, ms := range mss { + reg, err := regexp.Compile(ms[0]) + if err != nil { + continue + } + if reg.Match([]byte(activeWindow)) { + matches = append(matches, pair{ms[0], e, ms[1]}) + } + } + } + + var entry *Entry + var keySeq string + switch len(matches) { + case 0: + errPrompt.Error = fmt.Errorf("no autotype window match for %s", activeWindow) + return entry, keySeq, errPrompt + case 1: + entry = &matches[0].ent + keySeq = matches[0].seq + default: + items := make([]string, len(matches)) + for i, m := range matches { + items[i] = fmt.Sprintf("%s - %s - (%s)", m.ent.FullEntry.GetContent("Title"), m.reg, m.seq) + } + sel, err := PromptChoose(menu, items) + ep := ErrorPrompt{} + if err != ep || sel == -1 { + return entry, keySeq, ep + } + entry = &matches[sel].ent + keySeq = matches[sel].seq + } + + if len(matches) == 1 && menu.Configuration.General.AutotypeConfirm { + sel, err := PromptChoose(menu, []string{ + "Auto-type " + entry.FullEntry.GetContent("Title"), + "Cancel", + }) + nulErr := ErrorPrompt{} + if sel != 0 || err != nulErr { + errPrompt.Cancelled = true + return entry, "", errPrompt + } + } + return entry, keySeq, errPrompt +} + +func getContent(e gokeepasslib.Entry, k string) string { + k = strings.ToLower(k) + for _, v := range e.Values { + if strings.ToLower(v.Key) == k { + return v.Value.Content + } + } + return "" +} diff --git a/kpmenulib/sequence.go b/kpmenulib/sequence.go new file mode 100644 index 0000000..8a6a5bf --- /dev/null +++ b/kpmenulib/sequence.go @@ -0,0 +1,218 @@ +package kpmenulib + +import ( + "fmt" + "regexp" + "strings" +) + +const ( + // Field names, e.g. {USERNAME} + FIELD = iota + // Keywords, e.g. {TAB} or {ENTER} + KEYWORD + // Commands, e.g. {DELAY 5} + COMMAND + // Raw text, e.g. text not enclosed in {} + RAW + // Special alias characters, e.g. a ^ not in {} means "control" + SPECIAL +) + +// SeqEntry is a single token in a key sequence, denoted by the token, the +// parsed type, and any args if it is a command. +type SeqEntry struct { + // token is the processed text, stripped of {} + Token string + // args is only set for COMMANDs, and will be nil otherwise + Args []string + // The type of the sequence entry, e.g. KEYWORD, COMMAND, etc. + Type int +} + +type SeqEntries []SeqEntry + +// Sequence is a parsed sequence of tokens +type Sequence struct { + SeqEntries + Keylag int +} + +// NewSequence returns a new Sequence instance bound to a typer. Unless mocking, this +// should normally be: +// ``` +// s := NewSequence(Robot{}) +// ``` +func NewSequence() Sequence { + return Sequence{ + make(SeqEntries, 0), + 50, + } +} + +// Parse processes a [Keepass autotype sequence](https://keepass.info/help/base/autotype.html) +// and returns the parsed keys in the order in which they occurred. +func (rv *Sequence) Parse(keySeq string) error { + if _atKeySeqRE == nil { + initKeySeqParser() + } + if len(keySeq) == 0 { + return fmt.Errorf("received empty sequence") + } + matches := _atKeySeqRE.FindAllString(keySeq, -1) + if len(matches) == 0 { + return fmt.Errorf("received malformed sequence %#v", keySeq) + } + for _, match := range matches { + var s SeqEntry + switch { + case match == "{{}": + s.Token = "{" + s.Type = KEYWORD + case match == "{}}": + s.Token = "}" + s.Type = KEYWORD + case match[0] == '{': + match = strings.Trim(match, "{}") + match = strings.Trim(match, " ") + match = strings.Replace(match, "=", " ", 1) + parts := strings.Split(match, " ") + s.Token = parts[0] + if contains(_commands, s.Token) { + s.Type = COMMAND + if len(parts) > 1 { + s.Args = parts[1:] + } else { + s.Args = []string{} + } + } else if contains(_atKeywords, match) { + s.Token = match + s.Type = KEYWORD + } else if len(match) == 0 { + return fmt.Errorf("invalid key sequence {}") + } else { + s.Token = match + s.Type = FIELD + } + default: + s.Token = match + s.Type = RAW + if contains(_specials, match) { + s.Type = SPECIAL + } + } + rv.SeqEntries = append(rv.SeqEntries, s) + } + return nil +} + +const ( + // Argument-laden keywords: DELAY, VKEY, APPACTIVATE, BEEP + AT_KW_DELAY = "DELAY" + AT_KW_VKEY = "VKEY" + AT_KW_APPACT = "APPACTIVATE" + AT_KW_BEEP = "BEEP" + // No-argument keywords + AT_KW_CLEAR = "CLEARFIELD" + AT_KW_PLUS = "PLUS" + AT_KW_PERCENT = "PERCENT" + AT_KW_CARET = "CARET" + AT_KW_TILDE = "TILDE" + AT_KW_LEFTPAREN = "LEFTPAREN" + AT_KW_RIGHTPAREN = "RIGHTPAREN" + AT_KW_LEFTBRACE = "LEFTBRACE" + AT_KW_RIGHTBRACE = "RIGHTBRACE" + AT_KW_AT = "AT" + AT_KW_TAB = "TAB" + AT_KW_ENTER = "ENTER" + AT_KW_ARROW_UP = "UP" + AT_KW_ARROW_DOWN = "DOWN" + AT_KW_ARROW_LEFT = "LEFT" + AT_KW_ARROW_RIGHT = "RIGHT" + AT_KW_INSERT = "INSERT" + AT_KW_INSERT2 = "INS" + AT_KW_DELETE = "DELETE" + AT_KW_DELETE2 = "DEL" + AT_KW_HOME = "HOME" + AT_KW_END = "END" + AT_KW_PAGE_UP = "PGUP" + AT_KW_PAGE_DOWN = "PGDN" + AT_KW_SPACE = "SPACE" + AT_KW_BREAK = "BREAK" + AT_KW_CAPS_LOCK = "CAPSLOCK" + AT_KW_ESCAPE = "ESC" + AT_KW_BACKSPACE = "BACKSPACE" + AT_KW_KW_BACKSPACE2 = "BS" + AT_KW_KW_BACKSPACE3 = "BKSP" + AT_KW_WINDOWS_KEY_LEFT = "WIN" + AT_KW_WINDOWS_KEY_LEFT2 = "LWIN" + AT_KW_WINDOWS_KEY_RIGHT = "RWIN" + AT_KW_HELP = "HELP" + AT_KW_NUMLOCK = "NUMLOCK" + AT_KW_PRINTSCREEN = "PRTSC" + AT_KW_SCROLLLOCK = "SCROLLLOCK" + AT_KW_NUMPAD_PLUS = "ADD" + AT_KW_NUMPAD_MINUS = "SUBTRACT" + AT_KW_NUMPAD_MULT = "MULTIPLY" + AT_KW_NUMPAD_DIV = "DIVIDE" + AT_KW_APPS_MENU = "APPS" + //Numeric pad 0 to 9 {NUMPAD0} to {NUMPAD9} + // F1 - F16 {F1} - {F16} + // Special characters within {} -- thanks, keepass2! + AT_SCHAR = "+%^~{}[]()" + // Special characters *outside* of {} + AT_CH_SHIFT = "+" + AT_CH_CTRL = "^" + AT_CH_ALT = "%" + AT_CH_WINDOWSKEY = "@" + AT_CH_ENTER2 = "~" +) + +var _commands []string = []string{AT_KW_DELAY, AT_KW_VKEY, AT_KW_APPACT, AT_KW_BEEP} + +var _specials []string = []string{AT_CH_SHIFT, AT_CH_CTRL, AT_CH_ALT, AT_CH_WINDOWSKEY, AT_CH_ENTER2} + +// This has been benchmarked. An O(M*N) array search is faster than either map or regex. +var _atKeywords []string = []string{ + AT_KW_CLEAR, AT_KW_PLUS, AT_KW_PERCENT, + AT_KW_CARET, AT_KW_TILDE, AT_KW_LEFTPAREN, AT_KW_RIGHTPAREN, AT_KW_LEFTBRACE, AT_KW_RIGHTBRACE, + AT_KW_AT, AT_KW_TAB, AT_KW_ENTER, AT_KW_ARROW_UP, AT_KW_ARROW_DOWN, AT_KW_ARROW_LEFT, + AT_KW_ARROW_RIGHT, AT_KW_INSERT, AT_KW_INSERT2, AT_KW_DELETE, AT_KW_DELETE2, AT_KW_HOME, + AT_KW_END, AT_KW_PAGE_UP, AT_KW_PAGE_DOWN, AT_KW_SPACE, AT_KW_BREAK, AT_KW_CAPS_LOCK, + AT_KW_ESCAPE, AT_KW_BACKSPACE, AT_KW_KW_BACKSPACE2, AT_KW_KW_BACKSPACE3, AT_KW_WINDOWS_KEY_LEFT, + AT_KW_WINDOWS_KEY_LEFT2, AT_KW_WINDOWS_KEY_RIGHT, AT_KW_HELP, AT_KW_NUMLOCK, AT_KW_PRINTSCREEN, + AT_KW_SCROLLLOCK, AT_KW_NUMPAD_PLUS, AT_KW_NUMPAD_MINUS, AT_KW_NUMPAD_MULT, AT_KW_NUMPAD_DIV, + AT_KW_APPS_MENU, +} + +// Breaks a sequence string into tokens +var _atKeySeqRE *regexp.Regexp + +// Don't make this init() -- it will (unnecessarily) slow down client calls. You probably +// don't want to call it; it's called by `Parse()` if necessary +func initKeySeqParser() { + _atKeySeqRE = regexp.MustCompile("\\{[^\\}]+\\}|[^\\{\\}]+|\\s+|\\{\\{\\}|\\{\\}\\}") + + // Add special characters in {} + for _, c := range strings.Split(AT_SCHAR, "") { + _atKeywords = append(_atKeywords, c) + } + // NUMPAD0 - NUMPAD9 + for i := 0; i < 10; i++ { + _atKeywords = append(_atKeywords, fmt.Sprintf("NUMPAD%d", i)) + } + // F1 - F16 + for i := 1; i < 17; i++ { + _atKeywords = append(_atKeywords, fmt.Sprintf("F%d", i)) + } +} + +// contains answers: does an array of strings contain a string? +func contains(kws []string, s string) bool { + for _, keyword := range kws { + if keyword == s { + return true + } + } + return false +} diff --git a/kpmenulib/sequence_test.go b/kpmenulib/sequence_test.go new file mode 100644 index 0000000..8503bdb --- /dev/null +++ b/kpmenulib/sequence_test.go @@ -0,0 +1,108 @@ +package kpmenulib + +import ( + "testing" +) + +func TestNewSequence(t *testing.T) { + s := NewSequence() + s.SeqEntries = append(s.SeqEntries, SeqEntry{}) + if 1 != len(s.SeqEntries) { + t.Fatalf("expected %d, got %d", 1, len(s.SeqEntries)) + } +} + +func Test_parseKeySeq(t *testing.T) { + tests := []struct { + name string + input string + expected int + }{ + {"Parse", "{PLUS}{TAB}{F7}heyho{NUMPAD5}{DELAY 5}{VKEY 5 6}{APPACTIVATE window}{BEEP 100 200}{[}", 10}, + {"Token count", "{PLUS}{TAB}{F7}heyho{NUMPAD5}{TILDE}{ENTER}{[}{%}", 9}, + {"One token", "{PLUS}", 1}, + {"Empty", "", 0}, + {"Only a field", "{TOTP}", 1}, + {"Only a command", "{DELAY 5}", 1}, + {"Only text", "TOTP", 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + seq := NewSequence() + seq.Parse(tt.input) + if len(seq.SeqEntries) != tt.expected { + t.Errorf("parseKeySeq() %s = %v, want %v", tt.name, seq.SeqEntries, tt.expected) + } + }) + } +} + +func Test_parseKeySeqTokens(t *testing.T) { + tests := []struct { + name string + input string + expected SeqEntry + }{ + {"Keyword", "{TAB}", SeqEntry{Token: "TAB", Args: nil, Type: KEYWORD}}, + {"Keyword - left brace", "{{}", SeqEntry{Token: "{", Args: nil, Type: KEYWORD}}, + {"Keyword - right brace", "{}}", SeqEntry{Token: "}", Args: nil, Type: KEYWORD}}, + {"F-key", "{F11}", SeqEntry{Token: "F11", Args: nil, Type: KEYWORD}}, + {"Numpad", "{NUMPAD0}", SeqEntry{Token: "NUMPAD0", Args: nil, Type: KEYWORD}}, + {"Raw", "raw text", SeqEntry{Token: "raw text", Args: nil, Type: RAW}}, + {"Command", "{BEEP 300 123}", SeqEntry{Token: "BEEP", Args: []string{"300", "123"}, Type: COMMAND}}, + {"Character", "{~}", SeqEntry{Token: "~", Args: nil, Type: KEYWORD}}, + {"Command", "{BEEP 300 123}", SeqEntry{Token: "BEEP", Args: []string{"300", "123"}, Type: COMMAND}}, + {"Special char", "@", SeqEntry{Token: "@", Args: nil, Type: SPECIAL}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + seq := NewSequence() + seq.Parse(tt.input) + if 1 != len(seq.SeqEntries) { + t.Fatalf("expected %d, got %d", 1, len(seq.SeqEntries)) + } + if len(seq.SeqEntries) == 1 { + e := seq.SeqEntries[0] + if tt.expected.Token != e.Token { + t.Fatalf("expected %#v, got %#v", tt.expected.Token, e.Token) + } + if len(tt.expected.Args) != len(e.Args) { + t.Fatalf("expected %#v, got %#v", tt.expected.Args, e.Args) + } else { + for i, el := range tt.expected.Args { + if e.Args[i] != el { + t.Fatalf("expected %#v, got %#v", e, e.Args[i]) + } + } + } + if tt.expected.Type != e.Type { + t.Fatalf("expected %d, got %d", tt.expected.Type, e.Type) + } + } + }) + } +} + +func BenchmarkMap(b *testing.B) { + input := "{BEEP}{PLUS}{USERNAME}{TAB}{F7}heyho{NUMPAD5}{TILDE}{PASSWORD}{ENTER}{[}{%}" + for i := 0; i < b.N; i++ { + seq := NewSequence() + seq.Parse(input) + } +} + +func Test_initKeySeqParser(t *testing.T) { + tests := []string{ + "TAB", "NUMPAD3", "F10", "}", "%", + } + initKeySeqParser() +outer: + for _, i := range tests { + for _, e := range _atKeywords { + if i == e { + continue outer + } + } + t.Errorf("expected to find %s", i) + } +} From a5c05b2e767bfb22b3f0cfe7061bb56a20c8bbdc Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Fri, 20 Aug 2021 15:02:43 -0500 Subject: [PATCH 6/7] Change default window ID tool to quasiauto --- kpmenulib/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kpmenulib/config.go b/kpmenulib/config.go index fd74831..a2ff732 100644 --- a/kpmenulib/config.go +++ b/kpmenulib/config.go @@ -94,7 +94,7 @@ const ( // Autotype default helpers const ( - AutotypeWindowIdentifier = "xdotool getwindowfocus getwindowname" + AutotypeWindowIdentifier = "quasiauto -title" AutotypeTyper = "quasiauto" ) From e14a292316b3b0d00012cc22c625c43a434721c5 Mon Sep 17 00:00:00 2001 From: "Sean E. Russell" Date: Wed, 2 Nov 2022 16:24:00 -0500 Subject: [PATCH 7/7] Sets default TOTP values; they should be provided by the issuer, but some providers don't. This uses the most common values (6 digits, 30s refresh). --- kpmenulib/otp.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/kpmenulib/otp.go b/kpmenulib/otp.go index 557e70b..240b02f 100644 --- a/kpmenulib/otp.go +++ b/kpmenulib/otp.go @@ -50,15 +50,14 @@ func (o OTPError) Error() string { // Modern versions of KeepassXC and Keepass2Android store this URL in the `otp` key. A historic version // stored data ds: // -// TOTP Seed = SECRET -// TOTP Settings = PERIOD;DIGITS +// TOTP Seed = SECRET +// TOTP Settings = PERIOD;DIGITS // // If the `otp` key exists, it should be used and the TOTP values ignored; otherwise, the legacy values can // be used. // // entry is the DB entry for which to generate a code; time is the Unix time to generate for the code -- // generally time.Now().Unix() -// func CreateOTP(a gokeepasslib.Entry, time int64) (otp string, err error) { otpa, err := CreateOTPAuth(a) if err != nil { @@ -91,6 +90,9 @@ func (o OTPAuth) Create(time int64) (otp string, err error) { otp = fmt.Sprint(r % int32(pow(10, o.Digits))) if len(otp) != o.Digits { + if len(otp) > o.Digits { + return otp, fmt.Errorf("otp length (%d) must be greater than Digits (%d)", len(otp), o.Digits) + } rpt := strings.Repeat("0", o.Digits-len(otp)) otp = rpt + otp } @@ -165,11 +167,11 @@ func CreateOTPAuth(a gokeepasslib.Entry) (otp OTPAuth, err error) { // parseOTPAuth parses a Google Authenticator otpauth URL, which is used by // both KeepassXC and Keepass2Android. // -// otpauth://TYPE/LABEL?PARAMETERS +// otpauth://TYPE/LABEL?PARAMETERS // // e.g., the KeepassXC format is // -// otpauth://totp/ISSUER:USERNAME?secret=SECRET&period=SECONDS&digits=D&issuer=ISSUER +// otpauth://totp/ISSUER:USERNAME?secret=SECRET&period=SECONDS&digits=D&issuer=ISSUER // // where TITLE is the record entry title, e.g. `github`; USERNAME is the entry // user name, e.g. `xxxserxxx`; SECRET is the TOTP seed secret; SECONDS is the @@ -179,7 +181,10 @@ func CreateOTPAuth(a gokeepasslib.Entry) (otp OTPAuth, err error) { // // The spec is at https://github.com/google/google-authenticator/wiki/Key-Uri-Format func parseOTPAuth(s string) (OTPAuth, error) { - otp := OTPAuth{} + otp := OTPAuth{ + Digits: 6, + Period: 30, + } u, err := url.ParseRequestURI(s) if err != nil { return otp, err