2 Commits
Author SHA1 Message Date
sneak b4a03c595e Ask before removing a secret, version, vault or unlocker (closes #39)
check / check (push) Failing after 3s
secret rm, secret version rm, secret vault remove and secret unlocker
remove ask [y/N] on a terminal, naming what they remove, and go ahead
only on y or yes. Without --force, a command whose stdin is not a
terminal fails at once. --force, now also on rm and version rm,
removes without asking; it replaces the old refusals to remove a vault
with secrets or the last unlocker without --force. The checks run and
the question is asked before the state directory lock is taken; under
the lock the checks run again, and nothing is removed if they would
ask a different question.

Model: opus-5-5
2026-10-04 15:18:17 +00:00
clawbot 7e4e0f7806 Give every new unlocker a directory of its own (closes #71)
check / check (push) Failing after 1s
A passphrase unlocker added to a vault that had one, and a PGP, keychain
or Secure Enclave unlocker added on the same day as another of its type,
were written into the existing unlocker's directory file by file, so a
crash part-way left a current unlocker whose files did not belong
together.

Unlocker directories, keychain items and Secure Enclave keys are now
named with the time to the nanosecond, and secret.WriteDir refuses a
directory that exists. Adding a passphrase unlocker writes the new one,
points current-unlocker at it, and only then removes the vault's other
passphrase unlockers.

Model: opus-5-5
2026-10-04 16:58:45 +02:00
24 changed files with 1455 additions and 326 deletions
+53 -24
View File
@@ -70,6 +70,24 @@ make build
## Commands Reference ## Commands Reference
### Confirmation Before Removal
`secret rm`, `secret version rm`, `secret vault remove` and
`secret unlocker remove` destroy data that exists nowhere else. On a terminal
each one first asks `[y/N]`, naming exactly what it is about to remove, and
goes ahead only on `y` or `yes`; any other answer, a bare Enter included,
cancels and removes nothing. The question is asked only after the command's
checks have passed, and before it changes anything.
Whether to ask is decided by stdin, where the answer is read from, so
`secret rm foo | tee log` still asks. When stdin is not a terminal, as in a
script or a CI job, nobody is there to answer: the command fails at once,
removes nothing, and says to pass `--force`.
`--force` (`-f`) removes without asking, whatever the command removes: a vault
that holds secrets and the last unlocker of a vault included. Scripts that
remove things pass `--force`.
### Initialization ### Initialization
#### `secret init` #### `secret init`
@@ -100,13 +118,13 @@ Switches to the specified vault for subsequent operations.
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑 #### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
**DANGER**: Permanently removes a vault and all its secrets. Like Unix `rm`, **DANGER**: Permanently removes a vault and all its secrets. It first asks
this command does not ask for confirmation. for confirmation, naming the vault and how many secrets it holds (see
[Confirmation Before Removal](#confirmation-before-removal)). The last vault
cannot be removed. Removing the current vault makes another vault the current
one.
Requires --force if the vault contains secrets. With --force, will - `--force, -f`: Remove without asking, also a vault that contains secrets
automatically switch to another vault if removing the current one.
- `--force, -f`: Force removal even if vault contains secrets
- **NO RECOVERY**: All secrets in the vault will be permanently deleted - **NO RECOVERY**: All secrets in the vault will be permanently deleted
### Secret Management ### Secret Management
@@ -132,9 +150,12 @@ Retrieves and outputs a secret value to stdout.
Lists all secrets in the current vault. Optional filter for substring Lists all secrets in the current vault. Optional filter for substring
matching. matching.
#### `secret remove <secret-name>` / `secret rm` ⚠️ 🛑 #### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
**DANGER**: Permanently removes a secret and ALL its versions. Like Unix `rm`, this command does not ask for confirmation. **DANGER**: Permanently removes a secret and ALL its versions. It first asks
for confirmation, naming the secret, its vault and how many versions it has
(see [Confirmation Before Removal](#confirmation-before-removal)).
- `--force, -f`: Remove without asking
- **NO RECOVERY**: Once removed, the secret cannot be recovered - **NO RECOVERY**: Once removed, the secret cannot be recovered
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently deleted - **ALL VERSIONS DELETED**: Every version of the secret will be permanently deleted
@@ -158,10 +179,12 @@ Lists all versions of a secret showing creation time, status, and validity perio
Promotes a specific version to current by updating the symlink. Does not Promotes a specific version to current by updating the symlink. Does not
modify any timestamps, allowing for rollback scenarios. modify any timestamps, allowing for rollback scenarios.
#### `secret version remove <secret-name> <version>` / `secret version rm` ⚠️ 🛑 #### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
**DANGER**: Permanently removes a specific version of a secret. Like Unix **DANGER**: Permanently removes a specific version of a secret. It first asks
`rm`, this command does not ask for confirmation. for confirmation, naming the version, the secret and its vault (see
[Confirmation Before Removal](#confirmation-before-removal)).
- `--force, -f`: Remove without asking
- **NO RECOVERY**: Once removed, this version cannot be recovered - **NO RECOVERY**: Once removed, this version cannot be recovered
- Cannot remove the current version (must promote another version first) - Cannot remove the current version (must promote another version first)
@@ -197,14 +220,20 @@ Creates a new unlocker of the specified type:
**Options:** **Options:**
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified) - `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
A vault has one passphrase unlocker: adding one replaces the one the vault
has, which is removed only once the new one is the current unlocker.
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑 #### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
**DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command **DANGER**: Permanently removes an unlocker. It first asks for confirmation,
does not ask for confirmation. Cannot remove the last unlocker if the vault naming the unlocker and its vault and saying whether it is the vault's last
has secrets unless --force is used. An unlocker directory that unlocker; for the last one it says how many secrets the vault holds and warns
`secret unlocker list` skips with a warning, because its metadata cannot be that the vault then opens only with its mnemonic (see
read or parsed, is removed by the directory name the warning gives. [Confirmation Before Removal](#confirmation-before-removal)). An unlocker
- `--force, -f`: Force removal of last unlocker even if vault has secrets directory that `secret unlocker list` skips with a warning, because its
metadata cannot be read or parsed, is removed by the directory name the
warning gives.
- `--force, -f`: Remove without asking, even the last unlocker
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase, - **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
vault data will be PERMANENTLY INACCESSIBLE vault data will be PERMANENTLY INACCESSIBLE
- **NO RECOVERY**: Removing all unlockers without having your mnemonic means - **NO RECOVERY**: Removing all unlockers without having your mnemonic means
@@ -243,8 +272,8 @@ Decrypts data using an Age key stored as a secret.
├── vaults.d/ ├── vaults.d/
│ ├── default/ │ ├── default/
│ │ ├── unlockers.d/ │ │ ├── unlockers.d/
│ │ │ ├── passphrase/ # Passphrase unlocker │ │ │ ├── passphrase-<time>/ # Passphrase unlocker
│ │ │ └── pgp/ # PGP unlocker │ │ │ └── <host>-pgp-<time>/ # PGP unlocker
│ │ ├── secrets.d/ │ │ ├── secrets.d/
│ │ │ ├── api%key/ # Secret: api/key │ │ │ ├── api%key/ # Secret: api/key
│ │ │ │ ├── versions/ │ │ │ │ ├── versions/
@@ -260,7 +289,7 @@ Decrypts data using an Age key stored as a secret.
│ │ │ └── current -> versions/20231215.001 │ │ │ └── current -> versions/20231215.001
│ │ ├── vault-metadata.json # Vault metadata │ │ ├── vault-metadata.json # Vault metadata
│ │ ├── pub.age # Long-term public key │ │ ├── pub.age # Long-term public key
│ │ └── current-unlocker -> ../unlockers.d/passphrase │ │ └── current-unlocker # Current unlocker's directory name
│ └── work/ │ └── work/
│ ├── unlockers.d/ │ ├── unlockers.d/
│ ├── secrets.d/ │ ├── secrets.d/
@@ -374,7 +403,7 @@ secret list
secret get database/prod/password secret get database/prod/password
secret get services/api/key secret get services/api/key
# Remove a secret ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!) # Remove a secret ⚠️ 🛑 (asks first - PERMANENT!)
secret remove ssh/servers/web01 secret remove ssh/servers/web01
``` ```
@@ -397,7 +426,7 @@ echo "personal-email-pass" | secret add email/password
# List all vaults # List all vaults
secret vault list secret vault list
# Remove a vault ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!) # Remove a vault ⚠️ 🛑 (--force: NO CONFIRMATION - PERMANENT!)
secret vault remove personal --force secret vault remove personal --force
``` ```
@@ -415,7 +444,7 @@ secret unlocker list
# Select a specific unlocker # Select a specific unlocker
secret unlocker select <unlocker-id> secret unlocker select <unlocker-id>
# Remove an unlocker ⚠️ 🛑 (NO CONFIRMATION!) # Remove an unlocker ⚠️ 🛑 (asks first!)
secret unlocker remove <unlocker-id> secret unlocker remove <unlocker-id>
``` ```
@@ -428,7 +457,7 @@ secret version list database/prod/password
# Promote an older version to current # Promote an older version to current
secret version promote database/prod/password 20231215.001 secret version promote database/prod/password 20231215.001
# Remove an old version ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!) # Remove an old version ⚠️ 🛑 (asks first - PERMANENT!)
secret version remove database/prod/password 20231214.001 secret version remove database/prod/password 20231214.001
``` ```
+29 -11
View File
@@ -25,6 +25,34 @@ Bring the repo into policy compliance in one commit:
# Completed Steps # Completed Steps
- 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and
`secret unlocker remove` ask `[y/N]` before removing anything
(https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the
secret, its vault and its version count; the version, secret and vault; the
vault and its secret count; the unlocker, its vault and whether it is the
last, and for the last the vault's secret count and that the vault then
opens only with its mnemonic. Only `y` or `yes` goes ahead. Without
`--force`, a command whose stdin is not a terminal fails at once. `--force`
(now also on `rm` and `version rm`) removes without asking; it replaces the
old refusals to remove a vault with secrets or the last unlocker of one
without `--force`, which the question now covers. The checks run, and the
question is asked, before the state directory lock is taken; under the
lock the checks run again, and if they would ask a different question,
nothing is removed. `secret rm` fails when it cannot count the versions.
- 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
current unlocker that cannot open the vault
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
directory of its own, named with the time to the nanosecond:
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure
Enclave unlocker the keychain item or Secure Enclave key, which names the
directory, carries the time instead of the day. `secret.WriteDir` fails on a
directory that exists instead of writing into it. `unlocker add passphrase`
writes the new unlocker, makes it current, and only then removes the vault's
other passphrase unlockers; a crash between the last two steps leaves the old
one beside the new, and the old passphrase still opens the vault through it
until the next `unlocker add passphrase` or an `unlocker remove` removes it.
A PGP, keychain or Secure Enclave unlocker added on the same host and day as
another of its type is added beside it instead of replacing it.
- 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once - 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once
per command, in its `RunE`, into locked buffers on the CLI `Instance`, and per command, in its `RunE`, into locked buffers on the CLI `Instance`, and
unset at once, so that no program the command runs, `gpg` included, unset at once, so that no program the command runs, `gpg` included,
@@ -79,9 +107,7 @@ Bring the repo into policy compliance in one commit:
and encrypt everything before writing anything. All four unlocker and encrypt everything before writing anything. All four unlocker
types write their files through `secret.WriteDir`: a new unlocker is types write their files through `secret.WriteDir`: a new unlocker is
built in a temporary directory, renamed into place when complete and built in a temporary directory, renamed into place when complete and
removed on a failure. One added under the directory name of an removed on a failure.
existing unlocker is still written into that directory in place
(https://git.eeqj.de/sneak/secret/issues/71).
- 2026-10-04: `secret unlocker select` and `secret unlocker remove` - 2026-10-04: `secret unlocker select` and `secret unlocker remove`
skip, with the warning `unlocker list` gives, an unlocker directory skip, with the warning `unlocker list` gives, an unlocker directory
whose metadata file cannot be checked for, read or parsed, instead of whose metadata file cannot be checked for, read or parsed, instead of
@@ -177,12 +203,6 @@ Bring the repo into policy compliance in one commit:
into place, and removals rename out of the way first, so a version into place, and removals rename out of the way first, so a version
or secret is never half-added and never half-removed. An or secret is never half-added and never half-removed. An
interrupted command can still leave: interrupted command can still leave:
- a broken unlocker, when it was replacing one: an unlocker added
under the directory name of an existing one is rewritten file by
file. That happens to a passphrase unlocker added to a vault that
has one, and to a PGP, keychain or Secure Enclave unlocker added
on the same host and day as another of its type
(https://git.eeqj.de/sneak/secret/issues/71);
- from `init` or `vault create` killed after the passphrase prompt - from `init` or `vault create` killed after the passphrase prompt
but before the unlocker is written, a vault with no unlocker, but before the unlocker is written, a vault with no unlocker,
which `vault create` has already made the current vault; which `vault create` has already made the current vault;
@@ -298,8 +318,6 @@ Bring the repo into policy compliance in one commit:
- High priority: - High priority:
- Secure temporary file handling and cleanup. - Secure temporary file handling and cleanup.
- Initialize a default unlock key at vault creation. - Initialize a default unlock key at vault creation.
- Confirmation prompts for destructive operations (keys rm, vault
deletion).
- Add secret rm and vault deletion commands. - Add secret rm and vault deletion commands.
- Medium priority: - Medium priority:
- Standardize error messages; stop leaking internals. - Standardize error messages; stop leaking internals.
+1
View File
@@ -9,6 +9,7 @@ require (
github.com/btcsuite/btcd/btcec/v2 v2.1.3 github.com/btcsuite/btcd/btcec/v2 v2.1.3
github.com/btcsuite/btcd/btcutil v1.1.6 github.com/btcsuite/btcd/btcutil v1.1.6
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
github.com/creack/pty v1.1.24
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1 github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1
github.com/oklog/ulid/v2 v2.1.1 github.com/oklog/ulid/v2 v2.1.1
github.com/spf13/afero v1.14.0 github.com/spf13/afero v1.14.0
+2
View File
@@ -35,6 +35,8 @@ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+5
View File
@@ -3,6 +3,7 @@ package cli
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
@@ -21,6 +22,10 @@ type Instance struct {
// none. // none.
Mnemonic *memguard.LockedBuffer Mnemonic *memguard.LockedBuffer
UnlockPassphrase *memguard.LockedBuffer UnlockPassphrase *memguard.LockedBuffer
// terminal, when set, stands in for the terminal that confirm reads
// the user's answer from; only tests set it. When it is nil, confirm
// reads stdin, and only when stdin is a terminal.
terminal io.Reader
} }
// NewCLIInstance creates a new CLI instance with the real filesystem // NewCLIInstance creates a new CLI instance with the real filesystem
+108
View File
@@ -0,0 +1,108 @@
package cli
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strings"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// Sentinel errors for asking the user to confirm a removal
var (
errNoTerminal = errors.New("stdin is not a terminal, so there is " +
"nobody to ask for confirmation; pass --force to remove without asking")
errNotConfirmed = errors.New("cancelled; nothing was removed")
errChangedWhileAsking = errors.New("what was to be removed changed " +
"while waiting for the answer; nothing was removed")
)
// askThenLock asks the user to confirm a removal, unless force is set, and
// then takes the state directory lock and returns the function that
// releases it. find makes the command's checks, keeps what it found for
// the caller to remove, and returns the question that names it. find runs
// before the question, which is asked without the lock so that no other
// command waits while the user answers, and runs again once the lock is
// taken. That run is the last, so the caller removes what find found under
// the lock. If its question then differs from the one the user answered,
// something changed in between, and askThenLock fails.
func (cli *Instance) askThenLock(
cmd *cobra.Command, force bool, find func() (string, error),
) (func(), error) {
asked := ""
if !force {
question, err := find()
if err != nil {
return nil, err
}
err = cli.confirm(cmd, question)
if err != nil {
return nil, err
}
asked = question
}
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return nil, err
}
question, err := find()
if err == nil && !force && question != asked {
err = errChangedWhileAsking
}
if err != nil {
release()
return nil, err
}
return release, nil
}
// confirm asks question and returns nil only when the user answers y or
// yes; any other answer, a bare Enter included, cancels. When stdin is not
// a terminal it asks nothing and fails at once: nobody is there to answer,
// and waiting for an answer would hang a script. Stdin decides, not
// stdout, because the answer is read from stdin: `secret rm foo | tee log`
// still asks. The question goes to stderr.
func (cli *Instance) confirm(cmd *cobra.Command, question string) error {
answers := cli.terminal
if answers == nil {
answers = cmd.InOrStdin()
if !isTerminal(answers) {
return errNoTerminal
}
}
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s [y/N] ", question)
answer, err := bufio.NewReader(answers).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return fmt.Errorf("failed to read the answer: %w", err)
}
switch strings.ToLower(strings.TrimSpace(answer)) {
case "y", "yes":
return nil
default:
return errNotConfirmed
}
}
// isTerminal reports whether r is a terminal.
func isTerminal(r io.Reader) bool {
file, ok := r.(*os.File)
return ok && term.IsTerminal(int(file.Fd()))
}
+410
View File
@@ -0,0 +1,410 @@
// Confirmation Tests
//
// `secret rm`, `secret version rm`, `secret vault remove` and
// `secret unlocker remove` ask the user to confirm on a terminal, naming
// what they are about to remove, and remove it only on y or yes. --force
// skips the question. Without --force, a command whose stdin is not a
// terminal fails at once, since nobody is there to answer.
//
// The tests answer through Instance.terminal, which stands in for a
// terminal. Without it, whether stdin is a terminal decides; the tests in
// integration_test.go that run `secret rm` on a pseudo-terminal cover that.
//nolint:testpackage // sets the unexported terminal field of Instance
package cli
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
// confirmTestSecret is the secret the tests remove, or remove a
// version of, in the vault "work".
confirmTestSecret = "test/secret"
// lastUnlockerRemoval names the case that removes the only unlocker.
lastUnlockerRemoval = "unlocker rm, the last one"
)
// removal is one removal command, set up on its own state directory.
type removal struct {
fs afero.Fs
run func(cli *Instance, cmd *cobra.Command, force bool) error
// removed is the directory the command removes.
removed string
// question is the question the command asks.
question string
}
// newConfirmTestVaults returns an in-memory state directory with the
// vaults "other" and "work", the current one. "work" holds two versions of
// confirmTestSecret and the given number of PGP unlockers. It returns the
// directory of "work" and the older version.
func newConfirmTestVaults(
t *testing.T, unlockers int,
) (*afero.MemMapFs, string, string) {
t.Helper()
fs := &afero.MemMapFs{}
mnemonic := testMnemonicBuffer(t)
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic)
require.NoError(t, err)
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic)
require.NoError(t, err)
addTestSecret(t, vlt, []byte("older"), false)
addTestSecret(t, vlt, []byte("newer"), true)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
versions, err := secret.ListVersions(fs,
filepath.Join(vaultDir, "secrets.d", "test%secret"))
require.NoError(t, err)
require.Len(t, versions, 2)
for i := range unlockers {
writePGPUnlocker(t, fs, filepath.Join(vaultDir, "unlockers.d"),
fmt.Sprintf("pgp-%d", i),
time.Date(2026, time.October, 4, 12, i, 0, 0, time.UTC),
listTestGPGKeyID+string(rune('A'+i)))
}
// ListVersions lists the newest version first.
return fs, vaultDir, versions[1]
}
// newRemoval sets up the removal the command names.
func newRemoval(t *testing.T, command string) removal {
t.Helper()
unlockers := 2
if command == lastUnlockerRemoval {
unlockers = 1
}
fs, workDir, older := newConfirmTestVaults(t, unlockers)
unlockerID := "pgp-" + listTestGPGKeyID + "A"
removeFirstUnlocker := func(cli *Instance, cmd *cobra.Command, force bool) error {
return cli.UnlockersRemove(unlockerID, force, cmd)
}
switch command {
case "rm":
return removal{
fs: fs,
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
return cli.RemoveSecret(cmd, confirmTestSecret, force)
},
removed: filepath.Join(workDir, "secrets.d", "test%secret"),
question: "Permanently remove secret 'test/secret' and its 2 " +
"version(s) from vault 'work'?",
}
case "version rm":
return removal{
fs: fs,
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
return cli.RemoveVersion(cmd, confirmTestSecret, older, force)
},
removed: filepath.Join(
workDir, "secrets.d", "test%secret", "versions", older),
question: "Permanently remove version " + older +
" of secret 'test/secret' from vault 'work'?",
}
case "vault rm":
return removal{
fs: fs,
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
return cli.RemoveVault(cmd, "work", force)
},
removed: workDir,
question: "Permanently remove vault 'work' and its 1 secret(s)?",
}
case "unlocker rm":
return removal{
fs: fs,
run: removeFirstUnlocker,
removed: filepath.Join(workDir, "unlockers.d", "pgp-0"),
question: "Permanently remove unlocker '" + unlockerID +
"' from vault 'work'? It is not the vault's last unlocker.",
}
case lastUnlockerRemoval:
return removal{
fs: fs,
run: removeFirstUnlocker,
removed: filepath.Join(workDir, "unlockers.d", "pgp-0"),
question: "Permanently remove unlocker '" + unlockerID +
"', the last unlocker of vault 'work', which holds 1 " +
"secret(s)? Without an unlocker the vault opens only " +
"with its mnemonic.",
}
}
t.Fatalf("no removal %q", command)
return removal{}
}
// removalCommands lists the commands newRemoval sets up.
func removalCommands() []string {
return []string{
"rm", "version rm", "vault rm", "unlocker rm", lastUnlockerRemoval,
}
}
// newConfirmTestCommand returns a command whose output is discarded and
// whose stderr, where the question goes, is the returned buffer.
func newConfirmTestCommand() (*cobra.Command, *bytes.Buffer) {
var stderr bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
cmd.SetErr(&stderr)
return cmd, &stderr
}
// requireExists asserts whether the directory dir exists.
func requireExists(t *testing.T, fs afero.Fs, dir string, want bool) {
t.Helper()
exists, err := afero.DirExists(fs, dir)
require.NoError(t, err)
require.Equal(t, want, exists, dir)
}
// TestConfirmAnswers checks which answers confirm accepts: y or yes, in
// any case, around which spaces do not matter.
func TestConfirmAnswers(t *testing.T) {
t.Parallel()
for answer, want := range map[string]error{
"y\n": nil,
"Y\n": nil,
"yes\n": nil,
" YES \n": nil,
"y": nil,
"\n": errNotConfirmed,
"": errNotConfirmed,
"n\n": errNotConfirmed,
"yy\n": errNotConfirmed,
"no\ny\n": errNotConfirmed,
} {
t.Run(fmt.Sprintf("%q", answer), func(t *testing.T) {
t.Parallel()
cli := &Instance{terminal: strings.NewReader(answer)}
cmd, stderr := newConfirmTestCommand()
err := cli.confirm(cmd, "Remove it?")
require.ErrorIs(t, err, want)
assert.Equal(t, "Remove it? [y/N] ", stderr.String())
})
}
}
// TestRemovalAnsweredYesRemoves checks that each removal asks its question
// and removes what it names when the user answers y.
func TestRemovalAnsweredYesRemoves(t *testing.T) {
t.Parallel()
for _, command := range removalCommands() {
t.Run(command, func(t *testing.T) {
t.Parallel()
r := newRemoval(t, command)
requireExists(t, r.fs, r.removed, true)
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
cli.terminal = strings.NewReader("y\n")
cmd, stderr := newConfirmTestCommand()
require.NoError(t, r.run(cli, cmd, false))
assert.Equal(t, r.question+" [y/N] ", stderr.String())
requireExists(t, r.fs, r.removed, false)
})
}
}
// TestRemovalDeclinedLeavesEverything checks that each removal changes
// nothing when the user answers anything but y or yes, a bare Enter
// included.
func TestRemovalDeclinedLeavesEverything(t *testing.T) {
t.Parallel()
for _, command := range removalCommands() {
for _, answer := range []string{"\n", "n\n", ""} {
t.Run(fmt.Sprintf("%s %q", command, answer), func(t *testing.T) {
t.Parallel()
r := newRemoval(t, command)
before := stateDirModTimes(t, r.fs)
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
cli.terminal = strings.NewReader(answer)
cmd, stderr := newConfirmTestCommand()
err := r.run(cli, cmd, false)
require.ErrorIs(t, err, errNotConfirmed)
assert.Equal(t, r.question+" [y/N] ", stderr.String())
assert.Equal(t, before, stateDirModTimes(t, r.fs))
})
}
}
}
// TestRemovalForcedAsksNothing checks that each removal with --force
// removes what it would have named without asking, and without reading
// its input, which is not a terminal.
func TestRemovalForcedAsksNothing(t *testing.T) {
t.Parallel()
for _, command := range removalCommands() {
t.Run(command, func(t *testing.T) {
t.Parallel()
r := newRemoval(t, command)
input := strings.NewReader("n\n")
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
cmd, stderr := newConfirmTestCommand()
cmd.SetIn(input)
require.NoError(t, r.run(cli, cmd, true))
assert.Empty(t, stderr.String(), "asked with --force")
assert.Equal(t, 2, input.Len(), "read its input with --force")
requireExists(t, r.fs, r.removed, false)
})
}
}
// TestRemovalWithoutTerminalFailsAtOnce checks that each removal without
// --force, whose input is not a terminal, fails at once telling the user
// to pass --force, and changes nothing. The input is a pipe that nobody
// writes to or closes, so reading it would block for good.
func TestRemovalWithoutTerminalFailsAtOnce(t *testing.T) {
t.Parallel()
for _, command := range removalCommands() {
t.Run(command, func(t *testing.T) {
t.Parallel()
r := newRemoval(t, command)
before := stateDirModTimes(t, r.fs)
input, inputWriter, err := os.Pipe()
require.NoError(t, err)
t.Cleanup(func() {
_ = inputWriter.Close()
_ = input.Close()
})
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
cmd, stderr := newConfirmTestCommand()
cmd.SetIn(input)
done := make(chan error, 1)
go func() { done <- r.run(cli, cmd, false) }()
select {
case err := <-done:
require.ErrorIs(t, err, errNoTerminal)
assert.Contains(t, err.Error(), "pass --force")
case <-time.After(lockWait):
// Closing the pipe ends the read, and frees the lock if
// the command holds it.
_ = inputWriter.Close()
t.Fatal("waited for an answer on input that is not a terminal")
}
assert.Empty(t, stderr.String(), "asked without a terminal")
assert.Equal(t, before, stateDirModTimes(t, r.fs))
})
}
}
// TestRemovalAsksWithoutHoldingLock checks that while `secret rm` waits
// for its answer, another command can take the state directory lock and
// change the secret, and that the removal then removes nothing, since the
// secret is no longer what the question named.
func TestRemovalAsksWithoutHoldingLock(t *testing.T) {
t.Parallel()
r := newRemoval(t, "rm")
answers, answerWriter := io.Pipe()
questions, questionWriter := io.Pipe()
// Closing the answers ends the read if the test fails while the
// command waits for one.
t.Cleanup(func() { _ = answerWriter.Close() })
rm := NewCLIInstanceWithStateDir(r.fs, testStateDir)
rm.terminal = answers
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
cmd.SetErr(questionWriter)
done := make(chan error, 1)
go func() { done <- r.run(rm, cmd, false) }()
question, err := bufio.NewReader(questions).ReadString(']')
require.NoError(t, err)
require.Equal(t, r.question+" [y/N]", question)
// Adds a third version while rm waits for its answer.
add := NewCLIInstanceWithStateDir(r.fs, testStateDir)
add.Mnemonic = testMnemonicBuffer(t)
add.cmd = &cobra.Command{}
add.cmd.SetIn(strings.NewReader("newest"))
add.cmd.SetOut(io.Discard)
added := make(chan error, 1)
go func() { added <- add.AddSecret(confirmTestSecret, true) }()
select {
case err := <-added:
require.NoError(t, err)
case <-time.After(lockWait):
t.Fatal("secret add waited for the lock while secret rm asked")
}
_, err = answerWriter.Write([]byte("y\n"))
require.NoError(t, err)
select {
case err := <-done:
require.ErrorIs(t, err, errChangedWhileAsking)
case <-time.After(lockWait):
t.Fatal("secret rm did not finish once answered")
}
requireExists(t, r.fs, r.removed, true)
}
+159 -9
View File
@@ -2,10 +2,13 @@
package cli_test package cli_test
import ( import (
"bufio"
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -16,7 +19,11 @@ import (
"git.eeqj.de/sneak/secret/internal/cli" "git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/creack/pty"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -366,8 +373,15 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
unlockersDir := filepath.Join(defaultVaultDir, "unlockers.d") unlockersDir := filepath.Join(defaultVaultDir, "unlockers.d")
verifyFileExists(t, unlockersDir) verifyFileExists(t, unlockersDir)
// Check current-unlocker file names the unlocker's directory
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
verifyFileExists(t, currentUnlockerFile)
currentUnlockerContent := readFile(t, currentUnlockerFile)
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should point to passphrase type")
// Verify passphrase unlocker was created // Verify passphrase unlocker was created
passphraseUnlockerDir := filepath.Join(unlockersDir, "passphrase") passphraseUnlockerDir := filepath.Join(unlockersDir, string(currentUnlockerContent))
verifyFileExists(t, passphraseUnlockerDir) verifyFileExists(t, passphraseUnlockerDir)
// Check unlocker metadata // Check unlocker metadata
@@ -382,13 +396,6 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age") encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age")
verifyFileExists(t, encryptedLTPubKey) verifyFileExists(t, encryptedLTPubKey)
// Check current-unlocker file contains the relative path
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
verifyFileExists(t, currentUnlockerFile)
currentUnlockerContent := readFile(t, currentUnlockerFile)
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should point to passphrase type")
// Verify vault-metadata.json in vault // Verify vault-metadata.json in vault
vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json") vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
verifyFileExists(t, vaultMetadata) verifyFileExists(t, vaultMetadata)
@@ -537,7 +544,8 @@ func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase st
verifyFileExists(t, pubKeyFile) verifyFileExists(t, pubKeyFile)
// Verify passphrase unlocker was created // Verify passphrase unlocker was created
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", "passphrase") currentUnlocker := readFile(t, filepath.Join(workVaultDir, "current-unlocker"))
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", string(currentUnlocker))
verifyFileExists(t, passphraseUnlockerDir) verifyFileExists(t, passphraseUnlockerDir)
// Check unlocker files // Check unlocker files
@@ -2542,3 +2550,145 @@ func copyFile(src, dst string) error {
return nil return nil
} }
// secretRmCommand makes a state directory whose vault "default" holds the
// secret "x", and returns `secret rm x` on the built binary against it, and
// the directory of "x". The vault has no unlocker, so making it derives no
// key from a passphrase.
func secretRmCommand(ctx context.Context, t *testing.T) (*exec.Cmd, string) {
t.Helper()
stateDir := t.TempDir()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default", mnemonic)
require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("value"))
defer value.Destroy()
require.NoError(t, vlt.AddSecret("x", value, false))
//nolint:gosec // G204: test executes the freshly built secret binary
cmd := exec.CommandContext(ctx, secretBinaryPath(t), "rm", "x")
cmd.Env = []string{
secret.EnvStateDir + "=" + stateDir,
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
return cmd, filepath.Join(stateDir, "vaults.d", "default", "secrets.d", "x")
}
// TestRemoveWithoutTerminalFailsAtOnce runs `secret rm` without --force,
// with a stdin that is not a terminal and never delivers anything, as in a
// script or a CI job. It must fail at once, telling the user to pass
// --force, instead of waiting for an answer, and remove nothing.
func TestRemoveWithoutTerminalFailsAtOnce(t *testing.T) {
t.Parallel()
// Nobody writes to or closes the pipe, so reading it would block for good.
stdin, stdinWriter, err := os.Pipe()
require.NoError(t, err)
defer func() {
_ = stdinWriter.Close()
_ = stdin.Close()
}()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
cmd, secretDir := secretRmCommand(ctx, t)
cmd.Stdin = stdin
output, err := cmd.CombinedOutput()
require.NoError(t, ctx.Err(), "secret rm waited for an answer")
require.Error(t, err)
assert.Contains(t, string(output), "pass --force")
assert.DirExists(t, secretDir)
}
// The next two tests run `secret rm` with a terminal on stdin or on stdout
// and stderr, not both: whether it asks must depend on stdin alone, where
// the answer is read from. pty.Open returns the two ends of a new terminal:
// tty is the end a program uses as its terminal, and ptmx the end the test
// reads what the terminal shows from and types into.
// TestRemoveIgnoresTerminalOnStdout runs `echo y | secret rm x` at a
// terminal. stdin is a pipe, so nobody can answer there, and the command
// must fail as in a script, removing nothing.
func TestRemoveIgnoresTerminalOnStdout(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
cmd, secretDir := secretRmCommand(ctx, t)
ptmx, tty, err := pty.Open()
require.NoError(t, err)
defer func() { _ = ptmx.Close() }()
cmd.Stdin = strings.NewReader("y\n")
cmd.Stdout = tty
cmd.Stderr = tty
require.NoError(t, cmd.Start())
_ = tty.Close()
// The read ends once secret rm has exited and so closed the terminal.
shown, _ := io.ReadAll(ptmx)
require.Error(t, cmd.Wait())
assert.Contains(t, string(shown), "pass --force")
assert.DirExists(t, secretDir)
}
// TestRemoveAsksAtTerminalOnStdin runs `secret rm x | cat` at a terminal.
// It must ask on the terminal, and remove the secret when y is typed there.
func TestRemoveAsksAtTerminalOnStdin(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
cmd, secretDir := secretRmCommand(ctx, t)
ptmx, tty, err := pty.Open()
require.NoError(t, err)
defer func() { _ = ptmx.Close() }()
cmd.Stdin = tty
// Not a file, so exec.Cmd connects stdout through a pipe.
cmd.Stdout = io.Discard
cmd.Stderr = tty
require.NoError(t, cmd.Start())
_ = tty.Close()
var (
shown []byte
char byte
)
terminal := bufio.NewReader(ptmx)
for !bytes.HasSuffix(shown, []byte("[y/N] ")) {
char, err = terminal.ReadByte()
require.NoError(t, err, "secret rm ended without asking: %s", shown)
shown = append(shown, char)
}
_, err = ptmx.WriteString("y\n")
require.NoError(t, err)
require.NoError(t, cmd.Wait())
assert.NoDirExists(t, secretDir)
}
+11 -9
View File
@@ -241,8 +241,10 @@ func TestFailedCommandReleasesLock(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithStateDir(fs, testStateDir) cli := NewCLIInstanceWithStateDir(fs, testStateDir)
// Fails once it holds the lock: there is no current vault // Fails once it holds the lock: there is no current vault. Without
err := cli.RemoveSecret(&cobra.Command{}, "missing", false) // --force it would fail before taking the lock, on the check it makes
// before asking.
err := cli.RemoveSecret(&cobra.Command{}, "missing", true)
require.Error(t, err) require.Error(t, err)
select { select {
@@ -431,8 +433,8 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
{"encrypt", false, func(cli *Instance, _, _ string) error { {"encrypt", false, func(cli *Instance, _, _ string) error {
return cli.Encrypt("key", testInput, "") return cli.Encrypt("key", testInput, "")
}}, }},
{"rm", false, func(cli *Instance, _, _ string) error { {"rm --force", false, func(cli *Instance, _, _ string) error {
return cli.RemoveSecret(cli.cmd, "test/secret", false) return cli.RemoveSecret(cli.cmd, "test/secret", true)
}}, }},
{"move", false, func(cli *Instance, _, _ string) error { {"move", false, func(cli *Instance, _, _ string) error {
return cli.MoveSecret(cli.cmd, "test/secret", "moved", false) return cli.MoveSecret(cli.cmd, "test/secret", "moved", false)
@@ -440,8 +442,8 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
{"version promote", false, func(cli *Instance, olderVersion, _ string) error { {"version promote", false, func(cli *Instance, olderVersion, _ string) error {
return cli.PromoteVersion(cli.cmd, "test/secret", olderVersion) return cli.PromoteVersion(cli.cmd, "test/secret", olderVersion)
}}, }},
{"version rm", false, func(cli *Instance, olderVersion, _ string) error { {"version rm --force", false, func(cli *Instance, olderVersion, _ string) error {
return cli.RemoveVersion(cli.cmd, "test/secret", olderVersion) return cli.RemoveVersion(cli.cmd, "test/secret", olderVersion, true)
}}, }},
{"vault create", false, func(cli *Instance, _, _ string) error { {"vault create", false, func(cli *Instance, _, _ string) error {
return cli.CreateVault(cli.cmd, "created") return cli.CreateVault(cli.cmd, "created")
@@ -452,13 +454,13 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
{"vault import", false, func(cli *Instance, _, _ string) error { {"vault import", false, func(cli *Instance, _, _ string) error {
return cli.VaultImport(cli.cmd, "other") return cli.VaultImport(cli.cmd, "other")
}}, }},
{"vault rm", false, func(cli *Instance, _, _ string) error { {"vault rm --force", false, func(cli *Instance, _, _ string) error {
return cli.RemoveVault(cli.cmd, "other", false) return cli.RemoveVault(cli.cmd, "other", true)
}}, }},
{"unlocker add", false, func(cli *Instance, _, _ string) error { {"unlocker add", false, func(cli *Instance, _, _ string) error {
return cli.UnlockersAdd("passphrase", cli.cmd) return cli.UnlockersAdd("passphrase", cli.cmd)
}}, }},
{"unlocker rm", true, func(cli *Instance, _, unlockerID string) error { {"unlocker rm --force", true, func(cli *Instance, _, unlockerID string) error {
return cli.UnlockersRemove(unlockerID, true, cli.cmd) return cli.UnlockersRemove(unlockerID, true, cli.cmd)
}}, }},
{"unlocker select", true, func(cli *Instance, _, unlockerID string) error { {"unlocker select", true, func(cli *Instance, _, unlockerID string) error {
+19 -19
View File
@@ -170,8 +170,8 @@ func requireRejectedAndUnchanged(
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for // TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
// https://git.eeqj.de/sneak/secret/issues/33, where `secret rm ..` deleted // https://git.eeqj.de/sneak/secret/issues/33, where `secret rm ..` deleted
// the whole vault, and `secret rm .` or `secret rm ""` every secret in it. // the whole vault, and `secret rm .` or `secret rm ""` every secret in it.
// Moves and imports use --force, so that only the name check stands in // Removals, moves and imports use --force, so that only the name check
// the way. // stands in the way.
// //
//nolint:paralleltest // the cases share cmd //nolint:paralleltest // the cases share cmd
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) { func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
@@ -181,7 +181,7 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
vaultDir := testStateDir + "/vaults.d/default" vaultDir := testStateDir + "/vaults.d/default"
require.Contains(t, before, vaultDir+"/secrets.d/x/") require.Contains(t, before, vaultDir+"/secrets.d/x/")
require.Contains(t, before, vaultDir+"/unlockers.d/passphrase/") require.Contains(t, before, vaultDir+"/current-unlocker")
require.Equal(t, "default", before[testStateDir+"/currentvault"]) require.Equal(t, "default", before[testStateDir+"/currentvault"])
cmd := &cobra.Command{} cmd := &cobra.Command{}
@@ -191,17 +191,17 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
rejected string // the secret name the command must reject rejected string // the secret name the command must reject
run func(c *cli.Instance) error run func(c *cli.Instance) error
}{ }{
{"rm ..", "..", func(c *cli.Instance) error { {"rm --force ..", "..", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "..", false) return c.RemoveSecret(cmd, "..", true)
}}, }},
{"rm .", ".", func(c *cli.Instance) error { {"rm --force .", ".", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, ".", false) return c.RemoveSecret(cmd, ".", true)
}}, }},
{`rm ""`, "", func(c *cli.Instance) error { {`rm --force ""`, "", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "", false) return c.RemoveSecret(cmd, "", true)
}}, }},
{"rm ../../etc", "../../etc", func(c *cli.Instance) error { {"rm --force ../../etc", "../../etc", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "../../etc", false) return c.RemoveSecret(cmd, "../../etc", true)
}}, }},
{"mv --force .. x", "..", func(c *cli.Instance) error { {"mv --force .. x", "..", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "..", "x", true) return c.MoveSecret(cmd, "..", "x", true)
@@ -244,8 +244,8 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
{"version promote ..", "..", func(c *cli.Instance) error { {"version promote ..", "..", func(c *cli.Instance) error {
return c.PromoteVersion(cmd, "..", testVersion) return c.PromoteVersion(cmd, "..", testVersion)
}}, }},
{"version rm ..", "..", func(c *cli.Instance) error { {"version rm --force ..", "..", func(c *cli.Instance) error {
return c.RemoveVersion(cmd, "..", testVersion) return c.RemoveVersion(cmd, "..", testVersion, true)
}}, }},
{"encrypt ..", "..", func(c *cli.Instance) error { {"encrypt ..", "..", func(c *cli.Instance) error {
return c.Encrypt("..", "", "") return c.Encrypt("..", "", "")
@@ -279,8 +279,8 @@ func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
command string command string
run func(c *cli.Instance, version string) error run func(c *cli.Instance, version string) error
}{ }{
{"version rm x", func(c *cli.Instance, version string) error { {"version rm --force x", func(c *cli.Instance, version string) error {
return c.RemoveVersion(cmd, "x", version) return c.RemoveVersion(cmd, "x", version, true)
}}, }},
{"version promote x", func(c *cli.Instance, version string) error { {"version promote x", func(c *cli.Instance, version string) error {
return c.PromoteVersion(cmd, "x", version) return c.PromoteVersion(cmd, "x", version)
@@ -361,9 +361,9 @@ func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
} }
} }
// TestRemoveVersionRemovesOnlyThatVersion checks that `secret version rm` // TestRemoveVersionRemovesOnlyThatVersion checks that
// with a version that is not the current one removes that version and // `secret version rm --force` with a version that is not the current one
// changes nothing else. // removes that version and changes nothing else.
func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) { func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
t.Parallel() t.Parallel()
@@ -389,7 +389,7 @@ func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
require.Contains(t, before, oldDir) require.Contains(t, before, oldDir)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir) c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
err = c.RemoveVersion(&cobra.Command{}, "x", versions[1]) err = c.RemoveVersion(&cobra.Command{}, "x", versions[1], true)
require.NoError(t, err) require.NoError(t, err)
// Expected: the state as before without everything under oldDir. // Expected: the state as before without everything under oldDir.
+68 -29
View File
@@ -205,19 +205,25 @@ func newRemoveCmd() *cobra.Command {
Aliases: []string{"rm"}, Aliases: []string{"rm"},
Short: "Remove a secret from the vault", Short: "Remove a secret from the vault",
Long: `Remove a secret and all its versions from the current ` + Long: `Remove a secret and all its versions from the current ` +
`vault. This action is permanent and cannot be undone.`, `vault. This action is permanent and cannot be undone. ` +
`Asks for confirmation first; when stdin is not a terminal, ` +
`fails unless --force is given.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir), ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
cli, err := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
return cli.RemoveSecret(cmd, args[0], false) return cli.RemoveSecret(cmd, args[0], force)
}, },
} }
cmd.Flags().BoolP("force", "f", false, "Remove without asking for confirmation")
return cmd return cmd
} }
@@ -699,29 +705,64 @@ func (cli *Instance) ImportSecret(
return nil return nil
} }
// RemoveSecret removes a secret from the vault // RemoveSecret removes a secret and all its versions from the current
func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool) error { // vault, after asking the user to confirm unless force is set.
func (cli *Instance) RemoveSecret(
cmd *cobra.Command, secretName string, force bool,
) error {
err := vault.ValidateSecretName(secretName) err := vault.ValidateSecretName(secretName)
if err != nil { if err != nil {
return err return err
} }
release, err := vault.LockStateDir(cli.fs, cli.stateDir) var found secretToRemove
release, err := cli.askThenLock(cmd, force, func() (string, error) {
var err error
found, err = cli.findSecretToRemove(secretName)
return found.question, err
})
if err != nil { if err != nil {
return err return err
} }
defer release() defer release()
// Get current vault err = secret.RemoveDirAtomic(cli.fs, found.dir)
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to remove secret: %w", err)
}
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n",
secretName, found.versions)
return nil
}
// secretToRemove is what removing a secret removes, as findSecretToRemove
// found it.
type secretToRemove struct {
// dir is the secret's directory, which holds all its versions.
dir string
versions int
// question names what is removed, for the user to confirm.
question string
}
// findSecretToRemove checks that the secret exists in the current vault
// and counts its versions.
func (cli *Instance) findSecretToRemove(
secretName string,
) (secretToRemove, error) {
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return secretToRemove{}, err
} }
// Check if secret exists
vaultDir, err := currentVlt.GetDirectory() vaultDir, err := currentVlt.GetDirectory()
if err != nil { if err != nil {
return err return secretToRemove{}, err
} }
encodedName := strings.ReplaceAll(secretName, "/", "%") encodedName := strings.ReplaceAll(secretName, "/", "%")
@@ -729,32 +770,30 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
exists, err := afero.DirExists(cli.fs, secretDir) exists, err := afero.DirExists(cli.fs, secretDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to check if secret exists: %w", err) return secretToRemove{},
fmt.Errorf("failed to check if secret exists: %w", err)
} }
if !exists { if !exists {
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound) return secretToRemove{},
fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
} }
// Count versions for information // A secret without a versions directory has no versions, and can
versionsDir := filepath.Join(secretDir, "versions") // still be removed.
versionCount := 0 versions, err := afero.ReadDir(cli.fs, filepath.Join(secretDir, "versions"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
entries, err := afero.ReadDir(cli.fs, versionsDir) return secretToRemove{}, fmt.Errorf(
if err == nil { "failed to count the versions of secret '%s': %w", secretName, err)
versionCount = len(entries)
} }
// Remove the secret directory return secretToRemove{
err = secret.RemoveDirAtomic(cli.fs, secretDir) dir: secretDir,
if err != nil { versions: len(versions),
return fmt.Errorf("failed to remove secret: %w", err) question: fmt.Sprintf("Permanently remove secret '%s' and its %d "+
} "version(s) from vault '%s'?",
secretName, len(versions), currentVlt.GetName()),
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n", }, nil
secretName, versionCount)
return nil
} }
// MoveSecret moves or renames a secret (within or across vaults), holding // MoveSecret moves or renames a secret (within or across vaults), holding
+79 -41
View File
@@ -47,7 +47,6 @@ var (
errGPGKeyAlreadyUnlocker = errors.New( errGPGKeyAlreadyUnlocker = errors.New(
"is already added as an unlocker") "is already added as an unlocker")
errUnsupportedUnlockerType = errors.New("unsupported unlocker type") errUnsupportedUnlockerType = errors.New("unsupported unlocker type")
errLastUnlocker = errors.New("refusing to remove last unlocker")
) )
// UnlockerInfo represents unlocker information for display // UnlockerInfo represents unlocker information for display
@@ -267,10 +266,11 @@ func newUnlockerRemoveCmd() *cobra.Command {
Use: "remove <unlocker-id>", Use: "remove <unlocker-id>",
Aliases: []string{"rm"}, Aliases: []string{"rm"},
Short: "Remove an unlocker", Short: "Remove an unlocker",
Long: `Remove an unlocker from the current vault. Cannot remove ` + Long: `Remove an unlocker from the current vault. Asks for ` +
`the last unlocker if the vault has secrets unless --force is ` + `confirmation first, saying whether it is the vault's last ` +
`used. Warning: Without unlockers and without your mnemonic, ` + `unlocker; when stdin is not a terminal, fails unless --force ` +
`vault data will be permanently inaccessible.`, `is given. Warning: Without unlockers and without your ` +
`mnemonic, vault data will be permanently inaccessible.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir), ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -286,7 +286,7 @@ func newUnlockerRemoveCmd() *cobra.Command {
} }
cmd.Flags().BoolP("force", "f", false, cmd.Flags().BoolP("force", "f", false,
"Force removal of last unlocker even if vault has secrets") "Remove without asking for confirmation, even the last unlocker")
return cmd return cmd
} }
@@ -603,8 +603,8 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID()) cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
// Auto-select the newly created unlocker // CreatePassphraseUnlocker has already made it the current unlocker
autoSelectUnlocker(cmd, vlt, passphraseUnlocker.GetID()) cmd.Printf("Automatically selected as current unlocker\n")
return nil return nil
} }
@@ -726,55 +726,91 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
return nil return nil
} }
// UnlockersRemove removes an unlocker, holding the state directory lock // UnlockersRemove removes an unlocker from the current vault, after asking
// while removeUnlocker runs // the user to confirm unless force is set.
func (cli *Instance) UnlockersRemove( func (cli *Instance) UnlockersRemove(
unlockerID string, force bool, cmd *cobra.Command, unlockerID string, force bool, cmd *cobra.Command,
) error { ) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir) var found unlockerToRemove
release, err := cli.askThenLock(cmd, force, func() (string, error) {
var err error
found, err = cli.findUnlockerToRemove(unlockerID)
return found.question, err
})
if err != nil { if err != nil {
return err return err
} }
defer release() defer release()
return cli.removeUnlocker(unlockerID, force, cmd) return cli.removeUnlocker(unlockerID, found, cmd)
} }
// removeUnlocker removes an unlocker with safety checks // unlockerToRemove is what removing an unlocker removes, as
func (cli *Instance) removeUnlocker( // findUnlockerToRemove found it.
unlockerID string, force bool, cmd *cobra.Command, type unlockerToRemove struct {
) error { vlt *vault.Vault
// Get current vault // last is set when the unlocker counts as the vault's last one, and
// secrets is then the number of secrets in the vault.
last bool
secrets int
// question names what is removed, for the user to confirm.
question string
}
// findUnlockerToRemove checks that the current vault has the unlocker and
// finds whether it is the vault's last one.
func (cli *Instance) findUnlockerToRemove(
unlockerID string,
) (unlockerToRemove, error) {
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
return err return unlockerToRemove{}, err
}
exists, err := vlt.HasUnlocker(unlockerID)
if err != nil {
return unlockerToRemove{}, err
}
if !exists {
return unlockerToRemove{}, fmt.Errorf("unlocker with ID %s %w",
unlockerID, vault.ErrUnlockerNotFound)
} }
// Get list of unlockers. It leaves out a directory whose metadata file // Get list of unlockers. It leaves out a directory whose metadata file
// is missing or cannot be checked for, read or parsed. // is missing or cannot be checked for, read or parsed.
unlockers, err := vlt.ListUnlockers() unlockers, err := vlt.ListUnlockers()
if err != nil { if err != nil {
return fmt.Errorf("failed to list unlockers: %w", err) return unlockerToRemove{},
fmt.Errorf("failed to list unlockers: %w", err)
} }
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
if err != nil { if err != nil {
return fmt.Errorf("failed to get vault directory: %w", err) return unlockerToRemove{},
fmt.Errorf("failed to get vault directory: %w", err)
} }
unlockersDir := filepath.Join(vaultDir, "unlockers.d") unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// Check if we're removing the last unlocker found := unlockerToRemove{
removingLast := false vlt: vlt,
question: fmt.Sprintf("Permanently remove unlocker '%s' from vault "+
"'%s'? It is not the vault's last unlocker.",
unlockerID, vlt.GetName()),
}
if len(unlockers) == 1 { if len(unlockers) == 1 {
lastID, err := findUnlockerIDByMetadata( lastID, err := findUnlockerIDByMetadata(
cli.fs, unlockersDir, unlockers[0], true) cli.fs, unlockersDir, unlockers[0], true)
if err != nil { if err != nil {
return err return unlockerToRemove{}, err
} }
removingLast = lastID == unlockerID found.last = lastID == unlockerID
} }
// unlockerID may instead name a directory left out of the list. If its // unlockerID may instead name a directory left out of the list. If its
@@ -783,34 +819,36 @@ func (cli *Instance) removeUnlocker(
// for or read, the unlocker may be the only working one, so removing it // for or read, the unlocker may be the only working one, so removing it
// counts as removing the last unlocker. // counts as removing the last unlocker.
if metadataUnreadable(cli.fs, filepath.Join(unlockersDir, unlockerID)) { if metadataUnreadable(cli.fs, filepath.Join(unlockersDir, unlockerID)) {
removingLast = true found.last = true
} }
if removingLast { if found.last {
// Check if vault has secrets found.secrets, err = vlt.NumSecrets()
numSecrets, err := vlt.NumSecrets()
if err != nil { if err != nil {
return fmt.Errorf("failed to count secrets: %w", err) return unlockerToRemove{},
fmt.Errorf("failed to count secrets: %w", err)
} }
if numSecrets > 0 && !force { found.question = fmt.Sprintf("Permanently remove unlocker '%s', "+
cmd.Println("ERROR: Cannot remove the last unlocker when the " + "the last unlocker of vault '%s', which holds %d secret(s)? "+
"vault contains secrets.") "Without an unlocker the vault opens only with its mnemonic.",
cmd.Println("WARNING: Without unlockers, you MUST have your " + unlockerID, vlt.GetName(), found.secrets)
"mnemonic phrase to decrypt the vault.")
cmd.Println("If you want to proceed anyway, use --force")
return errLastUnlocker
} }
if numSecrets > 0 && force { return found, nil
}
// removeUnlocker removes the unlocker that findUnlockerToRemove found. The
// caller holds the state directory lock.
func (cli *Instance) removeUnlocker(
unlockerID string, found unlockerToRemove, cmd *cobra.Command,
) error {
if found.last && found.secrets > 0 {
cmd.Println("WARNING: Removing the last unlocker. You MUST " + cmd.Println("WARNING: Removing the last unlocker. You MUST " +
"have your mnemonic phrase to access this vault again!") "have your mnemonic phrase to access this vault again!")
} }
}
// Remove the unlocker err := found.vlt.RemoveUnlocker(unlockerID)
err = vlt.RemoveUnlocker(unlockerID)
if err != nil { if err != nil {
return err return err
} }
+27 -32
View File
@@ -5,14 +5,15 @@
// one the commands act on, metadata that is not JSON, and check that the // one the commands act on, metadata that is not JSON, and check that the
// commands step past it, and that it can itself be removed by its // commands step past it, and that it can itself be removed by its
// directory name, which `secret unlocker list` names in its warning. A // directory name, which `secret unlocker list` names in its warning. A
// last test checks that an unlocker whose metadata file cannot be read is // last test checks that an unlocker whose metadata file cannot be read
// removed by its directory name only as the last unlocker is. // counts as the last unlocker when it is removed by its directory name.
//nolint:testpackage // white-box test of unexported internals //nolint:testpackage // white-box test of unexported internals
package cli package cli
import ( import (
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
@@ -56,37 +57,27 @@ func TestUnlockerSelectSkipsCorruptUnlocker(t *testing.T) {
} }
// TestUnlockerRemoveWithCorruptUnlocker asserts that the second unlocker // TestUnlockerRemoveWithCorruptUnlocker asserts that the second unlocker
// can be removed, unless the vault holds secrets: the corrupt unlocker // counts as the vault's last one, since the corrupt unlocker cannot unlock
// cannot unlock the vault, so the second is its last. The corrupt one can // the vault, and that the corrupt one, removed by its directory name, does
// be removed by its directory name without --force even then. // not. Either is removed once the user confirms.
func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) { func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
name string name string
unlockerID string unlockerID string
withSecret bool wantLast bool
wantErr error
wantEntries []string wantEntries []string
}{ }{
{ {
name: "the other unlocker", name: "the other unlocker",
unlockerID: "pgp-" + listTestGPGKeyID + "B", unlockerID: "pgp-" + listTestGPGKeyID + "B",
wantLast: true,
wantEntries: []string{listTestUnlockerDirOne}, wantEntries: []string{listTestUnlockerDirOne},
}, },
{
name: "the other unlocker, the last one, with secrets",
unlockerID: "pgp-" + listTestGPGKeyID + "B",
withSecret: true,
wantErr: errLastUnlocker,
wantEntries: []string{
listTestUnlockerDirOne, listTestUnlockerDirTwo,
},
},
{ {
name: "the corrupt unlocker by its directory name", name: "the corrupt unlocker by its directory name",
unlockerID: listTestUnlockerDirOne, unlockerID: listTestUnlockerDirOne,
withSecret: true,
wantEntries: []string{listTestUnlockerDirTwo}, wantEntries: []string{listTestUnlockerDirTwo},
}, },
} }
@@ -96,14 +87,16 @@ func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
t.Parallel() t.Parallel()
fs := newCorruptUnlockerVault(t) fs := newCorruptUnlockerVault(t)
if tt.withSecret {
writeTestSecret(t, fs, testVaultDir(listTestVaultName)) writeTestSecret(t, fs, testVaultDir(listTestVaultName))
}
instance, cmd := newTestInstance(fs) instance, cmd := newTestInstance(fs)
err := instance.UnlockersRemove(tt.unlockerID, false, cmd) found, err := instance.findUnlockerToRemove(tt.unlockerID)
require.ErrorIs(t, err, tt.wantErr) require.NoError(t, err)
assert.Equal(t, tt.wantLast, found.last)
instance.terminal = strings.NewReader("y\n")
require.NoError(t, instance.UnlockersRemove(tt.unlockerID, false, cmd))
assertDirEntries(t, fs, assertDirEntries(t, fs,
filepath.Join(testVaultDir(listTestVaultName), filepath.Join(testVaultDir(listTestVaultName),
@@ -113,13 +106,14 @@ func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
} }
} }
// TestUnlockerRemoveWithUnreadableMetadata asserts that removing the only // TestUnlockerRemoveWithUnreadableMetadata asserts that the only unlocker
// unlocker of a vault with secrets by its directory name, when its // of a vault with secrets, removed by its directory name when its metadata
// metadata file cannot be checked for or read, is refused without --force: // file cannot be checked for or read, counts as the vault's last unlocker,
// listing leaves it out, but it may still be the vault's only working // so the question warns that it is: listing leaves it out, but it may
// unlocker. With --force it is removed. The state directory lock refuses // still be the vault's only working unlocker. It is then removed. The
// the failing filesystem, so the test calls removeUnlocker, which // state directory lock refuses the failing filesystem, so the test calls
// UnlockersRemove runs once it holds the lock. // findUnlockerToRemove and removeUnlocker, which UnlockersRemove runs to
// make its checks and, once it holds the lock, to remove the unlocker.
func TestUnlockerRemoveWithUnreadableMetadata(t *testing.T) { func TestUnlockerRemoveWithUnreadableMetadata(t *testing.T) {
t.Parallel() t.Parallel()
@@ -155,12 +149,13 @@ func TestUnlockerRemoveWithUnreadableMetadata(t *testing.T) {
instance, cmd := newTestInstance(tt.wrap(base)) instance, cmd := newTestInstance(tt.wrap(base))
err := instance.removeUnlocker(listTestUnlockerDirOne, false, cmd) found, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
require.ErrorIs(t, err, errLastUnlocker) require.NoError(t, err)
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne) assert.True(t, found.last)
assert.Contains(t, found.question, "the last unlocker")
require.NoError(t, require.NoError(t,
instance.removeUnlocker(listTestUnlockerDirOne, true, cmd)) instance.removeUnlocker(listTestUnlockerDirOne, found, cmd))
assertDirEntries(t, base, unlockersDir) assertDirEntries(t, base, unlockersDir)
}) })
} }
+36 -9
View File
@@ -2,15 +2,18 @@
// //
// The checks that guard adding a PGP unlocker (is this key already an // The checks that guard adding a PGP unlocker (is this key already an
// unlocker?), removing the last unlocker and removing a vault (does the // unlocker?), removing the last unlocker and removing a vault (does the
// vault hold secrets?), and importing a mnemonic (does the vault already // vault hold secrets?), removing a secret (how many versions does it
// have a long-term key?) each look at the vault on disk before acting. // have?), and importing a mnemonic (does the vault already have a
// long-term key?) each look at the vault on disk before acting.
// When that look fails they must refuse to act, not read the failure as // When that look fails they must refuse to act, not read the failure as
// "nothing there" and go ahead. // "nothing there" and go ahead.
// //
// The tests make the look fail with a wrapper around the in-memory // The tests make the look fail with a wrapper around the in-memory
// filesystem, which the state directory lock refuses. So they call the // filesystem, which the state directory lock refuses. So they call the
// function each command runs once it holds the lock, such as removeVault // function each command runs once it holds the lock, such as addPGPUnlocker
// for RemoveVault. // for UnlockersAdd, or, for a removal, the function that makes its checks,
// such as findVaultToRemove for RemoveVault, which runs again under the
// lock before anything is removed, with --force or without.
//nolint:testpackage // white-box test of unexported internals //nolint:testpackage // white-box test of unexported internals
package cli package cli
@@ -285,10 +288,9 @@ func TestRemoveLastUnlockerAbortsWhenSecretsUnreadable(t *testing.T) {
base := newListTestVault(t, 1) base := newListTestVault(t, 1)
writeTestSecret(t, base, vaultDir) writeTestSecret(t, base, vaultDir)
instance, cmd := newTestInstance(&statFailFs{Fs: base, path: path}) instance, _ := newTestInstance(&statFailFs{Fs: base, path: path})
err := instance.removeUnlocker( _, err := instance.findUnlockerToRemove("pgp-" + listTestGPGKeyID + "A")
"pgp-"+listTestGPGKeyID+"A", false, cmd)
require.ErrorIs(t, err, errStatFailed) require.ErrorIs(t, err, errStatFailed)
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne) assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
@@ -332,9 +334,9 @@ func TestRemoveVaultAbortsWhenSecretsDirUnreadable(t *testing.T) {
base := newListTestVault(t, 1) base := newListTestVault(t, 1)
writeTestSecret(t, base, vaultDir) writeTestSecret(t, base, vaultDir)
instance, cmd := newTestInstance(tt.failFs(base)) instance, _ := newTestInstance(tt.failFs(base))
err := instance.removeVault(cmd, unreadableTestOtherVault, false) _, err := instance.findVaultToRemove(unreadableTestOtherVault)
require.ErrorIs(t, err, tt.wantErr) require.ErrorIs(t, err, tt.wantErr)
@@ -345,6 +347,31 @@ func TestRemoveVaultAbortsWhenSecretsDirUnreadable(t *testing.T) {
} }
} }
// TestRemoveSecretAbortsWhenVersionsUnreadable asserts that a secret is
// kept when its versions directory exists but cannot be listed, so that
// the question cannot say how many versions would be removed.
func TestRemoveSecretAbortsWhenVersionsUnreadable(t *testing.T) {
t.Parallel()
secretDir := filepath.Join(testVaultDir(listTestVaultName),
unreadableTestSecretsDirName, unreadableTestSecretName)
versionsDir := filepath.Join(secretDir, "versions")
base := newListTestVault(t, 1)
writeTestSecret(t, base, testVaultDir(listTestVaultName))
require.NoError(t, base.MkdirAll(versionsDir, listTestDirPerm))
instance, _ := newTestInstance(&openFailFs{Fs: base, path: versionsDir})
_, err := instance.findSecretToRemove(unreadableTestSecretName)
require.ErrorIs(t, err, errOpenFailed)
exists, err := afero.DirExists(base, secretDir)
require.NoError(t, err)
assert.True(t, exists, "the secret must not be removed")
}
// TestVaultImportAbortsWhenPubKeyUnreadable asserts that a mnemonic import // TestVaultImportAbortsWhenPubKeyUnreadable asserts that a mnemonic import
// stops when whether the vault already has a long-term key cannot be // stops when whether the vault already has a long-term key cannot be
// determined. // determined.
+88 -67
View File
@@ -31,8 +31,6 @@ var (
errPassphraseEnvNotSet = errors.New( errPassphraseEnvNotSet = errors.New(
"SB_UNLOCK_PASSPHRASE environment variable not set") "SB_UNLOCK_PASSPHRASE environment variable not set")
errCannotRemoveLastVault = errors.New("cannot remove the last vault") errCannotRemoveLastVault = errors.New("cannot remove the last vault")
errVaultContainsSecrets = errors.New(
"contains secrets; use --force to remove")
) )
func newVaultCmd() *cobra.Command { func newVaultCmd() *cobra.Command {
@@ -156,9 +154,12 @@ func newVaultRemoveCmd() *cobra.Command {
Use: "remove <name>", Use: "remove <name>",
Aliases: []string{"rm"}, Aliases: []string{"rm"},
Short: "Remove a vault", Short: "Remove a vault",
Long: `Remove a vault. Requires --force if the vault contains ` + Long: `Remove a vault and all its secrets. Asks for ` +
`secrets. Will automatically switch to another vault if ` + `confirmation first, naming how many secrets the vault ` +
`removing the currently selected one.`, `holds; when stdin is not a terminal, fails unless --force ` +
`is given. Will automatically switch to another vault if ` +
`removing the currently selected one. The last vault ` +
`cannot be removed.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir), ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -173,7 +174,8 @@ func newVaultRemoveCmd() *cobra.Command {
}, },
} }
cmd.Flags().BoolP("force", "f", false, "Force removal even if vault contains secrets") cmd.Flags().BoolP("force", "f", false,
"Remove without asking for confirmation, even a vault that contains secrets")
return cmd return cmd
} }
@@ -537,27 +539,27 @@ func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error
return nil return nil
} }
// vaultHasSecrets reports whether the vault directory contains any secrets // countVaultSecrets returns the number of secrets in the vault directory
func (cli *Instance) vaultHasSecrets(vaultDir string) (bool, error) { func (cli *Instance) countVaultSecrets(vaultDir string) (int, error) {
secretsDir := filepath.Join(vaultDir, "secrets.d") secretsDir := filepath.Join(vaultDir, "secrets.d")
exists, err := afero.DirExists(cli.fs, secretsDir) exists, err := afero.DirExists(cli.fs, secretsDir)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to check secrets directory %s: %w", return 0, fmt.Errorf("failed to check secrets directory %s: %w",
secretsDir, err) secretsDir, err)
} }
if !exists { if !exists {
return false, nil return 0, nil
} }
entries, err := afero.ReadDir(cli.fs, secretsDir) entries, err := afero.ReadDir(cli.fs, secretsDir)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to read secrets directory %s: %w", return 0, fmt.Errorf("failed to read secrets directory %s: %w",
secretsDir, err) secretsDir, err)
} }
return len(entries) > 0, nil return len(entries), nil
} }
// switchAwayFromVault selects another vault as current before removal // switchAwayFromVault selects another vault as current before removal
@@ -586,88 +588,107 @@ func (cli *Instance) switchAwayFromVault(
return nil return nil
} }
// RemoveVault removes a vault, holding the state directory lock while // RemoveVault removes a vault and all its secrets, after asking the user
// removeVault runs // to confirm unless force is set.
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error { func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
err := vault.ValidateVaultName(name) err := vault.ValidateVaultName(name)
if err != nil { if err != nil {
return err return err
} }
release, err := vault.LockStateDir(cli.fs, cli.stateDir) var found vaultToRemove
release, err := cli.askThenLock(cmd, force, func() (string, error) {
var err error
found, err = cli.findVaultToRemove(name)
return found.question, err
})
if err != nil { if err != nil {
return err return err
} }
defer release() defer release()
return cli.removeVault(cmd, name, force)
}
// removeVault removes a vault with safety checks
func (cli *Instance) removeVault(cmd *cobra.Command, name string, force bool) error {
// Get list of all vaults
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to list vaults: %w", err)
}
// Check if vault exists
if !slices.Contains(vaults, name) {
return fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
}
// Don't allow removing the last vault
if len(vaults) == 1 {
return errCannotRemoveLastVault
}
// Check if this is the current vault
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to get current vault: %w", err)
}
isCurrentVault := currentVault.GetName() == name
// Load the vault to check for secrets
vlt := vault.NewVault(cli.fs, cli.stateDir, name)
vaultDir, err := vlt.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get vault directory: %w", err)
}
// Check if vault has secrets
hasSecrets, err := cli.vaultHasSecrets(vaultDir)
if err != nil {
return err
}
// Require --force if vault has secrets
if hasSecrets && !force {
return fmt.Errorf("vault '%s' %w", name, errVaultContainsSecrets)
}
// If removing current vault, switch to another vault first // If removing current vault, switch to another vault first
if isCurrentVault { if found.isCurrent {
err = cli.switchAwayFromVault(cmd, vaults, name) err = cli.switchAwayFromVault(cmd, found.vaults, name)
if err != nil { if err != nil {
return err return err
} }
} }
// Remove the vault directory // Remove the vault directory
err = secret.RemoveDirAtomic(cli.fs, vaultDir) err = secret.RemoveDirAtomic(cli.fs, found.dir)
if err != nil { if err != nil {
return fmt.Errorf("failed to remove vault directory: %w", err) return fmt.Errorf("failed to remove vault directory: %w", err)
} }
cmd.Printf("Removed vault '%s'\n", name) cmd.Printf("Removed vault '%s'\n", name)
if hasSecrets { if found.secrets > 0 {
cmd.Printf("Warning: Vault contained secrets that have been " + cmd.Printf("Warning: Vault contained secrets that have been " +
"permanently deleted\n") "permanently deleted\n")
} }
return nil return nil
} }
// vaultToRemove is what removing a vault removes, as findVaultToRemove
// found it.
type vaultToRemove struct {
// dir is the vault's directory, which holds all its secrets.
dir string
secrets int
// vaults lists every vault, this one included, and isCurrent is set
// when this one is the current vault.
vaults []string
isCurrent bool
// question names what is removed, for the user to confirm.
question string
}
// findVaultToRemove checks that the vault exists and is not the last one,
// and counts its secrets.
func (cli *Instance) findVaultToRemove(name string) (vaultToRemove, error) {
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err != nil {
return vaultToRemove{}, fmt.Errorf("failed to list vaults: %w", err)
}
if !slices.Contains(vaults, name) {
return vaultToRemove{},
fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
}
if len(vaults) == 1 {
return vaultToRemove{}, errCannotRemoveLastVault
}
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return vaultToRemove{},
fmt.Errorf("failed to get current vault: %w", err)
}
vaultDir, err := vault.NewVault(cli.fs, cli.stateDir, name).GetDirectory()
if err != nil {
return vaultToRemove{},
fmt.Errorf("failed to get vault directory: %w", err)
}
secrets, err := cli.countVaultSecrets(vaultDir)
if err != nil {
return vaultToRemove{}, err
}
return vaultToRemove{
dir: vaultDir,
secrets: secrets,
vaults: vaults,
isCurrent: currentVault.GetName() == name,
question: fmt.Sprintf(
"Permanently remove vault '%s' and its %d secret(s)?",
name, secrets),
}, nil
}
+62 -25
View File
@@ -89,7 +89,8 @@ func VersionCommands(cli *Instance) *cobra.Command {
Aliases: []string{"rm"}, Aliases: []string{"rm"},
Short: "Remove a specific version of a secret", Short: "Remove a specific version of a secret",
Long: "Remove a specific version of a secret. Cannot remove the " + Long: "Remove a specific version of a secret. Cannot remove the " +
"current version.", "current version. Asks for confirmation first; when stdin " +
"is not a terminal, fails unless --force is given.",
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
ValidArgsFunction: func( ValidArgsFunction: func(
cmd *cobra.Command, args []string, toComplete string, cmd *cobra.Command, args []string, toComplete string,
@@ -102,10 +103,15 @@ func VersionCommands(cli *Instance) *cobra.Command {
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return cli.RemoveVersion(cmd, args[0], args[1]) force, _ := cmd.Flags().GetBool("force")
return cli.RemoveVersion(cmd, args[0], args[1], force)
}, },
} }
removeCmd.Flags().BoolP("force", "f", false,
"Remove without asking for confirmation")
versionCmd.AddCommand(listCmd, promoteCmd, removeCmd) versionCmd.AddCommand(listCmd, promoteCmd, removeCmd)
return versionCmd return versionCmd
@@ -297,30 +303,62 @@ func (cli *Instance) PromoteVersion(
return nil return nil
} }
// RemoveVersion removes a specific version of a secret // RemoveVersion removes a specific version of a secret, after asking the
// user to confirm unless force is set.
func (cli *Instance) RemoveVersion( func (cli *Instance) RemoveVersion(
cmd *cobra.Command, secretName string, version string, cmd *cobra.Command, secretName string, version string, force bool,
) error { ) error {
err := vault.ValidateSecretName(secretName) err := vault.ValidateSecretName(secretName)
if err != nil { if err != nil {
return err return err
} }
release, err := vault.LockStateDir(cli.fs, cli.stateDir) var found versionToRemove
release, err := cli.askThenLock(cmd, force, func() (string, error) {
var err error
found, err = cli.findVersionToRemove(secretName, version)
return found.question, err
})
if err != nil { if err != nil {
return err return err
} }
defer release() defer release()
// Get current vault err = secret.RemoveDirAtomic(cli.fs, found.dir)
if err != nil {
return fmt.Errorf("failed to remove version: %w", err)
}
cmd.Printf("Removed version %s of secret '%s'\n", version, secretName)
return nil
}
// versionToRemove is what removing a version removes, as
// findVersionToRemove found it.
type versionToRemove struct {
// dir is the version's directory.
dir string
// question names what is removed, for the user to confirm.
question string
}
// findVersionToRemove checks that the version exists in the secret in the
// current vault and is not its current version.
func (cli *Instance) findVersionToRemove(
secretName, version string,
) (versionToRemove, error) {
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
return err return versionToRemove{}, err
} }
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
if err != nil { if err != nil {
return err return versionToRemove{}, err
} }
// Get the encoded secret name // Get the encoded secret name
@@ -330,45 +368,44 @@ func (cli *Instance) RemoveVersion(
// Check if secret exists // Check if secret exists
exists, err := afero.DirExists(cli.fs, secretDir) exists, err := afero.DirExists(cli.fs, secretDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to check if secret exists: %w", err) return versionToRemove{},
fmt.Errorf("failed to check if secret exists: %w", err)
} }
if !exists { if !exists {
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound) return versionToRemove{},
fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
} }
// Check if version exists // Check if version exists
exists, err = secret.VersionExists(cli.fs, secretDir, version) exists, err = secret.VersionExists(cli.fs, secretDir, version)
if err != nil { if err != nil {
return fmt.Errorf("failed to check if version exists: %w", err) return versionToRemove{},
fmt.Errorf("failed to check if version exists: %w", err)
} }
if !exists { if !exists {
return fmt.Errorf("version '%s' %w '%s'", return versionToRemove{}, fmt.Errorf("version '%s' %w '%s'",
version, errVersionNotFound, secretName) version, errVersionNotFound, secretName)
} }
// Get current version // Get current version
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir) currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to get current version: %w", err) return versionToRemove{},
fmt.Errorf("failed to get current version: %w", err)
} }
// Don't allow removing the current version // Don't allow removing the current version
if version == currentVersion { if version == currentVersion {
return fmt.Errorf("cannot remove the current version '%s'; %w", return versionToRemove{}, fmt.Errorf(
"cannot remove the current version '%s'; %w",
version, errCannotRemoveCurrentVersion) version, errCannotRemoveCurrentVersion)
} }
// Remove the version directory return versionToRemove{
versionDir := filepath.Join(secretDir, "versions", version) dir: filepath.Join(secretDir, "versions", version),
question: fmt.Sprintf("Permanently remove version %s of secret "+
err = secret.RemoveDirAtomic(cli.fs, versionDir) "'%s' from vault '%s'?", version, secretName, vlt.GetName()),
if err != nil { }, nil
return fmt.Errorf("failed to remove version: %w", err)
}
cmd.Printf("Removed version %s of secret '%s'\n", version, secretName)
return nil
} }
+8 -8
View File
@@ -3,6 +3,7 @@ package secret
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -62,13 +63,12 @@ func TempDirFor(fs afero.Fs, target string) (string, error) {
return dir, nil return dir, nil
} }
// WriteDir calls write to write the files of the directory dir. When dir does // WriteDir calls write to write the files of the new directory dir into a
// not exist yet, write writes them into a temporary directory from TempDirFor, // temporary directory from TempDirFor, which is then renamed to dir, so that
// which is then renamed to dir, so that neither a failure nor a crash leaves // neither a failure nor a crash leaves dir half-written; on a failure the
// dir half-written; on a failure the temporary directory is removed, and a // temporary directory is removed, and a failure to remove it is returned
// failure to remove it is returned along with the first. A directory cannot be // along with the first. A directory cannot be replaced in one rename, so if
// renamed over one that has files in it, so when dir already exists, write // dir already exists, WriteDir fails without calling write.
// writes into it in place; dir is then never removed.
func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error { func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
exists, err := afero.Exists(fs, dir) exists, err := afero.Exists(fs, dir)
if err != nil { if err != nil {
@@ -76,7 +76,7 @@ func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
} }
if exists { if exists {
return write(dir) return fmt.Errorf("failed to create %s: %w", dir, os.ErrExist)
} }
// Create the directory the finished one is renamed into // Create the directory the finished one is renamed into
+150 -17
View File
@@ -191,6 +191,22 @@ func dirNames(t *testing.T, fs afero.Fs, dir string) []string {
return names return names
} }
// dirFiles returns the contents of the files in dir, by name.
func dirFiles(t *testing.T, fs afero.Fs, dir string) map[string]string {
t.Helper()
files := map[string]string{}
for _, name := range dirNames(t, fs, dir) {
data, err := afero.ReadFile(fs, filepath.Join(dir, name))
require.NoError(t, err)
files[name] = string(data)
}
return files
}
// writeLongTermKey gives the test vault under stateDir a new long-term key // writeLongTermKey gives the test vault under stateDir a new long-term key
// and returns it. // and returns it.
func writeLongTermKey( func writeLongTermKey(
@@ -668,14 +684,14 @@ func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
require.NoError(t, err) require.NoError(t, err)
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase") // The vault has no unlocker yet, so any directory in here is
// the new one
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
fs := hookFs{Fs: base, before: func(string, string) error { fs := hookFs{Fs: base, before: func(string, string) error {
exists, err := afero.DirExists(base, unlockerDir) for _, name := range dirNames(t, base, unlockersDir) {
require.NoError(t, err) assert.ElementsMatch(t, files,
dirNames(t, base, filepath.Join(unlockersDir, name)),
if exists {
assert.ElementsMatch(t, files, dirNames(t, base, unlockerDir),
"unlocker directory visible before it was complete") "unlocker directory visible before it was complete")
} }
@@ -688,13 +704,130 @@ func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
hooked := vault.NewVault(fs, stateDir, testVaultName) hooked := vault.NewVault(fs, stateDir, testVaultName)
hooked.Mnemonic = vlt.Mnemonic hooked.Mnemonic = vlt.Mnemonic
_, err = hooked.CreatePassphraseUnlocker(passphrase) unlocker, err := hooked.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err) require.NoError(t, err)
assert.ElementsMatch(t, files, dirNames(t, base, unlockerDir)) assert.ElementsMatch(t, files, dirNames(t, base, unlocker.GetDirectory()))
}) })
} }
} }
// TestPassphraseUnlockerReplacementKeepsVaultOpen replaces the vault's
// passphrase unlocker twice, each time with only the current unlocker to open
// the vault. The first replacement fails right after making the new unlocker
// current, so the old one is not removed. The second checks, before every
// change it makes, that the vault opens with the passphrase through its
// current unlocker, which is what a crash at that change would leave; once it
// returns, the vault must have one passphrase unlocker left.
func TestPassphraseUnlockerReplacementKeepsVaultOpen(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
testMnemonicBuffer(t))
require.NoError(t, err)
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
require.NoError(t, err)
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
_, err = vlt.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Every change after the switch to the new unlocker fails
switched := false
failAfterSwitch := hookFs{Fs: base, before: func(op, path string) error {
if switched {
return errInjected
}
switched = op == opRename && path == currentUnlockerPath
return nil
}}
replacing := vault.NewVault(failAfterSwitch, stateDir, testVaultName)
replacing.Unlock(ltIdentity)
_, err = replacing.CreatePassphraseUnlocker(passphrase)
require.ErrorIs(t, err, errInjected)
unlockers, err := vlt.ListUnlockers()
require.NoError(t, err)
assert.Len(t, unlockers, 2, "the old unlocker is left beside the new")
assertOpens := vaultOpensCheck(t, base, stateDir, ltIdentity, passphrase)
checked := hookFs{Fs: base, before: func(string, string) error {
assertOpens()
return nil
}}
replacing = vault.NewVault(checked, stateDir, testVaultName)
replacing.Unlock(ltIdentity)
_, err = replacing.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
assertOpens()
unlockers, err = vlt.ListUnlockers()
require.NoError(t, err)
assert.Len(t, unlockers, 1)
})
}
}
// vaultOpensCheck returns a function that checks that the test vault under
// stateDir opens through its current unlocker, with passphrase, to the
// long-term key ltIdentity. Opening it takes a second, so an unlocker
// directory it has opened through before is not opened again: it must hold
// the same files as then.
func vaultOpensCheck(
t *testing.T, fs afero.Fs, stateDir string, ltIdentity *age.X25519Identity,
passphrase *memguard.LockedBuffer,
) func() {
t.Helper()
vaultDir := filepath.Join(stateDir, "vaults.d", testVaultName)
// The files of each unlocker directory the vault has opened through
opened := map[string]map[string]string{}
return func() {
t.Helper()
current, err := afero.ReadFile(fs, filepath.Join(vaultDir, "current-unlocker"))
require.NoError(t, err)
files := dirFiles(t, fs, filepath.Join(vaultDir, "unlockers.d", string(current)))
if before, ok := opened[string(current)]; ok {
assert.Equal(t, before, files, "unlocker changed since it opened the vault")
return
}
opener := vault.NewVault(fs, stateDir, testVaultName)
opener.UnlockPassphrase = passphrase
key, err := opener.UnlockVault()
require.NoError(t, err)
assert.Equal(t, ltIdentity.Recipient().String(), key.Recipient().String())
opened[string(current)] = files
}
}
// TestWriteDirFailureLeavesNothing makes writing a new directory fail after // TestWriteDirFailureLeavesNothing makes writing a new directory fail after
// a file has been written in it, and checks that neither the directory nor // a file has been written in it, and checks that neither the directory nor
// its temporary directory is left behind; and, when the temporary directory // its temporary directory is left behind; and, when the temporary directory
@@ -740,10 +873,10 @@ func TestWriteDirFailureLeavesNothing(t *testing.T) {
} }
} }
// TestWriteDirKeepsExistingDir makes writing into a directory that already // TestWriteDirRefusesExistingDir checks that WriteDir fails, without calling
// exists fail, and checks that the directory, with what was in it, is still // write, when the directory already exists, and leaves the directory as it
// there: WriteDir writes into it in place and never removes it. // was: it never writes into a directory in place.
func TestWriteDirKeepsExistingDir(t *testing.T) { func TestWriteDirRefusesExistingDir(t *testing.T) {
t.Parallel() t.Parallel()
for _, tfs := range testFilesystems { for _, tfs := range testFilesystems {
@@ -751,17 +884,17 @@ func TestWriteDirKeepsExistingDir(t *testing.T) {
t.Parallel() t.Parallel()
fs, dir := tfs.open(t) fs, dir := tfs.open(t)
target := filepath.Join(dir, "unlockers.d", "passphrase") target := filepath.Join(dir, "unlockers.d", "existing")
require.NoError(t, fs.MkdirAll(target, secret.DirPerms)) require.NoError(t, fs.MkdirAll(target, secret.DirPerms))
require.NoError(t, secret.WriteFileAtomic(fs, require.NoError(t, secret.WriteFileAtomic(fs,
filepath.Join(target, unlockerMetadataFile), []byte("{}"))) filepath.Join(target, unlockerMetadataFile), []byte("{}")))
err := secret.WriteDir(fs, target, func(got string) error { err := secret.WriteDir(fs, target, func(string) error {
assert.Equal(t, target, got) t.Error("write called for a directory that exists")
return errInjected return nil
}) })
require.ErrorIs(t, err, errInjected) require.ErrorIs(t, err, os.ErrExist)
assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target)) assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target))
}) })
} }
+6
View File
@@ -16,6 +16,12 @@ const (
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE" EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
// EnvGPGKeyID is the environment variable for providing the GPG key ID // EnvGPGKeyID is the environment variable for providing the GPG key ID
EnvGPGKeyID = "SB_GPG_KEY_ID" EnvGPGKeyID = "SB_GPG_KEY_ID"
// UnlockerTimeFormat is the layout of the time, in UTC, in the name of a
// new unlocker's directory, keychain item and Secure Enclave key. It runs
// to the nanosecond, so that every new unlocker, even one added right
// after another, gets a directory of its own.
UnlockerTimeFormat = "2006-01-02.15.04.05.000000000"
) )
// File system permission constants // File system permission constants
+3 -3
View File
@@ -233,10 +233,10 @@ func generateKeychainUnlockerName(vaultName string) (string, error) {
return "", fmt.Errorf("failed to get hostname: %w", err) return "", fmt.Errorf("failed to get hostname: %w", err)
} }
// Format: secret-<vault>-<hostname>-<date> // Format: secret-<vault>-<hostname>-<time>
enrollmentDate := time.Now().Format("2006-01-02") enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentTime), nil
} }
// getLongTermPrivateKey derives the long-term private key from mnemonic when // getLongTermPrivateKey derives the long-term private key from mnemonic when
+5 -6
View File
@@ -209,21 +209,20 @@ func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
} }
// generatePGPUnlockerName generates a unique name for the PGP unlocker // generatePGPUnlockerName generates a unique name for the PGP unlocker
// based on hostname and date // based on hostname and time
func generatePGPUnlockerName() (string, error) { func generatePGPUnlockerName() (string, error) {
hostname, err := os.Hostname() hostname, err := os.Hostname()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err) return "", fmt.Errorf("failed to get hostname: %w", err)
} }
// Format: hostname-pgp-YYYY-MM-DD enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
enrollmentDate := time.Now().Format("2006-01-02")
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentTime), nil
} }
// pgpUnlockerDir returns the current vault and the directory in it for a // pgpUnlockerDir returns the current vault and the directory in it for a
// new PGP unlocker, named after the host and the day. // new PGP unlocker, named after the host and the time.
// //
//nolint:ireturn // the vault is only available behind VaultInterface //nolint:ireturn // the vault is only available behind VaultInterface
func pgpUnlockerDir( func pgpUnlockerDir(
@@ -235,7 +234,7 @@ func pgpUnlockerDir(
return nil, "", fmt.Errorf("failed to get current vault: %w", err) return nil, "", fmt.Errorf("failed to get current vault: %w", err)
} }
// Generate the unlocker name based on hostname and date // Generate the unlocker name based on hostname and time
unlockerName, err := generatePGPUnlockerName() unlockerName, err := generatePGPUnlockerName()
if err != nil { if err != nil {
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err) return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
+38
View File
@@ -7,6 +7,7 @@ import (
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -64,3 +65,40 @@ func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, dirNames(t, base, filepath.Join(vaultDir, "unlockers.d"))) assert.Empty(t, dirNames(t, base, filepath.Join(vaultDir, "unlockers.d")))
} }
// TestPGPUnlockerAddedTwiceKeepsFirst adds two PGP unlockers one right after
// the other, so on the same host and day, and checks that the second gets a
// directory of its own and leaves the first one's files as they were.
// CreatePGPUnlocker does not check whether the GPG key already has an
// unlocker, so the test key serves for both.
//
//nolint:paralleltest // installFakeGPG uses t.Setenv
func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
installFakeGPG(t)
original := secret.GPGEncryptFunc
t.Cleanup(func() { secret.GPGEncryptFunc = original })
// Stands in for gpg, which the test does not have: "encrypts" by copying
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, _ string) ([]byte, error) {
return []byte(data.String()), nil
}
fs := afero.NewMemMapFs()
mnemonic := testMnemonicBuffer(t)
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic)
require.NoError(t, err)
first, err := secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
require.NoError(t, err)
firstFiles := dirFiles(t, fs, first.GetDirectory())
second, err := secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
require.NoError(t, err)
assert.NotEqual(t, first.GetDirectory(), second.GetDirectory())
assert.Equal(t, firstFiles, dirFiles(t, fs, first.GetDirectory()))
}
+2 -2
View File
@@ -193,14 +193,14 @@ func generateSEKeyLabel(vaultName string) (string, error) {
return "", fmt.Errorf("failed to get hostname: %w", err) return "", fmt.Errorf("failed to get hostname: %w", err)
} }
enrollmentDate := time.Now().UTC().Format("2006-01-02") enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf( return fmt.Sprintf(
"%s.%s-%s-%s", "%s.%s-%s-%s",
seKeyLabelPrefix, seKeyLabelPrefix,
vaultName, vaultName,
hostname, hostname,
enrollmentDate, enrollmentTime,
), nil ), nil
} }
+81 -10
View File
@@ -2,8 +2,10 @@ package vault
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -103,7 +105,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
// resolveUnlockerDirectory reads the current-unlocker file to get the // resolveUnlockerDirectory reads the current-unlocker file to get the
// unlocker directory path // unlocker directory path
// The file contains just the unlocker name (e.g., "passphrase") // The file contains just the name of the unlocker's directory in unlockers.d
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) { func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath) secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
@@ -272,6 +274,24 @@ func (v *Vault) readUnlockerMetadataOrWarn(
return metadata, true return metadata, true
} }
// HasUnlocker reports whether RemoveUnlocker finds something to remove by
// the ID unlockerID: an unlocker with that ID, or an unlocker directory of
// that name that ListUnlockers skips.
func (v *Vault) HasUnlocker(unlockerID string) (bool, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return false, err
}
_, unlockerDir, err := v.findUnlockerByID(
filepath.Join(vaultDir, "unlockers.d"), unlockerID)
if err != nil {
return false, err
}
return unlockerDir != "", nil
}
// RemoveUnlocker removes an unlocker from this vault. An unlocker // RemoveUnlocker removes an unlocker from this vault. An unlocker
// directory that ListUnlockers skips is removed by its directory name; its // directory that ListUnlockers skips is removed by its directory name; its
// type is unknown, so only the directory is removed. // type is unknown, so only the directory is removed.
@@ -341,7 +361,10 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
return nil return nil
} }
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker // CreatePassphraseUnlocker creates a new passphrase-protected unlocker in a
// directory of its own, makes it the current unlocker, and only then removes
// the vault's other passphrase unlockers: a vault keeps one. A crash at any
// point leaves a complete current unlocker, the old one or the new.
// The passphrase must be provided as a LockedBuffer for security // The passphrase must be provided as a LockedBuffer for security
func (v *Vault) CreatePassphraseUnlocker( func (v *Vault) CreatePassphraseUnlocker(
passphrase *memguard.LockedBuffer, passphrase *memguard.LockedBuffer,
@@ -353,13 +376,23 @@ func (v *Vault) CreatePassphraseUnlocker(
// We need to get the long-term key (either from memory if unlocked, or // We need to get the long-term key (either from memory if unlocked, or
// derive it). Getting it before anything is written means failing to // derive it). Getting it before anything is written means failing to
// get it changes nothing, even when replacing the current unlocker. // get it changes nothing.
ltIdentity, err := v.GetOrDeriveLongTermKey() ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get long-term key: %w", err) return nil, fmt.Errorf("failed to get long-term key: %w", err)
} }
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase) unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// The passphrase unlockers the new one replaces
oldDirs, err := v.passphraseUnlockerDirs(unlockersDir)
if err != nil {
return nil, err
}
createdAt := time.Now()
unlockerDir := filepath.Join(unlockersDir, unlockerTypePassphrase+"-"+
createdAt.UTC().Format(secret.UnlockerTimeFormat))
// Generate new age keypair for unlocker // Generate new age keypair for unlocker
unlockerIdentity, err := age.GenerateX25519Identity() unlockerIdentity, err := age.GenerateX25519Identity()
@@ -379,7 +412,7 @@ func (v *Vault) CreatePassphraseUnlocker(
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: unlockerTypePassphrase, Type: unlockerTypePassphrase,
CreatedAt: time.Now(), CreatedAt: createdAt,
Flags: []string{}, Flags: []string{},
} }
@@ -397,16 +430,54 @@ func (v *Vault) CreatePassphraseUnlocker(
return nil, err return nil, err
} }
// Create the unlocker instance // Select the new unlocker by its directory, not by its ID: an old
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata) // passphrase unlocker created in the same minute has the same ID.
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Select this unlocker as current err = secret.WriteFileAtomic(v.fs, currentUnlockerPath,
err = v.SelectUnlocker(unlocker.GetID()) []byte(filepath.Base(unlockerDir)))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to select new unlocker: %w", err) return nil, fmt.Errorf("failed to select new unlocker: %w", err)
} }
return unlocker, nil for _, oldDir := range oldDirs {
err = secret.RemoveDirAtomic(v.fs, oldDir)
if err != nil {
return nil, fmt.Errorf(
"created and selected the new passphrase unlocker: %w", err)
}
}
return secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata), nil
}
// passphraseUnlockerDirs returns the directories in unlockersDir that hold
// passphrase unlockers. A directory ListUnlockers skips is left out, with the
// same warning.
func (v *Vault) passphraseUnlockerDirs(unlockersDir string) ([]string, error) {
files, err := afero.ReadDir(v.fs, unlockersDir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
}
var dirs []string
for _, file := range files {
if !file.IsDir() {
continue
}
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
if ok && metadata.Type == unlockerTypePassphrase {
dirs = append(dirs, filepath.Join(unlockersDir, file.Name()))
}
}
return dirs, nil
} }
// readUnlockerMetadata reads and parses the unlocker-metadata.json file in // readUnlockerMetadata reads and parses the unlocker-metadata.json file in