feat: Implement per-secret key architecture with individual keypairs - Each secret now has its own encryption keypair stored as pub.age, priv.age, value.age - Secret private keys are encrypted to vault long-term public key - Values stored as value.age instead of secret.age for new architecture
This commit is contained in:
parent
43767c725f
commit
f838c8cb98
@ -225,7 +225,7 @@ func (s *Secret) GetEncryptedData() ([]byte, error) {
|
||||
slog.String("vault_name", s.vault.Name),
|
||||
)
|
||||
|
||||
secretPath := filepath.Join(s.Directory, "secret.age")
|
||||
secretPath := filepath.Join(s.Directory, "value.age")
|
||||
|
||||
Debug("Reading encrypted secret file", "secret_path", secretPath)
|
||||
|
||||
@ -250,7 +250,7 @@ func (s *Secret) Exists() (bool, error) {
|
||||
slog.String("vault_name", s.vault.Name),
|
||||
)
|
||||
|
||||
secretPath := filepath.Join(s.Directory, "secret.age")
|
||||
secretPath := filepath.Join(s.Directory, "value.age")
|
||||
|
||||
Debug("Checking secret file existence", "secret_path", secretPath)
|
||||
|
||||
@ -266,4 +266,4 @@ func (s *Secret) Exists() (bool, error) {
|
||||
)
|
||||
|
||||
return exists, nil
|
||||
}
|
||||
}
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -46,60 +47,220 @@ type Configuration struct {
|
||||
|
||||
// Vault represents a secrets vault
|
||||
type Vault struct {
|
||||
Name string
|
||||
fs afero.Fs
|
||||
stateDir string
|
||||
Name string
|
||||
fs afero.Fs
|
||||
stateDir string
|
||||
longTermKey *age.X25519Identity // In-memory long-term key when unlocked
|
||||
}
|
||||
|
||||
// NewVault creates a new Vault instance
|
||||
func NewVault(fs afero.Fs, name string, stateDir string) *Vault {
|
||||
return &Vault{
|
||||
Name: name,
|
||||
fs: fs,
|
||||
stateDir: stateDir,
|
||||
Name: name,
|
||||
fs: fs,
|
||||
stateDir: stateDir,
|
||||
longTermKey: nil,
|
||||
}
|
||||
}
|
||||
|
||||
// Locked returns true if the vault doesn't have a long-term key in memory
|
||||
func (v *Vault) Locked() bool {
|
||||
return v.longTermKey == nil
|
||||
}
|
||||
|
||||
// Unlock sets the long-term key in memory, unlocking the vault
|
||||
func (v *Vault) Unlock(key *age.X25519Identity) {
|
||||
v.longTermKey = key
|
||||
}
|
||||
|
||||
// GetLongTermKey returns the long-term key if available in memory
|
||||
func (v *Vault) GetLongTermKey() *age.X25519Identity {
|
||||
return v.longTermKey
|
||||
}
|
||||
|
||||
// ClearLongTermKey removes the long-term key from memory (locks the vault)
|
||||
func (v *Vault) ClearLongTermKey() {
|
||||
v.longTermKey = nil
|
||||
}
|
||||
|
||||
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it from available sources
|
||||
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||
// If we have it in memory, return it
|
||||
if !v.Locked() {
|
||||
return v.longTermKey, nil
|
||||
}
|
||||
|
||||
Debug("Vault is locked, attempting to unlock", "vault_name", v.Name)
|
||||
|
||||
// Try to derive from environment mnemonic first
|
||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
||||
Debug("Using mnemonic from environment for long-term key derivation", "vault_name", v.Name)
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
|
||||
if err != nil {
|
||||
Debug("Failed to derive long-term key from mnemonic", "error", err, "vault_name", v.Name)
|
||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully derived long-term key from mnemonic",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("public_key", ltIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
// No mnemonic available, try to use current unlock key
|
||||
Debug("No mnemonic available, using current unlock key to unlock vault", "vault_name", v.Name)
|
||||
|
||||
// Get current unlock key
|
||||
unlockKey, err := v.GetCurrentUnlockKey()
|
||||
if err != nil {
|
||||
Debug("Failed to get current unlock key", "error", err, "vault_name", v.Name)
|
||||
return nil, fmt.Errorf("failed to get current unlock key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Retrieved current unlock key for vault unlock",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("unlock_key_type", unlockKey.GetType()),
|
||||
slog.String("unlock_key_id", unlockKey.GetID()),
|
||||
)
|
||||
|
||||
// Get unlock key identity
|
||||
unlockIdentity, err := unlockKey.GetIdentity()
|
||||
if err != nil {
|
||||
Debug("Failed to get unlock key identity", "error", err, "unlock_key_type", unlockKey.GetType())
|
||||
return nil, fmt.Errorf("failed to get unlock key identity: %w", err)
|
||||
}
|
||||
|
||||
// Read encrypted long-term private key from unlock key directory
|
||||
unlockKeyDir := unlockKey.GetDirectory()
|
||||
encryptedLtPrivKeyPath := filepath.Join(unlockKeyDir, "longterm.age")
|
||||
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
|
||||
|
||||
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted long-term private key", "error", err, "path", encryptedLtPrivKeyPath)
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Read encrypted long-term private key",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("unlock_key_type", unlockKey.GetType()),
|
||||
slog.Int("encrypted_length", len(encryptedLtPrivKey)),
|
||||
)
|
||||
|
||||
// Decrypt long-term private key using unlock key
|
||||
Debug("Decrypting long-term private key with unlock key", "unlock_key_type", unlockKey.GetType())
|
||||
ltPrivKeyData, err := decryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt long-term private key", "error", err, "unlock_key_type", unlockKey.GetType())
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted long-term private key",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("unlock_key_type", unlockKey.GetType()),
|
||||
slog.Int("decrypted_length", len(ltPrivKeyData)),
|
||||
)
|
||||
|
||||
// Parse long-term private key
|
||||
Debug("Parsing long-term private key", "vault_name", v.Name)
|
||||
ltIdentity, err := age.ParseX25519Identity(string(ltPrivKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse long-term private key", "error", err, "vault_name", v.Name)
|
||||
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully obtained long-term identity via unlock key",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("unlock_key_type", unlockKey.GetType()),
|
||||
slog.String("public_key", ltIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
// resolveVaultSymlink resolves the currentvault symlink by changing into it and getting the absolute path
|
||||
func resolveVaultSymlink(fs afero.Fs, symlinkPath string) (string, error) {
|
||||
Debug("resolveVaultSymlink starting", "symlink_path", symlinkPath)
|
||||
|
||||
// For real filesystems, we can use os.Chdir and os.Getwd
|
||||
if _, ok := fs.(*afero.OsFs); ok {
|
||||
Debug("Using real filesystem symlink resolution")
|
||||
|
||||
// Check what the symlink points to first
|
||||
Debug("Checking symlink target", "symlink_path", symlinkPath)
|
||||
linkTarget, err := os.Readlink(symlinkPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read symlink target", "error", err, "symlink_path", symlinkPath)
|
||||
// Maybe it's not a symlink, try reading as file
|
||||
Debug("Trying to read as file instead of symlink")
|
||||
targetBytes, err := os.ReadFile(symlinkPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read as file", "error", err)
|
||||
return "", fmt.Errorf("failed to read vault symlink or file: %w", err)
|
||||
}
|
||||
linkTarget = strings.TrimSpace(string(targetBytes))
|
||||
Debug("Read vault path from file", "target", linkTarget)
|
||||
return linkTarget, nil
|
||||
}
|
||||
Debug("Symlink points to", "target", linkTarget)
|
||||
|
||||
// Save current directory
|
||||
Debug("Getting current directory")
|
||||
originalDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
Debug("Failed to get current directory", "error", err)
|
||||
return "", fmt.Errorf("failed to get current directory: %w", err)
|
||||
}
|
||||
Debug("Got current directory", "original_dir", originalDir)
|
||||
|
||||
// Change to the symlink directory
|
||||
Debug("Changing to symlink directory", "symlink_path", symlinkPath)
|
||||
Debug("About to call os.Chdir - this might hang if symlink is broken")
|
||||
err = os.Chdir(symlinkPath)
|
||||
if err != nil {
|
||||
Debug("Failed to change into vault symlink", "error", err, "symlink_path", symlinkPath)
|
||||
return "", fmt.Errorf("failed to change into vault symlink: %w", err)
|
||||
}
|
||||
Debug("Changed to symlink directory successfully - os.Chdir completed")
|
||||
|
||||
// Get absolute path of current directory
|
||||
Debug("Getting absolute path of current directory")
|
||||
absolutePath, err := os.Getwd()
|
||||
if err != nil {
|
||||
Debug("Failed to get absolute path", "error", err)
|
||||
// Try to restore original directory before returning error
|
||||
if restoreErr := os.Chdir(originalDir); restoreErr != nil {
|
||||
Debug("Failed to restore original directory", "restore_error", restoreErr)
|
||||
return "", fmt.Errorf("failed to get absolute path: %w (and failed to restore directory: %v)", err, restoreErr)
|
||||
}
|
||||
return "", fmt.Errorf("failed to get absolute path: %w", err)
|
||||
}
|
||||
Debug("Got absolute path", "absolute_path", absolutePath)
|
||||
|
||||
// Restore original directory
|
||||
Debug("Restoring original directory", "original_dir", originalDir)
|
||||
err = os.Chdir(originalDir)
|
||||
if err != nil {
|
||||
Debug("Failed to restore original directory", "error", err, "original_dir", originalDir)
|
||||
return "", fmt.Errorf("failed to restore original directory: %w", err)
|
||||
}
|
||||
Debug("Restored original directory successfully")
|
||||
|
||||
Debug("resolveVaultSymlink completed successfully", "result", absolutePath)
|
||||
return absolutePath, nil
|
||||
} else {
|
||||
Debug("Using mock filesystem fallback")
|
||||
// Fallback for mock filesystems: read the path from file contents
|
||||
targetBytes, err := afero.ReadFile(fs, symlinkPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read vault path from file", "error", err, "symlink_path", symlinkPath)
|
||||
return "", fmt.Errorf("failed to read vault path: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(targetBytes)), nil
|
||||
result := strings.TrimSpace(string(targetBytes))
|
||||
Debug("Read vault path from file", "result", result)
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,6 +269,7 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
||||
DebugWith("Getting current vault", slog.String("state_dir", stateDir))
|
||||
|
||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||
Debug("Checking current vault symlink", "path", currentVaultPath)
|
||||
|
||||
// Check if the symlink exists
|
||||
_, err := fs.Stat(currentVaultPath)
|
||||
@ -115,24 +277,32 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
||||
Debug("Failed to stat current vault symlink", "error", err, "path", currentVaultPath)
|
||||
return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
|
||||
}
|
||||
Debug("Current vault symlink exists")
|
||||
|
||||
// Resolve the symlink to get the target directory
|
||||
Debug("Resolving vault symlink")
|
||||
targetPath, err := resolveVaultSymlink(fs, currentVaultPath)
|
||||
if err != nil {
|
||||
Debug("Failed to resolve vault symlink", "error", err, "symlink_path", currentVaultPath)
|
||||
return nil, err
|
||||
}
|
||||
Debug("Resolved vault symlink", "target_path", targetPath)
|
||||
|
||||
// Extract vault name from the target path
|
||||
// Target path should be something like "/state/vaults.d/vaultname"
|
||||
vaultName := filepath.Base(targetPath)
|
||||
Debug("Extracted vault name", "vault_name", vaultName)
|
||||
|
||||
DebugWith("Current vault resolved",
|
||||
slog.String("vault_name", vaultName),
|
||||
slog.String("target_path", targetPath),
|
||||
)
|
||||
|
||||
return NewVault(fs, vaultName, stateDir), nil
|
||||
Debug("Creating NewVault instance")
|
||||
vault := NewVault(fs, vaultName, stateDir)
|
||||
Debug("Created NewVault instance successfully")
|
||||
|
||||
return vault, nil
|
||||
}
|
||||
|
||||
// ListVaults returns a list of all available vaults
|
||||
@ -259,6 +429,7 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
||||
return fmt.Errorf("failed to create symlink for current vault: %w", err)
|
||||
}
|
||||
} else {
|
||||
// FIXME this code should not exist! we do not support the currentvaultpath not being a symlink. remove this!
|
||||
Debug("Creating vault path file (symlinks not supported)", "target", vaultDir, "file", currentVaultPath)
|
||||
// Fallback: write the vault directory path as a regular file
|
||||
if err := afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), 0600); err != nil {
|
||||
@ -324,6 +495,15 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
||||
return secrets, nil
|
||||
}
|
||||
|
||||
// isValidSecretName validates secret names according to the format [a-z0-9\.\-\_\/]+
|
||||
func isValidSecretName(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_\/]+$`, name)
|
||||
return matched
|
||||
}
|
||||
|
||||
// AddSecret adds a secret to this vault
|
||||
func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
DebugWith("Adding secret to vault",
|
||||
@ -333,11 +513,20 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
slog.Bool("force", force),
|
||||
)
|
||||
|
||||
// Validate secret name
|
||||
if !isValidSecretName(name) {
|
||||
Debug("Invalid secret name provided", "secret_name", name)
|
||||
return fmt.Errorf("invalid secret name '%s': must match pattern [a-z0-9.\\-_/]+", name)
|
||||
}
|
||||
Debug("Secret name validation passed", "secret_name", name)
|
||||
|
||||
Debug("Getting vault directory")
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
Debug("Failed to get vault directory for secret addition", "error", err, "vault_name", v.Name)
|
||||
return err
|
||||
}
|
||||
Debug("Got vault directory", "vault_dir", vaultDir)
|
||||
|
||||
// Convert slashes to percent signs for storage
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
@ -349,11 +538,14 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
)
|
||||
|
||||
// Check if secret already exists
|
||||
Debug("Checking if secret already exists", "secret_dir", secretDir)
|
||||
exists, err := afero.DirExists(v.fs, secretDir)
|
||||
if err != nil {
|
||||
Debug("Failed to check if secret exists", "error", err, "secret_dir", secretDir)
|
||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
Debug("Secret existence check complete", "exists", exists)
|
||||
|
||||
if exists && !force {
|
||||
Debug("Secret already exists and force not specified", "secret_name", name, "secret_dir", secretDir)
|
||||
return fmt.Errorf("secret %s already exists (use --force to overwrite)", name)
|
||||
@ -365,8 +557,56 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
Debug("Failed to create secret directory", "error", err, "secret_dir", secretDir)
|
||||
return fmt.Errorf("failed to create secret directory: %w", err)
|
||||
}
|
||||
Debug("Created secret directory successfully")
|
||||
|
||||
// Get long-term public key for encryption
|
||||
// Step 1: Generate a new keypair for this secret
|
||||
Debug("Generating secret-specific keypair", "secret_name", name)
|
||||
secretIdentity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
Debug("Failed to generate secret keypair", "error", err, "secret_name", name)
|
||||
return fmt.Errorf("failed to generate secret keypair: %w", err)
|
||||
}
|
||||
|
||||
secretPublicKey := secretIdentity.Recipient().String()
|
||||
secretPrivateKey := secretIdentity.String()
|
||||
|
||||
DebugWith("Generated secret keypair",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("public_key", secretPublicKey),
|
||||
)
|
||||
|
||||
// Step 2: Store the secret's public key
|
||||
pubKeyPath := filepath.Join(secretDir, "pub.age")
|
||||
Debug("Writing secret public key", "path", pubKeyPath)
|
||||
if err := afero.WriteFile(v.fs, pubKeyPath, []byte(secretPublicKey), 0600); err != nil {
|
||||
Debug("Failed to write secret public key", "error", err, "path", pubKeyPath)
|
||||
return fmt.Errorf("failed to write secret public key: %w", err)
|
||||
}
|
||||
Debug("Wrote secret public key successfully")
|
||||
|
||||
// Step 3: Encrypt the secret value to the secret's public key
|
||||
Debug("Encrypting secret value to secret's public key", "secret_name", name)
|
||||
encryptedValue, err := encryptToRecipient(value, secretIdentity.Recipient())
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt secret value", "error", err, "secret_name", name)
|
||||
return fmt.Errorf("failed to encrypt secret value: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Secret value encrypted",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("encrypted_length", len(encryptedValue)),
|
||||
)
|
||||
|
||||
// Step 4: Store the encrypted secret value as value.age
|
||||
valuePath := filepath.Join(secretDir, "value.age")
|
||||
Debug("Writing encrypted secret value", "path", valuePath)
|
||||
if err := afero.WriteFile(v.fs, valuePath, encryptedValue, 0600); err != nil {
|
||||
Debug("Failed to write encrypted secret value", "error", err, "path", valuePath)
|
||||
return fmt.Errorf("failed to write encrypted secret value: %w", err)
|
||||
}
|
||||
Debug("Wrote encrypted secret value successfully")
|
||||
|
||||
// Step 5: Get long-term public key for encrypting the secret's private key
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
Debug("Reading long-term public key", "path", ltPubKeyPath)
|
||||
|
||||
@ -375,7 +615,9 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
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("Read long-term public key successfully", "key_length", len(ltPubKeyData))
|
||||
|
||||
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)
|
||||
@ -384,25 +626,30 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
|
||||
DebugWith("Parsed long-term public key", slog.String("recipient", ltRecipient.String()))
|
||||
|
||||
// Encrypt secret data
|
||||
Debug("Encrypting secret data")
|
||||
encryptedData, err := encryptToRecipient(value, ltRecipient)
|
||||
// Step 6: Encrypt the secret's private key to the long-term public key
|
||||
Debug("Encrypting secret private key to long-term public key", "secret_name", name)
|
||||
encryptedPrivKey, err := encryptToRecipient([]byte(secretPrivateKey), ltRecipient)
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt secret", "error", err)
|
||||
return fmt.Errorf("failed to encrypt secret: %w", err)
|
||||
Debug("Failed to encrypt secret private key", "error", err, "secret_name", name)
|
||||
return fmt.Errorf("failed to encrypt secret private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Secret encrypted", slog.Int("encrypted_length", len(encryptedData)))
|
||||
DebugWith("Secret private key encrypted",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("encrypted_length", len(encryptedPrivKey)),
|
||||
)
|
||||
|
||||
// Write encrypted secret
|
||||
secretPath := filepath.Join(secretDir, "secret.age")
|
||||
Debug("Writing encrypted secret", "path", secretPath)
|
||||
if err := afero.WriteFile(v.fs, secretPath, encryptedData, 0600); err != nil {
|
||||
Debug("Failed to write encrypted secret", "error", err, "path", secretPath)
|
||||
return fmt.Errorf("failed to write encrypted secret: %w", err)
|
||||
// Step 7: Store the encrypted secret private key as priv.age
|
||||
privKeyPath := filepath.Join(secretDir, "priv.age")
|
||||
Debug("Writing encrypted secret private key", "path", privKeyPath)
|
||||
if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, 0600); err != nil {
|
||||
Debug("Failed to write encrypted secret private key", "error", err, "path", privKeyPath)
|
||||
return fmt.Errorf("failed to write encrypted secret private key: %w", err)
|
||||
}
|
||||
Debug("Wrote encrypted secret private key successfully")
|
||||
|
||||
// Create and write metadata
|
||||
// Step 8: Create and write metadata
|
||||
Debug("Creating secret metadata")
|
||||
now := time.Now()
|
||||
metadata := SecretMetadata{
|
||||
Name: name,
|
||||
@ -416,11 +663,13 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
slog.Time("updated_at", metadata.UpdatedAt),
|
||||
)
|
||||
|
||||
Debug("Marshaling secret metadata")
|
||||
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
|
||||
if err != nil {
|
||||
Debug("Failed to marshal secret metadata", "error", err)
|
||||
return fmt.Errorf("failed to marshal secret metadata: %w", err)
|
||||
}
|
||||
Debug("Marshaled secret metadata successfully")
|
||||
|
||||
metadataPath := filepath.Join(secretDir, "secret-metadata.json")
|
||||
Debug("Writing secret metadata", "path", metadataPath)
|
||||
@ -428,8 +677,9 @@ func (v *Vault) AddSecret(name string, value []byte, force bool) error {
|
||||
Debug("Failed to write secret metadata", "error", err, "path", metadataPath)
|
||||
return fmt.Errorf("failed to write secret metadata: %w", err)
|
||||
}
|
||||
Debug("Wrote secret metadata successfully")
|
||||
|
||||
Debug("Successfully added secret to vault", "secret_name", name, "vault_name", v.Name)
|
||||
Debug("Successfully added secret to vault with per-secret key architecture", "secret_name", name, "vault_name", v.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -440,105 +690,161 @@ func (v *Vault) GetSecret(name string) ([]byte, error) {
|
||||
slog.String("secret_name", name),
|
||||
)
|
||||
|
||||
// Check if we have SB_SECRET_MNEMONIC environment variable for direct decryption
|
||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
||||
Debug("Using mnemonic from environment for secret decryption")
|
||||
|
||||
// Use mnemonic directly to derive long-term key
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
|
||||
if err != nil {
|
||||
Debug("Failed to derive long-term key from environment mnemonic", "error", err)
|
||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
|
||||
// Create a secret object to read the encrypted data
|
||||
secret := NewSecret(v, name)
|
||||
|
||||
// Check if secret exists
|
||||
exists, err := secret.Exists()
|
||||
if err != nil {
|
||||
Debug("Failed to check if secret exists", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
|
||||
return nil, fmt.Errorf("secret %s not found", name)
|
||||
}
|
||||
|
||||
Debug("Secret exists, reading encrypted data", "secret_name", name)
|
||||
|
||||
// Read encrypted secret data
|
||||
encryptedData, err := secret.GetEncryptedData()
|
||||
if err != nil {
|
||||
Debug("Failed to get encrypted secret data", "error", err, "secret_name", name)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
DebugWith("Retrieved encrypted secret data",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("encrypted_length", len(encryptedData)),
|
||||
)
|
||||
|
||||
// Decrypt secret data
|
||||
Debug("Decrypting secret with long-term key", "secret_name", name)
|
||||
decryptedData, err := decryptWithIdentity(encryptedData, ltIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt secret", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted secret",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("decrypted_length", len(decryptedData)),
|
||||
)
|
||||
|
||||
return decryptedData, nil
|
||||
}
|
||||
|
||||
Debug("Using unlock key for secret decryption", "secret_name", name)
|
||||
|
||||
// Use unlock key to decrypt the secret
|
||||
unlockKey, err := v.GetCurrentUnlockKey()
|
||||
if err != nil {
|
||||
Debug("Failed to get current unlock key", "error", err, "vault_name", v.Name)
|
||||
return nil, fmt.Errorf("failed to get current unlock key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Retrieved current unlock key",
|
||||
slog.String("unlock_key_type", unlockKey.GetType()),
|
||||
slog.String("unlock_key_id", unlockKey.GetID()),
|
||||
)
|
||||
|
||||
// Create a secret object
|
||||
// Create a secret object to handle file access
|
||||
secret := NewSecret(v, name)
|
||||
|
||||
// Check if secret exists
|
||||
exists, err := secret.Exists()
|
||||
if err != nil {
|
||||
Debug("Failed to check if secret exists via unlock key", "error", err, "secret_name", name)
|
||||
Debug("Failed to check if secret exists", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
Debug("Secret not found via unlock key", "secret_name", name, "vault_name", v.Name)
|
||||
Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
|
||||
return nil, fmt.Errorf("secret %s not found", name)
|
||||
}
|
||||
|
||||
Debug("Decrypting secret via unlock key", "secret_name", name, "unlock_key_type", unlockKey.GetType())
|
||||
Debug("Secret exists, proceeding with vault unlock and decryption", "secret_name", name)
|
||||
|
||||
// Let the unlock key handle decryption
|
||||
decryptedData, err := unlockKey.DecryptSecret(secret)
|
||||
// Step 1: Unlock the vault (get long-term key in memory)
|
||||
longTermIdentity, err := v.UnlockVault()
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt secret via unlock key", "error", err, "secret_name", name, "unlock_key_type", unlockKey.GetType())
|
||||
Debug("Failed to unlock vault", "error", err, "vault_name", v.Name)
|
||||
return nil, fmt.Errorf("failed to unlock vault: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully unlocked vault",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("secret_name", name),
|
||||
slog.String("long_term_public_key", longTermIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
// Step 2: Use the unlocked vault to decrypt the secret
|
||||
decryptedValue, err := v.decryptSecretWithLongTermKey(name, longTermIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt secret with long-term key", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to decrypt secret: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted secret with per-secret key architecture",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.Int("decrypted_length", len(decryptedValue)),
|
||||
)
|
||||
|
||||
return decryptedValue, nil
|
||||
}
|
||||
|
||||
// UnlockVault unlocks the vault and returns the long-term private key
|
||||
func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
||||
Debug("Unlocking vault", "vault_name", v.Name)
|
||||
|
||||
// If vault is already unlocked, return the cached key
|
||||
if !v.Locked() {
|
||||
Debug("Vault already unlocked, returning cached long-term key", "vault_name", v.Name)
|
||||
return v.longTermKey, nil
|
||||
}
|
||||
|
||||
// Get or derive the long-term key (but don't store it yet)
|
||||
longTermIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
Debug("Failed to get or derive long-term key", "error", err, "vault_name", v.Name)
|
||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Now unlock the vault by storing the key in memory
|
||||
v.Unlock(longTermIdentity)
|
||||
|
||||
DebugWith("Successfully unlocked vault",
|
||||
slog.String("vault_name", v.Name),
|
||||
slog.String("public_key", longTermIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
return longTermIdentity, nil
|
||||
}
|
||||
|
||||
// decryptSecretWithLongTermKey decrypts a secret using the provided long-term key
|
||||
func (v *Vault) decryptSecretWithLongTermKey(name string, longTermIdentity *age.X25519Identity) ([]byte, error) {
|
||||
DebugWith("Decrypting secret with long-term key",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("vault_name", v.Name),
|
||||
)
|
||||
|
||||
// Get vault and secret directories
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted secret via unlock key",
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
|
||||
|
||||
// Step 1: Read the encrypted secret private key from priv.age
|
||||
encryptedSecretPrivKeyPath := filepath.Join(secretDir, "priv.age")
|
||||
Debug("Reading encrypted secret private key", "path", encryptedSecretPrivKeyPath)
|
||||
|
||||
encryptedSecretPrivKey, err := afero.ReadFile(v.fs, encryptedSecretPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted secret private key", "error", err, "path", encryptedSecretPrivKeyPath)
|
||||
return nil, fmt.Errorf("failed to read encrypted secret private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Read encrypted secret private key",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("unlock_key_type", unlockKey.GetType()),
|
||||
slog.Int("decrypted_length", len(decryptedData)),
|
||||
slog.Int("encrypted_length", len(encryptedSecretPrivKey)),
|
||||
)
|
||||
|
||||
return decryptedData, nil
|
||||
// Step 2: Decrypt the secret's private key using the long-term private key
|
||||
Debug("Decrypting secret private key with long-term key", "secret_name", name)
|
||||
secretPrivKeyData, err := decryptWithIdentity(encryptedSecretPrivKey, longTermIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt secret private key", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to decrypt secret private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Parse the secret's private key
|
||||
Debug("Parsing secret private key", "secret_name", name)
|
||||
secretIdentity, err := age.ParseX25519Identity(string(secretPrivKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse secret private key", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to parse secret private key: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully parsed secret identity",
|
||||
slog.String("secret_name", name),
|
||||
slog.String("public_key", secretIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
// Step 4: Read the encrypted secret value from value.age
|
||||
encryptedValuePath := filepath.Join(secretDir, "value.age")
|
||||
Debug("Reading encrypted secret value", "path", encryptedValuePath)
|
||||
|
||||
encryptedValue, err := afero.ReadFile(v.fs, encryptedValuePath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted secret value", "error", err, "path", encryptedValuePath)
|
||||
return nil, fmt.Errorf("failed to read encrypted secret value: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Read encrypted secret value",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("encrypted_length", len(encryptedValue)),
|
||||
)
|
||||
|
||||
// Step 5: Decrypt the secret value using the secret's private key
|
||||
Debug("Decrypting secret value with secret's private key", "secret_name", name)
|
||||
decryptedValue, err := decryptWithIdentity(encryptedValue, secretIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt secret value", "error", err, "secret_name", name)
|
||||
return nil, fmt.Errorf("failed to decrypt secret value: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Successfully decrypted secret value",
|
||||
slog.String("secret_name", name),
|
||||
slog.Int("decrypted_length", len(decryptedValue)),
|
||||
)
|
||||
|
||||
return decryptedValue, nil
|
||||
}
|
||||
|
||||
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
|
||||
@ -873,7 +1179,12 @@ func (v *Vault) SelectUnlockKey(keyID string) error {
|
||||
}
|
||||
|
||||
// CreatePassphraseKey creates a new passphrase-protected unlock key for this vault
|
||||
// The vault must be unlocked (have a long-term key in memory) before calling this method
|
||||
func (v *Vault) CreatePassphraseKey(passphrase string) (*PassphraseUnlockKey, error) {
|
||||
if v.Locked() {
|
||||
return nil, fmt.Errorf("vault must be unlocked before creating passphrase key")
|
||||
}
|
||||
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
@ -923,78 +1234,11 @@ func (v *Vault) CreatePassphraseKey(passphrase string) (*PassphraseUnlockKey, er
|
||||
return nil, fmt.Errorf("failed to write encrypted private key: %w", err)
|
||||
}
|
||||
|
||||
// Get or derive the long-term private key
|
||||
var ltPrivKeyData []byte
|
||||
|
||||
// Check if mnemonic is available in environment variable
|
||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
||||
// Use mnemonic directly to derive long-term key
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
ltPrivKeyData = []byte(ltIdentity.String())
|
||||
} else {
|
||||
// Try to get the long-term private key from the current unlock key
|
||||
currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key")
|
||||
|
||||
// Check if current unlock key exists
|
||||
_, err := v.fs.Stat(currentUnlockKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no current unlock key found and no mnemonic available in environment. Set SB_SECRET_MNEMONIC or ensure a current unlock key exists")
|
||||
}
|
||||
|
||||
// Resolve the current unlock key path
|
||||
var currentUnlockKeyDir string
|
||||
if _, ok := v.fs.(*afero.OsFs); ok {
|
||||
// For real filesystems, resolve the symlink properly
|
||||
currentUnlockKeyDir, err = resolveVaultSymlink(v.fs, currentUnlockKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve current unlock key symlink: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Fallback for mock filesystems: read the path from file contents
|
||||
currentUnlockKeyTarget, err := afero.ReadFile(v.fs, currentUnlockKeyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read current unlock key: %w", err)
|
||||
}
|
||||
currentUnlockKeyDir = strings.TrimSpace(string(currentUnlockKeyTarget))
|
||||
}
|
||||
|
||||
// Read the current unlock key's encrypted private key
|
||||
currentEncPrivKeyData, err := afero.ReadFile(v.fs, filepath.Join(currentUnlockKeyDir, "priv.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read current unlock key private key: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt the current unlock key private key with the same passphrase
|
||||
// (assuming the user wants to use the same passphrase for the new key)
|
||||
currentPrivKeyData, err := decryptWithPassphrase(currentEncPrivKeyData, passphrase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt current unlock key private key: %w", err)
|
||||
}
|
||||
|
||||
// Parse the current unlock key
|
||||
currentIdentity, err := age.ParseX25519Identity(string(currentPrivKeyData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse current unlock key: %w", err)
|
||||
}
|
||||
|
||||
// Read the encrypted long-term private key
|
||||
encryptedLtPrivKey, err := afero.ReadFile(v.fs, filepath.Join(currentUnlockKeyDir, "longterm.age"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt the long-term private key using the current unlock key
|
||||
ltPrivKeyData, err = decryptWithIdentity(encryptedLtPrivKey, currentIdentity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
}
|
||||
// Get the long-term private key from memory (vault must be unlocked)
|
||||
ltPrivKey := []byte(v.longTermKey.String())
|
||||
|
||||
// Encrypt the long-term private key to the new unlock key
|
||||
encryptedLtPrivKey, err := encryptToRecipient(ltPrivKeyData, identity.Recipient())
|
||||
encryptedLtPrivKey, err := encryptToRecipient(ltPrivKey, identity.Recipient())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt long-term private key to new unlock key: %w", err)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user