Spécification

Vérifier une chaîne d'audit StandardOS

Chaque export StandardOS contient un tableau audit_trail. Ce document spécifie exactement comment chaque entrée est hachée, pour que vous puissiez recalculer la chaîne vous-même, dans n'importe quel langage, sans exécuter StandardOS et sans nous faire confiance.

Une implémentation complète, en une centaine de lignes de JavaScript sans dépendance, est publiée à côté de ce document à l'adresse https://getstandardos.com/verify-chain.mjs. Elle est écrite pour être lue, mais si vous préférez l'implémenter vous-même, ce document suffit.

Ce que la chaîne garantit

Chaque entrée scelle celle qui la précède. Modifiez n'importe quel champ de n'importe quelle entrée historique et son row_hash ne correspond plus ; comme le prev_hash de l'entrée suivante est ce haché, toutes les entrées suivantes se rompent aussi. Supprimer une entrée rompt le lien à travers le trou. Ajouter une entrée fabriquée n'exige que de connaître la tête actuelle, ce qui est acceptable. La chaîne prouve que l'historique n'a pas été réécrit, pas qu'un événement particulier a eu lieu.

Ce qu'elle ne garantit pas à elle seule

La règle de hachage est publique et sans clé, et c'est délibéré : une règle à clé ne serait vérifiable que par celui qui détient la clé, c'est-à-dire nous. La conséquence est que quiconque peut écrire dans la base peut aussi recalculer tous les hachés suivants, et la chaîne seule ne le montrera pas.

C'est à cela que servent les ancres. Voir « Ancres » ci-dessous. Vérifiez la chaîne contre un reçu d'ancre que vous détenez déjà et l'attaque par recalcul est fermée, parce que nous ne pouvons pas modifier un reçu qui est déjà dans votre boîte aux lettres.

La préimage

Pour chaque entrée, le haché est :

row_hash = lowercase_hex( sha256( utf8( preimage ) ) )

La préimage est la concaténation des parties suivantes, sans séparateur sauf là où il est indiqué :

# Partie Notes
1 prev_hash 64 caractères hexadécimaux en minuscules
2 old_data la chaîne telle qu'exportée, ou "" si null
3 new_data la chaîne telle qu'exportée, ou "" si null
4 action INSERT, UPDATE ou DELETE
5 table_name par exemple risks
6 (hash_version ≥ 2 seulement) actor + "|" + record_id actor vaut "" si null
7 (hash_version ≥ 3 seulement) "|" + principal "" si null
8 occurred_at formaté exactement comme ci-dessous

La préimage de chaque version est une extension stricte par préfixe de la précédente, donc une entrée v1 est hachée exactement comme avant l'existence de v2. Appliquez la règle nommée par la hash_version de chaque ligne. L'historique n'est jamais rehaché quand la règle change ; c'est la raison d'être de la colonne.

Format de occurred_at

C'est la partie que l'on se trompe le plus souvent. L'horodatage est rendu en UTC sous la forme :

YYYY-MM-DDTHH:MM:SS.ffffffZ

