clawvault/api/handlers.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)
}