1 Commits
Author SHA1 Message Date
sneak beb6741934 Stop vault safety checks from reading unreadable state as empty (closes #51)
check / check (push) Failing after 2s
Adding a PGP unlocker checked unlockers.d for a duplicate and, when the
directory could not be read, reported no duplicate and went on. It now
stops with an error naming the directory and cause.

The same flaw guarded removing the last unlocker and removing a vault
(an unreadable secrets directory counted as no secrets) and vault
import (an unreadable pub.age counted as no long-term key). Those now
stop with an error too. `unlocker list` keeps skipping entries it
cannot read.

`vault rm` and `unlocker rm` now take the state directory lock and call
an unexported function that does the work, as `vault import` does, so
the tests can reach their checks.

Model: opus-5-5
2026-10-04 06:10:39 +00:00
9 changed files with 63 additions and 298 deletions
+6 -15
View File
@@ -25,15 +25,6 @@ Bring the repo into policy compliance in one commit:
# Completed Steps # Completed Steps
- 2026-10-04: `secret init` refuses when the default vault exists, and
`secret vault create NAME` when `NAME` does, with "vault NAME already
exists", before writing anything. The check is in `vault.CreateVault`,
which both commands call while holding the state directory lock, so two
creates of one vault at once cannot both pass the check. Before, either
command replaced the vault's metadata, passphrase unlocker and
`longterm.age`, so none of its secrets could be decrypted any more. Both
commands now ask for the unlocker passphrase before creating the vault,
so one stopped at that prompt leaves no vault behind.
- 2026-10-04: The `internal/cli` tests are back to about their time - 2026-10-04: The `internal/cli` tests are back to about their time
before the state directory lock before the state directory lock
(https://git.eeqj.de/sneak/secret/issues/80). The test that each (https://git.eeqj.de/sneak/secret/issues/80). The test that each
@@ -87,9 +78,9 @@ Bring the repo into policy compliance in one commit:
has one, and to a PGP, keychain or Secure Enclave unlocker added has one, and to a PGP, keychain or Secure Enclave unlocker added
on the same host and day as another of its type on the same host and day as another of its type
(https://git.eeqj.de/sneak/secret/issues/71); (https://git.eeqj.de/sneak/secret/issues/71);
- from `init` or `vault create` killed after the passphrase prompt - from `vault create` stopped at the passphrase prompt, a new vault
but before the unlocker is written, a vault with no unlocker, with no unlocker that is already the current vault; from `init`
which `vault create` has already made the current vault; stopped there, the default vault with no unlocker;
- from an unlocker add stopped before its metadata is written, a - from an unlocker add stopped before its metadata is written, a
directory that `unlocker list` warns about and `unlocker rm` directory that `unlocker list` warns about and `unlocker rm`
cannot remove; cannot remove;
@@ -102,9 +93,9 @@ Bring the repo into policy compliance in one commit:
error naming the path and cause when they cannot read what they error naming the path and cause when they cannot read what they
inspect, instead of reading the failure as "nothing there": the inspect, instead of reading the failure as "nothing there": the
duplicate check before `unlocker add pgp` (an unreadable duplicate check before `unlocker add pgp` (an unreadable
`unlockers.d` or unlocker metadata file), the secret count that `unlockers.d`), the secret count that guards removing the last
guards removing the last unlocker and removing a vault, and the unlocker and removing a vault, and the existing long-term key check
existing long-term key check before `vault import`. before `vault import`.
- 2026-10-03: `version rm`, `version promote` and `get --version` - 2026-10-03: `version rm`, `version promote` and `get --version`
accept a version only if it is one of the versions `version list` accept a version only if it is one of the versions `version list`
lists for that secret, compared as typed before any path is built lists for that secret, compared as typed before any path is built
-148
View File
@@ -1,148 +0,0 @@
package cli_test
import (
"testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
// TestCreateExistingVaultChangesNothing is a regression test for
// https://git.eeqj.de/sneak/secret/issues/74, where running `secret init`
// a second time, or `secret vault create` with the name of an existing
// vault, replaced that vault's keys, so that none of its secrets could be
// decrypted any more. Each must refuse, change nothing, and leave every
// vault's secret readable through its passphrase unlocker.
//
//nolint:paralleltest // t.Setenv forbids parallel subtests
func TestCreateExistingVaultChangesNothing(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
// `secret init`, `secret vault create work`, `secret vault select
// default`, and the secret "x" in each vault. "work" is then not the
// current vault, which creating it again must not change.
fs := afero.NewMemMapFs()
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
cmd := &cobra.Command{}
require.NoError(t, c.Init(cmd))
require.NoError(t, c.CreateVault(cmd, "work"))
require.NoError(t, c.SelectVault(cmd, "default"))
vaults, err := vault.ListVaults(fs, testStateDir)
require.NoError(t, err)
require.Len(t, vaults, 2)
for _, name := range vaults {
value := memguard.NewBufferFromBytes([]byte("value"))
err := vault.NewVault(fs, testStateDir, name).AddSecret("x", value, false)
require.NoError(t, err)
}
before := snapshotStateDir(t, fs)
tests := []struct {
command string
want string
run func(c *cli.Instance) error
}{
{
"init",
"failed to create default vault: vault default already exists",
func(c *cli.Instance) error { return c.Init(cmd) },
},
{
"vault create default",
"vault default already exists",
func(c *cli.Instance) error { return c.CreateVault(cmd, "default") },
},
{
"vault create work",
"vault work already exists",
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
fs := newFsFromSnapshot(t, before)
err := tt.run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
require.EqualError(t, err, tt.want)
require.Equal(t, before, snapshotStateDir(t, fs))
})
}
// Every case left the state directory exactly as recorded in before, so
// reading each vault's secret once from it shows that it still decrypts
// after each case. Without the mnemonic, reading a secret goes through
// the vault's passphrase unlocker, which is slow.
t.Setenv(secret.EnvMnemonic, "")
for _, name := range vaults {
value, err := vault.NewVault(fs, testStateDir, name).GetSecret("x")
require.NoError(t, err)
require.Equal(t, "value", string(value))
}
}
// TestStopAtPassphrasePromptLeavesNothing is a regression test for the
// review of https://git.eeqj.de/sneak/secret/pulls/82: `secret init` or
// `secret vault create` stopped at the passphrase prompt left a vault with
// no unlocker, which neither command would then create again. Each must ask
// for the passphrase before writing anything.
//
//nolint:paralleltest // t.Setenv forbids parallel subtests
func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
// Without the passphrase in the environment, both commands prompt for
// it, which fails because the tests do not run in a terminal.
t.Setenv(secret.EnvUnlockPassphrase, "")
// An empty state directory for `secret init`, and one holding the vault
// "default" for `secret vault create work`.
empty := afero.NewMemMapFs()
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
withDefault := afero.NewMemMapFs()
_, err := vault.CreateVault(withDefault, testStateDir, "default")
require.NoError(t, err)
cmd := &cobra.Command{}
tests := []struct {
command string
fs afero.Fs
run func(c *cli.Instance) error
}{
{
"init",
empty,
func(c *cli.Instance) error { return c.Init(cmd) },
},
{
"vault create work",
withDefault,
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
before := snapshotStateDir(t, tt.fs)
err := tt.run(cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir))
require.ErrorContains(t, err, "failed to read passphrase")
require.Equal(t, before, snapshotStateDir(t, tt.fs))
})
}
}
+7 -8
View File
@@ -160,14 +160,6 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
errInvalidMnemonicPhrase) errInvalidMnemonicPhrase)
} }
// Ask for the unlocker passphrase before creating the vault, so that
// stopping at the prompt leaves no vault without an unlocker behind
passphraseBuffer, err := resolvePassphrase()
if err != nil {
return err
}
defer passphraseBuffer.Destroy()
// Set mnemonic in environment for CreateVault to use // Set mnemonic in environment for CreateVault to use
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr) restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
defer restoreMnemonicEnv() defer restoreMnemonicEnv()
@@ -183,6 +175,13 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
// Unlock the vault with the derived long-term key // Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity) vlt.Unlock(ltIdentity)
// Prompt for passphrase for unlocker
passphraseBuffer, err := resolvePassphrase()
if err != nil {
return err
}
defer passphraseBuffer.Destroy()
// Create passphrase-protected unlocker // Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlocker") secret.Debug("Creating passphrase-protected unlocker")
+6 -7
View File
@@ -271,12 +271,11 @@ func stateDirModTimes(t *testing.T, fs afero.Fs) map[string]int64 {
} }
// setupEveryCommand makes what each command in // setupEveryCommand makes what each command in
// TestChangingCommandsWaitForLock needs: the current vault "work" with two // TestChangingCommandsWaitForLock needs: the current vault "default" with
// versions of "test/secret", the vault "other" without a long-term key, for // two versions of "test/secret", the vault "other" without a long-term key,
// vault import, and the file testInput. There is no vault "default", which // for vault import, and the file testInput. If withUnlocker is set, it also
// init creates. If withUnlocker is set, it also gives "work" a passphrase // gives "default" a passphrase unlocker, which is slow. It returns the older
// unlocker, which is slow. It returns the older version and the unlocker's // version and the unlocker's ID.
// ID.
func setupEveryCommand( func setupEveryCommand(
t *testing.T, fs afero.Fs, withUnlocker bool, t *testing.T, fs afero.Fs, withUnlocker bool,
) (string, string) { ) (string, string) {
@@ -289,7 +288,7 @@ func setupEveryCommand(
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age"))) require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
vlt, err := vault.CreateVault(fs, testStateDir, "work") vlt, err := vault.CreateVault(fs, testStateDir, "default")
require.NoError(t, err) require.NoError(t, err)
addTestSecret(t, vlt, []byte("older"), false) addTestSecret(t, vlt, []byte("older"), false)
+13 -39
View File
@@ -345,9 +345,10 @@ func unlockerIDFromDir(
// stored metadata matches the given type and creation time and returns // stored metadata matches the given type and creation time and returns
// the matching unlocker's ID. It returns ("", nil) when the directory is // the matching unlocker's ID. It returns ("", nil) when the directory is
// readable but holds no match, and a non-nil error when the directory // readable but holds no match, and a non-nil error when the directory
// itself cannot be read. Callers must distinguish the two: an unreadable // itself cannot be read, which means the unlocker's ID cannot be known.
// directory means the unlocker's real ID is unknowable, so the entry has // `unlocker list` then skips the entry rather than show a made-up ID; the
// to be skipped rather than reported under a synthesized ID. // duplicate check before adding an unlocker must stop instead, because
// the skipped entry may be the duplicate.
// //
// A metadata file that cannot be read or parsed is skipped without a // A metadata file that cannot be read or parsed is skipped without a
// warning: every caller gets metadata from vault.ListUnlockers first, // warning: every caller gets metadata from vault.ListUnlockers first,
@@ -804,12 +805,7 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
// checkUnlockerExists reports whether the vault already has an unlocker // checkUnlockerExists reports whether the vault already has an unlocker
// with the given ID. It returns an error, and no answer, when unlockers.d // with the given ID. It returns an error, and no answer, when unlockers.d
// or an unlocker's metadata file cannot be read; the caller must then not // cannot be read; the caller must then not create the unlocker.
// create the unlocker. It reads unlockers.d itself because
// vault.ListUnlockers skips an unlocker it cannot read, which suits
// `unlocker list` but not this check: the skipped unlocker may be the
// duplicate. A directory whose metadata file is missing or corrupt is not
// a working unlocker and is passed over.
func (cli *Instance) checkUnlockerExists( func (cli *Instance) checkUnlockerExists(
vlt *vault.Vault, unlockerID string, vlt *vault.Vault, unlockerID string,
) (bool, error) { ) (bool, error) {
@@ -820,44 +816,22 @@ func (cli *Instance) checkUnlockerExists(
unlockersDir := filepath.Join(vaultDir, "unlockers.d") unlockersDir := filepath.Join(vaultDir, "unlockers.d")
entries, err := afero.ReadDir(cli.fs, unlockersDir) unlockers, err := vlt.ListUnlockers()
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
if err != nil { if err != nil {
return false, fmt.Errorf( return false, fmt.Errorf(
"failed to read unlockers directory %s: %w", unlockersDir, err, "failed to list unlockers in %s: %w", unlockersDir, err,
) )
} }
for _, entry := range entries { for _, metadata := range unlockers {
if !entry.IsDir() { // Construct the unlocker matching this metadata to get its ID
continue id, err := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
}
unlockerDir := filepath.Join(unlockersDir, entry.Name())
metadataBytes, err := afero.ReadFile(
cli.fs, filepath.Join(unlockerDir, "unlocker-metadata.json"))
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil { if err != nil {
return false, fmt.Errorf( // Unlike `unlocker list`, never skip here: a skipped entry may be the duplicate.
"failed to read metadata of unlocker %s: %w", unlockerDir, err, return false, err
)
} }
var metadata secret.UnlockerMetadata if id != "" && id == unlockerID {
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
continue
}
if unlockerIDFromDir(cli.fs, unlockerDir, metadata, true) == unlockerID {
return true, nil return true, nil
} }
} }
+21 -52
View File
@@ -163,78 +163,47 @@ func addTestPGPUnlocker(fs afero.Fs) error {
return instance.addPGPUnlocker(cmd) return instance.addPGPUnlocker(cmd)
} }
// TestAddPGPUnlockerDuplicateCheck asserts that adding a PGP unlocker for // TestAddPGPUnlockerDuplicateCheck asserts that adding a PGP unlocker
// a key that already has one fails, and creates no unlocker directory, // fails, and creates no unlocker directory, when unlockers.d cannot be
// when unlockers.d or the existing unlocker's metadata file cannot be // read for the duplicate check; and, as the control case, that a readable
// read; and, as the control case, that the existing unlocker is refused // unlockers.d holding the same key is still refused as a duplicate.
// as a duplicate when everything can be read.
// //
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests //nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
func TestAddPGPUnlockerDuplicateCheck(t *testing.T) { func TestAddPGPUnlockerDuplicateCheck(t *testing.T) {
fingerprint := newTestGPGKey(t) fingerprint := newTestGPGKey(t)
unlockersDir := filepath.Join( unlockersDir := filepath.Join(
testVaultDir(listTestVaultName), listTestUnlockersDirName) testVaultDir(listTestVaultName), listTestUnlockersDirName)
duplicateDir := filepath.Join(unlockersDir, listTestUnlockerDirTwo)
// newVaultWithDuplicate returns a vault holding an unlocker for the
// test key, beside the one newListTestVault writes.
newVaultWithDuplicate := func(t *testing.T) afero.Fs {
t.Helper()
base := newListTestVault(t, 1)
writePGPUnlocker(t, base, unlockersDir, listTestUnlockerDirTwo,
time.Date(2026, time.August, 10, 12, 30, 0, 0, time.UTC),
fingerprint)
return base
}
tests := []struct { tests := []struct {
name string name string
failFs func(base afero.Fs) afero.Fs openBudget int
wantErr error
// wantPath is the path the error must name.
wantPath string
}{ }{
{ // The vault's own enumeration of unlockers.d fails.
name: "unlockers.d unreadable", {name: "listing fails", openBudget: 0},
failFs: func(base afero.Fs) afero.Fs { // The enumeration succeeds; the rescan that resolves IDs fails.
return &unlockersDirFailFs{Fs: base} {name: "rescan fails", openBudget: 1},
},
wantErr: errUnlockersDirUnreadable,
wantPath: unlockersDir,
},
{
name: "existing unlocker's metadata unreadable",
failFs: func(base afero.Fs) afero.Fs {
return &metadataReadFailFs{
Fs: base,
unreadablePath: filepath.Join(
duplicateDir, listTestMetadataFileName),
}
},
wantErr: errMetadataUnreadable,
wantPath: duplicateDir,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
base := newVaultWithDuplicate(t) base := newListTestVault(t, 1)
fs := &unlockersDirFailFs{Fs: base, openBudget: tt.openBudget}
err := addTestPGPUnlocker(tt.failFs(base)) err := addTestPGPUnlocker(fs)
require.ErrorIs(t, err, tt.wantErr) require.ErrorIs(t, err, errUnlockersDirUnreadable)
require.NotErrorIs(t, err, errGPGKeyAlreadyUnlocker) require.NotErrorIs(t, err, errGPGKeyAlreadyUnlocker)
assert.Contains(t, err.Error(), tt.wantPath, assert.Contains(t, err.Error(), unlockersDir,
"the error must name what it could not read") "the error must name the directory it could not read")
assertDirEntries(t, base, unlockersDir, assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
listTestUnlockerDirOne, listTestUnlockerDirTwo)
}) })
} }
t.Run("duplicate refused", func(t *testing.T) { t.Run("duplicate refused", func(t *testing.T) {
base := newVaultWithDuplicate(t) base := newListTestVault(t, 1)
writePGPUnlocker(t, base, unlockersDir, listTestUnlockerDirTwo,
time.Date(2026, time.August, 10, 12, 30, 0, 0, time.UTC),
fingerprint)
err := addTestPGPUnlocker(base) err := addTestPGPUnlocker(base)
+7 -8
View File
@@ -309,14 +309,6 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
return errInvalidMnemonicPhrase return errInvalidMnemonicPhrase
} }
// Ask for the unlocker passphrase before creating the vault, so that
// stopping at the prompt leaves no vault without an unlocker behind
passphraseBuffer, err := resolvePassphrase()
if err != nil {
return err
}
defer passphraseBuffer.Destroy()
// Set mnemonic in environment for CreateVault to use // Set mnemonic in environment for CreateVault to use
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr) restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
defer restoreMnemonicEnv() defer restoreMnemonicEnv()
@@ -344,6 +336,13 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
// Unlock the vault with the derived long-term key // Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity) vlt.Unlock(ltIdentity)
// Get or prompt for passphrase
passphraseBuffer, err := resolvePassphrase()
if err != nil {
return err
}
defer passphraseBuffer.Destroy()
// Create passphrase-protected unlocker // Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlocker") secret.Debug("Creating passphrase-protected unlocker")
-4
View File
@@ -26,10 +26,6 @@ var (
// as "vault <name> does not exist". // as "vault <name> does not exist".
ErrVaultNotFound = errors.New("does not exist") ErrVaultNotFound = errors.New("does not exist")
// ErrVaultExists indicates that a vault to be created already exists.
// Composed as "vault <name> already exists".
ErrVaultExists = errors.New("already exists")
// ErrNilValueBuffer indicates a nil value buffer was supplied. // ErrNilValueBuffer indicates a nil value buffer was supplied.
ErrNilValueBuffer = errors.New("value buffer is nil") ErrNilValueBuffer = errors.New("value buffer is nil")
+3 -17
View File
@@ -191,11 +191,7 @@ func processMnemonicForVault(
return derivationIndex, publicKeyHash, familyHash, nil return derivationIndex, publicKeyHash, familyHash, nil
} }
// CreateVault creates a new vault and selects it as the current vault. It // CreateVault creates a new vault
// refuses a vault that already exists before writing anything: creating it
// again would replace its keys, and its secrets could no longer be
// decrypted. The commands that call it hold the state directory lock, so no
// other command can create the vault between the check and the writes.
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) { func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir) secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
@@ -211,22 +207,12 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
secret.Debug("Vault name validation passed", "vault_name", name) secret.Debug("Vault name validation passed", "vault_name", name)
vaultDir := filepath.Join(stateDir, "vaults.d", name)
exists, err := afero.DirExists(fs, vaultDir)
if err != nil {
return nil, fmt.Errorf("failed to check if vault exists: %w", err)
}
if exists {
return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists)
}
// Create vault directory structure // Create vault directory structure
vaultDir := filepath.Join(stateDir, "vaults.d", name)
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir) secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
// Create main vault directory // Create main vault directory
err = fs.MkdirAll(vaultDir, secret.DirPerms) err := fs.MkdirAll(vaultDir, secret.DirPerms)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create vault directory: %w", err) return nil, fmt.Errorf("failed to create vault directory: %w", err)
} }