Skip to content

Commit 0970bed

Browse files
committed
Http: Add HttpUtil::getTOTP() and relevant classes and interfaces
1 parent 725c6cc commit 0970bed

File tree

5 files changed

+168
-0
lines changed

5 files changed

+168
-0
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Salient\Contract;
4+
5+
/**
6+
* @api
7+
*/
8+
interface HasHmacHashAlgorithm
9+
{
10+
public const ALGORITHM_SHA1 = 'sha1';
11+
public const ALGORITHM_SHA256 = 'sha256';
12+
public const ALGORITHM_SHA512 = 'sha512';
13+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace Salient\Contract\Http;
4+
5+
/**
6+
* @api
7+
*/
8+
interface OneTimePasswordGeneratorInterface
9+
{
10+
/**
11+
* @param string $key Base32-encoded shared key.
12+
*/
13+
public function getPassword(string $key): string;
14+
}

src/Toolkit/Http/HttpUtil.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@
1313
use Salient\Contract\Http\HasHttpHeader;
1414
use Salient\Contract\Http\HasMediaType;
1515
use Salient\Contract\Http\HasRequestMethod;
16+
use Salient\Contract\HasHmacHashAlgorithm;
1617
use Salient\Http\Exception\InvalidHeaderException;
1718
use Salient\Http\Internal\FormDataEncoder;
19+
use Salient\Http\Internal\TOTPGenerator;
1820
use Salient\Utility\AbstractUtility;
1921
use Salient\Utility\Date;
2022
use Salient\Utility\Package;
@@ -31,6 +33,7 @@
3133
*/
3234
final class HttpUtil extends AbstractUtility implements
3335
HasFormDataFlag,
36+
HasHmacHashAlgorithm,
3437
HasHttpHeader,
3538
HasHttpRegex,
3639
HasMediaType,
@@ -432,4 +435,20 @@ public static function getNameValuePairs(iterable $items): iterable
432435
yield $item['name'] => $item['value'];
433436
}
434437
}
438+
439+
/**
440+
* Get a time-based one-time password as per [RFC6238]
441+
*
442+
* @param string $key Base32-encoded shared key.
443+
* @param HttpUtil::ALGORITHM_* $algorithm
444+
*/
445+
public static function getTOTP(
446+
string $key,
447+
int $digits = 6,
448+
string $algorithm = HttpUtil::ALGORITHM_SHA1,
449+
int $timeStep = 30,
450+
int $startTime = 0
451+
): string {
452+
return (new TOTPGenerator($digits, $algorithm, $timeStep, $startTime))->getPassword($key);
453+
}
435454
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php
2+
3+
namespace Salient\Http\Internal;
4+
5+
use Salient\Contract\Http\OneTimePasswordGeneratorInterface;
6+
use Salient\Contract\HasHmacHashAlgorithm;
7+
use Salient\Utility\Str;
8+
9+
/**
10+
* An [RFC6238] time-based one-time password generator
11+
*
12+
* @internal
13+
*/
14+
final class TOTPGenerator implements
15+
OneTimePasswordGeneratorInterface,
16+
HasHmacHashAlgorithm
17+
{
18+
private int $Digits;
19+
/** @var self::ALGORITHM_* */
20+
private string $Algorithm;
21+
private int $TimeStep;
22+
private int $StartTime;
23+
24+
/**
25+
* @param TOTPGenerator::ALGORITHM_* $algorithm
26+
*/
27+
public function __construct(
28+
int $digits = 6,
29+
string $algorithm = TOTPGenerator::ALGORITHM_SHA1,
30+
int $timeStep = 30,
31+
int $startTime = 0
32+
) {
33+
$this->Digits = $digits;
34+
$this->Algorithm = $algorithm;
35+
$this->TimeStep = $timeStep;
36+
$this->StartTime = $startTime;
37+
}
38+
39+
/**
40+
* @inheritDoc
41+
*/
42+
public function getPassword(string $key): string
43+
{
44+
// Get time steps between T0 and now as per [RFC6238] Section 4.2
45+
$X = $this->TimeStep;
46+
$T0 = $this->StartTime;
47+
$T = (int) floor((time() - $T0) / $X);
48+
49+
/** @var string */
50+
$T = hex2bin(sprintf('%016s', dechex($T)));
51+
$K = Str::decodeBase32($key, true);
52+
53+
$hash = hash_hmac($this->Algorithm, $T, $K, true);
54+
55+
// Use dynamic truncation as per [RFC4226] Section 5.3
56+
$offset = ord($hash[-1]) & 0xF;
57+
58+
$binaryCode = ((ord($hash[$offset]) & 0x7F) << 24)
59+
| ((ord($hash[$offset + 1]) & 0xFF) << 16)
60+
| ((ord($hash[$offset + 2]) & 0xFF) << 8)
61+
| (ord($hash[$offset + 3]) & 0xFF);
62+
63+
return sprintf(
64+
'%0' . $this->Digits . 's',
65+
substr((string) $binaryCode, -$this->Digits),
66+
);
67+
}
68+
}

