Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Leeway #152

Merged
merged 2 commits into from
Feb 18, 2022
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
28 changes: 21 additions & 7 deletions doc/Window.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,27 @@ $hotp->verify('123456', 999, 10); // Will return true (1000 is tested)

## Window and TOTP

The window of timestamps goes from `timestamp - window * period` to `timestamp + window * period`.
For example, if the `window` is `5`, the period `30` and the timestamp `1476822000`, the OTP tested are within `1476821850` (`1476822000 - 5 * 30`) and `1476822150` (`1476822000 + 5 * 30`).
The window of TOTP acts as time drift.
If the `window` is `10`, the period `30` and the timestamp `147682209`,
the OTP tested are within `1476821999` (`147682209 - 10`), `147682209` and `1476822219` (`147682209 + 10`).
This includes the previous OTP, but not the next one.

As an example, at the timestamp `147682209` the correct TOTP is `123456`.
But the user device timestamp `1476821995` and the proposed input is the previous one `654321`.

**It is mandatory to have a window lower than the period**.

```php
$totp->at(1000); // e.g. will return '123456'
$totp->verify('123456'); // Will return true
// 30 seconds later
$totp->verify('123456'); // Will return false
$totp->verify('123456', null, 1); // Will return true during the next period
// Without the window feature, this will fail
$totp->verify('654321'); // returns false

// With a window 5 seconds, this will fail
// because the input is tested with 147682209-5 and 147682209+5 seconds.
// and this does not allow the previous OTP to be used
$totp->verify('654321', null, 5); // returns false

// With a window 10 seconds, this will succeed
// because the input is tested with 147682209-10 and 147682209+10 seconds.
// and the previous OTP is tested
$totp->verify('654321', null, 10); // returns true
```
43 changes: 12 additions & 31 deletions src/TOTP.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,24 @@ public function now(): string
}

/**
* If no timestamp is provided, the OTP is verified at the actual timestamp.
* If no timestamp is provided, the OTP is verified at the actual timestamp. When used, the leeway parameter will
* allow time drift. The passed value is in seconds.
*/
public function verify(string $otp, null|int $timestamp = null, null|int $window = null): bool
public function verify(string $otp, null|int $timestamp = null, null|int $leeway = null): bool
{
$timestamp = $this->getTimestamp($timestamp);
$timestamp = $timestamp ?? time();
Assertion::greaterOrEqualThan($timestamp, 0, 'Timestamp must be at least 0.');

if ($window === null) {
if ($leeway === null) {
return $this->compareOTP($this->at($timestamp), $otp);
}

return $this->verifyOtpWithWindow($otp, $timestamp, $window);
$leeway = abs($leeway);
Assertion::lessThan($leeway, $this->getPeriod(), 'The leeway must be lower than the TOTP period');

return $this->compareOTP($this->at($timestamp - $leeway), $otp)
|| $this->compareOTP($this->at($timestamp), $otp)
|| $this->compareOTP($this->at($timestamp + $leeway), $otp);
}

public function getProvisioningUri(): string
Expand Down Expand Up @@ -133,32 +140,6 @@ private function setEpoch(int $epoch): void
$this->setParameter('epoch', $epoch);
}

private function verifyOtpWithWindow(string $otp, int $timestamp, int $window): bool
{
$window = abs($window);

for ($i = 0; $i <= $window; ++$i) {
$next = $i * $this->getPeriod() + $timestamp;
$previous = -$i * $this->getPeriod() + $timestamp;
$valid = $this->compareOTP($this->at($next), $otp) ||
$this->compareOTP($this->at($previous), $otp);

if ($valid) {
return true;
}
}

return false;
}

private function getTimestamp(null|int $timestamp): int
{
$timestamp = $timestamp ?? time();
Assertion::greaterOrEqualThan($timestamp, 0, 'Timestamp must be at least 0.');

return $timestamp;
}

