From 4b303e33972f6c886f32b38fea60f41be6d74ec1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steve=20M=C3=BCller?= Date: Mon, 26 Feb 2018 14:45:15 +0100 Subject: [PATCH] Implement TOTP T0 (epoch) parameter according to RFC 6238 (#100) Implement TOTP T0 (epoch) parameter according to RFC 6238 --- doc/Customize.md | 50 +++++++++++++++++++++++++++++++++++- src/TOTP.php | 64 ++++++++++++++++++++++++++++++++++++++-------- tests/TOTPTest.php | 52 ++++++++++++++++++++++++++++++++++--- 3 files changed, 151 insertions(+), 15 deletions(-) diff --git a/doc/Customize.md b/doc/Customize.md index 3f3a3346..86ae607b 100644 --- a/doc/Customize.md +++ b/doc/Customize.md @@ -8,7 +8,7 @@ To generate one-time passwords, each class needs at least the following paramete Depending on the type of OTP, you will need the following additional parameters: -* For TOTP: a period +* For TOTP: a period (and optionally an epoch) * For HOTP: a counter ## Secret @@ -81,6 +81,54 @@ $totp = TOTP::create( ); ``` +## Epoch (TOTP only) + +By default, the epoch for a TOTP is `0`. +The epoch is equivalent to the `T0` parameter in [RFC 6238](https://tools.ietf.org/html/rfc6238#page-4). +This parameter basically determines at which timestamp (epoch) to start counting. It is useful in scenarios where +you need an exact period to verify passwords in. The epoch can be shared by client and server to specify the exact +timestamp at which the password was created so that you can reuse it for exact verification. + +**CAUTION:** If you follow this approach and share the epoch as password creation timestamp, you should use dynamic +secrets that are different each time, otherwise you will most likely always produce the same passwords. You could for +example encode the timestamp in the secret to make it different each time. + +```php +at(1519401289); // Current period is: 1519401285 - 1519401289 + +$otp->verify($password, 1519401289); // Second 1: true +$otp->verify($password, 1519401290); // Second 2: false + +// With epoch +$otp = TOTP::create( + null, // Let the secret be defined by the class + 5, // The period (5 seconds) + 'sha1', // The digest algorithm + 6, // The output will generate 6 digits + 1519401289 // The epoch is now 02/23/2018 @ 3:54:49pm (UTC) +); + +$password = $otp->at(1519401289); // Current period is: 1519401289 - 1519401293 + +$otp->verify($password, 1519401289); // Second 1: true +$otp->verify($password, 1519401290); // Second 2: true +$otp->verify($password, 1519401291); // Second 3: true +$otp->verify($password, 1519401292); // Second 4: true +$otp->verify($password, 1519401293); // Second 5: true +$otp->verify($password, 1519401294); // Second 6: false +``` + ## Custom parameters OTP objects are able to support custom parameters. diff --git a/src/TOTP.php b/src/TOTP.php index 7a0bf8ae..de96ab96 100644 --- a/src/TOTP.php +++ b/src/TOTP.php @@ -24,11 +24,13 @@ final class TOTP extends OTP implements TOTPInterface * @param int $period * @param string $digest * @param int $digits + * @param int $epoch */ - protected function __construct(?string $secret, int $period, string $digest, int $digits) + protected function __construct(?string $secret, int $period, string $digest, int $digits, int $epoch = 0) { parent::__construct($secret, $digest, $digits); $this->setPeriod($period); + $this->setEpoch($epoch); } /** @@ -38,12 +40,13 @@ protected function __construct(?string $secret, int $period, string $digest, int * @param int $period * @param string $digest * @param int $digits + * @param int $epoch * * @return self */ - public static function create(?string $secret = null, int $period = 30, string $digest = 'sha1', int $digits = 6): self + public static function create(?string $secret = null, int $period = 30, string $digest = 'sha1', int $digits = 6, int $epoch = 0): self { - return new self($secret, $period, $digest, $digits); + return new self($secret, $period, $digest, $digits, $epoch); } /** @@ -62,6 +65,22 @@ public function getPeriod(): int return $this->getParameter('period'); } + /** + * @param int $epoch + */ + private function setEpoch(int $epoch) + { + $this->setParameter('epoch', $epoch); + } + + /** + * {@inheritdoc} + */ + public function getEpoch(): int + { + return $this->getParameter('epoch'); + } + /** * {@inheritdoc} */ @@ -138,7 +157,11 @@ public function getProvisioningUri(): string { $params = []; if (30 !== $this->getPeriod()) { - $params = ['period' => $this->getPeriod()]; + $params['period'] = $this->getPeriod(); + } + + if (0 !== $this->getEpoch()) { + $params['epoch'] = $this->getEpoch(); } return $this->generateURI('totp', $params); @@ -151,7 +174,7 @@ public function getProvisioningUri(): string */ private function timecode(int $timestamp): int { - return (int) floor($timestamp / $this->getPeriod()); + return (int) floor(($timestamp - $this->getEpoch()) / $this->getPeriod()); } /** @@ -161,13 +184,34 @@ protected function getParameterMap(): array { $v = array_merge( parent::getParameterMap(), - ['period' => function ($value) { - Assertion::greaterThan((int) $value, 0, 'Period must be at least 1.'); - - return (int) $value; - }] + [ + 'period' => function ($value) { + Assertion::greaterThan((int) $value, 0, 'Period must be at least 1.'); + + return (int) $value; + }, + 'epoch' => function ($value) { + Assertion::greaterOrEqualThan((int) $value, 0, 'Epoch must be greater than or equal to 0.'); + + return (int) $value; + }, + ] ); return $v; } + + /** + * {@inheritdoc} + */ + protected function filterOptions(array &$options) + { + parent::filterOptions($options); + + if (isset($options['epoch']) && 0 === $options['epoch']) { + unset($options['epoch']); + } + + ksort($options); + } } diff --git a/tests/TOTPTest.php b/tests/TOTPTest.php index d2dd47f7..519a9efb 100644 --- a/tests/TOTPTest.php +++ b/tests/TOTPTest.php @@ -32,12 +32,12 @@ public function testLabelNotDefined() public function testCustomParameter() { - $otp = TOTP::create('JDDK4U6G3BJLEZ7Y', 20, 'sha512', 8); + $otp = TOTP::create('JDDK4U6G3BJLEZ7Y', 20, 'sha512', 8, 100); $otp->setLabel('alice@foo.bar'); $otp->setIssuer('My Project'); $otp->setParameter('foo', 'bar.baz'); - $this->assertEquals('otpauth://totp/My%20Project%3Aalice%40foo.bar?algorithm=sha512&digits=8&foo=bar.baz&issuer=My%20Project&period=20&secret=JDDK4U6G3BJLEZ7Y', $otp->getProvisioningUri()); + $this->assertEquals('otpauth://totp/My%20Project%3Aalice%40foo.bar?algorithm=sha512&digits=8&epoch=100&foo=bar.baz&issuer=My%20Project&period=20&secret=JDDK4U6G3BJLEZ7Y', $otp->getProvisioningUri()); } public function testObjectCreationValid() @@ -56,6 +56,15 @@ public function testPeriodIsNot1OrMore() TOTP::create('JDDK4U6G3BJLEZ7Y', -20, 'sha512', 8); } + /** + * @expectedException \InvalidArgumentException + * @expectedExceptionMessage Epoch must be greater than or equal to 0. + */ + public function testEpochIsNot0OrMore() + { + TOTP::create('JDDK4U6G3BJLEZ7Y', 30, 'sha512', 8, -1); + } + /** * @expectedException \RuntimeException * @expectedExceptionMessage Unable to decode the secret. Is it correctly base32 encoded? @@ -84,6 +93,15 @@ public function testGenerateOtpAt() $this->assertEquals('139664', $otp->at(1301012137)); } + public function testGenerateOtpWithEpochAt() + { + $otp = $this->createTOTP(6, 'sha1', 30, 'JDDK4U6G3BJLEZ7Y', 'alice@foo.bar', 'My Project', 100); + + $this->assertEquals('855783', $otp->at(100)); + $this->assertEquals('762124', $otp->at(319690900)); + $this->assertEquals('139664', $otp->at(1301012237)); + } + public function testWrongSizeOtp() { $otp = $this->createTOTP(6, 'sha1', 30); @@ -123,6 +141,19 @@ public function testVerifyOtp() $this->assertFalse($otp->verify('139664', 1301012197)); } + public function testVerifyOtpWithEpoch() + { + $otp = $this->createTOTP(6, 'sha1', 30, 'JDDK4U6G3BJLEZ7Y', 'alice@foo.bar', 'My Project', 100); + + $this->assertTrue($otp->verify('855783', 100)); + $this->assertTrue($otp->verify('762124', 319690900)); + $this->assertTrue($otp->verify('139664', 1301012237)); + + $this->assertFalse($otp->verify('139664', 1301012207)); + $this->assertFalse($otp->verify('139664', 1301012267)); + $this->assertFalse($otp->verify('139664', 1301012297)); + } + public function testNotCompatibleWithGoogleAuthenticator() { $otp = $this->createTOTP(9, 'sha512', 10); @@ -188,6 +219,19 @@ public function testVerifyOtpInWindow() $this->assertFalse($otp->verify('465009', 319690800, 10)); // +11 periods } + public function testVerifyOtpWithEpochInWindow() + { + $otp = $this->createTOTP(6, 'sha1', 30, 'JDDK4U6G3BJLEZ7Y', 'alice@foo.bar', 'My Project', 100); + + $this->assertFalse($otp->verify('054409', 319690900, 10)); // -11 periods + $this->assertTrue($otp->verify('808167', 319690900, 10)); // -10 periods + $this->assertTrue($otp->verify('364393', 319690900, 10)); // -9 periods + $this->assertTrue($otp->verify('762124', 319690900, 10)); // 0 periods + $this->assertTrue($otp->verify('988451', 319690900, 10)); // +9 periods + $this->assertTrue($otp->verify('789387', 319690900, 10)); // +10 periods + $this->assertFalse($otp->verify('465009', 319690900, 10)); // +11 periods + } + public function testQRCodeUri() { $otp = $this->createTOTP(6, 'sha1', 30, 'DJBSWY3DPEHPK3PXP', 'alice@google.com', 'My Big Compagny'); @@ -196,9 +240,9 @@ public function testQRCodeUri() $this->assertEquals('http://api.qrserver.com/v1/create-qr-code/?color=5330FF&bgcolor=70FF7E&data=otpauth%3A%2F%2Ftotp%2FMy%2520Big%2520Compagny%253Aalice%2540google.com%3Fissuer%3DMy%2520Big%2520Compagny%26secret%3DDJBSWY3DPEHPK3PXP&qzone=2&margin=0&size=300x300&ecc=H', $otp->getQrCodeUri('http://api.qrserver.com/v1/create-qr-code/?color=5330FF&bgcolor=70FF7E&data=[DATA HERE]&qzone=2&margin=0&size=300x300&ecc=H', '[DATA HERE]')); } - private function createTOTP($digits, $digest, $period, $secret = 'JDDK4U6G3BJLEZ7Y', $label = 'alice@foo.bar', $issuer = 'My Project') + private function createTOTP($digits, $digest, $period, $secret = 'JDDK4U6G3BJLEZ7Y', $label = 'alice@foo.bar', $issuer = 'My Project', $epoch = 0) { - $otp = TOTP::create($secret, $period, $digest, $digits); + $otp = TOTP::create($secret, $period, $digest, $digits, $epoch); $otp->setLabel($label); $otp->setIssuer($issuer);