Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ It's possible to adapt the application colors to your preferred color scheme. To

It's possible to use andcli without the TUI and query a vault directly: `andcli --query 'something'`. The result will be either a string separated by " " as in `<Issuer> <Token> <ValidSecs>` or, in the case of multiple/no matches, an error.

## Session timeout

andcli will auto-quit after an adjustable time to not leave juicy info exposed in the open. The default session timeout is set to 300s (5 minutes) and can be adjusted via the `--session-timeout` flag or set directly as `session_timeout` in the config file. It can be disabled by setting this value to 0.

## Options

```text
Expand All @@ -70,6 +74,7 @@ Options:
-h, --help Show this help
--passwd-stdin Read the vault password from stdin. If set, skips the password input.
-q, --query string Query the vault directly and skip TUI functionality
--session-timeout int Auto-close after N seconds of inactivity (0=disabled) (default 300)
--timeout int Timeout for decrypting the vault file, in seconds (default 5)
-t, --type string Vault type (andotp, aegis, twofas, stratum, keepass, proton)
-v, --version Prints version info and exits
Expand Down
2 changes: 1 addition & 1 deletion cmd/andcli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func open(cfg *config.Config) (vaults.Vault, error) {

select {
case <-done:
case <-time.After(cfg.Timeout()):
case <-time.After(cfg.DecryptionTimeoutD()):
return nil, fmt.Errorf("decrypt: operation timed out. wrong type?")
}

Expand Down
2 changes: 1 addition & 1 deletion internal/config/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func replace(af *ast.File, pathStr string, value any) error {

node, err := path.FilterFile(af)
if err != nil {
return nil
return err
}

var newNode ast.Node
Expand Down
30 changes: 22 additions & 8 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@ import (

type (
Config struct {
File string `yaml:"file"`
Type vaults.Type `yaml:"type"`
ClipboardCmd string `yaml:"clipboard_cmd"`
Options *Opts `yaml:"options"`
Theme *Theme `yaml:"theme"`
File string `yaml:"file"`
Type vaults.Type `yaml:"type"`
ClipboardCmd string `yaml:"clipboard_cmd"`
Options *Opts `yaml:"options"`
Theme *Theme `yaml:"theme"`
SessionTimeout int `yaml:"session_timeout"`
//
path string
passwordFromStdin bool
Expand Down Expand Up @@ -58,7 +59,8 @@ func create(dir string) (*Config, error) {
ShowUsernames: true,
ShowTokens: false,
},
Theme: &DefaultTheme,
Theme: &DefaultTheme,
SessionTimeout: 300,
}

if err := cfg.mergeExisting(); err != nil {
Expand Down Expand Up @@ -97,6 +99,7 @@ func (cfg Config) Persist() error {
"$.file": cfg.File,
"$.type": string(cfg.Type),
"$.clipboard_cmd": cfg.ClipboardCmd,
"$.session_timeout": cfg.SessionTimeout,
"$.options.show_usernames": cfg.Options.ShowUsernames,
"$.options.show_tokens": cfg.Options.ShowTokens,
"$.theme.base": cfg.Theme.Base,
Expand All @@ -108,8 +111,13 @@ func (cfg Config) Persist() error {
"$.theme.white": cfg.Theme.White,
}

// fallback: write full file if a key is missing (old version)
if err := apply(af, patch); err != nil {
return err
b, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return os.WriteFile(cfg.path, b, 0o600)
}

return os.WriteFile(cfg.path, []byte(af.String()), 0o600)
Expand All @@ -126,10 +134,15 @@ func (cfg Config) Query() string {
}

// Returns the timeout value as time.Duration.
func (cfg Config) Timeout() time.Duration {
func (cfg Config) DecryptionTimeoutD() time.Duration {
return time.Duration(cfg.timeout * int(time.Second))
}

// Returns the session timeout value as time.Duration.
func (cfg Config) SessionTimeoutD() time.Duration {
return time.Duration(cfg.SessionTimeout * int(time.Second))
}

// Reads an possibly existing config file and merges the content
// into the current config.
func (cfg *Config) mergeExisting() error {
Expand All @@ -150,6 +163,7 @@ func (cfg *Config) mergeExisting() error {
cfg.File = existing.File
cfg.Type = existing.Type
cfg.ClipboardCmd = existing.ClipboardCmd
cfg.SessionTimeout = existing.SessionTimeout

if existing.Options != nil {
cfg.Options = existing.Options
Expand Down
44 changes: 37 additions & 7 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ func TestConfig_mergeExisting(t *testing.T) {
},
false,
},
{
"merges session timeout",
&Config{File: "", Type: "", ClipboardCmd: "", SessionTimeout: 0, path: path},
&Config{File: "/tmp/test.json", Type: "aegis", ClipboardCmd: "", SessionTimeout: 600, path: path},
false,
},
{
"handles custom options",
&Config{
Expand Down Expand Up @@ -172,7 +178,7 @@ func TestConfig_Persist(t *testing.T) {
fname := filepath.Join(os.TempDir(), "andcli_test_config.yaml")
defer os.RemoveAll(fname)

cfg := &Config{File: "test.json", Type: "aegis", ClipboardCmd: "/usr/bin/test", path: fname, dirty: true}
cfg := &Config{File: "test.json", Type: "aegis", ClipboardCmd: "/usr/bin/test", SessionTimeout: 300, path: fname, dirty: true}
if err := cfg.Persist(); err != nil {
t.Errorf("Config.Persist() error = %v, expected none", err)
return
Expand Down Expand Up @@ -206,6 +212,7 @@ func TestConfig_Persist_preservesComments(t *testing.T) {
original := `# This is a comment at the top
file: /path/to/vault.json # inline comment
type: aegis
session_timeout: 300
# Comment before options
options:
show_usernames: true # another inline
Expand All @@ -227,9 +234,10 @@ theme:
}

