package lib import ( "crypto/rand" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "strings" "time" _ "github.com/mattn/go-sqlite3" ) var ( ErrNotFound = errors.New("not found") ErrVersionConflict = errors.New("version conflict: entry was modified") ) const schema = ` CREATE TABLE IF NOT EXISTS entries ( entry_id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL DEFAULT 0, type TEXT NOT NULL, title TEXT NOT NULL, title_idx BLOB NOT NULL, data BLOB NOT NULL, data_level INTEGER NOT NULL DEFAULT 1, scopes TEXT NOT NULL DEFAULT '0000', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, version INTEGER NOT NULL DEFAULT 1, deleted_at INTEGER, checksum INTEGER, replicated_at INTEGER, replication_dirty BOOLEAN DEFAULT 0, alternate_for INTEGER, -- points to primary entry_id if this is an alternate password verified_at INTEGER -- timestamp when this password was confirmed working ); CREATE INDEX IF NOT EXISTS idx_entries_parent ON entries(parent_id); CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type); CREATE INDEX IF NOT EXISTS idx_entries_title_idx ON entries(title_idx); CREATE INDEX IF NOT EXISTS idx_entries_deleted ON entries(deleted_at); CREATE INDEX IF NOT EXISTS idx_entries_dirty ON entries(replication_dirty) WHERE replication_dirty = 1; CREATE INDEX IF NOT EXISTS idx_entries_alternate ON entries(alternate_for) WHERE alternate_for IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_entries_verified ON entries(verified_at) WHERE verified_at IS NOT NULL; CREATE TABLE IF NOT EXISTS audit_log ( event_id INTEGER PRIMARY KEY, entry_id INTEGER, title TEXT, action TEXT NOT NULL, actor TEXT NOT NULL, ip_addr TEXT, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_audit_entry ON audit_log(entry_id); CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at); CREATE TABLE IF NOT EXISTS webauthn_credentials ( cred_id INTEGER PRIMARY KEY, name TEXT NOT NULL, public_key BLOB NOT NULL, credential_id BLOB NOT NULL DEFAULT X'', sign_count INTEGER NOT NULL DEFAULT 0, authenticator_attachment TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS webauthn_challenges ( challenge BLOB PRIMARY KEY, type TEXT NOT NULL, created_at INTEGER NOT NULL ); -- Slice 2 (commercial enrollment gating). -- These tables exist in all builds but are only written to by commercial-only -- handlers. In community vaults they stay empty — community has no central -- admin issuing tokens. CREATE TABLE IF NOT EXISTS pending_enrollments ( token TEXT PRIMARY KEY, -- 6 uppercase chars (e.g. AB7QXP) customer_id TEXT NOT NULL, -- central admin's customer id, opaque to POP plan TEXT NOT NULL, -- plan name for display/audit name TEXT, -- 'Anna' — display name set at issue time expires_at INTEGER NOT NULL, -- unix seconds, typically now+24h consumed_at INTEGER, -- NULL until claimed consumed_by_l0 TEXT, -- L0 (hex) of the vault that claimed the token created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_pending_enrollments_expires ON pending_enrollments(expires_at); CREATE TABLE IF NOT EXISTS enrollment_attempts ( ip TEXT PRIMARY KEY, window_start INTEGER NOT NULL, -- start of the 10-minute window (unix s) fail_count INTEGER NOT NULL DEFAULT 0, blocked_until INTEGER NOT NULL DEFAULT 0 -- unix s; 0 = not blocked ); ` // OpenDB opens the SQLite database. func OpenDB(dbPath string) (*DB, error) { conn, err := sql.Open("sqlite3", dbPath+"?_journal_mode=WAL&_foreign_keys=ON&_busy_timeout=5000") if err != nil { return nil, fmt.Errorf("open db: %w", err) } if err := conn.Ping(); err != nil { return nil, fmt.Errorf("ping db: %w", err) } return &DB{Conn: conn, DBPath: dbPath}, nil } // InitSchema creates tables for a new vault. No migrations — early stage. // Schema changes = delete vault and re-register. See CLAVITOR-PRINCIPLES.md func InitSchema(db *DB) error { if _, err := db.Conn.Exec(schema); err != nil { return err } return nil } // Close closes the database connection. func (db *DB) Close() error { return db.Conn.Close() } // --------------------------------------------------------------------------- // Entry operations // --------------------------------------------------------------------------- // EntryCreate creates a new entry. func EntryCreate(db *DB, vaultKey []byte, e *Entry) error { if e.EntryID == 0 { e.EntryID = HexID(NewID()) } now := time.Now().UnixMilli() e.CreatedAt = now e.UpdatedAt = now e.Version = 1 if e.DataLevel == 0 { e.DataLevel = DataLevelL1 } if e.Scopes == "" { e.Scopes = ScopeOwner } entryKey, err := DeriveEntryKey(vaultKey, int64(e.EntryID)) if err != nil { return err } hmacKey, err := DeriveHMACKey(vaultKey) if err != nil { return err } // Agent entries pre-set TitleIdx to BlindIndex(agent_id) for lookup. Don't overwrite. if len(e.TitleIdx) == 0 { e.TitleIdx = BlindIndex(hmacKey, strings.ToLower(e.Title)) } if e.VaultData != nil { dataJSON, err := json.Marshal(e.VaultData) if err != nil { return err } packed, err := Pack(entryKey, string(dataJSON)) if err != nil { return err } e.Data = packed } _, err = db.Conn.Exec( `INSERT INTO entries (entry_id, parent_id, type, title, title_idx, data, data_level, scopes, created_at, updated_at, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, int64(e.EntryID), int64(e.ParentID), e.Type, e.Title, e.TitleIdx, e.Data, e.DataLevel, e.Scopes, e.CreatedAt, e.UpdatedAt, e.Version, ) return err } // EntryGet retrieves an entry by ID. func EntryGet(db *DB, vaultKey []byte, entryID int64) (*Entry, error) { var e Entry var deletedAt sql.NullInt64 var replicatedAt sql.NullInt64 err := db.Conn.QueryRow( `SELECT entry_id, parent_id, type, title, title_idx, data, data_level, scopes, created_at, updated_at, version, deleted_at, replicated_at FROM entries WHERE entry_id = ?`, entryID, ).Scan(&e.EntryID, &e.ParentID, &e.Type, &e.Title, &e.TitleIdx, &e.Data, &e.DataLevel, &e.Scopes, &e.CreatedAt, &e.UpdatedAt, &e.Version, &deletedAt, &replicatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } if err != nil { return nil, err } if deletedAt.Valid { v := deletedAt.Int64 e.DeletedAt = &v } if replicatedAt.Valid { v := replicatedAt.Int64 e.ReplicatedAt = &v } if len(e.Data) > 0 && e.DataLevel == DataLevelL1 { entryKey, err := DeriveEntryKey(vaultKey, int64(e.EntryID)) if err != nil { return nil, err } dataText, err := Unpack(entryKey, e.Data) if err != nil { return nil, err } var vd VaultData if err := json.Unmarshal([]byte(dataText), &vd); err != nil { return nil, err } e.VaultData = &vd } return &e, nil } // EntryUpdate updates an existing entry with optimistic locking. func EntryUpdate(db *DB, vaultKey []byte, e *Entry) error { now := time.Now().UnixMilli() entryKey, err := DeriveEntryKey(vaultKey, int64(e.EntryID)) if err != nil { return err } hmacKey, err := DeriveHMACKey(vaultKey) if err != nil { return err } e.TitleIdx = BlindIndex(hmacKey, strings.ToLower(e.Title)) if e.VaultData != nil { dataJSON, err := json.Marshal(e.VaultData) if err != nil { return err } packed, err := Pack(entryKey, string(dataJSON)) if err != nil { return err } e.Data = packed } result, err := db.Conn.Exec( `UPDATE entries SET parent_id=?, type=?, title=?, title_idx=?, data=?, data_level=?, scopes=?, updated_at=?, version=version+1 WHERE entry_id = ? AND version = ? AND deleted_at IS NULL`, int64(e.ParentID), e.Type, e.Title, e.TitleIdx, e.Data, e.DataLevel, e.Scopes, now, int64(e.EntryID), e.Version, ) if err != nil { return err } affected, _ := result.RowsAffected() if affected == 0 { return ErrVersionConflict } e.Version++ e.UpdatedAt = now return nil } // EntryDelete soft-deletes an entry. func EntryDelete(db *DB, entryID int64) error { now := time.Now().UnixMilli() result, err := db.Conn.Exec( `UPDATE entries SET deleted_at = ?, updated_at = ? WHERE entry_id = ? AND deleted_at IS NULL`, now, now, entryID) if err != nil { return err } affected, _ := result.RowsAffected() if affected == 0 { return ErrNotFound } return nil } // EntryList returns all non-deleted entries, optionally filtered by parent. func EntryList(db *DB, vaultKey []byte, parentID *int64) ([]Entry, error) { var rows *sql.Rows var err error if parentID != nil { rows, err = db.Conn.Query( `SELECT entry_id, parent_id, type, title, title_idx, data, data_level, scopes, created_at, updated_at, version FROM entries WHERE deleted_at IS NULL AND parent_id = ? ORDER BY type, title`, *parentID) } else { rows, err = db.Conn.Query( `SELECT entry_id, parent_id, type, title, title_idx, data, data_level, scopes, created_at, updated_at, version FROM entries WHERE deleted_at IS NULL ORDER BY type, title`) } if err != nil { return nil, err } defer rows.Close() var entries []Entry for rows.Next() { var e Entry if err := rows.Scan(&e.EntryID, &e.ParentID, &e.Type, &e.Title, &e.TitleIdx, &e.Data, &e.DataLevel, &e.Scopes, &e.CreatedAt, &e.UpdatedAt, &e.Version); err != nil { return nil, err } if len(e.Data) > 0 && e.DataLevel == DataLevelL1 { entryKey, _ := DeriveEntryKey(vaultKey, int64(e.EntryID)) dataText, _ := Unpack(entryKey, e.Data) var vd VaultData if json.Unmarshal([]byte(dataText), &vd) == nil { e.VaultData = &vd } } entries = append(entries, e) } return entries, rows.Err() } // EntryListMeta returns metadata only — no decryption. func EntryListMeta(db *DB) ([]Entry, error) { rows, err := db.Conn.Query( `SELECT entry_id, parent_id, type, title, data_level, scopes, created_at, updated_at, version FROM entries WHERE deleted_at IS NULL ORDER BY type, title`) if err != nil { return nil, err } defer rows.Close() var entries []Entry for rows.Next() { var e Entry if err := rows.Scan(&e.EntryID, &e.ParentID, &e.Type, &e.Title, &e.DataLevel, &e.Scopes, &e.CreatedAt, &e.UpdatedAt, &e.Version); err != nil { return nil, err } entries = append(entries, e) } return entries, rows.Err() } // EntrySearchFuzzy searches entries by title using LIKE. func EntrySearchFuzzy(db *DB, vaultKey []byte, query string) ([]Entry, error) { rows, err := db.Conn.Query( `SELECT entry_id, parent_id, type, title, title_idx, data, data_level, scopes, created_at, updated_at, version FROM entries WHERE deleted_at IS NULL AND title LIKE ? ORDER BY title`, "%"+query+"%") if err != nil { return nil, err } defer rows.Close() var entries []Entry for rows.Next() { var e Entry if err := rows.Scan(&e.EntryID, &e.ParentID, &e.Type, &e.Title, &e.TitleIdx, &e.Data, &e.DataLevel, &e.Scopes, &e.CreatedAt, &e.UpdatedAt, &e.Version); err != nil { return nil, err } if len(e.Data) > 0 && e.DataLevel == DataLevelL1 { entryKey, _ := DeriveEntryKey(vaultKey, int64(e.EntryID)) dataText, _ := Unpack(entryKey, e.Data) var vd VaultData if json.Unmarshal([]byte(dataText), &vd) == nil { e.VaultData = &vd } } entries = append(entries, e) } return entries, rows.Err() } // EntryUpdateScopes updates only the scopes column. func EntryUpdateScopes(db *DB, entryID int64, scopes string) error { now := time.Now().UnixMilli() result, err := db.Conn.Exec( `UPDATE entries SET scopes = ?, updated_at = ? WHERE entry_id = ? AND deleted_at IS NULL`, scopes, now, entryID) if err != nil { return err } affected, _ := result.RowsAffected() if affected == 0 { return ErrNotFound } return nil } // EntryMarkWorked marks a credential as verified working and deprecates alternates. // When a credential works, all alternates with the same title+username become deprecated. func EntryMarkWorked(db *DB, entryID int64) error { now := time.Now().UnixMilli() tx, err := db.Conn.Begin() if err != nil { return err } defer tx.Rollback() // Get the entry to find title and username var entry Entry var data []byte err = tx.QueryRow( `SELECT entry_id, title, data, alternate_for FROM entries WHERE entry_id = ? AND deleted_at IS NULL`, entryID).Scan(&entry.EntryID, &entry.Title, &data, &entry.AlternateFor) if err != nil { return err } // Mark this entry as verified _, err = tx.Exec(`UPDATE entries SET verified_at = ?, updated_at = ? WHERE entry_id = ?`, now, now, entryID) if err != nil { return err } // Find the primary entry (either this one or the one it points to) primaryID := entryID if entry.AlternateFor != 0 { primaryID = int64(entry.AlternateFor) } // Mark all other alternates for this primary as deprecated (set deleted_at) // But we keep them for history - don't actually delete _, err = tx.Exec( `UPDATE entries SET alternate_for = ?, updated_at = ? WHERE entry_id != ? AND (alternate_for = ? OR entry_id = ?) AND deleted_at IS NULL`, primaryID, now, entryID, primaryID, primaryID) if err != nil { return err } return tx.Commit() } // EntryListAlternates returns all alternate credentials for a given entry. // This includes the primary entry and all alternates with the same title. func EntryListAlternates(db *DB, vaultKey []byte, entryID int64) ([]Entry, error) { // First, get the entry to find its primary var primaryID int64 err := db.Conn.QueryRow( `SELECT COALESCE(alternate_for, entry_id) FROM entries WHERE entry_id = ? AND deleted_at IS NULL`, entryID).Scan(&primaryID) if err != nil { return nil, err } // Get all entries that are either the primary or point to it rows, err := db.Conn.Query( `SELECT entry_id, parent_id, type, title, title_idx, data, data_level, scopes, created_at, updated_at, version, alternate_for, verified_at FROM entries WHERE (entry_id = ? OR alternate_for = ?) AND deleted_at IS NULL ORDER BY verified_at DESC NULLS LAST, created_at DESC`, primaryID, primaryID) if err != nil { return nil, err } defer rows.Close() var entries []Entry for rows.Next() { var e Entry var verifiedAt sql.NullInt64 var alternateFor sql.NullInt64 if err := rows.Scan(&e.EntryID, &e.ParentID, &e.Type, &e.Title, &e.TitleIdx, &e.Data, &e.DataLevel, &e.Scopes, &e.CreatedAt, &e.UpdatedAt, &e.Version, &alternateFor, &verifiedAt); err != nil { return nil, err } if alternateFor.Valid { e.AlternateFor = HexID(alternateFor.Int64) } if verifiedAt.Valid { e.VerifiedAt = &verifiedAt.Int64 } if len(e.Data) > 0 && e.DataLevel == DataLevelL1 { entryKey, _ := DeriveEntryKey(vaultKey, int64(e.EntryID)) dataText, _ := Unpack(entryKey, e.Data) var vd VaultData if json.Unmarshal([]byte(dataText), &vd) == nil { e.VaultData = &vd } } entries = append(entries, e) } return entries, rows.Err() } // EntryMarkReplicated sets replicated_at to now and clears dirty flag. func EntryMarkReplicated(db *DB, entryID int64) error { now := time.Now().UnixMilli() _, err := db.Conn.Exec(`UPDATE entries SET replicated_at = ?, replication_dirty = 0 WHERE entry_id = ?`, now, entryID) return err } // EntryMarkDirty sets replication_dirty flag. // Called when entry is modified and needs replication. func EntryMarkDirty(db *DB, entryID int64) error { _, err := db.Conn.Exec(`UPDATE entries SET replication_dirty = 1 WHERE entry_id = ?`, entryID) return err } // EntryListDirty returns entries with replication_dirty = 1 (faster than unreplicated check). func EntryListDirty(db *DB, limit int) ([]Entry, error) { rows, err := db.Conn.Query( `SELECT entry_id, parent_id, type, title, title_idx, data, data_level, scopes, created_at, updated_at, version, deleted_at FROM entries WHERE replication_dirty = 1 LIMIT ?`, limit) if err != nil { return nil, err } defer rows.Close() var entries []Entry for rows.Next() { var e Entry var deletedAt sql.NullInt64 if err := rows.Scan(&e.EntryID, &e.ParentID, &e.Type, &e.Title, &e.TitleIdx, &e.Data, &e.DataLevel, &e.Scopes, &e.CreatedAt, &e.UpdatedAt, &e.Version, &deletedAt); err != nil { return nil, err } if deletedAt.Valid { t := deletedAt.Int64 e.DeletedAt = &t } entries = append(entries, e) } return entries, rows.Err() } // EntryListUnreplicated returns entries needing replication (legacy, for initial sync). func EntryListUnreplicated(db *DB) ([]Entry, error) { rows, err := db.Conn.Query( `SELECT entry_id, parent_id, type, title, title_idx, data, data_level, scopes, created_at, updated_at, version, deleted_at FROM entries WHERE replicated_at IS NULL OR replicated_at < updated_at`) if err != nil { return nil, err } defer rows.Close() var entries []Entry for rows.Next() { var e Entry var deletedAt sql.NullInt64 if err := rows.Scan(&e.EntryID, &e.ParentID, &e.Type, &e.Title, &e.TitleIdx, &e.Data, &e.DataLevel, &e.Scopes, &e.CreatedAt, &e.UpdatedAt, &e.Version, &deletedAt); err != nil { return nil, err } if deletedAt.Valid { v := deletedAt.Int64 e.DeletedAt = &v } entries = append(entries, e) } return entries, rows.Err() } // --------------------------------------------------------------------------- // Agent lookup (agents are entries) // --------------------------------------------------------------------------- // AgentLookup finds an agent entry by agent_id using blind index. func AgentLookup(db *DB, vaultKey []byte, agentIDHex string) (*AgentData, error) { hmacKey, err := DeriveHMACKey(vaultKey) if err != nil { return nil, err } idx := BlindIndex(hmacKey, agentIDHex) var e Entry err = db.Conn.QueryRow( `SELECT entry_id, type, title, data, data_level FROM entries WHERE title_idx = ? AND type = ? AND deleted_at IS NULL`, idx, TypeAgent, ).Scan(&e.EntryID, &e.Type, &e.Title, &e.Data, &e.DataLevel) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, err } if e.DataLevel != DataLevelL1 || len(e.Data) == 0 { return nil, nil } entryKey, err := DeriveEntryKey(vaultKey, int64(e.EntryID)) if err != nil { return nil, err } dataText, err := Unpack(entryKey, e.Data) if err != nil { return nil, err } var vd VaultData if err := json.Unmarshal([]byte(dataText), &vd); err != nil { return nil, err } return &AgentData{ AgentID: vd.AgentID, Name: e.Title, Scopes: vd.Scopes, AllAccess: vd.AllAccess, Admin: vd.Admin, AllowedIPs: vd.AllowedIPs, RateLimit: vd.RateLimit, RateLimitHour: vd.RateLimitHour, Locked: vd.Locked, LastStrikeAt: vd.LastStrikeAt, EntryID: e.EntryID, }, nil } // agentMutate runs fn against an agent entry's decrypted VaultData, // then re-encrypts and persists. The shared core for every agent-record // state change so each individual mutation is one line. func agentMutate(db *DB, vaultKey []byte, entryID HexID, fn func(*VaultData)) error { var e Entry err := db.Conn.QueryRow( `SELECT entry_id, data, data_level FROM entries WHERE entry_id = ? AND deleted_at IS NULL`, int64(entryID), ).Scan(&e.EntryID, &e.Data, &e.DataLevel) if err != nil { return err } entryKey, err := DeriveEntryKey(vaultKey, int64(e.EntryID)) if err != nil { return err } dataText, err := Unpack(entryKey, e.Data) if err != nil { return err } var vd VaultData if err := json.Unmarshal([]byte(dataText), &vd); err != nil { return err } fn(&vd) updated, err := json.Marshal(vd) if err != nil { return err } packed, err := Pack(entryKey, string(updated)) if err != nil { return err } _, err = db.Conn.Exec(`UPDATE entries SET data = ?, updated_at = ? WHERE entry_id = ?`, packed, time.Now().Unix(), int64(entryID)) return err } // AgentUpdateAllowedIPs re-encrypts the agent entry data with updated AllowedIPs. func AgentUpdateAllowedIPs(db *DB, vaultKey []byte, agent *AgentData) error { return agentMutate(db, vaultKey, agent.EntryID, func(vd *VaultData) { vd.AllowedIPs = agent.AllowedIPs }) } // AgentRecordStrike updates an agent's LastStrikeAt timestamp. // First strike — agent is throttled but not locked. func AgentRecordStrike(db *DB, vaultKey []byte, entryID HexID, ts int64) error { return agentMutate(db, vaultKey, entryID, func(vd *VaultData) { vd.LastStrikeAt = ts }) } // AgentLockWithStrike sets Locked=true and updates LastStrikeAt atomically. // Used for the second strike within the 2-hour window. func AgentLockWithStrike(db *DB, vaultKey []byte, entryID HexID, ts int64) error { return agentMutate(db, vaultKey, entryID, func(vd *VaultData) { vd.Locked = true vd.LastStrikeAt = ts }) } // AgentLock sets the locked flag on an agent without changing LastStrikeAt. func AgentLock(db *DB, vaultKey []byte, entryID HexID) error { return agentMutate(db, vaultKey, entryID, func(vd *VaultData) { vd.Locked = true }) } // AgentUnlock clears Locked and resets the strike clock. // Owner-driven (PRF tap) — also clears LastStrikeAt so the agent gets a fresh start. func AgentUnlock(db *DB, vaultKey []byte, entryID HexID) error { return agentMutate(db, vaultKey, entryID, func(vd *VaultData) { vd.Locked = false vd.LastStrikeAt = 0 }) } // AgentCreateOpts is the options struct for creating an agent. type AgentCreateOpts struct { POP string // POP routing tag (commercial); "" for community Name string // human-readable label Scopes string // comma-separated scope IDs; "" or "auto" → fresh random scope AllAccess bool // true → bypass scope checks Admin bool // true → can create/edit/delete other agents RateLimit int // unique entries per minute; 0 = unlimited RateLimitHour int // unique entries per hour; 0 = unlimited } // AgentCreate creates an agent entry. The credential token is generated client-side by the web UI. func AgentCreate(db *DB, vaultKey, l0 []byte, opts AgentCreateOpts) (*AgentData, error) { // Generate random 16-byte agent_id and scope_id agentID := make([]byte, 16) rand.Read(agentID) agentIDHex := hex.EncodeToString(agentID) // Auto-assign scope if not provided scopes := opts.Scopes if scopes == "" || scopes == "auto" { scopeID := make([]byte, 16) rand.Read(scopeID) scopes = hex.EncodeToString(scopeID) } // Create agent entry (NO L2 stored - agent gets it from their credential token generated client-side) vd := &VaultData{ Title: opts.Name, Type: TypeAgent, AgentID: agentIDHex, Scopes: scopes, AllAccess: opts.AllAccess, Admin: opts.Admin, RateLimit: opts.RateLimit, RateLimitHour: opts.RateLimitHour, } entry := &Entry{ Type: TypeAgent, Title: opts.Name, DataLevel: DataLevelL1, Scopes: ScopeOwner, // agent entries are owner-only VaultData: vd, } // Use agent_id as the blind index key (for lookup) hmacKey, err := DeriveHMACKey(vaultKey) if err != nil { return nil, err } entry.TitleIdx = BlindIndex(hmacKey, agentIDHex) if err := EntryCreate(db, vaultKey, entry); err != nil { return nil, err } return &AgentData{ AgentID: agentIDHex, Name: opts.Name, Scopes: scopes, AllAccess: opts.AllAccess, Admin: opts.Admin, RateLimit: opts.RateLimit, RateLimitHour: opts.RateLimitHour, }, nil } // --------------------------------------------------------------------------- // Audit operations // --------------------------------------------------------------------------- // AuditLog records an audit event. func AuditLog(db *DB, ev *AuditEvent) error { if ev.EventID == 0 { ev.EventID = HexID(NewID()) } if ev.CreatedAt == 0 { ev.CreatedAt = time.Now().UnixMilli() } _, err := db.Conn.Exec( `INSERT INTO audit_log (event_id, entry_id, title, action, actor, ip_addr, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, int64(ev.EventID), int64(ev.EntryID), ev.Title, ev.Action, ev.Actor, ev.IPAddr, ev.CreatedAt) return err } // AuditList returns recent audit events. func AuditList(db *DB, limit int) ([]AuditEvent, error) { if limit <= 0 { limit = 100 } rows, err := db.Conn.Query( `SELECT event_id, entry_id, title, action, actor, ip_addr, created_at FROM audit_log ORDER BY created_at DESC LIMIT ?`, limit) if err != nil { return nil, err } defer rows.Close() var events []AuditEvent for rows.Next() { var ev AuditEvent var entryID sql.NullInt64 var title, ipAddr sql.NullString if err := rows.Scan(&ev.EventID, &entryID, &title, &ev.Action, &ev.Actor, &ipAddr, &ev.CreatedAt); err != nil { return nil, err } if entryID.Valid { ev.EntryID = HexID(entryID.Int64) } if title.Valid { ev.Title = title.String } if ipAddr.Valid { ev.IPAddr = ipAddr.String } events = append(events, ev) } return events, rows.Err() } // EntryCount returns total entry count (excluding agents/scopes). func EntryCount(db *DB) (int, error) { var count int err := db.Conn.QueryRow( `SELECT COUNT(*) FROM entries WHERE deleted_at IS NULL AND type NOT IN (?, ?)`, TypeAgent, TypeScope).Scan(&count) return count, err } // --------------------------------------------------------------------------- // WebAuthn // --------------------------------------------------------------------------- func StoreWebAuthnCredential(db *DB, c *WebAuthnCredential) error { if c.CreatedAt == 0 { c.CreatedAt = time.Now().Unix() } _, err := db.Conn.Exec( `INSERT INTO webauthn_credentials (cred_id, name, public_key, credential_id, sign_count, authenticator_attachment, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, int64(c.CredID), c.Name, c.PublicKey, c.CredentialID, c.SignCount, c.AuthenticatorAttachment, c.CreatedAt) return err } func GetWebAuthnCredentials(db *DB) ([]WebAuthnCredential, error) { rows, err := db.Conn.Query( `SELECT cred_id, name, public_key, credential_id, sign_count, authenticator_attachment, created_at FROM webauthn_credentials ORDER BY created_at DESC`) if err != nil { return nil, err } defer rows.Close() var creds []WebAuthnCredential for rows.Next() { var c WebAuthnCredential if err := rows.Scan(&c.CredID, &c.Name, &c.PublicKey, &c.CredentialID, &c.SignCount, &c.AuthenticatorAttachment, &c.CreatedAt); err != nil { return nil, err } creds = append(creds, c) } return creds, rows.Err() } func WebAuthnCredentialCount(db *DB) (int, error) { var count int err := db.Conn.QueryRow(`SELECT COUNT(*) FROM webauthn_credentials`).Scan(&count) return count, err } func GetWebAuthnCredentialByRawID(db *DB, credentialID []byte) (*WebAuthnCredential, error) { var c WebAuthnCredential err := db.Conn.QueryRow( `SELECT cred_id, name, public_key, credential_id, sign_count, created_at FROM webauthn_credentials WHERE credential_id = ?`, credentialID, ).Scan(&c.CredID, &c.Name, &c.PublicKey, &c.CredentialID, &c.SignCount, &c.CreatedAt) if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return &c, err } func DeleteWebAuthnCredential(db *DB, credID int64) error { result, err := db.Conn.Exec(`DELETE FROM webauthn_credentials WHERE cred_id = ?`, credID) if err != nil { return err } affected, _ := result.RowsAffected() if affected == 0 { return ErrNotFound } return nil } func UpdateWebAuthnSignCount(db *DB, credID int64, count int) error { _, err := db.Conn.Exec(`UPDATE webauthn_credentials SET sign_count = ? WHERE cred_id = ?`, count, credID) return err } func StoreWebAuthnChallenge(db *DB, challenge []byte, challengeType string) error { _, err := db.Conn.Exec( `INSERT INTO webauthn_challenges (challenge, type, created_at) VALUES (?, ?, ?)`, challenge, challengeType, time.Now().Unix()) return err } func ConsumeWebAuthnChallenge(db *DB, challenge []byte, challengeType string) error { fiveMinAgo := time.Now().Unix() - 300 result, err := db.Conn.Exec( `DELETE FROM webauthn_challenges WHERE challenge = ? AND type = ? AND created_at > ?`, challenge, challengeType, fiveMinAgo) if err != nil { return err } affected, _ := result.RowsAffected() if affected == 0 { return errors.New("challenge not found or expired") } return nil } func CleanExpiredChallenges(db *DB) { fiveMinAgo := time.Now().Unix() - 300 db.Conn.Exec(`DELETE FROM webauthn_challenges WHERE created_at < ?`, fiveMinAgo) }