Verificar
Compruebe nuestro trabajo sin preguntarnos
Cada exportación de StandardOS contiene el rastro de auditoría, los hashes y el contenido sobre el que se calcularon esos hashes. Esta página es la regla para recalcularlos y un script que lo hace. Ninguno de los dos necesita que StandardOS esté en marcha, una cuenta ni nuestro permiso.
Lo que esto demuestra, y lo que no
Cada entrada sella la anterior. Cambie cualquier campo de cualquier entrada histórica y su hash deja de coincidir, y como el prev_hash de la entrada siguiente es ese hash, todo lo posterior también se rompe. Borrar una entrada rompe el enlace a través del hueco.
No demuestra que un evento concreto ocurriera realmente, y la regla de hash es pública y sin clave, así que cualquiera que pueda escribir en la base de datos podría recalcular todos los hashes posteriores. Para eso están las raíces publicadas diarias y el recibo de anclaje en su propio buzón: no podemos alterar una copia que usted ya tiene.
Ejecutarlo
La criptografía integrada de Node y nada más: sin instalación, sin red, sin confiar en nosotros más allá del archivo que le dieron. Código de salida 0 si cada entrada se verifica, 1 si alguna no.
curl -O https://getstandardos.com/verify-chain.mjs
node verify-chain.mjs standardos-export.jsonUn ejemplo resuelto
La comprobación más sencilla de la especificación, sobre una cadena real. El prev_hash de la primera entrada debe ser la semilla génesis, que es simplemente el SHA-256 del id de la organización:
$ printf '0e86d1c6-805e-4e17-854c-03eea9085642' | sha256sum
bdf11e687b6e9e83bce787aa7239f60e1c44f5d4637bb34e45cdce4fa27cfdb0
entry 1 prev_hash
bdf11e687b6e9e83bce787aa7239f60e1c44f5d4637bb34e45cdce4fa27cfdb0 ✓El propio row_hash de esa entrada es 42743ec31dbaf5e5ff68807133991f10f78a134009c6b23ad09a172a16e91745, que pasa a ser el prev_hash de la entrada siguiente, y así hasta la cabeza. El script lo hace para cada entrada y le indica la primera que discrepa.
La especificación
La preimagen exacta, el formato de marca de tiempo que todo el mundo escribe mal la primera vez, y cómo funciona hash_version para que el historial nunca se vuelva a hashear cuando cambia la regla. Escrita para poder implementarse en cualquier lenguaje.
Leer la especificación(en inglés)
El script
144 líneas · sin dependencias · descargar
Es el mismo archivo que nuestra propia CI ejecuta contra una cadena real en cada push, servido desde el repositorio en lugar de copiado aquí, de modo que la versión que lee es la versión que se prueba. Si alguna vez él y la especificación discrepan, la especificación está equivocada.
#!/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);
}
¿Lo está implementando usted mismo y algo no coincide? Díganoslo en security@getstandardos.com. Una discrepancia es o un fallo en nuestra especificación o un hallazgo real, y queremos saber cuál.