Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
67 / 67
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
AuditIntegrityService
100.00% covered (success)
100.00%
66 / 66
100.00% covered (success)
100.00%
5 / 5
19
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 computeRecordHash
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getLatestHash
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 verifyTableChain
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
7
 resolvePayloadColumn
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Audit\Application\Service;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Audit\Domain\Model\IntegrityVerificationResult;
12use PDO;
13
14/**
15 * Audit Trail Cryptographic Integrity Service.
16 *
17 * Implements NIST SP 800-53 AU-9 (Protection of Audit Information) through sequential
18 * HMAC-SHA256 cryptographic hash chaining and automated tampering verification.
19 *
20 * @package App\Core\Audit\Application\Service
21 */
22readonly class AuditIntegrityService
23{
24    /**
25     * Default genesis hash for the first record in an audit chain.
26     */
27    public const string GENESIS_HASH = '0000000000000000000000000000000000000000000000000000000000000000';
28
29    /**
30     * AuditIntegrityService constructor.
31     *
32     * @param PDO    $pdo       Database connection.
33     * @param string $secretKey HMAC secret key.
34     * @param string $prefix    Table prefix.
35     */
36    public function __construct(
37        private PDO $pdo,
38        private string $secretKey = 'ammonly_audit_hmac_master_key',
39        private string $prefix = 'a_',
40    ) {
41    }
42
43    /**
44     * Calculates the cryptographic HMAC-SHA256 record hash for an audit entry.
45     *
46     * @param string      $prevHash   Hash of the preceding record in the chain.
47     * @param string      $moduleName Module name.
48     * @param int         $recordId   Record primary key.
49     * @param string      $payload    Serialized payload/diff data.
50     * @param int|null    $actorId    Actor user ID.
51     * @param string      $ipAddress  Client IP address.
52     * @param string|null $requestId  Request correlation UUID.
53     * @return string 64-character lowercase hex HMAC-SHA256 string.
54     */
55    public function computeRecordHash(
56        string  $prevHash,
57        string  $moduleName,
58        int     $recordId,
59        string  $payload,
60        ?int    $actorId,
61        string  $ipAddress,
62        ?string $requestId
63    ): string {
64        $data = implode('|', [
65            $prevHash,
66            $moduleName,
67            (string) $recordId,
68            $payload,
69            (string) ($actorId ?? 0),
70            $ipAddress,
71            $requestId ?? 'none',
72        ]);
73
74        return hash_hmac('sha256', $data, $this->secretKey);
75    }
76
77    /**
78     * Fetches the latest record_hash from the specified audit table.
79     *
80     * @param string      $tableName      Unprefixed table name without prefix (e.g. 'logs_audit_create_records').
81     * @param string|null $prefixOverride Optional table prefix override.
82     * @return string Latest record hash or GENESIS_HASH if table is empty.
83     */
84    public function getLatestHash(string $tableName, ?string $prefixOverride = null): string
85    {
86        try {
87            $table = ($prefixOverride ?? $this->prefix) . $tableName;
88            $stmt = $this->pdo->query("SELECT `record_hash` FROM `{$table}` ORDER BY `id` DESC LIMIT 1");
89            $hash = $stmt !== false ? $stmt->fetchColumn() : false;
90
91            return is_string($hash) && $hash !== '' ? $hash : self::GENESIS_HASH;
92        } catch (\Throwable) {
93            return self::GENESIS_HASH;
94        }
95    }
96
97    /**
98     * Verifies the cryptographic integrity of an audit table's hash chain.
99     *
100     * @param string      $tableName      Audit table name without prefix (e.g. 'logs_audit_create_records').
101     * @param int         $limit          Maximum records to verify.
102     * @param string|null $prefixOverride Optional table prefix override.
103     * @return IntegrityVerificationResult Verification outcome report.
104     */
105    public function verifyTableChain(
106        string $tableName,
107        int $limit = 1000,
108        ?string $prefixOverride = null
109    ): IntegrityVerificationResult {
110        $table = ($prefixOverride ?? $this->prefix) . $tableName;
111        $violations = [];
112        $verifiedCount = 0;
113        $latestHash = null;
114
115        try {
116            $sql = "SELECT `id`, `module_name`, `record_id`, `actor_id`, `ip_address`, `request_id`,"
117                . " `prev_hash`, `record_hash`, " . $this->resolvePayloadColumn($tableName) . " AS `payload`"
118                . " FROM `{$table}` ORDER BY `id` ASC LIMIT :limit";
119
120            $stmt = $this->pdo->prepare($sql);
121            $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
122            $stmt->execute();
123            $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
124
125            $expectedPrevHash = self::GENESIS_HASH;
126
127            foreach ($rows as $row) {
128                $id = (int) $row['id'];
129                $prevHash = (string) ($row['prev_hash'] ?? '');
130                $recordHash = (string) ($row['record_hash'] ?? '');
131
132                if ($prevHash !== $expectedPrevHash) {
133                    $violations[] = "Record #{$id}: Broken chain linkage. Expected prev_hash "
134                        . "'{$expectedPrevHash}', got '{$prevHash}'.";
135                }
136
137                $computed = $this->computeRecordHash(
138                    $prevHash,
139                    (string) $row['module_name'],
140                    (int) $row['record_id'],
141                    (string) $row['payload'],
142                    $row['actor_id'] !== null ? (int) $row['actor_id'] : null,
143                    (string) $row['ip_address'],
144                    $row['request_id'] !== null ? (string) $row['request_id'] : null
145                );
146
147                if (!hash_equals($computed, $recordHash)) {
148                    $violations[] = "Record #{$id}: Signature mismatch. Expected '{$computed}', got '{$recordHash}'.";
149                }
150
151                $expectedPrevHash = $recordHash;
152                $latestHash = $recordHash;
153                $verifiedCount++;
154            }
155        } catch (\Throwable $e) {
156            $violations[] = "Database query error during verification: " . $e->getMessage();
157        }
158
159        return new IntegrityVerificationResult(
160            isValid:       $violations === [],
161            table:         $tableName,
162            verifiedCount: $verifiedCount,
163            violations:    $violations,
164            latestHash:    $latestHash
165        );
166    }
167
168    /**
169     * Resolves the primary payload/data column for the audit table.
170     *
171     * @param string $tableName Unprefixed table name.
172     * @return string SQL column expression.
173     */
174    private function resolvePayloadColumn(string $tableName): string
175    {
176        return match ($tableName) {
177            'logs_audit_create_records' => '`payload`',
178            'logs_audit_delete_records' => '`snapshot`',
179            'logs_audit_update_records' =>
180                "CONCAT(`field_key`, ':', IFNULL(`old_value`, ''), '->', IFNULL(`new_value`, ''))",
181            default                     => "''",
182        };
183    }
184}