Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.32% covered (success)
94.32%
83 / 88
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
NtpTimeSyncService
94.25% covered (success)
94.25%
82 / 87
78.57% covered (warning)
78.57%
11 / 14
41.32
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAdjustedTimestamp
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getAdjustedDateTime
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getClockDriftSeconds
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
 syncWithNtp
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 isTokenExpired
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 queryNtpServer
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 exchangeNtpPacket
82.35% covered (warning)
82.35%
14 / 17
0.00% covered (danger)
0.00%
0 / 1
5.14
 computeNtpDrift
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 resolveConfiguredNtpHost
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 loadDriftFromDiskCache
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 readDriftCachePayload
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
6.07
 saveDriftToDiskCache
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getCacheFilePath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
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\Time;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use DateTimeImmutable;
12use DateTimeInterface;
13use DateTimeZone;
14use PDO;
15use Throwable;
16
17/**
18 * Authoritative Time Synchronization Service with NTP Clock Drift Compensation.
19 *
20 * Synchronizes local system clock with national and international atomic time servers
21 * (GUM tempus1.gum.gov.pl, NIST time.nist.gov, Cloudflare) stored in a_mod_server_ntp_records.
22 * Guarantees precise token lifetime enforcement (strict 15-minute OWASP ASVS window)
23 * and RFC 6238 TOTP step accuracy regardless of host clock desynchronization.
24 *
25 * @package App\Core\Time
26 */
27final class NtpTimeSyncService implements NtpTimeSyncServiceInterface
28{
29    /** @var int NTP epoch offset from Unix epoch (1970 - 1900 in seconds). */
30    private const NTP_EPOCH_OFFSET = 2208988800;
31
32    /** @var int Cache TTL for clock drift in seconds (10 minutes). */
33    private const CACHE_TTL_SECONDS = 600;
34
35    /** @var string Default primary NTP server fallback. */
36    private const DEFAULT_NTP_HOST = 'tempus1.gum.gov.pl';
37
38    /** @var float|null In-memory cached clock drift in seconds. */
39    private static ?float $memoryDrift = null;
40
41    /** @var int|null Timestamp when in-memory drift was measured. */
42    private static ?int $memoryDriftTime = null;
43
44    /**
45     * NtpTimeSyncService constructor.
46     *
47     * @param PDO|null $pdo Optional PDO instance for querying configured NTP servers.
48     * @param string|null $cacheDir Optional custom cache directory.
49     */
50    public function __construct(
51        private readonly ?PDO $pdo = null,
52        private readonly ?string $cacheDir = null
53    ) {
54    }
55
56    /** {@inheritdoc} */
57    public function getAdjustedTimestamp(): int
58    {
59        $drift = $this->getClockDriftSeconds();
60        return (int) round(time() + $drift);
61    }
62
63    /** {@inheritdoc} */
64    public function getAdjustedDateTime(): DateTimeImmutable
65    {
66        $adjustedTimestamp = $this->getAdjustedTimestamp();
67        return (new DateTimeImmutable('@' . $adjustedTimestamp))->setTimezone(new DateTimeZone('UTC'));
68    }
69
70    /** {@inheritdoc} */
71    public function getClockDriftSeconds(): float
72    {
73        $now = time();
74        if (self::$memoryDrift !== null
75            && self::$memoryDriftTime !== null
76            && ($now - self::$memoryDriftTime) < self::CACHE_TTL_SECONDS
77        ) {
78            return self::$memoryDrift;
79        }
80
81        $cachedDrift = $this->loadDriftFromDiskCache($now);
82        if ($cachedDrift !== null) {
83            self::$memoryDrift = $cachedDrift;
84            self::$memoryDriftTime = $now;
85            return $cachedDrift;
86        }
87
88        $measuredDrift = $this->syncWithNtp();
89        self::$memoryDrift = $measuredDrift;
90        self::$memoryDriftTime = $now;
91        $this->saveDriftToDiskCache($measuredDrift, $now);
92
93        return $measuredDrift;
94    }
95
96    /** {@inheritdoc} */
97    public function syncWithNtp(?string $host = null, int $port = 123, int $timeout = 2): float
98    {
99        $targetHost = $host ?? $this->resolveConfiguredNtpHost();
100        $drift = $this->queryNtpServer($targetHost, $port, $timeout);
101
102        if ($drift === null && $host === null && $targetHost !== self::DEFAULT_NTP_HOST) {
103            $drift = $this->queryNtpServer(self::DEFAULT_NTP_HOST, $port, $timeout);
104        }
105
106        return $drift ?? 0.0;
107    }
108
109    /** {@inheritdoc} */
110    public function isTokenExpired(DateTimeInterface $expiresAt): bool
111    {
112        return $expiresAt->getTimestamp() <= $this->getAdjustedTimestamp();
113    }
114
115    /**
116     * Queries NTP server via UDP socket (RFC 5905).
117     *
118     * @param string $host NTP host.
119     * @param int $port NTP port.
120     * @param int $timeout Connection timeout in seconds.
121     * @return float|null Measured drift in seconds, or null on network failure.
122     */
123    private function queryNtpServer(string $host, int $port, int $timeout): ?float
124    {
125        $packet = $this->exchangeNtpPacket($host, $port, $timeout);
126        if ($packet === null) {
127            return null;
128        }
129
130        return $this->computeNtpDrift($packet['data'], $packet['send_time'], $packet['recv_time']);
131    }
132
133    /**
134     * @return array{data: string, send_time: float, recv_time: float}|null
135     */
136    private function exchangeNtpPacket(string $host, int $port, int $timeout): ?array
137    {
138        $socketUri = 'udp://' . $host . ':' . $port;
139        $socket = @stream_socket_client($socketUri, $errNo, $errStr, (float) $timeout);
140        if ($socket === false) {
141            return null;
142        }
143
144        stream_set_timeout($socket, $timeout);
145        $requestPacket = "\x1b" . str_repeat("\0", 47);
146        $written = @fwrite($socket, $requestPacket);
147        if ($written !== 48) {
148            fclose($socket);
149            return null;
150        }
151
152        $sendTime = microtime(true);
153        $response = @fread($socket, 48);
154        $recvTime = microtime(true);
155        fclose($socket);
156
157        return ($response !== false && strlen($response) >= 48)
158            ? ['data' => $response, 'send_time' => $sendTime, 'recv_time' => $recvTime]
159            : null;
160    }
161
162    private function computeNtpDrift(string $response, float $sendTime, float $recvTime): ?float
163    {
164        $data = unpack('N12', $response);
165        if ($data === false || !isset($data[9])) {
166            return null;
167        }
168
169        $ntpSeconds = (int) $data[9];
170        $ntpFraction = (int) ($data[10] ?? 0);
171        $ntpTimestamp = ($ntpSeconds - self::NTP_EPOCH_OFFSET) + ($ntpFraction / 4294967296);
172
173        $localTimestamp = ($sendTime + $recvTime) / 2.0;
174        return round($ntpTimestamp - $localTimestamp, 4);
175    }
176
177    /**
178     * Resolves active primary NTP server host from database if available.
179     *
180     * @return string NTP server hostname.
181     */
182    private function resolveConfiguredNtpHost(): string
183    {
184        if ($this->pdo === null) {
185            return self::DEFAULT_NTP_HOST;
186        }
187
188        try {
189            $stmt = $this->pdo->prepare(
190                'SELECT host FROM a_mod_server_ntp_records WHERE is_active = 1 ' .
191                'ORDER BY is_primary DESC, stratum ASC LIMIT 1'
192            );
193            $stmt->execute();
194            $host = $stmt->fetchColumn();
195            return is_string($host) && trim($host) !== '' ? trim($host) : self::DEFAULT_NTP_HOST;
196        } catch (Throwable) {
197            return self::DEFAULT_NTP_HOST;
198        }
199    }
200
201    /**
202     * Loads cached clock drift from local disk cache file.
203     *
204     * @param int $now Current local timestamp.
205     * @return float|null Cached drift if still fresh, null otherwise.
206     */
207    private function loadDriftFromDiskCache(int $now): ?float
208    {
209        $payload = $this->readDriftCachePayload();
210        if ($payload === null) {
211            return null;
212        }
213
214        $age = $now - (int) $payload['timestamp'];
215        if ($age < 0 || $age >= self::CACHE_TTL_SECONDS) {
216            return null;
217        }
218
219        return (float) $payload['drift'];
220    }
221
222    /**
223     * @return array{drift: float, timestamp: int}|null
224     */
225    private function readDriftCachePayload(): ?array
226    {
227        $cachePath = $this->getCacheFilePath();
228        if (!file_exists($cachePath)) {
229            return null;
230        }
231
232        $raw = @file_get_contents($cachePath);
233        if ($raw === false || $raw === '') {
234            return null;
235        }
236
237        $payload = json_decode($raw, true);
238        return is_array($payload) && isset($payload['drift'], $payload['timestamp']) ? $payload : null;
239    }
240
241    /**
242     * Saves clock drift to local disk cache file.
243     *
244     * @param float $drift Measured drift.
245     * @param int $now Current timestamp.
246     */
247    private function saveDriftToDiskCache(float $drift, int $now): void
248    {
249        $cachePath = $this->getCacheFilePath();
250        $payload = json_encode(['drift' => $drift, 'timestamp' => $now]);
251        if ($payload !== false) {
252            @file_put_contents($cachePath, $payload, LOCK_EX);
253        }
254    }
255
256    /**
257     * Returns absolute path to NTP drift cache file.
258     *
259     * @return string Cache file path.
260     */
261    private function getCacheFilePath(): string
262    {
263        $dir = $this->cacheDir ?? sys_get_temp_dir();
264        return rtrim($dir, '/\\') . '/ammonly_ntp_drift.json';
265    }
266}