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 reads the current-unlocker file to get the unlocker directory path // The file contains just the unlocker name (e.g., "passphrase") func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) { secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath) unlockerNameBytes, err := afero.ReadFile(v.fs, currentUnlockerPath) if err != nil { secret.Debug("Failed to read current-unlocker file", "error", err, "path", currentUnlockerPath) return "", fmt.Errorf("failed to read current unlocker: %w", err) } unlockerName := strings.TrimSpace(string(unlockerNameBytes)) secret.Debug("Read unlocker name from file", "unlocker_name", unlockerName) // Resolve to absolute path: vaultDir/unlockers.d/unlockerName vaultDir := filepath.Dir(currentUnlockerPath) absolutePath := filepath.Join(vaultDir, "unlockers.d", unlockerName) secret.Debug("Resolved to absolute path", "absolute_path", absolutePath) return absolutePath, 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 file with just the unlocker name currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker") // Remove existing file if it exists if exists, err := afero.Exists(v.fs, currentUnlockerPath); err != nil { return fmt.Errorf("failed to check if current-unlocker file exists: %w", err) } else if exists { if err := v.fs.Remove(currentUnlockerPath); err != nil { return fmt.Errorf("failed to remove existing current-unlocker file: %w", err) } } // Get just the unlocker name (basename of the directory) unlockerName := filepath.Base(targetUnlockerDir) // Write just the unlocker name to the file secret.Debug("Writing current-unlocker file", "unlocker_name", unlockerName) if err := afero.WriteFile(v.fs, currentUnlockerPath, []byte(unlockerName), secret.FilePerms); err != nil { return fmt.Errorf("failed to create current-unlocker 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() privKeyBuffer := memguard.NewBufferFromBytes([]byte(privKeyStr)) defer privKeyBuffer.Destroy() encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, 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 }