Spezifikation

Eine StandardOS-Prüfkette verifizieren

Jeder StandardOS-Export enthält ein Array audit_trail. Dieses Dokument legt genau fest, wie jeder Eintrag gehasht wird, damit Sie die Kette selbst nachrechnen können, in jeder Programmiersprache, ohne StandardOS auszuführen und ohne uns zu vertrauen.

Eine vollständige Implementierung in etwa hundert Zeilen JavaScript ohne Abhängigkeiten ist neben diesem Dokument unter https://getstandardos.com/verify-chain.mjs veröffentlicht. Sie ist zum Lesen geschrieben; wer sie lieber selbst implementiert, dem genügt dieses Dokument.

Was die Kette garantiert

Jeder Eintrag versiegelt den Eintrag davor. Ändern Sie ein beliebiges Feld eines historischen Eintrags, stimmt sein row_hash nicht mehr, und weil der prev_hash des nächsten Eintrags genau dieser Hash ist, bricht auch jeder folgende Eintrag. Das Löschen eines Eintrags bricht die Verbindung über die Lücke. Das Anhängen eines erfundenen Eintrags setzt nur die Kenntnis des aktuellen Kopfes voraus, und das ist in Ordnung. Die Kette beweist, dass die Geschichte nicht umgeschrieben wurde, nicht, dass ein bestimmtes Ereignis stattgefunden hat.

Was sie für sich allein nicht garantiert

Die Hash-Regel ist öffentlich und ohne Schlüssel, und das ist Absicht: Eine Regel mit Schlüssel wäre nur für den prüfbar, der den Schlüssel hält, und das wären wir. Die Folge ist, dass jeder, der in die Datenbank schreiben kann, auch jeden folgenden Hash neu berechnen kann, und die Kette allein zeigt das nicht.

Dafür gibt es die Anker. Siehe „Anker“ weiter unten. Prüfen Sie die Kette gegen eine Ankerquittung, die Sie bereits besitzen, und der Angriff durch Neuberechnung ist geschlossen, denn eine Quittung, die schon in Ihrem Postfach liegt, können wir nicht mehr ändern.

Das Urbild

Für jeden Eintrag lautet der Hash:

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

Das Urbild ist die Verkettung der folgenden Teile, ohne Trennzeichen außer dort, wo sie gezeigt werden:

# Teil Hinweise
1 prev_hash 64 hexadezimale Kleinbuchstaben
2 old_data die Zeichenkette wie exportiert, oder "" bei null
3 new_data die Zeichenkette wie exportiert, oder "" bei null
4 action INSERT, UPDATE oder DELETE
5 table_name z. B. risks
6 (nur hash_version ≥ 2) actor + "|" + record_id actor ist "" bei null
7 (nur hash_version ≥ 3) "|" + principal "" bei null
8 occurred_at genau wie unten formatiert

Das Urbild jeder Version ist eine strikte Präfix-Erweiterung der vorigen, ein v1-Eintrag wird also genau so gehasht wie vor v2. Wenden Sie die Regel an, die die eigene hash_version der Zeile nennt. Die Geschichte wird nie neu gehasht, wenn sich die Regel ändert; deshalb gibt es die Spalte.

Formatierung von occurred_at

Das ist der Teil, den viele falsch machen. Der Zeitstempel wird in UTC gerendert als:

YYYY-MM-DDTHH:MM:SS.ffffffZ

