diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ce1afa..7f7a5ad 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. 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) - 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 b461c15..a7f9878 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,19 +49,33 @@ 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 + * @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): 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'); + } + if (!$timestamp && $timestamp !== 0) { // @codeCoverageIgnoreStart $timestamp = self::getTime(); // @codeCoverageIgnoreEnd } - $counter = intval($timestamp / $window) ; + if ($timestamp < 0) { + 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); + return self::generateByCounter($key, $counter, $algorithm); } /** @@ -68,23 +87,37 @@ 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 + * @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): 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'); + } + if (!$timestamp && $timestamp !== 0) { // @codeCoverageIgnoreStart $timestamp = self::getTime(); // @codeCoverageIgnoreEnd } - $counter = intval($timestamp / $window); + if ($timestamp < 0) { + 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); $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 8016171..ab51340 100644 --- a/tests/HOTPTest.php +++ b/tests/HOTPTest.php @@ -150,6 +150,75 @@ 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 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 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); + $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 [ @@ -168,6 +237,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 {