2 Commits
Author SHA1 Message Date
clawbot 0fbbc5332a Refuse to create a vault that already exists (closes #74)
check / check (push) Failing after 31s
vault.CreateVault now checks for the vault before writing anything and
fails with "vault NAME already exists" (vault.ErrVaultExists). secret
init and secret vault create call it while holding the state directory
lock, so two creates at once cannot both pass the check. Before, either
command over an existing vault replaced its metadata, passphrase
unlocker and longterm.age, so none of its secrets could be decrypted.

Both commands now ask for the unlocker passphrase before creating the
vault, so one stopped at that prompt leaves no vault without an
unlocker behind, which they would then refuse to create again.

The lock tests set up the vault "work" instead of "default", which init
now refuses to create again.

Model: opus-5-5
2026-10-04 05:04:49 +00:00
clawbot 641d5659ec 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 cannot be checked for, read or parsed, as it already did for a
missing one. The listing's ID lookup skips such a directory without
warning again.

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

Model: opus-5-5
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
2026-10-04 06:42:14 +02:00
8 changed files with 269 additions and 49 deletions
+12 -8
View File
@@ -31,9 +31,15 @@ Bring the repo into policy compliance in one commit:
which both commands call while holding the state directory lock, so two 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 creates of one vault at once cannot both pass the check. Before, either
command replaced the vault's metadata, passphrase unlocker and command replaced the vault's metadata, passphrase unlocker and
`longterm.age`, so none of its secrets could be decrypted any more. A `longterm.age`, so none of its secrets could be decrypted any more. Both
vault left without an unlocker by an `init` or `vault create` stopped at commands now ask for the unlocker passphrase before creating the vault,
the passphrase prompt is refused like any other. so one stopped at that prompt leaves no vault behind.
- 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID
no longer panics: `GetID()` warns with the unlocker's directory and
returns `pgp-unknown`. `ListUnlockers` skips, with a warning, an
unlocker whose metadata file cannot be checked for, read or parsed
instead of failing, so `secret unlocker list` still lists the others;
the listing's ID lookup no longer warns about that directory again.
- 2026-10-03: `secret mv` rejects a move whose destination is the - 2026-10-03: `secret mv` rejects a move whose destination is the
source (`mv --force x x`, `mv --force work:x work:`, or an empty source (`mv --force x x`, `mv --force work:x work:`, or an empty
destination, which defaults to the source name) before changing destination, which defaults to the source name) before changing
@@ -60,9 +66,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 `vault create` stopped at the passphrase prompt, a new vault - from `init` or `vault create` killed after the passphrase prompt
with no unlocker that is already the current vault; from `init` but before the unlocker is written, a vault with no unlocker,
stopped there, the default vault with no unlocker; which `vault create` has already made the current vault;
- 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;
@@ -169,8 +175,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.
+62 -8
View File
@@ -75,16 +75,70 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
require.EqualError(t, err, tt.want) require.EqualError(t, err, tt.want)
require.Equal(t, before, snapshotStateDir(t, fs)) require.Equal(t, before, snapshotStateDir(t, fs))
})
}
// Without the mnemonic, reading a secret goes through the // Every case left the state directory exactly as recorded in before, so
// vault's passphrase unlocker. // reading each vault's secret once from it shows that it still decrypts
t.Setenv(secret.EnvMnemonic, "") // 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 { for _, name := range vaults {
value, err := vault.NewVault(fs, testStateDir, name).GetSecret("x") value, err := vault.NewVault(fs, testStateDir, name).GetSecret("x")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, "value", string(value)) 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.
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))
}) })
} }
} }
+8 -7
View File
@@ -160,6 +160,14 @@ 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()
@@ -175,13 +183,6 @@ 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")
+4 -6
View File
@@ -349,6 +349,10 @@ func unlockerIDFromDir(
// itself cannot be read. Callers must distinguish the two: an unreadable // itself cannot be read. Callers must distinguish the two: an unreadable
// directory means the unlocker's real ID is unknowable, so the entry has // directory means the unlocker's real ID is unknowable, so the entry has
// to be skipped rather than reported under a synthesized ID. // to be skipped rather than reported under a synthesized ID.
//
// A metadata file that cannot be read or parsed is skipped without a
// warning: every caller gets metadata from vault.ListUnlockers first,
// which has already warned about that directory.
func findUnlockerIDByMetadata( func findUnlockerIDByMetadata(
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata, fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
includeSecureEnclave bool, includeSecureEnclave bool,
@@ -371,9 +375,6 @@ func findUnlockerIDByMetadata(
// Check if this is the right unlocker by comparing metadata // Check if this is the right unlocker by comparing metadata
metadataBytes, err := afero.ReadFile(fs, metadataPath) metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil { if err != nil {
secret.Warn("Could not read unlocker metadata file",
"path", metadataPath, "error", err)
continue continue
} }
@@ -381,9 +382,6 @@ func findUnlockerIDByMetadata(
err = json.Unmarshal(metadataBytes, &diskMetadata) err = json.Unmarshal(metadataBytes, &diskMetadata)
if err != nil { if err != nil {
secret.Warn("Could not parse unlocker metadata file",
"path", metadataPath, "error", err)
continue continue
} }
+151 -2
View File
@@ -1,13 +1,19 @@
// 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.
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
// file cannot be checked for or read is left out, and the other is
// still 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
@@ -22,6 +28,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
@@ -92,6 +99,49 @@ func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
return f.Fs.Open(name) return f.Fs.Open(name)
} }
// errMetadataUnreadable is returned by the test filesystem in place of a
// successful open of one unlocker's metadata file.
var errMetadataUnreadable = errors.New("input/output error")
// metadataReadFailFs fails every open of the file at unreadablePath. The
// file still exists, so checking for it succeeds and only reading it fails.
type metadataReadFailFs struct {
afero.Fs
unreadablePath string
}
//nolint:ireturn // afero.File is the interface required by afero.Fs
func (f *metadataReadFailFs) Open(name string) (afero.File, error) {
if name == f.unreadablePath {
return nil, errMetadataUnreadable
}
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
return f.Fs.Open(name)
}
// errMetadataUncheckable is returned by the test filesystem in place of a
// successful check for one unlocker's metadata file.
var errMetadataUncheckable = errors.New("permission denied")
// metadataStatFailFs fails every check for whether the file at
// uncheckablePath exists, as when its unlocker directory cannot be entered.
type metadataStatFailFs struct {
afero.Fs
uncheckablePath string
}
func (f *metadataStatFailFs) Stat(name string) (os.FileInfo, error) {
if name == f.uncheckablePath {
return nil, errMetadataUncheckable
}
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
return f.Fs.Stat(name)
}
// writePGPUnlocker writes a PGP unlocker directory with metadata that // writePGPUnlocker writes a PGP unlocker directory with metadata that
// yields the real ID "pgp-<keyID>". // yields the real ID "pgp-<keyID>".
func writePGPUnlocker( func writePGPUnlocker(
@@ -227,3 +277,102 @@ 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)
}
})
}
}
// TestUnlockersListSkipsUnreadableMetadata asserts that an unlocker whose
// metadata file cannot be checked for or cannot be read is left out of the
// listing, and the other unlocker is still listed with its real ID. The
// failing one sorts first, so finding the other's ID has to step past it
// as well.
func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) {
t.Parallel()
failingPath := filepath.Join(listTestStateDir, "vaults.d",
listTestVaultName, listTestUnlockersDirName,
listTestUnlockerDirOne, listTestMetadataFileName)
tests := []struct {
name string
wrap func(base afero.Fs) afero.Fs
}{
{
name: "checking for the file fails",
wrap: func(base afero.Fs) afero.Fs {
return &metadataStatFailFs{Fs: base, uncheckablePath: failingPath}
},
},
{
name: "reading the file fails",
wrap: func(base afero.Fs) afero.Fs {
return &metadataReadFailFs{Fs: base, unreadablePath: failingPath}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := tt.wrap(newListTestVault(t, 2))
unlockers := listUnlockersJSON(t, fs)
require.Len(t, unlockers, 1,
"only the unlocker with usable metadata may be listed")
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[0].ID,
"the listed row must carry the real unlocker ID")
})
}
}
+8 -7
View File
@@ -309,6 +309,14 @@ 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()
@@ -336,13 +344,6 @@ 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")
+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
} }
+12 -7
View File
@@ -233,9 +233,10 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
exists, err := afero.Exists(v.fs, metadataPath) exists, err := afero.Exists(v.fs, metadataPath)
if err != nil { if err != nil {
return nil, fmt.Errorf( secret.Warn("Skipping unlocker directory whose metadata file cannot be checked",
"failed to check if metadata exists for unlocker %s: %w", "directory", file.Name(), "error", err)
file.Name(), err)
continue
} }
if !exists { if !exists {
@@ -247,16 +248,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)