1908 lines
61 KiB
Go
1908 lines
61 KiB
Go
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 1–3
|
||
// 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)
|
||
}
|