secret/internal/vault/unlock_keys.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
}