Verifizieren
Unsere Arbeit prüfen, ohne uns zu fragen
Jeder StandardOS-Export enthält das Audit-Protokoll, die Hashes und den Inhalt, über den diese Hashes berechnet wurden. Diese Seite ist die Regel, um sie nachzurechnen, und ein Skript, das es tut. Beides braucht weder ein laufendes StandardOS noch ein Konto noch unsere Erlaubnis.
Was das beweist, und was nicht
Jeder Eintrag versiegelt den vorhergehenden. Ändern Sie irgendein Feld irgendeines historischen Eintrags, stimmt sein Hash nicht mehr, und weil der prev_hash des nächsten Eintrags dieser Hash ist, bricht auch alles danach. Das Löschen eines Eintrags bricht die Verbindung über die Lücke.
Es beweist nicht, dass ein bestimmtes Ereignis tatsächlich stattgefunden hat, und die Hash-Regel ist öffentlich und ohne Schlüssel, sodass jeder mit Schreibzugriff auf die Datenbank jeden späteren Hash neu berechnen könnte. Dafür sind die täglich veröffentlichten Wurzeln und die Anker-Quittung in Ihrem eigenen Postfach da: Eine Kopie, die Sie bereits besitzen, können wir nicht verändern.
Ausführen
Die eingebaute Kryptografie von Node und sonst nichts: kein Installationsschritt, kein Netzwerk, kein Vertrauen in uns jenseits der Datei, die Sie erhalten haben. Exit-Code 0, wenn jeder Eintrag verifiziert, 1, wenn irgendeiner nicht.
curl -O https://getstandardos.com/verify-chain.mjs
node verify-chain.mjs standardos-export.jsonEin durchgerechnetes Beispiel
Die einfachste Prüfung in der Spezifikation, an einer echten Kette. Der prev_hash des ersten Eintrags muss der Genesis-Seed sein, und das ist einfach der SHA-256 der Organisations-ID:
$ printf '0e86d1c6-805e-4e17-854c-03eea9085642' | sha256sum
bdf11e687b6e9e83bce787aa7239f60e1c44f5d4637bb34e45cdce4fa27cfdb0
entry 1 prev_hash
bdf11e687b6e9e83bce787aa7239f60e1c44f5d4637bb34e45cdce4fa27cfdb0 ✓Der eigene row_hash dieses Eintrags ist 42743ec31dbaf5e5ff68807133991f10f78a134009c6b23ad09a172a16e91745, und der wird zum prev_hash des nächsten Eintrags, und so weiter bis zum Kopf. Das Skript tut das für jeden Eintrag und nennt Ihnen den ersten, der nicht übereinstimmt.
Die Spezifikation
Das exakte Preimage, das Zeitstempelformat, das alle beim ersten Mal falsch machen, und wie hash_version funktioniert, damit die Historie nie neu gehasht wird, wenn sich die Regel ändert. Geschrieben, um in jeder Sprache implementierbar zu sein.
Die Spezifikation lesen(auf Englisch)
Das Skript
144 Zeilen · keine Abhängigkeiten · herunterladen
Das ist dieselbe Datei, die unsere eigene CI bei jedem Push gegen eine echte Kette ausführt, aus dem Repository ausgeliefert statt hierher kopiert, sodass die Version, die Sie lesen, die Version ist, die getestet wird. Sollten sie und die Spezifikation je auseinanderfallen, ist die Spezifikation falsch.
#!/usr/bin/env node
/**
* Verify a StandardOS audit chain from an export, without StandardOS.
*
* node verify-chain.mjs standardos-export.json
*
* Node's built-in crypto and nothing else: no install step, no network, no
* trust in us beyond the file you were given. The specification it implements
* is published at https://getstandardos.com/chain-verification.md; if the two
* ever disagree the document is wrong, because this file is executed by CI
* against a real chain on every push.
*
* Exit code 0 if every entry verifies, 1 if any does not.
*/
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
const sha256 = (s) => createHash("sha256").update(s, "utf8").digest("hex");
/**
* The timestamp must render with exactly six fractional digits, in UTC.
* Date.toISOString() gives three and would silently produce a wrong hash on
* every row, so the exported string's own digits are used and only padded.
*/
export function formatOccurredAt(iso) {
const m = String(iso).match(
/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?/,
);
if (!m) throw new Error(`unparseable occurred_at: ${iso}`);
const [, y, mo, d, h, mi, s, frac = ""] = m;
// The export is already UTC (Postgres renders it at time zone 'UTC').
return `${y}-${mo}-${d}T${h}:${mi}:${s}.${frac.padEnd(6, "0").slice(0, 6)}Z`;
}
/** The preimage, per https://getstandardos.com/chain-verification.md */
export function preimage(row) {
const v = Number(row.hash_version ?? 1);
let s =
row.prev_hash +
(row.old_data ?? "") +
(row.new_data ?? "") +
row.action +
row.table_name;
if (v >= 2) s += `${row.actor ?? ""}|${row.record_id ?? ""}`;
if (v >= 3) s += `|${row.principal ?? ""}`;
return s + formatOccurredAt(row.occurred_at);
}
/** Per-org chain seed: sha256 of the org UUID text. */
export const genesisSeed = (orgId) => sha256(orgId);
export function verifyChain(entries, orgId) {
const rows = [...entries].sort((a, b) => Number(a.id) - Number(b.id));
let expectedPrev = genesisSeed(orgId);
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
if (row.prev_hash !== expectedPrev)
return {
ok: false,
checked: i,
id: row.id,
why:
i === 0
? "the first entry does not chain from the genesis seed, so entries have been removed from the beginning"
: "this entry does not chain from the previous one, so an entry has been removed or reordered",
};
const computed = sha256(preimage(row));
if (computed !== row.row_hash)
return {
ok: false,
checked: i,
id: row.id,
why: `recomputed hash does not match the recorded one (expected ${row.row_hash}, computed ${computed})`,
};
expectedPrev = row.row_hash;
}
return { ok: true, checked: rows.length, head: expectedPrev };
}
// --- CLI ------------------------------------------------------------------
// globalThis._importMeta_.main is not available on the Node versions this needs to run on
// for an auditor who just installed it, so compare argv instead.
if (
process.argv[1] &&
globalThis._importMeta_.url.endsWith(process.argv[1].split("/").pop())
) {
const path = process.argv[2];
if (!path) {
console.error("usage: node verify-chain.mjs <standardos-export.json>");
process.exit(2);
}
const doc = JSON.parse(readFileSync(path, "utf8"));
const entries = doc.audit_trail ?? [];
const orgId = doc.organization?.id;
if (!orgId) {
console.error(
"export has no organization.id, so the genesis seed cannot be derived",
);
process.exit(2);
}
if (entries.length && doc.audit_trail[0].old_data === undefined) {
console.error(
"this export predates format standardos-export/v4 and does not carry the hashed content; request a fresh export",
);
process.exit(2);
}
const r = verifyChain(entries, orgId);
if (r.ok) {
console.log(`OK: ${r.checked} entries verified for org ${orgId}`);
console.log(`head ${r.head}`);
// Attribution is only sealed from hash_version 3. On earlier rows the
// `principal` and `actor` columns are stored but not covered by the hash,
// so they can be rewritten without breaking anything above. Saying "OK"
// without saying this would let a reader take the whole export as attested,
// which is precisely the mistake this line exists to prevent.
const unattested = entries.filter(
(e) => Number(e.hash_version ?? 1) < 3,
).length;
if (unattested)
console.log(
`\nNOTE: ${unattested} of ${r.checked} entries predate hash rule v3, ` +
"which is where attribution became part of the hash. For those rows the\n" +
"'principal' and (below v2) 'actor' fields are recorded but NOT sealed: " +
"treat them as\nclaims, not as proof of who acted. The content, order and " +
"timing of every entry are\nverified regardless.",
);
console.log(
"\nThis proves the history in this file has not been rewritten. To also rule out\n" +
"a recomputation by StandardOS, check the head above against an anchor receipt\n" +
"you received by email before the period in question.",
"See https://getstandardos.com/verify",
);
process.exit(0);
}
console.error(
`FAILED: intact for the first ${r.checked} entries; entry ${r.id} does not verify.\n${r.why}`,
);
process.exit(1);
}
Sie implementieren das selbst, und etwas stimmt nicht überein? Sagen Sie es uns unter security@getstandardos.com. Eine Abweichung ist entweder ein Fehler in unserer Spezifikation oder eine echte Feststellung, und wir wollen wissen, welches von beiden.