<?php if (!defined('DASHBOARD_ROOT')) { http_response_code(403); exit; } class MerkleAnchor { private const BATCH_SIZE = 100; public static function buildTree(array $leaves): string { if (empty($leaves)) return hash('sha256', ''); if (count($leaves) === 1) return $leaves[0]; if (count($leaves) % 2 !== 0) { $leaves[] = $leaves[count($leaves) - 1]; } $nextLevel = []; for ($i = 0; $i < count($leaves); $i += 2) { $nextLevel[] = hash('sha256', $leaves[$i] . $leaves[$i + 1]); } return self::buildTree($nextLevel); } public static function hashEntry(array $entry): string { $material = implode('|', [ $entry['id'], $entry['admin_id'] ?? '', $entry['action'], $entry['target_type'] ?? '', $entry['target_id'] ?? '', $entry['fields_accessed'] ?? '', $entry['ip_address'] ?? '', $entry['created_at'], ]); return hash('sha256', $material); } public static function processBatch(): array { $lastAnchorId = 0; try { $row = Database::fetchOne( "SELECT last_event_id FROM merkle_anchors ORDER BY id DESC LIMIT 1" ); if ($row) $lastAnchorId = (int)$row['last_event_id']; } catch (\Throwable $e) {} try { $entries = Database::fetchAll( "SELECT id, admin_id, action, target_type, target_id, fields_accessed, ip_address, created_at                  FROM events WHERE id > :last ORDER BY id ASC LIMIT :batch", [':last' => $lastAnchorId, ':batch' => self::BATCH_SIZE] ); } catch (\Throwable $e) { return ['ok' => false, 'error' => 'Fetch failed']; } if (empty($entries)) { return ['ok' => true, 'message' => 'No new entries to anchor']; } $leaves = []; foreach ($entries as $e) { $leaves[] = self::hashEntry($e); } $merkleRoot = self::buildTree($leaves); $firstId = (int)$entries[0]['id']; $lastId = (int)$entries[count($entries) - 1]['id']; try { Database::insert( "INSERT INTO merkle_anchors (merkle_root, first_event_id, last_event_id, leaf_count, created_at)                  VALUES (:root, :first, :last, :count, NOW())", [ ':root' => $merkleRoot, ':first' => $firstId, ':last' => $lastId, ':count' => count($leaves), ] ); } catch (\Throwable $e) { return ['ok' => false, 'error' => 'Anchor storage failed']; } Events::record(Events::CAT_SYSTEM, 'merkle.anchored', 'audit', null, [ 'merkle_root' => $merkleRoot, 'events' => $firstId . '-' . $lastId, 'leaf_count' => count($leaves), ]); return [ 'ok' => true, 'merkle_root' => $merkleRoot, 'first_id' => $firstId, 'last_id' => $lastId, 'leaf_count' => count($leaves), ]; } public static function verifyEntry(int $entryId): array { try { $entry = Database::fetchOne( "SELECT * FROM events WHERE id = :id", [':id' => $entryId] ); } catch (\Throwable $e) { return ['ok' => false, 'error' => 'Entry not found']; } if (!$entry) return ['ok' => false, 'error' => 'Entry not found']; $currentHash = self::hashEntry($entry); try { $anchor = Database::fetchOne( "SELECT * FROM merkle_anchors WHERE first_event_id <= :id AND last_event_id >= :id LIMIT 1", [':id' => $entryId] ); } catch (\Throwable $e) { return ['ok' => false, 'error' => 'Anchor lookup failed']; } if (!$anchor) { return [ 'ok' => true, 'verified' => false, 'reason' => 'No anchor covers this entry yet', 'hash' => $currentHash, ]; } try { $entries = Database::fetchAll( "SELECT id, admin_id, action, target_type, target_id, fields_accessed, ip_address, created_at                  FROM events WHERE id >= :first AND id <= :last ORDER BY id ASC", [':first' => (int)$anchor['first_event_id'], ':last' => (int)$anchor['last_event_id']] ); } catch (\Throwable $e) { return ['ok' => false, 'error' => 'Recompute failed']; } $leaves = []; foreach ($entries as $e) { $leaves[] = self::hashEntry($e); } $recomputedRoot = self::buildTree($leaves); $matches = hash_equals($anchor['merkle_root'], $recomputedRoot); return [ 'ok' => true, 'verified' => $matches, 'entry_hash' => $currentHash, 'stored_root' => $anchor['merkle_root'], 'recomputed_root' => $recomputedRoot, 'anchor_id' => (int)$anchor['id'], 'tampered' => !$matches, ]; } public static function status(): array { try { $anchors = Database::fetchAll( "SELECT id, merkle_root, first_event_id, last_event_id, leaf_count, tx_id, block_height, created_at                  FROM merkle_anchors ORDER BY id DESC LIMIT 10" ); $totalAnchored = (int)Database::scalar("SELECT COUNT(*) FROM merkle_anchors"); $totalEvents = (int)Database::scalar("SELECT COUNT(*) FROM events"); $lastAnchor = Database::fetchOne("SELECT last_event_id FROM merkle_anchors ORDER BY id DESC LIMIT 1"); $unanchored = $lastAnchor ? (int)Database::scalar("SELECT COUNT(*) FROM events WHERE id > :last", [':last' => (int)$lastAnchor['last_event_id']]) : $totalEvents; } catch (\Throwable $e) { $anchors = []; $totalAnchored = 0; $totalEvents = 0; $unanchored = 0; } return [ 'anchors' => $anchors, 'total_anchors' => $totalAnchored, 'total_events' => $totalEvents, 'unanchored' => $unanchored, 'batch_size' => self::BATCH_SIZE, ]; } } 