check / check (push) Failing after 35s
version rm, version promote and get --version joined the version argument into a path unchecked, so "", ".", "..", "../../.." removed or read every version, the secret, the vault or directories above it. A version is now accepted only if it is one of the versions ListVersions lists for the secret, compared by name before any path is built (secret.VersionExists, used by all three). An empty --version is rejected instead of meaning the current version: GetSecretVersion no longer treats "" as current, and GetSecret looks the current version up itself. Model: opus-5-5
571 lines
17 KiB
Go
571 lines
17 KiB
Go
package secret
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"path/filepath"
|
|
"slices"
|
|
"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
|
|
}
|
|
|
|
// VersionExists reports whether version is one of the versions ListVersions
|
|
// lists for the secret in secretDir. It only compares names, so a version
|
|
// the user typed can be checked with it before any path is built from it.
|
|
func VersionExists(fs afero.Fs, secretDir string, version string) (bool, error) {
|
|
versions, err := ListVersions(fs, secretDir)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return slices.Contains(versions, version), 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
|
|
}
|