Vérifier
Vérifiez notre travail sans nous demander
Chaque export StandardOS contient la piste d'audit, les hachages, et le contenu sur lequel ces hachages ont été calculés. Cette page est la règle pour les recalculer et un script qui le fait. Ni l'un ni l'autre n'a besoin que StandardOS tourne, d'un compte, ni de notre permission.
Ce que cela prouve, et ce que cela ne prouve pas
Chaque entrée scelle la précédente. Modifiez n'importe quel champ de n'importe quelle entrée historique et son hachage ne correspond plus, et comme le prev_hash de l'entrée suivante est ce hachage, tout ce qui suit casse aussi. Supprimer une entrée casse le lien au-dessus du trou.
Cela ne prouve pas qu'un événement donné a réellement eu lieu, et la règle de hachage est publique et sans clé, si bien que quiconque peut écrire dans la base de données pourrait recalculer tous les hachages suivants. C'est à cela que servent les racines publiées quotidiennes et le reçu d'ancrage dans votre propre boîte mail : nous ne pouvons pas altérer une copie que vous détenez déjà.
L'exécuter
La cryptographie intégrée de Node et rien d'autre : pas d'installation, pas de réseau, aucune confiance en nous au-delà du fichier qu'on vous a remis. Code de sortie 0 si chaque entrée est vérifiée, 1 si l'une ne l'est pas.
curl -O https://getstandardos.com/verify-chain.mjs
node verify-chain.mjs standardos-export.jsonUn exemple détaillé
La vérification la plus simple de la spécification, sur une vraie chaîne. Le prev_hash de la première entrée doit être la graine de genèse, qui n'est que le SHA-256 de l'identifiant de l'organisation :
$ printf '0e86d1c6-805e-4e17-854c-03eea9085642' | sha256sum
bdf11e687b6e9e83bce787aa7239f60e1c44f5d4637bb34e45cdce4fa27cfdb0
entry 1 prev_hash
bdf11e687b6e9e83bce787aa7239f60e1c44f5d4637bb34e45cdce4fa27cfdb0 ✓Le row_hash propre de cette entrée est 42743ec31dbaf5e5ff68807133991f10f78a134009c6b23ad09a172a16e91745, qui devient le prev_hash de l'entrée suivante, et ainsi de suite jusqu'à la tête. Le script fait cela pour chaque entrée et vous indique la première qui diverge.
La spécification
La pré-image exacte, le format d'horodatage que tout le monde se trompe à écrire la première fois, et le fonctionnement de hash_version pour que l'historique ne soit jamais rehaché quand la règle change. Écrite pour être implémentable dans n'importe quel langage.
Lire la spécification(en anglais)
Le script
144 lignes · aucune dépendance · télécharger
C'est le même fichier que notre propre CI exécute contre une vraie chaîne à chaque push, servi depuis le dépôt plutôt que copié ici, si bien que la version que vous lisez est la version testée. Si lui et la spécification venaient à diverger, c'est la spécification qui a tort.
#!/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);
}
Vous l'implémentez vous-même et quelque chose ne correspond pas ? Dites-le-nous à security@getstandardos.com. Une divergence est soit un bug dans notre spécification, soit un vrai constat, et nous voulons savoir lequel.