package api import ( "bytes" "crypto/rand" "crypto/sha256" "encoding/base32" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "log" "net" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" "github.com/go-chi/chi/v5" "github.com/johanj/vault1984/lib" "github.com/pquerna/otp/totp" ) // challenge holds an in-memory WebAuthn challenge. type challenge struct { Data []byte Type string CreatedAt time.Time } // Handlers holds dependencies for HTTP handlers. type Handlers struct { Cfg *lib.Config mu sync.Mutex challenges map[string]challenge // in-memory challenge store (key = hex of challenge bytes) } // NewHandlers creates a new Handlers instance. func NewHandlers(cfg *lib.Config) *Handlers { h := &Handlers{Cfg: cfg, challenges: make(map[string]challenge)} go func() { for { time.Sleep(60 * time.Second) h.cleanChallenges() } }() return h } func (h *Handlers) storeChallenge(data []byte, typ string) { h.mu.Lock() defer h.mu.Unlock() h.challenges[hex.EncodeToString(data)] = challenge{Data: data, Type: typ, CreatedAt: time.Now()} } func (h *Handlers) consumeChallenge(data []byte, typ string) error { h.mu.Lock() defer h.mu.Unlock() key := hex.EncodeToString(data) c, ok := h.challenges[key] if !ok || c.Type != typ || time.Since(c.CreatedAt) > 5*time.Minute { return fmt.Errorf("challenge not found or expired") } delete(h.challenges, key) return nil } func (h *Handlers) cleanChallenges() { h.mu.Lock() defer h.mu.Unlock() for k, c := range h.challenges { if time.Since(c.CreatedAt) > 5*time.Minute { delete(h.challenges, k) } } } // db returns the vault DB for this request (from context, set by VaultMiddleware). func (h *Handlers) db(r *http.Request) *lib.DB { return DBFromContext(r.Context()) } // vk returns the vault key for this request (from context, set by VaultMiddleware). func (h *Handlers) vk(r *http.Request) []byte { return VaultKeyFromContext(r.Context()) } // --------------------------------------------------------------------------- // Health & Auth // --------------------------------------------------------------------------- // VaultInfo returns the vault ID for the current request. func (h *Handlers) VaultInfo(w http.ResponseWriter, r *http.Request) { db := h.db(r) if db == nil { ErrorResponse(w, http.StatusNotFound, "no_vault", "No vault initialized") return } base := filepath.Base(db.DBPath) vaultID := strings.TrimSuffix(base, ".db") JSONResponse(w, http.StatusOK, map[string]string{"vault_id": vaultID}) } // Version is derived from the binary's modification time. var Version = func() string { exe, err := os.Executable() if err != nil { return "dev" } info, err := os.Stat(exe) if err != nil { return "dev" } return info.ModTime().UTC().Format("20060102-1504") }() // Health returns server status. func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) { var count int if db := h.db(r); db != nil { count, _ = lib.EntryCount(db) } JSONResponse(w, http.StatusOK, map[string]any{ "status": "ok", "version": Version, "entries": count, "time": time.Now().UTC().Format(time.RFC3339), }) } // Setup creates a session (test-only endpoint). func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) { session, err := lib.SessionCreate(h.db(r), h.Cfg.SessionTTL, lib.ActorWeb) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "session_failed", "Failed to create session") return } lib.AuditLog(h.db(r), &lib.AuditEvent{ Action: "setup", Actor: lib.ActorWeb, IPAddr: realIP(r), }) JSONResponse(w, http.StatusOK, map[string]string{ "token": session.Token, }) } // AuthStatus returns whether the vault is fresh (no credentials) or locked. func (h *Handlers) AuthStatus(w http.ResponseWriter, r *http.Request) { db := h.db(r) if db == nil { JSONResponse(w, http.StatusOK, map[string]any{"state": "fresh", "credentials": 0}) return } count, err := lib.WebAuthnCredentialCount(db) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "db_error", "Failed to check vault status") return } state := "fresh" if count > 0 { state = "locked" } JSONResponse(w, http.StatusOK, map[string]any{ "state": state, "credentials": count, }) } // rpID extracts the RP ID from the Host header (strips port). func rpID(r *http.Request) string { host := r.Host if idx := strings.Index(host, ":"); idx != -1 { host = host[:idx] } if host == "127.0.0.1" || host == "::1" { return "localhost" } return host } // hasValidSession checks if the request has a valid Bearer token. func (h *Handlers) hasValidSession(r *http.Request) bool { auth := r.Header.Get("Authorization") if !strings.HasPrefix(auth, "Bearer ") { return false } db := h.db(r) if db == nil { return false } session, err := lib.SessionGet(db, strings.TrimPrefix(auth, "Bearer ")) return err == nil && session != nil } // AuthRegisterBegin starts WebAuthn registration. // First passkey: no auth needed (DB may not exist yet). Additional passkeys: valid session required. func (h *Handlers) AuthRegisterBegin(w http.ResponseWriter, r *http.Request) { db := h.db(r) if db != nil { count, _ := lib.WebAuthnCredentialCount(db) if count > 0 && !h.hasValidSession(r) { ErrorResponse(w, http.StatusForbidden, "already_registered", "Vault already has credentials. Authenticate first to add more.") return } } challengeBytes := make([]byte, 32) rand.Read(challengeBytes) h.storeChallenge(challengeBytes, "register") JSONResponse(w, http.StatusOK, map[string]any{ "publicKey": map[string]any{ "challenge": challengeBytes, "rp": map[string]string{"name": "Vault1984", "id": rpID(r)}, "user": map[string]any{ "id": []byte("vault1984-owner"), "name": "vault-owner", "displayName": "Vault Owner", }, "pubKeyCredParams": []map[string]any{ {"type": "public-key", "alg": -7}, {"type": "public-key", "alg": -257}, }, "authenticatorSelection": map[string]any{ "residentKey": "preferred", "userVerification": "required", }, "attestation": "none", "extensions": map[string]any{ "prf": map[string]any{}, }, }, }) } // AuthRegisterComplete finishes WebAuthn registration and creates a session. func (h *Handlers) AuthRegisterComplete(w http.ResponseWriter, r *http.Request) { db := h.db(r) if db != nil { count, _ := lib.WebAuthnCredentialCount(db) if count > 0 && !h.hasValidSession(r) { ErrorResponse(w, http.StatusForbidden, "already_registered", "Vault already has credentials. Authenticate first to add more.") return } } var req struct { Challenge []byte `json:"challenge"` CredentialID []byte `json:"credential_id"` PublicKey []byte `json:"public_key"` PRFSalt []byte `json:"prf_salt"` Name string `json:"name"` L1Key []byte `json:"l1_key"` // first 8 bytes of master, for vault DB naming } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } if err := h.consumeChallenge(req.Challenge, "register"); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_challenge", "Challenge verification failed") return } // First passkey → create DB if db == nil && len(req.PublicKey) > 0 { // DB named from L1 key: vault1984-XXXXXX (base64url of first 4 bytes, no extension) var dbName string if len(req.L1Key) >= 4 { dbName = "vault1984-" + base64UrlEncode(req.L1Key[:4]) } else { // Fallback: derive from public key hash (legacy compat) hash := sha256.Sum256(req.PublicKey) dbName = "vault1984-" + base64UrlEncode(hash[:4]) } dbPath := filepath.Join(h.Cfg.DataDir, dbName) newDB, err := lib.OpenDB(dbPath) if err != nil { log.Printf("DB create failed: path=%s err=%v", dbPath, err) ErrorResponse(w, http.StatusInternalServerError, "db_create_failed", "Failed to create vault database") return } defer newDB.Close() if err := lib.MigrateDB(newDB); err != nil { ErrorResponse(w, http.StatusInternalServerError, "db_migrate_failed", "Failed to initialize vault database") return } db = newDB log.Printf("Vault created: %s", dbPath) } if req.Name == "" { req.Name = "Primary Passkey" } cred := &lib.WebAuthnCredential{ CredID: lib.HexID(lib.NewID()), Name: req.Name, CredentialID: req.CredentialID, PublicKey: req.PublicKey, PRFSalt: req.PRFSalt, } if err := lib.StoreWebAuthnCredential(db, cred); err != nil { ErrorResponse(w, http.StatusInternalServerError, "store_failed", "Failed to store credential") return } session, err := lib.SessionCreate(db, h.Cfg.SessionTTL, lib.ActorWeb) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "session_failed", "Failed to create session") return } lib.AuditLog(db, &lib.AuditEvent{ Action: "register", Actor: lib.ActorWeb, IPAddr: realIP(r), }) JSONResponse(w, http.StatusCreated, map[string]any{ "token": session.Token, "cred_id": cred.CredID, }) } // AuthLoginBegin starts WebAuthn authentication. func (h *Handlers) AuthLoginBegin(w http.ResponseWriter, r *http.Request) { creds, err := lib.GetWebAuthnCredentials(h.db(r)) if err != nil || len(creds) == 0 { ErrorResponse(w, http.StatusNotFound, "no_credentials", "No credentials registered") return } challenge := make([]byte, 32) rand.Read(challenge) h.storeChallenge(challenge, "login") var allowCreds []map[string]any var prfSalt []byte for _, c := range creds { allowCreds = append(allowCreds, map[string]any{ "type": "public-key", "id": c.CredentialID, }) if len(c.PRFSalt) > 0 { prfSalt = c.PRFSalt } } prfExt := map[string]any{} if len(prfSalt) > 0 { prfExt["eval"] = map[string]any{"first": prfSalt} } JSONResponse(w, http.StatusOK, map[string]any{ "publicKey": map[string]any{ "challenge": challenge, "rpId": rpID(r), "allowCredentials": allowCreds, "userVerification": "required", "extensions": map[string]any{"prf": prfExt}, }, }) } // AuthLoginComplete finishes WebAuthn authentication and creates a session. func (h *Handlers) AuthLoginComplete(w http.ResponseWriter, r *http.Request) { var req struct { Challenge []byte `json:"challenge"` CredentialID []byte `json:"credential_id"` SignCount int `json:"sign_count"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } if err := h.consumeChallenge(req.Challenge, "login"); err != nil { ErrorResponse(w, http.StatusUnauthorized, "invalid_challenge", "Challenge verification failed") return } cred, err := lib.GetWebAuthnCredentialByRawID(h.db(r), req.CredentialID) if err != nil { ErrorResponse(w, http.StatusUnauthorized, "unknown_credential", "Credential not recognized") return } // Cloned authenticator detection if req.SignCount > 0 && req.SignCount <= cred.SignCount { lib.AuditLog(h.db(r), &lib.AuditEvent{ Action: "cloned_authenticator_warning", Actor: lib.ActorWeb, IPAddr: realIP(r), }) } lib.UpdateWebAuthnSignCount(h.db(r), int64(cred.CredID), req.SignCount) session, err := lib.SessionCreate(h.db(r), h.Cfg.SessionTTL, lib.ActorWeb) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "session_failed", "Failed to create session") return } lib.AuditLog(h.db(r), &lib.AuditEvent{ Action: "login", Actor: lib.ActorWeb, IPAddr: realIP(r), }) JSONResponse(w, http.StatusOK, map[string]any{ "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()) var parent *int64 if pidStr := r.URL.Query().Get("parent_id"); pidStr != "" { pid, err := lib.HexToID(pidStr) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid parent_id") return } parent = &pid } entries, err := lib.EntryList(h.db(r), h.vk(r), parent) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries") return } if entries == nil { entries = []lib.Entry{} } // Strip L2 field values for MCP/agent actors; web/extension decrypt client-side if actor == lib.ActorMCP || actor == lib.ActorAgent { 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, err := lib.HexToID(chi.URLParam(r, "id")) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID") return } entry, err := lib.EntryGet(h.db(r), h.vk(r), 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 } // Strip L2 field values for MCP actors only; web/extension decrypt client-side if actor == lib.ActorMCP && entry.VaultData != nil { stripL2Fields(entry.VaultData) } lib.AuditLog(h.db(r), &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 lib.HexID `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(r), h.vk(r), entry); err != nil { ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create entry") return } lib.AuditLog(h.db(r), &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, err := lib.HexToID(chi.URLParam(r, "id")) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID") return } var req struct { Type string `json:"type"` Title string `json:"title"` ParentID lib.HexID `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(r), h.vk(r), 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(r), h.vk(r), 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(r), &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, err := lib.HexToID(chi.URLParam(r, "id")) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID") return } // Get entry for audit log entry, _ := lib.EntryGet(h.db(r), h.vk(r), entryID) if err := lib.EntryDelete(h.db(r), 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(r), &lib.AuditEvent{ EntryID: lib.HexID(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(r), h.vk(r), query) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "search_failed", "Search failed") return } if entries == nil { entries = []lib.Entry{} } // Strip L2 field values for MCP/agent actors; web/extension decrypt client-side if actor == lib.ActorMCP || actor == lib.ActorAgent { 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" wordsParam := r.URL.Query().Get("words") wordsN := 0 if wordsParam == "true" { wordsN = 4 } else if n, err := strconv.Atoi(wordsParam); err == nil && n > 0 { wordsN = n } var password string if wordsN > 0 { password = generatePassphrase(wordsN) } 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, err := lib.HexToID(chi.URLParam(r, "id")) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID") return } entry, err := lib.EntryGet(h.db(r), h.vk(r), 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(r), &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(r), h.vk(r), 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 — set LLM_API_KEY in your environment") return } var req struct { EntryID lib.HexID `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(r), h.vk(r), int64(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 parses known formats directly; falls back to chunked LLM for unknown formats. func (h *Handlers) ImportEntries(w http.ResponseWriter, r *http.Request) { actor := ActorFromContext(r.Context()) if err := r.ParseMultipartForm(32 << 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 } // Try direct parsers first (fast, free, handles 12k+ entries) var entries []lib.VaultData if parsed, ok := lib.DetectAndParse(content); ok { entries = parsed } else { // Unknown format — LLM in chunks of 100 rows if h.Cfg.FireworksAPIKey == "" { ErrorResponse(w, http.StatusServiceUnavailable, "no_llm", "Unknown import format and LLM not configured — set LLM_API_KEY to enable AI-assisted import") return } entries, err = parseLLMFormat(h.Cfg, content) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "llm_failed", err.Error()) return } lib.AutoL2Fields(entries) } // Classify entries against existing vault existingAll, _ := lib.EntryList(h.db(r), h.vk(r), nil) existingIndex := map[string]lib.HexID{} for _, e := range existingAll { if e.VaultData == nil { continue } existingIndex[importDedupKey(e.VaultData)] = e.EntryID } type PreviewEntry struct { lib.VaultData Status string `json:"status"` ExistingID lib.HexID `json:"existing_id,omitempty"` } batchSeen := map[string]bool{} preview := make([]PreviewEntry, 0, len(entries)) for _, vd := range entries { key := importDedupKey(&vd) pe := PreviewEntry{VaultData: vd} if batchSeen[key] { pe.Status = "duplicate" } else if existingID, found := existingIndex[key]; found { pe.Status = "update" pe.ExistingID = existingID } else { pe.Status = "new" } batchSeen[key] = true preview = append(preview, pe) } newCount, updateCount, dupCount := 0, 0, 0 for _, pe := range preview { switch pe.Status { case "new": newCount++ case "update": updateCount++ case "duplicate": dupCount++ } } // Return first 100 for preview UI; client confirms all previewSlice := preview if len(previewSlice) > 100 { previewSlice = previewSlice[:100] } lib.AuditLog(h.db(r), &lib.AuditEvent{ Action: lib.ActionImport, Actor: actor, IPAddr: realIP(r), Title: fmt.Sprintf("%d parsed: %d new, %d update, %d duplicate", len(entries), newCount, updateCount, dupCount), }) JSONResponse(w, http.StatusOK, map[string]any{ "entries": previewSlice, "all_entries": preview, // full list for confirm "total": len(preview), "new": newCount, "update": updateCount, "duplicates": dupCount, }) } // parseLLMFormat detects the column mapping of an unknown format using the LLM, // then maps all rows client-side. Only sends headers + 1 masked sample row to the LLM — // never actual credential values. func parseLLMFormat(cfg *lib.Config, content []byte) ([]lib.VaultData, error) { lines := strings.Split(strings.TrimSpace(string(content)), "\n") if len(lines) < 2 { return nil, fmt.Errorf("file too short to detect format") } header := lines[0] // Build a masked sample row — replace all values with their column name // so the LLM sees structure, never real data. sampleRow := maskSampleRow(header, lines[1]) prompt := fmt.Sprintf(`You are analyzing a password manager export format. Here is the header row and ONE masked sample row (values replaced with column names — no real data): Header: %s Sample: %s Map each column to our vault schema. Return ONLY this JSON, no explanation: { "title": "", "url": "", "username": "", "password": "", "totp": "", "notes": "", "extra_fields": [""] }`, header, sampleRow) resp, err := callLLM(cfg, "You are a data format analyzer. Return only JSON.", prompt) if err != nil { return nil, fmt.Errorf("LLM format detection failed: %w", err) } // Parse the column mapping start := strings.Index(resp, "{") end := strings.LastIndex(resp, "}") if start < 0 || end <= start { return nil, fmt.Errorf("LLM returned invalid JSON mapping") } var mapping struct { Title string `json:"title"` URL string `json:"url"` Username string `json:"username"` Password string `json:"password"` TOTP string `json:"totp"` Notes string `json:"notes"` ExtraFields []string `json:"extra_fields"` } if err := json.Unmarshal([]byte(resp[start:end+1]), &mapping); err != nil { return nil, fmt.Errorf("failed to parse LLM mapping: %w", err) } // Parse header into column index map cols := parseCSVLine(header) idx := map[string]int{} for i, col := range cols { idx[strings.TrimSpace(col)] = i } col := func(name string, row []string) string { if name == "" { return "" } if i, ok := idx[name]; ok && i < len(row) { return strings.TrimSpace(row[i]) } return "" } // Map all data rows using the detected column mapping var entries []lib.VaultData for _, line := range lines[1:] { line = strings.TrimSpace(line) if line == "" { continue } row := parseCSVLine(line) title := col(mapping.Title, row) if title == "" { title = col(mapping.URL, row) } if title == "" { title = "Imported entry" } vd := lib.VaultData{ Title: title, Type: lib.TypeCredential, } if u := col(mapping.URL, row); u != "" { vd.URLs = []string{u} } if v := col(mapping.Username, row); v != "" { vd.Fields = append(vd.Fields, lib.VaultField{Label: "username", Value: v, Kind: "text"}) } if v := col(mapping.Password, row); v != "" { vd.Fields = append(vd.Fields, lib.VaultField{Label: "password", Value: v, Kind: "password"}) } if v := col(mapping.TOTP, row); v != "" { vd.Fields = append(vd.Fields, lib.VaultField{Label: "totp", Value: v, Kind: "totp"}) } for _, extra := range mapping.ExtraFields { if v := col(extra, row); v != "" { vd.Fields = append(vd.Fields, lib.VaultField{Label: extra, Value: v, Kind: "text"}) } } if v := col(mapping.Notes, row); v != "" { vd.Notes = v } entries = append(entries, vd) } return entries, nil } // maskSampleRow replaces each CSV value in a data row with its corresponding header name. // Result is safe to send to an LLM — no actual credential data. func maskSampleRow(header, dataRow string) string { headers := parseCSVLine(header) values := parseCSVLine(dataRow) masked := make([]string, len(headers)) for i, h := range headers { if i < len(values) && values[i] != "" { masked[i] = "<" + strings.TrimSpace(h) + ">" } else { masked[i] = "" } } return strings.Join(masked, ",") } // parseCSVLine parses a single CSV line respecting quoted fields. func parseCSVLine(line string) []string { var fields []string var cur strings.Builder inQuote := false for i := 0; i < len(line); i++ { c := line[i] if c == '"' { inQuote = !inQuote } else if c == ',' && !inQuote { fields = append(fields, cur.String()) cur.Reset() } else { cur.WriteByte(c) } } fields = append(fields, cur.String()) return fields } // 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 } // Build dedup index: normalized "url|username" → existing Entry (for upsert) existing, _ := lib.EntryList(h.db(r), h.vk(r), nil) dedupIndex := map[string]*lib.Entry{} for i, e := range existing { if e.VaultData == nil { continue } dedupIndex[importDedupKey(e.VaultData)] = &existing[i] } var created, updated, skipped int // Track intra-batch keys to avoid importing same entry twice batchSeen := map[string]bool{} for _, vd := range req.Entries { key := importDedupKey(&vd) if batchSeen[key] { continue // intra-batch duplicate — first occurrence wins within same batch } batchSeen[key] = true vdCopy := vd if existingEntry, found := dedupIndex[key]; found { // Collision: keep newest by source modification date. // If incoming has no date (e.g. Chrome CSV) → don't overwrite. // If incoming date > existing → overwrite. incomingMod := vdCopy.SourceModified existingMod := existingEntry.UpdatedAt shouldUpdate := incomingMod > 0 && incomingMod > existingMod if shouldUpdate { existingEntry.Title = vdCopy.Title existingEntry.Type = vdCopy.Type existingEntry.VaultData = &vdCopy if err := lib.EntryUpdate(h.db(r), h.vk(r), existingEntry); err == nil { updated++ } } else if incomingMod == 0 { // No date in source — skip, existing wins skipped++ } else { // Existing is newer — skip skipped++ } } else { entry := &lib.Entry{ Type: vdCopy.Type, Title: vdCopy.Title, DataLevel: lib.DataLevelL1, VaultData: &vdCopy, } if err := lib.EntryCreate(h.db(r), h.vk(r), entry); err == nil { created++ dedupIndex[key] = entry } } } lib.AuditLog(h.db(r), &lib.AuditEvent{ Action: lib.ActionImport, Actor: actor, IPAddr: realIP(r), Title: fmt.Sprintf("%d created, %d updated, %d skipped (date-based)", created, updated, skipped), }) JSONResponse(w, http.StatusOK, map[string]any{"imported": created, "updated": updated, "skipped": skipped}) } // --------------------------------------------------------------------------- // 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(r), 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 } // Check read-only enforcement for scoped MCP tokens mcpToken := MCPTokenFromContext(r.Context()) if mcpToken != nil && mcpToken.ReadOnly { // Parse the call to check for write methods var call struct { Name string `json:"name"` } if req.Method == "tools/call" { json.Unmarshal(req.Params, &call) // Block write operations on read-only tokens if call.Name == "create_credential" || call.Name == "update_credential" || call.Name == "delete_credential" { mcpError(w, req.ID, -32000, "token is read-only") 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") } mcpToken := MCPTokenFromContext(r.Context()) 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(r), h.vk(r), query) if err != nil { return nil, err } entries = mcpFilterEntries(entries, mcpToken) // 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(r), h.vk(r), nil) if err != nil { return nil, err } entries = mcpFilterEntries(entries, mcpToken) 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(r), h.vk(r), query) if err != nil || len(entries) == 0 { return map[string]any{"content": []map[string]string{{"type": "text", "text": "Entry not found"}}}, nil } entries = mcpFilterEntries(entries, mcpToken) if 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(r), h.vk(r), nil) if err != nil { return nil, err } entries = mcpFilterEntries(entries, mcpToken) 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, }, }) } // --------------------------------------------------------------------------- // MCP Token Management // --------------------------------------------------------------------------- // HandleCreateMCPToken creates a scoped MCP token. func (h *Handlers) HandleCreateMCPToken(w http.ResponseWriter, r *http.Request) { var req struct { Label string `json:"label"` EntryIDs []lib.HexID `json:"entry_ids"` ReadOnly bool `json:"read_only"` ExpiresIn int64 `json:"expires_in_days"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } if req.Label == "" { ErrorResponse(w, http.StatusBadRequest, "missing_label", "Label is required") return } t := &lib.MCPToken{ Label: req.Label, EntryIDs: req.EntryIDs, ReadOnly: req.ReadOnly, } if req.ExpiresIn > 0 { t.ExpiresAt = time.Now().Unix() + req.ExpiresIn*86400 } if err := lib.CreateMCPToken(h.db(r), t); err != nil { ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create MCP token") return } JSONResponse(w, http.StatusCreated, t) } // HandleListMCPTokens returns all MCP tokens. func (h *Handlers) HandleListMCPTokens(w http.ResponseWriter, r *http.Request) { tokens, err := lib.ListMCPTokens(h.db(r)) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list MCP tokens") return } if tokens == nil { tokens = []lib.MCPToken{} } JSONResponse(w, http.StatusOK, tokens) } // HandleDeleteMCPToken revokes an MCP token. func (h *Handlers) HandleDeleteMCPToken(w http.ResponseWriter, r *http.Request) { id, err := lib.HexToID(chi.URLParam(r, "id")) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid token ID") return } if err := lib.DeleteMCPToken(h.db(r), id); err != nil { if err == lib.ErrNotFound { ErrorResponse(w, http.StatusNotFound, "not_found", "Token not found") return } ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete MCP token") return } JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"}) } // --------------------------------------------------------------------------- // WebAuthn PRF // --------------------------------------------------------------------------- // HandleWebAuthnRegisterBegin starts WebAuthn registration. func (h *Handlers) HandleWebAuthnRegisterBegin(w http.ResponseWriter, r *http.Request) { // Generate a challenge challenge := make([]byte, 32) rand.Read(challenge) options := map[string]any{ "publicKey": map[string]any{ "challenge": challenge, "rp": map[string]string{"name": "Vault1984", "id": rpID(r)}, "user": map[string]any{ "id": []byte("vault1984-owner"), "name": "vault-owner", "displayName": "Vault1984 Owner", }, "pubKeyCredParams": []map[string]any{ {"type": "public-key", "alg": -7}, // ES256 {"type": "public-key", "alg": -257}, // RS256 }, "authenticatorSelection": map[string]any{ "residentKey": "preferred", "userVerification": "required", }, "extensions": map[string]any{ "prf": map[string]any{}, }, }, } JSONResponse(w, http.StatusOK, options) } // HandleWebAuthnRegisterComplete finishes WebAuthn registration. func (h *Handlers) HandleWebAuthnRegisterComplete(w http.ResponseWriter, r *http.Request) { var req struct { CredID string `json:"cred_id"` PublicKey []byte `json:"public_key"` PRFSalt []byte `json:"prf_salt"` Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } // Decode the base64url credential ID from the browser credentialID, err := base64.RawURLEncoding.DecodeString(req.CredID) if err != nil { credentialID = []byte{} } cred := &lib.WebAuthnCredential{ CredID: lib.HexID(lib.NewID()), Name: req.Name, PublicKey: req.PublicKey, CredentialID: credentialID, PRFSalt: req.PRFSalt, } if err := lib.StoreWebAuthnCredential(h.db(r), cred); err != nil { ErrorResponse(w, http.StatusInternalServerError, "store_failed", "Failed to store credential") return } JSONResponse(w, http.StatusCreated, map[string]any{"status": "registered", "cred_id": cred.CredID}) } // HandleWebAuthnAuthBegin starts WebAuthn authentication with PRF extension. func (h *Handlers) HandleWebAuthnAuthBegin(w http.ResponseWriter, r *http.Request) { creds, err := lib.GetWebAuthnCredentials(h.db(r)) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get credentials") return } challenge := make([]byte, 32) rand.Read(challenge) var allowCreds []map[string]any var prfSalt []byte for _, c := range creds { allowCreds = append(allowCreds, map[string]any{ "type": "public-key", "id": c.CredID, }) if len(c.PRFSalt) > 0 { prfSalt = c.PRFSalt } } prfExt := map[string]any{} if len(prfSalt) > 0 { prfExt["eval"] = map[string]any{ "first": prfSalt, } } options := map[string]any{ "publicKey": map[string]any{ "challenge": challenge, "allowCredentials": allowCreds, "userVerification": "required", "extensions": map[string]any{ "prf": prfExt, }, }, } JSONResponse(w, http.StatusOK, options) } // HandleWebAuthnAuthComplete finishes WebAuthn authentication. func (h *Handlers) HandleWebAuthnAuthComplete(w http.ResponseWriter, r *http.Request) { var req struct { CredID lib.HexID `json:"cred_id"` SignCount int `json:"sign_count"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } if err := lib.UpdateWebAuthnSignCount(h.db(r), int64(req.CredID), req.SignCount); err != nil { ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update sign count") return } JSONResponse(w, http.StatusOK, map[string]string{"status": "authenticated"}) } // HandleListWebAuthnCredentials returns all registered WebAuthn credentials. func (h *Handlers) HandleListWebAuthnCredentials(w http.ResponseWriter, r *http.Request) { creds, err := lib.GetWebAuthnCredentials(h.db(r)) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list credentials") return } if creds == nil { creds = []lib.WebAuthnCredential{} } JSONResponse(w, http.StatusOK, creds) } // HandleDeleteWebAuthnCredential removes a WebAuthn credential. func (h *Handlers) HandleDeleteWebAuthnCredential(w http.ResponseWriter, r *http.Request) { id, err := lib.HexToID(chi.URLParam(r, "id")) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid credential ID") return } if err := lib.DeleteWebAuthnCredential(h.db(r), id); err != nil { if err == lib.ErrNotFound { ErrorResponse(w, http.StatusNotFound, "not_found", "Credential not found") return } ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete credential") return } JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"}) } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- // mcpFilterEntries filters entries based on scoped MCP token restrictions. func mcpFilterEntries(entries []lib.Entry, token *lib.MCPToken) []lib.Entry { if token == nil || len(token.EntryIDs) == 0 { return entries } idSet := map[lib.HexID]bool{} for _, id := range token.EntryIDs { idSet[id] = true } var filtered []lib.Entry for _, e := range entries { if idSet[e.EntryID] { filtered = append(filtered, e) } } return filtered } func stripL2Fields(vd *lib.VaultData) { for i := range vd.Fields { if vd.Fields[i].L2 { vd.Fields[i].Value = "[REDACTED — not available to agents]" } } } // normalizeURLForDedup strips scheme differences, trailing slashes, and lowercases // so that "http://x.com/" and "https://x.com" produce the same dedup key. func normalizeURLForDedup(u string) string { u = strings.ToLower(u) u = strings.TrimPrefix(u, "https://") u = strings.TrimPrefix(u, "http://") u = strings.TrimRight(u, "/") return u } // importDedupKey builds a dedup key from a VaultData's first URL + username. // For notes/entries without URLs or usernames, fall back to the title to avoid // all notes colliding on the same empty key. func importDedupKey(vd *lib.VaultData) string { var url, username string if len(vd.URLs) > 0 { url = normalizeURLForDedup(vd.URLs[0]) } for _, f := range vd.Fields { lbl := strings.ToLower(f.Label) if lbl == "username" || lbl == "email" || lbl == "login" { username = strings.ToLower(f.Value) break } } key := url + "|" + username if key == "|" { // No URL and no username — use title to differentiate. key = "title:" + strings.ToLower(vd.Title) } return key } 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", cfg.LLMBaseURL+"/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) } // GeoLookup returns the visitor's geolocation. // If ?lat=X&lon=Y supplied (browser geo fallback), reverse-geocodes those coordinates via BigDataCloud. // Otherwise geolocates the request IP; returns {"private":true} for LAN IPs. func (h *Handlers) GeoLookup(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // lat/lon path — browser geolocation fallback for LAN visitors if latStr := r.URL.Query().Get("lat"); latStr != "" { lonStr := r.URL.Query().Get("lon") latF, errLat := strconv.ParseFloat(latStr, 64) lonF, errLon := strconv.ParseFloat(lonStr, 64) if errLat != nil || errLon != nil { json.NewEncoder(w).Encode(map[string]any{"private": true}) return } // Nominatim reverse geocode (OSM, free, no key) url := fmt.Sprintf("https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=%f&lon=%f", latF, lonF) client := &http.Client{Timeout: 5 * time.Second} req, _ := http.NewRequest("GET", url, nil) req.Header.Set("User-Agent", "vault1984/1.0 (https://vault1984.com)") resp, err := client.Do(req) if err == nil { defer resp.Body.Close() var raw struct { Address struct { City string `json:"city"` Town string `json:"town"` Village string `json:"village"` State string `json:"state"` Country string `json:"country"` CountryCode string `json:"country_code"` } `json:"address"` } if json.NewDecoder(resp.Body).Decode(&raw) == nil { city := raw.Address.City if city == "" { city = raw.Address.Town } if city == "" { city = raw.Address.Village } cc := strings.ToUpper(raw.Address.CountryCode) json.NewEncoder(w).Encode(map[string]any{ "city": city, "region": raw.Address.State, "country_name": raw.Address.Country, "country_code": cc, "latitude": latF, "longitude": lonF, }) return } } // reverse-geocode failed — at least return coords json.NewEncoder(w).Encode(map[string]any{"latitude": latF, "longitude": lonF}) return } // IP-based path ip := realIP(r) if isPrivateIP(ip) { json.NewEncoder(w).Encode(map[string]any{"private": true}) return } resp, err := http.Get("http://ip-api.com/json/" + ip + "?fields=status,city,regionName,country,countryCode,lat,lon") if err != nil { json.NewEncoder(w).Encode(map[string]any{"private": true}) return } defer resp.Body.Close() var raw map[string]any if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil || raw["status"] != "success" { json.NewEncoder(w).Encode(map[string]any{"private": true}) return } json.NewEncoder(w).Encode(map[string]any{ "city": raw["city"], "region": raw["regionName"], "country_name": raw["country"], "country_code": raw["countryCode"], "latitude": raw["lat"], "longitude": raw["lon"], }) } func isPrivateIP(ip string) bool { parsed := net.ParseIP(ip) if parsed == nil { return true } private := []string{"10.0.0.0/8","172.16.0.0/12","192.168.0.0/16","127.0.0.0/8","::1/128","fc00::/7"} for _, cidr := range private { _, block, _ := net.ParseCIDR(cidr) if block != nil && block.Contains(parsed) { return true } } return false } // --- Backup / Restore --- // ListBackups returns all available backup files. func (h *Handlers) ListBackups(w http.ResponseWriter, r *http.Request) { backups := lib.ListBackups(h.Cfg.DataDir) JSONResponse(w, http.StatusOK, backups) } // CreateBackup triggers an immediate backup. func (h *Handlers) CreateBackup(w http.ResponseWriter, r *http.Request) { lib.RunBackups(h.Cfg.DataDir) backups := lib.ListBackups(h.Cfg.DataDir) JSONResponse(w, http.StatusOK, backups) } // RestoreBackup restores from a named backup file. func (h *Handlers) RestoreBackup(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" { ErrorResponse(w, http.StatusBadRequest, "invalid_request", "Backup name required") return } if err := lib.RestoreBackup(h.Cfg.DataDir, req.Name); err != nil { ErrorResponse(w, http.StatusInternalServerError, "restore_error", err.Error()) return } JSONResponse(w, http.StatusOK, map[string]string{"status": "restored", "name": req.Name}) } // --------------------------------------------------------------------------- // Agent Management // --------------------------------------------------------------------------- // HandleCreateAgent creates a new agent. // POST /api/agents func (h *Handlers) HandleCreateAgent(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` IPWhitelist []string `json:"ip_whitelist"` RateLimitMinute int `json:"rate_limit_minute"` RateLimitHour int `json:"rate_limit_hour"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } if req.Name == "" { ErrorResponse(w, http.StatusBadRequest, "missing_name", "Name is required") return } db := h.db(r) // Check for duplicate name existing, _ := lib.AgentGetByName(db, req.Name) if existing != nil { ErrorResponse(w, http.StatusConflict, "duplicate_name", "Agent with this name already exists") return } a := &lib.Agent{ Name: req.Name, IPWhitelist: req.IPWhitelist, RateLimitMinute: req.RateLimitMinute, RateLimitHour: req.RateLimitHour, } if err := lib.AgentCreate(db, a); err != nil { ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create agent") return } lib.AuditLog(db, &lib.AuditEvent{ Action: "agent_create", Actor: ActorFromContext(r.Context()), IPAddr: realIP(r), Title: a.Name, }) JSONResponse(w, http.StatusCreated, a) } // HandleListAgents lists all agents. // GET /api/agents func (h *Handlers) HandleListAgents(w http.ResponseWriter, r *http.Request) { agents, err := lib.AgentList(h.db(r)) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list agents") return } if agents == nil { agents = []lib.Agent{} } JSONResponse(w, http.StatusOK, agents) } // HandleGetAgent returns a single agent. // GET /api/agents/{id} func (h *Handlers) HandleGetAgent(w http.ResponseWriter, r *http.Request) { agentID, err := lib.HexToID(chi.URLParam(r, "id")) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID") return } a, err := lib.AgentGet(h.db(r), agentID) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent") return } if a == nil { ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found") return } JSONResponse(w, http.StatusOK, a) } // HandleUpdateAgentWhitelist updates an agent's IP whitelist. // PUT /api/agents/{id}/whitelist func (h *Handlers) HandleUpdateAgentWhitelist(w http.ResponseWriter, r *http.Request) { agentID, err := lib.HexToID(chi.URLParam(r, "id")) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID") return } var req struct { IPWhitelist []string `json:"ip_whitelist"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } db := h.db(r) a, err := lib.AgentGet(db, agentID) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent") return } if a == nil { ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found") return } if err := lib.AgentUpdateWhitelist(db, agentID, req.IPWhitelist); err != nil { ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update whitelist") return } lib.AuditLog(db, &lib.AuditEvent{ Action: "agent_update_whitelist", Actor: ActorFromContext(r.Context()), IPAddr: realIP(r), Title: a.Name, }) JSONResponse(w, http.StatusOK, map[string]string{"status": "updated"}) } // HandleUpdateAgentRateLimits updates an agent's rate limits. // PUT /api/agents/{id}/rate-limits func (h *Handlers) HandleUpdateAgentRateLimits(w http.ResponseWriter, r *http.Request) { agentID, err := lib.HexToID(chi.URLParam(r, "id")) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID") return } var req struct { RateLimitMinute int `json:"rate_limit_minute"` RateLimitHour int `json:"rate_limit_hour"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } db := h.db(r) a, err := lib.AgentGet(db, agentID) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent") return } if a == nil { ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found") return } if err := lib.AgentUpdateRateLimits(db, agentID, req.RateLimitMinute, req.RateLimitHour); err != nil { ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update rate limits") return } lib.AuditLog(db, &lib.AuditEvent{ Action: "agent_update_rate_limits", Actor: ActorFromContext(r.Context()), IPAddr: realIP(r), Title: a.Name, }) JSONResponse(w, http.StatusOK, map[string]string{"status": "updated"}) } // HandleLockAgent manually locks an agent. // POST /api/agents/{id}/lock func (h *Handlers) HandleLockAgent(w http.ResponseWriter, r *http.Request) { agentID, err := lib.HexToID(chi.URLParam(r, "id")) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID") return } db := h.db(r) a, err := lib.AgentGet(db, agentID) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent") return } if a == nil { ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found") return } if err := lib.AgentUpdateStatus(db, agentID, lib.AgentStatusLocked, "manually locked"); err != nil { ErrorResponse(w, http.StatusInternalServerError, "lock_failed", "Failed to lock agent") return } lib.AuditLog(db, &lib.AuditEvent{ Action: "agent_lock", Actor: ActorFromContext(r.Context()), IPAddr: realIP(r), Title: a.Name, }) JSONResponse(w, http.StatusOK, map[string]string{"status": "locked"}) } // HandleUnlockAgent unlocks a locked agent. // POST /api/agents/{id}/unlock func (h *Handlers) HandleUnlockAgent(w http.ResponseWriter, r *http.Request) { agentID, err := lib.HexToID(chi.URLParam(r, "id")) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID") return } db := h.db(r) a, err := lib.AgentGet(db, agentID) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent") return } if a == nil { ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found") return } if err := lib.AgentUpdateStatus(db, agentID, lib.AgentStatusActive, ""); err != nil { ErrorResponse(w, http.StatusInternalServerError, "unlock_failed", "Failed to unlock agent") return } lib.AuditLog(db, &lib.AuditEvent{ Action: "agent_unlock", Actor: ActorFromContext(r.Context()), IPAddr: realIP(r), Title: a.Name, }) JSONResponse(w, http.StatusOK, map[string]string{"status": "unlocked"}) } // HandleRevokeAgent permanently revokes an agent. // DELETE /api/agents/{id} func (h *Handlers) HandleRevokeAgent(w http.ResponseWriter, r *http.Request) { agentID, err := lib.HexToID(chi.URLParam(r, "id")) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID") return } db := h.db(r) a, err := lib.AgentGet(db, agentID) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent") return } if a == nil { ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found") return } if err := lib.AgentDelete(db, agentID); err != nil { ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to revoke agent") return } lib.AuditLog(db, &lib.AuditEvent{ Action: "agent_revoke", Actor: ActorFromContext(r.Context()), IPAddr: realIP(r), Title: a.Name, }) JSONResponse(w, http.StatusOK, map[string]string{"status": "revoked"}) } // --------------------------------------------------------------------------- // Vault Lock // --------------------------------------------------------------------------- // HandleVaultLockStatus returns the vault lock state. // GET /api/vault-lock func (h *Handlers) HandleVaultLockStatus(w http.ResponseWriter, r *http.Request) { vl, err := lib.VaultLockGet(h.db(r)) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get vault lock status") return } JSONResponse(w, http.StatusOK, vl) } // HandleVaultUnlock unlocks the vault. // POST /api/vault-unlock func (h *Handlers) HandleVaultUnlock(w http.ResponseWriter, r *http.Request) { db := h.db(r) if err := lib.VaultLockSet(db, false, ""); err != nil { ErrorResponse(w, http.StatusInternalServerError, "unlock_failed", "Failed to unlock vault") return } lib.AuditLog(db, &lib.AuditEvent{ Action: "vault_unlock", Actor: ActorFromContext(r.Context()), IPAddr: realIP(r), }) JSONResponse(w, http.StatusOK, map[string]string{"status": "unlocked"}) }