feat: copy otp to clipboard
ci/woodpecker/push/test Pipeline was successful Details

This commit is contained in:
Dawid Wysokiński 2024-04-22 07:51:35 +02:00
parent d5ab2627c5
commit 1f010a778b
Signed by: Kichiyaki
GPG Key ID: B5445E357FB8B892
5 changed files with 144 additions and 83 deletions

2
go.mod
View File

@ -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

View File

@ -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)
}
}

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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))
})
}
}
}