Sådan verificerer du en StandardOS-revisionskæde
Enhver StandardOS-eksport indeholder et array audit_trail. Dette dokument
fastlægger præcis, hvordan hver post hashes, så du selv kan genberegne kæden,
i et hvilket som helst sprog, uden at køre StandardOS og uden at stole på os.
En komplet implementering på omkring hundrede linjer JavaScript uden afhængigheder er udgivet sammen med dette dokument på https://getstandardos.com/verify-chain.mjs. Den er skrevet for at blive læst, men vil du hellere implementere den selv, er dette dokument nok.
Hvad kæden garanterer
Hver post forsegler posten før den. Ændr et vilkårligt felt i en vilkårlig
historisk post, og dens row_hash passer ikke længere, og fordi den næste posts
prev_hash er netop den hash, bryder alle efterfølgende poster også. Sletning
af en post bryder leddet hen over hullet. At tilføje en opdigtet post kræver kun kendskab til det aktuelle hoved, og det er i orden. Kæden beviser, at historien ikke er omskrevet,
ikke at en bestemt hændelse har fundet sted.
Hvad den ikke garanterer alene
Hashreglen er offentlig og uden nøgle, og det er bevidst: en regel med nøgle kunne kun verificeres af den, der har nøglen, og det ville være os. Følgen er, at enhver, der kan skrive til databasen, også kan genberegne alle efterfølgende hashes, og kæden alene viser det ikke.
Det er det, ankrene er til. Se "Ankre" nedenfor. Verificér kæden mod en ankerkvittering, du allerede har, og genberegningsangrebet er lukket, fordi vi ikke kan ændre en kvittering, der allerede ligger i din indbakke.
Preimage
For hver post er hashen:
row_hash = lowercase_hex( sha256( utf8( preimage ) ) )
Preimage er sammenkædningen af følgende dele, uden skilletegn undtagen hvor det er vist:
| # | Del | Noter |
|---|---|---|
| 1 | prev_hash |
64 hexadecimale tegn med små bogstaver |
| 2 | old_data |
strengen som eksporteret, eller "" ved null |
| 3 | new_data |
strengen som eksporteret, eller "" ved null |
| 4 | action |
INSERT, UPDATE eller DELETE |
| 5 | table_name |
f.eks. risks |
| 6 | (kun hash_version ≥ 2) actor + "|" + record_id |
actor er "" ved null |
| 7 | (kun hash_version ≥ 3) "|" + principal |
"" ved null |
| 8 | occurred_at |
formateret præcis som nedenfor |
Hver versions preimage er en streng præfiksudvidelse af den forrige, så en
v1-post hashes præcis, som den blev, før v2 fandtes. Anvend den regel, som
rækkens egen hash_version nævner. Historien hashes aldrig om, når reglen
ændres; det er derfor, kolonnen findes.
Formatering af occurred_at
Det er den del, folk får galt. Tidsstemplet gengives i UTC som:
YYYY-MM-DDTHH:MM:SS.ffffffZ
Præcis seks decimaler på sekundet, altid til stede, altid udfyldt med nuller, for eksempel 2026-07-30T17:41:39.558458Z. Postgres laver det med
to_char(occurred_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"').
Bemærk, at JavaScripts Date.toISOString() giver tre decimaler, ikke seks,
og derfor giver den forkerte hash. Det eksporterede occurred_at er en
ISO-streng med mikrosekunder; brug dens cifre direkte i stedet for at sende dem
gennem en datotype, der ikke kan rumme dem.
old_data / new_data
Eksporteres bevidst som strenge, ikke objekter. De bytes, der hashes, er
Postgres' kanoniske jsonb-gengivelse: objektnøgler sorteret efter længde og
derefter bytevis, ét mellemrum efter hvert : og ,. JSON.stringify
gengiver ikke det. Hash strengen præcis som eksporteret. Parse den kun, hvis du
vil læse den.
Et INSERT har ingen old_data; et DELETE har ingen new_data. Begge er
null i eksporten og bidrager med den tomme streng til preimage.
Kontrol af kæden
- Sortér posterne efter
idstigende inden for étorg_id. - Den første posts
prev_hashskal være lig med genesis-frøet:sha256(utf8(org_id))som hexadecimal med små bogstaver, hvororg_ider UUID'et i sin kanoniske form med bindestreger og små bogstaver. - For hver post: genberegn
row_hashfra preimage og sammenlign; bekræft derefter, at den næste postsprev_hasher lig med denne postsrow_hash.
Fejler trin 2, er der fjernet poster fra begyndelsen. Fejler trin 3 ved post n, er kæden intakt for de første n−1 poster, og noget ved n stemmer ikke med det, der blev registreret.
Ankre
Hver dag registrerer StandardOS hovedet af hver organisations kæde og sender organisationen en kvittering på mail med hovedhashen, antallet af poster og datoen. De kvitteringer er den uafhængige del: de ligger i din indbakke, ikke i vores database.
For at bruge en tager du hovedhashen fra en kvittering, der er ældre end den
periode, du interesserer dig for, finder posten med den row_hash i din
eksport og verificerer kæden op til det punkt. En genberegning udført efter
kvitteringens afsendelse kan ikke frembringe den hovedhash igen.
Gem kvitteringerne. De er grunden til, at "selv vi kan ikke ændre det uopdaget" er et udsagn om matematik og ikke om vores gode hensigter.
Hvad et anker ikke dækker
Et anker taget kl. 03:40 siger intet om hændelser skrevet kl. 09:00. Mellem to ankerkørsler er der et vindue, hvor en hændelse kan skrives og fjernes igen, uden at noget modsiger det: hashkæden opdager det ikke, fordi sletning af de nyeste poster ikke bryder noget led, og intet anker nogensinde dækkede dem.
To ting indsnævrer vinduet uden at lukke det. Hver natlig kørsel kontrollerer, at det forrige anker stadig holder, altså at den registrerede hændelse stadig findes med samme hash, og at kæden ikke er skrumpet under det forankrede antal, og registrerer en alarm, hvis ikke. Så alt, hvad der overlever ét anker, er beskyttet fra da af. Og den dybe gennemgang udleder hver kæde fra genesis dagligt, hvilket fanger indhold, der er ændret uden genberegning.
Det, der forbliver udækket, er ét ankerinterval, for hændelser skabt og tilintetgjort inden for det. Vi vil hellere skrive det ned end lade en læser antage noget andet.
Hvad hashreglen dækker, pr. version
Reglen er udvidet to gange, og hver række registrerer, hvilken version der skrev den.
| Version | Forsegler |
|---|---|
| 1 | indhold, handling, tabel, tidsstempel og leddet til den forrige post |
| 2 | ovenstående plus actor og record_id |
| 3 | ovenstående plus principal |
Historien hashes aldrig om, når reglen ændres. At omskrive en log, der kun kan
tilføjes til, så den verificerer under en ny regel, er præcis den operation,
loggen findes for at gøre opdagelig. Følgen er, at på en v1- eller v2-post er
tilskrivningskolonnerne registreret, men ikke forseglet: de kan ændres uden
at bryde verifikationen. Eksporten markerer det pr. række
(principal_attested, actor_attested), og verify-chain.mjs udskriver, hvor
mange poster det gælder. Behandl en uattesteret tilskrivning som en påstand,
ikke som bevis for, hvem der handlede. Indhold, rækkefølge og tidspunkt for
hver post verificeres uanset version.
Testvektorer
Har du implementeret reglen ovenfor, så kontrollér den mod disse, før du stoler
på din implementering på en rigtig eksport. Hver post er en komplet
revisionssporsrække og den row_hash, den skal give. De dækker alle tre
hashversioner, både tomme og udfyldte old_data/new_data, og en
sekundbrøkdel, der ikke allerede har seks cifre.
Det er ikke pynt. Vores CI genberegner hver eneste af dem ved hvert push med den udgivne verifikator, så en ændring af hashreglen, der glemte at opdatere dette dokument, får vores build til at fejle i stedet for din revision.
[
{
"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"
}
]
Sådan melder du en uoverensstemmelse
Fejler en verifikation, og mener du, at dataene er ægte, så skriv til os på
security@getstandardos.com med postens id og det, du beregnede. En kæde, der
ikke verificerer, er enten manipulation eller en fejl i vores hashing, og vi
vil lige så gerne som du vide hvilken.