gopass/internal/action/delete.go
google-labs-jules[bot] 8c60b17c24
feat(age): Add unlock command to age agent (#3244)
* feat(age): Add unlock command to age agent

This change introduces a proper lock/unlock mechanism for the age agent.

The issue was that after locking the agent with `gopass age lock`, there was no way to unlock it without restarting the agent. This made the lock command mostly useless.

This change introduces a new `unlock` command for the agent and a `locked` state.

- The `lock` command now sets a `locked` flag to `true` in addition to clearing identities.
- The `decrypt` function in the agent now checks this `locked` flag and returns an error if the agent is locked.
- When the gopass client receives the "agent is locked" error, it will ask the user for their passphrase, reload the identities, and send them to the agent.
- A new `gopass age agent unlock` CLI command is added to trigger this new functionality.
- The `gopass age agent status` command is enhanced to report whether the agent is locked.
- The old top-level `gopass age lock` command is hidden, and a new `gopass age agent lock` command is introduced for consistency.

Fixes #3242

* feat(age): Add unlock command to age agent

This change introduces a proper lock/unlock mechanism for the age agent.

The issue was that after locking the agent with `gopass age lock`, there was no way to unlock it without restarting the agent. This made the lock command mostly useless.

This change introduces a new `unlock` command for the agent and a `locked` state.

- The `lock` command now sets a `locked` flag to `true` in addition to clearing identities.
- The `decrypt` function in the agent now checks this `locked` flag and returns an error if the agent is locked.
- When the gopass client receives the "agent is locked" error, it will ask the user for their passphrase, reload the identities, and send them to the agent.
- A new `gopass age agent unlock` CLI command is added to trigger this new functionality.
- The `gopass age agent status` command is enhanced to report whether the agent is locked.
- The old top-level `gopass age lock` command is hidden, and a new `gopass age agent lock` command is introduced for consistency.

I have also addressed the PR comment about the import alias. I have removed the alias and used a dot import instead to avoid the name collision.

Fixes #3242

* feat(age): Add unlock command to age agent

This change introduces a proper lock/unlock mechanism for the age agent.

The issue was that after locking the agent with `gopass age lock`, there was no way to unlock it without restarting the agent. This made the lock command mostly useless.

This change introduces a new `unlock` command for the agent and a `locked` state.

- The `lock` command now sets a `locked` flag to `true` in addition to clearing identities.
- The `decrypt` function in the agent now checks this `locked` flag and returns an error if the agent is locked.
- When the gopass client receives the "agent is locked" error, it will ask the user for their passphrase, reload the identities, and send them to the agent.
- A new `gopass age agent unlock` CLI command is added to trigger this new functionality.
- The `gopass age agent status` command is enhanced to report whether the agent is locked.
- The old top-level `gopass age lock` command is hidden, and a new `gopass age agent lock` command is introduced for consistency.

To avoid name collisions with the imported `filippo.io/age` package, the local `age` package has been renamed to `agecrypto`.

Fixes #3242

* feat(age): Add auto-lock feature to age agent

This change introduces an auto-lock feature for the age agent. The agent will now automatically lock itself after a configurable period of inactivity.

This change also includes the initial fix for issue #3242, which introduced a proper lock/unlock mechanism for the age agent.

- A new config option `age.agent-timeout` is added to specify the inactivity timeout in seconds.
- The agent now has a timer that is reset on every successful decryption operation.
- If the timer expires, the agent locks itself.
- A new `set-timeout` command is added to the agent protocol to configure the timeout.
- The gopass client sends the timeout to the agent when it starts or when it unlocks the agent.
- A new test `TestAgentAutoLock` is added to verify the new functionality.

To avoid name collisions with the imported `filippo.io/age` package, the local `age` package has been renamed to `agecrypto`.

Fixes #3242

* [fix] Fix lint issues

Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>

---------

Signed-off-by: Dominik Schulz <dominik.schulz@gauner.org>
Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: Dominik Schulz <dominik.schulz@gauner.org>
2025-09-20 17:09:12 +02:00

136 lines
3.9 KiB
Go

package action
import (
"context"
"errors"
"fmt"
"github.com/gopasspw/gopass/internal/action/exit"
"github.com/gopasspw/gopass/internal/hook"
"github.com/gopasspw/gopass/internal/out"
"github.com/gopasspw/gopass/internal/store"
"github.com/gopasspw/gopass/pkg/ctxutil"
"github.com/gopasspw/gopass/pkg/debug"
"github.com/gopasspw/gopass/pkg/termio"
"github.com/urfave/cli/v2"
)
// Delete a secret file with its content.
func (s *Action) Delete(c *cli.Context) error {
ctx := ctxutil.WithGlobalFlags(c)
recursive := c.Bool("recursive")
name := c.Args().First()
if name == "" {
return exit.Error(exit.Usage, nil, "Usage: %s rm name", s.Name)
}
if recursive {
if len(c.Args().Tail()) > 1 {
return exit.Error(exit.Usage, nil, "Deleting multiple keys is not supported in recursive mode")
}
return s.deleteRecursive(ctx, name, c.Bool("force"))
}
if s.Store.IsDir(ctx, name) && !s.Store.Exists(ctx, name) {
return exit.Error(exit.Usage, nil, "Cannot remove %q: Is a directory. Use 'gopass rm -r %s' to delete", name, name)
}
// specifying a key is optional.
key := c.Args().Get(1)
// multiple secrets, so not a key
if len(c.Args().Tail()) > 1 {
key = ""
}
// Check for custom commit message
commitMsg := fmt.Sprintf("Deleted %s", name)
if key != "" {
commitMsg = fmt.Sprintf("Deleted key %s from %s", key, name)
}
if c.IsSet("commit-message") {
commitMsg = c.String("commit-message")
}
if c.Bool("interactive-commit") {
commitMsg = ""
}
ctx = ctxutil.WithCommitMessage(ctx, commitMsg)
names := append([]string{name}, c.Args().Tail()...)
if key != "" && s.Store.Exists(ctx, key) {
return exit.Error(exit.Unsupported, nil, "Key %q clashes with a secret of this name, use 'gopass edit %s' to delete", key, name)
}
if !s.Store.Exists(ctx, name) {
return exit.Error(exit.NotFound, nil, "Secret %q does not exist", name)
}
if !c.Bool("force") { // don't check if it's force anyway.
qStr := fmt.Sprintf("☠ Are you sure you would like to delete %q?", names)
if key != "" {
qStr = fmt.Sprintf("☠ Are you sure you would like to delete %q from %q?", key, name)
}
if (s.Store.Exists(ctx, name) || s.Store.IsDir(ctx, name)) && key == "" && !termio.AskForConfirmation(ctx, qStr) {
return nil
}
}
// deletes a single key from a YAML doc.
if key != "" {
debug.Log("removing key %q from %q", key, name)
return s.deleteKeyFromYAML(ctx, name, key)
}
for _, name := range names {
debug.Log("removing entry %q", name)
if err := s.Store.Delete(ctx, name); err != nil {
return exit.Error(exit.IO, err, "Can not delete %q: %s", name, err)
}
if err := hook.InvokeRoot(ctx, "delete.post-hook", name, s.Store); err != nil {
return exit.Error(exit.Hook, err, "Hook failed for %s: %s", name, err)
}
}
return nil
}
func (s *Action) deleteRecursive(ctx context.Context, name string, force bool) error {
if !force { // don't check if it's force anyway.
if (s.Store.Exists(ctx, name) || s.Store.IsDir(ctx, name)) && !termio.AskForConfirmation(ctx, fmt.Sprintf("☠ Are you sure you would like to recursively delete %q?", name)) {
return nil
}
}
debug.Log("pruning %q", name)
if err := s.Store.Prune(ctx, name); err != nil {
return exit.Error(exit.Unknown, err, "failed to prune %q: %s", name, err)
}
debug.Log("pruned %q", name)
return nil
}
// deleteKeyFromYAML deletes a single key from YAML.
func (s *Action) deleteKeyFromYAML(ctx context.Context, name, key string) error {
sec, err := s.Store.Get(ctx, name)
if err != nil {
return exit.Error(exit.IO, err, "Can not delete key %q from %q: %s", key, name, err)
}
sec.Del(key)
if err := s.Store.Set(ctx, name, sec); err != nil {
if !errors.Is(err, store.ErrMeaninglessWrite) {
return exit.Error(exit.IO, err, "Can not delete key %q from %q: %s", key, name, err)
}
out.Warningf(ctx, "No need to write: the YAML file does't seem to have the key to be deleted")
}
return hook.Invoke(ctx, "delete.post-hook", name, key)
}