secret/internal/secret/version.go
sneak 63cc06b93c Fix DecryptWithIdentity to return LockedBuffer
- Changed DecryptWithIdentity to return *memguard.LockedBuffer instead of []byte
- Updated all callers throughout the codebase to handle LockedBuffer
- This ensures decrypted data is protected in memory immediately after decryption
- Fixed all usages in vault, secret, version, and unlocker implementations
- Removed duplicate buffer creation and unnecessary memory clearing
2025-07-15 09:04:34 +02:00

491 lines
16 KiB
Go

package secret
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/oklog/ulid/v2"
"github.com/spf13/afero"
)
const (
versionNameParts = 2
maxVersionsPerDay = 999
)
// VersionMetadata contains information about a secret version
type VersionMetadata struct {
ID string `json:"id"` // ULID
CreatedAt *time.Time `json:"createdAt,omitempty"` // When version was created
NotBefore *time.Time `json:"notBefore,omitempty"` // When this version becomes active
NotAfter *time.Time `json:"notAfter,omitempty"` // When this version expires (nil = current)
}
// Version represents a version of a secret
type Version struct {
SecretName string
Version string
Directory string
Metadata VersionMetadata
vault VaultInterface
}
// NewVersion creates a new Version instance
func NewVersion(vault VaultInterface, secretName string, version string) *Version {
DebugWith("Creating new secret version instance",
slog.String("secret_name", secretName),
slog.String("version", version),
slog.String("vault_name", vault.GetName()),
)
vaultDir, _ := vault.GetDirectory()
storageName := strings.ReplaceAll(secretName, "/", "%")
versionDir := filepath.Join(vaultDir, "secrets.d", storageName, "versions", version)
DebugWith("Secret version storage details",
slog.String("secret_name", secretName),
slog.String("version", version),
slog.String("version_dir", versionDir),
)
now := time.Now()
return &Version{
SecretName: secretName,
Version: version,
Directory: versionDir,
vault: vault,
Metadata: VersionMetadata{
ID: ulid.Make().String(),
CreatedAt: &now,
},
}
}
// GenerateVersionName generates a new version name in YYYYMMDD.NNN format
func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
today := time.Now().Format("20060102")
versionsDir := filepath.Join(secretDir, "versions")
// Ensure versions directory exists
if err := fs.MkdirAll(versionsDir, DirPerms); err != nil {
return "", fmt.Errorf("failed to create versions directory: %w", err)
}
// Find the highest serial number for today
entries, err := afero.ReadDir(fs, versionsDir)
if err != nil {
return "", fmt.Errorf("failed to read versions directory: %w", err)
}
maxSerial := 0
prefix := today + "."
for _, entry := range entries {
// Skip non-directories and those without correct prefix
if !entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) {
continue
}
// Extract serial number
parts := strings.Split(entry.Name(), ".")
if len(parts) != versionNameParts {
continue
}
var serial int
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err != nil {
continue
}
if serial > maxSerial {
maxSerial = serial
}
}
// Generate new version name
newSerial := maxSerial + 1
if newSerial > maxVersionsPerDay {
return "", fmt.Errorf("exceeded maximum versions per day (999)")
}
return fmt.Sprintf("%s.%03d", today, newSerial), nil
}
// Save saves the version metadata and value
func (sv *Version) Save(value *memguard.LockedBuffer) error {
if value == nil {
return fmt.Errorf("value buffer is nil")
}
DebugWith("Saving secret version",
slog.String("secret_name", sv.SecretName),
slog.String("version", sv.Version),
slog.Int("value_length", value.Size()),
)
fs := sv.vault.GetFilesystem()
// Create version directory
if err := fs.MkdirAll(sv.Directory, DirPerms); err != nil {
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
return fmt.Errorf("failed to create version directory: %w", err)
}
// Step 1: Generate a new keypair for this version
Debug("Generating version-specific keypair", "version", sv.Version)
versionIdentity, err := age.GenerateX25519Identity()
if err != nil {
Debug("Failed to generate version keypair", "error", err, "version", sv.Version)
return fmt.Errorf("failed to generate version keypair: %w", err)
}
versionPublicKey := versionIdentity.Recipient().String()
// Store private key in memguard buffer immediately
versionPrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
defer versionPrivateKeyBuffer.Destroy()
DebugWith("Generated version keypair",
slog.String("version", sv.Version),
slog.String("public_key", versionPublicKey),
)
// Step 2: Store the version's public key
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
Debug("Writing version public key", "path", pubKeyPath)
if err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms); err != nil {
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
return fmt.Errorf("failed to write version public key: %w", err)
}
// Step 3: Encrypt the value to the version's public key
Debug("Encrypting value to version's public key", "version", sv.Version)
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
if err != nil {
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
return fmt.Errorf("failed to encrypt version value: %w", err)
}
// Step 4: Store the encrypted value
valuePath := filepath.Join(sv.Directory, "value.age")
Debug("Writing encrypted version value", "path", valuePath)
if err := afero.WriteFile(fs, valuePath, encryptedValue, FilePerms); err != nil {
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
return fmt.Errorf("failed to write encrypted version value: %w", err)
}
// Step 5: Get vault's long-term public key for encrypting the version's private key
vaultDir, _ := sv.vault.GetDirectory()
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
Debug("Reading long-term public key", "path", ltPubKeyPath)
ltPubKeyData, err := afero.ReadFile(fs, ltPubKeyPath)
if err != nil {
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
return fmt.Errorf("failed to read long-term public key: %w", err)
}
Debug("Parsing long-term public key")
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
if err != nil {
Debug("Failed to parse long-term public key", "error", err)
return fmt.Errorf("failed to parse long-term public key: %w", err)
}
// Step 6: Encrypt the version's private key to the long-term public key
Debug("Encrypting version private key to long-term public key", "version", sv.Version)
encryptedPrivKey, err := EncryptToRecipient(versionPrivateKeyBuffer, ltRecipient)
if err != nil {
Debug("Failed to encrypt version private key", "error", err, "version", sv.Version)
return fmt.Errorf("failed to encrypt version private key: %w", err)
}
// Step 7: Store the encrypted private key
privKeyPath := filepath.Join(sv.Directory, "priv.age")
Debug("Writing encrypted version private key", "path", privKeyPath)
if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms); err != nil {
Debug("Failed to write encrypted version private key", "error", err, "path", privKeyPath)
return fmt.Errorf("failed to write encrypted version private key: %w", err)
}
// Step 8: Encrypt and store metadata
Debug("Encrypting version metadata", "version", sv.Version)
metadataBytes, err := json.MarshalIndent(sv.Metadata, "", " ")
if err != nil {
Debug("Failed to marshal version metadata", "error", err)
return fmt.Errorf("failed to marshal version metadata: %w", err)
}
// Encrypt metadata to the version's public key
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
defer metadataBuffer.Destroy()
encryptedMetadata, err := EncryptToRecipient(metadataBuffer, versionIdentity.Recipient())
if err != nil {
Debug("Failed to encrypt version metadata", "error", err, "version", sv.Version)
return fmt.Errorf("failed to encrypt version metadata: %w", err)
}
metadataPath := filepath.Join(sv.Directory, "metadata.age")
Debug("Writing encrypted version metadata", "path", metadataPath)
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms); err != nil {
Debug("Failed to write encrypted version metadata", "error", err, "path", metadataPath)
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
}
Debug("Successfully saved secret version", "version", sv.Version, "secret_name", sv.SecretName)
return nil
}
// LoadMetadata loads and decrypts the version metadata
func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
DebugWith("Loading version metadata",
slog.String("secret_name", sv.SecretName),
slog.String("version", sv.Version),
)
fs := sv.vault.GetFilesystem()
// Step 1: Read encrypted version private key
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
if err != nil {
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
return fmt.Errorf("failed to read encrypted version private key: %w", err)
}
// Step 2: Decrypt version private key using long-term key
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
if err != nil {
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
return fmt.Errorf("failed to decrypt version private key: %w", err)
}
defer versionPrivKeyBuffer.Destroy()
// Step 3: Parse version private key
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
if err != nil {
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
return fmt.Errorf("failed to parse version private key: %w", err)
}
// Step 4: Read encrypted metadata
encryptedMetadataPath := filepath.Join(sv.Directory, "metadata.age")
encryptedMetadata, err := afero.ReadFile(fs, encryptedMetadataPath)
if err != nil {
Debug("Failed to read encrypted version metadata", "error", err, "path", encryptedMetadataPath)
return fmt.Errorf("failed to read encrypted version metadata: %w", err)
}
// Step 5: Decrypt metadata using version key
metadataBuffer, err := DecryptWithIdentity(encryptedMetadata, versionIdentity)
if err != nil {
Debug("Failed to decrypt version metadata", "error", err, "version", sv.Version)
return fmt.Errorf("failed to decrypt version metadata: %w", err)
}
defer metadataBuffer.Destroy()
// Step 6: Unmarshal metadata
var metadata VersionMetadata
if err := json.Unmarshal(metadataBuffer.Bytes(), &metadata); err != nil {
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
}
sv.Metadata = metadata
Debug("Successfully loaded version metadata", "version", sv.Version)
return nil
}
// GetValue retrieves and decrypts the version value
func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuffer, error) {
DebugWith("Getting version value",
slog.String("secret_name", sv.SecretName),
slog.String("version", sv.Version),
)
// Debug: Log the directory and long-term key info
Debug("SecretVersion GetValue debug info",
"secret_name", sv.SecretName,
"version", sv.Version,
"directory", sv.Directory,
"lt_identity_public_key", ltIdentity.Recipient().String())
fs := sv.vault.GetFilesystem()
// Step 1: Read encrypted version private key
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
Debug("Reading encrypted version private key", "path", encryptedPrivKeyPath)
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
if err != nil {
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
return nil, fmt.Errorf("failed to read encrypted version private key: %w", err)
}
Debug("Successfully read encrypted version private key", "path", encryptedPrivKeyPath, "size", len(encryptedPrivKey))
// Step 2: Decrypt version private key using long-term key
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
if err != nil {
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
}
defer versionPrivKeyBuffer.Destroy()
Debug("Successfully decrypted version private key", "version", sv.Version, "size", versionPrivKeyBuffer.Size())
// Step 3: Parse version private key
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
if err != nil {
Debug("Failed to parse version private key", "error", err, "version", sv.Version)
return nil, fmt.Errorf("failed to parse version private key: %w", err)
}
// Step 4: Read encrypted value
encryptedValuePath := filepath.Join(sv.Directory, "value.age")
Debug("Reading encrypted value", "path", encryptedValuePath)
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
if err != nil {
Debug("Failed to read encrypted version value", "error", err, "path", encryptedValuePath)
return nil, fmt.Errorf("failed to read encrypted version value: %w", err)
}
Debug("Successfully read encrypted value", "path", encryptedValuePath, "size", len(encryptedValue))
// Step 5: Decrypt value using version key
Debug("Decrypting value with version identity", "version", sv.Version)
valueBuffer, err := DecryptWithIdentity(encryptedValue, versionIdentity)
if err != nil {
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
return nil, fmt.Errorf("failed to decrypt version value: %w", err)
}
Debug("Successfully retrieved version value",
"version", sv.Version,
"value_length", valueBuffer.Size(),
"is_empty", valueBuffer.Size() == 0)
return valueBuffer, nil
}
// ListVersions lists all versions of a secret
func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
versionsDir := filepath.Join(secretDir, "versions")
// Check if versions directory exists
exists, err := afero.DirExists(fs, versionsDir)
if err != nil {
return nil, fmt.Errorf("failed to check versions directory: %w", err)
}
if !exists {
return []string{}, nil
}
// List all version directories
entries, err := afero.ReadDir(fs, versionsDir)
if err != nil {
return nil, fmt.Errorf("failed to read versions directory: %w", err)
}
var versions []string
for _, entry := range entries {
if entry.IsDir() {
versions = append(versions, entry.Name())
}
}
// Sort versions in reverse chronological order
sort.Sort(sort.Reverse(sort.StringSlice(versions)))
return versions, nil
}
// GetCurrentVersion returns the version that the "current" symlink points to
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
currentPath := filepath.Join(secretDir, "current")
// Try to read as a real symlink first
if _, ok := fs.(*afero.OsFs); ok {
target, err := os.Readlink(currentPath)
if err == nil {
// Extract version from path (e.g., "versions/20231215.001" -> "20231215.001")
parts := strings.Split(target, "/")
if len(parts) >= 2 && parts[0] == "versions" {
return parts[1], nil
}
return "", fmt.Errorf("invalid current version symlink format: %s", target)
}
}
// Fall back to reading as a file (for MemMapFs testing)
fileData, err := afero.ReadFile(fs, currentPath)
if err != nil {
return "", fmt.Errorf("failed to read current version symlink: %w", err)
}
target := strings.TrimSpace(string(fileData))
// Extract version from path
parts := strings.Split(target, "/")
if len(parts) >= 2 && parts[0] == "versions" {
return parts[1], nil
}
return "", fmt.Errorf("invalid current version symlink format: %s", target)
}
// SetCurrentVersion updates the "current" symlink to point to a specific version
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
currentPath := filepath.Join(secretDir, "current")
targetPath := filepath.Join("versions", version)
// Remove existing symlink if it exists
_ = fs.Remove(currentPath)
// Try to create a real symlink first (works on Unix systems)
if _, ok := fs.(*afero.OsFs); ok {
if err := os.Symlink(targetPath, currentPath); err == nil {
return nil
}
}
// Fall back to creating a file with the target path (for MemMapFs testing)
if err := afero.WriteFile(fs, currentPath, []byte(targetPath), FilePerms); err != nil {
return fmt.Errorf("failed to create current version symlink: %w", err)
}
return nil
}