Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
49 changes: 41 additions & 8 deletions src/HOTP.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ];
Expand All @@ -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);
}
Expand All @@ -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);
}

/**
Expand All @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/HOTPResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
92 changes: 92 additions & 0 deletions tests/HOTPTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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
{
Expand Down