Compare commits

2 Commits
Author SHA1 Message Date
clawbot 4cfbfc3719 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-14 21:49:40 +00:00
clawbot 72cd6c30a1 A wrong passphrase gives an error that says so (closes #157)
check / check (push) Canceled after 0s
A passphrase that did not decrypt the passphrase unlocker failed with age's
"no identity matched any of the recipients" and advice to give the vault a
new unlocker from its mnemonic, which would replace an unlocker that works.
GetIdentity now turns that age error into "wrong passphrase for unlocker
<id>", wrapping the new secret.ErrWrongPassphrase, and withMnemonicAdvice
leaves it alone, as it does when no passphrase was read. A passphrase from
SB_UNLOCK_PASSPHRASE and a typed one both reach this code. A priv.age that
is not an age file keeps the advice.

Judgement call: age cannot tell a wrong passphrase from a damaged key inside
a priv.age whose header reads, so that case now reads as a wrong passphrase.

Model: opus-5-5
2026-10-14 23:48:50 +02:00
11 changed files with 328 additions and 57 deletions

No files matched your search

+10 -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. 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` ⚠️ 🛑
+8
View File
@@ -18,6 +18,14 @@ https://git.eeqj.de/sneak/secret/milestone/12
# Completed Steps # Completed Steps
- 2026-10-14: A wrong passphrase, from `SB_UNLOCK_PASSPHRASE` or typed at the
prompt, gives the error `wrong passphrase for unlocker <id>`
(https://git.eeqj.de/sneak/secret/issues/157). It used to be age's "no
identity matched any of the recipients", followed by advice to give the vault
a new unlocker from its mnemonic, which would have replaced an unlocker that
works. The error now wraps `secret.ErrWrongPassphrase` and carries no advice
to use the mnemonic. A `priv.age` that is not an age file still gets that
advice.
- 2026-10-14: The passphrase and mnemonic prompts ask on the controlling - 2026-10-14: The passphrase and mnemonic prompts ask on the controlling
terminal (`/dev/tty`) instead of stdin, so `echo v | secret add s1 --force`, terminal (`/dev/tty`) instead of stdin, so `echo v | secret add s1 --force`,
`cat file | secret encrypt key` and `cat file.age | secret decrypt key` ask `cat file | secret encrypt key` and `cat file.age | secret decrypt key` ask
+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}
}
+9 -1
View File
@@ -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")
+39 -18
View File
@@ -1,12 +1,12 @@
// Unlock Failure Tests // Unlock Failure Tests
// //
// When a vault cannot be opened through its current unlocker, because a // When a vault cannot be opened through its current unlocker, because a
// file the unlocker needs is missing or the passphrase is wrong, the error // file the unlocker needs is missing, the error keeps its cause and ends by
// keeps its cause and ends by saying that the mnemonic still opens that // saying that the mnemonic still opens that vault, but only for a vault that
// vault, but only for a vault that the mnemonic does open, and not when the // the mnemonic does open, and not when the passphrase could not be read at
// passphrase could not be read at all. When the current unlocker does not // all. A wrong passphrase gives an error that says so, without that advice.
// exist and the vault has another that `secret unlocker select` accepts, the // When the current unlocker does not exist and the vault has another that
// error says to select one instead. // `secret unlocker select` accepts, the error says to select one instead.
// When a secret's current file is missing, the error says how to make a // When a secret's current file is missing, the error says how to make a
// version current again. Each test that pins such advice also follows it. // version current again. Each test that pins such advice also follows it.
@@ -21,7 +21,6 @@ import (
"syscall" "syscall"
"testing" "testing"
"filippo.io/age"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -165,26 +164,48 @@ func TestUnlockFailureNamesMnemonic(t *testing.T) {
} }
} }
// TestWrongPassphraseNamesMnemonic checks the error of `secret get` given a // TestWrongPassphraseNamesPassphrase checks the error of `secret get` given
// passphrase that does not decrypt the passphrase unlocker: it keeps age's // a passphrase that does not decrypt the passphrase unlocker: it says that
// error and ends with the advice that the mnemonic still opens the vault. // the passphrase is wrong, without the advice to use the mnemonic, since the
func TestWrongPassphraseNamesMnemonic(t *testing.T) { // unlocker still opens the vault with the right passphrase. The test then
// gives the right passphrase.
func TestWrongPassphraseNamesPassphrase(t *testing.T) {
t.Parallel() t.Parallel()
_, c := newUnlockTestCLI(t, newTwoVaultFs(t)) unlockerDir, c := newUnlockTestCLI(t, newTwoVaultFs(t))
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase")) c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase"))
t.Cleanup(c.UnlockPassphrase.Destroy) t.Cleanup(c.UnlockPassphrase.Destroy)
err := c.GetSecret(discardCmd(), "x") err := c.GetSecret(discardCmd(), "x")
var noMatch *age.NoIdentityMatchError require.ErrorIs(t, err, secret.ErrWrongPassphrase)
assert.NotContains(t, err.Error(), mnemonicAdvice)
require.ErrorAs(t, err, &noMatch)
require.EqualError(t, err, "failed to unlock vault: "+ require.EqualError(t, err, "failed to unlock vault: "+
"failed to get long-term key: failed to get unlocker identity: "+ "failed to get long-term key: failed to get unlocker identity: "+
"failed to decrypt unlocker private key: failed to create decryptor: "+ "wrong passphrase for unlocker "+filepath.Base(unlockerDir))
noMatch.Error()+mnemonicAdvice)
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(c.UnlockPassphrase.Destroy)
assert.Equal(t, "value", getSecret(t, c, "x"))
}
// TestDamagedUnlockerNamesMnemonic checks the error of `secret get` when the
// passphrase unlocker's priv.age is not an age file: the passphrase is not
// blamed, and the error ends with the advice that the mnemonic still opens
// the vault.
func TestDamagedUnlockerNamesMnemonic(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
unlockerDir, c := newUnlockTestCLI(t, fs)
require.NoError(t, afero.WriteFile(fs, filepath.Join(unlockerDir, "priv.age"),
[]byte("damaged"), secret.FilePerms))
err := c.GetSecret(discardCmd(), "x")
require.NotErrorIs(t, err, secret.ErrWrongPassphrase)
require.ErrorContains(t, err, mnemonicAdvice)
} }
// TestMoveUnlockFailureNamesVault checks the error of `secret move` into // TestMoveUnlockFailureNamesVault checks the error of `secret move` into
+3 -1
View File
@@ -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,
+10
View File
@@ -16,6 +16,10 @@ import (
// empty. A passphrase unlocker that fails with it was not tried. // empty. A passphrase unlocker that fails with it was not tried.
var ErrPassphraseNotRead = errors.New("failed to read passphrase") var ErrPassphraseNotRead = errors.New("failed to read passphrase")
// ErrWrongPassphrase is wrapped in the error of GetIdentity when the
// passphrase does not decrypt the unlocker's private key.
var ErrWrongPassphrase = errors.New("wrong passphrase")
// PassphraseUnlocker represents a passphrase-protected unlocker // PassphraseUnlocker represents a passphrase-protected unlocker
type PassphraseUnlocker struct { type PassphraseUnlocker struct {
Directory string Directory string
@@ -73,6 +77,12 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
Debug("Failed to decrypt unlocker private key", Debug("Failed to decrypt unlocker private key",
"error", err, "unlocker_id", p.GetID()) "error", err, "unlocker_id", p.GetID())
// age says that no identity matched when the passphrase is wrong
var noMatch *age.NoIdentityMatchError
if errors.As(err, &noMatch) {
return nil, fmt.Errorf("%w for unlocker %s", ErrWrongPassphrase, p.GetID())
}
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err) return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
} }
defer privKeyBuffer.Destroy() defer privKeyBuffer.Destroy()
+38 -15
View File
@@ -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
View File
@@ -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.
+5 -2
View File
@@ -291,10 +291,13 @@ func (v *Vault) unlockLongTermKey(
// without a mnemonic records none, and without its metadata the key cannot // without a mnemonic records none, and without its metadata the key cannot
// be derived. It is not added when the passphrase could not be read: the // be derived. It is not added when the passphrase could not be read: the
// unlocker was not tried, and adding one would need a passphrase read the // unlocker was not tried, and adding one would need a passphrase read the
// same way. Nor is it added when the current unlocker does not exist and the // same way. Nor is it added when the passphrase is wrong: the unlocker still
// vault has others: the error already says to select one of them. // opens the vault with the right one. Nor is it added when the current
// unlocker does not exist and the vault has others: the error already says
// to select one of them.
func (v *Vault) withMnemonicAdvice(err error) error { func (v *Vault) withMnemonicAdvice(err error) error {
if errors.Is(err, secret.ErrPassphraseNotRead) || if errors.Is(err, secret.ErrPassphraseNotRead) ||
errors.Is(err, secret.ErrWrongPassphrase) ||
errors.Is(err, ErrUnlockerNotFound) { errors.Is(err, ErrUnlockerNotFound) {
return err return err
} }
+1 -4
View File
@@ -5,7 +5,6 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"filippo.io/age"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -140,9 +139,7 @@ func TestAddSecretForceWithWrongPassphraseChangesNothing(t *testing.T) {
err = vlt.AddSecret(testSecretName, second, true) err = vlt.AddSecret(testSecretName, second, true)
var noMatch *age.NoIdentityMatchError require.ErrorIs(t, err, secret.ErrWrongPassphrase)
require.ErrorAs(t, err, &noMatch)
assert.Equal(t, before, dirContents(t, fs, secretDir)) assert.Equal(t, before, dirContents(t, fs, secretDir))
} }