439 lines
16 KiB
Go
439 lines
16 KiB
Go
package secret
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"github.com/oklog/ulid/v2"
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
// 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)
|
|
}
|
|
|
|
// SecretVersion represents a version of a secret
|
|
type SecretVersion struct {
|
|
SecretName string
|
|
Version string
|
|
Directory string
|
|
Metadata VersionMetadata
|
|
vault VaultInterface
|
|
}
|
|
|
|
// NewSecretVersion creates a new SecretVersion instance
|
|
func NewSecretVersion(vault VaultInterface, secretName string, version string) *SecretVersion {
|
|
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 &SecretVersion{
|
|
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 {
|
|
if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) {
|
|
// Extract serial number
|
|
parts := strings.Split(entry.Name(), ".")
|
|
if len(parts) == 2 {
|
|
var serial int
|
|
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err == nil {
|
|
if serial > maxSerial {
|
|
maxSerial = serial
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Generate new version name
|
|
newSerial := maxSerial + 1
|
|
if newSerial > 999 {
|
|
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 *SecretVersion) Save(value []byte) error {
|
|
DebugWith("Saving secret version",
|
|
slog.String("secret_name", sv.SecretName),
|
|
slog.String("version", sv.Version),
|
|
slog.Int("value_length", len(value)),
|
|
)
|
|
|
|
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()
|
|
versionPrivateKey := versionIdentity.String()
|
|
|
|
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([]byte(versionPrivateKey), 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
|
|
encryptedMetadata, err := EncryptToRecipient(metadataBytes, 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 *SecretVersion) 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
|
|
versionPrivKeyData, 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)
|
|
}
|
|
|
|
// Step 3: Parse version private key
|
|
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
|
|
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
|
|
metadataBytes, 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)
|
|
}
|
|
|
|
// Step 6: Unmarshal metadata
|
|
var metadata VersionMetadata
|
|
if err := json.Unmarshal(metadataBytes, &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 *SecretVersion) GetValue(ltIdentity *age.X25519Identity) ([]byte, 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)
|
|
versionPrivKeyData, 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)
|
|
}
|
|
Debug("Successfully decrypted version private key", "version", sv.Version, "size", len(versionPrivKeyData))
|
|
|
|
// Step 3: Parse version private key
|
|
versionIdentity, err := age.ParseX25519Identity(string(versionPrivKeyData))
|
|
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)
|
|
value, 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", len(value),
|
|
"value_as_string", string(value),
|
|
"is_empty", len(value) == 0)
|
|
return value, 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
|
|
}
|