package api import ( "crypto/rand" "crypto/sha256" "encoding/base32" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "path/filepath" "strconv" "strings" "sync" "time" "github.com/go-chi/chi/v5" "github.com/johanj/clavitor/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 set by main via ldflags at build time. var Version = "dev" // 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": "Clavitor", "id": rpID(r)}, "user": map[string]any{ "id": []byte("clavitor-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: clavitor-XXXXXX (base64url of first 4 bytes, no extension) var dbName string if len(req.L1Key) >= 4 { dbName = "clavitor-" + base64UrlEncode(req.L1Key[:4]) } else { // Fallback: derive from public key hash hash := sha256.Sum256(req.PublicKey) dbName = "clavitor-" + 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) { // Metadata-only mode: returns entry_id, type, title — no field data, no decryption. // Used by web UI list view. Full data fetched per entry on click. if r.URL.Query().Get("meta") == "1" { entries, err := lib.EntryListMeta(h.db(r)) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries") return } if entries == nil { entries = []lib.Entry{} } JSONResponse(w, http.StatusOK, entries) return } 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 agent actors; web/extension decrypt client-side if 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 agent actors; web/extension decrypt client-side if actor == lib.ActorAgent && 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 agent actors; web/extension decrypt client-side if 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) } // --------------------------------------------------------------------------- // 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 } var entries []lib.VaultData if parsed, ok := lib.DetectAndParse(content); ok { entries = parsed } else { ErrorResponse(w, http.StatusBadRequest, "unknown_format", "Unsupported import format. Supported: Chrome CSV, Firefox CSV, Bitwarden JSON, Proton Pass JSON") return } // 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, }) } // 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) } // --------------------------------------------------------------------------- // 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": "Clavitor", "id": rpID(r)}, "user": map[string]any{ "id": []byte("clavitor-owner"), "name": "vault-owner", "displayName": "Clavitor 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 // --------------------------------------------------------------------------- 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 } // generateTOTPSecret generates a new TOTP secret. func generateTOTPSecret() string { b := make([]byte, 20) rand.Read(b) return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b) } // --- 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"}) }