Stop vault safety checks from reading unreadable state as empty (closes #51)
check / check (push) Waiting to run

Adding a PGP unlocker checked unlockers.d for a duplicate and, when the
directory or an unlocker's metadata file could not be read, reported no
duplicate and went on. The check now reads unlockers.d itself and stops
with an error naming the path and cause; `unlocker list` keeps skipping
unlockers it cannot read.

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.

`vault rm` and `unlocker rm` now take the state directory lock and call
an unexported function that does the work, as `vault import` does.

Model: opus-5-5
This commit is contained in:
2026-10-04 07:23:03 +00:00
parent fb4481b4f7
commit 773a5ff69d
5 changed files with 462 additions and 40 deletions
+7
View File
@@ -98,6 +98,13 @@ Bring the repo into policy compliance in one commit:
being removed, encrypted keys included. Nothing deletes it; it being removed, encrypted keys included. Nothing deletes it; it
must be deleted by hand must be deleted by hand
(https://git.eeqj.de/sneak/secret/issues/75). (https://git.eeqj.de/sneak/secret/issues/75).
- 2026-10-03: The checks run before changing a vault now stop with an
error naming the path and cause when they cannot read what they
inspect, instead of reading the failure as "nothing there": the
duplicate check before `unlocker add pgp` (an unreadable
`unlockers.d` or unlocker metadata file), the secret count that
guards removing the last unlocker and removing a vault, and the
existing long-term key check 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
+65 -30
View File
@@ -49,7 +49,6 @@ var (
"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") errLastUnlocker = errors.New("refusing to remove last unlocker")
errUnlockerExists = errors.New("unlocker already exists")
) )
// UnlockerInfo represents unlocker information for display // UnlockerInfo represents unlocker information for display
@@ -695,8 +694,15 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
// Check if this GPG key is already added // Check if this GPG key is already added
expectedID := "pgp-" + fingerprint expectedID := "pgp-" + fingerprint
err = cli.checkUnlockerExists(vlt, expectedID) exists, err := cli.checkUnlockerExists(vlt, expectedID)
if err != nil { if err != nil {
return fmt.Errorf(
"could not check whether GPG key %s is already an unlocker: %w",
gpgKeyID, err,
)
}
if exists {
return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker) return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker)
} }
@@ -714,7 +720,8 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
return nil return nil
} }
// UnlockersRemove removes an unlocker with safety checks // UnlockersRemove removes an unlocker, holding the state directory lock
// while removeUnlocker runs
func (cli *Instance) UnlockersRemove( func (cli *Instance) UnlockersRemove(
unlockerID string, force bool, cmd *cobra.Command, unlockerID string, force bool, cmd *cobra.Command,
) error { ) error {
@@ -724,6 +731,13 @@ func (cli *Instance) UnlockersRemove(
} }
defer release() defer release()
return cli.removeUnlocker(unlockerID, force, cmd)
}
// removeUnlocker removes an unlocker with safety checks
func (cli *Instance) removeUnlocker(
unlockerID string, force bool, cmd *cobra.Command,
) error {
// Get current vault // Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
@@ -788,44 +802,65 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
return vlt.SelectUnlocker(unlockerID) return vlt.SelectUnlocker(unlockerID)
} }
// checkUnlockerExists checks if an unlocker with the given ID exists // checkUnlockerExists reports whether the vault already has an unlocker
func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) error { // with the given ID. It returns an error, and no answer, when unlockers.d
// Get the list of unlockers and check if any match the ID // or an unlocker's metadata file cannot be read; the caller must then not
unlockers, err := vlt.ListUnlockers() // create the unlocker. It reads unlockers.d itself because
if err != nil { // vault.ListUnlockers skips an unlocker it cannot read, which suits
secret.Warn("Could not list unlockers during duplicate check", "error", err) // `unlocker list` but not this check: the skipped unlocker may be the
// duplicate. A directory whose metadata file is missing or corrupt is not
return nil // If we can't list unlockers, assume it doesn't exist // a working unlocker and is passed over.
} func (cli *Instance) checkUnlockerExists(
vlt *vault.Vault, unlockerID string,
// Get vault directory to construct unlocker instances ) (bool, error) {
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
if err != nil { if err != nil {
secret.Warn("Could not get vault directory during duplicate check", return false, fmt.Errorf("failed to get vault directory: %w", err)
"error", err)
return nil
} }
// Check each unlocker's ID
unlockersDir := filepath.Join(vaultDir, "unlockers.d") unlockersDir := filepath.Join(vaultDir, "unlockers.d")
for _, metadata := range unlockers { entries, err := afero.ReadDir(cli.fs, unlockersDir)
// Construct the unlocker matching this metadata to get its ID if errors.Is(err, os.ErrNotExist) {
id, err := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true) return false, nil
if err != nil { }
secret.Warn(
"Could not read unlockers directory during duplicate check, "+
"skipping unlocker",
"unlockers_dir", unlockersDir, "error", err)
if err != nil {
return false, fmt.Errorf(
"failed to read unlockers directory %s: %w", unlockersDir, err,
)
}
for _, entry := range entries {
if !entry.IsDir() {
continue continue
} }
if id != "" && id == unlockerID { unlockerDir := filepath.Join(unlockersDir, entry.Name())
return errUnlockerExists
metadataBytes, err := afero.ReadFile(
cli.fs, filepath.Join(unlockerDir, "unlocker-metadata.json"))
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return false, fmt.Errorf(
"failed to read metadata of unlocker %s: %w", unlockerDir, err,
)
}
var metadata secret.UnlockerMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
continue
}
if unlockerIDFromDir(cli.fs, unlockerDir, metadata, true) == unlockerID {
return true, nil
} }
} }
return nil return false, nil
} }
+353
View File
@@ -0,0 +1,353 @@
// Unreadable Directory Tests
//
// The checks that guard adding a PGP unlocker (is this key already an
// unlocker?), removing the last unlocker and removing a vault (does the
// vault hold secrets?), 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
// "nothing there" and go ahead.
//
// The tests make the look fail with a wrapper around the in-memory
// filesystem, which the state directory lock refuses. So they call the
// function each command runs once it holds the lock, such as removeVault
// for RemoveVault.
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"context"
"errors"
"io"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
// unreadableTestGPGUserID is the user ID of the throwaway GPG key the
// PGP unlocker tests generate, and the --keyid they pass.
unreadableTestGPGUserID = "unlocker-test@example.com"
// unreadableTestSecretName is the secret stored in the vaults the
// removal tests remove from.
unreadableTestSecretName = "api-key"
// unreadableTestOtherVault is a second vault for the vault removal
// test, since the last vault can never be removed.
unreadableTestOtherVault = "work"
// unreadableTestSecretsDirName is the directory holding a vault's
// secrets, and unreadableTestCurrentFileName the per-secret file
// naming its current version.
unreadableTestSecretsDirName = "secrets.d"
unreadableTestCurrentFileName = "current"
)
// errStatFailed is returned by statFailFs in place of a successful stat.
var errStatFailed = errors.New("input/output error")
// statFailFs fails every Stat of one path, as an I/O or permission error
// on that path would.
type statFailFs struct {
afero.Fs
path string
}
func (f *statFailFs) Stat(name string) (os.FileInfo, error) {
if name == f.path {
return nil, errStatFailed
}
return f.Fs.Stat(name)
}
// errOpenFailed is returned by openFailFs in place of a successful open.
var errOpenFailed = errors.New("permission denied")
// openFailFs fails every Open of one path, as a directory without read
// permission does: checking that it exists succeeds, listing it fails.
type openFailFs struct {
afero.Fs
path string
}
//nolint:ireturn // afero.File is the interface required by afero.Fs
func (f *openFailFs) Open(name string) (afero.File, error) {
if name == f.path {
return nil, errOpenFailed
}
return f.Fs.Open(name)
}
// testVaultDir returns the directory of the named vault in the synthetic
// state directory built by newListTestVault.
func testVaultDir(vaultName string) string {
return filepath.Join(listTestStateDir, "vaults.d", vaultName)
}
// newTestInstance returns a CLI instance on fs whose output is discarded.
func newTestInstance(fs afero.Fs) (*Instance, *cobra.Command) {
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
return &Instance{fs: fs, stateDir: listTestStateDir, cmd: cmd}, cmd
}
// assertDirEntries asserts that dir holds exactly the named entries.
func assertDirEntries(t *testing.T, fs afero.Fs, dir string, want ...string) {
t.Helper()
entries, err := afero.ReadDir(fs, dir)
require.NoError(t, err)
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
assert.ElementsMatch(t, want, names)
}
// newTestGPGKey points GNUPGHOME at a fresh directory, generates a GPG key
// without a passphrase there, and returns the key's fingerprint.
func newTestGPGKey(t *testing.T) string {
t.Helper()
// Not t.TempDir(): on macOS its path is too long for the gpg-agent
// socket, which is created inside GNUPGHOME there.
gnupgHome, err := os.MkdirTemp("", "gpg") //nolint:usetesting // short path
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(gnupgHome) })
t.Setenv("GNUPGHOME", gnupgHome)
t.Cleanup(func() {
// Stop the gpg-agent that key generation starts; cleanups run in
// reverse order, so this happens before its directory is removed.
// t.Context is already canceled when cleanup runs.
ctx := context.WithoutCancel(t.Context())
_ = exec.CommandContext(ctx, "gpgconf", "--kill", "gpg-agent").Run()
})
output, err := exec.CommandContext(t.Context(), "gpg", "--batch",
"--pinentry-mode", "loopback", "--passphrase", "",
"--quick-gen-key", unreadableTestGPGUserID, "ed25519", "sign", "never",
).CombinedOutput()
require.NoError(t, err, "generating the test GPG key: %s", output)
fingerprint, err := secret.ResolveGPGKeyFingerprint(unreadableTestGPGUserID)
require.NoError(t, err)
return fingerprint
}
// addTestPGPUnlocker runs `secret unlocker add pgp` for the test key
// against fs.
func addTestPGPUnlocker(fs afero.Fs) error {
instance, cmd := newTestInstance(fs)
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
return instance.addPGPUnlocker(cmd)
}
// TestAddPGPUnlockerDuplicateCheck asserts that adding a PGP unlocker for
// a key that already has one fails, and creates no unlocker directory,
// when unlockers.d or the existing unlocker's metadata file cannot be
// read; and, as the control case, that the existing unlocker is refused
// as a duplicate when everything can be read.
//
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
func TestAddPGPUnlockerDuplicateCheck(t *testing.T) {
fingerprint := newTestGPGKey(t)
unlockersDir := filepath.Join(
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 {
name string
failFs func(base afero.Fs) afero.Fs
wantErr error
// wantPath is the path the error must name.
wantPath string
}{
{
name: "unlockers.d unreadable",
failFs: func(base afero.Fs) afero.Fs {
return &unlockersDirFailFs{Fs: base}
},
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 {
t.Run(tt.name, func(t *testing.T) {
base := newVaultWithDuplicate(t)
err := addTestPGPUnlocker(tt.failFs(base))
require.ErrorIs(t, err, tt.wantErr)
require.NotErrorIs(t, err, errGPGKeyAlreadyUnlocker)
assert.Contains(t, err.Error(), tt.wantPath,
"the error must name what it could not read")
assertDirEntries(t, base, unlockersDir,
listTestUnlockerDirOne, listTestUnlockerDirTwo)
})
}
t.Run("duplicate refused", func(t *testing.T) {
base := newVaultWithDuplicate(t)
err := addTestPGPUnlocker(base)
require.ErrorIs(t, err, errGPGKeyAlreadyUnlocker)
assertDirEntries(t, base, unlockersDir,
listTestUnlockerDirOne, listTestUnlockerDirTwo)
})
}
// writeTestSecret stores a secret with a current-version pointer, which is
// what makes it count as a secret, in the given vault directory.
func writeTestSecret(t *testing.T, fs afero.Fs, vaultDir string) {
t.Helper()
secretDir := filepath.Join(
vaultDir, unreadableTestSecretsDirName, unreadableTestSecretName)
require.NoError(t, fs.MkdirAll(secretDir, listTestDirPerm))
require.NoError(t, afero.WriteFile(fs,
filepath.Join(secretDir, unreadableTestCurrentFileName),
[]byte("20260809.001"), listTestFilePerm))
}
// TestRemoveLastUnlockerAbortsWhenSecretsUnreadable asserts that the last
// unlocker is kept when the secrets it protects cannot be counted.
func TestRemoveLastUnlockerAbortsWhenSecretsUnreadable(t *testing.T) {
t.Parallel()
vaultDir := testVaultDir(listTestVaultName)
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
secretsDir := filepath.Join(vaultDir, unreadableTestSecretsDirName)
for _, path := range []string{
secretsDir,
filepath.Join(secretsDir, unreadableTestSecretName,
unreadableTestCurrentFileName),
} {
t.Run(filepath.Base(path), func(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 1)
writeTestSecret(t, base, vaultDir)
instance, cmd := newTestInstance(&statFailFs{Fs: base, path: path})
err := instance.removeUnlocker(
"pgp-"+listTestGPGKeyID+"A", false, cmd)
require.ErrorIs(t, err, errStatFailed)
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
})
}
}
// TestRemoveVaultAbortsWhenSecretsDirUnreadable asserts that a vault is
// kept when whether it holds secrets cannot be determined: when checking
// that secrets.d exists fails, and when it exists but cannot be listed.
func TestRemoveVaultAbortsWhenSecretsDirUnreadable(t *testing.T) {
t.Parallel()
vaultDir := testVaultDir(unreadableTestOtherVault)
secretsDir := filepath.Join(vaultDir, unreadableTestSecretsDirName)
tests := []struct {
name string
failFs func(base afero.Fs) afero.Fs
wantErr error
}{
{
name: "check fails",
failFs: func(base afero.Fs) afero.Fs {
return &statFailFs{Fs: base, path: secretsDir}
},
wantErr: errStatFailed,
},
{
name: "listing fails",
failFs: func(base afero.Fs) afero.Fs {
return &openFailFs{Fs: base, path: secretsDir}
},
wantErr: errOpenFailed,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 1)
writeTestSecret(t, base, vaultDir)
instance, cmd := newTestInstance(tt.failFs(base))
err := instance.removeVault(cmd, unreadableTestOtherVault, false)
require.ErrorIs(t, err, tt.wantErr)
exists, err := afero.DirExists(base, vaultDir)
require.NoError(t, err)
assert.True(t, exists, "the vault must not be removed")
})
}
}
// TestVaultImportAbortsWhenPubKeyUnreadable asserts that a mnemonic import
// stops when whether the vault already has a long-term key cannot be
// determined.
func TestVaultImportAbortsWhenPubKeyUnreadable(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 1)
instance, cmd := newTestInstance(&statFailFs{
Fs: base, path: filepath.Join(testVaultDir(listTestVaultName), "pub.age"),
})
err := instance.importMnemonic(cmd, listTestVaultName)
require.ErrorIs(t, err, errStatFailed)
}
+30 -8
View File
@@ -401,8 +401,12 @@ func (cli *Instance) vaultImportPreflight(
// Check if vault already has a public key // Check if vault already has a public key
pubKeyPath := vaultDir + "/pub.age" pubKeyPath := vaultDir + "/pub.age"
_, err = cli.fs.Stat(pubKeyPath) exists, err = afero.Exists(cli.fs, pubKeyPath)
if err == nil { if err != nil {
return "", "", "", fmt.Errorf("failed to check %s: %w", pubKeyPath, err)
}
if exists {
return "", "", "", fmt.Errorf("vault '%s' %w", return "", "", "", fmt.Errorf("vault '%s' %w",
vaultName, errVaultHasLongTermKey) vaultName, errVaultHasLongTermKey)
} }
@@ -562,17 +566,26 @@ func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error
} }
// vaultHasSecrets reports whether the vault directory contains any secrets // vaultHasSecrets reports whether the vault directory contains any secrets
func (cli *Instance) vaultHasSecrets(vaultDir string) bool { func (cli *Instance) vaultHasSecrets(vaultDir string) (bool, error) {
secretsDir := filepath.Join(vaultDir, "secrets.d") secretsDir := filepath.Join(vaultDir, "secrets.d")
exists, _ := afero.DirExists(cli.fs, secretsDir) exists, err := afero.DirExists(cli.fs, secretsDir)
if err != nil {
return false, fmt.Errorf("failed to check secrets directory %s: %w",
secretsDir, err)
}
if !exists { if !exists {
return false return false, nil
} }
entries, err := afero.ReadDir(cli.fs, secretsDir) entries, err := afero.ReadDir(cli.fs, secretsDir)
if err != nil {
return false, fmt.Errorf("failed to read secrets directory %s: %w",
secretsDir, err)
}
return err == nil && len(entries) > 0 return len(entries) > 0, nil
} }
// switchAwayFromVault selects another vault as current before removal // switchAwayFromVault selects another vault as current before removal
@@ -601,7 +614,8 @@ func (cli *Instance) switchAwayFromVault(
return nil return nil
} }
// RemoveVault removes a vault with safety checks // RemoveVault removes a vault, holding the state directory lock while
// removeVault runs
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error { func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir) release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil { if err != nil {
@@ -609,6 +623,11 @@ func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) er
} }
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 // Get list of all vaults
vaults, err := vault.ListVaults(cli.fs, cli.stateDir) vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err != nil { if err != nil {
@@ -642,7 +661,10 @@ func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) er
} }
// Check if vault has secrets // Check if vault has secrets
hasSecrets := cli.vaultHasSecrets(vaultDir) hasSecrets, err := cli.vaultHasSecrets(vaultDir)
if err != nil {
return err
}
// Require --force if vault has secrets // Require --force if vault has secrets
if hasSecrets && !force { if hasSecrets && !force {
+7 -2
View File
@@ -138,7 +138,12 @@ func (v *Vault) NumSecrets() (int, error) {
secretsDir := filepath.Join(vaultDir, "secrets.d") secretsDir := filepath.Join(vaultDir, "secrets.d")
exists, _ := afero.DirExists(v.fs, secretsDir) exists, err := afero.DirExists(v.fs, secretsDir)
if err != nil {
return 0, fmt.Errorf("failed to check secrets directory %s: %w",
secretsDir, err)
}
if !exists { if !exists {
return 0, nil return 0, nil
} }
@@ -162,7 +167,7 @@ func (v *Vault) NumSecrets() (int, error) {
exists, err := afero.Exists(v.fs, currentFile) exists, err := afero.Exists(v.fs, currentFile)
if err != nil { if err != nil {
continue // Skip directories we can't read return 0, fmt.Errorf("failed to check %s: %w", currentFile, err)
} }
if exists { if exists {