diff --git a/internal/secret/secret.go b/internal/secret/secret.go index 409a3b6..c78493f 100644 --- a/internal/secret/secret.go +++ b/internal/secret/secret.go @@ -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 -} \ No newline at end of file +} diff --git a/internal/secret/vault.go b/internal/secret/vault.go index 2aa4c2e..6f91692 100644 --- a/internal/secret/vault.go +++ b/internal/secret/vault.go @@ -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) }