] * php verify.php json export.json --key * php verify.php file export.csv export.csv.sig --key (also for a report PDF and its .sig) * * Get the public key from https://virtum.cloud/transparency/key (compare it with the one in the signed * security.txt and the one you recorded when you first onboarded). Without --key the check still runs but the * result is "UNPINNED": a forger can sign with their own key, so a pinned key is what makes a pass meaningful. * * Exit status: 0 verified and pinned, 1 verification failed, 2 usage error, 3 verified but the key was not pinned. */ const GENESIS = '0000000000000000000000000000000000000000000000000000000000000000'; function fail(string $m, int $code = 1): void { fwrite(STDERR, $m."\n"); exit($code); } function ok(string $m): void { echo $m."\n"; } function canonical($v): string { $sort = function ($x) use (&$sort) { if (!is_array($x)) return $x; $isList = array_keys($x) === range(0, count($x) - 1); if ($isList) return array_map($sort, $x); ksort($x, SORT_STRING); return array_map($sort, $x); }; return json_encode($sort($v), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); } function sigok(string $msg, string $sigB64, string $keyB64): bool { $s = base64_decode($sigB64, true); $k = base64_decode($keyB64, true); return $s !== false && $k !== false && strlen($s) === SODIUM_CRYPTO_SIGN_BYTES && strlen($k) === SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES && sodium_crypto_sign_verify_detached($s, $msg, $k); } function entryHash(array $e): string { return hash('sha256', implode("\n", ['virtum-transparency-v1', $e['sequence'], $e['kind'], $e['component'], $e['version'], $e['image_digest'] ?? '', $e['signature_ref'] ?? '', $e['sbom_ref'] ?? '', $e['released_at'], $e['prev_hash']])); } function loadJson(string $path): array { $raw = @file_get_contents($path); $d = $raw === false ? null : json_decode($raw, true); if (!is_array($d)) fail("Cannot read $path as JSON.", 2); return $d; } function pinned(array $argv): ?string { $i = array_search('--key', $argv, true); return $i === false ? null : ($argv[$i + 1] ?? null); } function finish(array $problems, string $what, bool $isPinned): void { if ($problems) { foreach ($problems as $p) fwrite(STDERR, " - $p\n"); fail("FAILED: $what does not verify.", 1); } if (!$isPinned) { ok("VERIFIED but UNPINNED: $what is internally consistent. Re-run with --key to make this meaningful."); exit(3); } ok("VERIFIED: $what"); exit(0); } if (!function_exists('sodium_crypto_sign_verify_detached')) fail('The sodium extension is required.', 2); $mode = $argv[1] ?? ''; $file = $argv[2] ?? ''; $pin = pinned($argv); if ($file === '') fail("Usage: php verify.php log|json|file [] [--key ]", 2); if ($mode === 'log') { $feed = loadJson($file); $keys = []; foreach ($feed['public_keys'] ?? [] as $k) $keys[$k['key_id']] = $k['public_key']; $problems = []; $prev = GENESIS; $exp = 1; $by = []; foreach ($feed['entries'] ?? [] as $e) { $n = (int) $e['sequence']; if ($n !== $exp) { $problems[] = "entry $n: expected sequence $exp (an entry is missing or reordered)"; $exp = $n; } if (!hash_equals($prev, $e['prev_hash'])) $problems[] = "entry $n: does not link to the entry before it"; if (!hash_equals(entryHash($e), $e['entry_hash'])) $problems[] = "entry $n: hash does not match contents (edited)"; $key = $keys[$e['key_id']] ?? null; if ($key === null || !sigok($e['entry_hash'], $e['signature'], $key)) $problems[] = "entry $n: signature does not verify against the key"; $by[$n] = $e; $prev = $e['entry_hash']; $exp = $n + 1; } foreach ($by as $n => $e) if ($e['kind'] === 'anchor_checkpoint') { $t = $by[(int) $e['version']] ?? null; if ($t === null || $e['image_digest'] !== 'sha256:'.$t['entry_hash']) $problems[] = "entry $n: checkpoint does not match the entry it records"; } if ($pin !== null && !in_array($pin, array_column($feed['public_keys'] ?? [], 'public_key'), true)) $problems[] = 'the pinned key is not among the feed\'s keys'; finish($problems, count($by).' log entries, hash chain and signatures', $pin !== null); } if ($mode === 'json') { $doc = loadJson($file); $sig = $doc['signature'] ?? null; unset($doc['signature']); $problems = []; if (!is_array($sig) || ($sig['algorithm'] ?? '') !== 'ed25519') fail('No ed25519 signature block.', 1); if ($pin !== null && !hash_equals($pin, $sig['public_key'])) $problems[] = 'signed with a key other than the pinned one'; if (!sigok(canonical($doc), $sig['value'], $sig['public_key'])) $problems[] = 'signature does not match the document (edited, or not signed by that key)'; if (($doc['format'] ?? '') === 'virtum-audit-export-v1') { $prev = null; foreach ($doc['entries'] ?? [] as $i => $e) { if ($prev !== null && $e['sequence'] === $prev['sequence'] + 1 && !hash_equals($prev['entry_hash'], $e['prev_hash'])) $problems[] = "entry {$e['sequence']}: does not link to the entry before it"; $prev = $e; } $last = end($doc['entries']) ?: null; if ($last && ($doc['chain']['head_sequence'] ?? null) !== $last['sequence']) $problems[] = 'chain head does not match the last entry'; if (!empty($doc['anchor'])) foreach ($doc['entries'] as $e) if ($e['sequence'] === $doc['anchor']['sequence'] && !hash_equals($e['entry_hash'], $doc['anchor']['head_hash'])) $problems[] = 'anchored hash differs from the exported entry at that sequence'; } finish($problems, ($doc['format'] ?? 'document').' (signature'.(($doc['format'] ?? '') === 'virtum-audit-export-v1' ? ', chain links, anchor' : '').')', $pin !== null); } if ($mode === 'csv' || $mode === 'file') { $sigFile = $argv[3] ?? ''; $sig = loadJson($sigFile); $bytes = @file_get_contents($file); if ($bytes === false) fail("Cannot read $file.", 2); $problems = []; if (!hash_equals($sig['sha256'] ?? '', hash('sha256', $bytes))) $problems[] = 'the CSV does not match the hash that was signed (edited)'; if ($pin !== null && !hash_equals($pin, $sig['public_key'])) $problems[] = 'signed with a key other than the pinned one'; if (!sigok('virtum-file-v1'."\n".($sig['sha256'] ?? '')."\n".($sig['workspace_id'] ?? '')."\n".($sig['generated_at'] ?? ''), $sig['value'] ?? '', $sig['public_key'] ?? '')) $problems[] = 'signature does not verify'; finish($problems, 'file (hash and signature)', $pin !== null); } fail('Mode must be log, json or file.', 2);