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), 0600) } // 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, 0700); 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()), 0600); 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, 0600); 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, 0600); err != nil { return nil, fmt.Errorf("failed to write 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, 0600); 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 }