2 Commits
Author SHA1 Message Date
clawbot 759dbb1b1d 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.

GetSecretObject no longer loads the times, so secret get reads no more
files than before and 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-14 19:25:22 +00:00
clawbot 5371382cb4 A failed secret add --force leaves the secret unchanged (closes #136)
check / check (push) Canceled after 0s
addVersion wrote the new version before it unlocked the vault to set
notAfter on the version being replaced, so a wrong passphrase or mnemonic
left an extra version behind. It now unlocks the vault and loads that
version's metadata first; writing notAfter and switching current still
come after the new version is saved. generate secret --force and
import --force take the same path.

The time set as the new version's notBefore and the replaced version's
notAfter is taken after the unlock, which can wait for the passphrase.

updatePreviousVersion asks for the long-term key again. The vault keeps
it in memory after the first unlock, so this neither prompts nor derives
it a second time.

Model: opus-5-5
2026-10-14 20:48:48 +02:00
8 changed files with 372 additions and 39 deletions
+6
View File
@@ -150,6 +150,12 @@ 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.
#### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
**DANGER**: Permanently removes a secret and ALL its versions. It first asks for
+17
View File
@@ -18,6 +18,23 @@ https://git.eeqj.de/sneak/secret/milestone/12
# Completed Steps
- 2026-10-14: `secret list` gives each secret's own creation and last-update
times (https://git.eeqj.de/sneak/secret/issues/142). It gave the time of the
listing for both, which `Secret.LoadMetadata` set to the current time.
`LoadMetadata` 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. That
version's directory would be wrong, because the next add rewrites the
version's `metadata.age` in it. `GetSecretObject`, which `secret get` also
uses, no longer loads the times; `secret list` loads them itself.
- 2026-10-14: A `secret add --force` that cannot unlock the vault, given a wrong
passphrase or mnemonic, leaves the secret as it was
(https://git.eeqj.de/sneak/secret/issues/136). The new version used to be
written before the vault was unlocked to set `notAfter` on the version it
replaces, so each failed add left one more version that `secret version list`
showed. The vault is now unlocked, and that version's metadata read, before
anything is written. `generate secret --force` and `import --force` take the
same path.
- 2026-10-12: A mnemonic typed with extra spaces, tabs or line breaks gives the
same long-term key as its words separated by single spaces
(https://git.eeqj.de/sneak/secret/issues/135). The key was derived from the
+118
View File
@@ -0,0 +1,118 @@
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"))
}
// 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}
}
+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")
+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
}
+42 -23
View File
@@ -219,6 +219,15 @@ func (v *Vault) addVersion(
name, secretDir string, value *memguard.LockedBuffer,
previousVersion *secret.Version,
) error {
// Unlock the vault before writing anything, so that a wrong passphrase
// or mnemonic leaves the secret as it was
err := v.loadPreviousVersionMetadata(previousVersion)
if err != nil {
return err
}
// Taken after the unlock, which can wait for the user to type the
// passphrase, so that the new version starts when it is created
now := time.Now()
// Create the new version and save the encrypted value
@@ -418,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 {
@@ -445,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
@@ -632,8 +633,35 @@ func (v *Vault) checkExistingSecret(
return true, previousVersion, nil
}
// loadPreviousVersionMetadata unlocks the vault and loads the metadata of
// the version being superseded. It is a no-op when previousVersion is nil.
func (v *Vault) loadPreviousVersionMetadata(
previousVersion *secret.Version,
) error {
if previousVersion == nil {
return nil
}
ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil {
secret.Debug("Failed to get long-term key for previous version", "error", err)
return fmt.Errorf("failed to get long-term key: %w", err)
}
err = previousVersion.LoadMetadata(ltIdentity)
if err != nil {
secret.Debug("Failed to load previous version metadata", "error", err)
return fmt.Errorf("failed to load previous version metadata: %w", err)
}
return nil
}
// updatePreviousVersion sets the notAfter timestamp on the version being
// superseded. It is a no-op when previousVersion is nil.
// superseded, whose metadata loadPreviousVersionMetadata has loaded. It is
// a no-op when previousVersion is nil.
func (v *Vault) updatePreviousVersion(
previousVersion *secret.Version, now *time.Time,
) error {
@@ -641,22 +669,13 @@ func (v *Vault) updatePreviousVersion(
return nil
}
// Get long-term key to decrypt/encrypt metadata
// loadPreviousVersionMetadata unlocked the vault, so this returns the
// key it keeps in memory
ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil {
secret.Debug("Failed to get long-term key for metadata update", "error", err)
return fmt.Errorf("failed to get long-term key: %w", err)
}
// Load previous version metadata
err = previousVersion.LoadMetadata(ltIdentity)
if err != nil {
secret.Debug("Failed to load previous version metadata", "error", err)
return fmt.Errorf("failed to load previous version metadata: %w", err)
}
// Update notAfter timestamp
previousVersion.Metadata.NotAfter = now
+66
View File
@@ -10,6 +10,8 @@
// version
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
// across versions
// - TestVaultVersionTimestampsAfterUnlock: Tests that those timestamps are
// taken after AddSecret unlocks the vault
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
// - TestUpdateVersionMetadata: Tests metadata update functionality
//
@@ -295,6 +297,70 @@ func TestVaultVersionTimestamps(t *testing.T) {
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
}
// longTermKeyReadFs records when longterm.age, the last file an unlock
// reads, was last opened.
type longTermKeyReadFs struct {
afero.Fs
readAt time.Time
}
//nolint:ireturn // afero.File is the interface required by afero.Fs
func (f *longTermKeyReadFs) Open(name string) (afero.File, error) {
if filepath.Base(name) == "longterm.age" {
f.readAt = time.Now()
}
return f.Fs.Open(name)
}
// TestVaultVersionTimestampsAfterUnlock checks that when AddSecret has to
// unlock the vault to replace a version, the new version's notBefore and the
// replaced version's notAfter are taken after the unlock, which can wait for
// the user to type the passphrase.
func TestVaultVersionTimestampsAfterUnlock(t *testing.T) {
t.Parallel()
fs := &longTermKeyReadFs{Fs: afero.NewMemMapFs()}
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
passphrase := memguard.NewBufferFromBytes([]byte("test-passphrase"))
defer passphrase.Destroy()
created, err := CreateVault(fs, testStateDir, "test", mnemonic, passphrase)
require.NoError(t, err)
addTestSecretToVault(t, created, testSecretPath, []byte("version-1"), false)
// A new instance is locked; AddSecret unlocks it with the passphrase
vault := NewVault(fs, testStateDir, "test")
vault.SetUnlockPassphrase(passphrase)
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
require.False(t, fs.readAt.IsZero(), "AddSecret did not unlock the vault")
ltIdentity, err := vault.GetOrDeriveLongTermKey()
require.NoError(t, err)
vaultDir, _ := vault.GetDirectory()
secretDir := filepath.Join(vaultDir, "secrets.d", "test%secret")
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 2)
newVersion := secret.NewVersion(vault, testSecretPath, versions[0])
require.NoError(t, newVersion.LoadMetadata(ltIdentity))
replacedVersion := secret.NewVersion(vault, testSecretPath, versions[1])
require.NoError(t, replacedVersion.LoadMetadata(ltIdentity))
assert.False(t, newVersion.Metadata.NotBefore.Before(fs.readAt))
assert.False(t, replacedVersion.Metadata.NotAfter.Before(fs.readAt))
}
func TestVaultGetNonExistentVersion(t *testing.T) {
t.Parallel()
+76
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
@@ -103,3 +104,78 @@ func TestAddSecretCleansUpOnFailure(t *testing.T) {
require.Len(t, entries, 1)
assert.Equal(t, "pub.age", entries[0].Name())
}
// TestAddSecretForceWithWrongPassphraseChangesNothing checks that adding a
// version to an existing secret, with a passphrase that does not open the
// vault, fails and leaves the secret's directory as it was.
func TestAddSecretForceWithWrongPassphraseChangesNothing(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
created, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), passphrase)
require.NoError(t, err)
first := memguard.NewBufferFromBytes([]byte("first"))
t.Cleanup(first.Destroy)
require.NoError(t, created.AddSecret(testSecretName, first, false))
wrong := memguard.NewBufferFromBytes([]byte("wrong passphrase"))
t.Cleanup(wrong.Destroy)
vlt := vault.NewVault(fs, testStateDir, testVaultName)
vlt.SetUnlockPassphrase(wrong)
secretDir := filepath.Join(testStateDir, "vaults.d", testVaultName,
"secrets.d", testSecretName)
before := dirContents(t, fs, secretDir)
second := memguard.NewBufferFromBytes([]byte("second"))
t.Cleanup(second.Destroy)
err = vlt.AddSecret(testSecretName, second, true)
var noMatch *age.NoIdentityMatchError
require.ErrorAs(t, err, &noMatch)
assert.Equal(t, before, dirContents(t, fs, secretDir))
}
// dirContents maps every file under dir to its contents, and every
// directory, written with a trailing "/", to "".
func dirContents(t *testing.T, fs afero.Fs, dir string) map[string]string {
t.Helper()
contents := map[string]string{}
err := afero.Walk(fs, dir, func(
path string, info os.FileInfo, err error,
) error {
if err != nil {
return err
}
if info.IsDir() {
contents[path+"/"] = ""
return nil
}
content, err := afero.ReadFile(fs, path)
if err != nil {
return err
}
contents[path] = string(content)
return nil
})
require.NoError(t, err)
return contents
}