Compare commits
3
Commits
7dfa13a76f
...
596ebf0918
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
596ebf0918 | ||
|
|
6bc95f7c69 | ||
|
|
fdfe2c92f6 |
@@ -142,7 +142,11 @@ value may be over 100 MiB, whatever the limit.
|
|||||||
|
|
||||||
#### `secret add <secret-name> [--force]`
|
#### `secret add <secret-name> [--force]`
|
||||||
|
|
||||||
Adds a secret to the current vault. Reads the secret value from stdin.
|
Adds a secret to the current vault. Reads the secret value from stdin. When the
|
||||||
|
value ends in a newline, that one newline is dropped, so
|
||||||
|
`echo value | secret add name` stores `value`; from `printf 'a\n\n'` it stores
|
||||||
|
`a` and one newline. To keep a file's final newline, as in a PEM key, use
|
||||||
|
`secret import`, which stores the file's bytes unchanged.
|
||||||
|
|
||||||
- `--force, -f`: Overwrite existing secret
|
- `--force, -f`: Overwrite existing secret
|
||||||
|
|
||||||
@@ -155,13 +159,13 @@ contain `//`, or have `..` as a path segment.
|
|||||||
|
|
||||||
#### `secret get <secret-name> [--version <version>]`
|
#### `secret get <secret-name> [--version <version>]`
|
||||||
|
|
||||||
Retrieves and outputs a secret value to stdout. A failed write of the value,
|
Writes the secret's value to stdout exactly as stored, with no newline added
|
||||||
such as to a file on a full disk, prints the error and exits 1, with two
|
after it. A failed write of the value, such as to a file on a full disk, prints
|
||||||
exceptions. If the program reading from a pipe has already exited, as in
|
the error and exits 1, with two exceptions. If the program reading from a pipe
|
||||||
`secret get s | true`, the broken-pipe signal (SIGPIPE) ends `secret` with no
|
has already exited, as in `secret get s | true`, the broken-pipe signal
|
||||||
message and the shell reports exit status 141. If the shell closed stdout before
|
(SIGPIPE) ends `secret` with no message and the shell reports exit status 141.
|
||||||
`secret` started (`secret get s >&-`), the value is written to `/dev/null` and
|
If the shell closed stdout before `secret` started (`secret get s >&-`), the
|
||||||
`secret get` exits 0.
|
value is written to `/dev/null` and `secret get` exits 0.
|
||||||
|
|
||||||
- `--version, -v`: Get a specific version (default: current)
|
- `--version, -v`: Get a specific version (default: current)
|
||||||
|
|
||||||
@@ -300,7 +304,7 @@ Selects an unlocker as the current default for operations.
|
|||||||
#### `secret import <secret-name> --source <filename>`
|
#### `secret import <secret-name> --source <filename>`
|
||||||
|
|
||||||
Imports a secret from a file and stores it in the current vault under the given
|
Imports a secret from a file and stores it in the current vault under the given
|
||||||
name.
|
name. The file's bytes are stored unchanged, a final newline included.
|
||||||
|
|
||||||
### Encryption Operations
|
### Encryption Operations
|
||||||
|
|
||||||
|
|||||||
@@ -144,25 +144,20 @@ func TestVaultCreationLeavesNoSecretInEnvironment(t *testing.T) {
|
|||||||
// TestNoVaultImportCommand is a regression test for
|
// TestNoVaultImportCommand is a regression test for
|
||||||
// https://git.eeqj.de/sneak/secret/issues/139: `secret vault import`
|
// https://git.eeqj.de/sneak/secret/issues/139: `secret vault import`
|
||||||
// refused every vault that `secret vault create` makes, because each has a
|
// refused every vault that `secret vault create` makes, because each has a
|
||||||
// long-term key. The command is gone, so cobra answers it with the help of
|
// long-term key. The command is gone, so it fails as any subcommand of
|
||||||
// `secret vault`, as it does any subcommand of `secret vault` it does not
|
// `secret vault` that does not exist does.
|
||||||
// know.
|
|
||||||
func TestNoVaultImportCommand(t *testing.T) {
|
func TestNoVaultImportCommand(t *testing.T) {
|
||||||
t.Setenv(secret.EnvStateDir, t.TempDir())
|
t.Setenv(secret.EnvStateDir, t.TempDir())
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
run := func(args ...string) string {
|
_, err := cli.ExecuteCommandInProcess(
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
strings.Fields("vault create work"), "", nil)
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
require.NoError(t, err)
|
||||||
|
|
||||||
output, err := cli.ExecuteCommandInProcess(args, "", nil)
|
_, err = cli.ExecuteCommandInProcess(
|
||||||
require.NoError(t, err, "%v", args)
|
strings.Fields("vault import work"), "", nil)
|
||||||
|
require.EqualError(t, err, `unknown command "import" for "secret vault"`)
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
run("vault", "create", "work")
|
|
||||||
|
|
||||||
require.Equal(t, run("vault"), run("vault", "import", "work"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStopAtPassphrasePromptLeavesNothing is a regression test for the
|
// TestStopAtPassphrasePromptLeavesNothing is a regression test for the
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ func newGenerateCmd() *cobra.Command {
|
|||||||
Use: "generate",
|
Use: "generate",
|
||||||
Short: "Generate random data",
|
Short: "Generate random data",
|
||||||
Long: `Generate various types of random data including mnemonics and secrets.`,
|
Long: `Generate various types of random data including mnemonics and secrets.`,
|
||||||
|
// Cobra checks Args only on a command that has a RunE; without
|
||||||
|
// one, an unknown subcommand prints this help and exits 0.
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return cmd.Help()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(newGenerateMnemonicCmd())
|
cmd.AddCommand(newGenerateMnemonicCmd())
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAddDropsOneTrailingNewlineImportKeepsIt pins the rule the README
|
||||||
|
// gives: `secret add` drops one trailing newline from stdin, and
|
||||||
|
// `secret import` stores the file's bytes unchanged.
|
||||||
|
func TestAddDropsOneTrailingNewlineImportKeepsIt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs, vlt := newSizeTestVault(t)
|
||||||
|
value := []byte("a\n\n")
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetIn(bytes.NewReader(value))
|
||||||
|
|
||||||
|
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||||
|
cli.cmd = cmd
|
||||||
|
|
||||||
|
require.NoError(t, cli.AddSecret("added", false))
|
||||||
|
|
||||||
|
sourceFile := "/test/value"
|
||||||
|
require.NoError(t, afero.WriteFile(fs, sourceFile, value, 0o600))
|
||||||
|
require.NoError(t, cli.ImportSecret(cmd, "imported", sourceFile, false))
|
||||||
|
|
||||||
|
added, err := vlt.GetSecret("added")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer added.Destroy()
|
||||||
|
|
||||||
|
imported, err := vlt.GetSecret("imported")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer imported.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("a\n"), added.Bytes())
|
||||||
|
assert.Equal(t, []byte("a\n\n"), imported.Bytes())
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/secret/internal/secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUnknownSubcommandFails is a regression test for
|
||||||
|
// https://git.eeqj.de/sneak/secret/issues/183: given a subcommand it does not
|
||||||
|
// have, `secret vault`, `secret unlocker`, `secret version` and `secret
|
||||||
|
// generate` printed their help and exited 0, so a script could not tell a
|
||||||
|
// mistyped command from a successful one. Each runs here as the built binary
|
||||||
|
// and must exit 1 naming the subcommand, as an unknown command does at the
|
||||||
|
// top level, and must still print its help and exit 0 when given nothing.
|
||||||
|
func TestUnknownSubcommandFails(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
|
||||||
|
// run runs the built binary with args and returns what it printed and
|
||||||
|
// its exit code
|
||||||
|
run := func(args ...string) (string, int) {
|
||||||
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||||
|
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), args...)
|
||||||
|
cmd.Env = []string{secret.EnvStateDir + "=" + stateDir}
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
require.ErrorAs(t, err, &exitErr, "secret %v did not run", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(output), cmd.ProcessState.ExitCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, call := range []string{
|
||||||
|
"vault improt", "unlocker foo", "version foo", "generate foo",
|
||||||
|
} {
|
||||||
|
parent, unknown, _ := strings.Cut(call, " ")
|
||||||
|
|
||||||
|
output, exitCode := run(parent, unknown)
|
||||||
|
assert.Equal(t, 1, exitCode, "secret %s should fail:\n%s", call, output)
|
||||||
|
assert.Contains(t, output, fmt.Sprintf(
|
||||||
|
"unknown command %q for \"secret %s\"", unknown, parent))
|
||||||
|
|
||||||
|
output, exitCode = run(parent)
|
||||||
|
assert.Equal(t, 0, exitCode,
|
||||||
|
"secret %s should succeed:\n%s", parent, output)
|
||||||
|
assert.Contains(t, output, usageHeading,
|
||||||
|
"secret %s should print its help", parent)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,12 @@ func newUnlockerCmd() *cobra.Command {
|
|||||||
Use: "unlocker",
|
Use: "unlocker",
|
||||||
Short: "Manage unlockers",
|
Short: "Manage unlockers",
|
||||||
Long: `Create, list, and remove unlockers for the current vault.`,
|
Long: `Create, list, and remove unlockers for the current vault.`,
|
||||||
|
// Cobra checks Args only on a command that has a RunE; without
|
||||||
|
// one, an unknown subcommand prints this help and exits 0.
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return cmd.Help()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(newUnlockerListCmd())
|
cmd.AddCommand(newUnlockerListCmd())
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ func newVaultCmd() *cobra.Command {
|
|||||||
Use: "vault",
|
Use: "vault",
|
||||||
Short: "Manage vaults",
|
Short: "Manage vaults",
|
||||||
Long: `Create, list, and select vaults for organizing secrets.`,
|
Long: `Create, list, and select vaults for organizing secrets.`,
|
||||||
|
// Cobra checks Args only on a command that has a RunE; without
|
||||||
|
// one, an unknown subcommand prints this help and exits 0.
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return cmd.Help()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.AddCommand(newVaultListCmd())
|
cmd.AddCommand(newVaultListCmd())
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
Short: "Manage secret versions",
|
Short: "Manage secret versions",
|
||||||
Long: "Commands for managing secret versions including listing, " +
|
Long: "Commands for managing secret versions including listing, " +
|
||||||
"promoting, and retrieving specific versions",
|
"promoting, and retrieving specific versions",
|
||||||
|
// Cobra checks Args only on a command that has a RunE; without
|
||||||
|
// one, an unknown subcommand prints this help and exits 0.
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return cmd.Help()
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// List versions command
|
// List versions command
|
||||||
|
|||||||
@@ -388,7 +388,8 @@ func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
|||||||
return "", fmt.Errorf("%w: %s", ErrGPGKeyNotFound, keyID)
|
return "", fmt.Errorf("%w: %s", ErrGPGKeyNotFound, keyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
return "", fmt.Errorf(
|
||||||
|
"failed to resolve GPG key fingerprint: %w", withGPGStderr(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the output to extract the fingerprint
|
// Parse the output to extract the fingerprint
|
||||||
@@ -418,6 +419,24 @@ func checkGPGAvailable() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withGPGStderr appends gpg's stderr, which says why gpg failed, to err
|
||||||
|
// from cmd.Output. cmd.Output keeps that stderr in the *exec.ExitError.
|
||||||
|
// err is returned unchanged when gpg wrote nothing to stderr.
|
||||||
|
func withGPGStderr(err error) error {
|
||||||
|
var exitErr *exec.ExitError
|
||||||
|
|
||||||
|
if !errors.As(err, &exitErr) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr := strings.TrimSpace(string(exitErr.Stderr))
|
||||||
|
if stderr == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%w: %s", err, stderr)
|
||||||
|
}
|
||||||
|
|
||||||
// gpgEncryptDefault is the default implementation of GPG encryption
|
// gpgEncryptDefault is the default implementation of GPG encryption
|
||||||
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
@@ -437,7 +456,7 @@ func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error
|
|||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("GPG encryption failed: %w", err)
|
return nil, fmt.Errorf("GPG encryption failed: %w", withGPGStderr(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return output, nil
|
return output, nil
|
||||||
@@ -450,7 +469,7 @@ func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
|||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("GPG decryption failed: %w", err)
|
return nil, fmt.Errorf("GPG decryption failed: %w", withGPGStderr(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a secure buffer for the decrypted data
|
// Create a secure buffer for the decrypted data
|
||||||
|
|||||||
@@ -102,3 +102,27 @@ func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
|
|||||||
assert.NotEqual(t, first.GetDirectory(), second.GetDirectory())
|
assert.NotEqual(t, first.GetDirectory(), second.GetDirectory())
|
||||||
assert.Equal(t, firstFiles, dirFiles(t, fs, first.GetDirectory()))
|
assert.Equal(t, firstFiles, dirFiles(t, fs, first.GetDirectory()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGPGDecryptErrorHasGPGReason runs the real gpg on data that is not an
|
||||||
|
// OpenPGP message and checks that the error carries the reason gpg writes
|
||||||
|
// to stderr.
|
||||||
|
func TestGPGDecryptErrorHasGPGReason(t *testing.T) {
|
||||||
|
// The empty GNUPGHOME keeps gpg away from the user's own keyring;
|
||||||
|
// LC_ALL=C keeps gpg's message in English.
|
||||||
|
t.Setenv("GNUPGHOME", t.TempDir())
|
||||||
|
t.Setenv("LC_ALL", "C")
|
||||||
|
|
||||||
|
_, err := secret.GPGDecryptFunc([]byte("not an OpenPGP message"))
|
||||||
|
require.ErrorContains(t, err, "gpg: no valid OpenPGP data found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGPGDecryptErrorWithoutGPGStderr checks that when gpg fails without
|
||||||
|
// writing to stderr, the error ends with the exit status and nothing after it.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // installFakeGPG uses t.Setenv
|
||||||
|
func TestGPGDecryptErrorWithoutGPGStderr(t *testing.T) {
|
||||||
|
installFakeGPG(t)
|
||||||
|
|
||||||
|
_, err := secret.GPGDecryptFunc([]byte("not an OpenPGP message"))
|
||||||
|
require.EqualError(t, err, "GPG decryption failed: exit status 1")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user