Files
secret/internal/secret/secret.go
T
clawbot faf46d12ff
check / check (push) Waiting to run
secret list gives each secret's own creation and last-update times (closes #142)
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

194 lines
5.6 KiB
Go

package secret
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
var (
errGetEncryptedDataDeprecated = errors.New(
"GetEncryptedData is deprecated - use version-specific methods")
errGetCurrentVaultNotRegistered = errors.New(
"GetCurrentVault function not registered")
)
// VaultInterface defines the interface that vault implementations must satisfy
type VaultInterface interface {
GetDirectory() (string, error)
AddSecret(name string, value *memguard.LockedBuffer, force bool) error
GetName() string
GetFilesystem() afero.Fs
GetCurrentUnlocker() (Unlocker, error)
GetOrDeriveLongTermKey() (*age.X25519Identity, error)
// SetMnemonic and SetUnlockPassphrase give GetOrDeriveLongTermKey the
// mnemonic to derive the long-term key from, and the passphrase for a
// current passphrase unlocker; nil for none.
SetMnemonic(mnemonic *memguard.LockedBuffer)
SetUnlockPassphrase(passphrase *memguard.LockedBuffer)
CreatePassphraseUnlocker(
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
}
// Secret represents a secret in a vault
type Secret struct {
Name string
Directory string
Metadata Metadata
vault VaultInterface
}
// NewSecret creates a new Secret instance
func NewSecret(vault VaultInterface, name string) *Secret {
DebugWith("Creating new secret instance",
slog.String("secret_name", name),
slog.String("vault_name", vault.GetName()),
)
// Convert slashes to percent signs for storage directory name
storageName := strings.ReplaceAll(name, "/", "%")
vaultDir, _ := vault.GetDirectory()
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
DebugWith("Secret storage details",
slog.String("secret_name", name),
slog.String("storage_name", storageName),
slog.String("secret_dir", secretDir),
)
return &Secret{
Name: name,
Directory: secretDir,
vault: vault,
}
}
// 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 {
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: value.ModTime(),
UpdatedAt: current.ModTime(),
}
return nil
}
// GetMetadata returns the times LoadMetadata read
func (s *Secret) GetMetadata() Metadata {
return s.Metadata
}
// GetEncryptedData is deprecated - data is now stored in versions
func (s *Secret) GetEncryptedData() ([]byte, error) {
Debug("GetEncryptedData called but is deprecated in versioned model",
"secret_name", s.Name)
return nil, errGetEncryptedDataDeprecated
}
// Exists checks if the secret exists on disk
func (s *Secret) Exists() (bool, error) {
DebugWith("Checking if secret exists",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
)
// Check if the secret directory exists and has a current symlink
exists, err := afero.DirExists(s.vault.GetFilesystem(), s.Directory)
if err != nil {
Debug("Failed to check secret directory existence",
"error", err, "secret_dir", s.Directory)
return false, err
}
if !exists {
Debug("Secret directory does not exist", "secret_dir", s.Directory)
return false, nil
}
// Check if current symlink exists
_, err = GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
if err != nil {
Debug("No current version found", "error", err, "secret_name", s.Name)
return false, nil
}
DebugWith("Secret existence check result",
slog.String("secret_name", s.Name),
slog.Bool("exists", true),
)
return true, nil
}
// GetCurrentVault gets the current vault from the file system
// This function is a wrapper around the actual implementation in the vault package
// and exists to break the import cycle.
//
//nolint:ireturn // must return the interface to break the import cycle
func GetCurrentVault(fs afero.Fs, stateDir string) (VaultInterface, error) {
// This is a forward declaration. The actual implementation is provided
// by the vault package when it calls RegisterGetCurrentVaultFunc.
if getCurrentVaultFunc == nil {
return nil, errGetCurrentVaultNotRegistered
}
return getCurrentVaultFunc(fs, stateDir)
}
// getCurrentVaultFunc is a function variable that will be set by the vault package
// to implement the actual GetCurrentVault functionality
//
//nolint:gochecknoglobals // Required to break import cycle
var getCurrentVaultFunc func(fs afero.Fs, stateDir string) (VaultInterface, error)
// RegisterGetCurrentVaultFunc allows the vault package to register its
// implementation of GetCurrentVault to break the import cycle
func RegisterGetCurrentVaultFunc(
fn func(fs afero.Fs, stateDir string) (VaultInterface, error),
) {
getCurrentVaultFunc = fn
}