clavitor/clavis/clavis-vault/lib/dbcore.go

910 lines
28 KiB
Go

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)
}