377 lines
12 KiB
Go
377 lines
12 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 _, 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, "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_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(), "unlocker-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(), "unlocker-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(), "unlocker-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, "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 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
|
|
}
|