// 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" "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/assert" "github.com/stretchr/testify/require" ) 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()) }