Verify

Check our work without asking us

Every StandardOS export contains the audit trail, the hashes, and the content those hashes were computed over. This page is the rule for recomputing them and a script that does it. Neither needs StandardOS running, an account, or our permission.

What this proves, and what it does not

Each entry seals the one before it. Change any field of any historical entry and its hash no longer matches — and because the next entry's prev_hash is that hash, everything after it breaks too. Deleting an entry breaks the link across the gap.

It does not prove any particular event actually happened, and the hashing rule is public and unkeyed — so anyone who can write to the database could recompute every later hash. That is what the daily published roots and the anchor receipt in your own mailbox are for: we cannot alter a copy you already hold.

Run it

Node's built-in crypto and nothing else — no install step, no network, no trust in us beyond the file you were given. Exit code 0 if every entry verifies, 1 if any does not.

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

A worked example

The cheapest check in the specification, on a real chain. The first entry's prev_hash must be the genesis seed, which is just the SHA-256 of the organization's id:

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

entry 1 prev_hash
bdf11e687b6e9e83bce787aa7239f60e1c44f5d4637bb34e45cdce4fa27cfdb0   ✓

That entry's own row_hash is 42743ec31dbaf5e5ff68807133991f10f78a134009c6b23ad09a172a16e91745, which becomes the next entry's prev_hash, and so on to the head. The script does this for every entry and tells you the first one that disagrees.

The specification

The exact preimage, the timestamp format that everyone gets wrong the first time, and how hash_version works so that history is never rehashed when the rule changes. Written to be implementable in any language.

Read the specification

The script

144 lines · no dependencies · download

This is the same file our own CI runs against a real chain on every push, served from the repository rather than copied here — so the version you read is the version that is tested. If it and the specification ever disagree, the specification is wrong.

#!/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 — entries have been removed from the beginning"
            : "this entry does not chain from the previous one — 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 — cannot derive the genesis seed",
    );
    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);
}

Implementing this yourself and something does not match? Tell us at security@getstandardos.com. A mismatch is either a bug in our specification or a real finding, and we want to know which.