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
|
go 1.22
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4
|
||||||
github.com/charmbracelet/bubbles v0.18.0
|
github.com/charmbracelet/bubbles v0.18.0
|
||||||
github.com/charmbracelet/bubbletea v0.25.0
|
github.com/charmbracelet/bubbletea v0.25.0
|
||||||
github.com/charmbracelet/lipgloss v0.10.0
|
github.com/charmbracelet/lipgloss v0.10.0
|
||||||
|
@ -13,7 +14,6 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/boombuler/barcode v1.0.1 // indirect
|
github.com/boombuler/barcode v1.0.1 // indirect
|
||||||
github.com/containerd/console v1.0.4 // 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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/atotto/clipboard"
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
"github.com/charmbracelet/bubbles/list"
|
"github.com/charmbracelet/bubbles/list"
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
@ -29,6 +31,11 @@ type UI struct {
|
||||||
passwordError error
|
passwordError error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var keyBindingCopy = key.NewBinding(
|
||||||
|
key.WithKeys("enter"),
|
||||||
|
key.WithHelp("enter", "copy"),
|
||||||
|
)
|
||||||
|
|
||||||
func NewUI(appName string, vault Vault) UI {
|
func NewUI(appName string, vault Vault) UI {
|
||||||
passwordInput := textinput.New()
|
passwordInput := textinput.New()
|
||||||
passwordInput.Focus()
|
passwordInput.Focus()
|
||||||
|
@ -40,6 +47,19 @@ func NewUI(appName string, vault Vault) UI {
|
||||||
l.Title = appName
|
l.Title = appName
|
||||||
l.SetShowPagination(false)
|
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{
|
return UI{
|
||||||
view: viewPassword,
|
view: viewPassword,
|
||||||
vault: vault,
|
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.list.SetItems(newListItems(m.db, msg.t)),
|
||||||
m.tick(),
|
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
|
var listCmd tea.Cmd
|
||||||
|
@ -165,30 +199,30 @@ func (m UI) View() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
type listItem struct {
|
type listItem struct {
|
||||||
e DBEntry
|
entry DBEntry
|
||||||
t time.Time
|
t time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newListItems(db DB, t time.Time) []list.Item {
|
func newListItems(db DB, t time.Time) []list.Item {
|
||||||
items := make([]list.Item, 0, len(db.Entries))
|
items := make([]list.Item, 0, len(db.Entries))
|
||||||
for _, e := range db.Entries {
|
for _, e := range db.Entries {
|
||||||
items = append(items, listItem{
|
items = append(items, listItem{
|
||||||
e: e,
|
entry: e,
|
||||||
t: t,
|
t: t,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i listItem) Title() string {
|
func (i listItem) Title() string {
|
||||||
if i.e.Issuer == "" {
|
if i.entry.Issuer == "" {
|
||||||
return i.e.Name
|
return i.entry.Name
|
||||||
}
|
}
|
||||||
return i.e.Issuer + " - " + i.e.Name
|
return i.entry.Issuer + " - " + i.entry.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i listItem) Description() string {
|
func (i listItem) Description() string {
|
||||||
otp, remaining, err := generateOTP(i.e, i.t)
|
otp, remaining, err := i.entry.GenerateOTP(i.t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err.Error()
|
return err.Error()
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,11 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pquerna/otp"
|
||||||
|
"github.com/pquerna/otp/totp"
|
||||||
"golang.org/x/crypto/scrypt"
|
"golang.org/x/crypto/scrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -160,7 +164,73 @@ type DBEntry struct {
|
||||||
Info DBEntryInfo `json:"info"`
|
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 {
|
type DB struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Entries []DBEntry `json:"entries"`
|
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
|
package internal_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.dwysokinski.me/Kichiyaki/goaegis/internal"
|
"gitea.dwysokinski.me/Kichiyaki/goaegis/internal"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
@ -30,3 +32,32 @@ func TestVault_DecryptDB(t *testing.T) {
|
||||||
assert.NotEmpty(t, db.Version)
|
assert.NotEmpty(t, db.Version)
|
||||||
assert.NotEmpty(t, db.Entries)
|
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