package vault import ( "encoding/json" "fmt" "log/slog" "path/filepath" "strings" "time" "filippo.io/age" "git.eeqj.de/sneak/secret/internal/secret" "github.com/awnumar/memguard" "github.com/spf13/afero" ) // GetCurrentUnlocker returns the current unlocker for this vault func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) { secret.DebugWith("Getting current unlocker", slog.String("vault_name", v.Name)) vaultDir, err := v.GetDirectory() if err != nil { secret.Debug("Failed to get vault directory for unlocker", "error", err, "vault_name", v.Name) return nil, err } currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker") // Check if the symlink exists _, err = v.fs.Stat(currentUnlockerPath) if err != nil { secret.Debug("Failed to stat current unlocker symlink", "error", err, "path", currentUnlockerPath) return nil, fmt.Errorf("failed to read current unlocker: %w", err) } // Resolve the symlink to get the target directory unlockerDir, err := v.resolveUnlockerDirectory(currentUnlockerPath) if err != nil { return nil, err } secret.DebugWith("Resolved unlocker directory", slog.String("unlocker_dir", unlockerDir), slog.String("vault_name", v.Name), ) // Read unlocker metadata metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json") secret.Debug("Reading unlocker metadata", "path", metadataPath) metadataBytes, err := afero.ReadFile(v.fs, metadataPath) if err != nil { secret.Debug("Failed to read unlocker metadata", "error", err, "path", metadataPath) return nil, fmt.Errorf("failed to read unlocker metadata: %w", err) } var metadata UnlockerMetadata if err := json.Unmarshal(metadataBytes, &metadata); err != nil { secret.Debug("Failed to parse unlocker metadata", "error", err, "path", metadataPath) return nil, fmt.Errorf("failed to parse unlocker metadata: %w", err) } secret.DebugWith("Parsed unlocker metadata", slog.String("unlocker_type", metadata.Type), slog.Time("created_at", metadata.CreatedAt), slog.Any("flags", metadata.Flags), ) // Create unlocker instance using direct constructors with filesystem var unlocker secret.Unlocker // Use metadata directly as it's already the correct type switch metadata.Type { case "passphrase": secret.Debug("Creating passphrase unlocker instance", "unlocker_type", metadata.Type) unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata) case "pgp": secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type) unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata) case "keychain": secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type) unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, metadata) default: secret.Debug("Unsupported unlocker type", "type", metadata.Type) return nil, fmt.Errorf("unsupported unlocker type: %s", metadata.Type) } secret.DebugWith("Successfully created unlocker instance", slog.String("unlocker_type", unlocker.GetType()), slog.String("unlocker_id", unlocker.GetID()), slog.String("vault_name", v.Name), ) return unlocker, nil } // resolveUnlockerDirectory resolves the unlocker directory from a symlink or file func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) { linkReader, ok := v.fs.(afero.LinkReader) if !ok { // Fallback for filesystems that don't support symlinks return v.readUnlockerPathFromFile(currentUnlockerPath) } secret.Debug("Resolving unlocker symlink using afero") // Try to read as symlink first unlockerDir, err := linkReader.ReadlinkIfPossible(currentUnlockerPath) if err == nil { return unlockerDir, nil } secret.Debug("Failed to read symlink, falling back to file contents", "error", err, "symlink_path", currentUnlockerPath) // Fallback: read the path from file contents return v.readUnlockerPathFromFile(currentUnlockerPath) } // readUnlockerPathFromFile reads the unlocker directory path from a file func (v *Vault) readUnlockerPathFromFile(path string) (string, error) { secret.Debug("Reading unlocker path from file", "path", path) unlockerDirBytes, err := afero.ReadFile(v.fs, path) if err != nil { secret.Debug("Failed to read unlocker path file", "error", err, "path", path) return "", fmt.Errorf("failed to read current unlocker: %w", err) } return strings.TrimSpace(string(unlockerDirBytes)), nil } // findUnlockerByID finds an unlocker by its ID and returns the unlocker instance and its directory path func (v *Vault) findUnlockerByID(unlockersDir, unlockerID string) (secret.Unlocker, string, error) { files, err := afero.ReadDir(v.fs, unlockersDir) if err != nil { return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err) } for _, file := range files { if !file.IsDir() { continue } // Read metadata file metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json") exists, err := afero.Exists(v.fs, metadataPath) if err != nil { return nil, "", fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err) } if !exists { // Skip directories without metadata - they might not be unlockers continue } metadataBytes, err := afero.ReadFile(v.fs, metadataPath) if err != nil { return nil, "", fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err) } var metadata UnlockerMetadata if err := json.Unmarshal(metadataBytes, &metadata); err != nil { return nil, "", fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err) } unlockerDirPath := filepath.Join(unlockersDir, file.Name()) // Create the appropriate unlocker instance var tempUnlocker secret.Unlocker switch metadata.Type { case "passphrase": tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, metadata) case "pgp": tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata) case "keychain": tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, metadata) default: continue } // Check if this unlocker's ID matches if tempUnlocker.GetID() == unlockerID { return tempUnlocker, unlockerDirPath, nil } } return nil, "", nil } // ListUnlockers returns a list of available unlockers for this vault func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) { vaultDir, err := v.GetDirectory() if err != nil { return nil, err } unlockersDir := filepath.Join(vaultDir, "unlockers.d") // Check if unlockers directory exists exists, err := afero.DirExists(v.fs, unlockersDir) if err != nil { return nil, fmt.Errorf("failed to check if unlockers directory exists: %w", err) } if !exists { return []UnlockerMetadata{}, nil } // List directories in unlockers.d files, err := afero.ReadDir(v.fs, unlockersDir) if err != nil { return nil, fmt.Errorf("failed to read unlockers directory: %w", err) } var unlockers []UnlockerMetadata for _, file := range files { if file.IsDir() { // Read metadata file metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json") exists, err := afero.Exists(v.fs, metadataPath) if err != nil { return nil, fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err) } if !exists { return nil, fmt.Errorf("unlocker directory %s is missing metadata file", file.Name()) } metadataBytes, err := afero.ReadFile(v.fs, metadataPath) if err != nil { return nil, fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err) } var metadata UnlockerMetadata if err := json.Unmarshal(metadataBytes, &metadata); err != nil { return nil, fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err) } unlockers = append(unlockers, metadata) } } return unlockers, nil } // RemoveUnlocker removes an unlocker from this vault func (v *Vault) RemoveUnlocker(unlockerID string) error { vaultDir, err := v.GetDirectory() if err != nil { return err } // Find the unlocker directory and create the unlocker instance unlockersDir := filepath.Join(vaultDir, "unlockers.d") // Find the unlocker by ID unlocker, _, err := v.findUnlockerByID(unlockersDir, unlockerID) if err != nil { return err } if unlocker == nil { return fmt.Errorf("unlocker with ID %s not found", unlockerID) } // Use the unlocker's Remove method return unlocker.Remove() } // SelectUnlocker selects an unlocker as current for this vault func (v *Vault) SelectUnlocker(unlockerID string) error { vaultDir, err := v.GetDirectory() if err != nil { return err } // Find the unlocker directory by ID unlockersDir := filepath.Join(vaultDir, "unlockers.d") // Find the unlocker by ID _, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID) if err != nil { return err } if targetUnlockerDir == "" { return fmt.Errorf("unlocker with ID %s not found", unlockerID) } // Create/update current unlocker symlink currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker") // Remove existing symlink if it exists if exists, err := afero.Exists(v.fs, currentUnlockerPath); err != nil { return fmt.Errorf("failed to check if current unlocker symlink exists: %w", err) } else if exists { if err := v.fs.Remove(currentUnlockerPath); err != nil { return fmt.Errorf("failed to remove existing unlocker symlink: %w", err) } } // Create new symlink using afero's SymlinkIfPossible if linker, ok := v.fs.(afero.Linker); ok { secret.Debug("Creating unlocker symlink", "target", targetUnlockerDir, "link", currentUnlockerPath) if err := linker.SymlinkIfPossible(targetUnlockerDir, currentUnlockerPath); err != nil { return fmt.Errorf("failed to create unlocker symlink: %w", err) } } else { // Fallback: create a regular file with the target path for filesystems that don't support symlinks secret.Debug("Fallback: creating regular file with target path", "target", targetUnlockerDir) if err := afero.WriteFile(v.fs, currentUnlockerPath, []byte(targetUnlockerDir), secret.FilePerms); err != nil { return fmt.Errorf("failed to create unlocker symlink file: %w", err) } } return nil } // CreatePassphraseUnlocker creates a new passphrase-protected unlocker // The passphrase must be provided as a LockedBuffer for security func (v *Vault) CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*secret.PassphraseUnlocker, error) { vaultDir, err := v.GetDirectory() if err != nil { return nil, fmt.Errorf("failed to get vault directory: %w", err) } // Create unlocker directory unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase") if err := v.fs.MkdirAll(unlockerDir, secret.DirPerms); err != nil { return nil, fmt.Errorf("failed to create unlocker directory: %w", err) } // Generate new age keypair for unlocker unlockerIdentity, err := age.GenerateX25519Identity() if err != nil { return nil, fmt.Errorf("failed to generate unlocker: %w", err) } // Write public key pubKeyPath := filepath.Join(unlockerDir, "pub.age") if err := afero.WriteFile(v.fs, pubKeyPath, []byte(unlockerIdentity.Recipient().String()), secret.FilePerms); err != nil { return nil, fmt.Errorf("failed to write unlocker public key: %w", err) } // Encrypt private key with passphrase privKeyStr := unlockerIdentity.String() encryptedPrivKey, err := secret.EncryptWithPassphrase([]byte(privKeyStr), passphrase) if err != nil { return nil, fmt.Errorf("failed to encrypt unlocker private key: %w", err) } // Write encrypted private key privKeyPath := filepath.Join(unlockerDir, "priv.age") if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil { return nil, fmt.Errorf("failed to write encrypted unlocker private key: %w", err) } // Create metadata metadata := UnlockerMetadata{ Type: "passphrase", CreatedAt: time.Now(), Flags: []string{}, } // Write metadata metadataBytes, err := json.MarshalIndent(metadata, "", " ") if err != nil { return nil, fmt.Errorf("failed to marshal metadata: %w", err) } metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json") if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms); err != nil { return nil, fmt.Errorf("failed to write unlocker metadata: %w", err) } // Encrypt long-term private key to this unlocker // We need to get the long-term key (either from memory if unlocked, or derive it) ltIdentity, err := v.GetOrDeriveLongTermKey() if err != nil { return nil, fmt.Errorf("failed to get long-term key: %w", err) } ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String())) defer ltPrivKeyBuffer.Destroy() encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, unlockerIdentity.Recipient()) if err != nil { return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err) } ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age") if err := afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil { return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err) } // Create the unlocker instance unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata) // Select this unlocker as current if err := v.SelectUnlocker(unlocker.GetID()); err != nil { return nil, fmt.Errorf("failed to select new unlocker: %w", err) } return unlocker, nil }