From 1f010a778bf2b50cfdb9adeb299c4419517602fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Wysoki=C5=84ski?= Date: Mon, 22 Apr 2024 07:51:35 +0200 Subject: [PATCH] feat: copy otp to clipboard --- go.mod | 2 +- internal/otp.go | 74 ------------------------------------------ internal/ui.go | 50 +++++++++++++++++++++++----- internal/vault.go | 70 +++++++++++++++++++++++++++++++++++++++ internal/vault_test.go | 31 ++++++++++++++++++ 5 files changed, 144 insertions(+), 83 deletions(-) delete mode 100644 internal/otp.go diff --git a/go.mod b/go.mod index 4386c56..62ec725 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module gitea.dwysokinski.me/Kichiyaki/goaegis go 1.22 require ( + github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.25.0 github.com/charmbracelet/lipgloss v0.10.0 @@ -13,7 +14,6 @@ require ( ) require ( - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/boombuler/barcode v1.0.1 // indirect github.com/containerd/console v1.0.4 // indirect diff --git a/internal/otp.go b/internal/otp.go deleted file mode 100644 index 22824f8..0000000 --- a/internal/otp.go +++ /dev/null @@ -1,74 +0,0 @@ -package internal - -import ( - "fmt" - "strings" - "time" - - "github.com/pquerna/otp" - "github.com/pquerna/otp/totp" -) - -const ( - typeTOTP = "TOTP" - algorithmSHA1 = "SHA1" - algorithmSHA256 = "SHA256" - algorithmSHA512 = "SHA512" - algorithmMD5 = "MD5" - digitsSix = 6 - digitsEight = 8 -) - -func generateOTP(entry DBEntry, t time.Time) (string, int64, error) { - algorithm, err := parseAlgorithm(entry.Info.Algo) - if err != nil { - return "", 0, err - } - - digits, err := parseDigits(entry.Info.Digits) - if err != nil { - return "", 0, err - } - - switch strings.ToUpper(entry.Type) { - case typeTOTP: - code, err := totp.GenerateCodeCustom(entry.Info.Secret, t, totp.ValidateOpts{ - Algorithm: algorithm, - Period: uint(entry.Info.Period), - Digits: digits, - }) - if err != nil { - return "", 0, fmt.Errorf("couldn't generate totp: %w", err) - } - period := int64(entry.Info.Period) - return code, period - (t.Unix() % period), nil - default: - return "", 0, fmt.Errorf("unsupported entry type: %s", entry.Type) - } -} - -func parseAlgorithm(algorithm string) (otp.Algorithm, error) { - switch strings.ToUpper(algorithm) { - case algorithmSHA1: - return otp.AlgorithmSHA1, nil - case algorithmSHA256: - return otp.AlgorithmSHA256, nil - case algorithmSHA512: - return otp.AlgorithmSHA512, nil - case algorithmMD5: - return otp.AlgorithmMD5, nil - default: - return 0, fmt.Errorf("unsupported algorithm: %s", algorithm) - } -} - -func parseDigits(digits uint8) (otp.Digits, error) { - switch digits { - case digitsSix: - return otp.DigitsSix, nil - case digitsEight: - return otp.DigitsEight, nil - default: - return 0, fmt.Errorf("unsupported digits: %d", digits) - } -} diff --git a/internal/ui.go b/internal/ui.go index 073b894..43d4602 100644 --- a/internal/ui.go +++ b/internal/ui.go @@ -7,6 +7,8 @@ import ( "strings" "time" + "github.com/atotto/clipboard" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -29,6 +31,11 @@ type UI struct { passwordError error } +var keyBindingCopy = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "copy"), +) + func NewUI(appName string, vault Vault) UI { passwordInput := textinput.New() passwordInput.Focus() @@ -40,6 +47,19 @@ func NewUI(appName string, vault Vault) UI { l.Title = appName l.SetShowPagination(false) + if !clipboard.Unsupported { + l.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + keyBindingCopy, + } + } + l.AdditionalFullHelpKeys = func() []key.Binding { + return []key.Binding{ + keyBindingCopy, + } + } + } + return UI{ view: viewPassword, vault: vault, @@ -122,6 +142,20 @@ func (m UI) updateListView(teaMsg tea.Msg) (tea.Model, tea.Cmd) { m.list.SetItems(newListItems(m.db, msg.t)), m.tick(), ) + case tea.KeyMsg: + if msg.Type != tea.KeyEnter || clipboard.Unsupported { + break + } + + item, ok := m.list.SelectedItem().(listItem) + if !ok { + break + } + + otp, _, err := item.entry.GenerateOTP(item.t) + if err == nil { + _ = clipboard.WriteAll(otp) + } } var listCmd tea.Cmd @@ -165,30 +199,30 @@ func (m UI) View() string { } type listItem struct { - e DBEntry - t time.Time + entry DBEntry + t time.Time } func newListItems(db DB, t time.Time) []list.Item { items := make([]list.Item, 0, len(db.Entries)) for _, e := range db.Entries { items = append(items, listItem{ - e: e, - t: t, + entry: e, + t: t, }) } return items } func (i listItem) Title() string { - if i.e.Issuer == "" { - return i.e.Name + if i.entry.Issuer == "" { + return i.entry.Name } - return i.e.Issuer + " - " + i.e.Name + return i.entry.Issuer + " - " + i.entry.Name } func (i listItem) Description() string { - otp, remaining, err := generateOTP(i.e, i.t) + otp, remaining, err := i.entry.GenerateOTP(i.t) if err != nil { return err.Error() } diff --git a/internal/vault.go b/internal/vault.go index 9b74055..e84e27c 100644 --- a/internal/vault.go +++ b/internal/vault.go @@ -10,7 +10,11 @@ import ( "fmt" "io" "os" + "strings" + "time" + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" "golang.org/x/crypto/scrypt" ) @@ -160,7 +164,73 @@ type DBEntry struct { Info DBEntryInfo `json:"info"` } +const ( + typeTOTP = "TOTP" + algorithmSHA1 = "SHA1" + algorithmSHA256 = "SHA256" + algorithmSHA512 = "SHA512" + algorithmMD5 = "MD5" + digitsSix = 6 + digitsEight = 8 +) + +var ErrUnsupportedEntryType = errors.New("unsupported entry type") + +func (e DBEntry) GenerateOTP(t time.Time) (string, int64, error) { + algorithm, err := parseAlgorithm(e.Info.Algo) + if err != nil { + return "", 0, err + } + + digits, err := parseDigits(e.Info.Digits) + if err != nil { + return "", 0, err + } + + switch strings.ToUpper(e.Type) { + case typeTOTP: + code, totpErr := totp.GenerateCodeCustom(e.Info.Secret, t, totp.ValidateOpts{ + Algorithm: algorithm, + Period: uint(e.Info.Period), + Digits: digits, + }) + if totpErr != nil { + return "", 0, fmt.Errorf("couldn't generate totp: %w", totpErr) + } + period := int64(e.Info.Period) + return code, period - (t.Unix() % period), nil + default: + return "", 0, fmt.Errorf("%w: %s", ErrUnsupportedEntryType, e.Type) + } +} + type DB struct { Version int `json:"version"` Entries []DBEntry `json:"entries"` } + +func parseAlgorithm(algorithm string) (otp.Algorithm, error) { + switch strings.ToUpper(algorithm) { + case algorithmSHA1: + return otp.AlgorithmSHA1, nil + case algorithmSHA256: + return otp.AlgorithmSHA256, nil + case algorithmSHA512: + return otp.AlgorithmSHA512, nil + case algorithmMD5: + return otp.AlgorithmMD5, nil + default: + return 0, fmt.Errorf("unsupported algorithm: %s", algorithm) + } +} + +func parseDigits(digits uint8) (otp.Digits, error) { + switch digits { + case digitsSix: + return otp.DigitsSix, nil + case digitsEight: + return otp.DigitsEight, nil + default: + return 0, fmt.Errorf("unsupported digits: %d", digits) + } +} diff --git a/internal/vault_test.go b/internal/vault_test.go index 50d737f..7a47278 100644 --- a/internal/vault_test.go +++ b/internal/vault_test.go @@ -1,8 +1,10 @@ package internal_test import ( + "fmt" "path" "testing" + "time" "gitea.dwysokinski.me/Kichiyaki/goaegis/internal" "github.com/stretchr/testify/assert" @@ -30,3 +32,32 @@ func TestVault_DecryptDB(t *testing.T) { assert.NotEmpty(t, db.Version) assert.NotEmpty(t, db.Entries) } + +func TestDBEntry_GenerateOTP(t *testing.T) { + t.Parallel() + + for _, algo := range []string{"SHA1", "SHA256", "SHA512", "MD5"} { + for _, digits := range []uint8{6, 8} { + t.Run(fmt.Sprintf("totp - %s - %d digits", algo, digits), func(t *testing.T) { + t.Parallel() + + e := internal.DBEntry{ + Type: "totp", + Name: "Test", + Issuer: "Deno", + Info: internal.DBEntryInfo{ + Secret: "4SJHB4GSD43FZBAI7C2HLRJGPQ", + Algo: algo, + Digits: digits, + Period: 30, + }, + } + + otp, remaining, err := e.GenerateOTP(time.Now()) + require.NoError(t, err) + assert.Len(t, otp, int(e.Info.Digits)) + assert.Greater(t, remaining, int64(0)) + }) + } + } +}