check / check (push) Waiting to run
secret.IdentityToLockedBuffer replaces the eight places that converted an age identity's String() to bytes for a locked buffer and left the string, which holds the private key, in ordinary memory. It moves the string's own bytes into the buffer, which overwrites them. The copies age makes while encoding the key remain; the function's comment says so. TODO.md drops these places from the 1.0 memory-security entry, along with its stale version.go reference. Model: opus-5-5
587 lines
17 KiB
Go
587 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. 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)
|
|
}
|
|
|
|
versionPrivateKeyBuffer := IdentityToLockedBuffer(versionIdentity)
|
|
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
|
|
}
|