package cli_test import ( "bufio" "bytes" "context" "io" "os" "os/exec" "strings" "syscall" "testing" "filippo.io/age" "github.com/awnumar/memguard" "github.com/creack/pty" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sneak.berlin/go/secret/internal/secret" "sneak.berlin/go/secret/internal/vault" ) // TestPassphraseAskedAtTerminalWithPipedStdin is a regression test for // https://git.eeqj.de/sneak/secret/issues/144: run at a terminal with stdin // a pipe, as in `echo v2 | secret add s1 --force`, a command that needed // the passphrase failed instead of asking for it. Each command here must ask // on the terminal and succeed once the passphrase is typed there. func TestPassphraseAskedAtTerminalWithPipedStdin(t *testing.T) { t.Parallel() stateDir := t.TempDir() passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase)) defer passphrase.Destroy() vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default", testMnemonicBuffer(t), passphrase) require.NoError(t, err) value := memguard.NewBufferFromBytes([]byte("v1")) defer value.Destroy() require.NoError(t, vlt.AddSecret("s1", value, false)) identity, err := age.GenerateX25519Identity() require.NoError(t, err) key := secret.IdentityToLockedBuffer(identity) defer key.Destroy() require.NoError(t, vlt.AddSecret("mykey", key, false)) runAtTerminal(t, stateDir, "v2\n", "add", "s1", "--force") vlt.UnlockPassphrase = passphrase added, err := vlt.GetSecret("s1") require.NoError(t, err) defer added.Destroy() assert.Equal(t, "v2", added.String()) encrypted := runAtTerminal(t, stateDir, "a document\n", "encrypt", "mykey") decrypted := runAtTerminal(t, stateDir, encrypted, "decrypt", "mykey") assert.Equal(t, "a document\n", decrypted) } // TestInitWritesNoBlankLineAfterMnemonic runs `secret init 2>log` at a // terminal: the terminal is its controlling terminal and none of stdin, // stdout and stderr. The mnemonic is typed on the terminal and the prompt // ends its line there, so stderr must not get a newline for it as well, a // blank line in the log. pty.Open is described above // TestRemoveIgnoresTerminalOnStdout. func TestInitWritesNoBlankLineAfterMnemonic(t *testing.T) { t.Parallel() ctx, cancel := context.WithTimeout(t.Context(), commandWait) defer cancel() stateDir := t.TempDir() //nolint:gosec // G204: test executes the freshly built secret binary cmd := exec.CommandContext(ctx, secretBinaryPath(t), "init") cmd.Env = []string{ secret.EnvStateDir + "=" + stateDir, secret.EnvUnlockPassphrase + "=" + testPassphrase, "PATH=" + os.Getenv("PATH"), "HOME=" + os.Getenv("HOME"), } ptmx, tty, err := pty.Open() require.NoError(t, err) defer func() { _ = ptmx.Close() }() deadline, _ := ctx.Deadline() require.NoError(t, ptmx.SetReadDeadline(deadline)) var stderr strings.Builder cmd.Stderr = &stderr cmd.ExtraFiles = []*os.File{tty} // Ctty is a descriptor in the new process: 3, the first of ExtraFiles. cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 3} require.NoError(t, cmd.Start()) _ = tty.Close() var ( shown []byte char byte ) terminal := bufio.NewReader(ptmx) for !bytes.HasSuffix(shown, []byte("Enter your BIP39 mnemonic phrase: ")) { char, err = terminal.ReadByte() require.NoError(t, err, "secret init did not ask on the terminal: %s", shown) shown = append(shown, char) } _, err = ptmx.WriteString(testMnemonic + "\n") require.NoError(t, err) // The read ends once the command has exited and so closed the terminal. rest, _ := io.ReadAll(terminal) err = cmd.Wait() require.NoError(t, err, "secret init: %s%s%s", shown, rest, stderr.String()) assert.True(t, strings.HasPrefix(stderr.String(), "Initialized secrets manager at: "+stateDir+"\n\n"+ "Default vault created and configured\n"), "stderr: %q", stderr.String()) } // TestUnlockerAddAsksForNewPassphrase is a regression test for // https://git.eeqj.de/sneak/secret/issues/154: with SB_UNLOCK_PASSPHRASE set, // `secret unlocker add passphrase` gave the new unlocker the passphrase in // the variable without asking. At a terminal it must ask for the new // passphrase there, twice, and unlock the vault with the variable, so that // the vault then opens with the passphrase typed. pty.Open is described // above TestRemoveIgnoresTerminalOnStdout. func TestUnlockerAddAsksForNewPassphrase(t *testing.T) { t.Parallel() const newPassphrase = "new-passphrase" stateDir := t.TempDir() passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase)) defer passphrase.Destroy() _, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default", testMnemonicBuffer(t), passphrase) require.NoError(t, err) ctx, cancel := context.WithTimeout(t.Context(), commandWait) defer cancel() //nolint:gosec // G204: test executes the freshly built secret binary cmd := exec.CommandContext(ctx, secretBinaryPath(t), "unlocker", "add", "passphrase") cmd.Env = []string{ secret.EnvStateDir + "=" + stateDir, secret.EnvUnlockPassphrase + "=" + testPassphrase, "PATH=" + os.Getenv("PATH"), "HOME=" + os.Getenv("HOME"), } ptmx, tty, err := pty.Open() require.NoError(t, err) defer func() { _ = ptmx.Close() }() deadline, _ := ctx.Deadline() require.NoError(t, ptmx.SetReadDeadline(deadline)) cmd.Stderr = tty // Ctty is a descriptor in the new process: 2, its stderr. cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 2} require.NoError(t, cmd.Start()) _ = tty.Close() var ( shown []byte char byte ) terminal := bufio.NewReader(ptmx) for _, prompt := range []string{ "Enter passphrase for unlocker: ", "Confirm passphrase: ", } { for !bytes.HasSuffix(shown, []byte(prompt)) { char, err = terminal.ReadByte() require.NoError(t, err, "secret unlocker add passphrase did not ask on the terminal: %s", shown) shown = append(shown, char) } _, err = ptmx.WriteString(newPassphrase + "\n") require.NoError(t, err) } // The read ends once the command has exited and so closed the terminal. rest, _ := io.ReadAll(terminal) require.NoError(t, cmd.Wait(), "secret unlocker add passphrase: %s%s", shown, rest) typed := memguard.NewBufferFromBytes([]byte(newPassphrase)) defer typed.Destroy() reopened := vault.NewVault(afero.NewOsFs(), stateDir, "default") reopened.UnlockPassphrase = typed _, err = reopened.GetOrDeriveLongTermKey() require.NoError(t, err, "the vault does not open with the passphrase typed") } // TestUnlockerAddWithoutTerminalSaysPassphraseIsVariable is a regression // test for https://git.eeqj.de/sneak/secret/issues/154: with no terminal to // ask on, `secret unlocker add passphrase` gives the new unlocker the // passphrase in SB_UNLOCK_PASSPHRASE, and its output must say so. The binary // runs in a new session, which has no terminal. func TestUnlockerAddWithoutTerminalSaysPassphraseIsVariable(t *testing.T) { t.Parallel() stateDir := t.TempDir() passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase)) defer passphrase.Destroy() _, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default", testMnemonicBuffer(t), passphrase) require.NoError(t, err) ctx, cancel := context.WithTimeout(t.Context(), commandWait) defer cancel() //nolint:gosec // G204: test executes the freshly built secret binary cmd := exec.CommandContext(ctx, secretBinaryPath(t), "unlocker", "add", "passphrase") cmd.Env = []string{ secret.EnvStateDir + "=" + stateDir, secret.EnvUnlockPassphrase + "=" + testPassphrase, "PATH=" + os.Getenv("PATH"), "HOME=" + os.Getenv("HOME"), } cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} output, err := cmd.CombinedOutput() require.NoError(t, err, "secret unlocker add passphrase: %s", output) assert.Contains(t, string(output), "The new unlocker takes its passphrase "+ "from SB_UNLOCK_PASSPHRASE, since there is no terminal to ask for a "+ "new one on\n") } // runAtTerminal runs the secret binary with args and input on stdin, in a // new session whose controlling terminal is a new terminal, which is also // its stderr: a command typed at a terminal with its input piped. pty.Open // is described above TestRemoveIgnoresTerminalOnStdout. runAtTerminal waits // for the passphrase prompt on the terminal, types the passphrase there, // and returns what the command wrote to stdout once it has succeeded. func runAtTerminal(t *testing.T, stateDir, input string, args ...string) string { t.Helper() ctx, cancel := context.WithTimeout(t.Context(), commandWait) defer cancel() //nolint:gosec // G204: test executes the freshly built secret binary cmd := exec.CommandContext(ctx, secretBinaryPath(t), args...) cmd.Env = []string{ secret.EnvStateDir + "=" + stateDir, "PATH=" + os.Getenv("PATH"), "HOME=" + os.Getenv("HOME"), } ptmx, tty, err := pty.Open() require.NoError(t, err) defer func() { _ = ptmx.Close() }() deadline, _ := ctx.Deadline() require.NoError(t, ptmx.SetReadDeadline(deadline)) var stdout strings.Builder cmd.Stdin = strings.NewReader(input) cmd.Stdout = &stdout cmd.Stderr = tty // Ctty is a descriptor in the new process: 2, its stderr. cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 2} require.NoError(t, cmd.Start()) _ = tty.Close() var ( shown []byte char byte ) terminal := bufio.NewReader(ptmx) for !bytes.HasSuffix(shown, []byte("Enter unlock passphrase: ")) { char, err = terminal.ReadByte() require.NoError(t, err, "secret %v did not ask on the terminal: %s", args, shown) shown = append(shown, char) } _, err = ptmx.WriteString(testPassphrase + "\n") require.NoError(t, err) // The read ends once the command has exited and so closed the terminal. rest, _ := io.ReadAll(terminal) require.NoError(t, cmd.Wait(), "secret %v: %s%s", args, shown, rest) return stdout.String() }