Skip to content

Commit

Permalink
Implement TOTP T0 (epoch) parameter according to RFC 6238 (#100)
Browse files Browse the repository at this point in the history
Implement TOTP T0 (epoch) parameter according to RFC 6238
  • Loading branch information
deeky666 authored and Spomky committed Feb 26, 2018
1 parent d53c727 commit 4b303e3
Show file tree
Hide file tree
Showing 3 changed files with 151 additions and 15 deletions.
50 changes: 49 additions & 1 deletion doc/Customize.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
<?php
use OTPHP\TOTP;

// Without 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
);

$password = $otp->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.
Expand Down
64 changes: 54 additions & 10 deletions src/TOTP.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand All @@ -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);
}

/**
Expand All @@ -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}
*/
Expand Down Expand Up @@ -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);
Expand All @@ -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());
}

/**
Expand All @@ -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);
}
}
52 changes: 48 additions & 4 deletions tests/TOTPTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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?
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand All @@ -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);

Expand Down

0 comments on commit 4b303e3

Please sign in to comment.