Genau sechs Nachkommastellen der Sekunde, immer vorhanden, immer mit Nullen aufgefüllt, zum Beispiel 2026-07-30T17:41:39.558458Z. Postgres erzeugt das mit to_char(occurred_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"').

Beachten Sie, dass Date.toISOString() in JavaScript drei Nachkommastellen ausgibt, nicht sechs, und deshalb den falschen Hash liefert. Das exportierte occurred_at ist eine ISO-Zeichenkette mit Mikrosekunden; verwenden Sie ihre Ziffern direkt, statt sie durch einen Datumstyp zu schicken, der sie nicht halten kann.

old_data / new_data

Absichtlich als Zeichenketten exportiert, nicht als Objekte. Die gehashten Bytes sind die kanonische jsonb-Darstellung von Postgres: Objektschlüssel sortiert nach Länge, dann byteweise, ein Leerzeichen nach jedem : und ,. JSON.stringify reproduziert das nicht. Hashen Sie die Zeichenkette genau wie exportiert. Parsen Sie sie nur, wenn Sie sie lesen wollen.

Ein INSERT hat kein old_data; ein DELETE hat kein new_data. Beide sind im Export null und tragen die leere Zeichenkette zum Urbild bei.

Die Kette prüfen

  1. Sortieren Sie die Einträge nach id aufsteigend, innerhalb einer org_id.
  2. Der prev_hash des ersten Eintrags muss dem Genesis-Seed entsprechen: sha256(utf8(org_id)) als hexadezimale Kleinbuchstaben, wobei org_id die UUID in ihrer kanonischen Form mit Bindestrichen und Kleinbuchstaben ist.
  3. Für jeden Eintrag: row_hash aus dem Urbild neu berechnen und vergleichen; dann bestätigen, dass der prev_hash des nächsten Eintrags dem row_hash dieses Eintrags entspricht.

Schlägt Schritt 2 fehl, wurden Einträge am Anfang entfernt. Schlägt Schritt 3 bei Eintrag n fehl, ist die Kette für die ersten n−1 Einträge intakt und etwas bei n stimmt nicht mit dem überein, was aufgezeichnet wurde.

Anker

Jeden Tag zeichnet StandardOS den Kopf der Kette jeder Organisation auf und schickt dieser Organisation per E-Mail eine Quittung mit dem Kopf-Hash, der Anzahl der Einträge und dem Datum. Diese Quittungen sind der unabhängige Teil: Sie liegen in Ihrem Postfach, nicht in unserer Datenbank.

Nehmen Sie dazu den Kopf-Hash aus einer Quittung, die älter ist als der Zeitraum, um den es Ihnen geht, suchen Sie den Eintrag mit diesem row_hash in Ihrem Export und prüfen Sie die Kette bis zu diesem Punkt. Eine Neuberechnung nach dem Versand der Quittung kann diesen Kopf-Hash nicht noch einmal erzeugen.

Bewahren Sie die Quittungen auf. Sie sind der Grund, warum „selbst wir können es nicht unbemerkt ändern“ eine Aussage über Mathematik ist und nicht über unsere guten Absichten.

Was ein Anker nicht abdeckt

Ein Anker um 03:40 sagt nichts über Ereignisse, die um 09:00 geschrieben wurden. Zwischen zwei Ankerläufen gibt es ein Fenster, in dem ein Ereignis geschrieben und wieder entfernt werden kann, ohne dass etwas dem widerspricht: Die Hash-Kette bemerkt es nicht, weil das Löschen der neuesten Einträge keine Verbindung bricht, und kein Anker sie je abgedeckt hat.

Zwei Dinge verengen dieses Fenster, ohne es zu schließen. Jeder nächtliche Lauf prüft, dass der vorige Anker noch gilt, das heißt, dass das aufgezeichnete Ereignis mit demselben Hash noch vorhanden ist und dass die Kette nicht unter die verankerte Anzahl geschrumpft ist, und zeichnet andernfalls einen Alarm auf. Alles, was einen Anker überdauert, ist von da an geschützt. Und der tiefe Durchlauf leitet jede Kette täglich von der Genesis her neu ab, was Inhalte erkennt, die ohne Neuberechnung geändert wurden.

Unabgedeckt bleibt ein Ankerintervall, für Ereignisse, die innerhalb davon erzeugt und wieder vernichtet wurden. Wir schreiben das lieber auf, als einen Leser etwas anderes annehmen zu lassen.

Was die Hash-Regel abdeckt, je Version

Die Regel wurde zweimal erweitert, und jede Zeile hält fest, welche Version sie geschrieben hat.

Version Versiegelt
1 Inhalt, Aktion, Tabelle, Zeitstempel und die Verbindung zum vorigen Eintrag
2 das Obige, plus actor und record_id
3 das Obige, plus principal

Die Geschichte wird nie neu gehasht, wenn sich die Regel ändert. Ein Nur-Anhängen-Protokoll so umzuschreiben, dass es unter einer neuen Regel verifiziert, ist genau die Operation, die das Protokoll erkennbar machen soll. Die Folge ist, dass in einem v1- oder v2-Eintrag die Zuordnungsspalten aufgezeichnet, aber nicht versiegelt sind: Sie können geändert werden, ohne die Verifikation zu brechen. Der Export markiert das je Zeile (principal_attested, actor_attested), und verify-chain.mjs gibt aus, wie viele Einträge betroffen sind. Behandeln Sie eine unbestätigte Zuordnung als Behauptung, nicht als Beweis, wer gehandelt hat. Inhalt, Reihenfolge und Zeitpunkt jedes Eintrags werden unabhängig von der Version verifiziert.

Testvektoren

Wenn Sie die obige Regel implementiert haben, prüfen Sie sie gegen diese Vektoren, bevor Sie Ihrer Implementierung bei einem echten Export vertrauen. Jeder Eintrag ist eine vollständige Prüfpfad-Zeile mit dem row_hash, den sie erzeugen muss. Sie decken alle drei Hash-Versionen ab, sowohl leeres als auch gefülltes old_data/new_data, und einen Sekundenbruchteil, der nicht schon sechs Ziffern hat.

Das ist keine Dekoration. Unsere CI berechnet jeden dieser Vektoren bei jedem Push mit dem veröffentlichten Verifizierer neu, sodass eine Änderung der Hash-Regel, die dieses Dokument nicht aktualisiert, unseren Build scheitern lässt und nicht Ihr 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"
  }
]

Eine Abweichung melden

Schlägt eine Verifikation fehl und Sie halten die Daten für echt, schreiben Sie uns an security@getstandardos.com mit der id des Eintrags und dem, was Sie berechnet haben. Eine Kette, die nicht verifiziert, ist entweder Manipulation oder ein Fehler in unserem Hashing, und wir wollen so dringend wie Sie wissen, welches von beiden.

Diese Seite auf:EnglishFrançaisNederlandsEspañolDansk