From addb754e5659a615fedb65491e0a3fe9cf7270fe Mon Sep 17 00:00:00 2001 From: mehmet-yoti <111424390+mehmet-yoti@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:45:59 +0300 Subject: [PATCH 1/3] fix: prevent scientific notation in timestamp on low-precision PHP environments (#418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On PHP FPM environments with non-default precision ini settings (≤12), casting a large float to string could produce scientific notation (e.g. 1.78E+12) instead of a plain integer, causing Yoti signature verification to fail with 401 MESSAGE_SIGNING. Replace (string)(round(microtime(true) * 1000)) with sprintf('%.0F', microtime(true) * 1000) which forces a plain decimal string regardless of the precision ini setting, and drops the redundant round() since %.0F already rounds to zero decimal places. Tests added to verify plain integer output under precision=12 and precision=8 (via @dataProvider), and to assert the timestamp falls within the correct Unix-millisecond range under low precision without masking scientific notation via an early (int) cast. Fixes #417 --- .../AuthStrategy/SignedRequestStrategy.php | 2 +- .../SignedRequestStrategyTest.php | 62 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/Http/AuthStrategy/SignedRequestStrategy.php b/src/Http/AuthStrategy/SignedRequestStrategy.php index d27ae3c6..97327cb2 100644 --- a/src/Http/AuthStrategy/SignedRequestStrategy.php +++ b/src/Http/AuthStrategy/SignedRequestStrategy.php @@ -61,7 +61,7 @@ public function createQueryParams(): array { $params = [ 'nonce' => self::generateNonce(), - 'timestamp' => (string)(round(microtime(true) * 1000)), + 'timestamp' => sprintf('%.0F', microtime(true) * 1000), ]; if ($this->sdkId !== null) { diff --git a/tests/Http/AuthStrategy/SignedRequestStrategyTest.php b/tests/Http/AuthStrategy/SignedRequestStrategyTest.php index 1d44b54b..33f432ae 100644 --- a/tests/Http/AuthStrategy/SignedRequestStrategyTest.php +++ b/tests/Http/AuthStrategy/SignedRequestStrategyTest.php @@ -68,6 +68,68 @@ public function shouldReturnNonceAndTimestampQueryParams() $this->assertNotEmpty($params['timestamp']); } + /** + * @test + * @covers ::createQueryParams + * @dataProvider lowPrecisionProvider + * + * On PHP 8.4 FPM environments (e.g. WP Engine) the `precision` ini setting + * can cause (string)(round(microtime(true) * 1000)) to emit scientific notation + * (e.g. "1.78231576830E+12"), which breaks Yoti signature verification with 401. + */ + public function shouldReturnTimestampAsPlainIntegerStringUnderLowPrecision(string $precision): void + { + $strategy = new SignedRequestStrategy(PemFile::fromFilePath(TestData::PEM_FILE)); + + $originalPrecision = ini_get('precision'); + ini_set('precision', $precision); + + try { + $params = $strategy->createQueryParams(); + } finally { + ini_set('precision', $originalPrecision); + } + + $this->assertMatchesRegularExpression( + '/^\d+$/', + $params['timestamp'], + "Timestamp must be a plain integer string (no scientific notation) at precision={$precision}" + ); + } + + public function lowPrecisionProvider(): array + { + return [ + 'precision=12' => ['12'], + 'precision=8' => ['8'], + ]; + } + + /** + * @test + * @covers ::createQueryParams + */ + public function shouldReturnTimestampAsUnixMilliseconds(): void + { + $strategy = new SignedRequestStrategy(PemFile::fromFilePath(TestData::PEM_FILE)); + + $originalPrecision = ini_get('precision'); + ini_set('precision', '8'); + + try { + $beforeMs = (int) floor(microtime(true) * 1000); + $params = $strategy->createQueryParams(); + $afterMs = (int) ceil(microtime(true) * 1000); + } finally { + ini_set('precision', $originalPrecision); + } + + // Assert plain integer format first — (int) cast alone would mask scientific notation + $this->assertMatchesRegularExpression('/^\d+$/', $params['timestamp']); + $this->assertGreaterThanOrEqual($beforeMs, (int) $params['timestamp']); + $this->assertLessThanOrEqual($afterMs, (int) $params['timestamp']); + } + /** * @test * @covers ::createQueryParams From e34b608eda750e9265bba21b5dd507d517ab44d6 Mon Sep 17 00:00:00 2001 From: mehmet-yoti Date: Tue, 30 Jun 2026 13:58:43 +0100 Subject: [PATCH 2/3] test: guard ini_set precision tests against restricted runtimes Add ini_set()/ini_get() failure checks with markTestSkipped() so tests are skipped rather than silently passing as false-positives when the runtime disallows changing the precision ini setting. Cast restored precision to string defensively. Remove : void return types from new test methods for consistency with the rest of the test file. --- .../AuthStrategy/SignedRequestStrategyTest.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/tests/Http/AuthStrategy/SignedRequestStrategyTest.php b/tests/Http/AuthStrategy/SignedRequestStrategyTest.php index 33f432ae..61418386 100644 --- a/tests/Http/AuthStrategy/SignedRequestStrategyTest.php +++ b/tests/Http/AuthStrategy/SignedRequestStrategyTest.php @@ -77,17 +77,19 @@ public function shouldReturnNonceAndTimestampQueryParams() * can cause (string)(round(microtime(true) * 1000)) to emit scientific notation * (e.g. "1.78231576830E+12"), which breaks Yoti signature verification with 401. */ - public function shouldReturnTimestampAsPlainIntegerStringUnderLowPrecision(string $precision): void + public function shouldReturnTimestampAsPlainIntegerStringUnderLowPrecision(string $precision) { $strategy = new SignedRequestStrategy(PemFile::fromFilePath(TestData::PEM_FILE)); $originalPrecision = ini_get('precision'); - ini_set('precision', $precision); + if ($originalPrecision === false || ini_set('precision', $precision) === false) { + $this->markTestSkipped('Unable to set ini precision for this runtime'); + } try { $params = $strategy->createQueryParams(); } finally { - ini_set('precision', $originalPrecision); + ini_set('precision', (string) $originalPrecision); } $this->assertMatchesRegularExpression( @@ -109,19 +111,21 @@ public function lowPrecisionProvider(): array * @test * @covers ::createQueryParams */ - public function shouldReturnTimestampAsUnixMilliseconds(): void + public function shouldReturnTimestampAsUnixMilliseconds() { $strategy = new SignedRequestStrategy(PemFile::fromFilePath(TestData::PEM_FILE)); $originalPrecision = ini_get('precision'); - ini_set('precision', '8'); + if ($originalPrecision === false || ini_set('precision', '8') === false) { + $this->markTestSkipped('Unable to set ini precision for this runtime'); + } try { $beforeMs = (int) floor(microtime(true) * 1000); $params = $strategy->createQueryParams(); $afterMs = (int) ceil(microtime(true) * 1000); } finally { - ini_set('precision', $originalPrecision); + ini_set('precision', (string) $originalPrecision); } // Assert plain integer format first — (int) cast alone would mask scientific notation From 0f4291e7f6f02aa7aa7570ea0292d0aac6cbc5c9 Mon Sep 17 00:00:00 2001 From: mehmet-yoti Date: Tue, 30 Jun 2026 14:47:16 +0100 Subject: [PATCH 3/3] test: fix ClientTest relative URI rejected by Guzzle 7.x stricter validation Newer Guzzle 7.x patch versions reject relative URIs (e.g. '/') when no base_uri is configured, throwing InvalidArgumentException before the MockHandler is reached. Replace with an absolute URI so the mock handler intercepts correctly on all Guzzle 7.x versions. --- tests/Http/ClientTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Http/ClientTest.php b/tests/Http/ClientTest.php index 7fd38e82..58f77977 100644 --- a/tests/Http/ClientTest.php +++ b/tests/Http/ClientTest.php @@ -37,7 +37,7 @@ public function testSendRequest() $this->assertSame( $someResponse, - $client->sendRequest(new Request('GET', '/')) + $client->sendRequest(new Request('GET', 'https://api.yoti.com/')) ); $this->assertEquals(30, $someHandler->getLastOptions()['timeout']); @@ -138,6 +138,6 @@ private function sendRequestAndThrow(\Throwable $exception) $client = new Client(['handler' => $someHandlerStack]); - $client->sendRequest(new Request('GET', '/')); + $client->sendRequest(new Request('GET', 'https://api.yoti.com/')); } }