check / check (push) Failing after 1s
init and vault create put the mnemonic into the process environment for vault.CreateVault to read back, so every program they ran, gpg included, inherited it, and SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE were read at 13 places and never unset. Each command that may need them now reads both once, in its RunE, into locked buffers on the CLI Instance, and unsets them at once. The buffers are passed down: vault.CreateVault takes the mnemonic, a Vault carries Mnemonic and UnlockPassphrase, and the PGP, keychain and Secure Enclave unlocker constructors take both; CreatePGPUnlocker sets them on the vault it loads through SetMnemonic and SetUnlockPassphrase, new in VaultInterface. README warns against both variables. Model: opus-5-5
364 lines
11 KiB
Go
364 lines
11 KiB
Go
package secret
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
var (
|
|
// errSecretNotFound carries only the message tail; callers compose
|
|
// "secret <name> not found" around it so the emitted text is
|
|
// unchanged.
|
|
errSecretNotFound = errors.New("not found")
|
|
errUnlockerRequired = errors.New("unlocker required to decrypt secret")
|
|
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,
|
|
Metadata: Metadata{
|
|
CreatedAt: time.Now(),
|
|
UpdatedAt: time.Now(),
|
|
},
|
|
}
|
|
}
|
|
|
|
// GetValue retrieves and decrypts the current version's value, with the
|
|
// vault's long-term key derived from mnemonic when it is not nil, else
|
|
// obtained through unlocker
|
|
func (s *Secret) GetValue(
|
|
unlocker Unlocker, mnemonic *memguard.LockedBuffer,
|
|
) (*memguard.LockedBuffer, error) {
|
|
DebugWith("Getting secret value",
|
|
slog.String("secret_name", s.Name),
|
|
slog.String("vault_name", s.vault.GetName()),
|
|
)
|
|
|
|
// Check if secret exists
|
|
exists, err := s.Exists()
|
|
if err != nil {
|
|
Debug("Failed to check if secret exists during GetValue",
|
|
"error", err, "secret_name", s.Name)
|
|
|
|
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
|
}
|
|
|
|
if !exists {
|
|
Debug("Secret not found during GetValue",
|
|
"secret_name", s.Name, "vault_name", s.vault.GetName())
|
|
|
|
return nil, fmt.Errorf("secret %s %w", s.Name, errSecretNotFound)
|
|
}
|
|
|
|
Debug("Secret exists, getting current version", "secret_name", s.Name)
|
|
|
|
// Get current version
|
|
currentVersion, err := GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
|
|
if err != nil {
|
|
Debug("Failed to get current version", "error", err, "secret_name", s.Name)
|
|
|
|
return nil, fmt.Errorf("failed to get current version: %w", err)
|
|
}
|
|
|
|
// Create version object
|
|
version := NewVersion(s.vault, s.Name, currentVersion)
|
|
|
|
if mnemonic != nil {
|
|
return s.getValueViaMnemonic(version, mnemonic.String())
|
|
}
|
|
|
|
Debug("Using unlocker for vault access", "secret_name", s.Name)
|
|
|
|
// Use the provided unlocker to get the vault's long-term private key
|
|
if unlocker == nil {
|
|
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
|
|
|
|
return nil, errUnlockerRequired
|
|
}
|
|
|
|
ltIdentity, err := s.getLongTermIdentityFromUnlocker(unlocker)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
DebugWith("Successfully obtained vault's long-term key",
|
|
slog.String("secret_name", s.Name),
|
|
slog.String("public_key", ltIdentity.Recipient().String()),
|
|
)
|
|
|
|
// Use the long-term key to decrypt the version
|
|
return version.GetValue(ltIdentity)
|
|
}
|
|
|
|
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
|
func (s *Secret) LoadMetadata() error {
|
|
Debug("LoadMetadata called but is deprecated in versioned model",
|
|
"secret_name", s.Name)
|
|
// For backward compatibility, we'll populate with basic info
|
|
now := time.Now()
|
|
s.Metadata = Metadata{
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetMetadata returns the secret metadata (deprecated)
|
|
func (s *Secret) GetMetadata() Metadata {
|
|
Debug("GetMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
|
|
|
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
|
|
}
|
|
|
|
// getValueViaMnemonic derives the vault's long-term key from the
|
|
// mnemonic and decrypts the version value with it.
|
|
func (s *Secret) getValueViaMnemonic(
|
|
version *Version, mnemonic string,
|
|
) (*memguard.LockedBuffer, error) {
|
|
Debug("Using mnemonic for direct long-term key derivation",
|
|
"secret_name", s.Name)
|
|
|
|
// Get vault directory to read metadata
|
|
vaultDir, err := s.vault.GetDirectory()
|
|
if err != nil {
|
|
Debug("Failed to get vault directory", "error", err, "secret_name", s.Name)
|
|
|
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
|
}
|
|
|
|
// Load vault metadata to get the correct derivation index
|
|
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
|
|
|
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
|
|
if err != nil {
|
|
Debug("Failed to read vault metadata", "error", err, "path", metadataPath)
|
|
|
|
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
|
}
|
|
|
|
var metadata VaultMetadata
|
|
|
|
err = json.Unmarshal(metadataBytes, &metadata)
|
|
if err != nil {
|
|
Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name)
|
|
|
|
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
|
}
|
|
|
|
DebugWith("Using vault derivation index from metadata",
|
|
slog.String("secret_name", s.Name),
|
|
slog.String("vault_name", s.vault.GetName()),
|
|
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
|
|
)
|
|
|
|
// Use mnemonic with the vault's derivation index from metadata
|
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic, metadata.DerivationIndex)
|
|
if err != nil {
|
|
Debug("Failed to derive long-term key from mnemonic for secret",
|
|
"error", err, "secret_name", s.Name)
|
|
|
|
return nil, fmt.Errorf(
|
|
"failed to derive long-term key from mnemonic: %w", err)
|
|
}
|
|
|
|
Debug("Successfully derived long-term key from mnemonic", "secret_name", s.Name)
|
|
|
|
// Use the long-term key to decrypt the version
|
|
return version.GetValue(ltIdentity)
|
|
}
|
|
|
|
// getLongTermIdentityFromUnlocker uses the unlocker to obtain and parse
|
|
// the vault's long-term private key.
|
|
func (s *Secret) getLongTermIdentityFromUnlocker(
|
|
unlocker Unlocker,
|
|
) (*age.X25519Identity, error) {
|
|
DebugWith("Getting vault's long-term key using unlocker",
|
|
slog.String("secret_name", s.Name),
|
|
slog.String("unlocker_type", unlocker.GetType()),
|
|
slog.String("unlocker_id", unlocker.GetID()),
|
|
)
|
|
|
|
// Step 1: Use the unlocker to get the vault's long-term private key
|
|
unlockIdentity, err := unlocker.GetIdentity()
|
|
if err != nil {
|
|
Debug("Failed to get unlocker identity",
|
|
"error", err, "secret_name", s.Name,
|
|
"unlocker_type", unlocker.GetType())
|
|
|
|
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
|
}
|
|
|
|
// Read the encrypted long-term private key from the unlocker directory
|
|
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
|
|
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
|
|
|
|
encryptedLtPrivKey, err := afero.ReadFile(
|
|
s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
|
|
if err != nil {
|
|
Debug("Failed to read encrypted long-term private key",
|
|
"error", err, "path", encryptedLtPrivKeyPath)
|
|
|
|
return nil, fmt.Errorf(
|
|
"failed to read encrypted long-term private key: %w", err)
|
|
}
|
|
|
|
// Decrypt the encrypted long-term private key using the unlocker
|
|
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
|
|
|
|
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
|
if err != nil {
|
|
Debug("Failed to decrypt long-term private key",
|
|
"error", err, "secret_name", s.Name)
|
|
|
|
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
|
}
|
|
defer ltPrivKeyBuffer.Destroy()
|
|
|
|
// Parse the long-term private key
|
|
Debug("Parsing long-term private key", "secret_name", s.Name)
|
|
|
|
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
|
|
if err != nil {
|
|
Debug("Failed to parse long-term private key",
|
|
"error", err, "secret_name", s.Name)
|
|
|
|
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
|
}
|
|
|
|
return ltIdentity, 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
|
|
}
|