clavitor/clavis/clavis-vault/api/handlers.go

1653 lines
46 KiB
Go

package api
import (
"crypto/rand"
"crypto/sha256"
"encoding/base32"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/johanj/clavitor/lib"
"github.com/pquerna/otp/totp"
)
// challenge holds an in-memory WebAuthn challenge.
type challenge struct {
Data []byte
Type string
CreatedAt time.Time
}
// Handlers holds dependencies for HTTP handlers.
type Handlers struct {
Cfg *lib.Config
mu sync.Mutex
challenges map[string]challenge // in-memory challenge store (key = hex of challenge bytes)
}
// NewHandlers creates a new Handlers instance.
func NewHandlers(cfg *lib.Config) *Handlers {
h := &Handlers{Cfg: cfg, challenges: make(map[string]challenge)}
go func() {
for {
time.Sleep(60 * time.Second)
h.cleanChallenges()
}
}()
return h
}
func (h *Handlers) storeChallenge(data []byte, typ string) {
h.mu.Lock()
defer h.mu.Unlock()
h.challenges[hex.EncodeToString(data)] = challenge{Data: data, Type: typ, CreatedAt: time.Now()}
}
func (h *Handlers) consumeChallenge(data []byte, typ string) error {
h.mu.Lock()
defer h.mu.Unlock()
key := hex.EncodeToString(data)
c, ok := h.challenges[key]
if !ok || c.Type != typ || time.Since(c.CreatedAt) > 5*time.Minute {
return fmt.Errorf("challenge not found or expired")
}
delete(h.challenges, key)
return nil
}
func (h *Handlers) cleanChallenges() {
h.mu.Lock()
defer h.mu.Unlock()
for k, c := range h.challenges {
if time.Since(c.CreatedAt) > 5*time.Minute {
delete(h.challenges, k)
}
}
}
// db returns the vault DB for this request (from context, set by VaultMiddleware).
func (h *Handlers) db(r *http.Request) *lib.DB {
return DBFromContext(r.Context())
}
// vk returns the vault key for this request (from context, set by VaultMiddleware).
func (h *Handlers) vk(r *http.Request) []byte {
return VaultKeyFromContext(r.Context())
}
// ---------------------------------------------------------------------------
// Health & Auth
// ---------------------------------------------------------------------------
// VaultInfo returns the vault ID for the current request.
func (h *Handlers) VaultInfo(w http.ResponseWriter, r *http.Request) {
db := h.db(r)
if db == nil {
ErrorResponse(w, http.StatusNotFound, "no_vault", "No vault initialized")
return
}
base := filepath.Base(db.DBPath)
vaultID := strings.TrimSuffix(base, ".db")
JSONResponse(w, http.StatusOK, map[string]string{"vault_id": vaultID})
}
// Version is set by main via ldflags at build time.
var Version = "dev"
// Health returns server status.
func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) {
var count int
if db := h.db(r); db != nil {
count, _ = lib.EntryCount(db)
}
JSONResponse(w, http.StatusOK, map[string]any{
"status": "ok",
"version": Version,
"entries": count,
"time": time.Now().UTC().Format(time.RFC3339),
})
}
// Setup creates a session (test-only endpoint).
func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) {
session, err := lib.SessionCreate(h.db(r), h.Cfg.SessionTTL, lib.ActorWeb)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "session_failed", "Failed to create session")
return
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
Action: "setup",
Actor: lib.ActorWeb,
IPAddr: realIP(r),
})
JSONResponse(w, http.StatusOK, map[string]string{
"token": session.Token,
})
}
// AuthStatus returns whether the vault is fresh (no credentials) or locked.
func (h *Handlers) AuthStatus(w http.ResponseWriter, r *http.Request) {
db := h.db(r)
if db == nil {
JSONResponse(w, http.StatusOK, map[string]any{"state": "fresh", "credentials": 0})
return
}
count, err := lib.WebAuthnCredentialCount(db)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "db_error", "Failed to check vault status")
return
}
state := "fresh"
if count > 0 {
state = "locked"
}
JSONResponse(w, http.StatusOK, map[string]any{
"state": state,
"credentials": count,
})
}
// rpID extracts the RP ID from the Host header (strips port).
func rpID(r *http.Request) string {
host := r.Host
if idx := strings.Index(host, ":"); idx != -1 {
host = host[:idx]
}
if host == "127.0.0.1" || host == "::1" {
return "localhost"
}
return host
}
// hasValidSession checks if the request has a valid Bearer token.
func (h *Handlers) hasValidSession(r *http.Request) bool {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return false
}
db := h.db(r)
if db == nil {
return false
}
session, err := lib.SessionGet(db, strings.TrimPrefix(auth, "Bearer "))
return err == nil && session != nil
}
// AuthRegisterBegin starts WebAuthn registration.
// First passkey: no auth needed (DB may not exist yet). Additional passkeys: valid session required.
func (h *Handlers) AuthRegisterBegin(w http.ResponseWriter, r *http.Request) {
db := h.db(r)
if db != nil {
count, _ := lib.WebAuthnCredentialCount(db)
if count > 0 && !h.hasValidSession(r) {
ErrorResponse(w, http.StatusForbidden, "already_registered", "Vault already has credentials. Authenticate first to add more.")
return
}
}
challengeBytes := make([]byte, 32)
rand.Read(challengeBytes)
h.storeChallenge(challengeBytes, "register")
JSONResponse(w, http.StatusOK, map[string]any{
"publicKey": map[string]any{
"challenge": challengeBytes,
"rp": map[string]string{"name": "Clavitor", "id": rpID(r)},
"user": map[string]any{
"id": []byte("clavitor-owner"),
"name": "vault-owner",
"displayName": "Vault Owner",
},
"pubKeyCredParams": []map[string]any{
{"type": "public-key", "alg": -7},
{"type": "public-key", "alg": -257},
},
"authenticatorSelection": map[string]any{
"residentKey": "preferred",
"userVerification": "required",
},
"attestation": "none",
"extensions": map[string]any{
"prf": map[string]any{},
},
},
})
}
// AuthRegisterComplete finishes WebAuthn registration and creates a session.
func (h *Handlers) AuthRegisterComplete(w http.ResponseWriter, r *http.Request) {
db := h.db(r)
if db != nil {
count, _ := lib.WebAuthnCredentialCount(db)
if count > 0 && !h.hasValidSession(r) {
ErrorResponse(w, http.StatusForbidden, "already_registered", "Vault already has credentials. Authenticate first to add more.")
return
}
}
var req struct {
Challenge []byte `json:"challenge"`
CredentialID []byte `json:"credential_id"`
PublicKey []byte `json:"public_key"`
PRFSalt []byte `json:"prf_salt"`
Name string `json:"name"`
L1Key []byte `json:"l1_key"` // first 8 bytes of master, for vault DB naming
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
if err := h.consumeChallenge(req.Challenge, "register"); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_challenge", "Challenge verification failed")
return
}
// First passkey → create DB
if db == nil && len(req.PublicKey) > 0 {
// DB named from L1 key: clavitor-XXXXXX (base64url of first 4 bytes, no extension)
var dbName string
if len(req.L1Key) >= 4 {
dbName = "clavitor-" + base64UrlEncode(req.L1Key[:4])
} else {
// Fallback: derive from public key hash
hash := sha256.Sum256(req.PublicKey)
dbName = "clavitor-" + base64UrlEncode(hash[:4])
}
dbPath := filepath.Join(h.Cfg.DataDir, dbName)
newDB, err := lib.OpenDB(dbPath)
if err != nil {
log.Printf("DB create failed: path=%s err=%v", dbPath, err)
ErrorResponse(w, http.StatusInternalServerError, "db_create_failed", "Failed to create vault database")
return
}
defer newDB.Close()
if err := lib.MigrateDB(newDB); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "db_migrate_failed", "Failed to initialize vault database")
return
}
db = newDB
log.Printf("Vault created: %s", dbPath)
}
if req.Name == "" {
req.Name = "Primary Passkey"
}
cred := &lib.WebAuthnCredential{
CredID: lib.HexID(lib.NewID()),
Name: req.Name,
CredentialID: req.CredentialID,
PublicKey: req.PublicKey,
PRFSalt: req.PRFSalt,
}
if err := lib.StoreWebAuthnCredential(db, cred); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "store_failed", "Failed to store credential")
return
}
session, err := lib.SessionCreate(db, h.Cfg.SessionTTL, lib.ActorWeb)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "session_failed", "Failed to create session")
return
}
lib.AuditLog(db, &lib.AuditEvent{
Action: "register",
Actor: lib.ActorWeb,
IPAddr: realIP(r),
})
JSONResponse(w, http.StatusCreated, map[string]any{
"token": session.Token,
"cred_id": cred.CredID,
})
}
// AuthLoginBegin starts WebAuthn authentication.
func (h *Handlers) AuthLoginBegin(w http.ResponseWriter, r *http.Request) {
creds, err := lib.GetWebAuthnCredentials(h.db(r))
if err != nil || len(creds) == 0 {
ErrorResponse(w, http.StatusNotFound, "no_credentials", "No credentials registered")
return
}
challenge := make([]byte, 32)
rand.Read(challenge)
h.storeChallenge(challenge, "login")
var allowCreds []map[string]any
var prfSalt []byte
for _, c := range creds {
allowCreds = append(allowCreds, map[string]any{
"type": "public-key",
"id": c.CredentialID,
})
if len(c.PRFSalt) > 0 {
prfSalt = c.PRFSalt
}
}
prfExt := map[string]any{}
if len(prfSalt) > 0 {
prfExt["eval"] = map[string]any{"first": prfSalt}
}
JSONResponse(w, http.StatusOK, map[string]any{
"publicKey": map[string]any{
"challenge": challenge,
"rpId": rpID(r),
"allowCredentials": allowCreds,
"userVerification": "required",
"extensions": map[string]any{"prf": prfExt},
},
})
}
// AuthLoginComplete finishes WebAuthn authentication and creates a session.
func (h *Handlers) AuthLoginComplete(w http.ResponseWriter, r *http.Request) {
var req struct {
Challenge []byte `json:"challenge"`
CredentialID []byte `json:"credential_id"`
SignCount int `json:"sign_count"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
if err := h.consumeChallenge(req.Challenge, "login"); err != nil {
ErrorResponse(w, http.StatusUnauthorized, "invalid_challenge", "Challenge verification failed")
return
}
cred, err := lib.GetWebAuthnCredentialByRawID(h.db(r), req.CredentialID)
if err != nil {
ErrorResponse(w, http.StatusUnauthorized, "unknown_credential", "Credential not recognized")
return
}
// Cloned authenticator detection
if req.SignCount > 0 && req.SignCount <= cred.SignCount {
lib.AuditLog(h.db(r), &lib.AuditEvent{
Action: "cloned_authenticator_warning",
Actor: lib.ActorWeb,
IPAddr: realIP(r),
})
}
lib.UpdateWebAuthnSignCount(h.db(r), int64(cred.CredID), req.SignCount)
session, err := lib.SessionCreate(h.db(r), h.Cfg.SessionTTL, lib.ActorWeb)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "session_failed", "Failed to create session")
return
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
Action: "login",
Actor: lib.ActorWeb,
IPAddr: realIP(r),
})
JSONResponse(w, http.StatusOK, map[string]any{
"token": session.Token,
})
}
// ---------------------------------------------------------------------------
// Entry CRUD
// ---------------------------------------------------------------------------
// ListEntries returns all entries (tree structure).
func (h *Handlers) ListEntries(w http.ResponseWriter, r *http.Request) {
// Metadata-only mode: returns entry_id, type, title — no field data, no decryption.
// Used by web UI list view. Full data fetched per entry on click.
if r.URL.Query().Get("meta") == "1" {
entries, err := lib.EntryListMeta(h.db(r))
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries")
return
}
if entries == nil {
entries = []lib.Entry{}
}
JSONResponse(w, http.StatusOK, entries)
return
}
actor := ActorFromContext(r.Context())
var parent *int64
if pidStr := r.URL.Query().Get("parent_id"); pidStr != "" {
pid, err := lib.HexToID(pidStr)
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid parent_id")
return
}
parent = &pid
}
entries, err := lib.EntryList(h.db(r), h.vk(r), parent)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries")
return
}
if entries == nil {
entries = []lib.Entry{}
}
// Strip L2 field values for agent actors; web/extension decrypt client-side
if actor == lib.ActorAgent {
for i := range entries {
if entries[i].VaultData != nil {
stripL2Fields(entries[i].VaultData)
}
}
}
JSONResponse(w, http.StatusOK, entries)
}
// GetEntry returns a single entry.
func (h *Handlers) GetEntry(w http.ResponseWriter, r *http.Request) {
actor := ActorFromContext(r.Context())
entryID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID")
return
}
entry, err := lib.EntryGet(h.db(r), h.vk(r), entryID)
if err == lib.ErrNotFound {
ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
return
}
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry")
return
}
// Check if soft-deleted
if entry.DeletedAt != nil {
ErrorResponse(w, http.StatusNotFound, "deleted", "Entry has been deleted")
return
}
// Strip L2 field values for agent actors; web/extension decrypt client-side
if actor == lib.ActorAgent && entry.VaultData != nil {
stripL2Fields(entry.VaultData)
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
EntryID: entry.EntryID,
Title: entry.Title,
Action: lib.ActionRead,
Actor: actor,
IPAddr: realIP(r),
})
JSONResponse(w, http.StatusOK, entry)
}
// CreateEntry creates a new entry.
func (h *Handlers) CreateEntry(w http.ResponseWriter, r *http.Request) {
actor := ActorFromContext(r.Context())
var req struct {
Type string `json:"type"`
Title string `json:"title"`
ParentID lib.HexID `json:"parent_id"`
Data *lib.VaultData `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
if req.Title == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_title", "Title is required")
return
}
if req.Type == "" {
req.Type = lib.TypeCredential
}
entry := &lib.Entry{
Type: req.Type,
Title: req.Title,
ParentID: req.ParentID,
DataLevel: lib.DataLevelL1,
VaultData: req.Data,
}
if err := lib.EntryCreate(h.db(r), h.vk(r), entry); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create entry")
return
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
EntryID: entry.EntryID,
Title: entry.Title,
Action: lib.ActionCreate,
Actor: actor,
IPAddr: realIP(r),
})
JSONResponse(w, http.StatusCreated, entry)
}
// UpdateEntry updates an existing entry.
func (h *Handlers) UpdateEntry(w http.ResponseWriter, r *http.Request) {
actor := ActorFromContext(r.Context())
entryID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID")
return
}
var req struct {
Type string `json:"type"`
Title string `json:"title"`
ParentID lib.HexID `json:"parent_id"`
Version int `json:"version"`
Data *lib.VaultData `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
// Get existing entry
existing, err := lib.EntryGet(h.db(r), h.vk(r), entryID)
if err == lib.ErrNotFound {
ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
return
}
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry")
return
}
// Update fields
if req.Title != "" {
existing.Title = req.Title
}
if req.Type != "" {
existing.Type = req.Type
}
existing.ParentID = req.ParentID
existing.Version = req.Version
if req.Data != nil {
existing.VaultData = req.Data
}
if err := lib.EntryUpdate(h.db(r), h.vk(r), existing); err != nil {
if err == lib.ErrVersionConflict {
ErrorResponse(w, http.StatusConflict, "version_conflict", err.Error())
return
}
ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update entry")
return
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
EntryID: existing.EntryID,
Title: existing.Title,
Action: lib.ActionUpdate,
Actor: actor,
IPAddr: realIP(r),
})
JSONResponse(w, http.StatusOK, existing)
}
// DeleteEntry soft-deletes an entry.
func (h *Handlers) DeleteEntry(w http.ResponseWriter, r *http.Request) {
actor := ActorFromContext(r.Context())
entryID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID")
return
}
// Get entry for audit log
entry, _ := lib.EntryGet(h.db(r), h.vk(r), entryID)
if err := lib.EntryDelete(h.db(r), entryID); err != nil {
if err == lib.ErrNotFound {
ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
return
}
ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete entry")
return
}
title := ""
if entry != nil {
title = entry.Title
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
EntryID: lib.HexID(entryID),
Title: title,
Action: lib.ActionDelete,
Actor: actor,
IPAddr: realIP(r),
})
JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// ---------------------------------------------------------------------------
// Search
// ---------------------------------------------------------------------------
// SearchEntries searches entries by title.
func (h *Handlers) SearchEntries(w http.ResponseWriter, r *http.Request) {
actor := ActorFromContext(r.Context())
query := r.URL.Query().Get("q")
if query == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_query", "Query parameter 'q' is required")
return
}
// Use fuzzy search for practicality
entries, err := lib.EntrySearchFuzzy(h.db(r), h.vk(r), query)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "search_failed", "Search failed")
return
}
if entries == nil {
entries = []lib.Entry{}
}
// Strip L2 field values for agent actors; web/extension decrypt client-side
if actor == lib.ActorAgent {
for i := range entries {
if entries[i].VaultData != nil {
stripL2Fields(entries[i].VaultData)
}
}
}
JSONResponse(w, http.StatusOK, entries)
}
// ---------------------------------------------------------------------------
// Password Generator
// ---------------------------------------------------------------------------
// GeneratePassword generates a random password.
func (h *Handlers) GeneratePassword(w http.ResponseWriter, r *http.Request) {
lengthStr := r.URL.Query().Get("length")
length := 20
if lengthStr != "" {
if l, err := strconv.Atoi(lengthStr); err == nil && l > 0 && l <= 128 {
length = l
}
}
symbols := r.URL.Query().Get("symbols") != "false"
wordsParam := r.URL.Query().Get("words")
wordsN := 0
if wordsParam == "true" {
wordsN = 4
} else if n, err := strconv.Atoi(wordsParam); err == nil && n > 0 {
wordsN = n
}
var password string
if wordsN > 0 {
password = generatePassphrase(wordsN)
} else {
password = generatePassword(length, symbols)
}
JSONResponse(w, http.StatusOK, map[string]string{
"password": password,
})
}
func generatePassword(length int, symbols bool) string {
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
const digits = "0123456789"
const syms = "!@#$%^&*()_+-=[]{}|;:,.<>?"
charset := letters + digits
if symbols {
charset += syms
}
b := make([]byte, length)
rand.Read(b)
for i := range b {
b[i] = charset[int(b[i])%len(charset)]
}
return string(b)
}
func generatePassphrase(words int) string {
wordList := []string{
"correct", "horse", "battery", "staple", "cloud", "mountain",
"river", "forest", "castle", "dragon", "phoenix", "crystal",
"shadow", "thunder", "whisper", "harvest", "journey", "compass",
"anchor", "beacon", "bridge", "canyon", "desert", "empire",
}
b := make([]byte, words)
rand.Read(b)
parts := make([]string, words)
for i := range parts {
parts[i] = wordList[int(b[i])%len(wordList)]
}
return strings.Join(parts, "-")
}
// ---------------------------------------------------------------------------
// Extension API
// ---------------------------------------------------------------------------
// GetTOTP generates a live TOTP code for an entry.
func (h *Handlers) GetTOTP(w http.ResponseWriter, r *http.Request) {
actor := ActorFromContext(r.Context())
entryID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID")
return
}
entry, err := lib.EntryGet(h.db(r), h.vk(r), entryID)
if err == lib.ErrNotFound {
ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
return
}
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry")
return
}
if entry.VaultData == nil {
ErrorResponse(w, http.StatusBadRequest, "no_totp", "Entry has no TOTP field")
return
}
// Find TOTP field
var totpSeed string
var isL2 bool
for _, field := range entry.VaultData.Fields {
if field.Kind == "totp" {
if field.L2 {
isL2 = true
} else {
totpSeed = field.Value
}
break
}
}
if isL2 {
JSONResponse(w, http.StatusOK, map[string]any{
"l2": true,
})
return
}
if totpSeed == "" {
ErrorResponse(w, http.StatusBadRequest, "no_totp", "Entry has no TOTP field")
return
}
// Normalize seed (remove spaces, uppercase)
totpSeed = strings.ToUpper(strings.ReplaceAll(totpSeed, " ", ""))
// Generate TOTP code
code, err := totp.GenerateCode(totpSeed, time.Now())
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_totp", "Invalid TOTP seed")
return
}
// Calculate time until expiry (30 second window)
now := time.Now().Unix()
expiresIn := 30 - (now % 30)
lib.AuditLog(h.db(r), &lib.AuditEvent{
EntryID: entry.EntryID,
Title: entry.Title,
Action: "totp",
Actor: actor,
IPAddr: realIP(r),
})
JSONResponse(w, http.StatusOK, map[string]any{
"code": code,
"expires_in": expiresIn,
"l2": false,
})
}
// MatchURL finds credentials matching a URL (for extension popup).
func (h *Handlers) MatchURL(w http.ResponseWriter, r *http.Request) {
urlStr := r.URL.Query().Get("url")
if urlStr == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_url", "URL parameter required")
return
}
// Extract domain from URL
domain := extractDomain(urlStr)
// Get all entries and filter by URL
entries, err := lib.EntryList(h.db(r), h.vk(r), nil)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries")
return
}
var matches []lib.Entry
for _, entry := range entries {
if entry.VaultData == nil {
continue
}
for _, u := range entry.VaultData.URLs {
if strings.Contains(u, domain) || strings.Contains(domain, extractDomain(u)) {
matches = append(matches, entry)
break
}
}
}
if matches == nil {
matches = []lib.Entry{}
}
JSONResponse(w, http.StatusOK, matches)
}
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
// ImportEntries parses known formats directly; falls back to chunked LLM for unknown formats.
func (h *Handlers) ImportEntries(w http.ResponseWriter, r *http.Request) {
actor := ActorFromContext(r.Context())
if err := r.ParseMultipartForm(32 << 20); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_form", "Failed to parse form")
return
}
file, _, err := r.FormFile("file")
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "missing_file", "File is required")
return
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "read_failed", "Failed to read file")
return
}
var entries []lib.VaultData
if parsed, ok := lib.DetectAndParse(content); ok {
entries = parsed
} else {
ErrorResponse(w, http.StatusBadRequest, "unknown_format", "Unsupported import format. Supported: Chrome CSV, Firefox CSV, Bitwarden JSON, Proton Pass JSON")
return
}
// Classify entries against existing vault
existingAll, _ := lib.EntryList(h.db(r), h.vk(r), nil)
existingIndex := map[string]lib.HexID{}
for _, e := range existingAll {
if e.VaultData == nil {
continue
}
existingIndex[importDedupKey(e.VaultData)] = e.EntryID
}
type PreviewEntry struct {
lib.VaultData
Status string `json:"status"`
ExistingID lib.HexID `json:"existing_id,omitempty"`
}
batchSeen := map[string]bool{}
preview := make([]PreviewEntry, 0, len(entries))
for _, vd := range entries {
key := importDedupKey(&vd)
pe := PreviewEntry{VaultData: vd}
if batchSeen[key] {
pe.Status = "duplicate"
} else if existingID, found := existingIndex[key]; found {
pe.Status = "update"
pe.ExistingID = existingID
} else {
pe.Status = "new"
}
batchSeen[key] = true
preview = append(preview, pe)
}
newCount, updateCount, dupCount := 0, 0, 0
for _, pe := range preview {
switch pe.Status {
case "new":
newCount++
case "update":
updateCount++
case "duplicate":
dupCount++
}
}
// Return first 100 for preview UI; client confirms all
previewSlice := preview
if len(previewSlice) > 100 {
previewSlice = previewSlice[:100]
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
Action: lib.ActionImport,
Actor: actor,
IPAddr: realIP(r),
Title: fmt.Sprintf("%d parsed: %d new, %d update, %d duplicate", len(entries), newCount, updateCount, dupCount),
})
JSONResponse(w, http.StatusOK, map[string]any{
"entries": previewSlice,
"all_entries": preview, // full list for confirm
"total": len(preview),
"new": newCount,
"update": updateCount,
"duplicates": dupCount,
})
}
// ImportConfirm confirms and saves imported entries.
func (h *Handlers) ImportConfirm(w http.ResponseWriter, r *http.Request) {
actor := ActorFromContext(r.Context())
var req struct {
Entries []lib.VaultData `json:"entries"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
// Build dedup index: normalized "url|username" → existing Entry (for upsert)
existing, _ := lib.EntryList(h.db(r), h.vk(r), nil)
dedupIndex := map[string]*lib.Entry{}
for i, e := range existing {
if e.VaultData == nil {
continue
}
dedupIndex[importDedupKey(e.VaultData)] = &existing[i]
}
var created, updated, skipped int
// Track intra-batch keys to avoid importing same entry twice
batchSeen := map[string]bool{}
for _, vd := range req.Entries {
key := importDedupKey(&vd)
if batchSeen[key] {
continue // intra-batch duplicate — first occurrence wins within same batch
}
batchSeen[key] = true
vdCopy := vd
if existingEntry, found := dedupIndex[key]; found {
// Collision: keep newest by source modification date.
// If incoming has no date (e.g. Chrome CSV) → don't overwrite.
// If incoming date > existing → overwrite.
incomingMod := vdCopy.SourceModified
existingMod := existingEntry.UpdatedAt
shouldUpdate := incomingMod > 0 && incomingMod > existingMod
if shouldUpdate {
existingEntry.Title = vdCopy.Title
existingEntry.Type = vdCopy.Type
existingEntry.VaultData = &vdCopy
if err := lib.EntryUpdate(h.db(r), h.vk(r), existingEntry); err == nil {
updated++
}
} else if incomingMod == 0 {
// No date in source — skip, existing wins
skipped++
} else {
// Existing is newer — skip
skipped++
}
} else {
entry := &lib.Entry{
Type: vdCopy.Type,
Title: vdCopy.Title,
DataLevel: lib.DataLevelL1,
VaultData: &vdCopy,
}
if err := lib.EntryCreate(h.db(r), h.vk(r), entry); err == nil {
created++
dedupIndex[key] = entry
}
}
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
Action: lib.ActionImport,
Actor: actor,
IPAddr: realIP(r),
Title: fmt.Sprintf("%d created, %d updated, %d skipped (date-based)", created, updated, skipped),
})
JSONResponse(w, http.StatusOK, map[string]any{"imported": created, "updated": updated, "skipped": skipped})
}
// ---------------------------------------------------------------------------
// Audit Log
// ---------------------------------------------------------------------------
// GetAuditLog returns recent audit events.
func (h *Handlers) GetAuditLog(w http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
limit := 100
if limitStr != "" {
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
}
events, err := lib.AuditList(h.db(r), limit)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list audit events")
return
}
if events == nil {
events = []lib.AuditEvent{}
}
JSONResponse(w, http.StatusOK, events)
}
// ---------------------------------------------------------------------------
// WebAuthn PRF
// ---------------------------------------------------------------------------
// HandleWebAuthnRegisterBegin starts WebAuthn registration.
func (h *Handlers) HandleWebAuthnRegisterBegin(w http.ResponseWriter, r *http.Request) {
// Generate a challenge
challenge := make([]byte, 32)
rand.Read(challenge)
options := map[string]any{
"publicKey": map[string]any{
"challenge": challenge,
"rp": map[string]string{"name": "Clavitor", "id": rpID(r)},
"user": map[string]any{
"id": []byte("clavitor-owner"),
"name": "vault-owner",
"displayName": "Clavitor Owner",
},
"pubKeyCredParams": []map[string]any{
{"type": "public-key", "alg": -7}, // ES256
{"type": "public-key", "alg": -257}, // RS256
},
"authenticatorSelection": map[string]any{
"residentKey": "preferred",
"userVerification": "required",
},
"extensions": map[string]any{
"prf": map[string]any{},
},
},
}
JSONResponse(w, http.StatusOK, options)
}
// HandleWebAuthnRegisterComplete finishes WebAuthn registration.
func (h *Handlers) HandleWebAuthnRegisterComplete(w http.ResponseWriter, r *http.Request) {
var req struct {
CredID string `json:"cred_id"`
PublicKey []byte `json:"public_key"`
PRFSalt []byte `json:"prf_salt"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
// Decode the base64url credential ID from the browser
credentialID, err := base64.RawURLEncoding.DecodeString(req.CredID)
if err != nil {
credentialID = []byte{}
}
cred := &lib.WebAuthnCredential{
CredID: lib.HexID(lib.NewID()),
Name: req.Name,
PublicKey: req.PublicKey,
CredentialID: credentialID,
PRFSalt: req.PRFSalt,
}
if err := lib.StoreWebAuthnCredential(h.db(r), cred); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "store_failed", "Failed to store credential")
return
}
JSONResponse(w, http.StatusCreated, map[string]any{"status": "registered", "cred_id": cred.CredID})
}
// HandleWebAuthnAuthBegin starts WebAuthn authentication with PRF extension.
func (h *Handlers) HandleWebAuthnAuthBegin(w http.ResponseWriter, r *http.Request) {
creds, err := lib.GetWebAuthnCredentials(h.db(r))
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get credentials")
return
}
challenge := make([]byte, 32)
rand.Read(challenge)
var allowCreds []map[string]any
var prfSalt []byte
for _, c := range creds {
allowCreds = append(allowCreds, map[string]any{
"type": "public-key",
"id": c.CredID,
})
if len(c.PRFSalt) > 0 {
prfSalt = c.PRFSalt
}
}
prfExt := map[string]any{}
if len(prfSalt) > 0 {
prfExt["eval"] = map[string]any{
"first": prfSalt,
}
}
options := map[string]any{
"publicKey": map[string]any{
"challenge": challenge,
"allowCredentials": allowCreds,
"userVerification": "required",
"extensions": map[string]any{
"prf": prfExt,
},
},
}
JSONResponse(w, http.StatusOK, options)
}
// HandleWebAuthnAuthComplete finishes WebAuthn authentication.
func (h *Handlers) HandleWebAuthnAuthComplete(w http.ResponseWriter, r *http.Request) {
var req struct {
CredID lib.HexID `json:"cred_id"`
SignCount int `json:"sign_count"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
if err := lib.UpdateWebAuthnSignCount(h.db(r), int64(req.CredID), req.SignCount); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update sign count")
return
}
JSONResponse(w, http.StatusOK, map[string]string{"status": "authenticated"})
}
// HandleListWebAuthnCredentials returns all registered WebAuthn credentials.
func (h *Handlers) HandleListWebAuthnCredentials(w http.ResponseWriter, r *http.Request) {
creds, err := lib.GetWebAuthnCredentials(h.db(r))
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list credentials")
return
}
if creds == nil {
creds = []lib.WebAuthnCredential{}
}
JSONResponse(w, http.StatusOK, creds)
}
// HandleDeleteWebAuthnCredential removes a WebAuthn credential.
func (h *Handlers) HandleDeleteWebAuthnCredential(w http.ResponseWriter, r *http.Request) {
id, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid credential ID")
return
}
if err := lib.DeleteWebAuthnCredential(h.db(r), id); err != nil {
if err == lib.ErrNotFound {
ErrorResponse(w, http.StatusNotFound, "not_found", "Credential not found")
return
}
ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete credential")
return
}
JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func stripL2Fields(vd *lib.VaultData) {
for i := range vd.Fields {
if vd.Fields[i].L2 {
vd.Fields[i].Value = "[REDACTED — not available to agents]"
}
}
}
// normalizeURLForDedup strips scheme differences, trailing slashes, and lowercases
// so that "http://x.com/" and "https://x.com" produce the same dedup key.
func normalizeURLForDedup(u string) string {
u = strings.ToLower(u)
u = strings.TrimPrefix(u, "https://")
u = strings.TrimPrefix(u, "http://")
u = strings.TrimRight(u, "/")
return u
}
// importDedupKey builds a dedup key from a VaultData's first URL + username.
// For notes/entries without URLs or usernames, fall back to the title to avoid
// all notes colliding on the same empty key.
func importDedupKey(vd *lib.VaultData) string {
var url, username string
if len(vd.URLs) > 0 {
url = normalizeURLForDedup(vd.URLs[0])
}
for _, f := range vd.Fields {
lbl := strings.ToLower(f.Label)
if lbl == "username" || lbl == "email" || lbl == "login" {
username = strings.ToLower(f.Value)
break
}
}
key := url + "|" + username
if key == "|" {
// No URL and no username — use title to differentiate.
key = "title:" + strings.ToLower(vd.Title)
}
return key
}
func extractDomain(urlStr string) string {
// Simple domain extraction
urlStr = strings.TrimPrefix(urlStr, "https://")
urlStr = strings.TrimPrefix(urlStr, "http://")
urlStr = strings.TrimPrefix(urlStr, "www.")
if idx := strings.Index(urlStr, "/"); idx > 0 {
urlStr = urlStr[:idx]
}
if idx := strings.Index(urlStr, ":"); idx > 0 {
urlStr = urlStr[:idx]
}
return urlStr
}
// generateTOTPSecret generates a new TOTP secret.
func generateTOTPSecret() string {
b := make([]byte, 20)
rand.Read(b)
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
}
// --- Backup / Restore ---
// ListBackups returns all available backup files.
func (h *Handlers) ListBackups(w http.ResponseWriter, r *http.Request) {
backups := lib.ListBackups(h.Cfg.DataDir)
JSONResponse(w, http.StatusOK, backups)
}
// CreateBackup triggers an immediate backup.
func (h *Handlers) CreateBackup(w http.ResponseWriter, r *http.Request) {
lib.RunBackups(h.Cfg.DataDir)
backups := lib.ListBackups(h.Cfg.DataDir)
JSONResponse(w, http.StatusOK, backups)
}
// RestoreBackup restores from a named backup file.
func (h *Handlers) RestoreBackup(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
ErrorResponse(w, http.StatusBadRequest, "invalid_request", "Backup name required")
return
}
if err := lib.RestoreBackup(h.Cfg.DataDir, req.Name); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "restore_error", err.Error())
return
}
JSONResponse(w, http.StatusOK, map[string]string{"status": "restored", "name": req.Name})
}
// ---------------------------------------------------------------------------
// Agent Management
// ---------------------------------------------------------------------------
// HandleCreateAgent creates a new agent.
// POST /api/agents
func (h *Handlers) HandleCreateAgent(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
IPWhitelist []string `json:"ip_whitelist"`
RateLimitMinute int `json:"rate_limit_minute"`
RateLimitHour int `json:"rate_limit_hour"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
if req.Name == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_name", "Name is required")
return
}
db := h.db(r)
// Check for duplicate name
existing, _ := lib.AgentGetByName(db, req.Name)
if existing != nil {
ErrorResponse(w, http.StatusConflict, "duplicate_name", "Agent with this name already exists")
return
}
a := &lib.Agent{
Name: req.Name,
IPWhitelist: req.IPWhitelist,
RateLimitMinute: req.RateLimitMinute,
RateLimitHour: req.RateLimitHour,
}
if err := lib.AgentCreate(db, a); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create agent")
return
}
lib.AuditLog(db, &lib.AuditEvent{
Action: "agent_create",
Actor: ActorFromContext(r.Context()),
IPAddr: realIP(r),
Title: a.Name,
})
JSONResponse(w, http.StatusCreated, a)
}
// HandleListAgents lists all agents.
// GET /api/agents
func (h *Handlers) HandleListAgents(w http.ResponseWriter, r *http.Request) {
agents, err := lib.AgentList(h.db(r))
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list agents")
return
}
if agents == nil {
agents = []lib.Agent{}
}
JSONResponse(w, http.StatusOK, agents)
}
// HandleGetAgent returns a single agent.
// GET /api/agents/{id}
func (h *Handlers) HandleGetAgent(w http.ResponseWriter, r *http.Request) {
agentID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID")
return
}
a, err := lib.AgentGet(h.db(r), agentID)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent")
return
}
if a == nil {
ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
return
}
JSONResponse(w, http.StatusOK, a)
}
// HandleUpdateAgentWhitelist updates an agent's IP whitelist.
// PUT /api/agents/{id}/whitelist
func (h *Handlers) HandleUpdateAgentWhitelist(w http.ResponseWriter, r *http.Request) {
agentID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID")
return
}
var req struct {
IPWhitelist []string `json:"ip_whitelist"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
db := h.db(r)
a, err := lib.AgentGet(db, agentID)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent")
return
}
if a == nil {
ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
return
}
if err := lib.AgentUpdateWhitelist(db, agentID, req.IPWhitelist); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update whitelist")
return
}
lib.AuditLog(db, &lib.AuditEvent{
Action: "agent_update_whitelist",
Actor: ActorFromContext(r.Context()),
IPAddr: realIP(r),
Title: a.Name,
})
JSONResponse(w, http.StatusOK, map[string]string{"status": "updated"})
}
// HandleUpdateAgentRateLimits updates an agent's rate limits.
// PUT /api/agents/{id}/rate-limits
func (h *Handlers) HandleUpdateAgentRateLimits(w http.ResponseWriter, r *http.Request) {
agentID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID")
return
}
var req struct {
RateLimitMinute int `json:"rate_limit_minute"`
RateLimitHour int `json:"rate_limit_hour"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
db := h.db(r)
a, err := lib.AgentGet(db, agentID)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent")
return
}
if a == nil {
ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
return
}
if err := lib.AgentUpdateRateLimits(db, agentID, req.RateLimitMinute, req.RateLimitHour); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update rate limits")
return
}
lib.AuditLog(db, &lib.AuditEvent{
Action: "agent_update_rate_limits",
Actor: ActorFromContext(r.Context()),
IPAddr: realIP(r),
Title: a.Name,
})
JSONResponse(w, http.StatusOK, map[string]string{"status": "updated"})
}
// HandleLockAgent manually locks an agent.
// POST /api/agents/{id}/lock
func (h *Handlers) HandleLockAgent(w http.ResponseWriter, r *http.Request) {
agentID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID")
return
}
db := h.db(r)
a, err := lib.AgentGet(db, agentID)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent")
return
}
if a == nil {
ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
return
}
if err := lib.AgentUpdateStatus(db, agentID, lib.AgentStatusLocked, "manually locked"); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "lock_failed", "Failed to lock agent")
return
}
lib.AuditLog(db, &lib.AuditEvent{
Action: "agent_lock",
Actor: ActorFromContext(r.Context()),
IPAddr: realIP(r),
Title: a.Name,
})
JSONResponse(w, http.StatusOK, map[string]string{"status": "locked"})
}
// HandleUnlockAgent unlocks a locked agent.
// POST /api/agents/{id}/unlock
func (h *Handlers) HandleUnlockAgent(w http.ResponseWriter, r *http.Request) {
agentID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID")
return
}
db := h.db(r)
a, err := lib.AgentGet(db, agentID)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent")
return
}
if a == nil {
ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
return
}
if err := lib.AgentUpdateStatus(db, agentID, lib.AgentStatusActive, ""); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "unlock_failed", "Failed to unlock agent")
return
}
lib.AuditLog(db, &lib.AuditEvent{
Action: "agent_unlock",
Actor: ActorFromContext(r.Context()),
IPAddr: realIP(r),
Title: a.Name,
})
JSONResponse(w, http.StatusOK, map[string]string{"status": "unlocked"})
}
// HandleRevokeAgent permanently revokes an agent.
// DELETE /api/agents/{id}
func (h *Handlers) HandleRevokeAgent(w http.ResponseWriter, r *http.Request) {
agentID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid agent ID")
return
}
db := h.db(r)
a, err := lib.AgentGet(db, agentID)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get agent")
return
}
if a == nil {
ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
return
}
if err := lib.AgentDelete(db, agentID); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to revoke agent")
return
}
lib.AuditLog(db, &lib.AuditEvent{
Action: "agent_revoke",
Actor: ActorFromContext(r.Context()),
IPAddr: realIP(r),
Title: a.Name,
})
JSONResponse(w, http.StatusOK, map[string]string{"status": "revoked"})
}
// ---------------------------------------------------------------------------
// Vault Lock
// ---------------------------------------------------------------------------
// HandleVaultLockStatus returns the vault lock state.
// GET /api/vault-lock
func (h *Handlers) HandleVaultLockStatus(w http.ResponseWriter, r *http.Request) {
vl, err := lib.VaultLockGet(h.db(r))
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get vault lock status")
return
}
JSONResponse(w, http.StatusOK, vl)
}
// HandleVaultUnlock unlocks the vault.
// POST /api/vault-unlock
func (h *Handlers) HandleVaultUnlock(w http.ResponseWriter, r *http.Request) {
db := h.db(r)
if err := lib.VaultLockSet(db, false, ""); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "unlock_failed", "Failed to unlock vault")
return
}
lib.AuditLog(db, &lib.AuditEvent{
Action: "vault_unlock",
Actor: ActorFromContext(r.Context()),
IPAddr: realIP(r),
})
JSONResponse(w, http.StatusOK, map[string]string{"status": "unlocked"})
}