2411 lines
69 KiB
Go
2411 lines
69 KiB
Go
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": "Clavitor", "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) {
|
|
// 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 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": "<column name for entry title>",
|
|
"url": "<column name for URL, or null>",
|
|
"username": "<column name for username/email, or null>",
|
|
"password": "<column name for password, or null>",
|
|
"totp": "<column name for TOTP/2FA secret, or null>",
|
|
"notes": "<column name for notes, or null>",
|
|
"extra_fields": ["<any other column names worth keeping>"]
|
|
}`, 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": "Clavitor", "id": rpID(r)},
|
|
"user": map[string]any{
|
|
"id": []byte("vault1984-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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// 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", "clavitor/1.0 (https://clavitor.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"})
|
|
}
|