1 Commits
Author SHA1 Message Date
sneak d72043a859 A secret mv between vaults unlocks both vaults before taking the lock (closes #189)
check / check (push) Canceled after 0s
A move between two vaults unlocked them while holding the state directory
lock, and unlocking can ask for a passphrase, so every other changing
command waited at that prompt. moveSecretCrossVault now checks the move,
refusing an existing destination secret without --force, unlocks both
vaults, and only then takes the lock. Under the lock,
copySecretAndRemoveSource checks again before it copies. A move within a
vault takes the lock in moveSecretWithinVault, so MoveSecret takes none
itself.

The two tests of a source that cannot be removed now call
copySecretAndRemoveSource, since the lock refuses their wrapped
filesystems.

Model: opus-5-5
2026-10-15 07:23:44 +00:00
3 changed files with 29 additions and 75 deletions
+2 -34
View File
@@ -232,18 +232,13 @@ func TestCrossVaultMoveUnlocksBeforeLocking(t *testing.T) {
// TestCrossVaultMoveChecksAgainOnceLocked runs a move between vaults, given // TestCrossVaultMoveChecksAgainOnceLocked runs a move between vaults, given
// the mnemonic, as TestCommandsCheckAgainOnceLocked runs the commands in its // the mnemonic, as TestCommandsCheckAgainOnceLocked runs the commands in its
// table: once the move waits for the state directory lock, the test removes // table: once the move waits for the state directory lock, the test removes
// the source secret or the destination vault, or creates the destination // the source secret or the destination vault, and the move must then fail
// vault again from another mnemonic, and the move must then fail and change // and change nothing.
// nothing.
// //
//nolint:paralleltest // waitingForLock sees any test's command waiting for the lock //nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
func TestCrossVaultMoveChecksAgainOnceLocked(t *testing.T) { func TestCrossVaultMoveChecksAgainOnceLocked(t *testing.T) {
mnemonic := testMnemonicBuffer(t) mnemonic := testMnemonicBuffer(t)
otherMnemonic := memguard.NewBufferFromBytes([]byte("legal winner thank " +
"year wave sausage worth useful legal winner thank yellow"))
t.Cleanup(otherMnemonic.Destroy)
move := func(cli *Instance) error { move := func(cli *Instance) error {
cli.Mnemonic = mnemonic cli.Mnemonic = mnemonic
@@ -261,33 +256,6 @@ func TestCrossVaultMoveChecksAgainOnceLocked(t *testing.T) {
return fs.RemoveAll(testStateDir + "/vaults.d/other") return fs.RemoveAll(testStateDir + "/vaults.d/other")
}, vault.ErrVaultNotFound) }, vault.ErrVaultNotFound)
}) })
t.Run("destination vault created again from another mnemonic", func(t *testing.T) {
// Set by the move, which has finished once
// requireChecksAgainOnceLocked returns
var moveErr error
requireChecksAgainOnceLocked(t, func(cli *Instance) error {
moveErr = move(cli)
return moveErr
}, func(fs afero.Fs) error {
err := fs.RemoveAll(testStateDir + "/vaults.d/other")
if err != nil {
return err
}
_, err = vault.CreateVault(fs, testStateDir, "other", otherMnemonic, nil)
return err
}, errVaultCreatedAgain)
// secret unlocker add fails with the same error, so its message
// must fit a move too
require.EqualError(t, moveErr, "vault 'other' was removed and "+
"created again with another key while it was being unlocked; "+
"nothing was changed")
})
} }
// TestCrossVaultMoveRefusesExistingSecretBeforeUnlocking checks that a move // TestCrossVaultMoveRefusesExistingSecretBeforeUnlocking checks that a move
+18 -19
View File
@@ -1084,22 +1084,20 @@ func (cli *Instance) sameDirectory(dir, other string) (bool, error) {
// moveSecretCrossVault handles moving between two different vaults. Its // moveSecretCrossVault handles moving between two different vaults. Its
// caller, MoveSecret, has already checked both secret names and that both // caller, MoveSecret, has already checked both secret names and that both
// vaults exist. The copy needs the source vault's long-term key and is // vaults exist. The copy needs the source vault's long-term key; the
// encrypted to the destination vault's long-term public key. The // destination vault is unlocked only to check that it can be. Either can ask
// destination vault is unlocked to check that the copy will open with what // for a passphrase, so both happen before it takes the state directory lock,
// the user gave, and, once the state directory lock is held, that it still // and no other command waits while the user answers.
// has the key unlocked. Either unlock can ask for a passphrase, so both
// happen before the lock is taken, and no other command waits while the
// user answers.
func (cli *Instance) moveSecretCrossVault( func (cli *Instance) moveSecretCrossVault(
cmd *cobra.Command, cmd *cobra.Command,
srcVault *vault.Vault, srcSecretName string, srcVault *vault.Vault, srcSecretName string,
destVault *vault.Vault, destSecretName string, destVault *vault.Vault, destSecretName string,
force bool, force bool,
) error { ) error {
// Before unlocking, check that the source secret exists, that the // Before unlocking, check that the source secret and the destination
// destination is not the source under another name, and, without // vault exist, that the destination is not the source under another
// --force, that the destination secret does not exist // name, and, without --force, that the destination secret does not
// exist
_, err := cli.checkCrossVaultMove( _, err := cli.checkCrossVaultMove(
srcVault, srcSecretName, destVault, destSecretName, force) srcVault, srcSecretName, destVault, destSecretName, force)
if err != nil { if err != nil {
@@ -1110,7 +1108,7 @@ func (cli *Instance) moveSecretCrossVault(
destVault.Mnemonic, destVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase destVault.Mnemonic, destVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Unlock destination vault (will fail if neither mnemonic nor unlocker available) // Unlock destination vault (will fail if neither mnemonic nor unlocker available)
destIdentity, err := destVault.GetOrDeriveLongTermKey() _, err = destVault.GetOrDeriveLongTermKey()
if err != nil { if err != nil {
return fmt.Errorf("failed to unlock destination vault '%s': %w", destVault.Name, err) return fmt.Errorf("failed to unlock destination vault '%s': %w", destVault.Name, err)
} }
@@ -1127,11 +1125,6 @@ func (cli *Instance) moveSecretCrossVault(
} }
defer release() defer release()
err = cli.checkLongTermKey(destVault, destIdentity)
if err != nil {
return err
}
return cli.copySecretAndRemoveSource( return cli.copySecretAndRemoveSource(
cmd, srcVault, srcSecretName, destVault, destSecretName, force) cmd, srcVault, srcSecretName, destVault, destSecretName, force)
} }
@@ -1179,9 +1172,10 @@ func (cli *Instance) copySecretAndRemoveSource(
} }
// checkCrossVaultMove checks that the secret srcSecretName exists in // checkCrossVaultMove checks that the secret srcSecretName exists in
// srcVault, that destSecretName in destVault is not the source under // srcVault, that destVault exists, that destSecretName in destVault is not
// another name, and, unless force is set, that destSecretName does not // the source under another name, and, unless force is set, that
// exist in destVault. It returns the source's directory. // destSecretName does not exist in destVault. It returns the source's
// directory.
func (cli *Instance) checkCrossVaultMove( func (cli *Instance) checkCrossVaultMove(
srcVault *vault.Vault, srcSecretName string, srcVault *vault.Vault, srcSecretName string,
destVault *vault.Vault, destSecretName string, destVault *vault.Vault, destSecretName string,
@@ -1202,6 +1196,11 @@ func (cli *Instance) checkCrossVaultMove(
srcSecretName, vault.ErrSecretNotFound, srcVault.Name) srcSecretName, vault.ErrSecretNotFound, srcVault.Name)
} }
_, err = cli.existingVault(destVault.Name)
if err != nil {
return "", err
}
// The source is removed after the copy, so a destination that is the // The source is removed after the copy, so a destination that is the
// source under another name would be lost with it. // source under another name would be lost with it.
destVaultDir, err := destVault.GetDirectory() destVaultDir, err := destVault.GetDirectory()
+9 -22
View File
@@ -15,7 +15,6 @@ import (
"strings" "strings"
"time" "time"
"filippo.io/age"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret" "sneak.berlin/go/secret/internal/secret"
@@ -43,7 +42,7 @@ var (
// errVaultCreatedAgain carries only the message tail; the caller // errVaultCreatedAgain carries only the message tail; the caller
// composes "vault '<name>' was removed and created again ...". // composes "vault '<name>' was removed and created again ...".
errVaultCreatedAgain = errors.New("was removed and created again " + errVaultCreatedAgain = errors.New("was removed and created again " +
"with another key while it was being unlocked; nothing was changed") "with another key while it was being unlocked; nothing was added")
) )
// UnlockerInfo represents unlocker information for display // UnlockerInfo represents unlocker information for display
@@ -443,41 +442,29 @@ func (cli *Instance) unlockThenLock(vlt *vault.Vault) (func(), error) {
return nil, err return nil, err
} }
err = cli.checkLongTermKey(vlt, ltIdentity) _, err = cli.existingVault(vlt.GetName())
if err != nil { if err != nil {
release() release()
return nil, err return nil, err
} }
return release, nil
}
// checkLongTermKey checks, for the vault vlt unlocked as ltIdentity before
// the state directory lock was taken, that vlt still exists and that its
// long-term public key is still that of ltIdentity. Another command may have
// removed the vault in the meantime, or removed it and created it again with
// another long-term key. The caller holds the lock.
func (cli *Instance) checkLongTermKey(
vlt *vault.Vault, ltIdentity *age.X25519Identity,
) error {
_, err := cli.existingVault(vlt.GetName())
if err != nil {
return err
}
vaultDir, _ := vlt.GetDirectory() vaultDir, _ := vlt.GetDirectory()
ltPubKey, err := afero.ReadFile(cli.fs, filepath.Join(vaultDir, "pub.age")) ltPubKey, err := afero.ReadFile(cli.fs, filepath.Join(vaultDir, "pub.age"))
if err != nil { if err != nil {
return fmt.Errorf("failed to read long-term public key: %w", err) release()
return nil, fmt.Errorf("failed to read long-term public key: %w", err)
} }
if string(ltPubKey) != ltIdentity.Recipient().String() { if string(ltPubKey) != ltIdentity.Recipient().String() {
return fmt.Errorf("vault '%s' %w", vlt.GetName(), errVaultCreatedAgain) release()
return nil, fmt.Errorf("vault '%s' %w", vlt.GetName(), errVaultCreatedAgain)
} }
return nil return release, nil
} }
// autoSelectUnlocker selects the newly created unlocker as current, // autoSelectUnlocker selects the newly created unlocker as current,