1047 lines
28 KiB
Go
1047 lines
28 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/base32"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/johanj/clawvault/lib"
|
|
"github.com/pquerna/otp/totp"
|
|
)
|
|
|
|
// Handlers holds dependencies for HTTP handlers.
|
|
type Handlers struct {
|
|
DB *lib.DB
|
|
Cfg *lib.Config
|
|
}
|
|
|
|
// NewHandlers creates a new Handlers instance.
|
|
func NewHandlers(db *lib.DB, cfg *lib.Config) *Handlers {
|
|
return &Handlers{DB: db, Cfg: cfg}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Health & Setup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Health returns server status.
|
|
func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) {
|
|
count, _ := lib.EntryCount(h.DB)
|
|
JSONResponse(w, http.StatusOK, map[string]any{
|
|
"status": "ok",
|
|
"entries": count,
|
|
"time": time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
// Setup creates the initial session (first-time setup).
|
|
func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) {
|
|
// Create a web session
|
|
session, err := lib.SessionCreate(h.DB, h.Cfg.SessionTTL, lib.ActorWeb)
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "session_failed", "Failed to create session")
|
|
return
|
|
}
|
|
|
|
lib.AuditLog(h.DB, &lib.AuditEvent{
|
|
Action: "setup",
|
|
Actor: lib.ActorWeb,
|
|
IPAddr: realIP(r),
|
|
})
|
|
|
|
JSONResponse(w, http.StatusOK, map[string]string{
|
|
"token": session.Token,
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Entry CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ListEntries returns all entries (tree structure).
|
|
func (h *Handlers) ListEntries(w http.ResponseWriter, r *http.Request) {
|
|
actor := ActorFromContext(r.Context())
|
|
parentID := r.URL.Query().Get("parent_id")
|
|
var parent *string
|
|
if parentID != "" {
|
|
parent = &parentID
|
|
}
|
|
|
|
entries, err := lib.EntryList(h.DB, h.Cfg, parent)
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries")
|
|
return
|
|
}
|
|
if entries == nil {
|
|
entries = []lib.Entry{}
|
|
}
|
|
|
|
// For MCP, strip L2 field values
|
|
if actor == lib.ActorMCP {
|
|
for i := range entries {
|
|
if entries[i].VaultData != nil {
|
|
stripL2Fields(entries[i].VaultData)
|
|
}
|
|
}
|
|
}
|
|
|
|
JSONResponse(w, http.StatusOK, entries)
|
|
}
|
|
|
|
// GetEntry returns a single entry.
|
|
func (h *Handlers) GetEntry(w http.ResponseWriter, r *http.Request) {
|
|
actor := ActorFromContext(r.Context())
|
|
entryID := chi.URLParam(r, "id")
|
|
|
|
entry, err := lib.EntryGet(h.DB, h.Cfg, entryID)
|
|
if err == lib.ErrNotFound {
|
|
ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry")
|
|
return
|
|
}
|
|
|
|
// Check if soft-deleted
|
|
if entry.DeletedAt != nil {
|
|
ErrorResponse(w, http.StatusNotFound, "deleted", "Entry has been deleted")
|
|
return
|
|
}
|
|
|
|
// For MCP, strip L2 field values
|
|
if actor == lib.ActorMCP && entry.VaultData != nil {
|
|
stripL2Fields(entry.VaultData)
|
|
}
|
|
|
|
lib.AuditLog(h.DB, &lib.AuditEvent{
|
|
EntryID: entry.EntryID,
|
|
Title: entry.Title,
|
|
Action: lib.ActionRead,
|
|
Actor: actor,
|
|
IPAddr: realIP(r),
|
|
})
|
|
|
|
JSONResponse(w, http.StatusOK, entry)
|
|
}
|
|
|
|
// CreateEntry creates a new entry.
|
|
func (h *Handlers) CreateEntry(w http.ResponseWriter, r *http.Request) {
|
|
actor := ActorFromContext(r.Context())
|
|
|
|
var req struct {
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
ParentID string `json:"parent_id"`
|
|
Data *lib.VaultData `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Title == "" {
|
|
ErrorResponse(w, http.StatusBadRequest, "missing_title", "Title is required")
|
|
return
|
|
}
|
|
if req.Type == "" {
|
|
req.Type = lib.TypeCredential
|
|
}
|
|
|
|
entry := &lib.Entry{
|
|
Type: req.Type,
|
|
Title: req.Title,
|
|
ParentID: req.ParentID,
|
|
DataLevel: lib.DataLevelL1,
|
|
VaultData: req.Data,
|
|
}
|
|
|
|
if err := lib.EntryCreate(h.DB, h.Cfg, entry); err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create entry")
|
|
return
|
|
}
|
|
|
|
lib.AuditLog(h.DB, &lib.AuditEvent{
|
|
EntryID: entry.EntryID,
|
|
Title: entry.Title,
|
|
Action: lib.ActionCreate,
|
|
Actor: actor,
|
|
IPAddr: realIP(r),
|
|
})
|
|
|
|
JSONResponse(w, http.StatusCreated, entry)
|
|
}
|
|
|
|
// UpdateEntry updates an existing entry.
|
|
func (h *Handlers) UpdateEntry(w http.ResponseWriter, r *http.Request) {
|
|
actor := ActorFromContext(r.Context())
|
|
entryID := chi.URLParam(r, "id")
|
|
|
|
var req struct {
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
ParentID string `json:"parent_id"`
|
|
Version int `json:"version"`
|
|
Data *lib.VaultData `json:"data"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
// Get existing entry
|
|
existing, err := lib.EntryGet(h.DB, h.Cfg, entryID)
|
|
if err == lib.ErrNotFound {
|
|
ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry")
|
|
return
|
|
}
|
|
|
|
// Update fields
|
|
if req.Title != "" {
|
|
existing.Title = req.Title
|
|
}
|
|
if req.Type != "" {
|
|
existing.Type = req.Type
|
|
}
|
|
existing.ParentID = req.ParentID
|
|
existing.Version = req.Version
|
|
if req.Data != nil {
|
|
existing.VaultData = req.Data
|
|
}
|
|
|
|
if err := lib.EntryUpdate(h.DB, h.Cfg, existing); err != nil {
|
|
if err == lib.ErrVersionConflict {
|
|
ErrorResponse(w, http.StatusConflict, "version_conflict", err.Error())
|
|
return
|
|
}
|
|
ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update entry")
|
|
return
|
|
}
|
|
|
|
lib.AuditLog(h.DB, &lib.AuditEvent{
|
|
EntryID: existing.EntryID,
|
|
Title: existing.Title,
|
|
Action: lib.ActionUpdate,
|
|
Actor: actor,
|
|
IPAddr: realIP(r),
|
|
})
|
|
|
|
JSONResponse(w, http.StatusOK, existing)
|
|
}
|
|
|
|
// DeleteEntry soft-deletes an entry.
|
|
func (h *Handlers) DeleteEntry(w http.ResponseWriter, r *http.Request) {
|
|
actor := ActorFromContext(r.Context())
|
|
entryID := chi.URLParam(r, "id")
|
|
|
|
// Get entry for audit log
|
|
entry, _ := lib.EntryGet(h.DB, h.Cfg, entryID)
|
|
|
|
if err := lib.EntryDelete(h.DB, entryID); err != nil {
|
|
if err == lib.ErrNotFound {
|
|
ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
|
|
return
|
|
}
|
|
ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete entry")
|
|
return
|
|
}
|
|
|
|
title := ""
|
|
if entry != nil {
|
|
title = entry.Title
|
|
}
|
|
|
|
lib.AuditLog(h.DB, &lib.AuditEvent{
|
|
EntryID: entryID,
|
|
Title: title,
|
|
Action: lib.ActionDelete,
|
|
Actor: actor,
|
|
IPAddr: realIP(r),
|
|
})
|
|
|
|
JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Search
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// SearchEntries searches entries by title.
|
|
func (h *Handlers) SearchEntries(w http.ResponseWriter, r *http.Request) {
|
|
actor := ActorFromContext(r.Context())
|
|
query := r.URL.Query().Get("q")
|
|
if query == "" {
|
|
ErrorResponse(w, http.StatusBadRequest, "missing_query", "Query parameter 'q' is required")
|
|
return
|
|
}
|
|
|
|
// Use fuzzy search for practicality
|
|
entries, err := lib.EntrySearchFuzzy(h.DB, h.Cfg, query)
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "search_failed", "Search failed")
|
|
return
|
|
}
|
|
if entries == nil {
|
|
entries = []lib.Entry{}
|
|
}
|
|
|
|
// For MCP, strip L2 field values
|
|
if actor == lib.ActorMCP {
|
|
for i := range entries {
|
|
if entries[i].VaultData != nil {
|
|
stripL2Fields(entries[i].VaultData)
|
|
}
|
|
}
|
|
}
|
|
|
|
JSONResponse(w, http.StatusOK, entries)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Password Generator
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GeneratePassword generates a random password.
|
|
func (h *Handlers) GeneratePassword(w http.ResponseWriter, r *http.Request) {
|
|
lengthStr := r.URL.Query().Get("length")
|
|
length := 20
|
|
if lengthStr != "" {
|
|
if l, err := strconv.Atoi(lengthStr); err == nil && l > 0 && l <= 128 {
|
|
length = l
|
|
}
|
|
}
|
|
|
|
symbols := r.URL.Query().Get("symbols") != "false"
|
|
words := r.URL.Query().Get("words") == "true"
|
|
|
|
var password string
|
|
if words {
|
|
password = generatePassphrase(4)
|
|
} else {
|
|
password = generatePassword(length, symbols)
|
|
}
|
|
|
|
JSONResponse(w, http.StatusOK, map[string]string{
|
|
"password": password,
|
|
})
|
|
}
|
|
|
|
func generatePassword(length int, symbols bool) string {
|
|
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
const digits = "0123456789"
|
|
const syms = "!@#$%^&*()_+-=[]{}|;:,.<>?"
|
|
|
|
charset := letters + digits
|
|
if symbols {
|
|
charset += syms
|
|
}
|
|
|
|
b := make([]byte, length)
|
|
rand.Read(b)
|
|
for i := range b {
|
|
b[i] = charset[int(b[i])%len(charset)]
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func generatePassphrase(words int) string {
|
|
wordList := []string{
|
|
"correct", "horse", "battery", "staple", "cloud", "mountain",
|
|
"river", "forest", "castle", "dragon", "phoenix", "crystal",
|
|
"shadow", "thunder", "whisper", "harvest", "journey", "compass",
|
|
"anchor", "beacon", "bridge", "canyon", "desert", "empire",
|
|
}
|
|
b := make([]byte, words)
|
|
rand.Read(b)
|
|
parts := make([]string, words)
|
|
for i := range parts {
|
|
parts[i] = wordList[int(b[i])%len(wordList)]
|
|
}
|
|
return strings.Join(parts, "-")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Extension API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GetTOTP generates a live TOTP code for an entry.
|
|
func (h *Handlers) GetTOTP(w http.ResponseWriter, r *http.Request) {
|
|
actor := ActorFromContext(r.Context())
|
|
entryID := chi.URLParam(r, "id")
|
|
|
|
entry, err := lib.EntryGet(h.DB, h.Cfg, entryID)
|
|
if err == lib.ErrNotFound {
|
|
ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
|
|
return
|
|
}
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry")
|
|
return
|
|
}
|
|
|
|
if entry.VaultData == nil {
|
|
ErrorResponse(w, http.StatusBadRequest, "no_totp", "Entry has no TOTP field")
|
|
return
|
|
}
|
|
|
|
// Find TOTP field
|
|
var totpSeed string
|
|
var isL2 bool
|
|
for _, field := range entry.VaultData.Fields {
|
|
if field.Kind == "totp" {
|
|
if field.L2 {
|
|
isL2 = true
|
|
} else {
|
|
totpSeed = field.Value
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if isL2 {
|
|
JSONResponse(w, http.StatusOK, map[string]any{
|
|
"l2": true,
|
|
})
|
|
return
|
|
}
|
|
|
|
if totpSeed == "" {
|
|
ErrorResponse(w, http.StatusBadRequest, "no_totp", "Entry has no TOTP field")
|
|
return
|
|
}
|
|
|
|
// Normalize seed (remove spaces, uppercase)
|
|
totpSeed = strings.ToUpper(strings.ReplaceAll(totpSeed, " ", ""))
|
|
|
|
// Generate TOTP code
|
|
code, err := totp.GenerateCode(totpSeed, time.Now())
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusBadRequest, "invalid_totp", "Invalid TOTP seed")
|
|
return
|
|
}
|
|
|
|
// Calculate time until expiry (30 second window)
|
|
now := time.Now().Unix()
|
|
expiresIn := 30 - (now % 30)
|
|
|
|
lib.AuditLog(h.DB, &lib.AuditEvent{
|
|
EntryID: entry.EntryID,
|
|
Title: entry.Title,
|
|
Action: "totp",
|
|
Actor: actor,
|
|
IPAddr: realIP(r),
|
|
})
|
|
|
|
JSONResponse(w, http.StatusOK, map[string]any{
|
|
"code": code,
|
|
"expires_in": expiresIn,
|
|
"l2": false,
|
|
})
|
|
}
|
|
|
|
// MatchURL finds credentials matching a URL (for extension popup).
|
|
func (h *Handlers) MatchURL(w http.ResponseWriter, r *http.Request) {
|
|
urlStr := r.URL.Query().Get("url")
|
|
if urlStr == "" {
|
|
ErrorResponse(w, http.StatusBadRequest, "missing_url", "URL parameter required")
|
|
return
|
|
}
|
|
|
|
// Extract domain from URL
|
|
domain := extractDomain(urlStr)
|
|
|
|
// Get all entries and filter by URL
|
|
entries, err := lib.EntryList(h.DB, h.Cfg, nil)
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries")
|
|
return
|
|
}
|
|
|
|
var matches []lib.Entry
|
|
for _, entry := range entries {
|
|
if entry.VaultData == nil {
|
|
continue
|
|
}
|
|
for _, u := range entry.VaultData.URLs {
|
|
if strings.Contains(u, domain) || strings.Contains(domain, extractDomain(u)) {
|
|
matches = append(matches, entry)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if matches == nil {
|
|
matches = []lib.Entry{}
|
|
}
|
|
|
|
JSONResponse(w, http.StatusOK, matches)
|
|
}
|
|
|
|
// MapFields uses LLM to map vault fields to form fields.
|
|
func (h *Handlers) MapFields(w http.ResponseWriter, r *http.Request) {
|
|
if h.Cfg.FireworksAPIKey == "" {
|
|
ErrorResponse(w, http.StatusServiceUnavailable, "no_llm", "LLM not configured")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
EntryID string `json:"entry_id"`
|
|
PageFields []struct {
|
|
Selector string `json:"selector"`
|
|
Label string `json:"label"`
|
|
Type string `json:"type"`
|
|
Placeholder string `json:"placeholder"`
|
|
} `json:"page_fields"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
entry, err := lib.EntryGet(h.DB, h.Cfg, req.EntryID)
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
|
|
return
|
|
}
|
|
|
|
if entry.VaultData == nil {
|
|
ErrorResponse(w, http.StatusBadRequest, "no_data", "Entry has no data")
|
|
return
|
|
}
|
|
|
|
// Build field lists for LLM
|
|
var vaultFields []string
|
|
for _, f := range entry.VaultData.Fields {
|
|
if !f.L2 { // Only include L1 fields
|
|
vaultFields = append(vaultFields, f.Label)
|
|
}
|
|
}
|
|
|
|
var formFields []string
|
|
for _, f := range req.PageFields {
|
|
desc := f.Selector
|
|
if f.Label != "" {
|
|
desc = f.Label + " (" + f.Selector + ")"
|
|
}
|
|
formFields = append(formFields, desc)
|
|
}
|
|
|
|
// Call LLM
|
|
prompt := fmt.Sprintf(`Map these vault fields to these HTML form fields. Return JSON object mapping vault_field_label to css_selector.
|
|
|
|
Vault fields: %s
|
|
Form fields: %s
|
|
|
|
Return ONLY valid JSON, no explanation. Example: {"Username":"#email","Password":"#password"}`,
|
|
strings.Join(vaultFields, ", "),
|
|
strings.Join(formFields, ", "))
|
|
|
|
llmResp, err := callLLM(h.Cfg, "You are a field mapping assistant. Map credential fields to form fields.", prompt)
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "llm_failed", "LLM request failed")
|
|
return
|
|
}
|
|
|
|
// Parse LLM response
|
|
var mapping map[string]string
|
|
if err := json.Unmarshal([]byte(llmResp), &mapping); err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "parse_failed", "Failed to parse LLM response")
|
|
return
|
|
}
|
|
|
|
JSONResponse(w, http.StatusOK, mapping)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Import
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ImportEntries handles LLM-powered import from any format.
|
|
func (h *Handlers) ImportEntries(w http.ResponseWriter, r *http.Request) {
|
|
if h.Cfg.FireworksAPIKey == "" {
|
|
ErrorResponse(w, http.StatusServiceUnavailable, "no_llm", "LLM not configured")
|
|
return
|
|
}
|
|
|
|
actor := ActorFromContext(r.Context())
|
|
|
|
// Parse multipart form (max 10MB)
|
|
if err := r.ParseMultipartForm(10 << 20); err != nil {
|
|
ErrorResponse(w, http.StatusBadRequest, "invalid_form", "Failed to parse form")
|
|
return
|
|
}
|
|
|
|
file, _, err := r.FormFile("file")
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusBadRequest, "missing_file", "File is required")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
content, err := io.ReadAll(file)
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "read_failed", "Failed to read file")
|
|
return
|
|
}
|
|
|
|
// Call LLM to parse
|
|
prompt := fmt.Sprintf(`Parse this password manager export into an array of VaultData objects.
|
|
|
|
For each entry, create a JSON object with:
|
|
- title: string
|
|
- type: one of "credential", "card", "identity", "note", "ssh_key", "totp"
|
|
- fields: array of {label, value, kind, l2}
|
|
- kind: "text", "password", "totp", "url"
|
|
- l2: true for sensitive fields (card numbers, CVV/CVC, SSN, passport numbers, private keys, TOTP seeds)
|
|
- urls: array of URLs if applicable
|
|
- notes: any notes
|
|
|
|
Mark l2:true on these sensitive field types:
|
|
- Card numbers, CVV/CVC codes
|
|
- SSN, passport numbers
|
|
- Private keys, secret keys
|
|
- TOTP seeds/secrets
|
|
|
|
Return ONLY valid JSON array, no explanation.
|
|
|
|
File content:
|
|
%s`, string(content))
|
|
|
|
llmResp, err := callLLM(h.Cfg, "You are a data parser. Parse password manager exports into structured JSON.", prompt)
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "llm_failed", "LLM request failed: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Parse LLM response
|
|
var entries []lib.VaultData
|
|
if err := json.Unmarshal([]byte(llmResp), &entries); err != nil {
|
|
// Try to extract JSON from response
|
|
start := strings.Index(llmResp, "[")
|
|
end := strings.LastIndex(llmResp, "]")
|
|
if start >= 0 && end > start {
|
|
if json.Unmarshal([]byte(llmResp[start:end+1]), &entries) != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "parse_failed", "Failed to parse LLM response")
|
|
return
|
|
}
|
|
} else {
|
|
ErrorResponse(w, http.StatusInternalServerError, "parse_failed", "Failed to parse LLM response")
|
|
return
|
|
}
|
|
}
|
|
|
|
lib.AuditLog(h.DB, &lib.AuditEvent{
|
|
Action: lib.ActionImport,
|
|
Actor: actor,
|
|
IPAddr: realIP(r),
|
|
Title: fmt.Sprintf("%d entries parsed", len(entries)),
|
|
})
|
|
|
|
JSONResponse(w, http.StatusOK, map[string]any{
|
|
"entries": entries,
|
|
"count": len(entries),
|
|
})
|
|
}
|
|
|
|
// ImportConfirm confirms and saves imported entries.
|
|
func (h *Handlers) ImportConfirm(w http.ResponseWriter, r *http.Request) {
|
|
actor := ActorFromContext(r.Context())
|
|
|
|
var req struct {
|
|
Entries []lib.VaultData `json:"entries"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
|
|
return
|
|
}
|
|
|
|
var created int
|
|
for _, vd := range req.Entries {
|
|
entry := &lib.Entry{
|
|
Type: vd.Type,
|
|
Title: vd.Title,
|
|
DataLevel: lib.DataLevelL1,
|
|
VaultData: &vd,
|
|
}
|
|
if err := lib.EntryCreate(h.DB, h.Cfg, entry); err == nil {
|
|
created++
|
|
}
|
|
}
|
|
|
|
lib.AuditLog(h.DB, &lib.AuditEvent{
|
|
Action: lib.ActionImport,
|
|
Actor: actor,
|
|
IPAddr: realIP(r),
|
|
Title: fmt.Sprintf("%d entries imported", created),
|
|
})
|
|
|
|
JSONResponse(w, http.StatusOK, map[string]int{"imported": created})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Audit Log
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// GetAuditLog returns recent audit events.
|
|
func (h *Handlers) GetAuditLog(w http.ResponseWriter, r *http.Request) {
|
|
limitStr := r.URL.Query().Get("limit")
|
|
limit := 100
|
|
if limitStr != "" {
|
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
|
|
limit = l
|
|
}
|
|
}
|
|
|
|
events, err := lib.AuditList(h.DB, limit)
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list audit events")
|
|
return
|
|
}
|
|
if events == nil {
|
|
events = []lib.AuditEvent{}
|
|
}
|
|
|
|
JSONResponse(w, http.StatusOK, events)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MCP Endpoint
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// MCPHandler handles JSON-RPC 2.0 MCP protocol requests.
|
|
func (h *Handlers) MCPHandler(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID any `json:"id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
mcpError(w, nil, -32700, "Parse error")
|
|
return
|
|
}
|
|
|
|
if req.JSONRPC != "2.0" {
|
|
mcpError(w, req.ID, -32600, "Invalid Request")
|
|
return
|
|
}
|
|
|
|
var result any
|
|
var err error
|
|
|
|
switch req.Method {
|
|
case "tools/list":
|
|
result = h.mcpToolsList()
|
|
case "tools/call":
|
|
result, err = h.mcpToolsCall(r, req.Params)
|
|
default:
|
|
mcpError(w, req.ID, -32601, "Method not found")
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
mcpError(w, req.ID, -32000, err.Error())
|
|
return
|
|
}
|
|
|
|
mcpSuccess(w, req.ID, result)
|
|
}
|
|
|
|
func (h *Handlers) mcpToolsList() map[string]any {
|
|
return map[string]any{
|
|
"tools": []map[string]any{
|
|
{
|
|
"name": "get_credential",
|
|
"description": "Search and return a credential from the vault. L2 fields are omitted.",
|
|
"inputSchema": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"query": map[string]string{"type": "string", "description": "Search query (title or URL)"},
|
|
},
|
|
"required": []string{"query"},
|
|
},
|
|
},
|
|
{
|
|
"name": "list_credentials",
|
|
"description": "List all credentials in the vault (titles, types, URLs only).",
|
|
"inputSchema": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"filter": map[string]string{"type": "string", "description": "Optional type filter"},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
"name": "get_totp",
|
|
"description": "Get a live TOTP code for an entry. Only works for L1 TOTP fields.",
|
|
"inputSchema": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"query": map[string]string{"type": "string", "description": "Entry title or ID"},
|
|
},
|
|
"required": []string{"query"},
|
|
},
|
|
},
|
|
{
|
|
"name": "search_vault",
|
|
"description": "Search the vault by title.",
|
|
"inputSchema": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"query": map[string]string{"type": "string", "description": "Search query"},
|
|
},
|
|
"required": []string{"query"},
|
|
},
|
|
},
|
|
{
|
|
"name": "check_expiring",
|
|
"description": "Check for entries with expiring credentials.",
|
|
"inputSchema": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"days": map[string]any{"type": "number", "description": "Days to check (default 30)"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (h *Handlers) mcpToolsCall(r *http.Request, params json.RawMessage) (any, error) {
|
|
var call struct {
|
|
Name string `json:"name"`
|
|
Arguments map[string]any `json:"arguments"`
|
|
}
|
|
if err := json.Unmarshal(params, &call); err != nil {
|
|
return nil, fmt.Errorf("invalid params")
|
|
}
|
|
|
|
switch call.Name {
|
|
case "get_credential", "search_vault":
|
|
query, _ := call.Arguments["query"].(string)
|
|
if query == "" {
|
|
return nil, fmt.Errorf("query is required")
|
|
}
|
|
entries, err := lib.EntrySearchFuzzy(h.DB, h.Cfg, query)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Strip L2 fields
|
|
for i := range entries {
|
|
if entries[i].VaultData != nil {
|
|
stripL2Fields(entries[i].VaultData)
|
|
}
|
|
}
|
|
if len(entries) == 0 {
|
|
return map[string]any{"content": []map[string]string{{"type": "text", "text": "No credentials found"}}}, nil
|
|
}
|
|
// For get_credential, return best match
|
|
if call.Name == "get_credential" {
|
|
result, _ := json.Marshal(entries[0])
|
|
return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil
|
|
}
|
|
result, _ := json.Marshal(entries)
|
|
return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil
|
|
|
|
case "list_credentials":
|
|
filter, _ := call.Arguments["filter"].(string)
|
|
entries, err := lib.EntryList(h.DB, h.Cfg, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var list []map[string]any
|
|
for _, e := range entries {
|
|
if filter != "" && e.Type != filter {
|
|
continue
|
|
}
|
|
item := map[string]any{
|
|
"entry_id": e.EntryID,
|
|
"title": e.Title,
|
|
"type": e.Type,
|
|
}
|
|
if e.VaultData != nil && len(e.VaultData.URLs) > 0 {
|
|
item["urls"] = e.VaultData.URLs
|
|
}
|
|
list = append(list, item)
|
|
}
|
|
result, _ := json.Marshal(list)
|
|
return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil
|
|
|
|
case "get_totp":
|
|
query, _ := call.Arguments["query"].(string)
|
|
if query == "" {
|
|
return nil, fmt.Errorf("query is required")
|
|
}
|
|
entries, err := lib.EntrySearchFuzzy(h.DB, h.Cfg, query)
|
|
if err != nil || len(entries) == 0 {
|
|
return map[string]any{"content": []map[string]string{{"type": "text", "text": "Entry not found"}}}, nil
|
|
}
|
|
entry := entries[0]
|
|
if entry.VaultData == nil {
|
|
return map[string]any{"content": []map[string]string{{"type": "text", "text": "No TOTP field"}}}, nil
|
|
}
|
|
for _, field := range entry.VaultData.Fields {
|
|
if field.Kind == "totp" {
|
|
if field.L2 {
|
|
return map[string]any{"content": []map[string]string{{"type": "text", "text": "TOTP is L2 protected"}}}, nil
|
|
}
|
|
seed := strings.ToUpper(strings.ReplaceAll(field.Value, " ", ""))
|
|
code, err := totp.GenerateCode(seed, time.Now())
|
|
if err != nil {
|
|
return map[string]any{"content": []map[string]string{{"type": "text", "text": "Invalid TOTP seed"}}}, nil
|
|
}
|
|
now := time.Now().Unix()
|
|
expiresIn := 30 - (now % 30)
|
|
result := fmt.Sprintf(`{"code":"%s","expires_in":%d}`, code, expiresIn)
|
|
return map[string]any{"content": []map[string]string{{"type": "text", "text": result}}}, nil
|
|
}
|
|
}
|
|
return map[string]any{"content": []map[string]string{{"type": "text", "text": "No TOTP field"}}}, nil
|
|
|
|
case "check_expiring":
|
|
daysF, _ := call.Arguments["days"].(float64)
|
|
days := int(daysF)
|
|
if days <= 0 {
|
|
days = 30
|
|
}
|
|
entries, err := lib.EntryList(h.DB, h.Cfg, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cutoff := time.Now().AddDate(0, 0, days)
|
|
var expiring []map[string]any
|
|
for _, e := range entries {
|
|
if e.VaultData == nil || e.VaultData.Expires == "" {
|
|
continue
|
|
}
|
|
exp, err := time.Parse("2006-01-02", e.VaultData.Expires)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if exp.Before(cutoff) {
|
|
daysRemaining := int(exp.Sub(time.Now()).Hours() / 24)
|
|
expiring = append(expiring, map[string]any{
|
|
"title": e.Title,
|
|
"type": e.Type,
|
|
"expires": e.VaultData.Expires,
|
|
"days_remaining": daysRemaining,
|
|
})
|
|
}
|
|
}
|
|
result, _ := json.Marshal(expiring)
|
|
return map[string]any{"content": []map[string]string{{"type": "text", "text": string(result)}}}, nil
|
|
|
|
default:
|
|
return nil, fmt.Errorf("unknown tool: %s", call.Name)
|
|
}
|
|
}
|
|
|
|
func mcpSuccess(w http.ResponseWriter, id any, result any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"result": result,
|
|
})
|
|
}
|
|
|
|
func mcpError(w http.ResponseWriter, id any, code int, message string) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"error": map[string]any{
|
|
"code": code,
|
|
"message": message,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func stripL2Fields(vd *lib.VaultData) {
|
|
for i := range vd.Fields {
|
|
if vd.Fields[i].L2 {
|
|
vd.Fields[i].Value = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
func extractDomain(urlStr string) string {
|
|
// Simple domain extraction
|
|
urlStr = strings.TrimPrefix(urlStr, "https://")
|
|
urlStr = strings.TrimPrefix(urlStr, "http://")
|
|
urlStr = strings.TrimPrefix(urlStr, "www.")
|
|
if idx := strings.Index(urlStr, "/"); idx > 0 {
|
|
urlStr = urlStr[:idx]
|
|
}
|
|
if idx := strings.Index(urlStr, ":"); idx > 0 {
|
|
urlStr = urlStr[:idx]
|
|
}
|
|
return urlStr
|
|
}
|
|
|
|
func callLLM(cfg *lib.Config, system, user string) (string, error) {
|
|
reqBody := map[string]any{
|
|
"model": cfg.LLMModel,
|
|
"messages": []map[string]string{
|
|
{"role": "system", "content": system},
|
|
{"role": "user", "content": user},
|
|
},
|
|
"max_tokens": 4096,
|
|
}
|
|
body, _ := json.Marshal(reqBody)
|
|
|
|
req, _ := http.NewRequest("POST", "https://api.fireworks.ai/inference/v1/chat/completions", bytes.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer "+cfg.FireworksAPIKey)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var result struct {
|
|
Choices []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
} `json:"message"`
|
|
} `json:"choices"`
|
|
Error struct {
|
|
Message string `json:"message"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", err
|
|
}
|
|
if result.Error.Message != "" {
|
|
return "", fmt.Errorf("LLM error: %s", result.Error.Message)
|
|
}
|
|
if len(result.Choices) == 0 {
|
|
return "", fmt.Errorf("no response from LLM")
|
|
}
|
|
return result.Choices[0].Message.Content, nil
|
|
}
|
|
|
|
// generateTOTPSecret generates a new TOTP secret.
|
|
func generateTOTPSecret() string {
|
|
b := make([]byte, 20)
|
|
rand.Read(b)
|
|
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
|
|
}
|