lockbox

password manager
Log | Files | Refs | README | LICENSE

commit 3daf15afb1cd6fdb99b08b8a46d9f46821070c1b
parent bfd89a6dacb629f0ac58de27e46b0904b1e2b253
Author: Sean Enck <sean@ttypty.com>
Date:   Sun, 29 Jun 2025 20:38:38 -0400

add a clipmgr to handle clipboard manager outside of lb itself

Diffstat:
Mcmd/lb/main.go | 33++-------------------------------
Mcmd/lb/main_test.go | 2+-
Mcmd/lb/tests/expected.log | 1-
Minternal/app/commands/core.go | 6++++--
Minternal/config/toml_test.go | 2+-
Minternal/config/vars.go | 17++++++++++++++---
Minternal/config/vars_test.go | 3++-
Minternal/platform/clip/core.go | 34++++++++++++++--------------------
Minternal/platform/clip/core_test.go | 32++++++++++++++++++--------------
Ainternal/platform/clip/manager.go | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/platform/clip/manager_test.go | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
11 files changed, 335 insertions(+), 74 deletions(-)

diff --git a/cmd/lb/main.go b/cmd/lb/main.go @@ -5,10 +5,7 @@ import ( "errors" "fmt" "os" - "os/exec" "runtime/debug" - "strings" - "time" "git.sr.ht/~enckse/lockbox/internal/app" "git.sr.ht/~enckse/lockbox/internal/app/commands" @@ -47,8 +44,8 @@ func handleEarly(command string, args []string) (bool, error) { } fmt.Printf("version: %s\n", vers) return true, nil - case commands.ClipManager: - return true, clearClipboard() + case commands.ClipManager, commands.ClipManagerDaemon: + return true, clip.Manager(command == commands.ClipManagerDaemon, clip.DefaultDaemon{}) } return false, nil } @@ -112,29 +109,3 @@ func run() error { return fmt.Errorf("unknown command: %s", command) } } - -func clearClipboard() error { - var idx int64 - val, err := platform.Stdin(false) - if err != nil { - return err - } - clipboard, err := clip.New() - if err != nil { - return err - } - pCmd, pArgs := clipboard.Args(false) - val = strings.TrimSpace(val) - for idx < clipboard.MaxTime { - idx++ - time.Sleep(1 * time.Second) - out, err := exec.Command(pCmd, pArgs...).Output() - if err != nil { - continue - } - if strings.TrimSpace(string(out)) != val { - return nil - } - } - return clipboard.CopyTo("") -} diff --git a/cmd/lb/main_test.go b/cmd/lb/main_test.go @@ -315,7 +315,7 @@ func test(profile string) error { clipPassed := false tries := 0 for tries < clipTries { - if platform.PathExists(copyFile) && platform.PathExists(pasteFile) { + if platform.PathExists(copyFile) { clipPassed = true break } diff --git a/cmd/lb/tests/expected.log b/cmd/lb/tests/expected.log @@ -313,7 +313,6 @@ json } } clipboard -clipboard will clear in 3 seconds invalids Wrong password? HMAC-SHA256 of header mismatching no store set diff --git a/internal/app/commands/core.go b/internal/app/commands/core.go @@ -12,8 +12,10 @@ const ( TOTP = "totp" // Conv handles text conversion of the data store Conv = "conv" - // ClipManager is a callback to manage clipboard clearing - ClipManager = "clipmanager" + // ClipManager can handle simple clipboard management + ClipManager = "clipmgr" + // ClipManagerDaemon is the backing call to perform clipboard management + ClipManagerDaemon = "clipmgrd" // Clip will copy values to the clipboard Clip = "clip" // Insert adds a value diff --git a/internal/config/toml_test.go b/internal/config/toml_test.go @@ -251,7 +251,7 @@ func TestDefaultTOMLToLoadFile(t *testing.T) { if err := config.LoadConfigFile(file); err != nil { t.Errorf("invalid error: %v", err) } - if len(store.List()) != 18 { + if len(store.List()) != 19 { t.Errorf("invalid environment after load: %d", len(store.List())) } } diff --git a/internal/config/vars.go b/internal/config/vars.go @@ -36,12 +36,23 @@ var ( }) // EnvClipTimeout gets the maximum clipboard time EnvClipTimeout = environmentRegister(EnvironmentInt{ - environmentDefault: newDefaultedEnvironment(45, + environmentDefault: newDefaultedEnvironment(120, environmentBase{ key: clipCategory + "TIMEOUT", - description: "Override the amount of time before totp clears the clipboard (seconds).", + description: "Override the amount of time before clearing the clipboard (seconds).", }), - short: "clipboard max time", + short: "clipboard entry max time", + }) + // EnvClipProcessFile configures the clip manager daemon pidfile + EnvClipProcessFile = environmentRegister(EnvironmentString{ + environmentStrings: environmentStrings{ + environmentDefault: newDefaultedEnvironment("", + environmentBase{ + key: clipCategory + "PIDFILE", + description: "Set the pidfile for clipboard management", + }), + allowed: []string{fileExample}, + }, }) // EnvJSONHashLength handles the hashing output length EnvJSONHashLength = environmentRegister(EnvironmentInt{ diff --git a/internal/config/vars_test.go b/internal/config/vars_test.go @@ -62,7 +62,7 @@ func TestFormatTOTP(t *testing.T) { } func TestClipboardMax(t *testing.T) { - checkInt(config.EnvClipTimeout, "LOCKBOX_CLIP_TIMEOUT", "clipboard max time", 45, false, t) + checkInt(config.EnvClipTimeout, "LOCKBOX_CLIP_TIMEOUT", "clipboard entry max time", 120, false, t) } func TestHashLength(t *testing.T) { @@ -161,6 +161,7 @@ func TestEmptyStrings(t *testing.T) { config.EnvStore, config.EnvKeyFile, config.EnvDefaultModTime, + config.EnvClipProcessFile, } { val := v.Get() if val != "" { diff --git a/internal/platform/clip/core.go b/internal/platform/clip/core.go @@ -6,7 +6,6 @@ import ( "fmt" "os/exec" - "git.sr.ht/~enckse/lockbox/internal/app/commands" "git.sr.ht/~enckse/lockbox/internal/config" "git.sr.ht/~enckse/lockbox/internal/platform" ) @@ -14,6 +13,7 @@ import ( type ( // Board represent system clipboard operations. Board struct { + PIDFile string copying []string pasting []string MaxTime int64 @@ -25,7 +25,8 @@ func newBoard(copying, pasting []string) (Board, error) { if err != nil { return Board{}, err } - return Board{copying: copying, pasting: pasting, MaxTime: maximum}, nil + pid := config.EnvClipProcessFile.Get() + return Board{copying: copying, pasting: pasting, MaxTime: maximum, PIDFile: pid}, nil } // New creates a new clipboard @@ -74,31 +75,33 @@ func New() (Board, error) { // CopyTo will copy to clipboard, if non-empty will clear later. func (c Board) CopyTo(value string) error { - cmd, args := c.Args(true) - pipeTo(cmd, value, true, args...) - if value != "" { - fmt.Printf("clipboard will clear in %d seconds\n", c.MaxTime) - pipeTo(commands.Executable, value, false, commands.ClipManager) + cmd, args, err := c.Args(true) + if err != nil { + return err } + pipeTo(cmd, value, args...) return nil } // Args returns clipboard args for execution. -func (c Board) Args(copying bool) (string, []string) { +func (c Board) Args(copying bool) (string, []string, error) { var using []string if copying { using = c.copying } else { using = c.pasting } + if len(using) == 0 { + return "", nil, fmt.Errorf("command is not set (copying? %v)", copying) + } var args []string if len(using) > 1 { args = using[1:] } - return using[0], args + return using[0], args, nil } -func pipeTo(command, value string, wait bool, args ...string) error { +func pipeTo(command, value string, args ...string) error { cmd := exec.Command(command, args...) stdin, err := cmd.StdinPipe() if err != nil { @@ -111,14 +114,5 @@ func pipeTo(command, value string, wait bool, args ...string) error { fmt.Printf("failed writing to stdin: %v\n", err) } }() - var ran error - if wait { - ran = cmd.Run() - } else { - ran = cmd.Start() - } - if ran != nil { - return errors.New("failed to run command") - } - return nil + return cmd.Run() } diff --git a/internal/platform/clip/core_test.go b/internal/platform/clip/core_test.go @@ -24,7 +24,7 @@ func TestMaxTime(t *testing.T) { if err != nil { t.Errorf("invalid clipboard: %v", err) } - if c.MaxTime != 45 { + if c.MaxTime != 120 { t.Error("invalid default") } store.SetInt64("LOCKBOX_CLIP_TIMEOUT", 1) @@ -37,7 +37,7 @@ func TestMaxTime(t *testing.T) { } store.SetInt64("LOCKBOX_CLIP_TIMEOUT", -1) _, err = clip.New() - if err == nil || err.Error() != "clipboard max time must be > 0" { + if err == nil || err.Error() != "clipboard entry max time must be > 0" { t.Errorf("invalid max time error: %v", err) } } @@ -63,12 +63,12 @@ func TestArgsOverride(t *testing.T) { if err != nil { t.Errorf("invalid error: %v", err) } - cmd, args := c.Args(true) - if cmd != "clip.exe" || len(args) != 0 { + cmd, args, err := c.Args(true) + if cmd != "clip.exe" || len(args) != 0 || err != nil { t.Error("invalid parse") } - cmd, args = c.Args(false) - if cmd != "abc" || len(args) != 2 || args[0] != "xyz" || args[1] != "111" { + cmd, args, err = c.Args(false) + if cmd != "abc" || len(args) != 2 || args[0] != "xyz" || args[1] != "111" || err != nil { t.Error("invalid parse") } store.SetArray("LOCKBOX_CLIP_COPY_COMMAND", []string{"zzz", "lll", "123"}) @@ -76,12 +76,12 @@ func TestArgsOverride(t *testing.T) { if err != nil { t.Errorf("invalid error: %v", err) } - cmd, args = c.Args(true) - if cmd != "zzz" || len(args) != 2 || args[0] != "lll" || args[1] != "123" { + cmd, args, err = c.Args(true) + if cmd != "zzz" || len(args) != 2 || args[0] != "lll" || args[1] != "123" || err != nil { t.Error("invalid parse") } - cmd, args = c.Args(false) - if cmd != "abc" || len(args) != 2 || args[0] != "xyz" || args[1] != "111" { + cmd, args, err = c.Args(false) + if cmd != "abc" || len(args) != 2 || args[0] != "xyz" || args[1] != "111" || err != nil { t.Error("invalid parse") } store.Clear() @@ -90,12 +90,16 @@ func TestArgsOverride(t *testing.T) { if err != nil { t.Errorf("invalid error: %v", err) } - cmd, args = c.Args(true) - if cmd != "clip.exe" || len(args) != 0 { + cmd, args, err = c.Args(true) + if cmd != "clip.exe" || len(args) != 0 || err != nil { t.Error("invalid parse") } - cmd, args = c.Args(false) - if cmd != "powershell.exe" || len(args) != 2 || args[0] != "-command" || args[1] != "Get-Clipboard" { + cmd, args, err = c.Args(false) + if cmd != "powershell.exe" || len(args) != 2 || args[0] != "-command" || args[1] != "Get-Clipboard" || err != nil { t.Errorf("invalid parse %s %v", cmd, args) } + c = clip.Board{} + if _, _, err := c.Args(true); err == nil || err.Error() != "command is not set (copying? true)" { + t.Errorf("invalid error: %v", err) + } } diff --git a/internal/platform/clip/manager.go b/internal/platform/clip/manager.go @@ -0,0 +1,164 @@ +package clip + +import ( + "crypto/sha256" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "git.sr.ht/~enckse/lockbox/internal/app/commands" +) + +type ( + // Daemon is the manager interface + Daemon interface { + WriteFile(string, string) + ReadFile(string) ([]byte, error) + Output(string, ...string) ([]byte, error) + Start(string, ...string) error + Getpid() int + Copy(Board, string) + Sleep() + } + // DefaultDaemon is the default functioning daemon + DefaultDaemon struct{} +) + +// WriteFile will write the necessary file to backing filesystem +func (d DefaultDaemon) WriteFile(file, data string) { + os.WriteFile(file, []byte(data), 0o644) +} + +// ReadFile will read a file from the filesystem +func (d DefaultDaemon) ReadFile(file string) ([]byte, error) { + return os.ReadFile(file) +} + +// Output will run a command and get output +func (d DefaultDaemon) Output(cmd string, args ...string) ([]byte, error) { + return exec.Command(cmd, args...).Output() +} + +// Start will start an disconnected execution +func (d DefaultDaemon) Start(cmd string, args ...string) error { + return exec.Command(cmd, args...).Start() +} + +// Getpid will return the pid +func (d DefaultDaemon) Getpid() int { + return os.Getpid() +} + +// Copy will copy data to the clipboard +func (d DefaultDaemon) Copy(c Board, val string) { + c.CopyTo(val) +} + +// Sleep will cause a pause/delay/wait +func (d DefaultDaemon) Sleep() { + time.Sleep(1 * time.Second) +} + +// Manager handles the daemon runner +func Manager(daemon bool, manager Daemon) error { + if manager == nil { + return errors.New("manager is nil") + } + clipboard, err := New() + if err != nil { + return err + } + if clipboard.PIDFile == "" { + return errors.New("pidfile is unset") + } + if !daemon { + return manager.Start(commands.Executable, commands.ClipManagerDaemon) + } + paste, pasteArgs, err := clipboard.Args(false) + if err != nil { + return err + } + pasteFxn := func() (string, error) { + b, err := manager.Output(paste, pasteArgs...) + if err != nil { + return "", err + } + val := strings.TrimSpace(string(b)) + if val == "" { + return "", nil + } + hash := sha256.New() + if _, err := hash.Write([]byte(val)); err != nil { + return "", err + } + return fmt.Sprintf("%x", hash.Sum(nil)), nil + } + pid := strings.TrimSpace(fmt.Sprintf("%d", manager.Getpid())) + isCurrentProcess := func() (bool, error) { + b, err := manager.ReadFile(clipboard.PIDFile) + if err != nil { + return false, err + } + val := strings.TrimSpace(string(b)) + return val == pid, nil + } + manager.WriteFile(clipboard.PIDFile, pid) + var errs []error + for { + if len(errs) > 5 { + return errors.Join(errs...) + } + manager.Sleep() + ok, err := isCurrentProcess() + if err != nil { + errs = append(errs, err) + continue + } + if !ok { + return nil + } + current, err := pasteFxn() + if err != nil { + errs = append(errs, err) + continue + } + if current != "" { + ok, err := wait(current, clipboard, manager, isCurrentProcess, pasteFxn) + if err != nil { + errs = append(errs, err) + continue + } + if !ok { + return nil + } + } + errs = []error{} + } +} + +func wait(val string, clip Board, mgr Daemon, isCurrent func() (bool, error), pasteFxn func() (string, error)) (bool, error) { + var count int64 + for count < clip.MaxTime { + ok, err := isCurrent() + if err != nil { + return false, err + } + if !ok { + return false, nil + } + cur, err := pasteFxn() + if err != nil { + return false, err + } + if cur != val { + return true, nil + } + mgr.Sleep() + count++ + } + mgr.Copy(clip, "") + return true, nil +} diff --git a/internal/platform/clip/manager_test.go b/internal/platform/clip/manager_test.go @@ -0,0 +1,115 @@ +package clip_test + +import ( + "errors" + "fmt" + "strings" + "testing" + + "git.sr.ht/~enckse/lockbox/internal/config/store" + "git.sr.ht/~enckse/lockbox/internal/platform" + "git.sr.ht/~enckse/lockbox/internal/platform/clip" +) + +type mock struct { + err error + file string + data string + cmd string + args []string + pid int + pasted int +} + +func (d *mock) WriteFile(file, data string) { + d.file = file + d.data = data +} + +func (d *mock) ReadFile(file string) ([]byte, error) { + if file == "falsepid" { + return []byte("1"), nil + } + d.file = file + return []byte(d.data), d.err +} + +func (d *mock) Output(cmd string, args ...string) ([]byte, error) { + d.pasted++ + d.cmd = cmd + d.args = args + val := fmt.Sprintf("%d", min(d.pasted, 100)) + return []byte(val), d.err +} + +func (d *mock) Start(cmd string, args ...string) error { + d.cmd = cmd + d.args = args + return d.err +} + +func (d *mock) Getpid() int { + return d.pid +} + +func (d *mock) Copy(_ clip.Board, val string) { + d.err = fmt.Errorf("copied%s: %d", val, d.pasted) +} + +func (d *mock) Sleep() { +} + +func TestErrors(t *testing.T) { + store.Clear() + defer store.Clear() + store.SetString("LOCKBOX_PLATFORM", string(platform.Systems.LinuxWaylandSystem)) + if err := clip.Manager(false, nil); err == nil || err.Error() != "manager is nil" { + t.Errorf("invalid error: %v", err) + } + if err := clip.Manager(false, &mock{}); err == nil || err.Error() != "pidfile is unset" { + t.Errorf("invalid error: %v", err) + } + store.SetString("LOCKBOX_CLIP_PIDFILE", "a") + m := &mock{} + m.err = errors.New("xyz") + if err := clip.Manager(true, m); err == nil || strings.Count(err.Error(), "xyz") != 6 { + t.Errorf("invalid error: %v", err) + } +} + +func TestStart(t *testing.T) { + store.Clear() + defer store.Clear() + store.SetString("LOCKBOX_PLATFORM", string(platform.Systems.LinuxWaylandSystem)) + store.SetString("LOCKBOX_CLIP_PIDFILE", "a") + m := &mock{} + if err := clip.Manager(false, m); err != nil { + t.Errorf("invalid error: %v", err) + } + if m.cmd != "lb" || fmt.Sprintf("%v", m.args) != "[clipmgrd]" { + t.Errorf("invalid calls: %s %v", m.cmd, m.args) + } +} + +func TestPIDMismatch(t *testing.T) { + store.Clear() + defer store.Clear() + store.SetString("LOCKBOX_PLATFORM", string(platform.Systems.LinuxWaylandSystem)) + store.SetString("LOCKBOX_CLIP_PIDFILE", "falsepid") + m := &mock{} + if err := clip.Manager(true, m); err != nil { + t.Errorf("invalid error: %v", err) + } +} + +func TestChange(t *testing.T) { + store.Clear() + defer store.Clear() + store.SetString("LOCKBOX_PLATFORM", string(platform.Systems.LinuxWaylandSystem)) + store.SetString("LOCKBOX_CLIP_PIDFILE", "a") + m := &mock{} + // NOTE: 100 (count before static) + 120 (default timeout) + 1 (caused break of loop) + if err := clip.Manager(true, m); err == nil || strings.Count(err.Error(), "copied: 221") != 6 { + t.Errorf("invalid error: %v", err) + } +}