iForja

Audit chain SHA-256

prev_hash + entry_hash por evento · 3 issue types · verify endpoint público para auditor.

actualizado · 2026-05-24 ref · ADR-0007

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

Encadenamiento SHA-256 · por tenant
Evento N-1
canonical(row)
prev_hash a1f9…3c
entry_hash 7b2e…d1
Evento N
canonical(row)
prev_hash 7b2e…d1
entry_hash c4d0…9a
Evento N+1
canonical(row)
prev_hash c4d0…9a
entry_hash f0b7…22
entry_hash = SHA-256( canonical(row) ‖ prev_hash )
Si se altera el Evento N
Su 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 typeSignificado
hash_mismatchUna fila tiene entry_hash que no coincide con el recálculo · row modificada post-insert
broken_linkprev_hash de la fila N no coincide con entry_hash de la fila N-1 · evento eliminado o reordenado
missing_hashUna 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_chain es 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.