#!/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. */ 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; $firstSeq = (int)$rows[0]['chain_seq']; foreach ($rows as $r) { $seq = (int)$r['chain_seq']; $id = $r['id'] ?? '?'; 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); } $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; $checked++; } printf("OK — %d rows verified, seq %d to %d.\n", $checked, $firstSeq, $expectedSeq - 1); if ($firstSeq !== 1) { printf("Note: this export starts at seq %d, so it is a window rather than the whole " . "trail. Everything in the window is intact and consistent; rows before seq %d are " . "not in this file.\n", $firstSeq, $firstSeq); } exit(0);