tests/unit/Toolkit/Http/HttpUtilTest.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,4 +423,58 @@ public static function replaceQueryProvider(): array
423423
],
424424
];
425425
}
426+
427+
/**
428+
* @dataProvider getTOTPProvider
429+
*
430+
* @param HttpUtil::ALGORITHM_* $algorithm
431+
*/
432+
public function testGetTOTP(
433+
string $expected,
434+
string $key,
435+
int $digits = 6,
436+
string $algorithm = HttpUtil::ALGORITHM_SHA1,
437+
int $timeStep = 30,
438+
int $secondsSinceStartTime = 0
439+
): void {
440+
$startTime = time() - $secondsSinceStartTime;
441+
$this->assertSame(
442+
$expected,
443+
HttpUtil::getTOTP($key, $digits, $algorithm, $timeStep, $startTime),
444+
);
445+
}
446+
447+
/**
448+
* @return array<array{string,string,2?:int,3?:HttpUtil::ALGORITHM_*,4?:int,5?:int}>
449+
*/
450+
public static function getTOTPProvider(): array
451+
{
452+
// 12345678901234567890 (20 bytes)
453+
$key1 = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ';
454+
// 12345678901234567890123456789012 (32 bytes)
455+
$key256 = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZA====';
456+
// 1234567890123456789012345678901234567890123456789012345678901234 (64 bytes)
457+
$key512 = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQGEZDGNA=';
458+
459+
return [
460+
['94287082', $key1, 8, HttpUtil::ALGORITHM_SHA1, 30, 59],
461+
['46119246', $key256, 8, HttpUtil::ALGORITHM_SHA256, 30, 59],
462+
['90693936', $key512, 8, HttpUtil::ALGORITHM_SHA512, 30, 59],
463+
['07081804', $key1, 8, HttpUtil::ALGORITHM_SHA1, 30, 1111111109],
464+
['68084774', $key256, 8, HttpUtil::ALGORITHM_SHA256, 30, 1111111109],
465+
['25091201', $key512, 8, HttpUtil::ALGORITHM_SHA512, 30, 1111111109],
466+
['14050471', $key1, 8, HttpUtil::ALGORITHM_SHA1, 30, 1111111111],
467+
['67062674', $key256, 8, HttpUtil::ALGORITHM_SHA256, 30, 1111111111],
468+
['99943326', $key512, 8, HttpUtil::ALGORITHM_SHA512, 30, 1111111111],
469+
['89005924', $key1, 8, HttpUtil::ALGORITHM_SHA1, 30, 1234567890],
470+
['91819424', $key256, 8, HttpUtil::ALGORITHM_SHA256, 30, 1234567890],
471+
['93441116', $key512, 8, HttpUtil::ALGORITHM_SHA512, 30, 1234567890],
472+
['69279037', $key1, 8, HttpUtil::ALGORITHM_SHA1, 30, 2000000000],
473+
['90698825', $key256, 8, HttpUtil::ALGORITHM_SHA256, 30, 2000000000],
474+
['38618901', $key512, 8, HttpUtil::ALGORITHM_SHA512, 30, 2000000000],
475+
['65353130', $key1, 8, HttpUtil::ALGORITHM_SHA1, 30, 20000000000],
476+
['77737706', $key256, 8, HttpUtil::ALGORITHM_SHA256, 30, 20000000000],
477+
['47863826', $key512, 8, HttpUtil::ALGORITHM_SHA512, 30, 20000000000],
478+
];
479+
}
426480
}

0 commit comments

Comments
 (0)