3 Commits
Author SHA1 Message Date
sneak b4b9499df6 Ask for the new passphrase in unlocker add when the variable is set (closes #154)
check / check (push) Canceled after 0s
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 only to
unlock the vault. With no controlling terminal the new unlocker takes
the variable's passphrase, as before, 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
2026-10-14 22:47:33 +00:00
clawbot faf46d12ff secret list gives each secret's own creation and last-update times (closes #142)
check / check (push) Canceled after 0s
secret list and secret list --json gave the time of the listing as every
secret's creation and last-update time, because Secret.LoadMetadata set
both to the current time. It now reads file modification times, so
listing still needs no passphrase: the last update is when the secret's
current file was written, and the creation is when its oldest version's
value.age was written. A move to another vault gives the copied files
the times of the originals.

GetSecretObject no longer loads the times, so secret get keeps its error
for a missing current file; secret list calls LoadMetadata itself.

Judgement call: the creation time comes from value.age, not from the
oldest version's directory, whose time changes when the next add
rewrites that version's metadata.age.

Model: opus-5-5
2026-10-15 00:32:05 +02:00
clawbot 6d95d96477 Use gpg.conf's default-key when unlocker add pgp has no --keyid (closes #141)
check / check (push) Canceled after 0s
getDefaultGPGKey took the default-key value from `gpgconf --list-options
gpg` as printed. gpgconf prints a string value with a leading `"` and
percent-escaped, so gpg rejected the key ID. The parsing moved into
parseDefaultGPGKey, which drops the `"` and percent-decodes the rest with
url.PathUnescape, which leaves `+` alone. A value that does not decode is
an error, not a fallback to the first secret key.

Rule suppressed: gosec G703 on the test's write of gpg.conf into the
GNUPGHOME directory newTestGPGKey made.

Model: opus-5-5
2026-10-15 00:31:53 +02:00
10 changed files with 526 additions and 50 deletions
+16 -1
View File
@@ -150,6 +150,13 @@ Retrieves and outputs a secret value to stdout.
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` ⚠️ 🛑
**DANGER**: Permanently removes a secret and ALL its versions. It first asks for
@@ -181,7 +188,9 @@ period.
#### `secret version promote <secret-name> <version>`
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` ⚠️ 🛑
@@ -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,
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` ⚠️ 🛑
**DANGER**: Permanently removes an unlocker. It first asks for confirmation,
+161
View File
@@ -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}
}
+128
View File
@@ -138,6 +138,134 @@ func TestInitWritesNoBlankLineAfterMnemonic(t *testing.T) {
"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
+9 -1
View File
@@ -541,8 +541,12 @@ func printSecretsJSON(
"name": secretName,
}
// Try to get metadata using GetSecretObject
// A secret whose times cannot be read is listed without them
secretObj, err := vlt.GetSecretObject(secretName)
if err == nil {
err = secretObj.LoadMetadata()
}
if err == nil {
metadata := secretObj.GetMetadata()
secretInfo["created_at"] = metadata.CreatedAt
@@ -618,6 +622,10 @@ func printSecretsTable(
lastUpdated := "unknown"
secretObj, err := vlt.GetSecretObject(secretName)
if err == nil {
err = secretObj.LoadMetadata()
}
if err == nil {
metadata := secretObj.GetMetadata()
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
+49 -15
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"log"
"maps"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -66,12 +67,13 @@ func getDefaultGPGKey() (string, error) {
output, err := cmd.Output()
if err == nil {
for line := range strings.SplitSeq(string(output), "\n") {
fields := strings.Split(line, ":")
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
// The default key is in field 10 (index 9)
return fields[9], nil
}
defaultKey, parseErr := parseDefaultGPGKey(string(output))
if parseErr != nil {
return "", parseErr
}
if defaultKey != "" {
return defaultKey, nil
}
}
@@ -98,6 +100,27 @@ func getDefaultGPGKey() (string, error) {
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 {
cmd := &cobra.Command{
Use: "unlocker",
@@ -417,15 +440,20 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
// long-term key
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// The new unlocker gets the passphrase from the environment, which also
// unlocks the current passphrase unlocker, else the one entered here
passphraseBuffer := cli.UnlockPassphrase
if passphraseBuffer == nil {
// Use secure passphrase input with confirmation
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil {
return err
}
// The new passphrase is asked for even when SB_UNLOCK_PASSPHRASE is
// set, since that only unlocks the vault. With no terminal to ask on,
// the new unlocker takes the passphrase in the variable.
fromVariable := false
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
switch {
case errors.Is(err, secret.ErrNoTerminal) && cli.UnlockPassphrase != nil:
passphraseBuffer = cli.UnlockPassphrase
fromVariable = true
case err != nil:
return err
default:
defer passphraseBuffer.Destroy()
}
@@ -436,6 +464,12 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
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
cmd.Printf("Automatically selected as current unlocker\n")
+73
View File
@@ -2,6 +2,8 @@
package cli
import (
"bytes"
"os"
"path/filepath"
"strings"
"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
// 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
+3 -1
View File
@@ -65,7 +65,9 @@ func VersionCommands(cli *Instance) *cobra.Command {
Use: "promote <secret-name> <version>",
Short: "Promote a specific version to current",
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
ValidArgsFunction: func(
cmd *cobra.Command, args []string, toComplete string,
+5 -2
View File
@@ -15,10 +15,13 @@ import (
var (
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
errNoTerminal = errors.New("there is no terminal to ask on")
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
// terminal to read the mnemonic from, reading it failed, or it was empty.
var ErrMnemonicNotRead = errors.New("failed to read mnemonic")
@@ -203,7 +206,7 @@ func readFromTerminal(
return nil, fmt.Errorf(
"%w: %w. Please set the %s environment variable or run interactively",
notRead, errNoTerminal, envVar)
notRead, ErrNoTerminal, envVar)
}
defer func() { _ = terminal.Close() }()
+38 -15
View File
@@ -2,10 +2,11 @@ package secret
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
@@ -66,31 +67,53 @@ func NewSecret(vault VaultInterface, name string) *Secret {
Name: name,
Directory: secretDir,
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 {
Debug("LoadMetadata called but is deprecated in versioned model",
"secret_name", s.Name)
// For backward compatibility, we'll populate with basic info
now := time.Now()
fs := s.vault.GetFilesystem()
current, err := fs.Stat(filepath.Join(s.Directory, "current"))
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{
CreatedAt: now,
UpdatedAt: now,
CreatedAt: value.ModTime(),
UpdatedAt: current.ModTime(),
}
return nil
}
// GetMetadata returns the secret metadata (deprecated)
// GetMetadata returns the times LoadMetadata read
func (s *Secret) GetMetadata() Metadata {
Debug("GetMetadata called but is deprecated in versioned model", "secret_name", s.Name)
return s.Metadata
}
+44 -15
View File
@@ -427,7 +427,8 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
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) {
err := ValidateSecretName(name)
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)
}
// Create a Secret object
secretObj := secret.NewSecret(v, name)
// Load the metadata from disk
err = secretObj.LoadMetadata()
if err != nil {
return nil, err
}
return secretObj, nil
return secret.NewSecret(v, name), nil
}
// 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
err = v.copyVersions(srcVault, srcIdentity,
srcSecretName, destSecretName, destSecretDir, versions, currentVersion)
err = v.copyVersions(srcVault, srcIdentity, srcSecretName, srcSecretDir,
destSecretName, destSecretDir, versions, currentVersion)
if err != nil {
return err
}
@@ -801,10 +793,13 @@ func (v *Vault) createAndSaveVersion(
// pointer into a temporary directory, then moves that directory to
// destSecretDir, replacing a secret already there. Nothing in this vault
// 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(
srcVault *Vault, srcIdentity *age.X25519Identity,
srcSecretName, destSecretName, destSecretDir string,
srcSecretName, srcSecretDir, destSecretName, destSecretDir string,
versions []string, currentVersion string,
) error {
buildDir, err := secret.TempDirFor(v.fs, destSecretDir)
@@ -823,6 +818,14 @@ func (v *Vault) copyVersions(
if err != nil {
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)
@@ -830,6 +833,12 @@ func (v *Vault) copyVersions(
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
// replacement is complete
exists, err := afero.DirExists(v.fs, destSecretDir)
@@ -854,6 +863,26 @@ func (v *Vault) copyVersions(
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
// is set. A secret being replaced is removed by copyVersions, once its
// replacement is complete.