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

1908 lines
61 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package api
import (
"context"
"crypto/rand"
"encoding/base32"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/johanj/clavitor/edition"
"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
}
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(db *lib.DB, data []byte, typ string) {
if db == nil {
// Fallback to memory if no DB (shouldn't happen in normal flow)
h.mu.Lock()
defer h.mu.Unlock()
key := fmt.Sprintf("%x", data)
h.challenges[key] = challenge{Data: data, Type: typ, CreatedAt: time.Now()}
return
}
lib.StoreWebAuthnChallenge(db, data, typ)
}
func (h *Handlers) consumeChallenge(db *lib.DB, data []byte, typ string) error {
if db == nil {
// Fallback to memory
h.mu.Lock()
defer h.mu.Unlock()
key := fmt.Sprintf("%x", 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
}
return lib.ConsumeWebAuthnChallenge(db, data, typ)
}
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)
}
}
}
// --- Context helpers ---
func (h *Handlers) db(r *http.Request) *lib.DB { return DBFromContext(r.Context()) }
func (h *Handlers) vk(r *http.Request) []byte { return VaultKeyFromContext(r.Context()) }
func (h *Handlers) agent(r *http.Request) *lib.AgentData { return AgentFromContext(r.Context()) }
// l0 returns L0 (first 4 bytes of vault key before normalization).
func (h *Handlers) l0(r *http.Request) []byte {
vk := h.vk(r)
if len(vk) >= 8 {
return vk[:4]
}
return vk
}
// ---------------------------------------------------------------------------
// Authorization - Single, clean permission system
// ---------------------------------------------------------------------------
// Permission levels (ordered from most to least restrictive)
const (
PermSystem = iota // Agents cannot modify system entries (agents, scopes)
PermL3 // Agents cannot overwrite L3 fields
PermAdmin // Only web users (requires Bearer auth, blocks agents)
PermOwner // Alias for Admin (web users only)
)
// authorize checks if the request has the required permission.
// Returns true if request should be blocked (caller should return).
func authorize(w http.ResponseWriter, r *http.Request, level int, context ...any) bool {
// All levels block agents - only web users allowed
if IsAgentRequest(r) {
switch level {
case PermSystem:
// Check if trying to modify system entries
if len(context) > 0 {
entryType, ok := context[0].(string)
if ok && (entryType == lib.TypeAgent || entryType == lib.TypeScope) {
ErrorResponse(w, http.StatusForbidden, "system_type", "Agents cannot modify agent or scope records")
return true
}
}
return false // Not a system entry, allow
case PermL3:
// Check if overwriting L3 fields
if len(context) >= 2 {
existing, ok1 := context[0].(*lib.VaultData)
incoming, ok2 := context[1].(*lib.VaultData)
if ok1 && ok2 {
for _, f := range existing.Fields {
if f.Tier == 3 {
for _, nf := range incoming.Fields {
if nf.Label == f.Label && nf.Value != f.Value {
ErrorResponse(w, http.StatusForbidden, "l3_protected", "L3 fields cannot be modified by agents")
return true
}
}
}
}
}
}
return false
default:
// PermAdmin, PermOwner - block all agents
ErrorResponse(w, http.StatusForbidden, "owner_only", "This operation requires web authentication")
return true
}
}
// Web user authenticated via Bearer token - allow
return false
}
// Legacy aliases for backward compatibility (will be removed)
func (h *Handlers) requireOwner(w http.ResponseWriter, r *http.Request) bool {
return authorize(w, r, PermOwner)
}
func (h *Handlers) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
return authorize(w, r, PermAdmin)
}
func rejectAgentSystemWrite(w http.ResponseWriter, r *http.Request, entryType string) bool {
return authorize(w, r, PermSystem, entryType)
}
// rejectAgentL3Overwrite preserves L3 fields - agents cannot change them
// Returns true if any fields were protected
func rejectAgentL3Overwrite(w http.ResponseWriter, r *http.Request, existing, incoming *lib.VaultData) bool {
if existing == nil || incoming == nil {
return false
}
// Check authorization - if not an agent, allow changes
if !IsAgentRequest(r) {
return false
}
existingL3 := make(map[string]string)
for _, f := range existing.Fields {
if f.Tier >= 3 {
existingL3[f.Label] = f.Value
}
}
if len(existingL3) == 0 {
return false
}
protected := false
for i, f := range incoming.Fields {
if val, isL3 := existingL3[f.Label]; isL3 {
// Preserve the L3 value — agent cannot change it
incoming.Fields[i].Value = val
incoming.Fields[i].Tier = 3
protected = true
}
}
return protected
}
// filterByScope removes entries the agent cannot access.
func filterByScope(agent *lib.AgentData, entries []lib.Entry) []lib.Entry {
if agent == nil {
return entries
}
if agent.AllAccess {
return entries
}
var filtered []lib.Entry
for _, e := range entries {
if lib.AgentCanAccess(agent, e.Scopes) {
filtered = append(filtered, e)
}
}
if filtered == nil {
return []lib.Entry{}
}
return filtered
}
// filterOutSystemTypes removes agent/scope entries from results.
func filterOutSystemTypes(entries []lib.Entry) []lib.Entry {
var filtered []lib.Entry
for _, e := range entries {
if e.Type != lib.TypeAgent && e.Type != lib.TypeScope {
filtered = append(filtered, e)
}
}
if filtered == nil {
return []lib.Entry{}
}
return filtered
}
// ---------------------------------------------------------------------------
// Health & Auth
// ---------------------------------------------------------------------------
var (
Version = "dev"
Commit = "dev"
BuildDate = "unknown"
)
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)
JSONResponse(w, http.StatusOK, map[string]string{"vault_id": base})
}
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),
})
}
func (h *Handlers) Version(w http.ResponseWriter, r *http.Request) {
JSONResponse(w, http.StatusOK, map[string]string{
"version": Version,
"commit": Commit,
"buildDate": BuildDate,
})
}
func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) {
lib.AuditLog(h.db(r), &lib.AuditEvent{
Action: "setup", Actor: lib.ActorWeb, IPAddr: realIP(r),
})
JSONResponse(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handlers) AuthStatus(w http.ResponseWriter, r *http.Request) {
db := h.db(r)
if db == nil {
// Without authentication (no L0/bearer), we cannot determine vault state.
// In multi-vault hosted mode, no wildcard can tell us which vault to check.
JSONResponse(w, http.StatusOK, map[string]any{"state": "auth_required"})
return
}
count, _ := lib.WebAuthnCredentialCount(db)
state := "fresh"
if count > 0 {
state = "locked"
}
JSONResponse(w, http.StatusOK, map[string]any{"state": state, "credentials": count})
}
func rpID(r *http.Request) string {
host := r.Host
if idx := strings.Index(host, ":"); idx != -1 {
host = host[:idx]
}
return host
}
func (h *Handlers) AuthRegisterBegin(w http.ResponseWriter, r *http.Request) {
challengeBytes := make([]byte, 32)
rand.Read(challengeBytes)
h.storeChallenge(h.db(r), challengeBytes, "register")
// Hardcoded salt "Clavitor" - all vaults use same salt for deterministic recovery
hardcodedSalt := []byte("Clavitor")
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{"eval": map[string]any{"first": hardcodedSalt}}},
},
})
}
func (h *Handlers) AuthRegisterComplete(w http.ResponseWriter, r *http.Request) {
var req struct {
Challenge []byte `json:"challenge"`
CredentialID []byte `json:"credential_id"`
PublicKey []byte `json:"public_key"`
Name string `json:"name"`
L1 []byte `json:"l1"` // 8 bytes — server slices L0 from this[:4]
P1 []byte `json:"p1"` // 8 bytes — browser-derived WL3 lookup token
WrappedL3 []byte `json:"wrapped_l3"` // opaque blob; server stores, never decrypts
AuthenticatorAttachment string `json:"authenticator_attachment"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
if err := h.consumeChallenge(nil, req.Challenge, "register"); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_challenge", "Challenge verification failed")
return
}
// Server vetos: L2, L3, and the 32-byte master key never travel here.
// The browser sends only what the server is allowed to see: L1 (for vault
// routing + entry encryption), P1 (for WL3 lookup), and the opaque
// wrapped_l3 blob. Validate hard.
if len(req.L1) != 8 {
ErrorResponse(w, http.StatusBadRequest, "invalid_l1", "L1 must be exactly 8 bytes")
return
}
if len(req.P1) != 8 {
ErrorResponse(w, http.StatusBadRequest, "invalid_p1", "P1 must be exactly 8 bytes")
return
}
if len(req.WrappedL3) == 0 {
ErrorResponse(w, http.StatusBadRequest, "missing_wrapped_l3", "wrapped_l3 is required")
return
}
db := h.db(r)
if db == nil {
// L0 = L1[:4] for vault file naming.
l0 := req.L1[:4]
dbName := "clavitor-" + base64UrlEncode(l0)
dbPath := filepath.Join(h.Cfg.DataDir, dbName)
newDB, err := lib.OpenDB(dbPath)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "db_create_failed", "Failed to create vault")
return
}
defer newDB.Close()
if err := lib.InitSchema(newDB); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "db_init_failed", "Failed to initialize vault")
return
}
db = newDB
log.Printf("Vault created: %s", dbPath)
}
if req.Name == "" {
req.Name = "Primary Passkey"
}
// Store credential with authenticator attachment type
cred := &lib.WebAuthnCredential{
CredID: lib.HexID(lib.NewID()),
Name: req.Name,
CredentialID: req.CredentialID,
PublicKey: req.PublicKey,
AuthenticatorAttachment: req.AuthenticatorAttachment,
}
if err := lib.StoreWebAuthnCredential(db, cred); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "store_failed", "Failed to store credential")
return
}
// Write WL3 credential file. The browser pre-computed P1 (HKDF over its
// master key) and pre-wrapped L3 with its raw PRF output. The server is a
// dumb store: it persists what arrives and derives nothing. wrapped_l3 is
// opaque to the server — only a future device tap can unwrap it.
wl3Entry := &lib.WL3Entry{
P1: hex.EncodeToString(req.P1),
L0: hex.EncodeToString(req.L1[:4]),
WrappedL3: base64UrlEncode(req.WrappedL3),
CredentialID: base64UrlEncode(req.CredentialID),
PublicKey: base64UrlEncode(req.PublicKey),
HomePOP: "", // empty in community; commercial sets this from edition config
CreatedAt: time.Now().Unix(),
}
if err := lib.WL3Write(h.Cfg.WL3Dir, wl3Entry); err != nil {
// SECURITY.md: visible failure, no silent fallback. Registration fails loud.
log.Printf("WL3 write failed: %v", err)
ErrorResponse(w, http.StatusInternalServerError, "wl3_write_failed", "Failed to persist credential lookup record")
return
}
// Get all registered credential types for response
creds, _ := lib.GetWebAuthnCredentials(db)
typeMap := make(map[string]bool)
for _, c := range creds {
if c.AuthenticatorAttachment != "" {
typeMap[c.AuthenticatorAttachment] = true
}
}
registeredTypes := make([]string, 0, len(typeMap))
for t := range typeMap {
registeredTypes = append(registeredTypes, t)
}
lib.AuditLog(db, &lib.AuditEvent{Action: "register", Actor: lib.ActorWeb, IPAddr: realIP(r)})
JSONResponse(w, http.StatusCreated, map[string]any{
"status": "registered",
"cred_id": cred.CredID,
"registered_types": registeredTypes,
})
}
func (h *Handlers) AuthLoginBegin(w http.ResponseWriter, r *http.Request) {
// If no vault from bearer, check if L1 or L0 was provided
// L1 = first 8 bytes of master key; L0 = first 4 bytes of L1
db := h.db(r)
if db == nil {
var req struct {
P1 string `json:"p1"` // Hex-encoded P1 (8 bytes) — preferred lookup
L1 string `json:"l1"` // Base64-encoded L1 (8 bytes)
L0 string `json:"l0"` // Base64-encoded L0 (4 bytes) - from localStorage hint
CredentialIDs []string `json:"credential_ids"` // Credential IDs from localStorage hint
}
if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
// Try P1 first (preferred — looks up the WL3 file to find L0 + home_pop).
// The browser computes P1 client-side via HKDF over its master key and
// sends it. The server NEVER derives P1 — it only receives and stores it.
// P1 alone leaks no decryption capability; login still requires WebAuthn.
if req.P1 != "" {
p1Bytes, err := hex.DecodeString(req.P1)
if err == nil && len(p1Bytes) == 8 {
if entry, err := lib.WL3Read(h.Cfg.WL3Dir, p1Bytes); err == nil {
l0Bytes, err := hex.DecodeString(entry.L0)
if err == nil && len(l0Bytes) == 4 {
dbPath := filepath.Join(h.Cfg.DataDir, "clavitor-"+base64UrlEncode(l0Bytes))
openedDB, err := lib.OpenDB(dbPath)
if err == nil {
defer openedDB.Close()
ctx := context.WithValue(r.Context(), ctxDB, openedDB)
r = r.WithContext(ctx)
db = openedDB
}
}
}
}
}
// Try L1 first (from session)
if db == nil && req.L1 != "" {
l1Bytes, err := base64.RawURLEncoding.DecodeString(req.L1)
if err == nil && len(l1Bytes) >= 4 {
l0 := l1Bytes[:4] // Derive L0 from L1
dbPath := filepath.Join(h.Cfg.DataDir, "clavitor-"+base64UrlEncode(l0))
openedDB, err := lib.OpenDB(dbPath)
if err == nil {
defer openedDB.Close()
ctx := context.WithValue(r.Context(), ctxDB, openedDB)
r = r.WithContext(ctx)
db = openedDB
}
}
}
// Try L0 directly (from localStorage hint)
if db == nil && req.L0 != "" {
l0Bytes, err := base64.RawURLEncoding.DecodeString(req.L0)
if err == nil && len(l0Bytes) == 4 {
dbPath := filepath.Join(h.Cfg.DataDir, "clavitor-"+base64UrlEncode(l0Bytes))
openedDB, err := lib.OpenDB(dbPath)
if err == nil {
defer openedDB.Close()
ctx := context.WithValue(r.Context(), ctxDB, openedDB)
r = r.WithContext(ctx)
db = openedDB
}
}
}
}
// If still no vault, return error - vault identifier must be provided
if db == nil {
ErrorResponse(w, http.StatusNotFound, "no_vault", "No vault exists - registration required")
return
}
}
if db == nil {
ErrorResponse(w, http.StatusNotFound, "no_vault", "No vault exists")
return
}
creds, err := lib.GetWebAuthnCredentials(db)
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(db, challenge, "login")
// Hardcoded salt "Clavitor" for all vaults
hardcodedSalt := []byte("Clavitor")
var allowCreds []map[string]any
for _, c := range creds {
allowCreds = append(allowCreds, map[string]any{"type": "public-key", "id": c.CredentialID})
}
prfExt := map[string]any{"eval": map[string]any{"first": hardcodedSalt}}
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},
},
})
}
func (h *Handlers) AuthLoginComplete(w http.ResponseWriter, r *http.Request) {
if h.db(r) == nil {
ErrorResponse(w, http.StatusNotFound, "no_vault", "No vault exists")
return
}
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(h.db(r), 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
}
lib.UpdateWebAuthnSignCount(h.db(r), int64(cred.CredID), req.SignCount)
lib.AuditLog(h.db(r), &lib.AuditEvent{Action: "login", Actor: lib.ActorWeb, IPAddr: realIP(r)})
// Get all registered credential types for UI highlighting
creds, _ := lib.GetWebAuthnCredentials(h.db(r))
typeMap := make(map[string]bool)
for _, c := range creds {
if c.AuthenticatorAttachment != "" {
typeMap[c.AuthenticatorAttachment] = true
}
}
registeredTypes := make([]string, 0, len(typeMap))
for t := range typeMap {
registeredTypes = append(registeredTypes, t)
}
JSONResponse(w, http.StatusOK, map[string]any{
"status": "authenticated",
"registered_types": registeredTypes,
})
}
// AdminAuthBegin starts a WebAuthn assertion for admin operations (PRF tap required).
func (h *Handlers) AdminAuthBegin(w http.ResponseWriter, r *http.Request) {
if h.requireOwner(w, r) {
return
}
if h.db(r) == nil {
ErrorResponse(w, http.StatusNotFound, "no_vault", "No vault exists")
return
}
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(h.db(r), challenge, "admin-begin")
// Hardcoded salt "Clavitor" for admin auth
hardcodedSalt := []byte("Clavitor")
var allowCreds []map[string]any
for _, c := range creds {
allowCreds = append(allowCreds, map[string]any{"type": "public-key", "id": c.CredentialID})
}
prfExt := map[string]any{"eval": map[string]any{"first": hardcodedSalt}}
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},
},
})
}
// AdminAuthComplete verifies the WebAuthn assertion and returns a one-time admin token.
func (h *Handlers) AdminAuthComplete(w http.ResponseWriter, r *http.Request) {
if h.requireOwner(w, r) {
return
}
if h.db(r) == nil {
ErrorResponse(w, http.StatusNotFound, "no_vault", "No vault exists")
return
}
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(h.db(r), req.Challenge, "admin-begin"); 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
}
lib.UpdateWebAuthnSignCount(h.db(r), int64(cred.CredID), req.SignCount)
// Issue one-time admin token (valid 5 minutes, single use)
adminToken := make([]byte, 32)
rand.Read(adminToken)
h.storeChallenge(h.db(r), adminToken, "admin")
lib.AuditLog(h.db(r), &lib.AuditEvent{Action: "admin_auth", Actor: lib.ActorWeb, IPAddr: realIP(r)})
JSONResponse(w, http.StatusOK, map[string]string{"admin_token": hex.EncodeToString(adminToken)})
}
// ---------------------------------------------------------------------------
// Entry CRUD (scope-checked)
// ---------------------------------------------------------------------------
func (h *Handlers) ListEntries(w http.ResponseWriter, r *http.Request) {
agent := h.agent(r)
// List endpoint returns metadata only — never decrypted data.
// Full entry data is only available via GET /entries/{id} with scope checks.
entries, err := lib.EntryListMeta(h.db(r))
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries")
return
}
if entries == nil {
entries = []lib.Entry{}
}
entries = filterOutSystemTypes(entries)
entries = filterByScope(agent, entries)
JSONResponse(w, http.StatusOK, entries)
}
func (h *Handlers) GetEntry(w http.ResponseWriter, r *http.Request) {
agent := h.agent(r)
actor := ActorFromContext(r.Context())
entryID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID")
return
}
entry, err := lib.EntryGet(h.db(r), h.vk(r), entryID)
if err == lib.ErrNotFound {
ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
return
}
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry")
return
}
if entry.DeletedAt != nil {
ErrorResponse(w, http.StatusNotFound, "deleted", "Entry has been deleted")
return
}
if !lib.AgentCanAccess(agent, entry.Scopes) {
ErrorResponse(w, http.StatusForbidden, "forbidden", "Access denied")
return
}
// Per-agent unique-entries quota: repeated reads of the same entry are free,
// only distinct entries count toward the limit. Hour-limit hits trigger the
// strike-and-lock policy inside agentReadEntry.
if !agentReadEntry(agent, lib.IDToHex(int64(entry.EntryID)), h.db(r), h.vk(r)) {
ErrorResponse(w, http.StatusTooManyRequests, "rate_limited", "Agent rate limit exceeded")
return
}
if actor == lib.ActorAgent && entry.VaultData != nil {
stripL3Fields(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)
}
func (h *Handlers) CreateEntry(w http.ResponseWriter, r *http.Request) {
agent := h.agent(r)
actor := ActorFromContext(r.Context())
var req struct {
Type string `json:"type"`
Title string `json:"title"`
ParentID lib.HexID `json:"parent_id"`
Scopes string `json:"scopes"`
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 rejectAgentSystemWrite(w, r, req.Type) {
return
}
// Security: Agents can only create entries with scopes they have access to
if agent != nil && !lib.AgentCanAccess(agent, req.Scopes) {
ErrorResponse(w, http.StatusForbidden, "forbidden_scope", "Cannot create entry with scopes outside your access")
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, Scopes: req.Scopes, 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)
}
func (h *Handlers) CreateEntryBatch(w http.ResponseWriter, r *http.Request) {
actor := ActorFromContext(r.Context())
// Security: Agents are blocked entirely from batch import
// Batch import is a human UI convenience, agents should use single-entry API
if IsAgentRequest(r) {
ErrorResponse(w, http.StatusForbidden, "agent_forbidden", "Agents cannot use batch import")
return
}
var batch []struct {
Type string `json:"type"`
Title string `json:"title"`
Scopes string `json:"scopes"`
SourceModified int64 `json:"source_modified"` // Unix timestamp from source export
Data *lib.VaultData `json:"data"`
}
if err := json.NewDecoder(r.Body).Decode(&batch); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Expected JSON array")
return
}
db := h.db(r)
vk := h.vk(r)
created, updated, skipped := 0, 0, 0
for _, req := range batch {
if req.Title == "" {
continue
}
if req.Type == "" {
req.Type = lib.TypeCredential
}
// Security: Return 403 immediately on forbidden types (don't silently skip)
if req.Type == lib.TypeAgent || req.Type == lib.TypeScope {
ErrorResponse(w, http.StatusForbidden, "system_type", "Cannot create agent or scope entries via batch import")
return
}
// Get username from request data for proper matching
username := ""
if req.Data != nil {
for _, f := range req.Data.Fields {
if f.Label == "username" || f.Label == "email" {
username = f.Value
break
}
}
}
// Check for existing entry with same title+username
existing, _ := lib.EntrySearchFuzzy(db, vk, req.Title)
var primary *lib.Entry
primarySourceModified := int64(0)
for i := range existing {
if !strings.EqualFold(existing[i].Title, req.Title) {
continue
}
// Extract username from existing entry
existingUser := ""
existingModified := int64(0)
if existing[i].VaultData != nil {
for _, f := range existing[i].VaultData.Fields {
if f.Label == "username" || f.Label == "email" {
existingUser = f.Value
break
}
}
existingModified = existing[i].VaultData.SourceModified
}
// Match on both title and username
if strings.EqualFold(existingUser, username) {
primary = &existing[i]
primarySourceModified = existingModified
break
}
}
if primary != nil {
// Same title+user exists - check if new entry is newer
newModified := req.SourceModified
if newModified == 0 && req.Data != nil {
newModified = req.Data.SourceModified
}
if newModified > 0 && newModified <= primarySourceModified {
// New entry is older or same - skip it
skipped++
continue
}
// New entry is newer - update the existing entry (don't create alternate)
// Copy the entry ID to update in place
entry := &lib.Entry{
EntryID: primary.EntryID,
Type: req.Type,
Title: req.Title,
DataLevel: lib.DataLevelL1,
Scopes: req.Scopes,
VaultData: req.Data,
AlternateFor: 0, // Primary entry
}
// Preserve the original creation time
entry.CreatedAt = primary.CreatedAt
if lib.EntryUpdate(db, vk, entry) == nil {
updated++
}
} else {
// No existing entry - create as primary
entry := &lib.Entry{
Type: req.Type,
Title: req.Title,
DataLevel: lib.DataLevelL1,
Scopes: req.Scopes,
VaultData: req.Data,
}
if lib.EntryCreate(db, vk, entry) == nil {
created++
}
}
}
lib.AuditLog(db, &lib.AuditEvent{
Action: lib.ActionImport, Actor: actor, IPAddr: realIP(r),
Title: fmt.Sprintf("batch import: %d created, %d updated, %d skipped", created, updated, skipped),
})
JSONResponse(w, http.StatusCreated, map[string]any{"created": created, "updated": updated, "skipped": skipped})
}
func (h *Handlers) UpsertEntry(w http.ResponseWriter, r *http.Request) {
actor := ActorFromContext(r.Context())
var req struct {
Type string `json:"type"`
Title string `json:"title"`
ParentID lib.HexID `json:"parent_id"`
Scopes string `json:"scopes"`
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 rejectAgentSystemWrite(w, r, req.Type) {
return
}
if req.Title == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_title", "Title is required")
return
}
if req.Type == "" {
req.Type = lib.TypeCredential
}
existing, err := lib.EntrySearchFuzzy(h.db(r), h.vk(r), req.Title)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "search_failed", "Failed to search entries")
return
}
var match *lib.Entry
for i := range existing {
if strings.EqualFold(existing[i].Title, req.Title) {
match = &existing[i]
break
}
}
if match != nil {
match.Title = req.Title
match.Type = req.Type
match.ParentID = req.ParentID
if req.Data != nil {
// Security: Agents cannot overwrite L3 fields
if IsAgentRequest(r) {
rejectAgentL3Overwrite(w, r, match.VaultData, req.Data)
}
match.VaultData = req.Data
}
if err := lib.EntryUpdate(h.db(r), h.vk(r), match); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update entry")
return
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
EntryID: match.EntryID, Title: match.Title, Action: lib.ActionUpdate,
Actor: actor, IPAddr: realIP(r),
})
JSONResponse(w, http.StatusOK, match)
} else {
entry := &lib.Entry{
Type: req.Type, Title: req.Title, ParentID: req.ParentID,
DataLevel: lib.DataLevelL1, Scopes: req.Scopes, 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)
}
}
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
}
if rejectAgentSystemWrite(w, r, req.Type) {
return
}
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
}
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 {
// Agents cannot overwrite L3 fields — preserve existing L3 values
if IsAgentRequest(r) {
rejectAgentL3Overwrite(w, r, existing.VaultData, req.Data)
}
existing.VaultData = req.Data
}
// Agents cannot change entry type to a system type
if IsAgentRequest(r) && (existing.Type == lib.TypeAgent || existing.Type == lib.TypeScope) {
ErrorResponse(w, http.StatusForbidden, "system_type", "Agents cannot modify agent or scope records")
return
}
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)
}
func (h *Handlers) DeleteEntry(w http.ResponseWriter, r *http.Request) {
agent := h.agent(r)
actor := ActorFromContext(r.Context())
entryID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID")
return
}
entry, err := lib.EntryGet(h.db(r), h.vk(r), entryID)
if err == lib.ErrNotFound {
ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
return
}
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry")
return
}
// Security: Check scope access before deletion
if !lib.AgentCanAccess(agent, entry.Scopes) {
ErrorResponse(w, http.StatusForbidden, "forbidden", "Access denied")
return
}
if err := lib.EntryDelete(h.db(r), entryID); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete entry")
return
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
EntryID: lib.HexID(entryID), Title: entry.Title, Action: lib.ActionDelete,
Actor: actor, IPAddr: realIP(r),
})
JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// ---------------------------------------------------------------------------
// Search (scope-filtered)
// ---------------------------------------------------------------------------
func (h *Handlers) SearchEntries(w http.ResponseWriter, r *http.Request) {
agent := h.agent(r)
actor := ActorFromContext(r.Context())
query := r.URL.Query().Get("q")
if query == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_query", "Query parameter 'q' is required")
return
}
entries, err := lib.EntrySearchFuzzy(h.db(r), h.vk(r), query)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "search_failed", "Search failed")
return
}
if entries == nil {
entries = []lib.Entry{}
}
entries = filterOutSystemTypes(entries)
entries = filterByScope(agent, entries)
if actor == lib.ActorAgent {
for i := range entries {
if entries[i].VaultData != nil {
stripL3Fields(entries[i].VaultData)
}
}
}
// Security: Log search to audit trail
lib.AuditLog(h.db(r), &lib.AuditEvent{
Action: lib.ActionRead, Actor: actor, IPAddr: realIP(r),
Title: fmt.Sprintf("search: %q (%d results)", query, len(entries)),
})
JSONResponse(w, http.StatusOK, entries)
}
// ---------------------------------------------------------------------------
// Password Generator
// ---------------------------------------------------------------------------
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
// ---------------------------------------------------------------------------
func (h *Handlers) GetTOTP(w http.ResponseWriter, r *http.Request) {
agent := h.agent(r)
actor := ActorFromContext(r.Context())
entryID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID")
return
}
entry, err := lib.EntryGet(h.db(r), h.vk(r), entryID)
if err == lib.ErrNotFound {
ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
return
}
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to get entry")
return
}
if !lib.AgentCanAccess(agent, entry.Scopes) {
ErrorResponse(w, http.StatusForbidden, "forbidden", "Access denied")
return
}
if entry.VaultData == nil {
ErrorResponse(w, http.StatusBadRequest, "no_totp", "Entry has no TOTP field")
return
}
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
}
totpSeed = strings.ToUpper(strings.ReplaceAll(totpSeed, " ", ""))
code, err := totp.GenerateCode(totpSeed, time.Now())
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_totp", "Invalid TOTP seed")
return
}
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})
}
// HandleCredentialWorked marks a credential as verified working and deprecates alternates.
// Called by CLI/extension when a password successfully authenticates.
func (h *Handlers) HandleCredentialWorked(w http.ResponseWriter, r *http.Request) {
entryID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID")
return
}
if err := lib.EntryMarkWorked(h.db(r), entryID); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "mark_failed", "Failed to mark credential as worked")
return
}
JSONResponse(w, http.StatusOK, map[string]string{"status": "ok"})
}
// HandleListAlternates returns all alternate credentials for a given entry.
// This allows CLI/extension to try multiple passwords for the same site+user.
func (h *Handlers) HandleListAlternates(w http.ResponseWriter, r *http.Request) {
agent := h.agent(r)
entryID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID")
return
}
entries, err := lib.EntryListAlternates(h.db(r), h.vk(r), entryID)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list alternates")
return
}
// Filter by agent scope and per-agent unique-entries quota.
// Each alternate counts as a distinct entry — but if the agent has fetched
// some of them before in the current window, those are free.
var result []lib.Entry
for _, e := range entries {
if !lib.AgentCanAccess(agent, e.Scopes) {
continue
}
if !agentReadEntry(agent, lib.IDToHex(int64(e.EntryID)), h.db(r), h.vk(r)) {
ErrorResponse(w, http.StatusTooManyRequests, "rate_limited", "Agent rate limit exceeded")
return
}
result = append(result, e)
}
JSONResponse(w, http.StatusOK, map[string]any{"alternates": result})
}
func (h *Handlers) MatchURL(w http.ResponseWriter, r *http.Request) {
agent := h.agent(r)
urlStr := r.URL.Query().Get("url")
if urlStr == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_url", "URL parameter required")
return
}
domain := extractDomain(urlStr)
entries, err := lib.EntryList(h.db(r), h.vk(r), nil)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries")
return
}
// Group entries by title+username to handle alternates
type credentialKey struct {
title string
username string
}
grouped := make(map[credentialKey][]lib.Entry)
for _, entry := range entries {
if !lib.AgentCanAccess(agent, entry.Scopes) {
continue
}
if entry.VaultData == nil {
continue
}
for _, u := range entry.VaultData.URLs {
if strings.Contains(u, domain) || strings.Contains(domain, extractDomain(u)) {
// Per-agent unique-entries quota: matching this entry counts as
// reading it. Repeats inside the same window are free.
if !agentReadEntry(agent, lib.IDToHex(int64(entry.EntryID)), h.db(r), h.vk(r)) {
ErrorResponse(w, http.StatusTooManyRequests, "rate_limited", "Agent rate limit exceeded")
return
}
// Extract username for grouping
username := ""
for _, f := range entry.VaultData.Fields {
if f.Label == "username" || f.Label == "email" {
username = f.Value
break
}
}
key := credentialKey{title: strings.ToLower(entry.Title), username: strings.ToLower(username)}
grouped[key] = append(grouped[key], entry)
break
}
}
}
// Build response: each credential group sorted by verified_at (verified first)
var results []map[string]any
for _, group := range grouped {
// Sort within group: verified first, then by created_at desc
for i := range group {
for j := i + 1; j < len(group); j++ {
iVerified := group[i].VerifiedAt != nil
jVerified := group[j].VerifiedAt != nil
if !iVerified && jVerified {
group[i], group[j] = group[j], group[i]
} else if iVerified == jVerified && group[i].CreatedAt < group[j].CreatedAt {
group[i], group[j] = group[j], group[i]
}
}
}
// Primary is first (verified or newest)
primary := group[0]
var alternates []map[string]any
for i := 1; i < len(group); i++ {
alt := group[i]
alternates = append(alternates, map[string]any{
"entry_id": alt.EntryID,
"title": alt.Title,
"verified_at": alt.VerifiedAt,
"created_at": alt.CreatedAt,
})
}
result := map[string]any{
"entry_id": primary.EntryID,
"title": primary.Title,
"type": primary.Type,
"scopes": primary.Scopes,
"verified_at": primary.VerifiedAt,
"created_at": primary.CreatedAt,
"fields": primary.VaultData.Fields,
"urls": primary.VaultData.URLs,
}
if len(alternates) > 0 {
result["alternates"] = alternates
result["alternate_count"] = len(alternates)
}
results = append(results, result)
}
if results == nil {
results = []map[string]any{}
}
JSONResponse(w, http.StatusOK, results)
}
// ---------------------------------------------------------------------------
// Audit Log
// ---------------------------------------------------------------------------
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)
}
// ---------------------------------------------------------------------------
// Agent Management (OWNER ONLY — agents cannot call these)
// ---------------------------------------------------------------------------
func (h *Handlers) HandleCreateAgent(w http.ResponseWriter, r *http.Request) {
if h.requireAdmin(w, r) {
return
}
var req struct {
Name string `json:"name"`
Scopes string `json:"scopes"`
AllAccess bool `json:"all_access"`
Admin bool `json:"admin"`
RateLimitMinute int `json:"rate_limit_minute"` // 0 → use safe default
RateLimitHour int `json:"rate_limit_hour"` // 0 → use safe default
}
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
}
// Safe defaults for the unique-entries quota. Real agent flows touch 13
// credentials per task; defaults catch a harvester within minutes without
// breaking normal use. Owner can override per-agent at creation or via
// HandleUpdateAgent later.
if req.RateLimitMinute == 0 {
req.RateLimitMinute = 3
}
if req.RateLimitHour == 0 {
req.RateLimitHour = 10
}
// DESIGN NOTE: Empty scopes with all_access=false is intentional.
// This allows users to create a "blocked" agent that cannot access any entries,
// effectively quarantining a rogue agent without deleting it.
agent, err := lib.AgentCreate(h.db(r), h.vk(r), h.l0(r), lib.AgentCreateOpts{
Name: req.Name,
Scopes: req.Scopes,
AllAccess: req.AllAccess,
Admin: req.Admin,
RateLimit: req.RateLimitMinute,
RateLimitHour: req.RateLimitHour,
})
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "create_failed", "Failed to create agent")
return
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
Action: lib.ActionAgentCreate, Actor: ActorFromContext(r.Context()),
IPAddr: realIP(r), Title: agent.Name,
})
JSONResponse(w, http.StatusCreated, map[string]any{
"agent_id": agent.AgentID,
"name": agent.Name,
"scopes": agent.Scopes,
"all_access": agent.AllAccess,
"admin": agent.Admin,
// Note: credential is generated client-side by web UI
})
}
func (h *Handlers) HandleListAgents(w http.ResponseWriter, r *http.Request) {
// Only web users (not agents) can list agents
if h.requireOwner(w, r) {
return
}
entries, err := lib.EntryList(h.db(r), h.vk(r), nil)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list agents")
return
}
var agents []map[string]any
for _, e := range entries {
if e.Type != lib.TypeAgent || e.VaultData == nil {
continue
}
// Split allowed_ips into a list for the UI.
var ipList []string
if e.VaultData.AllowedIPs != "" {
for _, ip := range strings.Split(e.VaultData.AllowedIPs, ",") {
ip = strings.TrimSpace(ip)
if ip != "" {
ipList = append(ipList, ip)
}
}
}
status := "active"
if e.VaultData.Locked {
status = "locked"
}
agents = append(agents, map[string]any{
"id": lib.IDToHex(int64(e.EntryID)), // for /api/agents/{id}/* paths
"entry_id": e.EntryID, // legacy numeric, kept for compat
"agent_id": e.VaultData.AgentID,
"name": e.Title,
"scopes": e.VaultData.Scopes,
"all_access": e.VaultData.AllAccess,
"admin": e.VaultData.Admin,
"status": status,
"locked": e.VaultData.Locked,
"last_strike_at": e.VaultData.LastStrikeAt,
"rate_limit_minute": e.VaultData.RateLimit,
"rate_limit_hour": e.VaultData.RateLimitHour,
"ip_whitelist": ipList,
"created_at": e.CreatedAt,
})
}
if agents == nil {
agents = []map[string]any{}
}
JSONResponse(w, http.StatusOK, agents)
}
func (h *Handlers) HandleDeleteAgent(w http.ResponseWriter, r *http.Request) {
if h.requireAdmin(w, r) {
return
}
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 != nil {
if err == lib.ErrNotFound {
ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
} else {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to retrieve agent")
}
return
}
if entry.Type != lib.TypeAgent {
ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
return
}
if err := lib.EntryDelete(h.db(r), entryID); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "delete_failed", "Failed to delete agent")
return
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
Action: lib.ActionAgentDelete, Actor: ActorFromContext(r.Context()),
IPAddr: realIP(r), Title: entry.Title,
})
JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
}
func (h *Handlers) HandleUpdateAgent(w http.ResponseWriter, r *http.Request) {
if h.requireAdmin(w, r) {
return
}
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 != nil {
if err == lib.ErrNotFound {
ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
} else {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to retrieve agent")
}
return
}
if entry.Type != lib.TypeAgent {
ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
return
}
var req struct {
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
}
// Update allowed fields only (NOT admin, all_access, or scopes)
if entry.VaultData == nil {
ErrorResponse(w, http.StatusInternalServerError, "agent_data_missing", "Agent data not found")
return
}
if req.IPWhitelist != nil {
entry.VaultData.AllowedIPs = strings.Join(req.IPWhitelist, ",")
}
if req.RateLimitMinute > 0 {
entry.VaultData.RateLimit = req.RateLimitMinute
}
if req.RateLimitHour > 0 {
entry.VaultData.RateLimitHour = req.RateLimitHour
}
// Re-encrypt and update entry
if err := lib.EntryUpdate(h.db(r), h.vk(r), entry); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update agent")
return
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
Action: lib.ActionAgentUpdate, Actor: ActorFromContext(r.Context()),
IPAddr: realIP(r), Title: entry.Title,
})
JSONResponse(w, http.StatusOK, map[string]string{"status": "updated"})
}
// HandleUnlockAgent clears the locked state on an agent. Requires admin token
// (PRF tap). Resets LastStrikeAt so the agent gets a clean strike clock.
func (h *Handlers) HandleUnlockAgent(w http.ResponseWriter, r *http.Request) {
if h.requireAdmin(w, r) {
return
}
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 != nil {
if err == lib.ErrNotFound {
ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
} else {
ErrorResponse(w, http.StatusInternalServerError, "get_failed", "Failed to retrieve agent")
}
return
}
if entry.Type != lib.TypeAgent {
ErrorResponse(w, http.StatusNotFound, "not_found", "Agent not found")
return
}
if err := lib.AgentUnlock(h.db(r), h.vk(r), lib.HexID(entryID)); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "unlock_failed", "Failed to unlock agent")
return
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
Action: "agent_unlocked", Actor: ActorFromContext(r.Context()),
IPAddr: realIP(r), Title: entry.Title,
})
JSONResponse(w, http.StatusOK, map[string]string{"status": "unlocked"})
}
func (h *Handlers) HandleUpdateEntryScopes(w http.ResponseWriter, r *http.Request) {
if h.requireAdmin(w, r) {
return
}
entryID, err := lib.HexToID(chi.URLParam(r, "id"))
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_id", "Invalid entry ID")
return
}
var req struct {
Scopes string `json:"scopes"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
// Security: Validate scope format. Invalid format indicates possible data corruption.
if err := validateScopeFormat(req.Scopes); err != nil {
// Community: Log to stderr. Commercial: Also POSTs to telemetry endpoint.
edition.Current.AlertOperator(r.Context(), "data_corruption",
"Invalid scope format detected", map[string]any{"entry_id": entryID, "scopes": req.Scopes, "error": err.Error()})
ErrorResponse(w, http.StatusBadRequest, "invalid_scopes", "Invalid scope format - possible data corruption")
return
}
if err := lib.EntryUpdateScopes(h.db(r), entryID, req.Scopes); err != nil {
if err == lib.ErrNotFound {
ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
return
}
ErrorResponse(w, http.StatusInternalServerError, "update_failed", "Failed to update scopes")
return
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
EntryID: lib.HexID(entryID), Action: lib.ActionScopeUpdate,
Actor: ActorFromContext(r.Context()), IPAddr: realIP(r),
})
JSONResponse(w, http.StatusOK, map[string]string{"status": "updated"})
}
// ---------------------------------------------------------------------------
// WebAuthn credential management
// ---------------------------------------------------------------------------
func (h *Handlers) HandleWebAuthnRegisterBegin(w http.ResponseWriter, r *http.Request) {
challenge := make([]byte, 32)
rand.Read(challenge)
h.storeChallenge(h.db(r), challenge, "webauthn-register")
JSONResponse(w, http.StatusOK, 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": "Vault Owner",
},
"pubKeyCredParams": []map[string]any{
{"type": "public-key", "alg": -7},
{"type": "public-key", "alg": -257},
},
"authenticatorSelection": map[string]any{
"residentKey": "preferred", "userVerification": "required",
},
"extensions": map[string]any{"prf": map[string]any{}},
},
})
}
func (h *Handlers) HandleWebAuthnRegisterComplete(w http.ResponseWriter, r *http.Request) {
var req struct {
CredID string `json:"cred_id"`
PublicKey []byte `json:"public_key"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
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,
}
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})
}
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)
h.storeChallenge(h.db(r), challenge, "webauthn-auth")
// Hardcoded salt "Clavitor" for WebAuthn auth
hardcodedSalt := []byte("Clavitor")
var allowCreds []map[string]any
for _, c := range creds {
allowCreds = append(allowCreds, map[string]any{"type": "public-key", "id": c.CredID})
}
prfExt := map[string]any{"eval": map[string]any{"first": hardcodedSalt}}
JSONResponse(w, http.StatusOK, map[string]any{
"publicKey": map[string]any{
"challenge": challenge, "allowCredentials": allowCreds,
"userVerification": "required", "extensions": map[string]any{"prf": prfExt},
},
})
}
func (h *Handlers) HandleWebAuthnAuthComplete(w http.ResponseWriter, r *http.Request) {
var req struct {
Challenge []byte `json:"challenge"`
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
}
// Security: Verify the challenge was issued by us
if err := h.consumeChallenge(h.db(r), req.Challenge, "webauthn-auth"); err != nil {
ErrorResponse(w, http.StatusUnauthorized, "invalid_challenge", "Challenge verification failed")
return
}
lib.UpdateWebAuthnSignCount(h.db(r), int64(req.CredID), req.SignCount)
JSONResponse(w, http.StatusOK, map[string]string{"status": "authenticated"})
}
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)
}
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"})
}
// ---------------------------------------------------------------------------
// Backups
// ---------------------------------------------------------------------------
func (h *Handlers) ListBackups(w http.ResponseWriter, r *http.Request) {
backups := lib.ListBackups(h.Cfg.DataDir)
JSONResponse(w, http.StatusOK, backups)
}
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)
}
func (h *Handlers) RestoreBackup(w http.ResponseWriter, r *http.Request) {
if h.requireAdmin(w, r) {
return
}
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
}
lib.AuditLog(h.db(r), &lib.AuditEvent{
Action: lib.ActionBackupRestore, Actor: ActorFromContext(r.Context()),
IPAddr: realIP(r), Title: req.Name,
})
JSONResponse(w, http.StatusOK, map[string]string{"status": "restored", "name": req.Name})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// stripL3Fields removes L3 (client-only) fields from agent responses.
// L2 fields are returned encrypted — agents decrypt with their L2 key.
func stripL3Fields(vd *lib.VaultData) {
for i := range vd.Fields {
if vd.Fields[i].Tier >= 3 {
vd.Fields[i].Value = "[L3 — hardware key required]"
}
}
}
func extractDomain(urlStr string) string {
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
}
// validateScopeFormat validates that scopes is comma-separated hex IDs (16 chars each).
// Empty string is valid (no scopes). Returns error for invalid format.
func validateScopeFormat(scopes string) error {
if scopes == "" {
return nil
}
for _, s := range strings.Split(scopes, ",") {
s = strings.TrimSpace(s)
if len(s) != 16 {
return fmt.Errorf("invalid scope ID length: %q (expected 16 hex chars)", s)
}
for _, c := range s {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) {
return fmt.Errorf("invalid scope ID characters: %q (expected hex only)", s)
}
}
}
return nil
}
func generateTOTPSecret() string {
b := make([]byte, 20)
rand.Read(b)
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
}