Especificación

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

  1. Ordene las entradas por id ascendente, dentro de un mismo org_id.
  2. El prev_hash de la primera entrada debe ser igual a la semilla de génesis: sha256(utf8(org_id)) en hexadecimal minúsculo, donde org_id es el UUID en su forma canónica con guiones y en minúsculas.
  3. Para cada entrada: recalcule row_hash a partir de la preimagen y compare; después confirme que el prev_hash de la entrada siguiente es igual al row_hash de 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.

Esta página en:EnglishDeutschFrançaisNederlandsDansk