1 Commits
Author SHA1 Message Date
clawbot 4c47b233bb 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, it checks that the
destination vault still has the long-term key it unlocked, and
copySecretAndRemoveSource checks the move again before it copies. A move
within a vault takes the lock in moveSecretWithinVault, so MoveSecret
takes none itself. The error for a vault created again with another key,
shared with secret unlocker add, now ends "nothing was changed".

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