cfg := &Config{
File: "/new/vault.json",
Type: vaults.Type("2fas"),
ClipboardCmd: "pbcopy",
File: "/new/vault.json",
Type: vaults.Type("2fas"),
ClipboardCmd: "pbcopy",
SessionTimeout: 300,
Options: &Opts{
ShowUsernames: true,
ShowTokens: true,
Expand Down Expand Up @@ -275,6 +283,9 @@ theme:
if strings.Contains(string(b), "aegis") {
t.Error("type was not updated")
}
if !strings.Contains(string(b), "session_timeout: 300") {
t.Error("session timeout was not persisted")
}
}

func Test_create(t *testing.T) {
Expand All @@ -290,9 +301,10 @@ func Test_create(t *testing.T) {

// default config
want := &Config{
File: abs,
Type: vaults.Type(*vtype),
ClipboardCmd: "",
File: abs,
Type: vaults.Type(*vtype),
SessionTimeout: 300,
ClipboardCmd: "",
Options: &Opts{
ShowUsernames: true,
ShowTokens: false,
Expand Down Expand Up @@ -414,6 +426,24 @@ func TestConfig_Flags(t *testing.T) {
}
},
},
{
"sets session timeout",
[]string{"andcli", "--session-timeout", "600", "-t", "aegis", tmpFile.Name()},
func(c *Config) {
if c.SessionTimeout != 600 {
t.Errorf("SessionTimeout = %d, want %d", c.SessionTimeout, 600)
}
},
},
{
"session timeout disabled on 0",
[]string{"andcli", "--session-timeout", "0", "-t", "aegis", tmpFile.Name()},
func(c *Config) {
if c.SessionTimeout != 0 {
t.Errorf("SessionTimeout = %d, want %d", c.SessionTimeout, 0)
}
},
},
}

for _, tt := range tests {
Expand Down
26 changes: 16 additions & 10 deletions internal/config/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@ import (
)

var (
set = flag.NewFlagSet("default", flag.ExitOnError)
vfile = set.StringP("file", "f", "", "Path to the encrypted vault (deprecated: Pass the filename directly)")
vtype = set.StringP("type", "t", "", fmt.Sprintf("Vault type (%s)", vaults.StrTypes()))
cmd = set.StringP("clipboard-cmd", "c", "", "A custom clipboard command, including args (xclip, wl-copy, pbcopy etc.)")
pwstdin = set.Bool("passwd-stdin", false, "Read the vault password from stdin. If set, skips the password input.")
query = set.StringP("query", "q", "", "Query the vault directly and skip TUI functionality")
version = set.BoolP("version", "v", false, "Prints version info and exits")
timeout = set.Int("timeout", 5, "Timeout for decrypting the vault file, in seconds")
help = set.BoolP("help", "h", false, "Show this help")
set = flag.NewFlagSet("default", flag.ExitOnError)
vfile = set.StringP("file", "f", "", "Path to the encrypted vault (deprecated: Pass the filename directly)")
vtype = set.StringP("type", "t", "", fmt.Sprintf("Vault type (%s)", vaults.StrTypes()))
cmd = set.StringP("clipboard-cmd", "c", "", "A custom clipboard command, including args (xclip, wl-copy, pbcopy etc.)")
pwstdin = set.Bool("passwd-stdin", false, "Read the vault password from stdin. If set, skips the password input.")
query = set.StringP("query", "q", "", "Query the vault directly and skip TUI functionality")
version = set.BoolP("version", "v", false, "Prints version info and exits")
decryptionTimeout = set.Int("timeout", 5, "Timeout for decrypting the vault file, in seconds")
sessionTimeout = set.Int("session-timeout", 300, "Auto-close after N seconds of inactivity (0=disabled)")
help = set.BoolP("help", "h", false, "Show this help")
)

// Parses given flags into the existing config.
Expand Down Expand Up @@ -79,11 +80,16 @@ func (cfg *Config) parseFlags() error {
cfg.dirty = true
}

cfg.timeout = *timeout
cfg.timeout = *decryptionTimeout
if cfg.timeout <= 0 {
cfg.timeout = 5
}

if set.Changed("session-timeout") {
cfg.SessionTimeout = max(*sessionTimeout, 0)
cfg.dirty = true
}

return nil
}

Expand Down
27 changes: 19 additions & 8 deletions internal/model/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import (

type (
Model struct {
list list.Model
state *appState
style *appStyle
cb *clipboard.Clipboard
list list.Model
state *appState
style *appStyle
cb *clipboard.Clipboard
lastActivity time.Time
sessionTimeout time.Duration
}

appState struct {
Expand Down Expand Up @@ -60,10 +62,12 @@ func New(entries []vaults.Entry, cfg *config.Config) Model {
dlg := &itemDelegate{style, state}

m := Model{
list: initList(items, dlg, title),
state: state,
style: style,
cb: clipboard.New(cfg.ClipboardCmd),
list: initList(items, dlg, title),
state: state,
style: style,
cb: clipboard.New(cfg.ClipboardCmd),
sessionTimeout: cfg.SessionTimeoutD(),
lastActivity: time.Now(),
}

m.updateToken()
Expand All @@ -80,6 +84,9 @@ func (m Model) Init() tea.Cmd {
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
// resets on each keypress
m.lastActivity = time.Now()

if m.list.FilterState() == list.Filtering {
break
}
Expand All @@ -104,6 +111,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}

case tickMsg:
if m.sessionTimeout > 0 && time.Since(m.lastActivity) > m.sessionTimeout {
return m, tea.Quit
}

m.updateToken()
return m, tick()

Expand Down
Loading