check / check (push) Failing after 2s
REPO_POLICIES.md requires the module root sneak.berlin/go/<name>. go.mod, every import (rewritten with gofmt -r), the -X flags in script/build and the examples in the pkg READMEs now use the new path. go mod tidy lists go-humanize and fatih/color as direct requirements, since internal/cli imports them. This breaks anyone who fetched or imported git.eeqj.de/sneak/secret: they must switch to sneak.berlin/go/secret, which resolves to this repository. Model: opus-5-5
375 lines
12 KiB
Go
375 lines
12 KiB
Go
// Unlock Failure Tests
|
|
//
|
|
// When a vault cannot be opened through its current unlocker, because a
|
|
// file the unlocker needs is missing or the passphrase is wrong, the error
|
|
// keeps its cause and ends by saying that the mnemonic still opens that
|
|
// vault, but only for a vault that the mnemonic does open, and not when the
|
|
// passphrase could not be read at all. When a secret's current file is
|
|
// missing, the error says how to make a version current again. Each test
|
|
// that pins such advice also follows it.
|
|
|
|
package cli_test
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"filippo.io/age"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"sneak.berlin/go/secret/internal/cli"
|
|
"sneak.berlin/go/secret/internal/secret"
|
|
"sneak.berlin/go/secret/internal/vault"
|
|
)
|
|
|
|
const (
|
|
// mnemonicAdvice ends the error when the current vault "default", which
|
|
// its mnemonic opens, cannot be opened through its current unlocker.
|
|
mnemonicAdvice = "; the vault 'default' still opens with its mnemonic: " +
|
|
"run 'secret unlocker add passphrase' with SB_SECRET_MNEMONIC set " +
|
|
"to the mnemonic to give it a new unlocker"
|
|
|
|
// versionAdvice ends the error when a secret's current file cannot be
|
|
// read.
|
|
versionAdvice = "; this file only names the current version: " +
|
|
"'secret version list' lists the secret's versions, and " +
|
|
"'secret version promote' makes one of them current"
|
|
|
|
// unlockTestVaultDir is the directory of the vault "default" of
|
|
// newTwoVaultFs, the current vault, whose secret "x" is "value".
|
|
unlockTestVaultDir = testStateDir + "/vaults.d/default"
|
|
)
|
|
|
|
// currentUnlockerDir returns the directory of the current unlocker of the
|
|
// vault in vaultDir on fs.
|
|
func currentUnlockerDir(t *testing.T, fs afero.Fs, vaultDir string) string {
|
|
t.Helper()
|
|
|
|
unlockerName, err := afero.ReadFile(fs,
|
|
filepath.Join(vaultDir, "current-unlocker"))
|
|
require.NoError(t, err)
|
|
|
|
return filepath.Join(vaultDir, "unlockers.d", string(unlockerName))
|
|
}
|
|
|
|
// newUnlockTestCLI returns the directory of the current unlocker of the
|
|
// vault "default" on fs, a copy of the vaults of newTwoVaultFs, and a CLI
|
|
// instance on fs that has the unlock passphrase, as from the environment,
|
|
// but not the mnemonic.
|
|
func newUnlockTestCLI(t *testing.T, fs afero.Fs) (string, *cli.Instance) {
|
|
t.Helper()
|
|
|
|
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
t.Cleanup(c.UnlockPassphrase.Destroy)
|
|
|
|
return currentUnlockerDir(t, fs, unlockTestVaultDir), c
|
|
}
|
|
|
|
// discardCmd returns a command whose output is discarded.
|
|
func discardCmd() *cobra.Command {
|
|
cmd := &cobra.Command{}
|
|
cmd.SetOut(io.Discard)
|
|
|
|
return cmd
|
|
}
|
|
|
|
// getSecret returns what `secret get name` prints.
|
|
func getSecret(t *testing.T, c *cli.Instance, name string) string {
|
|
t.Helper()
|
|
|
|
var out bytes.Buffer
|
|
|
|
cmd := &cobra.Command{}
|
|
cmd.SetOut(&out)
|
|
require.NoError(t, c.GetSecret(cmd, name))
|
|
|
|
return out.String()
|
|
}
|
|
|
|
// TestUnlockFailureNamesMnemonic checks the error of `secret get` when a
|
|
// file that opening the vault through its current unlocker needs is
|
|
// missing: it keeps the cause, which names the file, and ends with the
|
|
// advice that the mnemonic still opens the vault. The test then follows
|
|
// that advice: `secret unlocker add passphrase`, with the mnemonic, gives
|
|
// the vault a new unlocker, which opens it.
|
|
func TestUnlockFailureNamesMnemonic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
file string // the file removed
|
|
inVaultDir bool // the file is the vault's, not the unlocker's
|
|
want string // the message before the cause
|
|
}{
|
|
{
|
|
file: "current-unlocker",
|
|
inVaultDir: true,
|
|
want: "failed to unlock vault: failed to get long-term key: " +
|
|
"failed to get current unlocker: " +
|
|
"failed to read current unlocker: ",
|
|
},
|
|
{
|
|
file: "priv.age",
|
|
want: "failed to unlock vault: failed to get long-term key: " +
|
|
"failed to get unlocker identity: " +
|
|
"failed to read unlocker private key: ",
|
|
},
|
|
{
|
|
file: "longterm.age",
|
|
want: "failed to unlock vault: failed to get long-term key: " +
|
|
"failed to read encrypted long-term private key: ",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.file, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := newTwoVaultFs(t)
|
|
unlockerDir, c := newUnlockTestCLI(t, fs)
|
|
|
|
path := filepath.Join(unlockerDir, tt.file)
|
|
|
|
if tt.inVaultDir {
|
|
path = filepath.Join(unlockTestVaultDir, tt.file)
|
|
}
|
|
|
|
require.NoError(t, fs.Remove(path))
|
|
|
|
err := c.GetSecret(discardCmd(), "x")
|
|
|
|
var cause *os.PathError
|
|
|
|
require.ErrorAs(t, err, &cause)
|
|
require.ErrorIs(t, err, os.ErrNotExist)
|
|
assert.Equal(t, path, cause.Path)
|
|
|
|
require.EqualError(t, err, tt.want+cause.Error()+mnemonicAdvice)
|
|
|
|
c.Mnemonic = testMnemonicBuffer(t)
|
|
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
|
|
|
|
c.Mnemonic = nil
|
|
assert.Equal(t, "value", getSecret(t, c, "x"))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWrongPassphraseNamesMnemonic checks the error of `secret get` given a
|
|
// passphrase that does not decrypt the passphrase unlocker: it keeps age's
|
|
// error and ends with the advice that the mnemonic still opens the vault.
|
|
func TestWrongPassphraseNamesMnemonic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
_, c := newUnlockTestCLI(t, newTwoVaultFs(t))
|
|
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase"))
|
|
t.Cleanup(c.UnlockPassphrase.Destroy)
|
|
|
|
err := c.GetSecret(discardCmd(), "x")
|
|
|
|
var noMatch *age.NoIdentityMatchError
|
|
|
|
require.ErrorAs(t, err, &noMatch)
|
|
|
|
require.EqualError(t, err, "failed to unlock vault: "+
|
|
"failed to get long-term key: failed to get unlocker identity: "+
|
|
"failed to decrypt unlocker private key: failed to create decryptor: "+
|
|
noMatch.Error()+mnemonicAdvice)
|
|
}
|
|
|
|
// TestMoveUnlockFailureNamesVault checks the error of `secret move` into
|
|
// the vault "work", which is not the current vault, when "work" cannot be
|
|
// opened through its current unlocker: the advice names "work" and says to
|
|
// select it first, since `secret unlocker add` acts on the current vault.
|
|
// The test then follows that advice, and the move succeeds.
|
|
func TestMoveUnlockFailureNamesVault(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := newTwoVaultFs(t)
|
|
_, c := newUnlockTestCLI(t, fs)
|
|
|
|
path := filepath.Join(
|
|
currentUnlockerDir(t, fs, testStateDir+"/vaults.d/work"), "priv.age")
|
|
require.NoError(t, fs.Remove(path))
|
|
|
|
err := c.MoveSecret(discardCmd(), "default:x", "work:y", false)
|
|
|
|
var cause *os.PathError
|
|
|
|
require.ErrorAs(t, err, &cause)
|
|
assert.Equal(t, path, cause.Path)
|
|
|
|
require.EqualError(t, err, "failed to unlock destination vault 'work': "+
|
|
"failed to get unlocker identity: failed to read unlocker private key: "+
|
|
cause.Error()+"; the vault 'work' still opens with its mnemonic: "+
|
|
"run 'secret vault select work', then 'secret unlocker add passphrase' "+
|
|
"with SB_SECRET_MNEMONIC set to the mnemonic to give it a new unlocker")
|
|
|
|
require.NoError(t, c.SelectVault(discardCmd(), "work"))
|
|
|
|
c.Mnemonic = testMnemonicBuffer(t)
|
|
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
|
|
|
|
c.Mnemonic = nil
|
|
require.NoError(t, c.MoveSecret(discardCmd(), "default:x", "work:y", false))
|
|
assert.Equal(t, "value", getSecret(t, c, "y"))
|
|
}
|
|
|
|
// TestPassphraseNotReadNamesNoMnemonic runs `secret get x` on the built
|
|
// binary without SB_UNLOCK_PASSPHRASE and with a stdin that is not a
|
|
// terminal, so the passphrase cannot be read. The unlocker was not tried,
|
|
// and adding one would need a passphrase read the same way, so the error
|
|
// is the cause alone, without the advice to use the mnemonic.
|
|
func TestPassphraseNotReadNamesNoMnemonic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
stateDir := t.TempDir()
|
|
|
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
|
defer mnemonic.Destroy()
|
|
|
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
defer passphrase.Destroy()
|
|
|
|
vlt, err := vault.CreateVault(
|
|
afero.NewOsFs(), stateDir, "default", mnemonic, passphrase)
|
|
require.NoError(t, err)
|
|
|
|
value := memguard.NewBufferFromBytes([]byte("value"))
|
|
defer value.Destroy()
|
|
|
|
require.NoError(t, vlt.AddSecret("x", value, false))
|
|
|
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
|
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), "get", "x")
|
|
cmd.Env = []string{
|
|
secret.EnvStateDir + "=" + stateDir,
|
|
"PATH=" + os.Getenv("PATH"),
|
|
"HOME=" + os.Getenv("HOME"),
|
|
}
|
|
|
|
output, err := cmd.CombinedOutput()
|
|
require.Error(t, err)
|
|
|
|
assert.Equal(t, "Error: failed to unlock vault: "+
|
|
"failed to get long-term key: failed to get unlocker identity: "+
|
|
"failed to read passphrase: stdin is not a terminal (piped input or "+
|
|
"script). Please set the SB_UNLOCK_PASSPHRASE environment variable or "+
|
|
"run interactively\n", string(output))
|
|
}
|
|
|
|
// TestCryptoUnlockFailureNamesMnemonic checks that `secret encrypt` and
|
|
// `secret decrypt`, reading the key secret, end with the same advice as
|
|
// `secret get` when the vault cannot be opened through its current
|
|
// unlocker.
|
|
func TestCryptoUnlockFailureNamesMnemonic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
command string
|
|
run func(c *cli.Instance) error
|
|
}{
|
|
{"encrypt", func(c *cli.Instance) error { return c.Encrypt("x", "", "") }},
|
|
{"decrypt", func(c *cli.Instance) error { return c.Decrypt("x", "", "") }},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.command, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := newTwoVaultFs(t)
|
|
unlockerDir, c := newUnlockTestCLI(t, fs)
|
|
|
|
path := filepath.Join(unlockerDir, "priv.age")
|
|
require.NoError(t, fs.Remove(path))
|
|
|
|
err := tt.run(c)
|
|
|
|
var cause *os.PathError
|
|
|
|
require.ErrorAs(t, err, &cause)
|
|
assert.Equal(t, path, cause.Path)
|
|
|
|
require.EqualError(t, err, "failed to get secret value: "+
|
|
"failed to unlock vault: failed to get long-term key: "+
|
|
"failed to get unlocker identity: "+
|
|
"failed to read unlocker private key: "+cause.Error()+
|
|
mnemonicAdvice)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestMissingCurrentFileNamesVersionCommands checks the error of `secret
|
|
// get` when the secret's current file is missing: it keeps the cause, which
|
|
// names the file, and ends with the advice that says how to make a version
|
|
// current again. The test then follows that advice.
|
|
func TestMissingCurrentFileNamesVersionCommands(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := newTwoVaultFs(t)
|
|
_, c := newUnlockTestCLI(t, fs)
|
|
|
|
secretDir := filepath.Join(unlockTestVaultDir, "secrets.d", "x")
|
|
path := filepath.Join(secretDir, "current")
|
|
require.NoError(t, fs.Remove(path))
|
|
|
|
err := c.GetSecret(discardCmd(), "x")
|
|
|
|
var cause *os.PathError
|
|
|
|
require.ErrorAs(t, err, &cause)
|
|
require.ErrorIs(t, err, os.ErrNotExist)
|
|
assert.Equal(t, path, cause.Path)
|
|
|
|
require.EqualError(t, err, "failed to get current version: "+
|
|
"failed to read current version file: "+cause.Error()+versionAdvice)
|
|
|
|
versions, err := afero.ReadDir(fs, filepath.Join(secretDir, "versions"))
|
|
require.NoError(t, err)
|
|
require.Len(t, versions, 1)
|
|
|
|
var out bytes.Buffer
|
|
|
|
cmd := &cobra.Command{}
|
|
cmd.SetOut(&out)
|
|
require.NoError(t, c.ListVersions(cmd, "x"))
|
|
assert.Contains(t, out.String(), versions[0].Name())
|
|
|
|
require.NoError(t, c.PromoteVersion(cmd, "x", versions[0].Name()))
|
|
assert.Equal(t, "value", getSecret(t, c, "x"))
|
|
}
|
|
|
|
// TestUnlockFailureWithoutLongTermKeyNamesNoMnemonic checks that a vault
|
|
// created without a mnemonic, which no mnemonic opens, gets no advice to
|
|
// use one: `secret unlocker add passphrase` there fails with the cause
|
|
// alone.
|
|
func TestUnlockFailureWithoutLongTermKeyNamesNoMnemonic(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fs := afero.NewMemMapFs()
|
|
|
|
_, err := vault.CreateVault(fs, testStateDir, "keyless", nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
|
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
|
|
t.Cleanup(c.UnlockPassphrase.Destroy)
|
|
|
|
err = c.UnlockersAdd("passphrase", discardCmd())
|
|
|
|
var cause *os.PathError
|
|
|
|
require.ErrorAs(t, err, &cause)
|
|
|
|
require.EqualError(t, err, "failed to get long-term key: "+
|
|
"failed to get current unlocker: failed to read current unlocker: "+
|
|
cause.Error())
|
|
}
|