Compare commits
3
Commits
3599a6fe94
...
b4b9499df6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4b9499df6 | ||
|
|
faf46d12ff | ||
|
|
6d95d96477 |
No files matched your search
@@ -150,6 +150,13 @@ Retrieves and outputs a secret value to stdout.
|
|||||||
|
|
||||||
Lists all secrets in the current vault. Optional filter for substring matching.
|
Lists all secrets in the current vault. Optional filter for substring matching.
|
||||||
|
|
||||||
|
The `LAST UPDATED` column, and `updated_at` in the `--json` output, give the
|
||||||
|
time the secret's `current` file was last written: every command that adds a
|
||||||
|
version replaces it, and so does `secret version promote`. `created_at` gives
|
||||||
|
the time the `value.age` of the secret's oldest version was written. Both are
|
||||||
|
the files' modification times, so listing asks for no passphrase. Moving a
|
||||||
|
secret to another vault keeps both times.
|
||||||
|
|
||||||
#### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
|
#### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
|
||||||
|
|
||||||
**DANGER**: Permanently removes a secret and ALL its versions. It first asks for
|
**DANGER**: Permanently removes a secret and ALL its versions. It first asks for
|
||||||
@@ -181,7 +188,9 @@ period.
|
|||||||
#### `secret version promote <secret-name> <version>`
|
#### `secret version promote <secret-name> <version>`
|
||||||
|
|
||||||
Promotes a specific version to current by rewriting the secret's `current` file
|
Promotes a specific version to current by rewriting the secret's `current` file
|
||||||
to name it. Does not modify any timestamps, allowing for rollback scenarios.
|
to name it. The versions' own times are left unchanged, allowing for rollback
|
||||||
|
scenarios; `secret list` then gives the time of the promote as the secret's last
|
||||||
|
update.
|
||||||
|
|
||||||
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
|
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
|
||||||
|
|
||||||
@@ -233,6 +242,12 @@ Creates a new unlocker of the specified type:
|
|||||||
A vault has one passphrase unlocker: adding one replaces the one the vault has,
|
A vault has one passphrase unlocker: adding one replaces the one the vault has,
|
||||||
which is removed only once the new one is the current unlocker.
|
which is removed only once the new one is the current unlocker.
|
||||||
|
|
||||||
|
`secret unlocker add passphrase` asks on the terminal for the new passphrase
|
||||||
|
even when `SB_UNLOCK_PASSPHRASE` is set, since the variable only unlocks the
|
||||||
|
vault. With no controlling terminal, as under cron or in a CI job, the new
|
||||||
|
unlocker takes its passphrase from `SB_UNLOCK_PASSPHRASE`, and the command says
|
||||||
|
so.
|
||||||
|
|
||||||
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
|
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
|
||||||
|
|
||||||
**DANGER**: Permanently removes an unlocker. It first asks for confirmation,
|
**DANGER**: Permanently removes an unlocker. It first asks for confirmation,
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// listClockStep is how long TestListShowsWhenSecretWasWritten waits
|
||||||
|
// between its steps, so that a time read in one step cannot be the
|
||||||
|
// time read in the next.
|
||||||
|
listClockStep = 200 * time.Millisecond
|
||||||
|
|
||||||
|
// fileTimeSlack allows for the clock the kernel stamps files with,
|
||||||
|
// which advances only every few milliseconds: a file's modification
|
||||||
|
// time can be slightly earlier than a time.Now() read before the file
|
||||||
|
// was written.
|
||||||
|
fileTimeSlack = 100 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestListShowsWhenSecretWasWritten is a regression test for
|
||||||
|
// https://git.eeqj.de/sneak/secret/issues/142, where `secret list` gave
|
||||||
|
// the time of the listing as every secret's creation and last-update time.
|
||||||
|
// Two listings with a clock step between must be the same, and give the
|
||||||
|
// time of the add; after a forced add, the creation time is unchanged and
|
||||||
|
// the last update is the time of that add. It runs on a real filesystem,
|
||||||
|
// where writing a file into a directory also changes the directory's time.
|
||||||
|
func TestListShowsWhenSecretWasWritten(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
|
||||||
|
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
|
||||||
|
beforeAdd := time.Now()
|
||||||
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("one")), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
afterAdd := time.Now()
|
||||||
|
|
||||||
|
firstListing := listSecrets(t, c, true)
|
||||||
|
|
||||||
|
time.Sleep(listClockStep)
|
||||||
|
require.Equal(t, firstListing, listSecrets(t, c, true))
|
||||||
|
|
||||||
|
added := listedTimes(t, firstListing)
|
||||||
|
assert.WithinRange(t, added.CreatedAt, beforeAdd.Add(-fileTimeSlack), afterAdd)
|
||||||
|
assert.WithinRange(t, added.UpdatedAt, beforeAdd.Add(-fileTimeSlack), afterAdd)
|
||||||
|
|
||||||
|
time.Sleep(listClockStep)
|
||||||
|
|
||||||
|
beforeUpdate := time.Now()
|
||||||
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("two")), true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
afterUpdate := time.Now()
|
||||||
|
|
||||||
|
updated := listedTimes(t, listSecrets(t, c, true))
|
||||||
|
assert.True(t, added.CreatedAt.Equal(updated.CreatedAt),
|
||||||
|
"created %s, then %s", added.CreatedAt, updated.CreatedAt)
|
||||||
|
assert.WithinRange(t, updated.UpdatedAt,
|
||||||
|
beforeUpdate.Add(-fileTimeSlack), afterUpdate)
|
||||||
|
|
||||||
|
// The table's LAST UPDATED column gives the same time, to the minute
|
||||||
|
assert.Contains(t, listSecrets(t, c, false),
|
||||||
|
updated.UpdatedAt.Format("2006-01-02 15:04"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMovedSecretKeepsItsTimes checks that a secret moved to another vault
|
||||||
|
// is listed there with the times it was listed with before the move, and
|
||||||
|
// not with the time of the move.
|
||||||
|
func TestMovedSecretKeepsItsTimes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
|
||||||
|
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// "default" is created last, so it is the current vault
|
||||||
|
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
|
||||||
|
// Two versions written apart, so that the creation and the last update
|
||||||
|
// are different times
|
||||||
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("one")), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(listClockStep)
|
||||||
|
|
||||||
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("two")), true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
c.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
beforeMove := listedTimes(t, listSecrets(t, c, true))
|
||||||
|
|
||||||
|
time.Sleep(listClockStep)
|
||||||
|
require.NoError(t, c.MoveSecret(&cobra.Command{}, "default:x", "other", false))
|
||||||
|
require.NoError(t, vault.SelectVault(fs, stateDir, "other"))
|
||||||
|
|
||||||
|
afterMove := listedTimes(t, listSecrets(t, c, true))
|
||||||
|
assert.True(t, beforeMove.CreatedAt.Equal(afterMove.CreatedAt),
|
||||||
|
"created %s, then %s", beforeMove.CreatedAt, afterMove.CreatedAt)
|
||||||
|
assert.True(t, beforeMove.UpdatedAt.Equal(afterMove.UpdatedAt),
|
||||||
|
"updated %s, then %s", beforeMove.UpdatedAt, afterMove.UpdatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listSecrets returns the output of `secret list`, or of
|
||||||
|
// `secret list --json` when jsonOutput is set.
|
||||||
|
func listSecrets(t *testing.T, c *cli.Instance, jsonOutput bool) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetOut(&out)
|
||||||
|
require.NoError(t, c.ListSecrets(cmd, jsonOutput, false, ""))
|
||||||
|
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// listedTimes returns the times that the output of `secret list --json`
|
||||||
|
// gives for the one secret in the vault.
|
||||||
|
func listedTimes(t *testing.T, listing string) secret.Metadata {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
Secrets []map[string]string `json:"secrets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(listing), &parsed))
|
||||||
|
require.Len(t, parsed.Secrets, 1)
|
||||||
|
|
||||||
|
createdAt, err := time.Parse(time.RFC3339Nano, parsed.Secrets[0]["created_at"])
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
updatedAt, err := time.Parse(time.RFC3339Nano, parsed.Secrets[0]["updated_at"])
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return secret.Metadata{CreatedAt: createdAt, UpdatedAt: updatedAt}
|
||||||
|
}
|
||||||
@@ -138,6 +138,134 @@ func TestInitWritesNoBlankLineAfterMnemonic(t *testing.T) {
|
|||||||
"stderr: %q", stderr.String())
|
"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
|
// 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
|
// 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
|
// its stderr: a command typed at a terminal with its input piped. pty.Open
|
||||||
|
|||||||
@@ -541,8 +541,12 @@ func printSecretsJSON(
|
|||||||
"name": secretName,
|
"name": secretName,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get metadata using GetSecretObject
|
// A secret whose times cannot be read is listed without them
|
||||||
secretObj, err := vlt.GetSecretObject(secretName)
|
secretObj, err := vlt.GetSecretObject(secretName)
|
||||||
|
if err == nil {
|
||||||
|
err = secretObj.LoadMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
metadata := secretObj.GetMetadata()
|
metadata := secretObj.GetMetadata()
|
||||||
secretInfo["created_at"] = metadata.CreatedAt
|
secretInfo["created_at"] = metadata.CreatedAt
|
||||||
@@ -618,6 +622,10 @@ func printSecretsTable(
|
|||||||
lastUpdated := "unknown"
|
lastUpdated := "unknown"
|
||||||
|
|
||||||
secretObj, err := vlt.GetSecretObject(secretName)
|
secretObj, err := vlt.GetSecretObject(secretName)
|
||||||
|
if err == nil {
|
||||||
|
err = secretObj.LoadMetadata()
|
||||||
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
metadata := secretObj.GetMetadata()
|
metadata := secretObj.GetMetadata()
|
||||||
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
|
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
|
||||||
|
|||||||
+47
-13
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"maps"
|
"maps"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -66,12 +67,13 @@ func getDefaultGPGKey() (string, error) {
|
|||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for line := range strings.SplitSeq(string(output), "\n") {
|
defaultKey, parseErr := parseDefaultGPGKey(string(output))
|
||||||
fields := strings.Split(line, ":")
|
if parseErr != nil {
|
||||||
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
|
return "", parseErr
|
||||||
// The default key is in field 10 (index 9)
|
|
||||||
return fields[9], nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if defaultKey != "" {
|
||||||
|
return defaultKey, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +100,27 @@ func getDefaultGPGKey() (string, error) {
|
|||||||
return "", errNoGPGSecretKeys
|
return "", errNoGPGSecretKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseDefaultGPGKey returns the default-key value in the output of
|
||||||
|
// `gpgconf --list-options gpg`, or "" when gpg.conf sets none. gpgconf(1)
|
||||||
|
// prints a string value with a leading `"` and percent-escaped.
|
||||||
|
func parseDefaultGPGKey(gpgconfOutput string) (string, error) {
|
||||||
|
for line := range strings.SplitSeq(gpgconfOutput, "\n") {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
|
||||||
|
// The default key is in field 10 (index 9)
|
||||||
|
keyID, err := url.PathUnescape(strings.TrimPrefix(fields[9], `"`))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode gpgconf default-key %s: %w",
|
||||||
|
fields[9], err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
func newUnlockerCmd() *cobra.Command {
|
func newUnlockerCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "unlocker",
|
Use: "unlocker",
|
||||||
@@ -417,15 +440,20 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
|
|||||||
// long-term key
|
// long-term key
|
||||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||||
|
|
||||||
// The new unlocker gets the passphrase from the environment, which also
|
// The new passphrase is asked for even when SB_UNLOCK_PASSPHRASE is
|
||||||
// unlocks the current passphrase unlocker, else the one entered here
|
// set, since that only unlocks the vault. With no terminal to ask on,
|
||||||
passphraseBuffer := cli.UnlockPassphrase
|
// the new unlocker takes the passphrase in the variable.
|
||||||
if passphraseBuffer == nil {
|
fromVariable := false
|
||||||
// Use secure passphrase input with confirmation
|
|
||||||
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||||
if err != nil {
|
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, secret.ErrNoTerminal) && cli.UnlockPassphrase != nil:
|
||||||
|
passphraseBuffer = cli.UnlockPassphrase
|
||||||
|
fromVariable = true
|
||||||
|
case err != nil:
|
||||||
return err
|
return err
|
||||||
}
|
default:
|
||||||
defer passphraseBuffer.Destroy()
|
defer passphraseBuffer.Destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,6 +464,12 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
|
|||||||
|
|
||||||
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
||||||
|
|
||||||
|
if fromVariable {
|
||||||
|
cmd.Printf("The new unlocker takes its passphrase from %s, since "+
|
||||||
|
"there is no terminal to ask for a new one on\n",
|
||||||
|
secret.EnvUnlockPassphrase)
|
||||||
|
}
|
||||||
|
|
||||||
// CreatePassphraseUnlocker has already made it the current unlocker
|
// CreatePassphraseUnlocker has already made it the current unlocker
|
||||||
cmd.Printf("Automatically selected as current unlocker\n")
|
cmd.Printf("Automatically selected as current unlocker\n")
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -83,6 +85,77 @@ func TestAddPGPUnlocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAddPGPUnlockerDefaultKey is a regression test for
|
||||||
|
// https://git.eeqj.de/sneak/secret/issues/141: with default-key set in
|
||||||
|
// gpg.conf and no --keyid, `secret unlocker add pgp` uses that key.
|
||||||
|
// gpg.conf names the key by its user ID, and the printed key ID must be that
|
||||||
|
// user ID. The first secret key, which is used when gpg.conf sets no
|
||||||
|
// default-key, prints as a hex key ID.
|
||||||
|
func TestAddPGPUnlockerDefaultKey(t *testing.T) {
|
||||||
|
newTestGPGKey(t)
|
||||||
|
// The key comes from neither --keyid nor the environment.
|
||||||
|
t.Setenv(secret.EnvGPGKeyID, "")
|
||||||
|
|
||||||
|
gpgConf := filepath.Join(os.Getenv("GNUPGHOME"), "gpg.conf")
|
||||||
|
//nolint:gosec // G703: GNUPGHOME is the directory newTestGPGKey made
|
||||||
|
require.NoError(t, os.WriteFile(gpgConf,
|
||||||
|
[]byte("default-key "+unreadableTestGPGUserID+"\n"), 0o600))
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
_, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
instance, cmd := newTestInstance(fs)
|
||||||
|
instance.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
|
||||||
|
var output bytes.Buffer
|
||||||
|
cmd.SetOut(&output)
|
||||||
|
|
||||||
|
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
|
||||||
|
assert.Contains(t, output.String(),
|
||||||
|
"Using default GPG key: "+unreadableTestGPGUserID+"\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseDefaultGPGKey is a regression test for
|
||||||
|
// https://git.eeqj.de/sneak/secret/issues/141: gpgconf prints the
|
||||||
|
// default-key value with a leading `"` and percent-escaped, and the key ID
|
||||||
|
// is that value decoded.
|
||||||
|
func TestParseDefaultGPGKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// gpgconfLine is the default-key line of `gpgconf --list-options gpg`
|
||||||
|
// up to the value, its last field.
|
||||||
|
const gpgconfLine = "default-key:0:0:use NAME as default secret key:" +
|
||||||
|
"1:1:NAME:::"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"fingerprint", `"56163D7F3FAFF709608855BEC1A360216D007F41`,
|
||||||
|
"56163D7F3FAFF709608855BEC1A360216D007F41",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"user ID with a comma", `"Doe%2c Alice <alice@example.com>`,
|
||||||
|
"Doe, Alice <alice@example.com>",
|
||||||
|
},
|
||||||
|
{"no default-key in gpg.conf", "", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
keyID, err := parseDefaultGPGKey(gpgconfLine + test.value + "\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, test.want, keyID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAddPGPUnlockerUnknownKey asserts that adding a PGP unlocker for a key
|
// TestAddPGPUnlockerUnknownKey asserts that adding a PGP unlocker for a key
|
||||||
// the keyring does not hold fails at looking up the key's fingerprint and
|
// the keyring does not hold fails at looking up the key's fingerprint and
|
||||||
// leaves no new unlocker directory. The error must come from the lookup: a
|
// leaves no new unlocker directory. The error must come from the lookup: a
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
Use: "promote <secret-name> <version>",
|
Use: "promote <secret-name> <version>",
|
||||||
Short: "Promote a specific version to current",
|
Short: "Promote a specific version to current",
|
||||||
Long: "Updates the current symlink to point to the specified " +
|
Long: "Updates the current symlink to point to the specified " +
|
||||||
"version without modifying timestamps",
|
"version. The versions' own times are left unchanged; secret " +
|
||||||
|
"list then gives the time of the promote as the secret's last " +
|
||||||
|
"update.",
|
||||||
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
||||||
ValidArgsFunction: func(
|
ValidArgsFunction: func(
|
||||||
cmd *cobra.Command, args []string, toComplete string,
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
|
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
|
||||||
errNoTerminal = errors.New("there is no terminal to ask on")
|
|
||||||
errNothingEntered = errors.New("nothing was entered")
|
errNothingEntered = errors.New("nothing was entered")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrNoTerminal is wrapped in the error of ReadPassphrase and ReadMnemonic
|
||||||
|
// when the process has no controlling terminal to ask on.
|
||||||
|
var ErrNoTerminal = errors.New("there is no terminal to ask on")
|
||||||
|
|
||||||
// ErrMnemonicNotRead is wrapped in every error of ReadMnemonic: there is no
|
// ErrMnemonicNotRead is wrapped in every error of ReadMnemonic: there is no
|
||||||
// terminal to read the mnemonic from, reading it failed, or it was empty.
|
// terminal to read the mnemonic from, reading it failed, or it was empty.
|
||||||
var ErrMnemonicNotRead = errors.New("failed to read mnemonic")
|
var ErrMnemonicNotRead = errors.New("failed to read mnemonic")
|
||||||
@@ -203,7 +206,7 @@ func readFromTerminal(
|
|||||||
|
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"%w: %w. Please set the %s environment variable or run interactively",
|
"%w: %w. Please set the %s environment variable or run interactively",
|
||||||
notRead, errNoTerminal, envVar)
|
notRead, ErrNoTerminal, envVar)
|
||||||
}
|
}
|
||||||
defer func() { _ = terminal.Close() }()
|
defer func() { _ = terminal.Close() }()
|
||||||
|
|
||||||
|
|||||||
+38
-15
@@ -2,10 +2,11 @@ package secret
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -66,31 +67,53 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
|||||||
Name: name,
|
Name: name,
|
||||||
Directory: secretDir,
|
Directory: secretDir,
|
||||||
vault: vault,
|
vault: vault,
|
||||||
Metadata: Metadata{
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
UpdatedAt: time.Now(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
// LoadMetadata sets the secret's times from the modification times of its
|
||||||
|
// files, which can be read without unlocking the vault. UpdatedAt is when
|
||||||
|
// the secret's current file was last written, by an add or a promote.
|
||||||
|
// CreatedAt is when its oldest version's value.age was written. That file
|
||||||
|
// is never rewritten; the version's directory is no use, as its time
|
||||||
|
// changes when the next add rewrites the version's metadata.age.
|
||||||
func (s *Secret) LoadMetadata() error {
|
func (s *Secret) LoadMetadata() error {
|
||||||
Debug("LoadMetadata called but is deprecated in versioned model",
|
fs := s.vault.GetFilesystem()
|
||||||
"secret_name", s.Name)
|
|
||||||
// For backward compatibility, we'll populate with basic info
|
current, err := fs.Stat(filepath.Join(s.Directory, "current"))
|
||||||
now := time.Now()
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read when secret %s was updated: %w",
|
||||||
|
s.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
versions, err := ListVersions(fs, s.Directory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(versions) == 0 {
|
||||||
|
return fmt.Errorf("secret %s has no versions: %w", s.Name, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListVersions lists the newest version first
|
||||||
|
oldest := versions[len(versions)-1]
|
||||||
|
|
||||||
|
value, err := fs.Stat(
|
||||||
|
filepath.Join(s.Directory, "versions", oldest, "value.age"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read when secret %s was created: %w",
|
||||||
|
s.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
s.Metadata = Metadata{
|
s.Metadata = Metadata{
|
||||||
CreatedAt: now,
|
CreatedAt: value.ModTime(),
|
||||||
UpdatedAt: now,
|
UpdatedAt: current.ModTime(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMetadata returns the secret metadata (deprecated)
|
// GetMetadata returns the times LoadMetadata read
|
||||||
func (s *Secret) GetMetadata() Metadata {
|
func (s *Secret) GetMetadata() Metadata {
|
||||||
Debug("GetMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
|
||||||
|
|
||||||
return s.Metadata
|
return s.Metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+44
-15
@@ -427,7 +427,8 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
|||||||
return longTermIdentity, nil
|
return longTermIdentity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
|
// GetSecretObject retrieves a Secret object from this vault. Its times are
|
||||||
|
// zero until its LoadMetadata reads them.
|
||||||
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
||||||
err := ValidateSecretName(name)
|
err := ValidateSecretName(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -454,16 +455,7 @@ func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
|||||||
return nil, fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
|
return nil, fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a Secret object
|
return secret.NewSecret(v, name), nil
|
||||||
secretObj := secret.NewSecret(v, name)
|
|
||||||
|
|
||||||
// Load the metadata from disk
|
|
||||||
err = secretObj.LoadMetadata()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return secretObj, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CopySecretVersion copies a single version from source into destSecretDir
|
// CopySecretVersion copies a single version from source into destSecretDir
|
||||||
@@ -581,8 +573,8 @@ func (v *Vault) CopySecretAllVersions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Copy each version and the current pointer, then move the copy into place
|
// Copy each version and the current pointer, then move the copy into place
|
||||||
err = v.copyVersions(srcVault, srcIdentity,
|
err = v.copyVersions(srcVault, srcIdentity, srcSecretName, srcSecretDir,
|
||||||
srcSecretName, destSecretName, destSecretDir, versions, currentVersion)
|
destSecretName, destSecretDir, versions, currentVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -801,10 +793,13 @@ func (v *Vault) createAndSaveVersion(
|
|||||||
// pointer into a temporary directory, then moves that directory to
|
// pointer into a temporary directory, then moves that directory to
|
||||||
// destSecretDir, replacing a secret already there. Nothing in this vault
|
// destSecretDir, replacing a secret already there. Nothing in this vault
|
||||||
// changes until the copy is complete, so an interrupted copy leaves only a
|
// changes until the copy is complete, so an interrupted copy leaves only a
|
||||||
// temporary directory behind.
|
// temporary directory behind. Each copied value.age and current file gets
|
||||||
|
// the modification time of the file it was copied from, so `secret list`,
|
||||||
|
// which reads a secret's times from them, lists the copy with the times of
|
||||||
|
// the source.
|
||||||
func (v *Vault) copyVersions(
|
func (v *Vault) copyVersions(
|
||||||
srcVault *Vault, srcIdentity *age.X25519Identity,
|
srcVault *Vault, srcIdentity *age.X25519Identity,
|
||||||
srcSecretName, destSecretName, destSecretDir string,
|
srcSecretName, srcSecretDir, destSecretName, destSecretDir string,
|
||||||
versions []string, currentVersion string,
|
versions []string, currentVersion string,
|
||||||
) error {
|
) error {
|
||||||
buildDir, err := secret.TempDirFor(v.fs, destSecretDir)
|
buildDir, err := secret.TempDirFor(v.fs, destSecretDir)
|
||||||
@@ -823,6 +818,14 @@ func (v *Vault) copyVersions(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
|
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
valuePath := filepath.Join("versions", versionName, "value.age")
|
||||||
|
|
||||||
|
err = copyModTime(srcVault.fs, filepath.Join(srcSecretDir, valuePath),
|
||||||
|
v.fs, filepath.Join(buildDir, valuePath))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = secret.SetCurrentVersion(v.fs, buildDir, currentVersion)
|
err = secret.SetCurrentVersion(v.fs, buildDir, currentVersion)
|
||||||
@@ -830,6 +833,12 @@ func (v *Vault) copyVersions(
|
|||||||
return fmt.Errorf("failed to set current version: %w", err)
|
return fmt.Errorf("failed to set current version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = copyModTime(srcVault.fs, filepath.Join(srcSecretDir, "current"),
|
||||||
|
v.fs, filepath.Join(buildDir, "current"))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// With --force, the secret being replaced goes only now that its
|
// With --force, the secret being replaced goes only now that its
|
||||||
// replacement is complete
|
// replacement is complete
|
||||||
exists, err := afero.DirExists(v.fs, destSecretDir)
|
exists, err := afero.DirExists(v.fs, destSecretDir)
|
||||||
@@ -854,6 +863,26 @@ func (v *Vault) copyVersions(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// copyModTime gives destPath in destFs the modification time of srcPath in
|
||||||
|
// srcFs.
|
||||||
|
func copyModTime(
|
||||||
|
srcFs afero.Fs, srcPath string, destFs afero.Fs, destPath string,
|
||||||
|
) error {
|
||||||
|
info, err := srcFs.Stat(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read modification time of %s: %w",
|
||||||
|
srcPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = destFs.Chtimes(destPath, info.ModTime(), info.ModTime())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to set modification time of %s: %w",
|
||||||
|
destPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// checkCopyDestination refuses to copy over an existing secret unless force
|
// checkCopyDestination refuses to copy over an existing secret unless force
|
||||||
// is set. A secret being replaced is removed by copyVersions, once its
|
// is set. A secret being replaced is removed by copyVersions, once its
|
||||||
// replacement is complete.
|
// replacement is complete.
|
||||||
|
|||||||
Reference in new issue
Block a user