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
- Triez les entrées par
idcroissant, au sein d'un mêmeorg_id. - Le
prev_hashde la première entrée doit être égal à la graine de genèse :sha256(utf8(org_id))en hexadécimal minuscule, oùorg_idest l'UUID sous sa forme canonique avec tirets et en minuscules. - Pour chaque entrée : recalculez
row_hashà partir de la préimage et comparez ; puis confirmez que leprev_hashde l'entrée suivante est égal aurow_hashde 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.