// Package vault provides functionality for managing encrypted vaults. package vault import ( "fmt" "os" "path/filepath" "regexp" "strings" "time" "git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/pkg/agehd" "github.com/spf13/afero" ) // Register the GetCurrentVault function with the secret package func init() { secret.RegisterGetCurrentVaultFunc(func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) { return GetCurrentVault(fs, stateDir) }) } // isValidVaultName validates vault names according to the format [a-z0-9\.\-\_]+ // Note: We don't allow slashes in vault names unlike secret names func isValidVaultName(name string) bool { if name == "" { return false } matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_]+$`, name) return matched } // ResolveVaultSymlink reads the currentvault file to get the path to the current vault // The file contains just the vault name (e.g., "default") func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) { secret.Debug("resolveVaultSymlink starting", "path", currentVaultPath) fileData, err := afero.ReadFile(fs, currentVaultPath) if err != nil { secret.Debug("Failed to read currentvault file", "error", err) return "", fmt.Errorf("failed to read currentvault file: %w", err) } // The file contains just the vault name like "default" vaultName := strings.TrimSpace(string(fileData)) secret.Debug("Read vault name from file", "vault_name", vaultName) // Resolve to absolute path: stateDir/vaults.d/vaultName stateDir := filepath.Dir(currentVaultPath) absolutePath := filepath.Join(stateDir, "vaults.d", vaultName) secret.Debug("Resolved to absolute path", "absolute_path", absolutePath) return absolutePath, nil } // GetCurrentVault gets the current vault from the file system func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) { secret.Debug("Getting current vault", "state_dir", stateDir) // Check if the current vault symlink exists currentVaultPath := filepath.Join(stateDir, "currentvault") secret.Debug("Checking current vault symlink", "path", currentVaultPath) _, err := fs.Stat(currentVaultPath) if err != nil { secret.Debug("Failed to stat current vault symlink", "error", err, "path", currentVaultPath) return nil, fmt.Errorf("failed to read current vault symlink: %w", err) } secret.Debug("Current vault symlink exists") // Resolve the symlink to get the actual vault directory secret.Debug("Resolving vault symlink") targetPath, err := ResolveVaultSymlink(fs, currentVaultPath) if err != nil { return nil, err } secret.Debug("Resolved vault symlink", "target_path", targetPath) // Extract the vault name from the path // The path will be something like "/path/to/vaults.d/default" vaultName := filepath.Base(targetPath) secret.Debug("Extracted vault name", "vault_name", vaultName) secret.Debug("Current vault resolved", "vault_name", vaultName, "target_path", targetPath) // Create and return the vault return NewVault(fs, stateDir, vaultName), nil } // ListVaults lists all vaults in the state directory func ListVaults(fs afero.Fs, stateDir string) ([]string, error) { vaultsDir := filepath.Join(stateDir, "vaults.d") // Check if vaults directory exists exists, err := afero.DirExists(fs, vaultsDir) if err != nil { return nil, fmt.Errorf("failed to check if vaults directory exists: %w", err) } if !exists { return []string{}, nil } // Read the vaults directory entries, err := afero.ReadDir(fs, vaultsDir) if err != nil { return nil, fmt.Errorf("failed to read vaults directory: %w", err) } // Extract vault names var vaults []string for _, entry := range entries { if entry.IsDir() { vaults = append(vaults, entry.Name()) } } return vaults, nil } // processMnemonicForVault handles mnemonic processing for vault creation func processMnemonicForVault(fs afero.Fs, stateDir, vaultDir, vaultName string) ( derivationIndex uint32, publicKeyHash string, familyHash string, err error) { // Check if mnemonic is available in environment mnemonic := os.Getenv(secret.EnvMnemonic) if mnemonic == "" { secret.Debug("No mnemonic in environment, vault created without long-term key", "vault", vaultName) // Use 0 for derivation index when no mnemonic is provided return 0, "", "", nil } secret.Debug("Mnemonic found in environment, deriving long-term key", "vault", vaultName) // Get the next available derivation index for this mnemonic derivationIndex, err = GetNextDerivationIndex(fs, stateDir, mnemonic) if err != nil { return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err) } // Derive the long-term key using the actual derivation index ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex) if err != nil { return 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err) } // Write the public key ltPubKey := ltIdentity.Recipient().String() ltPubKeyPath := filepath.Join(vaultDir, "pub.age") if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltPubKey), secret.FilePerms); err != nil { return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err) } secret.Debug("Wrote long-term public key", "path", ltPubKeyPath) // Compute verification hash from actual derivation index publicKeyHash = ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String())) // Compute family hash from index 0 (same for all vaults with this mnemonic) // This is used to identify which vaults belong to the same mnemonic family identity0, err := agehd.DeriveIdentity(mnemonic, 0) if err != nil { return 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err) } familyHash = ComputeDoubleSHA256([]byte(identity0.Recipient().String())) return derivationIndex, publicKeyHash, familyHash, nil } // CreateVault creates a new vault func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) { secret.Debug("Creating new vault", "name", name, "state_dir", stateDir) // Validate vault name if !isValidVaultName(name) { secret.Debug("Invalid vault name provided", "vault_name", name) return nil, fmt.Errorf("invalid vault name '%s': must match pattern [a-z0-9.\\-_]+", name) } secret.Debug("Vault name validation passed", "vault_name", name) // Create vault directory structure vaultDir := filepath.Join(stateDir, "vaults.d", name) secret.Debug("Creating vault directory structure", "vault_dir", vaultDir) // Create main vault directory if err := fs.MkdirAll(vaultDir, secret.DirPerms); err != nil { return nil, fmt.Errorf("failed to create vault directory: %w", err) } // Create secrets directory secretsDir := filepath.Join(vaultDir, "secrets.d") if err := fs.MkdirAll(secretsDir, secret.DirPerms); err != nil { return nil, fmt.Errorf("failed to create secrets directory: %w", err) } // Create unlockers directory unlockersDir := filepath.Join(vaultDir, "unlockers.d") if err := fs.MkdirAll(unlockersDir, secret.DirPerms); err != nil { return nil, fmt.Errorf("failed to create unlockers directory: %w", err) } // Process mnemonic if available derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(fs, stateDir, vaultDir, name) if err != nil { return nil, err } // Save vault metadata metadata := &Metadata{ CreatedAt: time.Now(), DerivationIndex: derivationIndex, PublicKeyHash: publicKeyHash, MnemonicFamilyHash: familyHash, } if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil { return nil, fmt.Errorf("failed to save vault metadata: %w", err) } // Select the newly created vault as current secret.Debug("Selecting newly created vault as current", "name", name) if err := SelectVault(fs, stateDir, name); err != nil { return nil, fmt.Errorf("failed to select vault: %w", err) } // Create and return the vault secret.Debug("Successfully created vault", "name", name) return NewVault(fs, stateDir, name), nil } // SelectVault selects the given vault as the current vault func SelectVault(fs afero.Fs, stateDir string, name string) error { secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir) // Validate vault name if !isValidVaultName(name) { secret.Debug("Invalid vault name provided", "vault_name", name) return fmt.Errorf("invalid vault name '%s': must match pattern [a-z0-9.\\-_]+", name) } secret.Debug("Vault name validation passed", "vault_name", name) // Check if vault exists vaultDir := filepath.Join(stateDir, "vaults.d", name) exists, err := afero.DirExists(fs, vaultDir) if err != nil { return fmt.Errorf("failed to check if vault exists: %w", err) } if !exists { return fmt.Errorf("vault %s does not exist", name) } // Create or update the currentvault file with just the vault name currentVaultPath := filepath.Join(stateDir, "currentvault") // Remove existing file if it exists if _, err := fs.Stat(currentVaultPath); err == nil { secret.Debug("Removing existing currentvault file", "path", currentVaultPath) _ = fs.Remove(currentVaultPath) } // Write just the vault name to the file secret.Debug("Writing currentvault file", "vault_name", name) if err := afero.WriteFile(fs, currentVaultPath, []byte(name), secret.FilePerms); err != nil { return fmt.Errorf("failed to select vault: %w", err) } secret.Debug("Successfully selected vault", "vault_name", name) return nil }