1410 lines
44 KiB
Go
1410 lines
44 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base32"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/johanj/clavitor/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(data []byte, typ string) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
key := fmt.Sprintf("%x", data)
|
|
h.challenges[key] = challenge{Data: data, Type: typ, CreatedAt: time.Now()}
|
|
}
|
|
|
|
func (h *Handlers) consumeChallenge(data []byte, typ string) error {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
key := 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// requireOwner rejects agent requests. Returns true if blocked.
|
|
func (h *Handlers) requireOwner(w http.ResponseWriter, r *http.Request) bool {
|
|
if IsAgentRequest(r) {
|
|
ErrorResponse(w, http.StatusForbidden, "owner_only", "This operation requires web authentication")
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// requireAdmin rejects agents AND requires a fresh WebAuthn challenge proof.
|
|
// The client must first call /api/auth/admin/begin, do a PRF tap, call
|
|
// /api/auth/admin/complete, then pass the resulting admin token in X-Admin-Token.
|
|
//
|
|
// SECURITY NOTE: The admin token is consumed immediately upon validation.
|
|
// If the subsequent operation fails (DB error, validation error, etc.), the token
|
|
// is gone but the operation didn't complete. The user must perform a fresh PRF tap
|
|
// to retry.
|
|
//
|
|
// This was reviewed and accepted because:
|
|
// - 5-10 minute token lifetime makes re-auth acceptable
|
|
// - It's a UX inconvenience, not a security vulnerability
|
|
// - Deferring consumption until operation success would require transaction-like complexity
|
|
// - Rare edge case: requires admin operation to fail after token validation
|
|
func (h *Handlers) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
|
if h.requireOwner(w, r) {
|
|
return true
|
|
}
|
|
token := r.Header.Get("X-Admin-Token")
|
|
if token == "" {
|
|
ErrorResponse(w, http.StatusForbidden, "admin_required", "Admin operation requires PRF authentication")
|
|
return true
|
|
}
|
|
tokenBytes, err := hex.DecodeString(token)
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusForbidden, "invalid_token", "Invalid admin token")
|
|
return true
|
|
}
|
|
// Token is consumed immediately. See SECURITY NOTE above.
|
|
if err := h.consumeChallenge(tokenBytes, "admin"); err != nil {
|
|
ErrorResponse(w, http.StatusForbidden, "expired_token", "Admin token expired or already used")
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// rejectAgentSystemWrite blocks agents from creating/updating agent or scope entries.
|
|
func rejectAgentSystemWrite(w http.ResponseWriter, r *http.Request, entryType string) bool {
|
|
if !IsAgentRequest(r) {
|
|
return false
|
|
}
|
|
if entryType == lib.TypeAgent || entryType == lib.TypeScope {
|
|
ErrorResponse(w, http.StatusForbidden, "system_type", "Agents cannot modify agent or scope records")
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// rejectAgentL3Overwrite blocks agents from overwriting L3 fields with lower-tier data.
|
|
// If an existing field is tier 3, the agent's update must keep the same value.
|
|
func rejectAgentL3Overwrite(w http.ResponseWriter, existing, incoming *lib.VaultData) bool {
|
|
if existing == nil || incoming == nil {
|
|
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
|
|
}
|
|
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
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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"
|
|
|
|
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) 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 {
|
|
JSONResponse(w, http.StatusOK, map[string]any{"state": "fresh", "credentials": 0})
|
|
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(challengeBytes, "register")
|
|
|
|
JSONResponse(w, http.StatusOK, map[string]any{
|
|
"publicKey": map[string]any{
|
|
"challenge": challengeBytes,
|
|
"rp": map[string]string{"name": "Clavitor", "id": rpID(r)},
|
|
"user": map[string]any{
|
|
"id": []byte("clavitor-owner"), "name": "vault-owner", "displayName": "Vault Owner",
|
|
},
|
|
"pubKeyCredParams": []map[string]any{
|
|
{"type": "public-key", "alg": -7},
|
|
{"type": "public-key", "alg": -257},
|
|
},
|
|
"authenticatorSelection": map[string]any{
|
|
"residentKey": "preferred", "userVerification": "required",
|
|
},
|
|
"attestation": "none",
|
|
"extensions": map[string]any{"prf": map[string]any{}},
|
|
},
|
|
})
|
|
}
|
|
|
|
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"`
|
|
PRFSalt []byte `json:"prf_salt"`
|
|
Name string `json:"name"`
|
|
L1Key []byte `json:"l1_key"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
|
|
return
|
|
}
|
|
if err := h.consumeChallenge(req.Challenge, "register"); err != nil {
|
|
ErrorResponse(w, http.StatusBadRequest, "invalid_challenge", "Challenge verification failed")
|
|
return
|
|
}
|
|
|
|
db := h.db(r)
|
|
if db == nil && len(req.PublicKey) > 0 {
|
|
var dbName string
|
|
if len(req.L1Key) >= 4 {
|
|
dbName = "clavitor-" + base64UrlEncode(req.L1Key[:4])
|
|
} else {
|
|
hash := sha256.Sum256(req.PublicKey)
|
|
dbName = "clavitor-" + base64UrlEncode(hash[:4])
|
|
}
|
|
dbPath := filepath.Join(h.Cfg.DataDir, dbName)
|
|
newDB, err := lib.OpenDB(dbPath)
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "db_create_failed", "Failed to create vault")
|
|
return
|
|
}
|
|
defer newDB.Close()
|
|
if err := lib.MigrateDB(newDB); err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "db_migrate_failed", "Failed to initialize vault")
|
|
return
|
|
}
|
|
db = newDB
|
|
log.Printf("Vault created: %s", dbPath)
|
|
}
|
|
|
|
if req.Name == "" {
|
|
req.Name = "Primary Passkey"
|
|
}
|
|
cred := &lib.WebAuthnCredential{
|
|
CredID: lib.HexID(lib.NewID()), Name: req.Name,
|
|
CredentialID: req.CredentialID, PublicKey: req.PublicKey, PRFSalt: req.PRFSalt,
|
|
}
|
|
if err := lib.StoreWebAuthnCredential(db, cred); err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "store_failed", "Failed to store credential")
|
|
return
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
func (h *Handlers) AuthLoginBegin(w http.ResponseWriter, r *http.Request) {
|
|
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(challenge, "login")
|
|
|
|
var allowCreds []map[string]any
|
|
var prfSalt []byte
|
|
for _, c := range creds {
|
|
allowCreds = append(allowCreds, map[string]any{"type": "public-key", "id": c.CredentialID})
|
|
if len(c.PRFSalt) > 0 {
|
|
prfSalt = c.PRFSalt
|
|
}
|
|
}
|
|
prfExt := map[string]any{}
|
|
if len(prfSalt) > 0 {
|
|
prfExt["eval"] = map[string]any{"first": prfSalt}
|
|
}
|
|
|
|
JSONResponse(w, http.StatusOK, map[string]any{
|
|
"publicKey": map[string]any{
|
|
"challenge": challenge, "rpId": rpID(r), "allowCredentials": allowCreds,
|
|
"userVerification": "required", "extensions": map[string]any{"prf": prfExt},
|
|
},
|
|
})
|
|
}
|
|
|
|
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(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)})
|
|
JSONResponse(w, http.StatusOK, map[string]string{"status": "authenticated"})
|
|
}
|
|
|
|
// 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(challenge, "admin-begin")
|
|
|
|
var allowCreds []map[string]any
|
|
var prfSalt []byte
|
|
for _, c := range creds {
|
|
allowCreds = append(allowCreds, map[string]any{"type": "public-key", "id": c.CredentialID})
|
|
if len(c.PRFSalt) > 0 {
|
|
prfSalt = c.PRFSalt
|
|
}
|
|
}
|
|
prfExt := map[string]any{}
|
|
if len(prfSalt) > 0 {
|
|
prfExt["eval"] = map[string]any{"first": prfSalt}
|
|
}
|
|
|
|
JSONResponse(w, http.StatusOK, map[string]any{
|
|
"publicKey": map[string]any{
|
|
"challenge": challenge, "rpId": rpID(r), "allowCredentials": allowCreds,
|
|
"userVerification": "required", "extensions": map[string]any{"prf": prfExt},
|
|
},
|
|
})
|
|
}
|
|
|
|
// 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(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(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
|
|
}
|
|
if actor == lib.ActorAgent && entry.VaultData != nil {
|
|
stripL2Fields(entry.VaultData)
|
|
}
|
|
lib.AuditLog(h.db(r), &lib.AuditEvent{
|
|
EntryID: entry.EntryID, Title: entry.Title, Action: lib.ActionRead,
|
|
Actor: actor, IPAddr: realIP(r),
|
|
})
|
|
JSONResponse(w, http.StatusOK, entry)
|
|
}
|
|
|
|
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"`
|
|
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 := 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
|
|
}
|
|
// Upsert: find existing by title, update if exists, create if not
|
|
existing, _ := lib.EntrySearchFuzzy(db, vk, req.Title)
|
|
var match *lib.Entry
|
|
for i := range existing {
|
|
if strings.EqualFold(existing[i].Title, req.Title) {
|
|
match = &existing[i]
|
|
break
|
|
}
|
|
}
|
|
if match != nil {
|
|
match.Type = req.Type
|
|
if req.Data != nil {
|
|
// Security: Preserve L3 fields during batch update
|
|
rejectAgentL3Overwrite(w, match.VaultData, req.Data)
|
|
match.VaultData = req.Data
|
|
}
|
|
if lib.EntryUpdate(db, vk, match) == nil {
|
|
updated++
|
|
}
|
|
} else {
|
|
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", created, updated),
|
|
})
|
|
JSONResponse(w, http.StatusCreated, map[string]any{"created": created, "updated": updated})
|
|
}
|
|
|
|
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, 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, 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 {
|
|
stripL2Fields(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})
|
|
}
|
|
|
|
func (h *Handlers) MatchURL(w http.ResponseWriter, r *http.Request) {
|
|
agent := h.agent(r)
|
|
urlStr := r.URL.Query().Get("url")
|
|
if urlStr == "" {
|
|
ErrorResponse(w, http.StatusBadRequest, "missing_url", "URL parameter required")
|
|
return
|
|
}
|
|
domain := extractDomain(urlStr)
|
|
entries, err := lib.EntryList(h.db(r), h.vk(r), nil)
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "list_failed", "Failed to list entries")
|
|
return
|
|
}
|
|
var matches []lib.Entry
|
|
for _, entry := range entries {
|
|
if !lib.AgentCanAccess(agent, entry.Scopes) {
|
|
continue
|
|
}
|
|
if entry.VaultData == nil {
|
|
continue
|
|
}
|
|
for _, u := range entry.VaultData.URLs {
|
|
if strings.Contains(u, domain) || strings.Contains(domain, extractDomain(u)) {
|
|
matches = append(matches, entry)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if matches == nil {
|
|
matches = []lib.Entry{}
|
|
}
|
|
JSONResponse(w, http.StatusOK, matches)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Audit Log
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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"`
|
|
}
|
|
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
|
|
}
|
|
// 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, credential, err := lib.AgentCreate(h.db(r), h.vk(r), h.l0(r), req.Name, req.Scopes, req.AllAccess, req.Admin)
|
|
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,
|
|
"credential": credential,
|
|
})
|
|
}
|
|
|
|
func (h *Handlers) HandleListAgents(w http.ResponseWriter, r *http.Request) {
|
|
if h.requireAdmin(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
|
|
}
|
|
agents = append(agents, map[string]any{
|
|
"agent_id": e.VaultData.AgentID,
|
|
"name": e.Title,
|
|
"scopes": e.VaultData.Scopes,
|
|
"all_access": e.VaultData.AllAccess,
|
|
"admin": e.VaultData.Admin,
|
|
"entry_id": e.EntryID,
|
|
"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, _ := lib.EntryGet(h.db(r), h.vk(r), entryID)
|
|
if entry == nil || 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) 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(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"`
|
|
PRFSalt []byte `json:"prf_salt"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
|
|
return
|
|
}
|
|
credentialID, err := base64.RawURLEncoding.DecodeString(req.CredID)
|
|
if err != nil {
|
|
credentialID = []byte{}
|
|
}
|
|
cred := &lib.WebAuthnCredential{
|
|
CredID: lib.HexID(lib.NewID()), Name: req.Name,
|
|
PublicKey: req.PublicKey, CredentialID: credentialID, PRFSalt: req.PRFSalt,
|
|
}
|
|
if err := lib.StoreWebAuthnCredential(h.db(r), cred); err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "store_failed", "Failed to store credential")
|
|
return
|
|
}
|
|
JSONResponse(w, http.StatusCreated, map[string]any{"status": "registered", "cred_id": cred.CredID})
|
|
}
|
|
|
|
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(challenge, "webauthn-auth")
|
|
|
|
var allowCreds []map[string]any
|
|
var prfSalt []byte
|
|
for _, c := range creds {
|
|
allowCreds = append(allowCreds, map[string]any{"type": "public-key", "id": c.CredID})
|
|
if len(c.PRFSalt) > 0 {
|
|
prfSalt = c.PRFSalt
|
|
}
|
|
}
|
|
// Security: All credentials must have PRF enabled. No non-PRF fallbacks.
|
|
if len(prfSalt) == 0 {
|
|
ErrorResponse(w, http.StatusInternalServerError, "no_prf", "No PRF-enabled credentials found")
|
|
return
|
|
}
|
|
prfExt := map[string]any{"eval": map[string]any{"first": prfSalt}}
|
|
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(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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func stripL2Fields(vd *lib.VaultData) {
|
|
for i := range vd.Fields {
|
|
if vd.Fields[i].L2 {
|
|
vd.Fields[i].Value = "[REDACTED — not available to agents]"
|
|
}
|
|
}
|
|
}
|
|
|
|
func extractDomain(urlStr string) string {
|
|
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)
|
|
}
|