Verifiëren
Controleer ons werk zonder het ons te vragen
Elke StandardOS-export bevat het auditspoor, de hashes en de inhoud waarover die hashes zijn berekend. Deze pagina is de regel om ze na te rekenen en een script dat het doet. Geen van beide heeft een draaiend StandardOS, een account of onze toestemming nodig.
Wat dit bewijst, en wat niet
Elke vermelding verzegelt de vorige. Verander een willekeurig veld van een willekeurige historische vermelding en haar hash klopt niet meer, en omdat de prev_hash van de volgende vermelding die hash is, breekt alles daarna ook. Een vermelding verwijderen breekt de schakel over het gat.
Het bewijst niet dat een bepaalde gebeurtenis ook echt heeft plaatsgevonden, en de hashregel is openbaar en zonder sleutel, dus iedereen die naar de database kan schrijven zou elke latere hash opnieuw kunnen berekenen. Daar zijn de dagelijks gepubliceerde wortels en het ankerbewijs in uw eigen mailbox voor: een kopie die u al heeft, kunnen wij niet veranderen.
Uitvoeren
De ingebouwde cryptografie van Node en niets anders: geen installatiestap, geen netwerk, geen vertrouwen in ons buiten het bestand dat u heeft gekregen. Exitcode 0 als elke vermelding verifieert, 1 als er een niet verifieert.
curl -O https://getstandardos.com/verify-chain.mjs
node verify-chain.mjs standardos-export.jsonEen uitgewerkt voorbeeld
De eenvoudigste controle uit de specificatie, op een echte keten. De prev_hash van de eerste vermelding moet het genesis-zaad zijn, en dat is gewoon de SHA-256 van het id van de organisatie:
$ printf '0e86d1c6-805e-4e17-854c-03eea9085642' | sha256sum
bdf11e687b6e9e83bce787aa7239f60e1c44f5d4637bb34e45cdce4fa27cfdb0
entry 1 prev_hash
bdf11e687b6e9e83bce787aa7239f60e1c44f5d4637bb34e45cdce4fa27cfdb0 ✓De eigen row_hash van die vermelding is 42743ec31dbaf5e5ff68807133991f10f78a134009c6b23ad09a172a16e91745, en dat wordt de prev_hash van de volgende vermelding, en zo verder tot de kop. Het script doet dit voor elke vermelding en zegt u welke de eerste is die afwijkt.
De specificatie
De exacte preimage, het tijdstempelformaat dat iedereen de eerste keer verkeerd doet, en hoe hash_version werkt zodat de geschiedenis nooit opnieuw wordt gehasht als de regel verandert. Geschreven om in elke taal implementeerbaar te zijn.
Lees de specificatie(in het Engels)
Het script
144 regels · geen afhankelijkheden · downloaden
Dit is hetzelfde bestand dat onze eigen CI bij elke push tegen een echte keten uitvoert, geserveerd vanuit de repository in plaats van hierheen gekopieerd, zodat de versie die u leest de versie is die wordt getest. Als het en de specificatie ooit van elkaar afwijken, heeft de specificatie het mis.
#!/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);
}
Implementeert u dit zelf en klopt er iets niet? Laat het ons weten op security@getstandardos.com. Een afwijking is ofwel een fout in onze specificatie ofwel een echte bevinding, en wij willen weten welke van de twee.