secret/internal/vault/unlockers.go
sneak bdcddadf90 fix: resolve exported type stuttering issues (revive)
- Rename VaultMetadata to Metadata in internal/vault package to avoid stuttering
- Rename BIP85DRNG to DRNG in pkg/bip85 package to avoid stuttering
- Update all references in code and tests
2025-06-20 12:47:06 -07:00

435 lines
15 KiB
Go

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 linkReader, ok := v.fs.(afero.LinkReader); ok {
secret.Debug("Resolving unlocker symlink using afero")
// Try to read as symlink first
unlockerDir, err = linkReader.ReadlinkIfPossible(currentUnlockerPath)
if err != nil {
secret.Debug("Failed to read symlink, falling back to file contents",
"error", err, "symlink_path", currentUnlockerPath)
// Fallback: 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))
}
} else {
secret.Debug("Reading unlocker path (filesystem doesn't support symlinks)")
// Fallback for filesystems that don't support symlinks: 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, "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
// Convert our metadata to secret.UnlockerMetadata
secretMetadata := secret.UnlockerMetadata(metadata)
switch metadata.Type {
case "passphrase":
secret.Debug("Creating passphrase unlocker instance", "unlocker_type", metadata.Type)
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, secretMetadata)
case "pgp":
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, secretMetadata)
case "keychain":
secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type)
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, secretMetadata)
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
}
// 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")
// 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(), "unlocker-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil {
return 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 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 fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
}
unlockerDirPath = filepath.Join(unlockersDir, file.Name())
// Convert our metadata to secret.UnlockerMetadata
secretMetadata := secret.UnlockerMetadata(metadata)
// Create the appropriate unlocker instance
var tempUnlocker secret.Unlocker
switch metadata.Type {
case "passphrase":
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, secretMetadata)
case "pgp":
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, secretMetadata)
case "keychain":
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, secretMetadata)
default:
continue
}
// Check if this unlocker's ID matches
if tempUnlocker.GetID() == unlockerID {
unlocker = tempUnlocker
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(), "unlocker-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil {
return 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 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 fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
}
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
// Convert our metadata to secret.UnlockerMetadata
secretMetadata := secret.UnlockerMetadata(metadata)
// Create the appropriate unlocker instance
var tempUnlocker secret.Unlocker
switch metadata.Type {
case "passphrase":
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, secretMetadata)
case "pgp":
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, secretMetadata)
case "keychain":
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, secretMetadata)
default:
continue
}
// Check if this unlocker's ID matches
if tempUnlocker.GetID() == unlockerID {
targetUnlockerDir = unlockerDirPath
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, 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
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
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
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)
}
ltPrivKey := []byte(ltIdentity.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)
}
// Convert our metadata to secret.UnlockerMetadata for the constructor
secretMetadata := secret.UnlockerMetadata(metadata)
// Create the unlocker instance
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, secretMetadata)
// 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
}