Fabio Alessandro Locati b7a43e6485
Add otp.onlyclip, otp.alsoclip and the -C parameter to otp (#3093)
Signed-off-by: Fabio Alessandro Locati <mail@fale.io>
2025-04-25 19:57:59 +02:00

263 lines
6.8 KiB
Go

package action
import (
"context"
"errors"
"fmt"
"strconv"
"time"
"github.com/gopasspw/gopass/internal/action/exit"
"github.com/gopasspw/gopass/internal/config"
"github.com/gopasspw/gopass/internal/out"
"github.com/gopasspw/gopass/internal/store"
"github.com/gopasspw/gopass/pkg/clipboard"
"github.com/gopasspw/gopass/pkg/ctxutil"
"github.com/gopasspw/gopass/pkg/debug"
"github.com/gopasspw/gopass/pkg/otp"
"github.com/gopasspw/gopass/pkg/termio"
"github.com/mattn/go-tty"
"github.com/pquerna/otp/hotp"
"github.com/pquerna/otp/totp"
"github.com/urfave/cli/v2"
)
// OTP implements OTP token handling for TOTP and HOTP.
func (s *Action) OTP(c *cli.Context) error {
ctx := ctxutil.WithGlobalFlags(c)
name := c.Args().First()
if name == "" {
return exit.Error(exit.Usage, nil, "Usage: %s otp <NAME>", s.Name)
}
qrf := c.String("qr")
clip := config.Bool(ctx, "otp.onlyclip")
if c.IsSet("clip") {
clip = c.Bool("clip")
}
alsoClip := config.Bool(ctx, "otp.autoclip")
if c.IsSet("alsoclip") {
alsoClip = c.Bool("alsoclip")
}
chained := c.Bool("chained")
pw := c.Bool("password")
snip := c.Bool("snip")
if snip {
qr, err := otp.ParseScreen(ctx)
if err != nil || len(qr) == 0 {
return err
}
choice, err := termio.AskForBool(ctx, "(Over)writing otpauth URL in key 'otpauth'?", true)
if err != nil || !choice {
return err
}
err = s.insertYAML(ctxutil.WithInteractive(ctx, false), name, "otpauth", []byte(qr), nil)
if err != nil {
return err
}
out.Print(ctx, "Value written, carrying on to display OTP value from it.")
}
return s.otp(ctx, name, qrf, clip, pw, true, chained, alsoClip)
}
func tickingBar(ctx context.Context, expiresAt time.Time, bar *termio.ProgressBar) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for tt := range ticker.C {
select {
case <-ctx.Done():
return // returning not to leak the goroutine.
default:
// we don't want to block if not cancelled.
}
if tt.After(expiresAt) {
return
}
bar.Inc()
}
}
func waitForKeyPress(ctx context.Context, cancel context.CancelFunc) (func(), func()) {
tty1, err := tty.Open()
if err != nil {
out.Errorf(ctx, "Unexpected error opening tty: %v", err)
cancel()
}
return func() {
for {
select {
case <-ctx.Done():
return // returning not to leak the goroutine.
default:
}
r, err := tty1.ReadRune()
if err != nil {
out.Errorf(ctx, "Unexpected error opening tty: %v", err)
}
if r == 'q' || r == 'x' || err != nil {
cancel()
return
}
}
}, func() {
_ = tty1.Close()
}
}
// nolint: cyclop
func (s *Action) otp(ctx context.Context, name, qrf string, clip, pw, recurse, chained, alsoClip bool) error {
sec, err := s.Store.Get(ctx, name)
if err != nil {
return s.otpHandleError(ctx, name, qrf, clip, pw, recurse, chained, alsoClip, err)
}
outerCtx := ctx
ctx = config.WithMount(ctx, s.Store.MountPoint(name))
ctx, cancel := context.WithCancel(ctx)
defer cancel()
skip := ctxutil.IsHidden(ctx) || pw || qrf != "" || !ctxutil.IsTerminal(ctx) || !ctxutil.IsInteractive(ctx) || clip
if !skip {
// let us monitor key presses for cancellation:.
runFn, cleanupFn := waitForKeyPress(ctx, cancel)
go runFn()
defer cleanupFn()
}
// only used for the HOTP case as a fallback
var counter uint64 = 1
if sv, found := sec.Get("counter"); found && sv != "" {
if iv, err := strconv.ParseUint(sv, 10, 64); iv != 0 && err == nil {
counter = iv
}
}
for {
select {
case <-ctx.Done():
return nil
default:
}
two, err := otp.Calculate(name, sec)
if err != nil {
return exit.Error(exit.Unknown, err, "No OTP entry found for %s: %s", name, err)
}
var token string
switch two.Type() {
case "totp":
token, err = totp.GenerateCodeCustom(two.Secret(), time.Now(), totp.ValidateOpts{
Period: uint(two.Period()),
Skew: 1,
Digits: two.Digits(),
Algorithm: two.Algorithm(),
Encoder: two.Encoder(),
})
if err != nil {
return exit.Error(exit.Unknown, err, "Failed to compute OTP token for %s: %s", name, err)
}
case "hotp":
token, err = hotp.GenerateCodeCustom(two.Secret(), counter, hotp.ValidateOpts{
Digits: two.Digits(),
Algorithm: two.Algorithm(),
Encoder: two.Encoder(),
})
if err != nil {
return exit.Error(exit.Unknown, err, "Failed to compute OTP token for %s: %s", name, err)
}
counter++
_ = sec.Set("counter", strconv.Itoa(int(counter)))
// using outerCtx here because we want to save the counter even if the user cancels.
if err := s.Store.Set(outerCtx, name, sec); err != nil {
out.Errorf(outerCtx, "Failed to persist counter value: %s", err)
}
debug.Log("Saved counter as %d", counter)
}
now := time.Now()
expiresAt := now.Add(time.Duration(two.Period()) * time.Second).Truncate(time.Duration(two.Period()) * time.Second)
secondsLeft := int(time.Until(expiresAt).Seconds())
bar := termio.NewProgressBar(int64(secondsLeft))
bar.Hidden = skip
debug.Log("OTP period: %ds", two.Period())
if chained {
token = fmt.Sprintf("%s%s", sec.Password(), token)
}
if clip || alsoClip {
if err := clipboard.CopyTo(ctx, fmt.Sprintf("token for %s", name), []byte(token), config.AsInt(s.cfg.Get("core.cliptimeout"))); err != nil {
return exit.Error(exit.IO, err, "failed to copy to clipboard: %s", err)
}
if clip {
return nil
}
}
out.Printf(ctx, "%s", token)
// In "QR-Code" mode just create the image file and then exit.
if qrf != "" {
return otp.WriteQRFile(two, qrf)
}
// If we are in "password mode", not interacting with a terminal or Stdout is attached to a pipe,
// we are done.
if skip {
return nil
}
// if not then we want to print a progress bar with the expiry time.
out.Warningf(ctx, "([q] to stop. -o flag to avoid.) This OTP password still lasts for:", nil)
if bar.Hidden {
cancel()
} else {
bar.Set(0)
go tickingBar(ctx, expiresAt, bar)
}
// let us wait until next OTP code:.
for {
select {
case <-ctx.Done():
bar.Done()
cancel()
return nil
default:
time.Sleep(time.Millisecond * 500)
}
if time.Now().After(expiresAt) {
bar.Done()
break
}
}
}
}
func (s *Action) otpHandleError(ctx context.Context, name, qrf string, clip, pw, recurse, chained, alsoClip bool, err error) error {
if !errors.Is(err, store.ErrNotFound) || !recurse || !ctxutil.IsTerminal(ctx) {
return exit.Error(exit.Unknown, err, "failed to retrieve secret %q: %s", name, err)
}
out.Printf(ctx, "Entry %q not found. Starting search...", name)
cb := func(ctx context.Context, c *cli.Context, name string, recurse bool) error {
return s.otp(ctx, name, qrf, clip, pw, false, chained, alsoClip)
}
if err := s.find(ctx, nil, name, cb, false); err != nil {
return exit.Error(exit.NotFound, err, "%s", err)
}
return nil
}