#!/usr/bin/env php ;`, `D;`, `S:`, `L:` for * lists and `M:` for maps — because in JSON `1` and `"1"` are different values and a * form that hashed them identically would let one be substituted for the other. * * `created_at` is used exactly as it appears. If a row's timestamp is not in the stored * format this script reports a break rather than reformatting it: a verifier that adjusts * its input to make it verify is not a verifier. * * REDACTED ROWS * * A row marked `"redacted": true` concerns a private board, or a job on one, that the person * who took the export cannot reach. Its target id and meta were removed, and its hash covers * them, so it cannot be recomputed here. This script still checks that it sits in its place — * the sequence has no gap and it names the right predecessor — and carries its recorded hash * forward, so the next recomputed row pins it. A redacted row that still carries a target id * or meta is refused: the mark would otherwise excuse a row from being checked. The report * lists how many rows were not recomputed. An export taken by somebody who can reach every * board has none. */ declare(strict_types=1); const GENESIS = '0000000000000000000000000000000000000000000000000000000000000000'; $file = $argv[1] ?? 'audit.jsonl'; if (!is_file($file)) { fwrite(STDERR, "usage: php verify.php audit.jsonl\n"); exit(2); } $h = fopen($file, 'rb'); if ($h === false) { fwrite(STDERR, "cannot read {$file}\n"); exit(2); } /** `n:` for absent, `sLEN:BYTES` otherwise. */ function o(?string $v): string { return $v === null ? 'n:' : 's' . strlen($v) . ':' . $v; } /** * The inner grammar for `meta`, byte-for-byte identical to the server's. * * Upper-case tags inside, lower-case outside, so a value inside meta can never interact * with the outer field framing. Types are tagged because in JSON `1` and `"1"` are * different values, and a form that hashed them the same would let one be substituted for * the other without changing the hash. */ function canonValue(mixed $v): string { if ($v === null) return 'N'; if ($v === true) return 'T'; if ($v === false) return 'F'; if (is_int($v)) return 'I' . $v . ';'; // `%.17G` rather than JSON's own float text: json_encode's output depends on the // serialize_precision ini setting, so a form embedding it would not be canonical. if (is_float($v)) return 'D' . sprintf('%.17G', $v) . ';'; if (is_string($v)) return 'S' . strlen($v) . ':' . $v; if (is_array($v)) { // Note: an EMPTY array is a map here, not a list — array_keys([]) is [] and // range(0, -1) is [0, -1], so the comparison is false. Stated because it looks // like an accident and both sides depend on it agreeing. $isList = array_keys($v) === range(0, count($v) - 1); if ($isList) { $out = 'L' . count($v) . ':'; foreach ($v as $item) $out .= canonValue($item); return $out; } $keys = array_map('strval', array_keys($v)); sort($keys, SORT_STRING); $out = 'M' . count($keys) . ':'; foreach ($keys as $k) { $out .= 'S' . strlen($k) . ':' . $k . canonValue($v[$k]); } return $out; } return 'X'; } /** * `meta` is canonicalised from its DECODED form, never from the stored bytes. * * That is what makes the hash survive a JSON store that normalises on write — MySQL's * native JSON type strips whitespace, reorders object keys and collapses duplicates. The * server canonicalises the round trip for exactly this reason, so this file must too. */ function canonicalMeta(mixed $meta): ?string { if ($meta === null || $meta === '') return null; $decoded = is_string($meta) ? json_decode($meta, true) : $meta; if (!is_array($decoded)) return 'X'; // unparseable: hashed as such, not skipped return canonValue($decoded); } function canonical(array $r): string { $int = static fn(mixed $v): ?string => $v === null ? null : (string)(int)$v; $str = static fn(string $k) => isset($r[$k]) && $r[$k] !== null ? (string)$r[$k] : null; return "jdaudit1\n" . o((string)$r['chain_key']) . o((string)(int)$r['chain_seq']) . o((string)$r['prev_hash']) . o($int($r['org_id'] ?? null)) . o($int($r['actor_user_id'] ?? null)) . o($str('actor_type')) . o($str('action')) . o($str('target_type')) . o($str('target_id')) . o($str('ip_address')) . o($str('user_agent')) . o(canonicalMeta($r['meta'] ?? null)) . o((string)$r['created_at']); } $rows = []; $line = 0; while (($raw = fgets($h)) !== false) { $line++; $raw = trim($raw); if ($raw === '') continue; $row = json_decode($raw, true); if (!is_array($row)) { fwrite(STDERR, "line {$line} is not JSON\n"); exit(2); } $rows[] = $row; } fclose($h); if ($rows === []) { echo "the file contains no rows.\n"; exit(0); } // Exports are written newest-first because that is how a person reads a trail; the chain // runs the other way. Sorted here rather than assumed, so the file's order is irrelevant. usort($rows, static fn(array $a, array $b): int => (int)$a['chain_seq'] <=> (int)$b['chain_seq']); $expectedPrev = null; $expectedSeq = null; $checked = 0; $redacted = 0; $redactedSeqs = []; $lastRecomputed = null; $firstSeq = (int)$rows[0]['chain_seq']; foreach ($rows as $r) { $seq = (int)$r['chain_seq']; $id = $r['id'] ?? '?'; // A chain that starts in this file starts from 64 zeros. The server's own verifier refuses a // first row that does not; this one declared the constant and never compared it. It matters // most for a redacted first row, whose hash is not recomputed and so pins nothing. if ($expectedSeq === null && $seq === 1 && (string)$r['prev_hash'] !== GENESIS) { fwrite(STDERR, "BREAK at seq 1 (id {$id}): the first row of the chain does not start from " . "the genesis predecessor, so it is not the row this chain was written with.\n"); exit(1); } if ($expectedSeq !== null && $seq !== $expectedSeq) { fwrite(STDERR, "BREAK at seq {$seq} (id {$id}): sequence gap — expected " . "{$expectedSeq}.\nA gap means a row is missing from this file. If you exported " . "a date range or a filtered view, that is expected and you should re-export " . "the whole trail to verify it. Otherwise a row has been removed.\n"); exit(1); } if ($expectedPrev !== null && (string)$r['prev_hash'] !== $expectedPrev) { fwrite(STDERR, "BREAK at seq {$seq} (id {$id}): this row names a different " . "predecessor than the row before it.\n"); exit(1); } if ((int)($r['canon'] ?? 0) !== 1) { fwrite(STDERR, "BREAK at seq {$seq} (id {$id}): canonical form " . (int)($r['canon'] ?? 0) . " is not one this script knows. It was written by a " . "newer version of Jobdeck; ask for a verifier that matches.\n"); exit(1); } if (!preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', (string)$r['created_at'])) { fwrite(STDERR, "BREAK at seq {$seq} (id {$id}): created_at is not in the stored " . "format, so this row cannot be checked without changing it — and a verifier " . "that adjusts its input is not a verifier.\n"); exit(1); } if (($r['redacted'] ?? false) === true) { if (($r['target_id'] ?? null) !== null || ($r['meta'] ?? null) !== null) { fwrite(STDERR, "BREAK at seq {$seq} (id {$id}): this row is marked redacted and still " . "carries a target id or meta. A redacted row has neither, so a row that keeps them " . "has been marked to avoid being recomputed.\n"); exit(1); } // Placed and linked, not recomputed: its hash covers the fields that were removed. $expectedPrev = (string)$r['row_hash']; $expectedSeq = $seq + 1; $redacted++; $redactedSeqs[] = $seq; continue; } $computed = hash('sha256', canonical($r)); if ($computed !== (string)$r['row_hash']) { fwrite(STDERR, "BREAK at seq {$seq} (id {$id}): this row's contents do not match its " . "hash.\n recorded: " . (string)$r['row_hash'] . "\n computed: {$computed}\n" . "The row has been altered since it was written.\n"); exit(1); } $expectedPrev = $computed; $expectedSeq = $seq + 1; $lastRecomputed = $seq; $checked++; } /* * REDACTED ROWS * * A row about a private board the exporter cannot reach arrives with its target id and details * removed. Its hash covers what was removed, so it cannot be recomputed from this file — and * nothing else in it can be checked either. Its place in the sequence and its link to the rows * either side are checked; its action, who did it, when and from where are NOT. Somebody holding * this file could change those fields on a redacted row and no hash here would disagree. Rows * after the last recomputed one are pinned by nothing at all. * * This used to print "OK" and exit 0 with that caveat underneath, which is a pass for a file this * script has not verified. So it is PARTIAL, exit 3, with the rows named. An export taken by * somebody who can reach every board has no redacted rows, and verifies whole. */ if ($redacted === 0) { printf("OK — %d rows verified, seq %d to %d.\n", $checked, $firstSeq, $expectedSeq - 1); } else { printf("PARTIAL — %d rows verified; %d redacted rows in seq %d to %d could not be.\n", $checked, $redacted, $firstSeq, $expectedSeq - 1); printf("A redacted row concerns a private board the person who took this export cannot reach. " . "Its place in the chain was checked. Its contents were not: its hash covers the target " . "and details that were removed, so this script cannot tell whether its action, actor, " . "time or address were changed after the export was taken. To verify them, check an " . "export taken by somebody who can reach every board.\n"); printf("Not verified: seq %s%s.\n", implode(', ', array_slice($redactedSeqs, 0, 50)), $redacted > 50 ? ' and ' . ($redacted - 50) . ' more' : ''); if ($lastRecomputed === null || $lastRecomputed < $expectedSeq - 1) { printf("No row after seq %s was recomputed, so nothing in this file pins the rows after it.\n", $lastRecomputed === null ? 'the start' : (string)$lastRecomputed); } } if ($firstSeq !== 1) { printf("Note: this export starts at seq %d, so it is a window rather than the whole " . "trail. Everything verified in the window is intact and consistent; rows before seq %d " . "are not in this file.\n", $firstSeq, $firstSeq); } exit($redacted === 0 ? 0 : 3);