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"
|
|
)
|
|
|
|
// GetCurrentUnlockKey returns the current unlock key for this vault
|
|
func (v *Vault) GetCurrentUnlockKey() (secret.UnlockKey, error) {
|
|
secret.DebugWith("Getting current unlock key", slog.String("vault_name", v.Name))
|
|
|
|
vaultDir, err := v.GetDirectory()
|
|
if err != nil {
|
|
secret.Debug("Failed to get vault directory for unlock key", "error", err, "vault_name", v.Name)
|
|
return nil, err
|
|
}
|
|
|
|
currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key")
|
|
|
|
// Check if the symlink exists
|
|
_, err = v.fs.Stat(currentUnlockKeyPath)
|
|
if err != nil {
|
|
secret.Debug("Failed to stat current unlock key symlink", "error", err, "path", currentUnlockKeyPath)
|
|
return nil, fmt.Errorf("failed to read current unlock key: %w", err)
|
|
}
|
|
|
|
// Resolve the symlink to get the target directory
|
|
var unlockKeyDir string
|
|
if _, ok := v.fs.(*afero.OsFs); ok {
|
|
secret.Debug("Resolving unlock key symlink (real filesystem)")
|
|
// For real filesystems, resolve the symlink properly
|
|
unlockKeyDir, err = ResolveVaultSymlink(v.fs, currentUnlockKeyPath)
|
|
if err != nil {
|
|
secret.Debug("Failed to resolve unlock key symlink", "error", err, "symlink_path", currentUnlockKeyPath)
|
|
return nil, fmt.Errorf("failed to resolve current unlock key symlink: %w", err)
|
|
}
|
|
} else {
|
|
secret.Debug("Reading unlock key path (mock filesystem)")
|
|
// Fallback for mock filesystems: read the path from file contents
|
|
unlockKeyDirBytes, err := afero.ReadFile(v.fs, currentUnlockKeyPath)
|
|
if err != nil {
|
|
secret.Debug("Failed to read unlock key path file", "error", err, "path", currentUnlockKeyPath)
|
|
return nil, fmt.Errorf("failed to read current unlock key: %w", err)
|
|
}
|
|
unlockKeyDir = strings.TrimSpace(string(unlockKeyDirBytes))
|
|
}
|
|
|
|
secret.DebugWith("Resolved unlock key directory",
|
|
slog.String("unlock_key_dir", unlockKeyDir),
|
|
slog.String("vault_name", v.Name),
|
|
)
|
|
|
|
// Read unlock key metadata
|
|
metadataPath := filepath.Join(unlockKeyDir, "unlock-metadata.json")
|
|
secret.Debug("Reading unlock key metadata", "path", metadataPath)
|
|
|
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
|
if err != nil {
|
|
secret.Debug("Failed to read unlock key metadata", "error", err, "path", metadataPath)
|
|
return nil, fmt.Errorf("failed to read unlock key metadata: %w", err)
|
|
}
|
|
|
|
var metadata UnlockKeyMetadata
|
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
secret.Debug("Failed to parse unlock key metadata", "error", err, "path", metadataPath)
|
|
return nil, fmt.Errorf("failed to parse unlock key metadata: %w", err)
|
|
}
|
|
|
|
secret.DebugWith("Parsed unlock key metadata",
|
|
slog.String("key_id", metadata.ID),
|
|
slog.String("key_type", metadata.Type),
|
|
slog.Time("created_at", metadata.CreatedAt),
|
|
slog.Any("flags", metadata.Flags),
|
|
)
|
|
|
|
// Create unlock key instance using direct constructors with filesystem
|
|
var unlockKey secret.UnlockKey
|
|
// Convert our metadata to secret.UnlockKeyMetadata
|
|
secretMetadata := secret.UnlockKeyMetadata(metadata)
|
|
switch metadata.Type {
|
|
case "passphrase":
|
|
secret.Debug("Creating passphrase unlock key instance", "key_id", metadata.ID)
|
|
unlockKey = secret.NewPassphraseUnlockKey(v.fs, unlockKeyDir, secretMetadata)
|
|
case "pgp":
|
|
secret.Debug("Creating PGP unlock key instance", "key_id", metadata.ID)
|
|
unlockKey = secret.NewPGPUnlockKey(v.fs, unlockKeyDir, secretMetadata)
|
|
case "keychain":
|
|
secret.Debug("Creating keychain unlock key instance", "key_id", metadata.ID)
|
|
unlockKey = secret.NewKeychainUnlockKey(v.fs, unlockKeyDir, secretMetadata)
|
|
default:
|
|
secret.Debug("Unsupported unlock key type", "type", metadata.Type, "key_id", metadata.ID)
|
|
return nil, fmt.Errorf("unsupported unlock key type: %s", metadata.Type)
|
|
}
|
|
|
|
secret.DebugWith("Successfully created unlock key instance",
|
|
slog.String("key_type", unlockKey.GetType()),
|
|
slog.String("key_id", unlockKey.GetID()),
|
|
slog.String("vault_name", v.Name),
|
|
)
|
|
|
|
return unlockKey, nil
|
|
}
|
|
|
|
// ListUnlockKeys returns a list of available unlock keys for this vault
|
|
func (v *Vault) ListUnlockKeys() ([]UnlockKeyMetadata, error) {
|
|
vaultDir, err := v.GetDirectory()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
|
|
|
|
// Check if unlock keys directory exists
|
|
exists, err := afero.DirExists(v.fs, unlockKeysDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check if unlock keys directory exists: %w", err)
|
|
}
|
|
if !exists {
|
|
return []UnlockKeyMetadata{}, nil
|
|
}
|
|
|
|
// List directories in unlock.d
|
|
files, err := afero.ReadDir(v.fs, unlockKeysDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read unlock keys directory: %w", err)
|
|
}
|
|
|
|
var keys []UnlockKeyMetadata
|
|
for _, file := range files {
|
|
if file.IsDir() {
|
|
// Read metadata file
|
|
metadataPath := filepath.Join(unlockKeysDir, 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 UnlockKeyMetadata
|
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
continue
|
|
}
|
|
|
|
keys = append(keys, metadata)
|
|
}
|
|
}
|
|
|
|
return keys, nil
|
|
}
|
|
|
|
// RemoveUnlockKey removes an unlock key from this vault
|
|
func (v *Vault) RemoveUnlockKey(keyID string) error {
|
|
vaultDir, err := v.GetDirectory()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Find the key directory and create the unlock key instance
|
|
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
|
|
|
|
// List directories in unlock.d
|
|
files, err := afero.ReadDir(v.fs, unlockKeysDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read unlock keys directory: %w", err)
|
|
}
|
|
|
|
var unlockKey secret.UnlockKey
|
|
var keyDir string
|
|
for _, file := range files {
|
|
if file.IsDir() {
|
|
// Read metadata file
|
|
metadataPath := filepath.Join(unlockKeysDir, 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 UnlockKeyMetadata
|
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
continue
|
|
}
|
|
|
|
if metadata.ID == keyID {
|
|
keyDir = filepath.Join(unlockKeysDir, file.Name())
|
|
|
|
// Convert our metadata to secret.UnlockKeyMetadata
|
|
secretMetadata := secret.UnlockKeyMetadata(metadata)
|
|
|
|
// Create the appropriate unlock key instance
|
|
switch metadata.Type {
|
|
case "passphrase":
|
|
unlockKey = secret.NewPassphraseUnlockKey(v.fs, keyDir, secretMetadata)
|
|
case "pgp":
|
|
unlockKey = secret.NewPGPUnlockKey(v.fs, keyDir, secretMetadata)
|
|
case "keychain":
|
|
unlockKey = secret.NewKeychainUnlockKey(v.fs, keyDir, secretMetadata)
|
|
default:
|
|
return fmt.Errorf("unsupported unlock key type: %s", metadata.Type)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if unlockKey == nil {
|
|
return fmt.Errorf("unlock key with ID %s not found", keyID)
|
|
}
|
|
|
|
// Use the unlock key's Remove method
|
|
return unlockKey.Remove()
|
|
}
|
|
|
|
// SelectUnlockKey selects an unlock key as current for this vault
|
|
func (v *Vault) SelectUnlockKey(keyID string) error {
|
|
vaultDir, err := v.GetDirectory()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Find the unlock key directory by ID
|
|
unlockKeysDir := filepath.Join(vaultDir, "unlock.d")
|
|
|
|
// List directories in unlock.d to find the key
|
|
files, err := afero.ReadDir(v.fs, unlockKeysDir)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read unlock keys directory: %w", err)
|
|
}
|
|
|
|
var targetKeyDir string
|
|
for _, file := range files {
|
|
if file.IsDir() {
|
|
// Read metadata file
|
|
metadataPath := filepath.Join(unlockKeysDir, 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 UnlockKeyMetadata
|
|
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
continue
|
|
}
|
|
|
|
if metadata.ID == keyID {
|
|
targetKeyDir = filepath.Join(unlockKeysDir, file.Name())
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if targetKeyDir == "" {
|
|
return fmt.Errorf("unlock key with ID %s not found", keyID)
|
|
}
|
|
|
|
// Create/update current unlock key symlink
|
|
currentUnlockKeyPath := filepath.Join(vaultDir, "current-unlock-key")
|
|
|
|
// Remove existing symlink if it exists
|
|
if exists, _ := afero.Exists(v.fs, currentUnlockKeyPath); exists {
|
|
if err := v.fs.Remove(currentUnlockKeyPath); err != nil {
|
|
secret.Debug("Failed to remove existing unlock key symlink", "error", err, "path", currentUnlockKeyPath)
|
|
}
|
|
}
|
|
|
|
// Create new symlink
|
|
return afero.WriteFile(v.fs, currentUnlockKeyPath, []byte(targetKeyDir), secret.FilePerms)
|
|
}
|
|
|
|
// CreatePassphraseKey creates a new passphrase-protected unlock key
|
|
func (v *Vault) CreatePassphraseKey(passphrase string) (*secret.PassphraseUnlockKey, error) {
|
|
vaultDir, err := v.GetDirectory()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
|
}
|
|
|
|
// Create unlock key directory with timestamp
|
|
timestamp := time.Now().Format("2006-01-02.15.04")
|
|
unlockKeyDir := filepath.Join(vaultDir, "unlock.d", "passphrase")
|
|
if err := v.fs.MkdirAll(unlockKeyDir, secret.DirPerms); err != nil {
|
|
return nil, fmt.Errorf("failed to create unlock key directory: %w", err)
|
|
}
|
|
|
|
// Generate new age keypair for unlock key
|
|
unlockIdentity, err := age.GenerateX25519Identity()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate unlock key: %w", err)
|
|
}
|
|
|
|
// Write public key
|
|
pubKeyPath := filepath.Join(unlockKeyDir, "pub.age")
|
|
if err := afero.WriteFile(v.fs, pubKeyPath, []byte(unlockIdentity.Recipient().String()), secret.FilePerms); err != nil {
|
|
return nil, fmt.Errorf("failed to write unlock key public key: %w", err)
|
|
}
|
|
|
|
// Encrypt private key with passphrase
|
|
privKeyData := []byte(unlockIdentity.String())
|
|
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, passphrase)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encrypt unlock key private key: %w", err)
|
|
}
|
|
|
|
// Write encrypted private key
|
|
privKeyPath := filepath.Join(unlockKeyDir, "priv.age")
|
|
if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
|
|
return nil, fmt.Errorf("failed to write encrypted unlock key private key: %w", err)
|
|
}
|
|
|
|
// Create metadata
|
|
keyID := fmt.Sprintf("%s-passphrase", timestamp)
|
|
metadata := UnlockKeyMetadata{
|
|
ID: keyID,
|
|
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(unlockKeyDir, "unlock-metadata.json")
|
|
if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
|
|
return nil, fmt.Errorf("failed to write unlock key metadata: %w", err)
|
|
}
|
|
|
|
// Encrypt long-term private key to this unlock key if vault is unlocked
|
|
if !v.Locked() {
|
|
ltPrivKey := []byte(v.GetLongTermKey().String())
|
|
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKey, unlockIdentity.Recipient())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
|
}
|
|
|
|
ltPrivKeyPath := filepath.Join(unlockKeyDir, "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 unlock key as current
|
|
if err := v.SelectUnlockKey(keyID); err != nil {
|
|
return nil, fmt.Errorf("failed to select new unlock key: %w", err)
|
|
}
|
|
|
|
// Convert our metadata to secret.UnlockKeyMetadata for the constructor
|
|
secretMetadata := secret.UnlockKeyMetadata(metadata)
|
|
|
|
return secret.NewPassphraseUnlockKey(v.fs, unlockKeyDir, secretMetadata), nil
|
|
}
|