check / check (push) Waiting to run
init and vault create put the mnemonic into the process environment for vault.CreateVault to read back, so every program they ran, gpg included, inherited it, and SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE were read at 13 places and never unset. Each command that may need them now reads both once, in its RunE, into locked buffers on the CLI Instance, and unsets them at once. The buffers are passed down: vault.CreateVault takes the mnemonic, a Vault carries Mnemonic and UnlockPassphrase, and the PGP, keychain and Secure Enclave unlocker constructors take both. Nothing below the command reads the environment. README warns against both variables. Model: opus-5-5
196 lines
5.9 KiB
Go
196 lines
5.9 KiB
Go
package cli_test
|
|
|
|
import (
|
|
"bytes"
|
|
"os"
|
|
"testing"
|
|
|
|
"git.eeqj.de/sneak/secret/internal/cli"
|
|
"git.eeqj.de/sneak/secret/internal/secret"
|
|
"git.eeqj.de/sneak/secret/internal/vault"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// TestCreateExistingVaultChangesNothing is a regression test for
|
|
// https://git.eeqj.de/sneak/secret/issues/74, where running `secret init`
|
|
// a second time, or `secret vault create` with the name of an existing
|
|
// vault, replaced that vault's keys, so that none of its secrets could be
|
|
// decrypted any more. Each must refuse, change nothing, and leave every
|
|
// vault's secret readable through its passphrase unlocker.
|
|
//
|
|
//nolint:paralleltest // the cases share cmd
|
|
func TestCreateExistingVaultChangesNothing(t *testing.T) {
|
|
mnemonic := testMnemonicBuffer(t)
|
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
t.Cleanup(passphrase.Destroy)
|
|
|
|
// newCLI returns an instance on fs given the mnemonic and the unlock
|
|
// passphrase, as from the environment
|
|
newCLI := func(fs afero.Fs) *cli.Instance {
|
|
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
c.Mnemonic = mnemonic
|
|
c.UnlockPassphrase = passphrase
|
|
|
|
return c
|
|
}
|
|
|
|
// `secret init`, `secret vault create work`, `secret vault select
|
|
// default`, and the secret "x" in each vault. "work" is then not the
|
|
// current vault, which creating it again must not change.
|
|
fs := afero.NewMemMapFs()
|
|
c := newCLI(fs)
|
|
cmd := &cobra.Command{}
|
|
|
|
require.NoError(t, c.Init(cmd))
|
|
require.NoError(t, c.CreateVault(cmd, "work"))
|
|
require.NoError(t, c.SelectVault(cmd, "default"))
|
|
|
|
vaults, err := vault.ListVaults(fs, testStateDir)
|
|
require.NoError(t, err)
|
|
require.Len(t, vaults, 2)
|
|
|
|
for _, name := range vaults {
|
|
value := memguard.NewBufferFromBytes([]byte("value"))
|
|
err := vault.NewVault(fs, testStateDir, name).AddSecret("x", value, false)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
before := snapshotStateDir(t, fs)
|
|
|
|
tests := []struct {
|
|
command string
|
|
want string
|
|
run func(c *cli.Instance) error
|
|
}{
|
|
{
|
|
"init",
|
|
"failed to create default vault: vault default already exists",
|
|
func(c *cli.Instance) error { return c.Init(cmd) },
|
|
},
|
|
{
|
|
"vault create default",
|
|
"vault default already exists",
|
|
func(c *cli.Instance) error { return c.CreateVault(cmd, "default") },
|
|
},
|
|
{
|
|
"vault create work",
|
|
"vault work already exists",
|
|
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.command, func(t *testing.T) {
|
|
fs := newFsFromSnapshot(t, before)
|
|
|
|
err := tt.run(newCLI(fs))
|
|
|
|
require.EqualError(t, err, tt.want)
|
|
require.Equal(t, before, snapshotStateDir(t, fs))
|
|
})
|
|
}
|
|
|
|
// Every case left the state directory exactly as recorded in before, so
|
|
// reading each vault's secret once from it shows that it still decrypts
|
|
// after each case. Without the mnemonic, reading a secret goes through
|
|
// the vault's passphrase unlocker, which is slow.
|
|
for _, name := range vaults {
|
|
vlt := vault.NewVault(fs, testStateDir, name)
|
|
vlt.UnlockPassphrase = passphrase
|
|
|
|
value, err := vlt.GetSecret("x")
|
|
require.NoError(t, err)
|
|
|
|
unchanged := bytes.Equal([]byte("value"), value.Bytes())
|
|
value.Destroy()
|
|
|
|
require.True(t, unchanged, "vault %q kept its secret", name)
|
|
}
|
|
}
|
|
|
|
// TestVaultCreationLeavesNoSecretInEnvironment is a regression test for
|
|
// https://git.eeqj.de/sneak/secret/issues/60, where `secret init` and
|
|
// `secret vault create` put the mnemonic into the process environment,
|
|
// which every program they ran inherited, and SB_SECRET_MNEMONIC and
|
|
// SB_UNLOCK_PASSPHRASE were never unset. Each command, given both, must
|
|
// leave neither in the environment.
|
|
func TestVaultCreationLeavesNoSecretInEnvironment(t *testing.T) {
|
|
t.Setenv(secret.EnvStateDir, t.TempDir())
|
|
|
|
run := func(args ...string) {
|
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
|
|
|
// With no terminal to prompt on, this succeeds only if the command
|
|
// read both variables
|
|
_, err := cli.ExecuteCommandInProcess(args, "", nil)
|
|
require.NoError(t, err)
|
|
|
|
for _, name := range []string{secret.EnvMnemonic, secret.EnvUnlockPassphrase} {
|
|
_, set := os.LookupEnv(name)
|
|
require.False(t, set, "%s is set after %v", name, args)
|
|
}
|
|
}
|
|
|
|
run("init")
|
|
run("vault", "create", "work")
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
//nolint:paralleltest // the cases share cmd
|
|
func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
|
|
mnemonic := testMnemonicBuffer(t)
|
|
|
|
// 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", mnemonic)
|
|
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)
|
|
|
|
// Given no unlock passphrase, both commands prompt for it, which
|
|
// fails because the tests do not run in a terminal.
|
|
c := cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir)
|
|
c.Mnemonic = mnemonic
|
|
|
|
err := tt.run(c)
|
|
|
|
require.ErrorContains(t, err, "failed to read passphrase")
|
|
require.Equal(t, before, snapshotStateDir(t, tt.fs))
|
|
})
|
|
}
|
|
}
|