Revaulter
GitHub

Audit events

Revaulter records security-relevant actions to a durable, append-only v2_audit_events table. The table sits alongside the operational application log (emitted to stdout): ordinary debug messages still go to the log stream, while audit rows are the long-lived history of who did what.

There is currently no admin UI or REST API for reading audit events for all users, and operators must query the table directly with SQL. A read endpoint may ship in a future release.

What gets recorded#

Each row captures one logical action: a confirmed request, a rotated request key, a deleted passkey, etc. Failed and denied attempts are recorded too where the signal is interesting (e.g. a failed login).

Events are written through one of two paths:

  • In-transaction: for security-critical state changes the audit row commits in the same transaction as the mutation. If the audit insert fails, the user action also rolls back. This guarantees that a “request confirmed” row exists if and only if the request was actually marked confirmed.
  • Best-effort: for purely observational events (logout cookie clear, background expiry, login failures) the audit row is written outside the mutation. A failed audit insert is logged as a warning and does not affect the user-facing response.

Schema#

columntypenullablenotes
idTEXT (SQLite) / UUID (Postgres)noTime-sortable primary key (UUIDv7 format)
created_atINTEGER (Unix seconds)noWhen the audit row was written
event_typeTEXTno<area>.<verb> — see list below
outcomeTEXTnosuccess | failure | denied
auth_methodTEXTnosession | request_key | system | none (set on rows from handlers that run before any authentication is established, e.g. auth.login_finish failures)
actor_user_idTEXTyesThe user who performed the action; NULL for unauthenticated failures and pure system events
target_user_idTEXTyesThe user the action affects (often equal to actor_user_id)
signing_key_idTEXTyesSet for signing_key.* events
credential_idTEXTyesSet for auth.credential_* events and auth.login_finish
request_stateTEXTyesThe v2 protocol request state for request.* events
http_request_idTEXTyesCorrelates the audit row with the HTTP access log
client_ipTEXTyesNULL for system events
user_agentTEXTyesCapped at 512 chars
metadataTEXT (SQLite) / JSONB (Postgres)noFree-form JSON; capped at 4 KiB; default {}

Event types#

Naming convention is <area>.<verb> with both halves in snake_case. The full list is fixed at the application layer; inserts with any other value are rejected.

Auth#

event_typeWhen it fires
auth.register_finishAccount creation completes (success in tx; failure best-effort)
auth.finalize_signupFirst-credential setup completes — anchor pubkeys, wrapped keys, request enc keys are written
auth.login_finishA login attempt finishes — outcome=success for accepted credentials, outcome=failure for rejected ones
auth.logoutUser invokes logout. The session JWT is not invalidated server-side — this records the cookie-clear
auth.request_key_regenerateUser rotates the CLI request key
auth.allowed_ips_changeAllowed-IP list updated. Metadata: {old_count, new_count}
auth.display_name_changeUser updates their display name
auth.wrapped_key_updateThe wrapped primary/anchor key changes. Metadata: {advance_epoch} (true when the change is a password rotation)
auth.credential_add_finishNew passkey registered
auth.credential_renamePasskey renamed
auth.credential_deletePasskey deleted

Requests#

event_typeWhen it fires
request.createCLI/API submits a new encrypt/decrypt/sign request. Metadata: {operation, algorithm, keyLabel, note?}
request.confirmUser approves a pending request. Metadata: {operation, algorithm, keyLabel, note?}
request.cancelUser cancels a pending request. Metadata: {operation, algorithm, keyLabel, note?}
request.expireTTL elapses and the background goroutine marks the request expired. auth_method=system. Metadata: {operation, algorithm, keyLabel, note?}

The note field is only included when the original request carried a non-empty user-facing note.

Signing keys#

event_typeWhen it fires
signing_key.createUser explicitly creates/uploads a signing key. Metadata: {algorithm, keyLabel, published, hasProof}
signing_key.publishExisting row flipped to published=true
signing_key.unpublishExisting row flipped back to published=false
signing_key.deleteRow deleted
signing_key.auto_storeServer stored a derived public key after a successful sign request (no explicit user action)

What is never recorded#

Audit events must never carry sensitive material. The metadata payloads above are designed accordingly. Specifically, audit rows do not store:

  • Request keys (rvk_…) or session tokens
  • Wrapped primary keys, wrapped anchor keys, or any encrypted blob
  • Raw WebAuthn credential JSON, attestation payloads, or signature bytes
  • Encrypted request/response envelopes
  • Webhook URLs or shared secrets
  • Allowed-IP lists in full (only their count)
  • ML-DSA / ES384 signatures of any kind

The schema-level cap of 4 KiB on metadata is a defence-in-depth limit, not a license to spend it on payloads.

Retention#

Rows older than 30 days are pruned automatically by a background task that runs once at startup and then every 24 hours.

Sample queries#

All queries below assume SQLite syntax; Postgres equivalents differ only in time arithmetic.

All events for a user in the last 24 hours

SELECT created_at, event_type, outcome, client_ip, metadata
FROM v2_audit_events
WHERE actor_user_id = 'user-123'
  AND created_at >= strftime('%s', 'now', '-1 day')
ORDER BY created_at DESC;

All denied or failed actions in the last week

SELECT created_at, event_type, actor_user_id, client_ip
FROM v2_audit_events
WHERE outcome IN ('failure', 'denied')
  AND created_at >= strftime('%s', 'now', '-7 days')
ORDER BY created_at DESC;

Every signing-key change ever

SELECT created_at, event_type, actor_user_id, signing_key_id, metadata
FROM v2_audit_events
WHERE event_type LIKE 'signing_key.%'
ORDER BY created_at DESC;

Background expiries during a window

SELECT created_at, request_state, metadata
FROM v2_audit_events
WHERE event_type = 'request.expire'
  AND created_at BETWEEN ?1 AND ?2
ORDER BY created_at DESC;
Edit this page on GitHub