Verificér

Kontrollér vores arbejde uden at spørge os

Hver StandardOS-eksport indeholder auditsporet, hasherne og det indhold, hasherne blev beregnet over. Denne side er reglen for at genberegne dem og et script, der gør det. Ingen af delene kræver et kørende StandardOS, en konto eller vores tilladelse.

Hvad dette beviser, og hvad det ikke gør

Hver post forsegler den forrige. Ændr et vilkårligt felt i en vilkårlig historisk post, og dens hash passer ikke længere, og fordi den næste posts prev_hash er den hash, bryder alt efter den også. Sletning af en post bryder leddet over hullet.

Det beviser ikke, at en bestemt hændelse faktisk skete, og hash-reglen er offentlig og uden nøgle, så enhver, der kan skrive til databasen, kunne genberegne hver senere hash. Det er det, de daglige offentliggjorte rødder og ankerkvitteringen i jeres egen indbakke er til: vi kan ikke ændre en kopi, I allerede har.

Kør det

Nodes indbyggede kryptografi og intet andet: intet installationstrin, intet netværk, ingen tillid til os ud over den fil, I fik. Exit-kode 0, hvis hver post verificerer, 1, hvis nogen ikke gør.

curl -O https://getstandardos.com/verify-chain.mjs
node verify-chain.mjs standardos-export.json

Et gennemregnet eksempel

Den enkleste kontrol i specifikationen, på en rigtig kæde. Den første posts prev_hash skal være genesis-seedet, som blot er SHA-256 af organisationens id:

$ printf '0e86d1c6-805e-4e17-854c-03eea9085642' | sha256sum
bdf11e687b6e9e83bce787aa7239f60e1c44f5d4637bb34e45cdce4fa27cfdb0

entry 1 prev_hash
bdf11e687b6e9e83bce787aa7239f60e1c44f5d4637bb34e45cdce4fa27cfdb0   ✓

Den posts egen row_hash er 42743ec31dbaf5e5ff68807133991f10f78a134009c6b23ad09a172a16e91745, som bliver den næste posts prev_hash, og så videre til hovedet. Scriptet gør dette for hver post og fortæller jer den første, der ikke stemmer.

Specifikationen

Det præcise preimage, det tidsstempelformat, alle får galt første gang, og hvordan hash_version virker, så historikken aldrig hashes om, når reglen ændres. Skrevet til at kunne implementeres i ethvert sprog.

Læs specifikationen(på engelsk)

Scriptet

144 linjer · ingen afhængigheder · download

Det er den samme fil, vores egen CI kører mod en rigtig kæde ved hvert push, serveret fra repositoriet frem for kopieret herhen, så den version, I læser, er den version, der testes. Skulle den og specifikationen nogensinde være uenige, er det specifikationen, der er forkert.

#!/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);
}

Implementerer I det selv, og noget stemmer ikke? Sig det til os på security@getstandardos.com. En uoverensstemmelse er enten en fejl i vores specifikation eller en reel observation, og vi vil vide hvilken.