check / check (push) Waiting to run
With SB_UNLOCK_PASSPHRASE set, `secret unlocker add passphrase` gave the new unlocker the passphrase in the variable without asking. It now asks on the terminal for the new passphrase and uses the variable to unlock the vault. With no controlling terminal the new unlocker takes the variable's passphrase, and the output says so. secret.ErrNoTerminal is exported so that the command can tell a missing terminal from other failures to read, such as nothing typed or a mismatched confirmation, which still fail the command. The in-process tests that add a passphrase unlocker with the variable set rely on the Docker test phase having no controlling terminal; at a terminal they would ask. Model: opus-5-5
333 lines
9.7 KiB
Go
333 lines
9.7 KiB
Go
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()
|
|
}
|