'aws-eu-central-1', 'key' => 'Ygye4iueq29lzAyJAOLnoIeQWOziNniUu7aOAJCfPEg=', 'operator' => 'Sigelith'], ['name' => 'konveria', 'key' => 'M4xZMLdCTHlh4jMmYBuE4/qHqRTSJcb/rGQousTYsCw=', 'operator' => 'Konveria, an independent operator'], ]; /** How many of the three signatures are needed. */ public const REQUIRED = 2; private const RECEIPT_FIELDS = ['chain_hash', 'digest', 'key', 'seq', 'sig', 'utc', 'v']; private const COSIGN_FIELDS = ['cosigner', 'key', 'last_chain_hash', 'last_seq', 'log', 'root', 'sig', 'since', 'size', 'time', 'v']; private const UTC = '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/'; private const HEX64 = '/^[0-9a-f]{64}$/'; private const MAX_INT = 9007199254740991; private const MAX_PATH = 64; /** Fetch with the API client ($client->verify(), $client->cosign()) and check here. */ public static function check(object $client, string $digest): array { $verify = $client->verify($digest); $cosign = null; if (!empty($verify['found'])) { try { $cosign = $client->cosign($digest); } catch (RuntimeException $e) { $cosign = null; // no cosignatures yet: the cosigners count as pending } } return self::checkResponses($digest, $verify, $cosign); } /** * The whole check, offline: responses of GET /api/proof/verify and GET /api/proof/cosign. * $pins (['logKeys' => [...], 'cosigners' => [...]]) only for test vectors. */ public static function checkResponses(string $digest, array $verify, ?array $cosign, ?array $pins = null): array { $logKeys = $pins['logKeys'] ?? self::LOG_KEYS; $cosigners = $pins['cosigners'] ?? self::COSIGNERS; $receipt = $verify['receipt'] ?? null; $log = is_array($receipt) ? self::checkReceipt($receipt, $digest, $logKeys) : ['ok' => false, 'code' => 'missing', 'entry' => null]; $signers = [['kind' => 'log', 'name' => 'log', 'operator' => 'Sigelith', 'ok' => $log['ok'], 'code' => $log['code'], 'after' => null, 'by' => null]]; // The leaf comes from the signed receipt; without one, from the response — the audit path // must still reach the root the cosigner signed, so a changed number or time fails. $entry = $log['entry']; if ($entry === null && is_array($cosign) && self::isInt($cosign['seq'] ?? null, 1) && is_string($cosign['utc'] ?? null) && preg_match(self::UTC, $cosign['utc'])) { $entry = ['seq' => $cosign['seq'], 'digest' => $digest, 'utc' => $cosign['utc']]; } $confirmations = (is_array($cosign) && is_array($cosign['confirmations'] ?? null)) ? $cosign['confirmations'] : []; foreach ($cosigners as $c) { $conf = null; foreach ($confirmations as $x) { if (is_array($x) && ($x['cosigner'] ?? null) === $c['name']) { $conf = $x; break; } } $base = ['kind' => 'cosigner', 'name' => $c['name'], 'operator' => $c['operator'], 'ok' => false, 'code' => null, 'after' => null, 'by' => null]; if ($conf === null) { $signers[] = array_merge($base, ['code' => 'pending']); } elseif ($entry === null) { $signers[] = array_merge($base, ['code' => 'missing']); } else { $signers[] = array_merge($base, self::checkCosignature($conf, $entry, $cosigners)); } } $valid = count(array_filter($signers, function ($s) { return $s['ok']; })); $failed = count(array_filter($signers, function ($s) { return !$s['ok'] && !in_array($s['code'], ['pending', 'missing'], true); })); return ['digest' => $digest, 'entry' => $entry, 'signers' => $signers, 'valid' => $valid, 'required' => self::REQUIRED, 'ok' => $valid >= self::REQUIRED, 'failed' => $failed]; } /** Receipt sigelith-receipt-v1 (LOG.md 5a): ['ok', 'code', 'entry' => ['seq','digest','utc']]. */ public static function checkReceipt(array $receipt, string $digest, array $logKeys = self::LOG_KEYS): array { $fail = function (string $code) { return ['ok' => false, 'code' => $code, 'entry' => null]; }; if (!self::hasExactly($receipt, self::RECEIPT_FIELDS) || $receipt['v'] !== 'sigelith-receipt-v1' || !self::isInt($receipt['seq'], 1) || !self::isHex64($receipt['digest']) || !is_string($receipt['utc']) || !preg_match(self::UTC, $receipt['utc']) || !self::isHex64($receipt['chain_hash'])) { return $fail('structure'); } if ($receipt['digest'] !== $digest) { return $fail('digest'); } if (!in_array($receipt['key'], $logKeys, true)) { return $fail('key'); } $body = $receipt; unset($body['sig']); $message = self::jcsMessage('sigelith-receipt-v1|', $body); $ok = $message === null ? false : self::ed25519($receipt['key'], $message, $receipt['sig']); if ($ok === null) { return $fail('unsupported'); } if (!$ok) { return $fail($message === null ? 'structure' : 'signature'); } return ['ok' => true, 'code' => null, 'entry' => ['seq' => $receipt['seq'], 'digest' => $receipt['digest'], 'utc' => $receipt['utc']]]; } /** * One element of `confirmations` from GET /api/proof/cosign, for the entry ['seq','digest','utc']. * The time window comes from the SIGNED document (PROTOCOL.md 5), not from seen_after/seen_by. */ public static function checkCosignature(array $conf, array $entry, array $cosigners = self::COSIGNERS): array { $out = ['ok' => false, 'code' => null, 'after' => null, 'by' => null]; $pinned = null; foreach ($cosigners as $c) { if ($c['name'] === ($conf['cosigner'] ?? null)) { $pinned = $c; } } $doc = $conf['cosignature'] ?? null; if ($pinned === null) { return array_merge($out, ['code' => 'key']); } if (!is_array($doc) || !self::hasExactly($doc, self::COSIGN_FIELDS) || $doc['v'] !== 'sigelith-cosign-v1') { return array_merge($out, ['code' => 'structure']); } if ($doc['cosigner'] !== $pinned['name'] || $doc['key'] !== $pinned['key']) { return array_merge($out, ['code' => 'key']); } if ($doc['log'] !== self::LOG_HOST) { return array_merge($out, ['code' => 'log']); } $since = $doc['since']; if (!self::isInt($doc['size'], 1) || !self::isHex64($doc['root']) || !is_string($doc['time']) || !preg_match(self::UTC, $doc['time']) || ($since !== null && !(is_array($since) && self::hasExactly($since, ['size', 'time']) && self::isInt($since['size'], 0) && is_string($since['time']) && preg_match(self::UTC, $since['time'])))) { return array_merge($out, ['code' => 'structure']); } $body = $doc; unset($body['sig']); $message = self::jcsMessage('sigelith-cosign-v1|', $body); if ($message === null) { return array_merge($out, ['code' => 'structure']); } $ok = self::ed25519($pinned['key'], $message, $doc['sig']); if ($ok === null) { return array_merge($out, ['code' => 'unsupported']); } if (!$ok) { return array_merge($out, ['code' => 'signature']); } if (($conf['tree_size'] ?? null) !== $doc['size'] || !self::isInt($conf['leaf_index'] ?? null, 0)) { return array_merge($out, ['code' => 'path']); } $leaf = self::leafHash($entry['seq'], $entry['digest'], $entry['utc']); $root = self::rootFromAuditPath($conf['leaf_index'], $doc['size'], $leaf, $conf['audit_path'] ?? null); if ($root === null || bin2hex($root) !== $doc['root']) { return array_merge($out, ['code' => 'path']); } $after = ($since !== null && $since['size'] <= $conf['leaf_index']) ? $since['time'] : null; return ['ok' => true, 'code' => null, 'after' => $after, 'by' => $doc['time']]; } /** Leaf of the global tree (LOG.md 4), raw 32 bytes. */ public static function leafHash(int $seq, string $digest, string $utc): string { return hash('sha256', "\x00" . 'beattime-entry-v1|' . $seq . '|' . $digest . '|' . $utc, true); } /** Root from an audit path — RFC 9162 2.1.3.2; null when the path leads nowhere. */ public static function rootFromAuditPath(int $leafIndex, int $treeSize, string $leaf, $path): ?string { if ($leafIndex < 0 || $treeSize < 1 || $leafIndex >= $treeSize || !is_array($path) || count($path) > self::MAX_PATH) { return null; } $fn = $leafIndex; $sn = $treeSize - 1; $r = $leaf; foreach ($path as $p) { if ($sn === 0 || !self::isHex64($p)) { return null; } $ph = hex2bin($p); if (($fn & 1) === 1 || $fn === $sn) { $r = hash('sha256', "\x01" . $ph . $r, true); if (($fn & 1) === 0) { while (($fn & 1) === 0 && $fn !== 0) { $fn >>= 1; $sn >>= 1; } } } else { $r = hash('sha256', "\x01" . $r . $ph, true); } $fn >>= 1; $sn >>= 1; } return $sn === 0 ? $r : null; } /** * Canonical JSON — the subset of RFC 8785 Sigelith uses (checkpoint.py): sorted ASCII keys, * no spaces, integers below 2^53, printable ASCII strings. Anything else: null. */ public static function jcs($v): ?string { if ($v === null) { return 'null'; } if (is_bool($v)) { return $v ? 'true' : 'false'; } if (is_int($v)) { return abs($v) <= self::MAX_INT ? (string) $v : null; } if (is_string($v)) { return preg_match('/^[\x20-\x7e]*$/', $v) ? json_encode($v, JSON_UNESCAPED_SLASHES) : null; } if (!is_array($v) || $v === []) { return null; } $parts = []; if (array_keys($v) === range(0, count($v) - 1)) { foreach ($v as $x) { $j = self::jcs($x); if ($j === null) { return null; } $parts[] = $j; } return '[' . implode(',', $parts) . ']'; } foreach (array_keys($v) as $k) { if (!is_string($k) || !preg_match('/^[\x20-\x7e]+$/', $k)) { return null; } } ksort($v, SORT_STRING); foreach ($v as $k => $x) { $j = self::jcs($x); if ($j === null) { return null; } $parts[] = json_encode((string) $k, JSON_UNESCAPED_SLASHES) . ':' . $j; } return '{' . implode(',', $parts) . '}'; } private static function jcsMessage(string $domain, array $body): ?string { $j = self::jcs($body); return $j === null ? null : $domain . $j; } /** Pure Ed25519 (RFC 8032); null when PHP has no sodium. */ private static function ed25519($keyB64, string $message, $sigB64): ?bool { if (!function_exists('sodium_crypto_sign_verify_detached')) { return null; } $key = self::b64($keyB64, 32); $sig = self::b64($sigB64, 64); if ($key === null || $sig === null) { return false; } try { return sodium_crypto_sign_verify_detached($sig, $message, $key); } catch (Throwable $e) { return false; } } /** Canonical base64 of a known length (another spelling of the same bytes is refused). */ private static function b64($s, int $len): ?string { if (!is_string($s)) { return null; } $raw = base64_decode($s, true); return ($raw !== false && strlen($raw) === $len && base64_encode($raw) === $s) ? $raw : null; } private static function hasExactly(array $obj, array $fields): bool { $keys = array_map('strval', array_keys($obj)); sort($keys); $want = $fields; sort($want); return $keys === $want; } private static function isInt($v, int $min): bool { return is_int($v) && $v >= $min && $v <= self::MAX_INT; } private static function isHex64($v): bool { return is_string($v) && preg_match(self::HEX64, $v) === 1; } }