feat: copy otp to clipboard
ci/woodpecker/push/test Pipeline was successful
Details
ci/woodpecker/push/test Pipeline was successful
Details
This commit is contained in:
parent
d5ab2627c5
commit
1f010a778b
2
go.mod
2
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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue