check / check (push) Waiting to run
init, vault create, unlocker add, and add, import and generate secret with --force asked for the mnemonic or passphrase, or unlocked the vault, while holding the state directory lock, so every other command that changes anything waited at the prompt. They now ask and unlock first and lock after. Under the lock, unlocker add fails if the vault was removed, or created again with another key, and refuses a GPG key added meanwhile; a forced add of a secret created meanwhile lets go of the lock, gets the key and locks again. secret.CreatePGPUnlocker takes the unlocked vault; SetMnemonic and SetUnlockPassphrase are gone. Not done: secret move between vaults still unlocks both under the lock (#189). Model: opus-5-5
189 lines
5.3 KiB
Go
189 lines
5.3 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)
|
|
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
|
|
}
|