check / check (push) Waiting to run
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
194 lines
5.6 KiB
Go
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
|
|
}
|