- Fix staticcheck QF1011: Remove explicit type declaration for io.Writer variables - Fix tagliatelle: Change all JSON tags from snake_case to camelCase - created_at → createdAt - keychain_item_name → keychainItemName - age_public_key → agePublicKey - age_priv_key_passphrase → agePrivKeyPassphrase - encrypted_longterm_key → encryptedLongtermKey - derivation_index → derivationIndex - public_key_hash → publicKeyHash - mnemonic_family_hash → mnemonicFamilyHash - gpg_key_id → gpgKeyId - Fix lll: Break long function signature line to stay under 120 character limit All linter issues have been resolved. The codebase now passes all linter checks.
364 lines
12 KiB
Go
364 lines
12 KiB
Go
// Package vault provides functionality for managing encrypted vaults.
|
|
package vault
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"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
|
|
}
|
|
|
|
// resolveRelativeSymlink resolves a relative symlink target to an absolute path
|
|
func resolveRelativeSymlink(symlinkPath, _ string) (string, error) {
|
|
// Get the current directory before changing
|
|
originalDir, err := os.Getwd()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get current directory: %w", err)
|
|
}
|
|
secret.Debug("Got current directory", "original_dir", originalDir)
|
|
|
|
// Change to the symlink's directory
|
|
symlinkDir := filepath.Dir(symlinkPath)
|
|
secret.Debug("Changing to symlink directory", "symlink_path", symlinkDir)
|
|
secret.Debug("About to call os.Chdir - this might hang if symlink is broken")
|
|
if err := os.Chdir(symlinkDir); err != nil {
|
|
return "", fmt.Errorf("failed to change to symlink directory: %w", err)
|
|
}
|
|
secret.Debug("Changed to symlink directory successfully - os.Chdir completed")
|
|
|
|
// Get the absolute path of the target
|
|
secret.Debug("Getting absolute path of current directory")
|
|
absolutePath, err := os.Getwd()
|
|
if err != nil {
|
|
// Try to restore original directory before returning error
|
|
_ = os.Chdir(originalDir)
|
|
|
|
return "", fmt.Errorf("failed to get absolute path: %w", err)
|
|
}
|
|
secret.Debug("Got absolute path", "absolute_path", absolutePath)
|
|
|
|
// Restore the original directory
|
|
secret.Debug("Restoring original directory", "original_dir", originalDir)
|
|
if err := os.Chdir(originalDir); err != nil {
|
|
return "", fmt.Errorf("failed to restore original directory: %w", err)
|
|
}
|
|
secret.Debug("Restored original directory successfully")
|
|
|
|
return absolutePath, nil
|
|
}
|
|
|
|
// ResolveVaultSymlink resolves the currentvault symlink by reading either the symlink target or file contents
|
|
// This function is designed to work on both Unix and Windows systems, as well as with in-memory filesystems
|
|
func ResolveVaultSymlink(fs afero.Fs, symlinkPath string) (string, error) {
|
|
secret.Debug("resolveVaultSymlink starting", "symlink_path", symlinkPath)
|
|
|
|
// First try to handle the path as a real symlink (works on Unix systems)
|
|
_, isOsFs := fs.(*afero.OsFs)
|
|
if isOsFs {
|
|
target, err := tryResolveOsSymlink(symlinkPath)
|
|
if err == nil {
|
|
secret.Debug("resolveVaultSymlink completed successfully", "result", target)
|
|
|
|
return target, nil
|
|
}
|
|
// Fall through to fallback if symlink resolution failed
|
|
} else {
|
|
secret.Debug("Not using OS filesystem, skipping symlink resolution")
|
|
}
|
|
|
|
// Fallback: treat it as a regular file containing the target path
|
|
secret.Debug("Fallback: trying to read regular file with target path")
|
|
|
|
fileData, err := afero.ReadFile(fs, symlinkPath)
|
|
if err != nil {
|
|
secret.Debug("Failed to read target path file", "error", err)
|
|
|
|
return "", fmt.Errorf("failed to read vault symlink: %w", err)
|
|
}
|
|
|
|
target := string(fileData)
|
|
secret.Debug("Read target path from file", "target", target)
|
|
|
|
secret.Debug("resolveVaultSymlink completed via fallback", "result", target)
|
|
|
|
return target, nil
|
|
}
|
|
|
|
// tryResolveOsSymlink attempts to resolve a symlink on OS filesystems
|
|
func tryResolveOsSymlink(symlinkPath string) (string, error) {
|
|
secret.Debug("Using real filesystem symlink resolution")
|
|
|
|
// Check if the symlink exists
|
|
secret.Debug("Checking symlink target", "symlink_path", symlinkPath)
|
|
target, err := os.Readlink(symlinkPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
secret.Debug("Symlink points to", "target", target)
|
|
|
|
// On real filesystem, we need to handle relative symlinks
|
|
// by resolving them relative to the symlink's directory
|
|
if !filepath.IsAbs(target) {
|
|
return resolveRelativeSymlink(symlinkPath, target)
|
|
}
|
|
|
|
return target, 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 current vault symlink/file
|
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
|
targetPath := filepath.Join(stateDir, "vaults.d", name)
|
|
|
|
// First try to remove existing symlink if it exists
|
|
if _, err := fs.Stat(currentVaultPath); err == nil {
|
|
secret.Debug("Removing existing current vault symlink", "path", currentVaultPath)
|
|
// Ignore errors from Remove as we'll try to create/update it anyway.
|
|
// On some systems, removing a symlink may fail but the subsequent create may still succeed.
|
|
_ = fs.Remove(currentVaultPath)
|
|
}
|
|
|
|
// Try to create a real symlink first (works on Unix systems)
|
|
if _, ok := fs.(*afero.OsFs); ok {
|
|
secret.Debug("Creating vault symlink", "target", targetPath, "link", currentVaultPath)
|
|
if err := os.Symlink(targetPath, currentVaultPath); err == nil {
|
|
secret.Debug("Successfully selected vault", "vault_name", name)
|
|
|
|
return nil
|
|
}
|
|
// If symlink creation fails, fall back to regular file
|
|
}
|
|
|
|
// Fallback: create a regular file with the target path
|
|
secret.Debug("Fallback: creating regular file with target path", "target", targetPath)
|
|
if err := afero.WriteFile(fs, currentVaultPath, []byte(targetPath), secret.FilePerms); err != nil {
|
|
return fmt.Errorf("failed to select vault: %w", err)
|
|
}
|
|
|
|
secret.Debug("Successfully selected vault", "vault_name", name)
|
|
|
|
return nil
|
|
}
|