package vault import ( "encoding/json" "fmt" "log/slog" "path/filepath" "strings" "time" "filippo.io/age" "git.eeqj.de/sneak/secret/internal/secret" "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 var unlockerDir string if _, ok := v.fs.(*afero.OsFs); ok { secret.Debug("Resolving unlocker symlink (real filesystem)") // For real filesystems, resolve the symlink properly unlockerDir, err = ResolveVaultSymlink(v.fs, currentUnlockerPath) if err != nil { secret.Debug("Failed to resolve unlocker symlink", "error", err, "symlink_path", currentUnlockerPath) return nil, fmt.Errorf("failed to resolve current unlocker symlink: %w", err) } } else { secret.Debug("Reading unlocker path (mock filesystem)") // Fallback for mock filesystems: read the path from file contents unlockerDirBytes, err := afero.ReadFile(v.fs, currentUnlockerPath) if err != nil { secret.Debug("Failed to read unlocker path file", "error", err, "path", currentUnlockerPath) return nil, fmt.Errorf("failed to read current unlocker: %w", err) } unlockerDir = strings.TrimSpace(string(unlockerDirBytes)) } secret.DebugWith("Resolved unlocker directory", slog.String("unlocker_dir", unlockerDir), slog.String("vault_name", v.Name), ) // Read unlocker metadata metadataPath := filepath.Join(unlockerDir, "unlock-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_id", metadata.ID), 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 // Convert our metadata to secret.UnlockerMetadata secretMetadata := secret.UnlockerMetadata(metadata) switch metadata.Type { case "passphrase": secret.Debug("Creating passphrase unlocker instance", "unlocker_id", metadata.ID) unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, secretMetadata) case "pgp": secret.Debug("Creating PGP unlocker instance", "unlocker_id", metadata.ID) unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, secretMetadata) case "keychain": secret.Debug("Creating keychain unlocker instance", "unlocker_id", metadata.ID) unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, secretMetadata) default: secret.Debug("Unsupported unlocker type", "type", metadata.Type, "unlocker_id", metadata.ID) 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 } // 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(), "unlock-metadata.json") exists, err := afero.Exists(v.fs, metadataPath) if err != nil { continue } if !exists { continue } metadataBytes, err := afero.ReadFile(v.fs, metadataPath) if err != nil { continue } var metadata UnlockerMetadata if err := json.Unmarshal(metadataBytes, &metadata); err != nil { continue } 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") // List directories in unlockers.d files, err := afero.ReadDir(v.fs, unlockersDir) if err != nil { return fmt.Errorf("failed to read unlockers directory: %w", err) } var unlocker secret.Unlocker var unlockerDirPath string for _, file := range files { if file.IsDir() { // Read metadata file metadataPath := filepath.Join(unlockersDir, file.Name(), "unlock-metadata.json") exists, err := afero.Exists(v.fs, metadataPath) if err != nil || !exists { continue } metadataBytes, err := afero.ReadFile(v.fs, metadataPath) if err != nil { continue } var metadata UnlockerMetadata if err := json.Unmarshal(metadataBytes, &metadata); err != nil { continue } if metadata.ID == unlockerID { unlockerDirPath = filepath.Join(unlockersDir, file.Name()) // Convert our metadata to secret.UnlockerMetadata secretMetadata := secret.UnlockerMetadata(metadata) // Create the appropriate unlocker instance switch metadata.Type { case "passphrase": unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, secretMetadata) case "pgp": unlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, secretMetadata) case "keychain": unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, secretMetadata) default: return fmt.Errorf("unsupported unlocker type: %s", metadata.Type) } break } } } 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") // List directories in unlockers.d to find the unlocker files, err := afero.ReadDir(v.fs, unlockersDir) if err != nil { return fmt.Errorf("failed to read unlockers directory: %w", err) } var targetUnlockerDir string for _, file := range files { if file.IsDir() { // Read metadata file metadataPath := filepath.Join(unlockersDir, file.Name(), "unlock-metadata.json") exists, err := afero.Exists(v.fs, metadataPath) if err != nil || !exists { continue } metadataBytes, err := afero.ReadFile(v.fs, metadataPath) if err != nil { continue } var metadata UnlockerMetadata if err := json.Unmarshal(metadataBytes, &metadata); err != nil { continue } if metadata.ID == unlockerID { targetUnlockerDir = filepath.Join(unlockersDir, file.Name()) break } } } 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, _ := afero.Exists(v.fs, currentUnlockerPath); exists { if err := v.fs.Remove(currentUnlockerPath); err != nil { secret.Debug("Failed to remove existing unlocker symlink", "error", err, "path", currentUnlockerPath) } } // Create new symlink return afero.WriteFile(v.fs, currentUnlockerPath, []byte(targetUnlockerDir), secret.FilePerms) } // CreatePassphraseUnlocker creates a new passphrase-protected unlocker func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseUnlocker, error) { vaultDir, err := v.GetDirectory() if err != nil { return nil, fmt.Errorf("failed to get vault directory: %w", err) } // Create unlocker directory with timestamp timestamp := time.Now().Format("2006-01-02.15.04") 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 privKeyData := []byte(unlockerIdentity.String()) encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, 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 unlockerID := fmt.Sprintf("%s-passphrase", timestamp) metadata := UnlockerMetadata{ ID: unlockerID, 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, "unlock-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 if vault is unlocked if !v.Locked() { ltPrivKey := []byte(v.GetLongTermKey().String()) encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKey, 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) } } // Select this unlocker as current if err := v.SelectUnlocker(unlockerID); err != nil { return nil, fmt.Errorf("failed to select new unlocker: %w", err) } // Convert our metadata to secret.UnlockerMetadata for the constructor secretMetadata := secret.UnlockerMetadata(metadata) return secret.NewPassphraseUnlocker(v.fs, unlockerDir, secretMetadata), nil }