From ed5a0438f7e552deed9a28c478b916f2162b9420 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 10 Jul 2026 19:15:09 -0700 Subject: [PATCH 1/4] Validate time-based parameters against invalid counters generateByTime and generateByTimeWindow now reject a non-positive $window (previously an uncaught DivisionByZeroError) and a negative $timestamp (previously silently produced an unintended counter), throwing InvalidArgumentException instead. --- src/HOTP.php | 16 ++++++++++++++++ tests/HOTPTest.php | 23 +++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/HOTP.php b/src/HOTP.php index b461c15..1c0b338 100644 --- a/src/HOTP.php +++ b/src/HOTP.php @@ -48,12 +48,20 @@ public static function generateByCounter(string $key, int $counter): HOTPResult */ public static function generateByTime(string $key, int $window, int|false $timestamp = false): HOTPResult { + if ($window <= 0) { + throw new \InvalidArgumentException('$window must be a positive integer'); + } + if (!$timestamp && $timestamp !== 0) { // @codeCoverageIgnoreStart $timestamp = self::getTime(); // @codeCoverageIgnoreEnd } + if ($timestamp < 0) { + throw new \InvalidArgumentException('$timestamp must not be negative'); + } + $counter = intval($timestamp / $window) ; return self::generateByCounter($key, $counter); @@ -72,12 +80,20 @@ public static function generateByTime(string $key, int $window, int|false $times */ public static function generateByTimeWindow(string $key, int $window, int $min = -1, int $max = 1, int|false $timestamp = false): array { + if ($window <= 0) { + throw new \InvalidArgumentException('$window must be a positive integer'); + } + if (!$timestamp && $timestamp !== 0) { // @codeCoverageIgnoreStart $timestamp = self::getTime(); // @codeCoverageIgnoreEnd } + if ($timestamp < 0) { + throw new \InvalidArgumentException('$timestamp must not be negative'); + } + $counter = intval($timestamp / $window); $window = range($min, $max); diff --git a/tests/HOTPTest.php b/tests/HOTPTest.php index 8016171..2b2d357 100644 --- a/tests/HOTPTest.php +++ b/tests/HOTPTest.php @@ -168,6 +168,29 @@ public function provideGenerateByTimeWindow(): array ]; } + public function provideInvalidTimeArguments(): array + { + return [ + 'zero window' => [0, 59], + 'negative window' => [-30, 59], + 'negative timestamp' => [30, -59], + ]; + } + + /** @dataProvider provideInvalidTimeArguments */ + public function testGenerateByTimeRejectsInvalidArguments(int $window, int $timestamp): void + { + $this->expectException(\InvalidArgumentException::class); + HOTP::generateByTime(self::KEY, $window, $timestamp); + } + + /** @dataProvider provideInvalidTimeArguments */ + public function testGenerateByTimeWindowRejectsInvalidArguments(int $window, int $timestamp): void + { + $this->expectException(\InvalidArgumentException::class); + HOTP::generateByTimeWindow(self::KEY, $window, -1, 1, $timestamp); + } + /** @dataProvider provideGenerateByTimeWindow */ public function testGenerateByTimeWindow(string $seed, array $result): void { From 134203d2adbdf0771fd0f53e058371eb80cad1fa Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 10 Jul 2026 19:18:25 -0700 Subject: [PATCH 2/4] Support configurable HMAC algorithm and fix wide-digest truncation generateByCounter, generateByTime and generateByTimeWindow now accept an optional $algorithm (default sha1), validated against hash_hmac_algos(). HOTPResult::toDec previously hard-coded the dynamic-truncation offset byte to index 19, the final byte of a SHA-1 digest, which truncated wider SHA-256/512 digests at the wrong position. It now derives the offset from the digest's actual final byte per RFC 4226. Verified against the RFC 6238 Appendix B SHA-1, SHA-256 and SHA-512 vectors. --- src/HOTP.php | 19 +++++++++++++------ src/HOTPResult.php | 5 ++++- tests/HOTPTest.php | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/HOTP.php b/src/HOTP.php index 1c0b338..29e2c09 100644 --- a/src/HOTP.php +++ b/src/HOTP.php @@ -18,10 +18,15 @@ class HOTP * Generate a HOTP key based on a counter value (event based HOTP) * @param string $key the key to use for hashing * @param int $counter the number of attempts represented in this hashing + * @param string $algorithm the HMAC hash algorithm to use, defaults to sha1 * @return HOTPResult a HOTP Result which can be truncated or output */ - public static function generateByCounter(string $key, int $counter): HOTPResult + public static function generateByCounter(string $key, int $counter, string $algorithm = 'sha1'): HOTPResult { + if (!in_array($algorithm, hash_hmac_algos(), true)) { + throw new \InvalidArgumentException("Unsupported HMAC algorithm: {$algorithm}"); + } + // The counter value can be more than one byte long, // so we need to pack it down properly. $curCounter = [ 0, 0, 0, 0, 0, 0, 0, 0 ]; @@ -34,7 +39,7 @@ public static function generateByCounter(string $key, int $counter): HOTPResult $binCounter = str_pad(implode("", $curCounter), 8, chr(0), STR_PAD_LEFT); // HMAC - $hash = hash_hmac('sha1', $binCounter, $key); + $hash = hash_hmac($algorithm, $binCounter, $key); return new HOTPResult($hash); } @@ -44,9 +49,10 @@ public static function generateByCounter(string $key, int $counter): HOTPResult * @param string $key the key to use for hashing * @param int $window the size of the window a key is valid for in seconds * @param int|false $timestamp a timestamp to calculate for, defaults to time() + * @param string $algorithm the HMAC hash algorithm to use, defaults to sha1 * @return HOTPResult a HOTP Result which can be truncated or output */ - public static function generateByTime(string $key, int $window, int|false $timestamp = false): HOTPResult + public static function generateByTime(string $key, int $window, int|false $timestamp = false, string $algorithm = 'sha1'): HOTPResult { if ($window <= 0) { throw new \InvalidArgumentException('$window must be a positive integer'); @@ -64,7 +70,7 @@ public static function generateByTime(string $key, int $window, int|false $times $counter = intval($timestamp / $window) ; - return self::generateByCounter($key, $counter); + return self::generateByCounter($key, $counter, $algorithm); } /** @@ -76,9 +82,10 @@ public static function generateByTime(string $key, int $window, int|false $times * @param int $min the minimum window to accept before $timestamp * @param int $max the maximum window to accept after $timestamp * @param int|false $timestamp a timestamp to calculate for, defaults to time() + * @param string $algorithm the HMAC hash algorithm to use, defaults to sha1 * @return HOTPResult[] */ - public static function generateByTimeWindow(string $key, int $window, int $min = -1, int $max = 1, int|false $timestamp = false): array + public static function generateByTimeWindow(string $key, int $window, int $min = -1, int $max = 1, int|false $timestamp = false, string $algorithm = 'sha1'): array { if ($window <= 0) { throw new \InvalidArgumentException('$window must be a positive integer'); @@ -100,7 +107,7 @@ public static function generateByTimeWindow(string $key, int $window, int $min = $out = []; foreach ($window as $value) { $shiftCounter = $counter + $value; - $out[$shiftCounter] = self::generateByCounter($key, $shiftCounter); + $out[$shiftCounter] = self::generateByCounter($key, $shiftCounter, $algorithm); } return $out; diff --git a/src/HOTPResult.php b/src/HOTPResult.php index b9e39af..f5d695c 100644 --- a/src/HOTPResult.php +++ b/src/HOTPResult.php @@ -54,7 +54,10 @@ public function toDec(): int $hmacResult[] = hexdec($hex); } - $offset = $hmacResult[19] & 0xf; + // RFC 4226 dynamic truncation uses the low nibble of the digest's + // final byte as the offset. SHA-1 digests are 20 bytes (index 19), + // but wider digests (SHA-256/512) place that byte at a higher index. + $offset = $hmacResult[array_key_last($hmacResult)] & 0xf; $this->decimal = ( (($hmacResult[$offset + 0] & 0x7f) << 24) | diff --git a/tests/HOTPTest.php b/tests/HOTPTest.php index 2b2d357..c6234e6 100644 --- a/tests/HOTPTest.php +++ b/tests/HOTPTest.php @@ -150,6 +150,38 @@ public function testTOTP(string $seed, string $result): void ); } + public function provideTOTPAlgorithms(): array + { + // Expected TOTP values from RFC 6238 Appendix B; the per-algorithm keys + // (20/32/64-byte ASCII seeds) come from the Appendix A reference code. + return [ + // [algorithm, key, timestamp, expected 8-digit TOTP] + ['sha1', '12345678901234567890', '59', '94287082'], + ['sha1', '12345678901234567890', '1234567890', '89005924'], + ['sha256', '12345678901234567890123456789012', '59', '46119246'], + ['sha256', '12345678901234567890123456789012', '1234567890', '91819424'], + ['sha512', '1234567890123456789012345678901234567890123456789012345678901234', '59', '90693936'], + ['sha512', '1234567890123456789012345678901234567890123456789012345678901234', '1234567890', '93441116'], + ]; + } + + /** @dataProvider provideTOTPAlgorithms */ + public function testTOTPWithAlgorithm(string $algorithm, string $key, string $seed, string $result): void + { + $totp = HOTP::generateByTime($key, 30, $seed, $algorithm); + + $this->assertEquals( + $result, + $totp->toHOTP(8) + ); + } + + public function testGenerateByCounterRejectsUnsupportedAlgorithm(): void + { + $this->expectException(\InvalidArgumentException::class); + HOTP::generateByCounter(self::KEY, 0, 'not-a-real-algo'); + } + public function provideGenerateByTimeWindow(): array { return [ From 022732fb88dffe2025b926b7bca3425368bc8b97 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 10 Jul 2026 20:00:06 -0700 Subject: [PATCH 3/4] Support the RFC 6238 start-time offset generateByTime and generateByTimeWindow accept an optional $startTime (RFC 6238 "T0", default 0) that is subtracted from the timestamp before the time step is computed, per RFC 6238 section 4.1. Documents the 2.2.0 release in the changelog. --- CHANGELOG.md | 7 +++++++ src/HOTP.php | 10 ++++++---- tests/HOTPTest.php | 24 ++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ce1afa..45efaf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +# 2.2.0 (Jul 10, 2026) +- `generateByTime` and `generateByTimeWindow` now reject a non-positive `$window` (previously an uncaught `DivisionByZeroError`) and a negative `$timestamp` (previously a silent, unintended counter), throwing `InvalidArgumentException` instead. +- `generateByCounter`, `generateByTime` and `generateByTimeWindow` accept an optional `$algorithm` parameter (defaults to `sha1`), enabling HMAC-SHA-256 and HMAC-SHA-512 TOTP per RFC 6238. +- Fixed dynamic truncation in `HOTPResult` to derive the RFC 4226 offset from the digest's actual final byte, so wider SHA-256/512 digests truncate correctly (no change for SHA-1). +- The time-based methods accept an optional `$startTime` parameter (RFC 6238 "T0", defaults to `0`) to set the Unix time from which time steps are counted. +- All new behavior is validated against the RFC 6238 Appendix B SHA-1, SHA-256 and SHA-512 test vectors. These additions are backwards compatible. + # 2.1.0 (Apr 13, 2026) - Adopts PSR-12 syntax everywhere (:heart: [jrzepa](https://github.com/jrzepa) [#12](https://github.com/thecodedrift/hotp-php/commit/7578eb618f2bca4f6a7121aaa75ee2b4c6fe6a94)) - Expands version range for PHPUnit to ensure tests run against PHP 7.2 [#16](https://github.com/jakobo/hotp-php/pull/16) diff --git a/src/HOTP.php b/src/HOTP.php index 29e2c09..d30d00c 100644 --- a/src/HOTP.php +++ b/src/HOTP.php @@ -50,9 +50,10 @@ public static function generateByCounter(string $key, int $counter, string $algo * @param int $window the size of the window a key is valid for in seconds * @param int|false $timestamp a timestamp to calculate for, defaults to time() * @param string $algorithm the HMAC hash algorithm to use, defaults to sha1 + * @param int $startTime the Unix time to start counting time steps from (RFC 6238 "T0"), defaults to 0 * @return HOTPResult a HOTP Result which can be truncated or output */ - public static function generateByTime(string $key, int $window, int|false $timestamp = false, string $algorithm = 'sha1'): HOTPResult + public static function generateByTime(string $key, int $window, int|false $timestamp = false, string $algorithm = 'sha1', int $startTime = 0): HOTPResult { if ($window <= 0) { throw new \InvalidArgumentException('$window must be a positive integer'); @@ -68,7 +69,7 @@ public static function generateByTime(string $key, int $window, int|false $times throw new \InvalidArgumentException('$timestamp must not be negative'); } - $counter = intval($timestamp / $window) ; + $counter = intval(($timestamp - $startTime) / $window) ; return self::generateByCounter($key, $counter, $algorithm); } @@ -83,9 +84,10 @@ public static function generateByTime(string $key, int $window, int|false $times * @param int $max the maximum window to accept after $timestamp * @param int|false $timestamp a timestamp to calculate for, defaults to time() * @param string $algorithm the HMAC hash algorithm to use, defaults to sha1 + * @param int $startTime the Unix time to start counting time steps from (RFC 6238 "T0"), defaults to 0 * @return HOTPResult[] */ - public static function generateByTimeWindow(string $key, int $window, int $min = -1, int $max = 1, int|false $timestamp = false, string $algorithm = 'sha1'): array + public static function generateByTimeWindow(string $key, int $window, int $min = -1, int $max = 1, int|false $timestamp = false, string $algorithm = 'sha1', int $startTime = 0): array { if ($window <= 0) { throw new \InvalidArgumentException('$window must be a positive integer'); @@ -101,7 +103,7 @@ public static function generateByTimeWindow(string $key, int $window, int $min = throw new \InvalidArgumentException('$timestamp must not be negative'); } - $counter = intval($timestamp / $window); + $counter = intval(($timestamp - $startTime) / $window); $window = range($min, $max); $out = []; diff --git a/tests/HOTPTest.php b/tests/HOTPTest.php index c6234e6..b4045c6 100644 --- a/tests/HOTPTest.php +++ b/tests/HOTPTest.php @@ -182,6 +182,30 @@ public function testGenerateByCounterRejectsUnsupportedAlgorithm(): void HOTP::generateByCounter(self::KEY, 0, 'not-a-real-algo'); } + public function testGenerateByTimeHonorsStartTime(): void + { + // With a start time of 29 and timestamp of 88, the effective time step + // is (88 - 29) / 30 = counter 1, matching the RFC 6238 T=59 vector. + $withStartTime = HOTP::generateByTime(self::KEY, 30, 88, 'sha1', 29); + $baseline = HOTP::generateByTime(self::KEY, 30, 59, 'sha1', 0); + + $this->assertEquals('94287082', $withStartTime->toHOTP(8)); + $this->assertEquals($baseline->toHOTP(8), $withStartTime->toHOTP(8)); + } + + public function testGenerateByTimeWindowHonorsStartTime(): void + { + $withStartTime = HOTP::generateByTimeWindow(self::KEY, 30, -1, 1, 88, 'sha1', 29); + $baseline = HOTP::generateByTimeWindow(self::KEY, 30, -1, 1, 59, 'sha1', 0); + + $reduce = static fn (array $results): array => array_map( + static fn ($r) => $r->toHOTP(6), + array_values($results) + ); + + $this->assertEquals($reduce($baseline), $reduce($withStartTime)); + } + public function provideGenerateByTimeWindow(): array { return [ From ec84e64de284cf74dac10b4d9a1fda0343b970e3 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 10 Jul 2026 20:00:06 -0700 Subject: [PATCH 4/4] Reject a start time later than the timestamp A $startTime greater than $timestamp yields a negative effective time step and an unintended counter. generateByTime and generateByTimeWindow now reject it with InvalidArgumentException. --- CHANGELOG.md | 2 +- src/HOTP.php | 8 ++++++++ tests/HOTPTest.php | 13 +++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45efaf1..7f7a5ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - `generateByTime` and `generateByTimeWindow` now reject a non-positive `$window` (previously an uncaught `DivisionByZeroError`) and a negative `$timestamp` (previously a silent, unintended counter), throwing `InvalidArgumentException` instead. - `generateByCounter`, `generateByTime` and `generateByTimeWindow` accept an optional `$algorithm` parameter (defaults to `sha1`), enabling HMAC-SHA-256 and HMAC-SHA-512 TOTP per RFC 6238. - Fixed dynamic truncation in `HOTPResult` to derive the RFC 4226 offset from the digest's actual final byte, so wider SHA-256/512 digests truncate correctly (no change for SHA-1). -- The time-based methods accept an optional `$startTime` parameter (RFC 6238 "T0", defaults to `0`) to set the Unix time from which time steps are counted. +- The time-based methods accept an optional `$startTime` parameter (RFC 6238 "T0", defaults to `0`) to set the Unix time from which time steps are counted. A `$startTime` later than `$timestamp` is rejected with `InvalidArgumentException`, as it would yield a negative time step. - All new behavior is validated against the RFC 6238 Appendix B SHA-1, SHA-256 and SHA-512 test vectors. These additions are backwards compatible. # 2.1.0 (Apr 13, 2026) diff --git a/src/HOTP.php b/src/HOTP.php index d30d00c..a7f9878 100644 --- a/src/HOTP.php +++ b/src/HOTP.php @@ -69,6 +69,10 @@ public static function generateByTime(string $key, int $window, int|false $times throw new \InvalidArgumentException('$timestamp must not be negative'); } + if ($timestamp - $startTime < 0) { + throw new \InvalidArgumentException('$timestamp must not be earlier than $startTime'); + } + $counter = intval(($timestamp - $startTime) / $window) ; return self::generateByCounter($key, $counter, $algorithm); @@ -103,6 +107,10 @@ public static function generateByTimeWindow(string $key, int $window, int $min = throw new \InvalidArgumentException('$timestamp must not be negative'); } + if ($timestamp - $startTime < 0) { + throw new \InvalidArgumentException('$timestamp must not be earlier than $startTime'); + } + $counter = intval(($timestamp - $startTime) / $window); $window = range($min, $max); diff --git a/tests/HOTPTest.php b/tests/HOTPTest.php index b4045c6..ab51340 100644 --- a/tests/HOTPTest.php +++ b/tests/HOTPTest.php @@ -193,6 +193,19 @@ public function testGenerateByTimeHonorsStartTime(): void $this->assertEquals($baseline->toHOTP(8), $withStartTime->toHOTP(8)); } + public function testGenerateByTimeRejectsStartTimeLaterThanTimestamp(): void + { + // A start time after $timestamp would yield a negative effective step. + $this->expectException(\InvalidArgumentException::class); + HOTP::generateByTime(self::KEY, 30, 30, 'sha1', 60); + } + + public function testGenerateByTimeWindowRejectsStartTimeLaterThanTimestamp(): void + { + $this->expectException(\InvalidArgumentException::class); + HOTP::generateByTimeWindow(self::KEY, 30, -1, 1, 30, 'sha1', 60); + } + public function testGenerateByTimeWindowHonorsStartTime(): void { $withStartTime = HOTP::generateByTimeWindow(self::KEY, 30, -1, 1, 88, 'sha1', 29);