// Package vault provides functionality for managing encrypted vaults. package vault import ( "fmt" "path/filepath" "regexp" "strings" "time" "filippo.io/age" "git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/pkg/agehd" "github.com/awnumar/memguard" "github.com/spf13/afero" ) // Register the GetCurrentVault function with the secret package // //nolint:gochecknoinits // registers the vault accessor with the secret package func init() { secret.RegisterGetCurrentVaultFunc( func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) { return GetCurrentVault(fs, stateDir) }) } // isValidVaultName reports whether name is a valid vault name: only // lowercase ASCII letters, digits, '.', '-' and '_', and not empty, "." or // "..". With no path separator allowed, a vault is always one directory // directly under vaults.d. func isValidVaultName(name string) bool { if name == "" || name == "." || name == ".." { return false } matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_]+$`, name) return matched } // ValidateVaultName returns an error wrapping ErrInvalidVaultName when name // is not a valid vault name. Call it on the name exactly as the user gave it, // before building any path from it. func ValidateVaultName(name string) error { if !isValidVaultName(name) { return fmt.Errorf( "%w '%s': only lowercase ASCII letters, digits, '.', '-' and '_' "+ "are allowed, and a name must not be empty, '.' or '..'", ErrInvalidVaultName, name, ) } return nil } // 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. // It returns the long-term key, nil when there is no mnemonic, and the // derivation index, public key hash, and family hash. func processMnemonicForVault( fs afero.Fs, stateDir, vaultDir, vaultName string, mnemonicBuffer *memguard.LockedBuffer, ) (*age.X25519Identity, uint32, string, string, error) { if mnemonicBuffer == nil { secret.Debug("No mnemonic given, vault created without long-term key", "vault", vaultName) // Use 0 for derivation index when no mnemonic is provided return nil, 0, "", "", nil } mnemonic := mnemonicBuffer.String() secret.Debug("Mnemonic given, 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 nil, 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 nil, 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") err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey)) if err != nil { return nil, 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 nil, 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err) } familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String())) return ltIdentity, derivationIndex, publicKeyHash, familyHash, nil } // CreateVault creates a new vault and selects it as the current vault. When // mnemonic is not nil, the vault's long-term key is derived from it, and the // returned vault has it as its Mnemonic; when it is nil, the vault has no // long-term key until one is imported. When passphrase is not nil, the vault // gets a passphrase unlocker protected by it, as its current unlocker; that // needs a mnemonic. It refuses a vault that already exists before writing // anything: creating it again would replace its keys, and its secrets could // no longer be decrypted. The commands that call it hold the state directory // lock, so no other command can create the vault between the check and the // writes. // // The vault is written whole into a temporary directory, which is renamed // into vaults.d only once complete, and only then selected: a crash at any // point leaves either no vault or a complete one. The next command that // takes the lock deletes what the crash left under a temporary name. func CreateVault( fs afero.Fs, stateDir string, name string, mnemonic, passphrase *memguard.LockedBuffer, ) (*Vault, error) { secret.Debug("Creating new vault", "name", name, "state_dir", stateDir) err := ValidateVaultName(name) if err != nil { secret.Debug("Invalid vault name provided", "vault_name", name) return nil, err } secret.Debug("Vault name validation passed", "vault_name", name) vaultDir := filepath.Join(stateDir, "vaults.d", name) exists, err := afero.DirExists(fs, vaultDir) if err != nil { return nil, fmt.Errorf("failed to check if vault exists: %w", err) } if exists { return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists) } if passphrase != nil && mnemonic == nil { return nil, fmt.Errorf("vault %s %w", name, ErrUnlockerWithoutMnemonic) } secret.Debug("Creating vault directory structure", "vault_dir", vaultDir) err = secret.WriteDir(fs, vaultDir, func(dir string) error { return writeVaultFiles(fs, stateDir, dir, name, mnemonic, passphrase) }) if err != nil { return nil, err } // Select the newly created vault as current secret.Debug("Selecting newly created vault as current", "name", name) err = SelectVault(fs, stateDir, name) if err != nil { return nil, fmt.Errorf("failed to select vault: %w", err) } // Create and return the vault secret.Debug("Successfully created vault", "name", name) vlt := NewVault(fs, stateDir, name) vlt.Mnemonic = mnemonic return vlt, nil } // writeVaultFiles writes the files of the new vault name into vaultDir: its // secrets and unlockers directories, its long-term public key and metadata, // and, when passphrase is not nil, a passphrase unlocker as its current one. func writeVaultFiles( fs afero.Fs, stateDir, vaultDir, name string, mnemonic, passphrase *memguard.LockedBuffer, ) error { for _, subdir := range []string{"secrets.d", "unlockers.d"} { err := fs.MkdirAll(filepath.Join(vaultDir, subdir), secret.DirPerms) if err != nil { return fmt.Errorf("failed to create %s directory: %w", subdir, err) } } ltIdentity, derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(fs, stateDir, vaultDir, name, mnemonic) if err != nil { return err } metadata := &Metadata{ CreatedAt: time.Now(), DerivationIndex: derivationIndex, PublicKeyHash: publicKeyHash, MnemonicFamilyHash: familyHash, } err = SaveVaultMetadata(fs, vaultDir, metadata) if err != nil { return fmt.Errorf("failed to save vault metadata: %w", err) } if passphrase == nil { return nil } _, err = writePassphraseUnlocker(fs, vaultDir, ltIdentity, passphrase) return err } // 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) err := ValidateVaultName(name) if err != nil { secret.Debug("Invalid vault name provided", "vault_name", name) return err } 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 %w", name, ErrVaultNotFound) } // Create or replace the currentvault file with just the vault name. It // is replaced in one rename, so it never goes missing. currentVaultPath := filepath.Join(stateDir, "currentvault") secret.Debug("Writing currentvault file", "vault_name", name) err = secret.WriteFileAtomic(fs, currentVaultPath, []byte(name)) if err != nil { return fmt.Errorf("failed to select vault: %w", err) } secret.Debug("Successfully selected vault", "vault_name", name) return nil }