Files
secret/internal/secret/version.go
sneak 9ee216f629
All checks were successful
check / check (push) Successful in 1m8s
Update golangci-lint to v2.12.2 with canonical config
- Replace .golangci.yml with the canonical strict config (all linters
  enabled except the standard disable list; lll 88, funlen 80/50,
  cyclop 15, dupl 100; test files now linted)
- Pin the Dockerfile lint stage to golangci/golangci-lint:v2.12.2 by
  tag and digest (Debian-based)
- Fix all ~1550 findings surfaced by the new config: line wrapping,
  wsl_v5/nlreturn blank lines, noinlineerr splits, err113 sentinel
  errors, perfsprint/modernize rewrites, goconst constants, thelper,
  testifylint, noctx CommandContext, testpackage conversions,
  t.Parallel() where safe, and complexity/dupl helper extraction
- Record the change and follow-up items in TODO.md
2026-08-07 17:27:23 +00:00

558 lines
16 KiB
Go

package secret
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"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
)
var (
errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)")
errNilValueBuffer = errors.New("value buffer is nil")
)
// 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"` // Expiry (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
err := fs.MkdirAll(versionsDir, DirPerms)
if 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
_, err := fmt.Sscanf(parts[1], "%03d", &serial)
if err != nil {
Warn("Skipping malformed version directory name",
"name", entry.Name(), "error", err)
continue
}
if serial > maxSerial {
maxSerial = serial
}
}
// Generate new version name
newSerial := maxSerial + 1
if newSerial > maxVersionsPerDay {
return "", errMaxVersionsPerDay
}
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 errNilValueBuffer
}
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
err := fs.MkdirAll(sv.Directory, DirPerms)
if err != nil {
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
return fmt.Errorf("failed to create version directory: %w", err)
}
// 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)
}
// 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", versionIdentity.Recipient().String()),
)
err = sv.writePublicKeyAndValue(fs, versionIdentity, value)
if err != nil {
return err
}
err = sv.writeEncryptedPrivateKey(fs, versionPrivateKeyBuffer)
if err != nil {
return err
}
err = sv.writeEncryptedMetadata(fs, versionIdentity)
if err != nil {
return 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
err = json.Unmarshal(metadataBuffer.Bytes(), &metadata)
if 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
}
// writePublicKeyAndValue stores the version's public key and the value
// encrypted to it.
func (sv *Version) writePublicKeyAndValue(
fs afero.Fs,
versionIdentity *age.X25519Identity,
value *memguard.LockedBuffer,
) error {
versionPublicKey := versionIdentity.Recipient().String()
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
Debug("Writing version public key", "path", pubKeyPath)
err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms)
if err != nil {
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
return fmt.Errorf("failed to write version public key: %w", err)
}
// 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)
}
valuePath := filepath.Join(sv.Directory, "value.age")
Debug("Writing encrypted version value", "path", valuePath)
err = afero.WriteFile(fs, valuePath, encryptedValue, FilePerms)
if err != nil {
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
return fmt.Errorf("failed to write encrypted version value: %w", err)
}
return nil
}
// writeEncryptedPrivateKey encrypts the version's private key to the
// vault's long-term public key and stores it.
func (sv *Version) writeEncryptedPrivateKey(
fs afero.Fs,
versionPrivateKeyBuffer *memguard.LockedBuffer,
) error {
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)
}
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)
}
privKeyPath := filepath.Join(sv.Directory, "priv.age")
Debug("Writing encrypted version private key", "path", privKeyPath)
err = afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms)
if 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)
}
return nil
}
// writeEncryptedMetadata encrypts the version metadata to the version's
// public key and stores it.
func (sv *Version) writeEncryptedMetadata(
fs afero.Fs,
versionIdentity *age.X25519Identity,
) error {
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)
err = afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms)
if err != nil {
Debug("Failed to write encrypted version metadata",
"error", err, "path", metadataPath)
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
}
return 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" file points to
// The file contains just the version name (e.g., "20231215.001")
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
currentPath := filepath.Join(secretDir, "current")
fileData, err := afero.ReadFile(fs, currentPath)
if err != nil {
return "", fmt.Errorf("failed to read current version file: %w", err)
}
version := strings.TrimSpace(string(fileData))
return version, nil
}
// SetCurrentVersion updates the "current" file to point to a specific version
// The file contains just the version name (e.g., "20231215.001")
func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
currentPath := filepath.Join(secretDir, "current")
// Remove existing file if it exists
_ = fs.Remove(currentPath)
// Write just the version name to the file
err := afero.WriteFile(fs, currentPath, []byte(version), FilePerms)
if err != nil {
return fmt.Errorf("failed to create current version file: %w", err)
}
return nil
}