Audit chain SHA-256
prev_hash + entry_hash por evento · 3 issue types · verify endpoint público para auditor.
Diseño
Cada fila en audit_events (tabla central de auditoría) lleva dos columnas adicionales:
prev_hash VARCHAR(64) -- SHA-256 del entry_hash del evento anterior (mismo tenant)
entry_hash VARCHAR(64) -- SHA-256(canonical(this_row) || prev_hash)
Las cadenas son per-tenant: cada tenant tiene su propia cadena cronológica y la integridad de un tenant no depende de la de otro (aislamiento por diseño).
La cadena, en un vistazo
Cada evento sella el anterior. El entry_hash de una fila entra como prev_hash de la siguiente, así que la secuencia queda encadenada: alterar una fila cambia su entry_hash y rompe el enlace de todas las posteriores. Es tamper-evident (la manipulación se detecta), no tamper-proof (no impide escribir).
entry_hash recalculado deja de coincidir con el almacenado → hash_mismatch. Y como el prev_hash del Evento N+1 apuntaba al valor antiguo, el enlace se rompe → broken_link. Ninguna fila posterior vuelve a cuadrar sin regenerar toda la cadena.Cómo se verifica
GET /api/audit/verify?tenant_id=acme-bank&since=2026-01-01
Respuesta:
{
"ok": true,
"data": {
"chain_intact": true,
"total_rows": 14382,
"verified_rows": 14382,
"issues": []
}
}
Si hay tampering detectado, chain_intact=false y issues[] describe el tipo:
| Issue type | Significado |
|---|---|
hash_mismatch | Una fila tiene entry_hash que no coincide con el recálculo · row modificada post-insert |
broken_link | prev_hash de la fila N no coincide con entry_hash de la fila N-1 · evento eliminado o reordenado |
missing_hash | Una fila tiene prev_hash o entry_hash NULL (caso legacy pre-ADR-0007) |
Canonicalización determinista
El hash se calcula sobre el JSON canónico de la fila usando el formato str de SQLite (no de Python json.dumps). Esto evita mismatches insert-vs-verify por diferencias de serialización entre runtimes.
Algoritmo:
def compute_entry_hash(row, prev_hash):
canonical = canonical_json(row) # str de SQLite, no Python
return hashlib.sha256(
(canonical + (prev_hash or "")).encode("utf-8")
).hexdigest()
Qué NO garantiza
- NO garantiza imposibilidad de tampering · un atacante con write access puede re-correr
regenerate_chain()para “limpiar” - PERO ese mismo
regenerate_chain()queda como audit_event posterior · trazable - Aceptamos que
regenerate_chaines destructivo y queda ligado a un ADR consciente
Para reforzar la garantía · backup externo de la DB con WORM (Write Once Read Many) + verificación cruzada con backup remoto firmado.
Costo schema
2 columnas extra por row · ~64 bytes adicionales. Negligible en práctica (audit_events suele ser pequeño relativo a tablas de runtime).
UI
/v2/audit?verify=1 muestra un badge institucional en el header con el resultado del último verify. Verde si OK · rojo si broken · ámbar si missing_hash legacy.
Tests
core/tests/test_audit_integrity.py · 15 tests cubriendo cadena válida, broken_link, hash_mismatch, missing_hash, regenerate, multi-tenant isolation.