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. The files are written into a // temporary directory that is renamed to sv.Directory once all of them are // complete, so the version directory is either whole or absent, even if the // process dies part-way. 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 the versions directory the finished version is renamed into err := fs.MkdirAll(filepath.Dir(sv.Directory), DirPerms) if err != nil { Debug("Failed to create versions directory", "error", err, "dir", sv.Directory) return fmt.Errorf("failed to create versions directory: %w", err) } tmpDir, err := TempDirFor(fs, sv.Directory) if err != nil { return err } // Once the rename below has moved it into place, this finds nothing. defer func() { _ = fs.RemoveAll(tmpDir) }() // 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, tmpDir, versionIdentity, value) if err != nil { return err } err = sv.writeEncryptedPrivateKey(fs, tmpDir, versionPrivateKeyBuffer) if err != nil { return err } err = sv.writeEncryptedMetadata(fs, tmpDir, versionIdentity) if err != nil { return err } err = fs.Rename(tmpDir, sv.Directory) if err != nil { Debug("Failed to move version into place", "error", err, "dir", sv.Directory) return fmt.Errorf("failed to move version into place: %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 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 in dir. func (sv *Version) writePublicKeyAndValue( fs afero.Fs, dir string, versionIdentity *age.X25519Identity, value *memguard.LockedBuffer, ) error { versionPublicKey := versionIdentity.Recipient().String() pubKeyPath := filepath.Join(dir, "pub.age") Debug("Writing version public key", "path", pubKeyPath) err := WriteFileAtomic(fs, pubKeyPath, []byte(versionPublicKey)) 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(dir, "value.age") Debug("Writing encrypted version value", "path", valuePath) err = WriteFileAtomic(fs, valuePath, encryptedValue) 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 in dir. func (sv *Version) writeEncryptedPrivateKey( fs afero.Fs, dir string, 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(dir, "priv.age") Debug("Writing encrypted version private key", "path", privKeyPath) err = WriteFileAtomic(fs, privKeyPath, encryptedPrivKey) 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 in dir. func (sv *Version) writeEncryptedMetadata( fs afero.Fs, dir string, 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(dir, "metadata.age") Debug("Writing encrypted version metadata", "path", metadataPath) err = WriteFileAtomic(fs, metadataPath, encryptedMetadata) 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"). It is // replaced in one rename, so once written it always exists. func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error { currentPath := filepath.Join(secretDir, "current") err := WriteFileAtomic(fs, currentPath, []byte(version)) if err != nil { return fmt.Errorf("failed to create current version file: %w", err) } return nil }