diff --git a/cmd/winauth/cmd_get.go b/cmd/winauth/cmd_get.go new file mode 100644 index 0000000..52c7d3b --- /dev/null +++ b/cmd/winauth/cmd_get.go @@ -0,0 +1,120 @@ +package main + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "git.wxccs.org/iceking2nd/winauth-go/internal/authenticator" + "git.wxccs.org/iceking2nd/winauth-go/internal/config" + "git.wxccs.org/iceking2nd/winauth-go/internal/i18n" +) + +func newGetCmd() *cobra.Command { + var noNewline, advance bool + cmd := &cobra.Command{ + Use: "get NAME", + Short: "Print the current OTP for an entry", + Long: `Print the current one-time password for the entry named NAME. +The name must match exactly (case-sensitive). + +For HOTP (counter-based) entries, this command refuses by default +because generating a code advances the counter. Use --advance to +confirm that you want to consume a counter value; the config file +will be updated automatically.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runGet(cmd, args[0], noNewline, advance) + }, + } + cmd.Flags().BoolVarP(&noNewline, "no-newline", "n", false, "Do not append a newline after the code") + cmd.Flags().BoolVar(&advance, "advance", false, "Advance HOTP counter (required for counter-based entries)") + return cmd +} + +func runGet(cmd *cobra.Command, name string, noNewline, advance bool) error { + path := resolveConfigPath(cmd) + + lang := preferredLanguage(path) + _ = i18n.Init(lang) + + cfg, passphrase, err := cliLoadConfig(path) + if err != nil { + if errors.Is(err, config.ErrPasswordWrong) { + return fmt.Errorf("wrong password") + } + return fmt.Errorf("load config: %w", err) + } + + // Find entry by exact name. + var target *config.Entry + var matches []string + for i := range cfg.Entries { + if cfg.Entries[i].Name == name { + target = &cfg.Entries[i] + break + } + matches = append(matches, cfg.Entries[i].Name) + } + if target == nil { + return fmt.Errorf("entry %q not found", name) + } + + a, err := buildAuth(*target) + if err != nil { + return fmt.Errorf("build authenticator: %w", err) + } + + // HOTP safety gate. + if a.Name() == "hotp" && !advance { + return fmt.Errorf("entry %q is counter-based (HOTP); re-run with --advance to consume a counter value", name) + } + + code, err := a.CurrentCode() + if err != nil { + return fmt.Errorf("compute OTP: %w", err) + } + + if noNewline { + fmt.Print(code) + } else { + fmt.Println(code) + } + + // Persist the updated counter for HOTP entries. + if a.Name() == "hotp" { + target.SecretRaw = a.SecretData() + if err := config.SaveYAML(cfg, path, passphrase); err != nil { + return fmt.Errorf("save config after HOTP advance: %w", err) + } + } + + return nil +} + +// buildAuth mirrors the switch in internal/ui/convert.go. Duplicated here +// so the CLI binary does not depend on the UI package. +func buildAuth(e config.Entry) (authenticator.Authenticator, error) { + var a authenticator.Authenticator + switch e.Vendor { + case "google", "": + a = authenticator.NewGoogleAuthenticator() + case "microsoft": + a = authenticator.NewMicrosoftAuthenticator() + case "okta": + a = authenticator.NewOktaVerifyAuthenticator() + case "hotp": + a = authenticator.NewHOTPAuthenticator() + case "battlenet": + a = authenticator.NewBattleNetAuthenticator() + case "steam": + a = authenticator.NewSteamAuthenticator() + default: + return nil, fmt.Errorf("unknown vendor %q", e.Vendor) + } + if err := a.SetSecretData(e.SecretRaw); err != nil { + return nil, fmt.Errorf("decode entry %q: %w", e.Name, err) + } + return a, nil +} diff --git a/cmd/winauth/cmd_list.go b/cmd/winauth/cmd_list.go new file mode 100644 index 0000000..08967c9 --- /dev/null +++ b/cmd/winauth/cmd_list.go @@ -0,0 +1,51 @@ +package main + +import ( + "errors" + "fmt" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" + + "git.wxccs.org/iceking2nd/winauth-go/internal/config" + "git.wxccs.org/iceking2nd/winauth-go/internal/i18n" +) + +func newListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List all authenticator entries", + Long: "Prints a table of all entries in the config file. For encrypted configs, a password prompt will appear.", + Args: cobra.NoArgs, + RunE: runList, + } +} + +func runList(cmd *cobra.Command, args []string) error { + const fn = "cmd.winauth.runList" + path := resolveConfigPath(cmd) + + lang := preferredLanguage(path) + _ = i18n.Init(lang) // best-effort; list output is English anyway + + cfg, _, err := cliLoadConfig(path) + if err != nil { + if errors.Is(err, config.ErrPasswordWrong) { + return fmt.Errorf("wrong password") + } + return fmt.Errorf("load config: %w", err) + } + + if len(cfg.Entries) == 0 { + fmt.Fprintln(os.Stderr, "No entries found.") + return nil + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "NAME\tVENDOR\tICON\tHOTKEY") + for _, e := range cfg.Entries { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", e.Name, e.Vendor, e.IconName, e.Hotkey) + } + return tw.Flush() +} diff --git a/cmd/winauth/helpers.go b/cmd/winauth/helpers.go new file mode 100644 index 0000000..4194d74 --- /dev/null +++ b/cmd/winauth/helpers.go @@ -0,0 +1,50 @@ +package main + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/cobra" + + "git.wxccs.org/iceking2nd/winauth-go/internal/config" +) + +// resolveConfigPath returns the config file path from the --config +// flag or the default location. +func resolveConfigPath(cmd *cobra.Command) string { + if p, _ := cmd.Root().PersistentFlags().GetString("config"); p != "" { + return p + } + return config.DefaultPath() +} + +// cliLoadConfig loads the config at path, prompting for a password if +// the file is encrypted. Returns the config and the passphrase (empty +// for plaintext configs). Up to 3 password attempts are allowed. +func cliLoadConfig(path string) (*config.Config, []byte, error) { + cfg, err := config.LoadYAML(path, nil) + if err == nil { + return cfg, nil, nil + } + if !errors.Is(err, config.ErrPasswordRequired) { + return nil, nil, err + } + + for attempt := 0; attempt < 3; attempt++ { + pw, err := readPassword("Password: ") + if err != nil { + return nil, nil, fmt.Errorf("read password: %w", err) + } + cfg, err = config.LoadYAML(path, pw) + if err == nil { + return cfg, pw, nil + } + if errors.Is(err, config.ErrPasswordWrong) { + fmt.Fprintln(os.Stderr, "Wrong password.") + continue + } + return nil, nil, err + } + return nil, nil, fmt.Errorf("too many failed password attempts") +} diff --git a/cmd/winauth/main.go b/cmd/winauth/main.go index 1b6e2c5..57c8e74 100644 --- a/cmd/winauth/main.go +++ b/cmd/winauth/main.go @@ -91,9 +91,12 @@ func main() { "Log level: panic|fatal|error|warn|info|debug|trace (or 0..6)") root.Flags().StringVar(&logFile, "log-file", "", "If set, also write logs to this file in addition to the console") - root.Flags().StringVar(&configPath, "config", "", + root.PersistentFlags().StringVar(&configPath, "config", "", "Path to the YAML config file (default: %APPDATA%\\winauth-go\\config.yaml or $XDG_CONFIG_HOME/winauth-go/config.yaml)") + root.AddCommand(newListCmd()) + root.AddCommand(newGetCmd()) + if err := root.Execute(); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) diff --git a/cmd/winauth/passphrase.go b/cmd/winauth/passphrase.go new file mode 100644 index 0000000..97e1768 --- /dev/null +++ b/cmd/winauth/passphrase.go @@ -0,0 +1,31 @@ +package main + +import ( + "bufio" + "fmt" + "os" + + "golang.org/x/term" +) + +// readPassword prompts the user for a password on the given file +// descriptor. If the fd is a terminal the input is hidden; otherwise +// (piped stdin) it falls back to a plain read. +func readPassword(prompt string) ([]byte, error) { + fd := int(os.Stdin.Fd()) + if term.IsTerminal(fd) { + fmt.Fprint(os.Stderr, prompt) + pw, err := term.ReadPassword(fd) + fmt.Fprintln(os.Stderr) + return pw, err + } + // Non-tty (piped): read a line without echo hiding. + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + if err := scanner.Err(); err != nil { + return nil, err + } + return nil, fmt.Errorf("no input on stdin") + } + return []byte(scanner.Text()), nil +} diff --git a/go.mod b/go.mod index 2c3e313..12f2bcc 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,18 @@ module git.wxccs.org/iceking2nd/winauth-go -go 1.23 +go 1.25.0 require ( gioui.org v0.7.1 github.com/BurntSushi/toml v1.4.0 + github.com/makiuchi-d/gozxing v0.1.1 github.com/nicksnyder/go-i18n/v2 v2.4.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.8.1 golang.org/x/crypto v0.28.0 + golang.org/x/image v0.18.0 + golang.org/x/sys v0.46.0 + golang.org/x/term v0.44.0 golang.org/x/text v0.19.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -18,11 +22,8 @@ require ( gioui.org/shader v1.0.8 // indirect github.com/go-text/typesetting v0.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/makiuchi-d/gozxing v0.1.1 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/exp v0.0.0-20240707233637-46b078467d37 // indirect golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37 // indirect - golang.org/x/image v0.18.0 // indirect - golang.org/x/sys v0.26.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect ) diff --git a/go.sum b/go.sum index e76a32d..ddd1b2d 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,10 @@ golang.org/x/exp/shiny v0.0.0-20240707233637-46b078467d37/go.mod h1:3F+MieQB7dRY golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=