Verificar una cadena de auditoría de StandardOS
Cada exportación de StandardOS contiene un array audit_trail. Este documento
especifica exactamente cómo se calcula el hash de cada entrada, para que usted
pueda recalcular la cadena por su cuenta, en cualquier lenguaje, sin ejecutar
StandardOS y sin confiar en nosotros.
Una implementación completa, en unas cien líneas de JavaScript sin dependencias, está publicada junto a este documento en https://getstandardos.com/verify-chain.mjs. Está escrita para leerse, pero si prefiere implementarla usted mismo, este documento basta.
Lo que la cadena garantiza
Cada entrada sella la anterior. Cambie cualquier campo de cualquier entrada
histórica y su row_hash deja de coincidir, y como el prev_hash de la
entrada siguiente es ese hash, todas las entradas posteriores se rompen
también. Borrar una entrada rompe el enlace a través del hueco. Añadir una entrada fabricada solo exige conocer la cabeza actual, lo cual es aceptable. La cadena demuestra que la historia no ha sido reescrita,
no que un evento concreto haya ocurrido.
Lo que no garantiza por sí sola
La regla de hash es pública y sin clave, y es deliberado: una regla con clave solo podría verificarla quien tuviera la clave, y ese seríamos nosotros. La consecuencia es que cualquiera que pueda escribir en la base de datos puede también recalcular todos los hashes posteriores, y la cadena por sí sola no lo mostrará.
Para eso están las anclas. Véase «Anclas» más abajo. Verifique la cadena contra un recibo de ancla que ya tenga y el ataque por recálculo queda cerrado, porque no podemos alterar un recibo que ya está en su buzón.
La preimagen
Para cada entrada, el hash es:
row_hash = lowercase_hex( sha256( utf8( preimage ) ) )
La preimagen es la concatenación de las partes siguientes, sin separadores salvo donde se indica:
| # | Parte | Notas |
|---|---|---|
| 1 | prev_hash |
64 caracteres hexadecimales en minúsculas |
| 2 | old_data |
la cadena tal como se exporta, o "" si es null |
| 3 | new_data |
la cadena tal como se exporta, o "" si es null |
| 4 | action |
INSERT, UPDATE o DELETE |
| 5 | table_name |
por ejemplo risks |
| 6 | (solo hash_version ≥ 2) actor + "|" + record_id |
actor es "" si es null |
| 7 | (solo hash_version ≥ 3) "|" + principal |
"" si es null |
| 8 | occurred_at |
con el formato exacto de abajo |
La preimagen de cada versión es una extensión estricta por prefijo de la
anterior, así que una entrada v1 se hashea exactamente igual que antes de que
existiera v2. Aplique la regla que nombra la hash_version de cada fila.
La historia nunca se vuelve a hashear cuando cambia la regla; por eso existe la
columna.
Formato de occurred_at
Esta es la parte en la que la gente se equivoca. La marca de tiempo se representa en UTC como:
YYYY-MM-DDTHH:MM:SS.ffffffZ
Exactamente seis dígitos de fracción de segundo, siempre presentes, siempre rellenados con ceros, por ejemplo 2026-07-30T17:41:39.558458Z. Postgres lo produce con
to_char(occurred_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"').
Tenga en cuenta que Date.toISOString() de JavaScript emite tres dígitos
de fracción, no seis, y por tanto producirá el hash equivocado. El
occurred_at exportado es una cadena ISO con precisión de microsegundos; use
sus dígitos directamente en lugar de pasarlos por un tipo de fecha que no puede
contenerlos.
old_data / new_data
Se exportan como cadenas, no como objetos, deliberadamente. Los bytes que
se hashean son la representación jsonb canónica de Postgres: claves de
objeto ordenadas por longitud y después byte a byte, un espacio tras cada : y
,. JSON.stringify no reproduce eso. Hashee la cadena exactamente como se
exporta. Analícela solo si quiere leerla.
Un INSERT no tiene old_data; un DELETE no tiene new_data. Ambos son
null en la exportación y aportan la cadena vacía a la preimagen.
Comprobar la cadena
- Ordene las entradas por
idascendente, dentro de un mismoorg_id. - El
prev_hashde la primera entrada debe ser igual a la semilla de génesis:sha256(utf8(org_id))en hexadecimal minúsculo, dondeorg_ides el UUID en su forma canónica con guiones y en minúsculas. - Para cada entrada: recalcule
row_hasha partir de la preimagen y compare; después confirme que elprev_hashde la entrada siguiente es igual alrow_hashde esta.
Si falla el paso 2, se han eliminado entradas al principio. Si falla el paso 3 en la entrada n, la cadena está intacta en las primeras n−1 entradas y algo en n no coincide con lo que se registró.
Anclas
Cada día StandardOS registra la cabeza de la cadena de cada organización y le envía por correo un recibo con el hash de cabeza, el número de entradas y la fecha. Esos recibos son la parte independiente: están en su buzón, no en nuestra base de datos.
Para usar uno, tome el hash de cabeza de un recibo anterior al periodo que le
interesa, busque la entrada con ese row_hash en su exportación y verifique la
cadena hasta ese punto. Un recálculo hecho después de enviar el recibo no puede
volver a producir ese hash de cabeza.
Conserve los recibos. Son la razón por la que «ni siquiera nosotros podemos alterarlo sin que se note» es una afirmación matemática y no una declaración de buenas intenciones.
Lo que un ancla no cubre
Un ancla tomada a las 03:40 no dice nada de los eventos escritos a las 09:00. Entre dos pasadas de anclaje hay una ventana en la que un evento puede escribirse y retirarse sin que nada lo contradiga: la cadena de hash no lo nota, porque borrar las entradas más recientes no rompe ningún enlace, y ningún ancla las cubrió nunca.
Dos cosas estrechan esa ventana sin cerrarla. Cada pasada nocturna comprueba que el ancla anterior sigue siendo válida, es decir, que el evento que registró sigue presente con el mismo hash y que la cadena no ha menguado por debajo del recuento anclado, y registra una alarma si no es así. Así que todo lo que sobrevive a un ancla queda protegido desde entonces. Y el barrido profundo vuelve a derivar cada cadena desde el génesis a diario, lo que detecta contenido alterado sin recálculo.
Lo que queda sin cubrir es un intervalo de anclaje, para los eventos creados y destruidos dentro de él. Preferimos dejarlo escrito antes que permitir que un lector suponga lo contrario.
Lo que cubre la regla de hash, por versión
La regla se ha ampliado dos veces, y cada fila registra qué versión la escribió.
| Versión | Sella |
|---|---|
| 1 | contenido, acción, tabla, marca de tiempo y el enlace a la entrada anterior |
| 2 | lo anterior, más actor y record_id |
| 3 | lo anterior, más principal |
La historia nunca se vuelve a hashear cuando cambia la regla. Reescribir un
registro de solo adición para que se verifique bajo una regla nueva es
exactamente la operación que el registro existe para hacer detectable. La
consecuencia es que en una entrada v1 o v2 las columnas de atribución están
registradas pero no selladas: pueden alterarse sin romper la verificación.
La exportación lo marca por fila (principal_attested, actor_attested), y
verify-chain.mjs imprime cuántas entradas están afectadas. Trate una
atribución no atestiguada como una afirmación, no como prueba de quién actuó.
El contenido, el orden y el momento de cada entrada se verifican sea cual sea
la versión.
Vectores de prueba
Si ha implementado la regla anterior, compruébela contra estos vectores antes
de confiar en su implementación con una exportación real. Cada entrada es una
fila completa de la pista de auditoría con el row_hash que debe producir.
Cubren las tres versiones de hash, old_data/new_data tanto nulos como
rellenos, y una fracción de segundo que no tiene ya seis dígitos.
No son decoración. Nuestra CI recalcula cada uno de ellos en cada push con el verificador publicado, de modo que un cambio en la regla de hash que olvidara actualizar este documento hace fallar nuestra compilación y no su auditoría.
[
{
"hash_version": 1,
"prev_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"old_data": null,
"new_data": "{\"id\":1}",
"action": "INSERT",
"table_name": "risks",
"actor": null,
"record_id": null,
"principal": null,
"occurred_at": "2026-07-30T17:41:39.558458Z",
"row_hash": "62586c3fab919870dc2a5eec406fde33ddc31f73b92d5d851acbf3e822b15704"
},
{
"hash_version": 2,
"prev_hash": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"old_data": "{\"id\":1}",
"new_data": "{\"id\":2}",
"action": "UPDATE",
"table_name": "soa_entries",
"actor": "11111111-1111-1111-1111-111111111111",
"record_id": "22222222-2222-2222-2222-222222222222",
"principal": null,
"occurred_at": "2026-07-30T17:41:39.5Z",
"row_hash": "6aad0bb2c1edf6053b297d5a38b88bcc9146fcfb6caa4e0f47dd009bcda60674"
},
{
"hash_version": 3,
"prev_hash": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"old_data": "{\"id\":3}",
"new_data": null,
"action": "DELETE",
"table_name": "documents",
"actor": null,
"record_id": "33333333-3333-3333-3333-333333333333",
"principal": "job:soa-justification-source",
"occurred_at": "2026-08-03T09:00:00Z",
"row_hash": "d895d6280468e1a0600034f15dc3a884a123333a5b73a2df7cf8e470c6cf2fe4"
}
]
Informar de una discrepancia
Si una verificación falla y usted cree que los datos son auténticos,
escríbanos a security@getstandardos.com con el id de la entrada y lo que ha
calculado. Una cadena que no verifica es o bien una manipulación o bien un
error en nuestro hash, y queremos saber cuál con la misma urgencia que usted.