Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64d0c0763d |
@@ -440,11 +440,10 @@ in a CI job, the command fails and names the variable to set.
|
|||||||
|
|
||||||
A command that changes anything locks the state directory, through its file
|
A command that changes anything locks the state directory, through its file
|
||||||
`lock`, and another such command waits until the lock is released.
|
`lock`, and another such command waits until the lock is released.
|
||||||
`secret init`, `secret vault create`, `secret unlocker add`, `secret mv` from
|
`secret init`, `secret vault create`, `secret unlocker add`, and `secret add`,
|
||||||
one vault to another, and `secret add`, `secret import` and
|
`secret import` and `secret generate secret` with `--force`, ask for the
|
||||||
`secret generate secret` with `--force`, ask for the mnemonic or passphrase
|
mnemonic or passphrase before they take the lock, so a prompt of theirs left
|
||||||
before they take the lock, so a prompt of theirs left open in one terminal holds
|
open in one terminal holds up no command in another.
|
||||||
up no command in another.
|
|
||||||
|
|
||||||
## Security Features
|
## Security Features
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -229,87 +230,68 @@ func TestCrossVaultMoveUnlocksBeforeLocking(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCrossVaultMoveChecksAgainOnceLocked runs a move between vaults, given
|
// TestCrossVaultMoveChecksAgainOnceLocked starts a move between two vaults,
|
||||||
// the mnemonic, as TestCommandsCheckAgainOnceLocked runs the commands in its
|
// given the mnemonic, while the test holds the state directory lock. Once
|
||||||
// table: once the move waits for the state directory lock, the test removes
|
// the move waits for the lock, the test removes what another command could
|
||||||
// the source secret or the destination vault, or creates the destination
|
// have removed in the meantime, then releases the lock. The move must fail
|
||||||
// vault again from another mnemonic, and the move must then fail and change
|
// with the error that says what is missing.
|
||||||
// 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)
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
// removed is the directory, under the state directory, that the
|
||||||
|
// test removes
|
||||||
|
removed string
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"source", "/vaults.d/default/secrets.d/x", vault.ErrSecretNotFound},
|
||||||
|
{"destination vault", "/vaults.d/work", vault.ErrVaultNotFound},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
createCrossVaults(t, fs)
|
||||||
|
|
||||||
otherMnemonic := memguard.NewBufferFromBytes([]byte("legal winner thank " +
|
release, err := vault.LockStateDir(fs, testStateDir)
|
||||||
"year wave sausage worth useful legal winner thank yellow"))
|
require.NoError(t, err)
|
||||||
t.Cleanup(otherMnemonic.Destroy)
|
|
||||||
|
|
||||||
move := func(cli *Instance) error {
|
// Released at most once, and also if the test fails while
|
||||||
cli.Mnemonic = mnemonic
|
// holding it, so that later tests can take it
|
||||||
|
release = sync.OnceFunc(release)
|
||||||
|
defer release()
|
||||||
|
|
||||||
return cli.MoveSecret(cli.cmd, "work:test/secret", "other:moved", false)
|
instance := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||||
}
|
instance.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
|
||||||
t.Run("source removed", func(t *testing.T) {
|
done := make(chan error, 1)
|
||||||
requireChecksAgainOnceLocked(t, move, func(fs afero.Fs) error {
|
|
||||||
return fs.RemoveAll(testStateDir + "/vaults.d/work/secrets.d/test%secret")
|
|
||||||
}, vault.ErrSecretNotFound)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("destination vault removed", func(t *testing.T) {
|
go func() {
|
||||||
requireChecksAgainOnceLocked(t, move, func(fs afero.Fs) error {
|
done <- instance.MoveSecret(&cobra.Command{},
|
||||||
return fs.RemoveAll(testStateDir + "/vaults.d/other")
|
"default:x", "work:y", false)
|
||||||
}, vault.ErrVaultNotFound)
|
}()
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("destination vault created again from another mnemonic", func(t *testing.T) {
|
timeout := time.After(lockWait)
|
||||||
// Set by the move, which has finished once
|
|
||||||
// requireChecksAgainOnceLocked returns
|
|
||||||
var moveErr error
|
|
||||||
|
|
||||||
requireChecksAgainOnceLocked(t, func(cli *Instance) error {
|
for !waitingForLock() {
|
||||||
moveErr = move(cli)
|
select {
|
||||||
|
case err := <-done:
|
||||||
return moveErr
|
t.Fatalf("finished while the lock was held, with error %v",
|
||||||
}, func(fs afero.Fs) error {
|
err)
|
||||||
err := fs.RemoveAll(testStateDir + "/vaults.d/other")
|
case <-timeout:
|
||||||
if err != nil {
|
t.Fatal("never waited for the lock")
|
||||||
return err
|
case <-time.After(time.Millisecond):
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = vault.CreateVault(fs, testStateDir, "other", otherMnemonic, nil)
|
require.NoError(t, fs.RemoveAll(testStateDir+tc.removed))
|
||||||
|
release()
|
||||||
|
|
||||||
return err
|
select {
|
||||||
}, errVaultCreatedAgain)
|
case err := <-done:
|
||||||
|
require.ErrorIs(t, err, tc.want)
|
||||||
// secret unlocker add fails with the same error, so its message
|
case <-time.After(lockWait):
|
||||||
// must fit a move too
|
t.Fatal("did not finish once the lock was released")
|
||||||
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
|
|
||||||
// between vaults onto a secret that exists, without --force, is refused
|
|
||||||
// before either vault is unlocked, since unlocking can ask for a
|
|
||||||
// passphrase. The vaults have no unlockers and the move is given no
|
|
||||||
// mnemonic, so unlocking either vault fails.
|
|
||||||
func TestCrossVaultMoveRefusesExistingSecretBeforeUnlocking(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
createCrossVaults(t, fs)
|
|
||||||
|
|
||||||
work := vault.NewVault(fs, testStateDir, "work")
|
|
||||||
work.Mnemonic = testMnemonicBuffer(t)
|
|
||||||
|
|
||||||
err := work.AddSecret("y",
|
|
||||||
memguard.NewBufferFromBytes([]byte("other")), false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
instance := NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
||||||
|
|
||||||
err = instance.MoveSecret(&cobra.Command{}, "default:x", "work:y", false)
|
|
||||||
require.ErrorIs(t, err, vault.ErrSecretExists)
|
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-33
@@ -1084,24 +1084,21 @@ 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, and that the destination is not the source under another
|
||||||
// --force, that the destination secret does not exist
|
// name
|
||||||
_, err := cli.checkCrossVaultMove(
|
_, err := cli.checkCrossVaultMove(
|
||||||
srcVault, srcSecretName, destVault, destSecretName, force)
|
srcVault, srcSecretName, destVault, destSecretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1110,7 +1107,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 +1124,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)
|
||||||
}
|
}
|
||||||
@@ -1148,7 +1140,7 @@ func (cli *Instance) copySecretAndRemoveSource(
|
|||||||
force bool,
|
force bool,
|
||||||
) error {
|
) error {
|
||||||
srcSecretDir, err := cli.checkCrossVaultMove(
|
srcSecretDir, err := cli.checkCrossVaultMove(
|
||||||
srcVault, srcSecretName, destVault, destSecretName, force)
|
srcVault, srcSecretName, destVault, destSecretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1179,13 +1171,11 @@ 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, and that destSecretName in destVault is
|
||||||
// another name, and, unless force is set, that destSecretName does not
|
// not the source under another name. It returns the source's directory.
|
||||||
// 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,
|
||||||
force bool,
|
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
srcVaultDir, err := srcVault.GetDirectory()
|
srcVaultDir, err := srcVault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1202,6 +1192,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()
|
||||||
@@ -1223,17 +1218,6 @@ func (cli *Instance) checkCrossVaultMove(
|
|||||||
destVault.Name, destSecretName)
|
destVault.Name, destSecretName)
|
||||||
}
|
}
|
||||||
|
|
||||||
exists, err = afero.DirExists(cli.fs, destSecretDir)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to check if destination secret exists: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists && !force {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"secret '%s' %w in vault '%s' (use --force to overwrite)",
|
|
||||||
destSecretName, vault.ErrSecretExists, destVault.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
return srcSecretDir, nil
|
return srcSecretDir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user