secret/internal/secret/vault.go

1042 lines
34 KiB
Go

package secret
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero"
)
// VaultMetadata contains information about a vault
type VaultMetadata struct {
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
Description string `json:"description,omitempty"`
}
// UnlockKeyMetadata contains information about an unlock key
type UnlockKeyMetadata struct {
ID string `json:"id"`
Type string `json:"type"` // passphrase, pgp, keychain
CreatedAt time.Time `json:"createdAt"`
Flags []string `json:"flags,omitempty"`
}
// SecretMetadata contains information about a secret
type SecretMetadata struct {
Name string `json:"name"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Configuration represents the global configuration
type Configuration struct {
Version int `json:"version"`
Config struct {
RequireAuth bool `json:"requireAuth"`
} `json:"configuration"`
}
// Vault represents a secrets vault
type Vault struct {
Name string
fs afero.Fs
stateDir string
}
// NewVault creates a new Vault instance
func NewVault(fs afero.Fs, name string, stateDir string) *Vault {
return &Vault{
Name: name,
fs: fs,
stateDir: stateDir,
}
}
// resolveVaultSymlink resolves the currentvault symlink by changing into it and getting the absolute path
func resolveVaultSymlink(fs afero.Fs, symlinkPath string) (string, error) {
// For real filesystems, we can use os.Chdir and os.Getwd
if _, ok := fs.(*afero.OsFs); ok {
// Save current directory
originalDir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get current directory: %w", err)
}
// Change to the symlink directory
err = os.Chdir(symlinkPath)
if err != nil {
return "", fmt.Errorf("failed to change into vault symlink: %w", err)
}
// Get absolute path of current directory
absolutePath, err := os.Getwd()
if err != nil {
// Try to restore original directory before returning error
if restoreErr := os.Chdir(originalDir); restoreErr != nil {
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)
}
// Restore original directory
err = os.Chdir(originalDir)
if err != nil {
return "", fmt.Errorf("failed to restore original directory: %w", err)
}
return absolutePath, nil
} else {
// Fallback for mock filesystems: read the path from file contents
targetBytes, err := afero.ReadFile(fs, symlinkPath)
if err != nil {
return "", fmt.Errorf("failed to read vault path: %w", err)
}
return strings.TrimSpace(string(targetBytes)), nil
}
}
// GetCurrentVault returns the currently selected vault
func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
DebugWith("Getting current vault", slog.String("state_dir", stateDir))
currentVaultPath := filepath.Join(stateDir, "currentvault")
// Check if the symlink exists
_, err := fs.Stat(currentVaultPath)
if err != nil {
Debug("Failed to stat current vault symlink", "error", err, "path", currentVaultPath)
return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
}
// Resolve the symlink to get the target directory
targetPath, err := resolveVaultSymlink(fs, currentVaultPath)
if err != nil {
Debug("Failed to resolve vault symlink", "error", err, "symlink_path", currentVaultPath)
return nil, err
}
// Extract vault name from the target path
// Target path should be something like "/state/vaults.d/vaultname"
vaultName := filepath.Base(targetPath)
DebugWith("Current vault resolved",
slog.String("vault_name", vaultName),
slog.String("target_path", targetPath),
)
return NewVault(fs, vaultName, stateDir), nil
}
// ListVaults returns a list of all available vaults
func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
DebugWith("Listing vaults", slog.String("state_dir", stateDir))
vaultsDir := filepath.Join(stateDir, "vaults.d")
// Check if vaults directory exists
exists, err := afero.DirExists(fs, vaultsDir)
if err != nil {
Debug("Failed to check vaults directory", "error", err, "vaults_dir", vaultsDir)
return nil, fmt.Errorf("failed to check if vaults directory exists: %w", err)
}
if !exists {
Debug("Vaults directory does not exist", "vaults_dir", vaultsDir)
return []string{}, nil
}
// List directories in vaults.d
files, err := afero.ReadDir(fs, vaultsDir)
if err != nil {
Debug("Failed to read vaults directory", "error", err, "vaults_dir", vaultsDir)
return nil, fmt.Errorf("failed to read vaults directory: %w", err)
}
var vaults []string
for _, file := range files {
if file.IsDir() {
vaults = append(vaults, file.Name())
}
}
DebugWith("Found vaults",
slog.Int("count", len(vaults)),
slog.Any("vault_names", vaults),
)
return vaults, nil
}
// CreateVault creates a new vault
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
DebugWith("Creating new vault", slog.String("name", name), slog.String("state_dir", stateDir))
vaultDir := filepath.Join(stateDir, "vaults.d", name)
// Check if vault already exists
exists, err := afero.DirExists(fs, vaultDir)
if err != nil {
Debug("Failed to check if vault exists", "error", err, "vault_dir", vaultDir)
return nil, fmt.Errorf("failed to check if vault exists: %w", err)
}
if exists {
Debug("Vault already exists", "name", name)
return nil, fmt.Errorf("vault %s already exists", name)
}
// Create vault directory and subdirectories
Debug("Creating vault directory structure", "vault_dir", vaultDir)
if err := fs.MkdirAll(vaultDir, 0700); err != nil {
Debug("Failed to create vault directory", "error", err, "vault_dir", vaultDir)
return nil, fmt.Errorf("failed to create vault directory: %w", err)
}
secretsDir := filepath.Join(vaultDir, "secrets.d")
if err := fs.MkdirAll(secretsDir, 0700); err != nil {
Debug("Failed to create secrets directory", "error", err, "secrets_dir", secretsDir)
return nil, fmt.Errorf("failed to create secrets directory: %w", err)
}
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
if err := fs.MkdirAll(unlockKeysDir, 0700); err != nil {
Debug("Failed to create unlock keys directory", "error", err, "unlock_keys_dir", unlockKeysDir)
return nil, fmt.Errorf("failed to create unlock keys directory: %w", err)
}
// Automatically select the newly created vault as current
Debug("Selecting newly created vault as current", "name", name)
if err := SelectVault(fs, stateDir, name); err != nil {
Debug("Failed to select newly created vault", "error", err, "name", name)
return nil, fmt.Errorf("failed to select newly created vault: %w", err)
}
Debug("Successfully created vault", "name", name)
return NewVault(fs, name, stateDir), nil
}
// SelectVault sets the current vault
func SelectVault(fs afero.Fs, stateDir string, name string) error {
DebugWith("Selecting vault", slog.String("vault_name", name), slog.String("state_dir", stateDir))
vaultDir := filepath.Join(stateDir, "vaults.d", name)
// Check if vault exists
exists, err := afero.DirExists(fs, vaultDir)
if err != nil {
Debug("Failed to check if vault exists during selection", "error", err, "vault_dir", vaultDir)
return fmt.Errorf("failed to check if vault exists: %w", err)
}
if !exists {
Debug("Vault does not exist for selection", "vault_name", name, "vault_dir", vaultDir)
return fmt.Errorf("vault %s does not exist", name)
}
// Write current vault symlink to vault directory
currentVaultPath := filepath.Join(stateDir, "currentvault")
// Remove existing symlink if it exists
_, err = fs.Stat(currentVaultPath)
if err == nil {
Debug("Removing existing current vault symlink", "path", currentVaultPath)
if err := fs.Remove(currentVaultPath); err != nil {
Debug("Failed to remove existing vault symlink", "error", err, "path", currentVaultPath)
return fmt.Errorf("failed to remove existing current vault symlink: %w", err)
}
}
// Create new symlink to vault directory
if linker, ok := fs.(afero.Linker); ok {
Debug("Creating vault symlink", "target", vaultDir, "link", currentVaultPath)
if err := linker.SymlinkIfPossible(vaultDir, currentVaultPath); err != nil {
Debug("Failed to create vault symlink", "error", err, "target", vaultDir, "link", currentVaultPath)
return fmt.Errorf("failed to create symlink for current vault: %w", err)
}
} else {
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 {
Debug("Failed to write vault path file", "error", err, "target", vaultDir, "file", currentVaultPath)
return fmt.Errorf("failed to write current vault path: %w", err)
}
}
Debug("Successfully selected vault", "vault_name", name)
return nil
}
// GetDirectory returns the filesystem path to this vault
func (v *Vault) GetDirectory() (string, error) {
return filepath.Join(v.stateDir, "vaults.d", v.Name), nil
}
// ListSecrets returns a list of secret names in this vault
func (v *Vault) ListSecrets() ([]string, error) {
DebugWith("Listing secrets in vault", slog.String("vault_name", v.Name))
vaultDir, err := v.GetDirectory()
if err != nil {
Debug("Failed to get vault directory for secret listing", "error", err, "vault_name", v.Name)
return nil, err
}
secretsDir := filepath.Join(vaultDir, "secrets.d")
// Check if secrets directory exists
exists, err := afero.DirExists(v.fs, secretsDir)
if err != nil {
Debug("Failed to check secrets directory", "error", err, "secrets_dir", secretsDir)
return nil, fmt.Errorf("failed to check if secrets directory exists: %w", err)
}
if !exists {
Debug("Secrets directory does not exist", "secrets_dir", secretsDir, "vault_name", v.Name)
return []string{}, nil
}
// List directories in secrets.d
files, err := afero.ReadDir(v.fs, secretsDir)
if err != nil {
Debug("Failed to read secrets directory", "error", err, "secrets_dir", secretsDir)
return nil, fmt.Errorf("failed to read secrets directory: %w", err)
}
var secrets []string
for _, file := range files {
if file.IsDir() {
// Convert storage name back to secret name
secretName := strings.ReplaceAll(file.Name(), "%", "/")
secrets = append(secrets, secretName)
}
}
DebugWith("Found secrets in vault",
slog.String("vault_name", v.Name),
slog.Int("secret_count", len(secrets)),
slog.Any("secret_names", secrets),
)
return secrets, nil
}
// AddSecret adds a secret to this vault
func (v *Vault) AddSecret(name string, value []byte, force bool) error {
DebugWith("Adding secret to vault",
slog.String("vault_name", v.Name),
slog.String("secret_name", name),
slog.Int("value_length", len(value)),
slog.Bool("force", force),
)
vaultDir, err := v.GetDirectory()
if err != nil {
Debug("Failed to get vault directory for secret addition", "error", err, "vault_name", v.Name)
return err
}
// Convert slashes to percent signs for storage
storageName := strings.ReplaceAll(name, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
DebugWith("Secret storage details",
slog.String("storage_name", storageName),
slog.String("secret_dir", secretDir),
)
// Check if secret already exists
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)
}
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)
}
// Create secret directory
Debug("Creating secret directory", "secret_dir", secretDir)
if err := v.fs.MkdirAll(secretDir, 0700); err != nil {
Debug("Failed to create secret directory", "error", err, "secret_dir", secretDir)
return fmt.Errorf("failed to create secret directory: %w", err)
}
// Get long-term public key for encryption
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
Debug("Reading long-term public key", "path", ltPubKeyPath)
ltPubKeyData, err := afero.ReadFile(v.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)
}
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)
}
DebugWith("Parsed long-term public key", slog.String("recipient", ltRecipient.String()))
// Encrypt secret data
Debug("Encrypting secret data")
encryptedData, err := encryptToRecipient(value, ltRecipient)
if err != nil {
Debug("Failed to encrypt secret", "error", err)
return fmt.Errorf("failed to encrypt secret: %w", err)
}
DebugWith("Secret encrypted", slog.Int("encrypted_length", len(encryptedData)))
// 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)
}
// Create and write metadata
now := time.Now()
metadata := SecretMetadata{
Name: name,
CreatedAt: now,
UpdatedAt: now,
}
DebugWith("Creating secret metadata",
slog.String("secret_name", metadata.Name),
slog.Time("created_at", metadata.CreatedAt),
slog.Time("updated_at", metadata.UpdatedAt),
)
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)
}
metadataPath := filepath.Join(secretDir, "secret-metadata.json")
Debug("Writing secret metadata", "path", metadataPath)
if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, 0600); err != nil {
Debug("Failed to write secret metadata", "error", err, "path", metadataPath)
return fmt.Errorf("failed to write secret metadata: %w", err)
}
Debug("Successfully added secret to vault", "secret_name", name, "vault_name", v.Name)
return nil
}
// GetSecret retrieves a secret from this vault
func (v *Vault) GetSecret(name string) ([]byte, error) {
DebugWith("Getting secret from vault",
slog.String("vault_name", v.Name),
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
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)
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)
return nil, fmt.Errorf("secret %s not found", name)
}
Debug("Decrypting secret via unlock key", "secret_name", name, "unlock_key_type", unlockKey.GetType())
// Let the unlock key handle decryption
decryptedData, err := unlockKey.DecryptSecret(secret)
if err != nil {
Debug("Failed to decrypt secret via unlock key", "error", err, "secret_name", name, "unlock_key_type", unlockKey.GetType())
return nil, err
}
DebugWith("Successfully decrypted secret via unlock key",
slog.String("secret_name", name),
slog.String("unlock_key_type", unlockKey.GetType()),
slog.Int("decrypted_length", len(decryptedData)),
)
return decryptedData, nil
}
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
func (v *Vault) GetSecretObject(name string) (*Secret, error) {
// First check if the secret exists by checking for the metadata file
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, err
}
// Convert slashes to percent signs for storage
storageName := strings.ReplaceAll(name, "/", "%")
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
// Check if secret directory exists
exists, err := afero.DirExists(v.fs, secretDir)
if err != nil {
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
return nil, fmt.Errorf("secret %s not found", name)
}
// Create a Secret object
secret := NewSecret(v, name)
// Load the metadata from disk
if err := secret.LoadMetadata(); err != nil {
return nil, err
}
return secret, nil
}
// GetCurrentUnlockKey returns the current unlock key for this vault
func (v *Vault) GetCurrentUnlockKey() (UnlockKey, error) {
DebugWith("Getting current unlock key", slog.String("vault_name", v.Name))
vaultDir, err := v.GetDirectory()
if err != nil {
Debug("Failed to get vault directory for unlock key", "error", err, "vault_name", v.Name)
return nil, err
}
currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key")
// Check if the symlink exists
_, err = v.fs.Stat(currentUnlockKeyPath)
if err != nil {
Debug("Failed to stat current unlock key symlink", "error", err, "path", currentUnlockKeyPath)
return nil, fmt.Errorf("failed to read current unlock key: %w", err)
}
// Resolve the symlink to get the target directory
var unlockKeyDir string
if _, ok := v.fs.(*afero.OsFs); ok {
Debug("Resolving unlock key symlink (real filesystem)")
// For real filesystems, resolve the symlink properly
unlockKeyDir, err = resolveVaultSymlink(v.fs, currentUnlockKeyPath)
if err != nil {
Debug("Failed to resolve unlock key symlink", "error", err, "symlink_path", currentUnlockKeyPath)
return nil, fmt.Errorf("failed to resolve current unlock key symlink: %w", err)
}
} else {
Debug("Reading unlock key path (mock filesystem)")
// Fallback for mock filesystems: read the path from file contents
unlockKeyDirBytes, err := afero.ReadFile(v.fs, currentUnlockKeyPath)
if err != nil {
Debug("Failed to read unlock key path file", "error", err, "path", currentUnlockKeyPath)
return nil, fmt.Errorf("failed to read current unlock key: %w", err)
}
unlockKeyDir = strings.TrimSpace(string(unlockKeyDirBytes))
}
DebugWith("Resolved unlock key directory",
slog.String("unlock_key_dir", unlockKeyDir),
slog.String("vault_name", v.Name),
)
// Read unlock key metadata
metadataPath := filepath.Join(unlockKeyDir, "unlock-metadata.json")
Debug("Reading unlock key metadata", "path", metadataPath)
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
Debug("Failed to read unlock key metadata", "error", err, "path", metadataPath)
return nil, fmt.Errorf("failed to read unlock key metadata: %w", err)
}
var metadata UnlockKeyMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
Debug("Failed to parse unlock key metadata", "error", err, "path", metadataPath)
return nil, fmt.Errorf("failed to parse unlock key metadata: %w", err)
}
DebugWith("Parsed unlock key metadata",
slog.String("key_id", metadata.ID),
slog.String("key_type", metadata.Type),
slog.Time("created_at", metadata.CreatedAt),
slog.Any("flags", metadata.Flags),
)
// Create unlock key instance using direct constructors with filesystem
var unlockKey UnlockKey
switch metadata.Type {
case "passphrase":
Debug("Creating passphrase unlock key instance", "key_id", metadata.ID)
unlockKey = NewPassphraseUnlockKey(v.fs, unlockKeyDir, metadata)
case "pgp":
Debug("Creating PGP unlock key instance", "key_id", metadata.ID)
unlockKey = NewPGPUnlockKey(v.fs, unlockKeyDir, metadata)
case "keychain":
Debug("Creating keychain unlock key instance", "key_id", metadata.ID)
unlockKey = NewKeychainUnlockKey(v.fs, unlockKeyDir, metadata)
default:
Debug("Unsupported unlock key type", "type", metadata.Type, "key_id", metadata.ID)
return nil, fmt.Errorf("unsupported unlock key type: %s", metadata.Type)
}
DebugWith("Successfully created unlock key instance",
slog.String("key_type", unlockKey.GetType()),
slog.String("key_id", unlockKey.GetID()),
slog.String("vault_name", v.Name),
)
return unlockKey, nil
}
// ListUnlockKeys returns a list of available unlock keys for this vault
func (v *Vault) ListUnlockKeys() ([]UnlockKeyMetadata, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, err
}
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
// Check if unlock keys directory exists
exists, err := afero.DirExists(v.fs, unlockKeysDir)
if err != nil {
return nil, fmt.Errorf("failed to check if unlock keys directory exists: %w", err)
}
if !exists {
return []UnlockKeyMetadata{}, nil
}
// List directories in unlock.d
files, err := afero.ReadDir(v.fs, unlockKeysDir)
if err != nil {
return nil, fmt.Errorf("failed to read unlock keys directory: %w", err)
}
var keys []UnlockKeyMetadata
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockKeysDir, file.Name(), "unlock-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil {
continue
}
if !exists {
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
continue
}
var metadata UnlockKeyMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
keys = append(keys, metadata)
}
}
return keys, nil
}
// RemoveUnlockKey removes an unlock key from this vault
func (v *Vault) RemoveUnlockKey(keyID string) error {
vaultDir, err := v.GetDirectory()
if err != nil {
return err
}
// Find the key directory and create the unlock key instance
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
// List directories in unlock.d
files, err := afero.ReadDir(v.fs, unlockKeysDir)
if err != nil {
return fmt.Errorf("failed to read unlock keys directory: %w", err)
}
var unlockKey UnlockKey
var keyDir string
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockKeysDir, file.Name(), "unlock-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil || !exists {
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
continue
}
var metadata UnlockKeyMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
if metadata.ID == keyID {
keyDir = filepath.Join(unlockKeysDir, file.Name())
// Create the appropriate unlock key instance
switch metadata.Type {
case "passphrase":
unlockKey = NewPassphraseUnlockKey(v.fs, keyDir, metadata)
case "pgp":
unlockKey = NewPGPUnlockKey(v.fs, keyDir, metadata)
case "keychain":
unlockKey = NewKeychainUnlockKey(v.fs, keyDir, metadata)
default:
return fmt.Errorf("unsupported unlock key type: %s", metadata.Type)
}
break
}
}
}
if unlockKey == nil {
return fmt.Errorf("unlock key %s not found", keyID)
}
// Check if this is the current unlock key
currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key")
currentKeyData, err := afero.ReadFile(v.fs, currentUnlockKeyPath)
if err == nil && string(currentKeyData) == keyDir {
// This is the current unlock key, so we need to remove the symlink
if err := v.fs.Remove(currentUnlockKeyPath); err != nil {
return fmt.Errorf("failed to remove current unlock key link: %w", err)
}
}
// Use the unlock key's Remove method to handle type-specific cleanup
if err := unlockKey.Remove(); err != nil {
return fmt.Errorf("failed to remove unlock key: %w", err)
}
return nil
}
// SelectUnlockKey sets the current unlock key for this vault
func (v *Vault) SelectUnlockKey(keyID string) error {
vaultDir, err := v.GetDirectory()
if err != nil {
return err
}
// Find the key directory
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
// List directories in unlock.d
files, err := afero.ReadDir(v.fs, unlockKeysDir)
if err != nil {
return fmt.Errorf("failed to read unlock keys directory: %w", err)
}
var keyDir string
for _, file := range files {
if file.IsDir() {
// Read metadata file
metadataPath := filepath.Join(unlockKeysDir, file.Name(), "unlock-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil || !exists {
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
continue
}
var metadata UnlockKeyMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
continue
}
if metadata.ID == keyID {
keyDir = filepath.Join(unlockKeysDir, file.Name())
break
}
}
}
if keyDir == "" {
return fmt.Errorf("unlock key %s not found", keyID)
}
// Set as current unlock key
currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key")
// Remove existing symlink if it exists
_, err = v.fs.Stat(currentUnlockKeyPath)
if err == nil {
if err := v.fs.Remove(currentUnlockKeyPath); err != nil {
return fmt.Errorf("failed to remove existing current unlock key link: %w", err)
}
}
// Create new symlink or write path directly if symlinks aren't supported
if linker, ok := v.fs.(afero.Linker); ok {
if err := linker.SymlinkIfPossible(keyDir, currentUnlockKeyPath); err != nil {
return fmt.Errorf("failed to create symlink for current unlock key: %w", err)
}
} else {
// Fallback: write the path as a regular file
if err := afero.WriteFile(v.fs, currentUnlockKeyPath, []byte(keyDir), 0600); err != nil {
return fmt.Errorf("failed to write current unlock key path: %w", err)
}
}
return nil
}
// CreatePassphraseKey creates a new passphrase-protected unlock key for this vault
func (v *Vault) CreatePassphraseKey(passphrase string) (*PassphraseUnlockKey, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
// Generate a new identity
identity, err := age.GenerateX25519Identity()
if err != nil {
return nil, fmt.Errorf("failed to generate key pair: %w", err)
}
publicKey := identity.Recipient().String()
privateKey := identity.String()
// Create unlock key directory
unlockKeyDir := filepath.Join(vaultDir, "unlock.d", "passphrase")
if err := v.fs.MkdirAll(unlockKeyDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create unlock key directory: %w", err)
}
// Write public key
if err := afero.WriteFile(v.fs, filepath.Join(unlockKeyDir, "pub.age"), []byte(publicKey), 0600); err != nil {
return nil, fmt.Errorf("failed to write public key: %w", err)
}
// Create a temporary PassphraseUnlockKey with proper metadata to generate the ID
now := time.Now()
tempMetadata := UnlockKeyMetadata{
Type: "passphrase",
CreatedAt: now,
}
tempKey := &PassphraseUnlockKey{
Directory: unlockKeyDir,
Metadata: tempMetadata,
fs: v.fs,
}
keyID := tempKey.ID()
// Encrypt private key with passphrase
encryptedPrivateKey, err := encryptWithPassphrase([]byte(privateKey), passphrase)
if err != nil {
return nil, fmt.Errorf("failed to encrypt private key with passphrase: %w", err)
}
// Write encrypted private key
if err := afero.WriteFile(v.fs, filepath.Join(unlockKeyDir, "priv.age"), encryptedPrivateKey, 0600); err != nil {
return nil, fmt.Errorf("failed to write encrypted private key: %w", err)
}
// Get or derive the long-term private key
ltPrivKeyData, err := v.GetLongTermKey()
if err != nil {
return nil, fmt.Errorf("failed to get long-term private key: %w", err)
}
// Encrypt the long-term private key to the new unlock key
encryptedLtPrivKey, err := encryptToRecipient(ltPrivKeyData, identity.Recipient())
if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key to new unlock key: %w", err)
}
// Write the encrypted long-term private key
if err := afero.WriteFile(v.fs, filepath.Join(unlockKeyDir, "longterm.age"), encryptedLtPrivKey, 0600); err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
// Create and write metadata
metadata := UnlockKeyMetadata{
ID: keyID,
Type: "passphrase",
CreatedAt: now,
}
metadataBytes, err := json.MarshalIndent(metadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal unlock key metadata: %w", err)
}
if err := afero.WriteFile(v.fs, filepath.Join(unlockKeyDir, "unlock-metadata.json"), metadataBytes, 0600); err != nil {
return nil, fmt.Errorf("failed to write unlock key metadata: %w", err)
}
// Set as current unlock key
currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key")
// Remove existing symlink if it exists
_, err = v.fs.Stat(currentUnlockKeyPath)
if err == nil {
if err := v.fs.Remove(currentUnlockKeyPath); err != nil {
return nil, fmt.Errorf("failed to remove existing current unlock key link: %w", err)
}
}
// Create new symlink or write path directly if symlinks aren't supported
if linker, ok := v.fs.(afero.Linker); ok {
if err := linker.SymlinkIfPossible(unlockKeyDir, currentUnlockKeyPath); err != nil {
return nil, fmt.Errorf("failed to create symlink for current unlock key: %w", err)
}
} else {
// Fallback: write the path as a regular file
if err := afero.WriteFile(v.fs, currentUnlockKeyPath, []byte(unlockKeyDir), 0600); err != nil {
return nil, fmt.Errorf("failed to write current unlock key path: %w", err)
}
}
return &PassphraseUnlockKey{
Directory: unlockKeyDir,
Metadata: metadata,
fs: v.fs,
}, nil
}
// GetLongTermKey returns the long-term private key for this vault
func (v *Vault) GetLongTermKey() ([]byte, error) {
DebugWith("Getting long-term key for vault", slog.String("vault_name", v.Name))
// Check if mnemonic is available in environment variable for direct derivation
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
Debug("Using mnemonic from environment to derive long-term key", "vault_name", v.Name)
// 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 mnemonic", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
ltPrivKeyData := []byte(ltIdentity.String())
DebugWith("Successfully derived long-term key from mnemonic",
slog.String("vault_name", v.Name),
slog.Int("key_length", len(ltPrivKeyData)),
)
return ltPrivKeyData, nil
}
Debug("Using current unlock key to decrypt long-term key", "vault_name", v.Name)
// Get current unlock key
currentUnlockKey, 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 long-term decryption",
slog.String("vault_name", v.Name),
slog.String("unlock_key_type", currentUnlockKey.GetType()),
slog.String("unlock_key_id", currentUnlockKey.GetID()),
)
// Use the unlock key's DecryptLongTermKey method
ltPrivKeyData, err := currentUnlockKey.DecryptLongTermKey()
if err != nil {
Debug("Failed to decrypt long-term key with current unlock key", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to decrypt long-term key: %w", err)
}
DebugWith("Successfully decrypted long-term key via current unlock key",
slog.String("vault_name", v.Name),
slog.String("unlock_key_type", currentUnlockKey.GetType()),
slog.Int("key_length", len(ltPrivKeyData)),
)
return ltPrivKeyData, nil
}