From c30061403a9b36afba29bf803fe473696e4e7b8f Mon Sep 17 00:00:00 2001 From: Stephan Vock Date: Thu, 26 Sep 2024 10:30:48 +0100 Subject: [PATCH] TOTP: introduce new interface that prevents code reuse --- src/OTPWithPreviousTimestampInterface.php | 19 ++++++++++ src/TOTP.php | 46 ++++++++++++++++++++--- tests/TOTPTest.php | 12 ++++++ 3 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 src/OTPWithPreviousTimestampInterface.php diff --git a/src/OTPWithPreviousTimestampInterface.php b/src/OTPWithPreviousTimestampInterface.php new file mode 100644 index 0000000..0f51a8d --- /dev/null +++ b/src/OTPWithPreviousTimestampInterface.php @@ -0,0 +1,19 @@ +verifyWithPreviousTimestamp($otp, $timestamp, $leeway, null); + } + + /** + * Verify method which prevents previously used codes from being used again. The passed values are in seconds. + * + * @param non-empty-string $otp + * @param 0|positive-int $timestamp + * @param null|0|positive-int $leeway + * @param null|0|positive-int $previousTimestamp + * @return int|false the timestamp matching the otp on success, and false on error + */ + public function verifyWithPreviousTimestamp(string $otp, null|int $timestamp = null, null|int $leeway = null, null|int $previousTimestamp = null): int|false { $timestamp ??= $this->clock->now() ->getTimestamp(); $timestamp >= 0 || throw new InvalidArgumentException('Timestamp must be at least 0.'); if ($leeway === null) { - return $this->compareOTP($this->at($timestamp), $otp); + return $this->verifyOTPAtTimestamps($otp, [$timestamp], $previousTimestamp); } $leeway = abs($leeway); @@ -135,9 +149,31 @@ public function verify(string $otp, null|int $timestamp = null, null|int $leeway 'The timestamp must be greater than or equal to the leeway.' ); - return $this->compareOTP($this->at($timestampMinusLeeway), $otp) - || $this->compareOTP($this->at($timestamp), $otp) - || $this->compareOTP($this->at($timestamp + $leeway), $otp); + return $this->verifyOTPAtTimestamps($otp, [$timestampMinusLeeway, $timestamp, $timestamp + $leeway], $previousTimestamp); + } + + /** + * @param non-empty-string $otp + * @param array<0|positive-int> $timestamps + */ + private function verifyOTPAtTimestamps(string $otp, array $timestamps, null|int $previousTimestamp): int|false + { + $previousTimeCode = null; + if ($previousTimestamp > 0) { + $previousTimeCode = $this->timecode($previousTimestamp); + } + + foreach ($timestamps as $timestamp) { + if ($previousTimeCode !== null && $previousTimeCode >= $this->timecode($timestamp)) { + continue; + } + + if ($this->compareOTP($this->at($timestamp), $otp)) { + return $timestamp; + } + } + + return false; } public function getProvisioningUri(): string diff --git a/tests/TOTPTest.php b/tests/TOTPTest.php index 1b8a2df..49b9e04 100644 --- a/tests/TOTPTest.php +++ b/tests/TOTPTest.php @@ -211,6 +211,18 @@ public function verifyOtpWithEpoch(): void static::assertFalse($otp->verify('139664', 1_301_012_297)); } + #[Test] + public function verifyOtpWithPreviousTimestamp(): void + { + $otp = self::createTOTP(6, 'sha1', 30); + + static::assertSame(319_690_800, $otp->verifyWithPreviousTimestamp('762124', 319_690_800, null, 0)); + static::assertSame(319_690_800, $otp->verifyWithPreviousTimestamp('762124', 319_690_800, null, 319_690_770), 'Can use new code'); + static::assertFalse($otp->verifyWithPreviousTimestamp('762124', 319_690_800, null, 319_690_800), 'Cannot use same code again'); + static::assertFalse($otp->verifyWithPreviousTimestamp('762124', 319_690_801, null, 319_690_800), 'Cannot use same code again at different timestamp'); + static::assertFalse($otp->verifyWithPreviousTimestamp('762124', 319_690_800, null, 319_690_830), 'Cannot use previous code'); + } + #[Test] public function notCompatibleWithGoogleAuthenticator(): void {