Compare commits
3
Commits
ec27d973ef
...
477dc166a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
477dc166a9 | ||
|
|
6bc95f7c69 | ||
|
|
fdfe2c92f6 |
@@ -114,7 +114,10 @@ are allowed, and a name must not be empty, `.` or `..`.
|
||||
|
||||
#### `secret vault select <name>`
|
||||
|
||||
Switches to the specified vault for subsequent operations.
|
||||
Switches to the specified vault for subsequent operations. When `currentvault`
|
||||
is empty or names a vault that does not exist, every command that uses the
|
||||
current vault fails with an error that names the file and says to run
|
||||
`secret vault select <name>`; `secret vault list` still lists the vaults.
|
||||
|
||||
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
|
||||
|
||||
@@ -142,7 +145,11 @@ value may be over 100 MiB, whatever the limit.
|
||||
|
||||
#### `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
|
||||
|
||||
@@ -155,13 +162,13 @@ contain `//`, or have `..` as a path segment.
|
||||
|
||||
#### `secret get <secret-name> [--version <version>]`
|
||||
|
||||
Retrieves and outputs a secret value to stdout. A failed write of the value,
|
||||
such as to a file on a full disk, prints the error and exits 1, with two
|
||||
exceptions. If the program reading from a pipe has already exited, as in
|
||||
`secret get s | true`, the broken-pipe signal (SIGPIPE) ends `secret` with no
|
||||
message and the shell reports exit status 141. If the shell closed stdout before
|
||||
`secret` started (`secret get s >&-`), the value is written to `/dev/null` and
|
||||
`secret get` exits 0.
|
||||
Writes the secret's value to stdout exactly as stored, with no newline added
|
||||
after it. A failed write of the value, such as to a file on a full disk, prints
|
||||
the error and exits 1, with two exceptions. If the program reading from a pipe
|
||||
has already exited, as in `secret get s | true`, the broken-pipe signal
|
||||
(SIGPIPE) ends `secret` with no message and the shell reports exit status 141.
|
||||
If the shell closed stdout before `secret` started (`secret get s >&-`), the
|
||||
value is written to `/dev/null` and `secret get` exits 0.
|
||||
|
||||
- `--version, -v`: Get a specific version (default: current)
|
||||
|
||||
@@ -300,7 +307,7 @@ Selects an unlocker as the current default for operations.
|
||||
#### `secret import <secret-name> --source <filename>`
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// TestListFailsWhenCurrentVaultDoesNotExist is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/156, where a currentvault file
|
||||
// that was empty, or named a vault that does not exist, made `secret list`
|
||||
// report an empty vault. Listing must fail with an error that names the
|
||||
// file and says to run `secret vault select`, `secret vault list` must
|
||||
// still list the vaults, and selecting a vault must make listing work
|
||||
// again.
|
||||
func TestListFailsWhenCurrentVaultDoesNotExist(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, name := range []string{"", "gone"} {
|
||||
t.Run(strconv.Quote(name), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
||||
require.NoError(t, afero.WriteFile(fs, currentVaultPath,
|
||||
[]byte(name), secret.FilePerms))
|
||||
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(&out)
|
||||
|
||||
err := c.ListSecrets(cmd, false, true, "")
|
||||
require.ErrorIs(t, err, vault.ErrVaultNotFound)
|
||||
require.ErrorContains(t, err, currentVaultPath)
|
||||
require.ErrorContains(t, err, "secret vault select")
|
||||
assert.Empty(t, out.String())
|
||||
|
||||
require.NoError(t, c.ListVaults(cmd, false))
|
||||
assert.Equal(t, "Available vaults:\n default\n work\n", out.String())
|
||||
|
||||
require.NoError(t, c.SelectVault(cmd, "default"))
|
||||
out.Reset()
|
||||
require.NoError(t, c.ListSecrets(cmd, false, true, ""))
|
||||
assert.Equal(t, "x\n", out.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -36,17 +36,11 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Create vault
|
||||
// Create vault, which selects it as the current vault
|
||||
_, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
||||
vaultPath := filepath.Join(testStateDir, "vaults.d", testVaultName)
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get vault and set up long-term key
|
||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -388,7 +388,8 @@ func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
||||
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
|
||||
@@ -418,6 +419,24 @@ func checkGPGAvailable() error {
|
||||
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
|
||||
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
||||
if data == nil {
|
||||
@@ -437,7 +456,7 @@ func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error
|
||||
|
||||
output, err := cmd.Output()
|
||||
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
|
||||
@@ -450,7 +469,7 @@ func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
||||
|
||||
output, err := cmd.Output()
|
||||
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
|
||||
|
||||
@@ -102,3 +102,27 @@ func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
|
||||
assert.NotEqual(t, first.GetDirectory(), second.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")
|
||||
}
|
||||
|
||||
@@ -55,7 +55,10 @@ func ValidateVaultName(name string) error {
|
||||
}
|
||||
|
||||
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
|
||||
// The file contains just the vault name (e.g., "default")
|
||||
// The file contains just the vault name (e.g., "default"). An empty or
|
||||
// invalid name, or the name of a vault that does not exist, is an error
|
||||
// wrapping ErrVaultNotFound that names the file and says to run
|
||||
// 'secret vault select <name>'.
|
||||
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
||||
secret.Debug("resolveVaultSymlink starting", "path", currentVaultPath)
|
||||
|
||||
@@ -74,6 +77,21 @@ func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
||||
stateDir := filepath.Dir(currentVaultPath)
|
||||
absolutePath := filepath.Join(stateDir, "vaults.d", vaultName)
|
||||
|
||||
// An empty name would make vaults.d itself the vault's directory
|
||||
exists := false
|
||||
if isValidVaultName(vaultName) {
|
||||
exists, err = afero.DirExists(fs, absolutePath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to check if vault exists: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return "", fmt.Errorf("vault '%s' named in %s %w; run "+
|
||||
"'secret vault select <name>' to select a vault",
|
||||
vaultName, currentVaultPath, ErrVaultNotFound)
|
||||
}
|
||||
|
||||
secret.Debug("Resolved to absolute path", "absolute_path", absolutePath)
|
||||
|
||||
return absolutePath, nil
|
||||
|
||||
Reference in New Issue
Block a user