Compare commits
2
Commits
v1.0.0-rc2
...
759dbb1b1d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
759dbb1b1d | ||
|
|
5371382cb4 |
@@ -150,6 +150,12 @@ 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.
|
||||||
|
|
||||||
#### `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
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ https://git.eeqj.de/sneak/secret/milestone/12
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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
|
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
|
(https://git.eeqj.de/sneak/secret/issues/135). The key was derived from the
|
||||||
|
|||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
+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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-23
@@ -219,6 +219,15 @@ func (v *Vault) addVersion(
|
|||||||
name, secretDir string, value *memguard.LockedBuffer,
|
name, secretDir string, value *memguard.LockedBuffer,
|
||||||
previousVersion *secret.Version,
|
previousVersion *secret.Version,
|
||||||
) error {
|
) 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()
|
now := time.Now()
|
||||||
|
|
||||||
// Create the new version and save the encrypted value
|
// Create the new version and save the encrypted value
|
||||||
@@ -418,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 {
|
||||||
@@ -445,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
|
||||||
@@ -632,8 +633,35 @@ func (v *Vault) checkExistingSecret(
|
|||||||
return true, previousVersion, nil
|
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
|
// 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(
|
func (v *Vault) updatePreviousVersion(
|
||||||
previousVersion *secret.Version, now *time.Time,
|
previousVersion *secret.Version, now *time.Time,
|
||||||
) error {
|
) error {
|
||||||
@@ -641,22 +669,13 @@ func (v *Vault) updatePreviousVersion(
|
|||||||
return nil
|
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()
|
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||||
if err != nil {
|
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)
|
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
|
// Update notAfter timestamp
|
||||||
previousVersion.Metadata.NotAfter = now
|
previousVersion.Metadata.NotAfter = now
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
// version
|
// version
|
||||||
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
|
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
|
||||||
// across versions
|
// across versions
|
||||||
|
// - TestVaultVersionTimestampsAfterUnlock: Tests that those timestamps are
|
||||||
|
// taken after AddSecret unlocks the vault
|
||||||
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
||||||
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
||||||
//
|
//
|
||||||
@@ -295,6 +297,70 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
|||||||
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
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) {
|
func TestVaultGetNonExistentVersion(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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"
|
||||||
@@ -103,3 +104,78 @@ func TestAddSecretCleansUpOnFailure(t *testing.T) {
|
|||||||
require.Len(t, entries, 1)
|
require.Len(t, entries, 1)
|
||||||
assert.Equal(t, "pub.age", entries[0].Name())
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user