add secret versioning support
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -62,9 +61,10 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
||||
}
|
||||
}
|
||||
|
||||
// Save saves a secret value to the vault
|
||||
// Save is deprecated - use vault.AddSecret directly which creates versions
|
||||
// Kept for backward compatibility
|
||||
func (s *Secret) Save(value []byte, force bool) error {
|
||||
DebugWith("Saving secret",
|
||||
DebugWith("Saving secret (deprecated method)",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
slog.Int("value_length", len(value)),
|
||||
@@ -81,7 +81,7 @@ func (s *Secret) Save(value []byte, force bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetValue retrieves and decrypts the secret value using the provided unlocker
|
||||
// GetValue retrieves and decrypts the current version's value using the provided unlocker
|
||||
func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
DebugWith("Getting secret value",
|
||||
slog.String("secret_name", s.Name),
|
||||
@@ -99,7 +99,17 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
return nil, fmt.Errorf("secret %s not found", s.Name)
|
||||
}
|
||||
|
||||
Debug("Secret exists, proceeding with decryption", "secret_name", s.Name)
|
||||
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 := NewSecretVersion(s.vault, s.Name, currentVersion)
|
||||
|
||||
// Check if we have SB_SECRET_MNEMONIC environment variable for direct decryption
|
||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
||||
@@ -114,8 +124,8 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
|
||||
Debug("Successfully derived long-term key from mnemonic", "secret_name", s.Name)
|
||||
|
||||
// Use the long-term key to decrypt the secret using per-secret architecture
|
||||
return s.decryptWithLongTermKey(ltIdentity)
|
||||
// Use the long-term key to decrypt the version
|
||||
return version.GetValue(ltIdentity)
|
||||
}
|
||||
|
||||
Debug("Using unlocker for vault access", "secret_name", s.Name)
|
||||
@@ -170,165 +180,33 @@ func (s *Secret) GetValue(unlocker Unlocker) ([]byte, error) {
|
||||
slog.String("public_key", ltIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
// Use the long-term key to decrypt the secret using per-secret architecture
|
||||
return s.decryptWithLongTermKey(ltIdentity)
|
||||
// Use the long-term key to decrypt the version
|
||||
return version.GetValue(ltIdentity)
|
||||
}
|
||||
|
||||
// decryptWithLongTermKey decrypts the secret using the vault's long-term private key
|
||||
// This implements the per-secret key architecture: longterm -> secret private key -> secret value
|
||||
func (s *Secret) decryptWithLongTermKey(ltIdentity *age.X25519Identity) ([]byte, error) {
|
||||
DebugWith("Decrypting secret with long-term key using per-secret architecture",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
)
|
||||
|
||||
// Step 1: Read the secret's encrypted private key from priv.age
|
||||
encryptedSecretPrivKeyPath := filepath.Join(s.Directory, "priv.age")
|
||||
Debug("Reading encrypted secret private key", "path", encryptedSecretPrivKeyPath)
|
||||
|
||||
encryptedSecretPrivKey, err := afero.ReadFile(s.vault.GetFilesystem(), encryptedSecretPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted secret private key", "error", err, "path", encryptedSecretPrivKeyPath)
|
||||
return nil, fmt.Errorf("failed to read encrypted secret private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Read encrypted secret private key",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.Int("encrypted_length", len(encryptedSecretPrivKey)),
|
||||
)
|
||||
|
||||
// Step 2: Decrypt the secret's private key using the vault's long-term private key
|
||||
Debug("Decrypting secret private key using long-term key", "secret_name", s.Name)
|
||||
secretPrivKeyData, err := DecryptWithIdentity(encryptedSecretPrivKey, ltIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt secret private key", "error", err, "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("failed to decrypt secret private key: %w", err)
|
||||
}
|
||||
|
||||
// Parse the secret's private key
|
||||
Debug("Parsing secret's private key", "secret_name", s.Name)
|
||||
secretIdentity, err := age.ParseX25519Identity(string(secretPrivKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse secret's private key", "error", err, "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("failed to parse secret's private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted and parsed secret's identity",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("secret_public_key", secretIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
// Step 3: Read the secret's encrypted value from value.age
|
||||
encryptedValuePath := filepath.Join(s.Directory, "value.age")
|
||||
Debug("Reading encrypted secret value", "path", encryptedValuePath)
|
||||
|
||||
encryptedValue, err := afero.ReadFile(s.vault.GetFilesystem(), encryptedValuePath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted secret value", "error", err, "path", encryptedValuePath)
|
||||
return nil, fmt.Errorf("failed to read encrypted secret value: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Read encrypted secret value",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.Int("encrypted_length", len(encryptedValue)),
|
||||
)
|
||||
|
||||
// Step 4: Decrypt the secret's value using the secret's private key
|
||||
Debug("Decrypting value using secret key", "secret_name", s.Name)
|
||||
decryptedValue, err := DecryptWithIdentity(encryptedValue, secretIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt secret value", "error", err, "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("failed to decrypt secret value: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted secret value using per-secret key architecture",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.Int("decrypted_length", len(decryptedValue)),
|
||||
)
|
||||
|
||||
return decryptedValue, nil
|
||||
}
|
||||
|
||||
// LoadMetadata loads the secret metadata from disk
|
||||
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
||||
func (s *Secret) LoadMetadata() error {
|
||||
DebugWith("Loading secret metadata",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
)
|
||||
|
||||
vaultDir, err := s.vault.GetDirectory()
|
||||
if err != nil {
|
||||
Debug("Failed to get vault directory for metadata loading", "error", err, "secret_name", s.Name)
|
||||
return err
|
||||
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 = SecretMetadata{
|
||||
Name: s.Name,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
// Convert slashes to percent signs for storage
|
||||
storageName := strings.ReplaceAll(s.Name, "/", "%")
|
||||
metadataPath := filepath.Join(vaultDir, "secrets.d", storageName, "secret-metadata.json")
|
||||
|
||||
DebugWith("Reading secret metadata",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("metadata_path", metadataPath),
|
||||
)
|
||||
|
||||
// Read metadata file
|
||||
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read secret metadata file", "error", err, "metadata_path", metadataPath)
|
||||
return fmt.Errorf("failed to read metadata: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Read secret metadata file",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.Int("metadata_size", len(metadataBytes)),
|
||||
)
|
||||
|
||||
var metadata SecretMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
Debug("Failed to parse secret metadata JSON", "error", err, "secret_name", s.Name)
|
||||
return fmt.Errorf("failed to parse metadata: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Parsed secret metadata",
|
||||
slog.String("secret_name", metadata.Name),
|
||||
slog.Time("created_at", metadata.CreatedAt),
|
||||
slog.Time("updated_at", metadata.UpdatedAt),
|
||||
)
|
||||
|
||||
s.Metadata = metadata
|
||||
Debug("Successfully loaded secret metadata", "secret_name", s.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMetadata returns the secret metadata
|
||||
// GetMetadata returns the secret metadata (deprecated)
|
||||
func (s *Secret) GetMetadata() SecretMetadata {
|
||||
Debug("Returning secret metadata", "secret_name", s.Name)
|
||||
Debug("GetMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
||||
return s.Metadata
|
||||
}
|
||||
|
||||
// GetEncryptedData reads and returns the encrypted secret data
|
||||
// GetEncryptedData is deprecated - data is now stored in versions
|
||||
func (s *Secret) GetEncryptedData() ([]byte, error) {
|
||||
DebugWith("Getting encrypted secret data",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
)
|
||||
|
||||
secretPath := filepath.Join(s.Directory, "value.age")
|
||||
|
||||
Debug("Reading encrypted secret file", "secret_path", secretPath)
|
||||
|
||||
encryptedData, err := afero.ReadFile(s.vault.GetFilesystem(), secretPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted secret file", "error", err, "secret_path", secretPath)
|
||||
return nil, fmt.Errorf("failed to read encrypted secret: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully read encrypted secret data",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.Int("encrypted_length", len(encryptedData)),
|
||||
)
|
||||
|
||||
return encryptedData, nil
|
||||
Debug("GetEncryptedData called but is deprecated in versioned model", "secret_name", s.Name)
|
||||
return nil, fmt.Errorf("GetEncryptedData is deprecated - use version-specific methods")
|
||||
}
|
||||
|
||||
// Exists checks if the secret exists on disk
|
||||
@@ -338,22 +216,31 @@ func (s *Secret) Exists() (bool, error) {
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
)
|
||||
|
||||
secretPath := filepath.Join(s.Directory, "value.age")
|
||||
|
||||
Debug("Checking secret file existence", "secret_path", secretPath)
|
||||
|
||||
exists, err := afero.Exists(s.vault.GetFilesystem(), secretPath)
|
||||
// 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 file existence", "error", err, "secret_path", secretPath)
|
||||
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", exists),
|
||||
slog.Bool("exists", true),
|
||||
)
|
||||
|
||||
return exists, nil
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetCurrentVault gets the current vault from the file system
|
||||
|
||||
@@ -3,6 +3,7 @@ package secret
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
@@ -23,12 +24,33 @@ func (m *MockVault) GetDirectory() (string, error) {
|
||||
}
|
||||
|
||||
func (m *MockVault) AddSecret(name string, value []byte, force bool) error {
|
||||
// Simplified implementation for testing
|
||||
secretDir := filepath.Join(m.directory, "secrets.d", name)
|
||||
if err := m.fs.MkdirAll(secretDir, DirPerms); err != nil {
|
||||
// Create versioned structure for testing
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
|
||||
|
||||
// Generate version name
|
||||
versionName, err := GenerateVersionName(m.fs, secretDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return afero.WriteFile(m.fs, filepath.Join(secretDir, "value.age"), value, FilePerms)
|
||||
|
||||
// Create version directory
|
||||
versionDir := filepath.Join(secretDir, "versions", versionName)
|
||||
if err := m.fs.MkdirAll(versionDir, DirPerms); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write encrypted value (simplified for testing)
|
||||
if err := afero.WriteFile(m.fs, filepath.Join(versionDir, "value.age"), value, FilePerms); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set current symlink
|
||||
if err := SetCurrentVersion(m.fs, secretDir, versionName); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockVault) GetName() string {
|
||||
@@ -122,16 +144,30 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
// Verify that all expected files were created
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
|
||||
|
||||
// Check value.age exists (the new per-secret key architecture format)
|
||||
secretExists, err := afero.Exists(
|
||||
fs,
|
||||
filepath.Join(secretDir, "value.age"),
|
||||
)
|
||||
if err != nil || !secretExists {
|
||||
t.Fatalf("value.age file was not created")
|
||||
// Check versions directory exists
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
versionsDirExists, err := afero.DirExists(fs, versionsDir)
|
||||
if err != nil || !versionsDirExists {
|
||||
t.Fatalf("versions directory was not created")
|
||||
}
|
||||
|
||||
t.Logf("All expected files created successfully")
|
||||
// Check current symlink exists
|
||||
currentVersion, err := GetCurrentVersion(fs, secretDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current version: %v", err)
|
||||
}
|
||||
|
||||
// Check value.age exists in the version directory
|
||||
versionDir := filepath.Join(versionsDir, currentVersion)
|
||||
valueExists, err := afero.Exists(
|
||||
fs,
|
||||
filepath.Join(versionDir, "value.age"),
|
||||
)
|
||||
if err != nil || !valueExists {
|
||||
t.Fatalf("value.age file was not created in version directory")
|
||||
}
|
||||
|
||||
t.Logf("All expected files created successfully with versioning")
|
||||
})
|
||||
|
||||
// Create a Secret object to test with
|
||||
|
||||
424
internal/secret/version.go
Normal file
424
internal/secret/version.go
Normal file
@@ -0,0 +1,424 @@
|
||||
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
|
||||
SecretName string `json:"secretName"` // Parent secret name
|
||||
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 string `json:"version"` // Version string (e.g., "20231215.001")
|
||||
}
|
||||
|
||||
// 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(),
|
||||
SecretName: secretName,
|
||||
CreatedAt: &now,
|
||||
Version: version,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
)
|
||||
|
||||
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 nil, 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 nil, 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 nil, fmt.Errorf("failed to parse version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Read encrypted value
|
||||
encryptedValuePath := filepath.Join(sv.Directory, "value.age")
|
||||
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)
|
||||
}
|
||||
|
||||
// Step 5: Decrypt value using version key
|
||||
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))
|
||||
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
|
||||
}
|
||||
333
internal/secret/version_test.go
Normal file
333
internal/secret/version_test.go
Normal file
@@ -0,0 +1,333 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// MockVault implements VaultInterface for testing
|
||||
type MockVersionVault struct {
|
||||
Name string
|
||||
fs afero.Fs
|
||||
stateDir string
|
||||
longTermKey *age.X25519Identity
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) GetDirectory() (string, error) {
|
||||
return filepath.Join(m.stateDir, "vaults.d", m.Name), nil
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) AddSecret(name string, value []byte, force bool) error {
|
||||
return fmt.Errorf("not implemented in mock")
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) GetName() string {
|
||||
return m.Name
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) GetFilesystem() afero.Fs {
|
||||
return m.fs
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||
return nil, fmt.Errorf("not implemented in mock")
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error) {
|
||||
return nil, fmt.Errorf("not implemented in mock")
|
||||
}
|
||||
|
||||
func TestGenerateVersionName(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
|
||||
// Test first version generation
|
||||
version1, err := GenerateVersionName(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Regexp(t, `^\d{8}\.001$`, version1)
|
||||
|
||||
// Create the version directory
|
||||
versionDir := filepath.Join(secretDir, "versions", version1)
|
||||
err = fs.MkdirAll(versionDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test second version generation on same day
|
||||
version2, err := GenerateVersionName(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Regexp(t, `^\d{8}\.002$`, version2)
|
||||
|
||||
// Verify they have the same date prefix
|
||||
assert.Equal(t, version1[:8], version2[:8])
|
||||
assert.NotEqual(t, version1, version2)
|
||||
}
|
||||
|
||||
func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// Create 999 versions
|
||||
today := time.Now().Format("20060102")
|
||||
for i := 1; i <= 999; i++ {
|
||||
versionName := fmt.Sprintf("%s.%03d", today, i)
|
||||
err := fs.MkdirAll(filepath.Join(versionsDir, versionName), 0755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Try to create one more - should fail
|
||||
_, err := GenerateVersionName(fs, secretDir)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
||||
}
|
||||
|
||||
func TestNewSecretVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
}
|
||||
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
|
||||
assert.Equal(t, "test/secret", sv.SecretName)
|
||||
assert.Equal(t, "20231215.001", sv.Version)
|
||||
assert.Contains(t, sv.Directory, "test%secret/versions/20231215.001")
|
||||
assert.NotEmpty(t, sv.Metadata.ID)
|
||||
assert.NotNil(t, sv.Metadata.CreatedAt)
|
||||
assert.Equal(t, "20231215.001", sv.Metadata.Version)
|
||||
}
|
||||
|
||||
func TestSecretVersionSave(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
}
|
||||
|
||||
// Create vault directory structure and long-term key
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
err := fs.MkdirAll(vaultDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate and store long-term public key
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
testValue := []byte("test-secret-value")
|
||||
|
||||
err = sv.Save(testValue)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify files were created
|
||||
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "pub.age")))
|
||||
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "priv.age")))
|
||||
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "value.age")))
|
||||
assert.True(t, fileExists(fs, filepath.Join(sv.Directory, "metadata.age")))
|
||||
}
|
||||
|
||||
func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
}
|
||||
|
||||
// Setup vault with long-term key
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
err := fs.MkdirAll(vaultDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version with custom metadata
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
now := time.Now()
|
||||
epochPlusOne := time.Unix(1, 0)
|
||||
sv.Metadata.NotBefore = &epochPlusOne
|
||||
sv.Metadata.NotAfter = &now
|
||||
|
||||
err = sv.Save([]byte("test-value"))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create new version object and load metadata
|
||||
sv2 := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
err = sv2.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify loaded metadata
|
||||
assert.Equal(t, sv.Metadata.ID, sv2.Metadata.ID)
|
||||
assert.Equal(t, sv.Metadata.SecretName, sv2.Metadata.SecretName)
|
||||
assert.Equal(t, sv.Metadata.Version, sv2.Metadata.Version)
|
||||
assert.NotNil(t, sv2.Metadata.NotBefore)
|
||||
assert.Equal(t, epochPlusOne.Unix(), sv2.Metadata.NotBefore.Unix())
|
||||
assert.NotNil(t, sv2.Metadata.NotAfter)
|
||||
}
|
||||
|
||||
func TestSecretVersionGetValue(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
}
|
||||
|
||||
// Setup vault with long-term key
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
err := fs.MkdirAll(vaultDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version
|
||||
sv := NewSecretVersion(vault, "test/secret", "20231215.001")
|
||||
originalValue := []byte("test-secret-value-12345")
|
||||
|
||||
err = sv.Save(originalValue)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve the value
|
||||
retrievedValue, err := sv.GetValue(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, originalValue, retrievedValue)
|
||||
}
|
||||
|
||||
func TestListVersions(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// No versions directory
|
||||
versions, err := ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, versions)
|
||||
|
||||
// Create some versions
|
||||
testVersions := []string{"20231215.001", "20231215.002", "20231216.001", "20231214.001"}
|
||||
for _, v := range testVersions {
|
||||
err := fs.MkdirAll(filepath.Join(versionsDir, v), 0755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create a file (not directory) that should be ignored
|
||||
err = afero.WriteFile(fs, filepath.Join(versionsDir, "ignore.txt"), []byte("test"), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// List versions
|
||||
versions, err = ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be sorted in reverse chronological order
|
||||
expected := []string{"20231216.001", "20231215.002", "20231215.001", "20231214.001"}
|
||||
assert.Equal(t, expected, versions)
|
||||
}
|
||||
|
||||
func TestGetCurrentVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
|
||||
// Simulate symlink with file content (works for both OsFs and MemMapFs)
|
||||
currentPath := filepath.Join(secretDir, "current")
|
||||
err := fs.MkdirAll(secretDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = afero.WriteFile(fs, currentPath, []byte("versions/20231216.001"), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
version, err := GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231216.001", version)
|
||||
}
|
||||
|
||||
func TestSetCurrentVersion(t *testing.T) {
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
|
||||
err := fs.MkdirAll(secretDir, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current version
|
||||
err = SetCurrentVersion(fs, secretDir, "20231216.002")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it was set
|
||||
version, err := GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231216.002", version)
|
||||
|
||||
// Update to different version
|
||||
err = SetCurrentVersion(fs, secretDir, "20231217.001")
|
||||
require.NoError(t, err)
|
||||
|
||||
version, err = GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231217.001", version)
|
||||
}
|
||||
|
||||
func TestVersionMetadataTimestamps(t *testing.T) {
|
||||
// Test that all timestamp fields behave consistently as pointers
|
||||
vm := VersionMetadata{
|
||||
ID: "test-id",
|
||||
SecretName: "test/secret",
|
||||
Version: "20231215.001",
|
||||
}
|
||||
|
||||
// All should be nil initially
|
||||
assert.Nil(t, vm.CreatedAt)
|
||||
assert.Nil(t, vm.NotBefore)
|
||||
assert.Nil(t, vm.NotAfter)
|
||||
|
||||
// Set timestamps
|
||||
now := time.Now()
|
||||
epoch := time.Unix(1, 0)
|
||||
future := now.Add(time.Hour)
|
||||
|
||||
vm.CreatedAt = &now
|
||||
vm.NotBefore = &epoch
|
||||
vm.NotAfter = &future
|
||||
|
||||
// All should be non-nil
|
||||
assert.NotNil(t, vm.CreatedAt)
|
||||
assert.NotNil(t, vm.NotBefore)
|
||||
assert.NotNil(t, vm.NotAfter)
|
||||
|
||||
// Values should match
|
||||
assert.Equal(t, now.Unix(), vm.CreatedAt.Unix())
|
||||
assert.Equal(t, int64(1), vm.NotBefore.Unix())
|
||||
assert.Equal(t, future.Unix(), vm.NotAfter.Unix())
|
||||
}
|
||||
|
||||
// Helper function
|
||||
func fileExists(fs afero.Fs, path string) bool {
|
||||
exists, _ := afero.Exists(fs, path)
|
||||
return exists
|
||||
}
|
||||
Reference in New Issue
Block a user