Author SHA1 Message Date
clawbot c43eb26127 Keep unlocker list working when unlocker metadata is corrupt (closes #42)
check / check (push) Successful in 1m12s
PGPUnlocker.GetID() panicked when its metadata could not be read or
parsed, which took down `secret unlocker list` for every unlocker. It
now warns with the unlocker's directory and returns `pgp-unknown`;
metadata with an empty GPG key ID counts as corrupt too.
ListUnlockers now skips, with a warning, an unlocker whose metadata
file is unreadable or not JSON, as it already did for a missing one.

This is the first half of the issue only. Passing the mnemonic in
memory moved to #60.

Model: opus-5-5
2026-10-03 12:17:04 +00:00
8 changed files with 120 additions and 339 deletions
+5 -9
View File
@@ -25,13 +25,11 @@ Bring the repo into policy compliance in one commit:
# Completed Steps # Completed Steps
- 2026-10-03: The checks run before changing a vault now stop with an - 2026-10-03: A PGP unlocker whose metadata has no usable GPG key ID
error naming the path and cause when they cannot read what they no longer panics: `GetID()` warns with the unlocker's directory and
inspect, instead of reading the failure as "nothing there": the returns `pgp-unknown`. `ListUnlockers` skips, with a warning, an
duplicate check before `unlocker add pgp` (an unreadable unlocker whose metadata file cannot be read or parsed instead of
`unlockers.d`), the secret count that guards removing the last failing, so `secret unlocker list` still lists the others.
unlocker and removing a vault, and the existing long-term key check
before `vault import`.
- 2026-10-02: A plain `docker build .` builds again: the size tests - 2026-10-02: A plain `docker build .` builds again: the size tests
skip a case that needs more locked memory than the process can skip a case that needs more locked memory than the process can
lock, and run every case under `script/cibuild`. The image stamps the lock, and run every case under `script/cibuild`. The image stamps the
@@ -108,8 +106,6 @@ Bring the repo into policy compliance in one commit:
- Timing attacks: bytes.Equal passphrase compare (cli/init.go: - Timing attacks: bytes.Equal passphrase compare (cli/init.go:
209-216); non-constant-time public key compare (vault.go:95-100). 209-216); non-constant-time public key compare (vault.go:95-100).
- High priority: - High priority:
- Return errors instead of panicking on corrupted metadata
(pgpunlocker.go:116, keychainunlocker.go:141).
- Secure temporary file handling and cleanup. - Secure temporary file handling and cleanup.
- Print cobra usage only for argument errors, not internal - Print cobra usage only for argument errors, not internal
failures. failures.
+28 -28
View File
@@ -49,6 +49,7 @@ 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
@@ -690,15 +691,8 @@ 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
exists, err := cli.checkUnlockerExists(vlt, expectedID) 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)
} }
@@ -778,38 +772,44 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
return vlt.SelectUnlocker(unlockerID) return vlt.SelectUnlocker(unlockerID)
} }
// checkUnlockerExists reports whether the vault already has an unlocker // checkUnlockerExists checks if an unlocker with the given ID exists
// with the given ID. It returns an error, and no answer, when unlockers.d func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) error {
// cannot be read; the caller must then not create the unlocker. // Get the list of unlockers and check if any match the ID
func (cli *Instance) checkUnlockerExists(
vlt *vault.Vault, unlockerID string,
) (bool, error) {
vaultDir, err := vlt.GetDirectory()
if err != nil {
return false, fmt.Errorf("failed to get vault directory: %w", err)
}
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
unlockers, err := vlt.ListUnlockers() unlockers, err := vlt.ListUnlockers()
if err != nil { if err != nil {
return false, fmt.Errorf( secret.Warn("Could not list unlockers during duplicate check", "error", err)
"failed to list unlockers in %s: %w", unlockersDir, err,
) return nil // If we can't list unlockers, assume it doesn't exist
} }
// Get vault directory to construct unlocker instances
vaultDir, err := vlt.GetDirectory()
if err != nil {
secret.Warn("Could not get vault directory during duplicate check",
"error", err)
return nil
}
// Check each unlocker's ID
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
for _, metadata := range unlockers { for _, metadata := range unlockers {
// Construct the unlocker matching this metadata to get its ID // Construct the unlocker matching this metadata to get its ID
id, err := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true) id, err := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
if err != nil { if err != nil {
// Unlike `unlocker list`, never skip here: a skipped entry may be the duplicate. secret.Warn(
return false, err "Could not read unlockers directory during duplicate check, "+
"skipping unlocker",
"unlockers_dir", unlockersDir, "error", err)
continue
} }
if id != "" && id == unlockerID { if id != "" && id == unlockerID {
return true, nil return errUnlockerExists
} }
} }
return false, nil return nil
} }
+58 -2
View File
@@ -1,13 +1,16 @@
// Unlocker List Tests // Unlocker List Tests
// //
// Tests for `secret unlocker list` behavior when the unlockers.d directory // Tests for `secret unlocker list` behavior when the unlockers.d directory,
// cannot be read while the listing is being rendered: // or an unlocker's metadata in it, cannot be read while the listing is
// being rendered:
// //
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable // - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
// unlockers.d yields no rows rather than rows bearing synthesized IDs. // unlockers.d yields no rows rather than rows bearing synthesized IDs.
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is // - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
// still listed, with its real ID and its current-unlocker marker, // still listed, with its real ID and its current-unlocker marker,
// when a later entry's scan fails. // when a later entry's scan fails.
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
// metadata does not stop the others from being listed.
// //
// The listing resolves each unlocker's real ID by rescanning unlockers.d // The listing resolves each unlocker's real ID by rescanning unlockers.d
// after the vault has already enumerated it. If that rescan fails the ID // after the vault has already enumerated it. If that rescan fails the ID
@@ -227,3 +230,56 @@ func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
assert.True(t, unlockers[0].IsCurrent) assert.True(t, unlockers[0].IsCurrent)
assert.False(t, unlockers[1].IsCurrent) assert.False(t, unlockers[1].IsCurrent)
} }
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
// corrupt metadata does not stop the listing. Metadata that is not JSON
// leaves that unlocker out; PGP metadata without a usable GPG key ID lists
// it as "pgp-unknown". The healthy unlocker is listed with its real ID.
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
t.Parallel()
healthyID := "pgp-" + listTestGPGKeyID + "A"
tests := []struct {
name string
metadata string
wantIDs []string
}{
{
name: "not JSON",
metadata: "not json",
wantIDs: []string{healthyID},
},
{
name: "GPG key ID of the wrong type",
metadata: `{"type": "pgp", "gpgKeyId": 42}`,
wantIDs: []string{healthyID, "pgp-unknown"},
},
{
name: "GPG key ID missing",
metadata: `{"type": "pgp"}`,
wantIDs: []string{healthyID, "pgp-unknown"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := newListTestVault(t, 2)
metadataPath := filepath.Join(listTestStateDir, "vaults.d",
listTestVaultName, listTestUnlockersDirName,
listTestUnlockerDirTwo, listTestMetadataFileName)
require.NoError(t, afero.WriteFile(
fs, metadataPath, []byte(tt.metadata), listTestFilePerm,
))
unlockers := listUnlockersJSON(t, fs)
require.Len(t, unlockers, len(tt.wantIDs))
for i, wantID := range tt.wantIDs {
assert.Equal(t, wantID, unlockers[i].ID)
}
})
}
}
-262
View File
@@ -1,262 +0,0 @@
// 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.
//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)
}
// 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()
t.Setenv("GNUPGHOME", t.TempDir())
t.Cleanup(func() {
// Stop the gpg-agent that key generation starts. 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
// fails, and creates no unlocker directory, when unlockers.d cannot be
// read for the duplicate check; and, as the control case, that a readable
// unlockers.d holding the same key is still refused as a duplicate.
//
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
func TestAddPGPUnlockerDuplicateCheck(t *testing.T) {
fingerprint := newTestGPGKey(t)
unlockersDir := filepath.Join(
testVaultDir(listTestVaultName), listTestUnlockersDirName)
tests := []struct {
name string
openBudget int
}{
// The vault's own enumeration of unlockers.d fails.
{name: "listing fails", openBudget: 0},
// The enumeration succeeds; the rescan that resolves IDs fails.
{name: "rescan fails", openBudget: 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
base := newListTestVault(t, 1)
fs := &unlockersDirFailFs{Fs: base, openBudget: tt.openBudget}
err := addTestPGPUnlocker(fs)
require.ErrorIs(t, err, errUnlockersDirUnreadable)
require.NotErrorIs(t, err, errGPGKeyAlreadyUnlocker)
assert.Contains(t, err.Error(), unlockersDir,
"the error must name the directory it could not read")
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
})
}
t.Run("duplicate refused", func(t *testing.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)
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.UnlockersRemove(
"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.
func TestRemoveVaultAbortsWhenSecretsDirUnreadable(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 1)
vaultDir := testVaultDir(unreadableTestOtherVault)
writeTestSecret(t, base, vaultDir)
instance, cmd := newTestInstance(&statFailFs{
Fs: base, path: filepath.Join(vaultDir, unreadableTestSecretsDirName),
})
err := instance.RemoveVault(cmd, unreadableTestOtherVault, false)
require.ErrorIs(t, err, errStatFailed)
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.VaultImport(cmd, listTestVaultName)
require.ErrorIs(t, err, errStatFailed)
}
+7 -23
View File
@@ -388,12 +388,8 @@ 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"
exists, err = afero.Exists(cli.fs, pubKeyPath) _, err = cli.fs.Stat(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)
} }
@@ -540,26 +536,17 @@ func (cli *Instance) VaultImport(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, error) { func (cli *Instance) vaultHasSecrets(vaultDir string) bool {
secretsDir := filepath.Join(vaultDir, "secrets.d") secretsDir := filepath.Join(vaultDir, "secrets.d")
exists, err := afero.DirExists(cli.fs, secretsDir) exists, _ := 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, nil return false
} }
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 len(entries) > 0, nil return err == nil && len(entries) > 0
} }
// switchAwayFromVault selects another vault as current before removal // switchAwayFromVault selects another vault as current before removal
@@ -623,10 +610,7 @@ func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) er
} }
// Check if vault has secrets // Check if vault has secrets
hasSecrets, err := cli.vaultHasSecrets(vaultDir) hasSecrets := 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 {
+12 -4
View File
@@ -155,14 +155,18 @@ func (p *PGPUnlocker) GetDirectory() string {
return p.Directory return p.Directory
} }
// GetID implements Unlocker interface - generates ID from GPG key ID // GetID implements Unlocker interface - generates ID from GPG key ID.
// If the metadata has no usable GPG key ID, it warns with the unlocker's
// directory and returns "pgp-unknown", so listing the other unlockers
// still works.
func (p *PGPUnlocker) GetID() string { func (p *PGPUnlocker) GetID() string {
// Generate ID using GPG key ID: pgp-<keyid> // Generate ID using GPG key ID: pgp-<keyid>
gpgKeyID, err := p.GetGPGKeyID() gpgKeyID, err := p.GetGPGKeyID()
if err != nil { if err != nil {
// The vault metadata is corrupt - this is a fatal error Warn("PGP unlocker metadata is corrupt or missing its GPG key ID",
// We cannot continue with a fallback ID as that would mask data corruption "directory", p.Directory, "error", err)
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
return "pgp-unknown"
} }
return "pgp-" + gpgKeyID return "pgp-" + gpgKeyID
@@ -197,6 +201,10 @@ func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
return "", fmt.Errorf("failed to parse PGP metadata: %w", err) return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
} }
if pgpMetadata.GPGKeyID == "" {
return "", fmt.Errorf("PGP metadata: %w", errGPGKeyIDEmpty)
}
return pgpMetadata.GPGKeyID, nil return pgpMetadata.GPGKeyID, nil
} }
+8 -4
View File
@@ -247,16 +247,20 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
metadataBytes, err := afero.ReadFile(v.fs, metadataPath) metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil { if err != nil {
return nil, fmt.Errorf( secret.Warn("Skipping unlocker directory with unreadable metadata file",
"failed to read metadata for unlocker %s: %w", file.Name(), err) "directory", file.Name(), "error", err)
continue
} }
var metadata UnlockerMetadata var metadata UnlockerMetadata
err = json.Unmarshal(metadataBytes, &metadata) err = json.Unmarshal(metadataBytes, &metadata)
if err != nil { if err != nil {
return nil, fmt.Errorf( secret.Warn("Skipping unlocker directory with corrupt metadata file",
"failed to parse metadata for unlocker %s: %w", file.Name(), err) "directory", file.Name(), "error", err)
continue
} }
unlockers = append(unlockers, metadata) unlockers = append(unlockers, metadata)
+2 -7
View File
@@ -138,12 +138,7 @@ func (v *Vault) NumSecrets() (int, error) {
secretsDir := filepath.Join(vaultDir, "secrets.d") secretsDir := filepath.Join(vaultDir, "secrets.d")
exists, err := afero.DirExists(v.fs, secretsDir) exists, _ := 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
} }
@@ -167,7 +162,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 {
return 0, fmt.Errorf("failed to check %s: %w", currentFile, err) continue // Skip directories we can't read
} }
if exists { if exists {