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

```json
[
  {
    "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.
