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" ) 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 []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 *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 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 *Version) 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), "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 }