Skip to content

Commit 67ad350

Browse files
committed
feat: implement TOTP
1 parent d566eb5 commit 67ad350

File tree

5 files changed

+171
-37
lines changed

5 files changed

+171
-37
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,29 @@ pago cannot retrieve tables.
277277

278278
If you show or clip a TOML entry without `--key`, the entire TOML document is returned.
279279

280+
### TOTP
281+
282+
pago can generate [time-based one-time passwords (TOTP)](https://en.wikipedia.org/wiki/Time-based_one-time_password) from a [TOML entry](#toml-entries).
283+
To use this feature, store the `otpauth://` URI in a key named `otp`.
284+
285+
```shell
286+
pago add -m services/my-service <<EOF
287+
user = "jdoe"
288+
otp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example"
289+
EOF
290+
```
291+
292+
When you use `show` or `clip` with the key `otp`, pago will generate and output a TOTP code.
293+
294+
```shell
295+
# Show the TOTP code.
296+
pago show -k otp services/my-service
297+
# => 123456
298+
299+
# Copy the TOTP code to the clipboard.
300+
pago clip -k otp services/my-service
301+
```
302+
280303
### Agent
281304

282305
The agent keeps your identities in memory to avoid repeated password prompts.

cmd/pago/main.go

Lines changed: 65 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import (
3737
"github.com/alecthomas/repr"
3838
"github.com/anmitsu/go-shlex"
3939
gitConfig "github.com/go-git/go-git/v5/config"
40+
"github.com/pquerna/otp"
41+
"github.com/pquerna/otp/totp"
4042
)
4143

4244
type CLI struct {
@@ -295,6 +297,62 @@ func englishPlural(singular, plural string, count int) string {
295297
return plural
296298
}
297299

300+
func decryptEntry(agentExecutable string, agentExpire time.Duration, agentMemlock bool, agentSocket, identities, passwordStore, name string) (string, error) {
301+
if agentSocket == "" {
302+
return crypto.DecryptEntry(identities, passwordStore, name)
303+
}
304+
305+
file, err := pago.EntryFile(passwordStore, name)
306+
if err != nil {
307+
return "", err
308+
}
309+
310+
encryptedData, err := os.ReadFile(file)
311+
if err != nil {
312+
return "", fmt.Errorf("failed to read password file: %v", err)
313+
}
314+
315+
if err := agent.Ping(agentSocket); err != nil {
316+
// Ping failed.
317+
// Attempt to start the agent.
318+
identitiesText, err := crypto.DecryptIdentities(identities)
319+
if err != nil {
320+
return "", err
321+
}
322+
323+
if err := agent.StartProcess(agentExecutable, agentExpire, agentMemlock, agentSocket, identitiesText); err != nil {
324+
return "", fmt.Errorf("failed to start agent: %v", err)
325+
}
326+
}
327+
328+
content, err := agent.Decrypt(agentSocket, encryptedData)
329+
if err != nil {
330+
return "", err
331+
}
332+
333+
return content, nil
334+
}
335+
336+
func getOTP(otpURL string) (string, error) {
337+
otpKey, err := otp.NewKeyFromURL(otpURL)
338+
if err != nil {
339+
return "", fmt.Errorf("failed to parse otpauth URL: %w", err)
340+
}
341+
342+
opts := totp.ValidateOpts{
343+
Period: uint(otpKey.Period()),
344+
Digits: otpKey.Digits(),
345+
Algorithm: otpKey.Algorithm(),
346+
}
347+
348+
code, err := totp.GenerateCodeCustom(otpKey.Secret(), time.Now(), opts)
349+
if err != nil {
350+
return "", fmt.Errorf("failed to generate TOTP code: %w", err)
351+
}
352+
353+
return code, nil
354+
}
355+
298356
func getPassword(agentExecutable string, agentExpire time.Duration, agentMemlock bool, agentSocket, identities, passwordStore, name, key string) (string, error) {
299357
content, err := decryptEntry(agentExecutable, agentExpire, agentMemlock, agentSocket, identities, passwordStore, name)
300358
if err != nil {
@@ -322,7 +380,13 @@ func getPassword(agentExecutable string, agentExpire time.Duration, agentMemlock
322380
return "", fmt.Errorf("key %q in entry %q is a table", key, name)
323381

324382
case reflect.String:
325-
return v.String(), nil
383+
s := v.String()
384+
385+
if key == "otp" {
386+
return getOTP(s)
387+
}
388+
389+
return s, nil
326390
}
327391

328392
var buf bytes.Buffer
@@ -334,42 +398,6 @@ func getPassword(agentExecutable string, agentExpire time.Duration, agentMemlock
334398
return buf.String(), nil
335399
}
336400

337-
func decryptEntry(agentExecutable string, agentExpire time.Duration, agentMemlock bool, agentSocket, identities, passwordStore, name string) (string, error) {
338-
if agentSocket == "" {
339-
return crypto.DecryptEntry(identities, passwordStore, name)
340-
}
341-
342-
file, err := pago.EntryFile(passwordStore, name)
343-
if err != nil {
344-
return "", err
345-
}
346-
347-
encryptedData, err := os.ReadFile(file)
348-
if err != nil {
349-
return "", fmt.Errorf("failed to read password file: %v", err)
350-
}
351-
352-
if err := agent.Ping(agentSocket); err != nil {
353-
// Ping failed.
354-
// Attempt to start the agent.
355-
identitiesText, err := crypto.DecryptIdentities(identities)
356-
if err != nil {
357-
return "", err
358-
}
359-
360-
if err := agent.StartProcess(agentExecutable, agentExpire, agentMemlock, agentSocket, identitiesText); err != nil {
361-
return "", fmt.Errorf("failed to start agent: %v", err)
362-
}
363-
}
364-
365-
content, err := agent.Decrypt(agentSocket, encryptedData)
366-
if err != nil {
367-
return "", err
368-
}
369-
370-
return content, nil
371-
}
372-
373401
func (cmd *ClipCmd) Run(config *Config) error {
374402
if config.Verbose {
375403
printRepr(cmd)

go.mod

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ require (
1515
github.com/charmbracelet/lipgloss v1.1.0
1616
github.com/go-git/go-git/v5 v5.16.2
1717
github.com/ktr0731/go-fuzzyfinder v0.9.0
18+
github.com/pquerna/otp v1.5.0
19+
github.com/stretchr/testify v1.10.0
1820
github.com/tidwall/redcon v1.6.2
1921
github.com/valkey-io/valkey-go v1.0.64
2022
github.com/xlab/treeprint v1.2.0
@@ -29,13 +31,15 @@ require (
2931
github.com/ProtonMail/go-crypto v1.3.0 // indirect
3032
github.com/atotto/clipboard v0.1.4 // indirect
3133
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
34+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
3235
github.com/charmbracelet/colorprofile v0.3.2 // indirect
3336
github.com/charmbracelet/x/ansi v0.10.1 // indirect
3437
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
3538
github.com/charmbracelet/x/term v0.2.1 // indirect
3639
github.com/cloudflare/circl v1.6.1 // indirect
3740
github.com/creack/pty v1.1.17 // indirect
3841
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
42+
github.com/davecgh/go-spew v1.1.1 // indirect
3943
github.com/emirpasic/gods v1.18.1 // indirect
4044
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
4145
github.com/gdamore/encoding v1.0.1 // indirect
@@ -56,6 +60,7 @@ require (
5660
github.com/nsf/termbox-go v1.1.1 // indirect
5761
github.com/pjbgf/sha1cd v0.4.0 // indirect
5862
github.com/pkg/errors v0.9.1 // indirect
63+
github.com/pmezard/go-difflib v1.0.0 // indirect
5964
github.com/rivo/uniseg v0.4.7 // indirect
6065
github.com/sergi/go-diff v1.4.0 // indirect
6166
github.com/skeema/knownhosts v1.3.1 // indirect
@@ -68,4 +73,5 @@ require (
6873
golang.org/x/sync v0.16.0 // indirect
6974
golang.org/x/text v0.28.0 // indirect
7075
gopkg.in/warnings.v0 v0.1.2 // indirect
76+
gopkg.in/yaml.v3 v3.0.1 // indirect
7177
)

go.sum

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
3535
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
3636
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
3737
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
38+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
39+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
3840
github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=
3941
github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=
4042
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
@@ -126,6 +128,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
126128
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
127129
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
128130
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
131+
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
132+
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
129133
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
130134
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
131135
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -138,6 +142,7 @@ github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnB
138142
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
139143
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
140144
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
145+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
141146
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
142147
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
143148
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=

test/e2e_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,78 @@ qux = {"key" = "value"}
777777
}
778778
}
779779

780+
func TestShowOTP(t *testing.T) {
781+
_, err := withPagoDir(func(dataDir string) (string, error) {
782+
// Add a TOML entry with a 6-digit otpauth URI.
783+
cmd := exec.Command(commandPago, "--dir", dataDir, "add", "otp-test-6digit", "--multiline")
784+
// Example URI from https://github.com/google/google-authenticator/wiki/Key-Uri-Format
785+
cmd.Stdin = strings.NewReader(`otp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example"`)
786+
var stdout, stderr bytes.Buffer
787+
cmd.Stdout = &stdout
788+
cmd.Stderr = &stderr
789+
err := cmd.Run()
790+
if err != nil {
791+
return stdout.String() + "\n" + stderr.String(), err
792+
}
793+
794+
// Add another TOML entry with an 8-digit otpauth URI.
795+
cmd = exec.Command(commandPago, "--dir", dataDir, "add", "otp-test-8digit", "--multiline")
796+
cmd.Stdin = strings.NewReader(`otp = "otpauth://totp/Example:alice@google.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=sha256&digits=8"`)
797+
cmd.Stdout = &stdout
798+
cmd.Stderr = &stderr
799+
err = cmd.Run()
800+
if err != nil {
801+
return stdout.String() + "\n" + stderr.String(), err
802+
}
803+
804+
checkOTP := func(entryName, expectedPattern string) error {
805+
var buf bytes.Buffer
806+
c, err := expect.NewConsole()
807+
if err != nil {
808+
return fmt.Errorf("failed to create console: %w", err)
809+
}
810+
defer c.Close()
811+
812+
cmd := exec.Command(commandPago, "--dir", dataDir, "--socket", "", "show", "--key", "otp", entryName)
813+
cmd.Stdin = c.Tty()
814+
cmd.Stdout = &buf
815+
cmd.Stderr = c.Tty()
816+
817+
err = cmd.Start()
818+
if err != nil {
819+
return fmt.Errorf("failed to start command for key 'otp': %w", err)
820+
}
821+
822+
_, _ = c.ExpectString("Enter password")
823+
_, _ = c.SendLine(password)
824+
825+
err = cmd.Wait()
826+
if err != nil {
827+
return fmt.Errorf("command failed for key 'otp': %w", err)
828+
}
829+
830+
output := strings.TrimSpace(buf.String())
831+
if matched, _ := regexp.MatchString(expectedPattern, output); !matched {
832+
return fmt.Errorf("expected OTP matching %q, got %q", expectedPattern, output)
833+
}
834+
return nil
835+
}
836+
837+
if err := checkOTP("otp-test-6digit", `^\d{6}$`); err != nil {
838+
return "", err
839+
}
840+
841+
if err := checkOTP("otp-test-8digit", `^\d{8}$`); err != nil {
842+
return "", err
843+
}
844+
845+
return "", nil
846+
})
847+
if err != nil {
848+
t.Errorf("Command `show --key otp` failed: %v", err)
849+
}
850+
}
851+
780852
func TestShowTree(t *testing.T) {
781853
output, err := withPagoDir(func(dataDir string) (string, error) {
782854
for _, name := range []string{"foo", "bar", "baz"} {

0 commit comments

Comments
 (0)