private function timecode(int $timestamp): int
{
return (int) floor(($timestamp - $this->getEpoch()) / $this->getPeriod());
Expand Down
77 changes: 61 additions & 16 deletions tests/TOTPTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -276,33 +276,58 @@ public function dataVectors(): array
/**
* @test
*/
public function verifyOtpInWindow(): void
public function invalidOtpWindow(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The leeway must be lower than the TOTP period');
$otp = $this->createTOTP(6, 'sha1', 30);
$otp->verify('123456', null, 31);
}

/**
* @test
* @dataProvider dataLeeway
*/
public function verifyOtpInWindow(int $timestamp, string $input, int $leeway, bool $expectedResult): void
{
ClockMock::register(TOTP::class);
ClockMock::withClockMock($timestamp);
$otp = $this->createTOTP(6, 'sha1', 30);

static::assertFalse($otp->verify('054409', 319690800, 10)); // -11 periods
static::assertTrue($otp->verify('808167', 319690800, 10)); // -10 periods
static::assertTrue($otp->verify('364393', 319690800, 10)); // -9 periods
static::assertTrue($otp->verify('762124', 319690800, 10)); // 0 periods
static::assertTrue($otp->verify('988451', 319690800, 10)); // +9 periods
static::assertTrue($otp->verify('789387', 319690800, 10)); // +10 periods
static::assertFalse($otp->verify('465009', 319690800, 10)); // +11 periods
static::assertSame($expectedResult, $otp->verify($input, null, $leeway));
}

/**
* @test
* @dataProvider dataLeewayWithEpoch
*/
public function verifyOtpWithEpochInWindow(): void
public function verifyOtpWithEpochInWindow(int $timestamp, string $input, int $leeway, bool $expectedResult): void
{
ClockMock::register(TOTP::class);
ClockMock::withClockMock($timestamp);
$otp = $this->createTOTP(6, 'sha1', 30, 'JDDK4U6G3BJLEZ7Y', '[email protected]', 'My Project', 100);

static::assertFalse($otp->verify('054409', 319690900, 10)); // -11 periods
static::assertTrue($otp->verify('808167', 319690900, 10)); // -10 periods
static::assertTrue($otp->verify('364393', 319690900, 10)); // -9 periods
static::assertTrue($otp->verify('762124', 319690900, 10)); // 0 periods
static::assertTrue($otp->verify('988451', 319690900, 10)); // +9 periods
static::assertTrue($otp->verify('789387', 319690900, 10)); // +10 periods
static::assertFalse($otp->verify('465009', 319690900, 10)); // +11 periods
static::assertSame($expectedResult, $otp->verify($input, null, $leeway));
}

/**
* @return array<int, int|string|bool>[]
*/
public function dataLeewayWithEpoch(): array
{
return [
[319690889, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec
[319690890, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec
[319690899, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec
[319690899, '762124', 0, false], //No leeway, **out** the period
[319690900, '762124', 0, true], //No leeway, in the period
[319690920, '762124', 0, true], //No leeway, in the period
[319690929, '762124', 0, true], //No leeway, in the period
[319690930, '762124', 0, false], //No leeway, **out** the period
[319690930, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec
[319690939, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec
[319690940, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec
];
}

/**
Expand Down Expand Up @@ -349,6 +374,26 @@ public function dataRemainingTimeBeforeExpiration(): array
];
}

/**
* @return array<int, int|string|bool>[]
*/
public function dataLeeway(): array
{
return [
[319690789, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec
[319690790, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec
[319690799, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec
[319690799, '762124', 0, false], //No leeway, **out** the period
[319690800, '762124', 0, true], //No leeway, in the period
[319690820, '762124', 0, true], //No leeway, in the period
[319690829, '762124', 0, true], //No leeway, in the period
[319690830, '762124', 0, false], //No leeway, **out** the period
[319690830, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec
[319690839, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec
[319690840, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec
];
}

private function createTOTP(
int $digits,
string $digest,
Expand Down