Een StandardOS-auditketen verifiëren
Elke StandardOS-export bevat een array audit_trail. Dit document legt precies
vast hoe elke regel wordt gehasht, zodat u de keten zelf kunt narekenen, in
elke programmeertaal, zonder StandardOS te draaien en zonder ons te vertrouwen.
Een volledige implementatie in ongeveer honderd regels JavaScript zonder afhankelijkheden is naast dit document gepubliceerd op https://getstandardos.com/verify-chain.mjs. Ze is geschreven om gelezen te worden, maar wie het liever zelf implementeert, heeft aan dit document genoeg.
Wat de keten garandeert
Elke regel verzegelt de regel ervoor. Verander een willekeurig veld van een
willekeurige historische regel en de row_hash klopt niet meer, en omdat de
prev_hash van de volgende regel die hash is, breekt ook elke volgende regel.
Een regel verwijderen breekt de schakel over het gat. Een verzonnen regel toevoegen vereist alleen kennis van de huidige kop, en dat is prima. De keten bewijst dat de geschiedenis niet is herschreven,
niet dat een bepaalde gebeurtenis heeft plaatsgevonden.
Wat ze op zichzelf niet garandeert
De hashregel is openbaar en zonder sleutel, en dat is opzet: een regel met sleutel zou alleen te verifiëren zijn door wie de sleutel heeft, en dat zouden wij zijn. Het gevolg is dat iedereen die naar de database kan schrijven ook elke volgende hash opnieuw kan berekenen, en de keten alleen laat dat niet zien.
Daar zijn de ankers voor. Zie "Ankers" hieronder. Verifieer de keten tegen een ankerbewijs dat u al in bezit hebt en de herberekeningsaanval is gesloten, omdat wij een bewijs dat al in uw postvak ligt niet meer kunnen veranderen.
De preimage
Voor elke regel is de hash:
row_hash = lowercase_hex( sha256( utf8( preimage ) ) )
De preimage is de aaneenschakeling van de volgende delen, zonder scheidingstekens behalve waar aangegeven:
| # | Deel | Opmerkingen |
|---|---|---|
| 1 | prev_hash |
64 hexadecimale tekens in kleine letters |
| 2 | old_data |
de tekenreeks zoals geëxporteerd, of "" bij null |
| 3 | new_data |
de tekenreeks zoals geëxporteerd, of "" bij null |
| 4 | action |
INSERT, UPDATE of DELETE |
| 5 | table_name |
bijvoorbeeld risks |
| 6 | (alleen hash_version ≥ 2) actor + "|" + record_id |
actor is "" bij null |
| 7 | (alleen hash_version ≥ 3) "|" + principal |
"" bij null |
| 8 | occurred_at |
precies opgemaakt zoals hieronder |
De preimage van elke versie is een strikte prefixuitbreiding van de vorige, dus
een v1-regel wordt precies zo gehasht als voordat v2 bestond. Pas de regel toe
die de eigen hash_version van de rij noemt. De geschiedenis wordt nooit
opnieuw gehasht als de regel verandert; daarom bestaat de kolom.
Opmaak van occurred_at
Dit is het deel dat mensen fout doen. Het tijdstempel wordt in UTC weergegeven als:
YYYY-MM-DDTHH:MM:SS.ffffffZ
Precies zes cijfers achter de seconde, altijd aanwezig, altijd met nullen aangevuld, bijvoorbeeld 2026-07-30T17:41:39.558458Z. Postgres maakt dit met
to_char(occurred_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"').
Let op: Date.toISOString() in JavaScript geeft drie cijfers achter de
seconde, geen zes, en levert daarom de verkeerde hash. Het geëxporteerde
occurred_at is een ISO-tekenreeks met microseconden; gebruik de cijfers
rechtstreeks in plaats van ze door een datumtype te halen dat ze niet kan
vasthouden.
old_data / new_data
Bewust geëxporteerd als tekenreeksen, niet als objecten. De gehashte bytes
zijn de canonieke jsonb-weergave van Postgres: objectsleutels gesorteerd op
lengte, dan bytegewijs, één spatie na elke : en ,. JSON.stringify
reproduceert dat niet. Hash de tekenreeks precies zoals geëxporteerd. Parseer
haar alleen als u haar wilt lezen.
Een INSERT heeft geen old_data; een DELETE heeft geen new_data. Beide
zijn null in de export en dragen de lege tekenreeks bij aan de preimage.
De keten controleren
- Sorteer de regels op
idoplopend, binnen éénorg_id. - De
prev_hashvan de eerste regel moet gelijk zijn aan het genesis-zaad:sha256(utf8(org_id))als hexadecimaal in kleine letters, waarbijorg_idde UUID is in zijn canonieke vorm met streepjes en kleine letters. - Voor elke regel: bereken
row_hashopnieuw uit de preimage en vergelijk; bevestig daarna dat deprev_hashvan de volgende regel gelijk is aan derow_hashvan deze regel.
Als stap 2 faalt, zijn er regels aan het begin verwijderd. Als stap 3 faalt bij regel n, is de keten intact voor de eerste n−1 regels en komt iets bij n niet overeen met wat is vastgelegd.
Ankers
Elke dag legt StandardOS de kop van de keten van elke organisatie vast en mailt die organisatie een bewijs met de kophash, het aantal regels en de datum. Die bewijzen zijn het onafhankelijke deel: ze liggen in uw postvak, niet in onze database.
Neem om er een te gebruiken de kophash uit een bewijs dat ouder is dan de
periode waar het u om gaat, zoek de regel met die row_hash in uw export en
verifieer de keten tot dat punt. Een herberekening na het versturen van het
bewijs kan die kophash niet opnieuw opleveren.
Bewaar de bewijzen. Zij zijn de reden dat "zelfs wij kunnen het niet onopgemerkt veranderen" een uitspraak over wiskunde is en niet over onze goede bedoelingen.
Wat een anker niet dekt
Een anker om 03:40 zegt niets over gebeurtenissen die om 09:00 zijn geschreven. Tussen twee ankerruns zit een venster waarin een gebeurtenis geschreven en weer verwijderd kan worden zonder dat iets het tegenspreekt: de hashketen merkt het niet, omdat het verwijderen van de nieuwste regels geen schakel breekt, en geen anker ze ooit heeft gedekt.
Twee dingen maken dat venster kleiner zonder het te sluiten. Elke nachtelijke run controleert of het vorige anker nog geldt, dat wil zeggen dat de vastgelegde gebeurtenis nog aanwezig is met dezelfde hash en dat de keten niet onder het verankerde aantal is gekrompen, en legt anders een alarm vast. Alles wat één anker overleeft, is vanaf dan beschermd. En de diepe controle leidt elke keten dagelijks opnieuw af vanaf genesis, wat inhoud opspoort die zonder herberekening is veranderd.
Wat ongedekt blijft, is één ankerinterval, voor gebeurtenissen die daarbinnen zijn aangemaakt en weer vernietigd. Wij schrijven dat liever op dan een lezer iets anders te laten aannemen.
Wat de hashregel dekt, per versie
De regel is twee keer uitgebreid, en elke rij legt vast welke versie haar heeft geschreven.
| Versie | Verzegelt |
|---|---|
| 1 | inhoud, actie, tabel, tijdstempel en de schakel naar de vorige regel |
| 2 | het bovenstaande, plus actor en record_id |
| 3 | het bovenstaande, plus principal |
De geschiedenis wordt nooit opnieuw gehasht als de regel verandert. Een
alleen-toevoegen-logboek herschrijven zodat het onder een nieuwe regel
verifieert, is precies de handeling die het logboek detecteerbaar moet maken.
Het gevolg is dat op een v1- of v2-regel de toeschrijvingskolommen vastgelegd
maar niet verzegeld zijn: ze kunnen worden gewijzigd zonder de verificatie te
breken. De export markeert dat per rij (principal_attested,
actor_attested), en verify-chain.mjs drukt af hoeveel regels het betreft.
Behandel een niet-geattesteerde toeschrijving als een bewering, niet als bewijs
van wie heeft gehandeld. Inhoud, volgorde en tijdstip van elke regel worden
ongeacht de versie geverifieerd.
Testvectoren
Als u de bovenstaande regel hebt geïmplementeerd, controleer haar dan tegen
deze vectoren voordat u uw implementatie op een echte export vertrouwt. Elke
regel is een volledige auditrij met de row_hash die zij moet opleveren. Ze
dekken alle drie de hashversies, zowel lege als gevulde old_data/new_data,
en een secondefractie die niet al zes cijfers heeft.
Dit is geen versiering. Onze CI berekent elk van deze vectoren bij elke push opnieuw met de gepubliceerde verifier, zodat een wijziging van de hashregel die dit document vergeet bij te werken onze build laat falen en niet uw 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"
}
]
Een afwijking melden
Als een verificatie faalt en u gelooft dat de gegevens echt zijn, mail ons dan
op security@getstandardos.com met de id van de regel en wat u hebt berekend.
Een keten die niet verifieert, is ofwel manipulatie ofwel een fout in onze
hashing, en wij willen net zo dringend als u weten welke van de twee.