Exactement six chiffres de fraction de seconde, toujours présents, toujours complétés par des zéros, par exemple 2026-07-30T17:41:39.558458Z. Postgres le produit avec to_char(occurred_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"').

Notez que Date.toISOString() en JavaScript émet trois chiffres de fraction, pas six, et produira donc le mauvais haché. Le occurred_at exporté est une chaîne ISO à la microseconde ; utilisez ses chiffres directement plutôt que de le faire passer par un type de date qui ne peut pas les contenir.

old_data / new_data

Exportés comme des chaînes, pas des objets, délibérément. Les octets hachés sont le rendu jsonb canonique de Postgres : clés d'objet triées par longueur puis octet par octet, une espace après chaque : et ,. JSON.stringify ne reproduit pas cela. Hachez la chaîne exactement telle qu'exportée. Ne l'analysez que si vous voulez la lire.

Un INSERT n'a pas de old_data ; un DELETE n'a pas de new_data. Les deux valent null dans l'export et apportent la chaîne vide à la préimage.

Vérifier la chaîne

  1. Triez les entrées par id croissant, au sein d'un même org_id.
  2. Le prev_hash de la première entrée doit être égal à la graine de genèse : sha256(utf8(org_id)) en hexadécimal minuscule, où org_id est l'UUID sous sa forme canonique avec tirets et en minuscules.
  3. Pour chaque entrée : recalculez row_hash à partir de la préimage et comparez ; puis confirmez que le prev_hash de l'entrée suivante est égal au row_hash de celle-ci.

Si l'étape 2 échoue, des entrées ont été retirées au début. Si l'étape 3 échoue à l'entrée n, la chaîne est intacte pour les n−1 premières entrées et quelque chose en n ne correspond pas à ce qui a été enregistré.

Ancres

Chaque jour, StandardOS enregistre la tête de la chaîne de chaque organisation et envoie à cette organisation un reçu par courriel contenant le haché de tête, le nombre d'entrées et la date. Ces reçus sont la partie indépendante : ils sont dans votre boîte aux lettres, pas dans notre base de données.

Pour en utiliser un, prenez le haché de tête d'un reçu antérieur à la période qui vous intéresse, trouvez l'entrée portant ce row_hash dans votre export et vérifiez la chaîne jusqu'à ce point. Un recalcul effectué après l'envoi du reçu ne peut pas reproduire ce haché de tête.

Conservez les reçus. Ils sont la raison pour laquelle « même nous ne pouvons pas le modifier sans que cela se voie » est une affirmation mathématique et non une déclaration de bonnes intentions.

Ce qu'une ancre ne couvre pas

Une ancre prise à 03:40 ne dit rien des événements écrits à 09:00. Entre deux passages d'ancrage, il existe une fenêtre pendant laquelle un événement peut être écrit puis retiré sans que rien ne le contredise : la chaîne de hachage ne le remarque pas, parce que supprimer les entrées les plus récentes ne rompt aucun lien, et aucune ancre ne les a jamais couvertes.

Deux choses réduisent cette fenêtre sans la fermer. Chaque passage nocturne vérifie que l'ancre précédente tient toujours, c'est-à-dire que l'événement qu'elle a enregistré est toujours présent avec le même haché et que la chaîne n'a pas rétréci sous le nombre ancré, et enregistre une alarme sinon. Tout ce qui survit à une ancre est donc protégé à partir de là. Et le balayage profond redérive chaque chaîne depuis la genèse chaque jour, ce qui détecte un contenu modifié sans recalcul.

Ce qui reste non couvert est un intervalle d'ancrage, pour les événements créés et détruits à l'intérieur de celui-ci. Nous préférons l'écrire plutôt que de laisser un lecteur supposer le contraire.

Ce que la règle de hachage couvre, par version

La règle a été étendue deux fois, et chaque ligne enregistre la version qui l'a écrite.

Version Scelle
1 le contenu, l'action, la table, l'horodatage et le lien vers l'entrée précédente
2 ce qui précède, plus actor et record_id
3 ce qui précède, plus principal

L'historique n'est jamais rehaché quand la règle change. Réécrire un journal en ajout seul pour qu'il se vérifie sous une nouvelle règle est exactement l'opération que le journal existe pour rendre détectable. La conséquence est que sur une entrée v1 ou v2, les colonnes d'attribution sont enregistrées mais non scellées : elles peuvent être modifiées sans rompre la vérification. L'export le marque par ligne (principal_attested, actor_attested), et verify-chain.mjs affiche combien d'entrées sont concernées. Traitez une attribution non attestée comme une affirmation, pas comme une preuve de qui a agi. Le contenu, l'ordre et l'horodatage de chaque entrée sont vérifiés quelle que soit la version.

Vecteurs de test

Si vous avez implémenté la règle ci-dessus, vérifiez-la contre ces vecteurs avant de faire confiance à votre implémentation sur un export réel. Chaque entrée est une ligne complète de piste d'audit avec le row_hash qu'elle doit produire. Ils couvrent les trois versions de hachage, old_data/new_data nuls et renseignés, et une fraction de seconde qui n'a pas déjà six chiffres.

Ce n'est pas décoratif. Notre CI recalcule chacun d'eux à chaque push avec le vérificateur publié, de sorte qu'un changement de la règle de hachage qui oublierait de mettre à jour ce document fait échouer notre build plutôt que votre audit.

[
  {
    "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"
  }
]

Signaler une discordance

Si une vérification échoue et que vous pensez que les données sont authentiques, écrivez-nous à security@getstandardos.com avec l'id de l'entrée et ce que vous avez calculé. Une chaîne qui ne se vérifie pas est soit une altération, soit un bogue dans notre hachage, et nous voulons savoir lequel aussi vite que vous.

Cette page en:EnglishDeutschNederlandsEspañolDansk