package api import ( "crypto/rand" "crypto/sha256" "encoding/base32" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "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 L1Middleware). 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 L1Middleware). func (h *Handlers) vk(r *http.Request) []byte { return VaultKeyFromContext(r.Context()) } // agent returns the agent for this request (nil = vault owner via legacy L1). func (h *Handlers) agent(r *http.Request) *lib.Agent { return AgentFromContext(r.Context()) } // l1Raw returns the raw (non-normalized) L1 key from the vault key context. // Needed for minting agent tokens that embed L1. func (h *Handlers) l1Raw(r *http.Request) []byte { vk := h.vk(r) if len(vk) == 16 { return vk[:8] // first 8 bytes of normalized key = original L1 } return vk } // requireOwner rejects agent requests. Returns true if blocked (caller should return). // Admin operations are HUMANS ONLY — hardware key required, agents forbidden. func (h *Handlers) requireOwner(w http.ResponseWriter, r *http.Request) bool { if IsAgentRequest(r) { ErrorResponse(w, http.StatusForbidden, "owner_only", "This operation requires web authentication") return true } return false } // filterByScope removes entries the agent cannot access. func filterByScope(agent *lib.Agent, entries []lib.Entry) []lib.Entry { if agent == nil || agent.AllAccess { return entries } var filtered []lib.Entry for _, e := range entries { if lib.AgentCanAccess(agent, e.Scopes) { filtered = append(filtered, e) } } if filtered == nil { return []lib.Entry{} } return filtered } // --------------------------------------------------------------------------- // 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 returns the RP ID for WebAuthn — the hostname from the Host header, port stripped. func rpID(r *http.Request) string { host := r.Host if idx := strings.Index(host, ":"); idx != -1 { host = host[:idx] } 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, filtered by agent scope. func (h *Handlers) ListEntries(w http.ResponseWriter, r *http.Request) { agent := h.agent(r) actor := ActorFromContext(r.Context()) // Metadata-only mode 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{} } entries = filterByScope(agent, entries) JSONResponse(w, http.StatusOK, entries) return } 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{} } entries = filterByScope(agent, entries) 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 (scope-checked). func (h *Handlers) GetEntry(w http.ResponseWriter, r *http.Request) { agent := h.agent(r) 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.DeletedAt != nil { ErrorResponse(w, http.StatusNotFound, "deleted", "Entry has been deleted") return } if !lib.AgentCanAccess(agent, entry.Scopes) { ErrorResponse(w, http.StatusForbidden, "forbidden", "Access denied") return } 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) } // UpsertEntry creates or updates an entry by title match (case-insensitive). // If an entry with the same title exists, it is updated. Otherwise, a new entry is created. func (h *Handlers) UpsertEntry(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 } // Search for existing entry with same title existing, err := lib.EntrySearchFuzzy(h.db(r), h.vk(r), req.Title) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "search_failed", "Failed to search entries") return } // Find exact title match (case-insensitive) var match *lib.Entry for i := range existing { if strings.EqualFold(existing[i].Title, req.Title) { match = &existing[i] break } } if match != nil { // Update existing match.Title = req.Title match.Type = req.Type match.ParentID = req.ParentID if req.Data != nil { match.VaultData = req.Data } if err := lib.EntryUpdate(h.db(r), h.vk(r), match); err != nil { ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update entry") return } lib.AuditLog(h.db(r), &lib.AuditEvent{ EntryID: match.EntryID, Title: match.Title, Action: lib.ActionUpdate, Actor: actor, IPAddr: realIP(r), }) JSONResponse(w, http.StatusOK, match) } else { // Create new 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 (scope-filtered). func (h *Handlers) SearchEntries(w http.ResponseWriter, r *http.Request) { agent := h.agent(r) actor := ActorFromContext(r.Context()) query := r.URL.Query().Get("q") if query == "" { ErrorResponse(w, http.StatusBadRequest, "missing_query", "Query parameter 'q' is required") return } 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{} } entries = filterByScope(agent, entries) 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 (scope-checked). func (h *Handlers) GetTOTP(w http.ResponseWriter, r *http.Request) { agent := h.agent(r) 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 !lib.AgentCanAccess(agent, entry.Scopes) { ErrorResponse(w, http.StatusForbidden, "forbidden", "Access denied") 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 (scope-filtered). func (h *Handlers) MatchURL(w http.ResponseWriter, r *http.Request) { agent := h.agent(r) urlStr := r.URL.Query().Get("url") if urlStr == "" { ErrorResponse(w, http.StatusBadRequest, "missing_url", "URL parameter required") return } domain := extractDomain(urlStr) 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 !lib.AgentCanAccess(agent, entry.Scopes) { continue } 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) } // --------------------------------------------------------------------------- // 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]" } } } 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. OWNER ONLY. // POST /api/agents func (h *Handlers) HandleCreateAgent(w http.ResponseWriter, r *http.Request) { if h.requireOwner(w, r) { return } var req struct { Name string `json:"name"` Scopes string `json:"scopes"` // "auto" or comma-separated hex, default auto AllAccess bool `json:"all_access"` Admin bool `json:"admin"` } 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 } if len(req.Name) > 100 { ErrorResponse(w, http.StatusBadRequest, "name_too_long", "Name must be 100 characters or less") return } db := h.db(r) scopes := req.Scopes if scopes == "" { scopes = "auto" } agent, rawToken, err := lib.AgentCreate(db, req.Name, scopes, req.AllAccess, req.Admin, h.l1Raw(r)) if err != nil { ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create agent") return } lib.AuditLog(db, &lib.AuditEvent{ Action: lib.ActionAgentCreate, Actor: ActorFromContext(r.Context()), IPAddr: realIP(r), Title: agent.Name, }) // Return agent info + raw token (shown ONCE, never retrievable again) JSONResponse(w, http.StatusCreated, map[string]any{ "id": agent.ID, "scope": agent.ScopeHex(), "name": agent.Name, "scopes": agent.Scopes, "all_access": agent.AllAccess, "admin": agent.Admin, "token": rawToken, }) } // HandleListAgents lists all agents. OWNER ONLY. // GET /api/agents func (h *Handlers) HandleListAgents(w http.ResponseWriter, r *http.Request) { if h.requireOwner(w, r) { return } 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. OWNER ONLY. // GET /api/agents/{id} func (h *Handlers) HandleGetAgent(w http.ResponseWriter, r *http.Request) { if h.requireOwner(w, r) { return } id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID") return } a, err := lib.AgentGet(h.db(r), id) 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) } // HandleUpdateAgent updates an agent's name, scopes, flags. OWNER ONLY. // PUT /api/agents/{id} func (h *Handlers) HandleUpdateAgent(w http.ResponseWriter, r *http.Request) { if h.requireOwner(w, r) { return } id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID") return } var req struct { Name string `json:"name"` Scopes string `json:"scopes"` AllAccess bool `json:"all_access"` Admin bool `json:"admin"` } 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, id) if err != nil || a == nil { ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found") return } if err := lib.AgentUpdate(db, id, req.Name, req.Scopes, req.AllAccess, req.Admin); err != nil { ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update agent") return } lib.AuditLog(db, &lib.AuditEvent{ Action: "agent_update", Actor: ActorFromContext(r.Context()), IPAddr: realIP(r), Title: a.Name, }) JSONResponse(w, http.StatusOK, map[string]string{"status": "updated"}) } // HandleDeleteAgent deletes an agent. OWNER ONLY. // DELETE /api/agents/{id} func (h *Handlers) HandleDeleteAgent(w http.ResponseWriter, r *http.Request) { if h.requireOwner(w, r) { return } id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID") return } db := h.db(r) a, err := lib.AgentGet(db, id) if err != nil || a == nil { ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found") return } if err := lib.AgentDelete(db, id); err != nil { ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete agent") return } lib.AuditLog(db, &lib.AuditEvent{ Action: lib.ActionAgentRevoke, Actor: ActorFromContext(r.Context()), IPAddr: realIP(r), Title: a.Name, }) JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"}) } // HandleUpdateEntryScopes updates only the scopes of an entry. OWNER ONLY. // PUT /api/entries/{id}/scopes func (h *Handlers) HandleUpdateEntryScopes(w http.ResponseWriter, r *http.Request) { if h.requireOwner(w, r) { return } entryID, err := lib.HexToID(chi.URLParam(r, "id")) if err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID") return } var req struct { Scopes string `json:"scopes"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } if err := lib.EntryUpdateScopes(h.db(r), entryID, req.Scopes); err != nil { if err == lib.ErrNotFound { ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found") return } ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update scopes") return } lib.AuditLog(h.db(r), &lib.AuditEvent{ EntryID: lib.HexID(entryID), Action: "entry_scopes_update", Actor: ActorFromContext(r.Context()), IPAddr: realIP(r), }) JSONResponse(w, http.StatusOK, map[string]string{"status": "updated"}) } // --------------------------------------------------------------------------- // 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"}) }