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" ) // Unlocker metadata type strings. const ( unlockerTypePassphrase = "passphrase" unlockerTypeSecureEnclave = "secure-enclave" ) // GetCurrentUnlocker returns the current unlocker for this vault // //nolint:ireturn // returns one of several concrete unlocker implementations 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 metadata, err := v.readUnlockerMetadata(unlockerDir) if err != nil { return nil, err } // 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 unlockerTypePassphrase: 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) case unlockerTypeSecureEnclave: secret.Debug("Creating secure enclave unlocker instance", "unlocker_type", metadata.Type) unlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDir, metadata) default: secret.Debug("Unsupported unlocker type", "type", metadata.Type) return nil, fmt.Errorf("%w: %s", ErrUnsupportedUnlockerType, 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 // //nolint:ireturn // returns one of several concrete unlocker implementations 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 err = json.Unmarshal(metadataBytes, &metadata) if 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 unlockerTypePassphrase: 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) case unlockerTypeSecureEnclave: tempUnlocker = secret.NewSecureEnclaveUnlocker(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 { secret.Warn("Skipping unlocker directory with missing metadata file", "directory", file.Name()) 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 err = json.Unmarshal(metadataBytes, &metadata) if 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 %w", unlockerID, ErrUnlockerNotFound) } // 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 %w", unlockerID, ErrUnlockerNotFound) } // Create/update current-unlocker file with just the unlocker name currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker") // Remove existing file if it exists exists, err := afero.Exists(v.fs, currentUnlockerPath) if err != nil { return fmt.Errorf("failed to check if current-unlocker file exists: %w", err) } if exists { err = v.fs.Remove(currentUnlockerPath) if 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) err = afero.WriteFile(v.fs, currentUnlockerPath, []byte(unlockerName), secret.FilePerms) if 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", unlockerTypePassphrase) err = v.fs.MkdirAll(unlockerDir, secret.DirPerms) if 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 the unlocker keypair (public and passphrase-encrypted private) err = v.writeUnlockerKeypair(unlockerDir, unlockerIdentity, passphrase) if err != nil { return nil, err } // Create metadata metadata := UnlockerMetadata{ Type: unlockerTypePassphrase, 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") err = afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms) if 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") err = afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms) if 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 err = v.SelectUnlocker(unlocker.GetID()) if err != nil { return nil, fmt.Errorf("failed to select new unlocker: %w", err) } return unlocker, nil } // readUnlockerMetadata reads and parses the unlocker-metadata.json file in // the given unlocker directory. func (v *Vault) readUnlockerMetadata(unlockerDir string) (UnlockerMetadata, error) { metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json") secret.Debug("Reading unlocker metadata", "path", metadataPath) var metadata UnlockerMetadata metadataBytes, err := afero.ReadFile(v.fs, metadataPath) if err != nil { secret.Debug("Failed to read unlocker metadata", "error", err, "path", metadataPath) return metadata, fmt.Errorf("failed to read unlocker metadata: %w", err) } err = json.Unmarshal(metadataBytes, &metadata) if err != nil { secret.Debug("Failed to parse unlocker metadata", "error", err, "path", metadataPath) return metadata, 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), ) return metadata, nil } // writeUnlockerKeypair writes the unlocker's public key and its // passphrase-encrypted private key into the unlocker directory. func (v *Vault) writeUnlockerKeypair( unlockerDir string, unlockerIdentity *age.X25519Identity, passphrase *memguard.LockedBuffer, ) error { // Write public key pubKeyPath := filepath.Join(unlockerDir, "pub.age") err := afero.WriteFile(v.fs, pubKeyPath, []byte(unlockerIdentity.Recipient().String()), secret.FilePerms) if err != nil { return 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 fmt.Errorf("failed to encrypt unlocker private key: %w", err) } // Write encrypted private key privKeyPath := filepath.Join(unlockerDir, "priv.age") err = afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms) if err != nil { return fmt.Errorf("failed to write encrypted unlocker private key: %w", err) } return nil }