Skip to content

Commit 0ef4423

Browse files
committed
Merge branch 'one-time-password'
2 parents d65a3bd + 0970bed commit 0ef4423

File tree

7 files changed

+260
-0
lines changed

7 files changed

+260
-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+
}

src/Toolkit/Utility/Str.php

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ final class Str extends AbstractUtility
3939
*/
4040
public const DEFAULT_ITEM_REGEX = '/^(?<indent>\h*[-*] )/';
4141

42+
private const BASE32_INDEX = ['A' => 0, 'B' => 1, 'C' => 2, 'D' => 3, 'E' => 4, 'F' => 5, 'G' => 6, 'H' => 7, 'I' => 8, 'J' => 9, 'K' => 10, 'L' => 11, 'M' => 12, 'N' => 13, 'O' => 14, 'P' => 15, 'Q' => 16, 'R' => 17, 'S' => 18, 'T' => 19, 'U' => 20, 'V' => 21, 'W' => 22, 'X' => 23, 'Y' => 24, 'Z' => 25, '2' => 26, '3' => 27, '4' => 28, '5' => 29, '6' => 30, '7' => 31];
43+
4244
/**
4345
* Get the first string that is not null or empty, or return the last value
4446
*
@@ -492,6 +494,60 @@ public static function expandLeadingTabs(
492494
return $expanded;
493495
}
494496

497+
/**
498+
* Decode data encoded with base32
499+
*
500+
* @param bool $strict If `true`, throw an exception if any characters in
501+
* `$string` are not in the \[RFC4648] "base32" alphabet after removing
502+
* padding characters ('=') and converting ASCII letters to uppercase:
503+
*
504+
* ```
505+
* ABCDEFGHIJKLMNOPQRSTUVWXYZ234567
506+
* ```
507+
*
508+
* Otherwise, discard invalid characters.
509+
*/
510+
public static function decodeBase32(string $string, bool $strict = false): string
511+
{
512+
$string = self::upper(rtrim($string, '='));
513+
514+
// Handle different `str_split('')` behaviour between PHP 8.2+ and
515+
// earlier versions
516+
if ($string === '') {
517+
return '';
518+
}
519+
520+
$bytes = '';
521+
$currentByte = 0;
522+
$currentBits = 0;
523+
foreach (str_split($string) as $character) {
524+
$value = self::BASE32_INDEX[$character] ?? null;
525+
if ($value === null) {
526+
if ($strict) {
527+
throw new InvalidArgumentException(
528+
sprintf('Character not in base32 alphabet: %s', $character),
529+
);
530+
}
531+
continue;
532+
}
533+
// `$value` won't complete a byte without 3 or more existing bits
534+
if ($currentBits < 3) {
535+
$currentByte <<= 5;
536+
$currentByte += $value;
537+
$currentBits += 5;
538+
} else {
539+
$useBits = 8 - $currentBits;
540+
$carryBits = 5 - $useBits;
541+
$currentByte <<= $useBits;
542+
$currentByte += $value >> $carryBits;
543+
$bytes .= chr($currentByte);
544+
$currentByte = $value & ((1 << $carryBits) - 1);
545+
$currentBits = $carryBits;
546+
}
547+
}
548+
return $bytes;
549+
}
550+
495551
/**
496552
* Copy a string to a php://temp stream
497553
*

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
}

tests/unit/Toolkit/Utility/StrTest.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,42 @@ public static function expandLeadingTabsProvider(): array
875875
];
876876
}
877877

878+
/**
879+
* @dataProvider decodeBase32provider
880+
*/
881+
public function testDecodeBase32(?string $expected, string $string, bool $strict = false): void
882+
{
883+
if ($expected === null) {
884+
$this->expectException(InvalidArgumentException::class);
885+
}
886+
$this->assertSame($expected, Str::decodeBase32($string, $strict));
887+
}
888+
889+
/**
890+
* @return array<array{string|null,string,2?:bool}>
891+
*/
892+
public function decodeBase32provider(): array
893+
{
894+
return [
895+
['', ''],
896+
['f', 'MY======'],
897+
['fo', 'MZXQ===='],
898+
['foo', 'MZXW6==='],
899+
['foob', 'mzxw6yq='],
900+
['fooba', 'mzxw6ytb'],
901+
['foobar', 'mzxw6ytBOI======'],
902+
['foobar', 'MZXW6YTBOI01===='],
903+
['', '', true],
904+
['f', 'my======', true],
905+
['fo', 'mzxq====', true],
906+
['foo', 'mzxw6===', true],
907+
['foob', 'MZXW6YQ=', true],
908+
['fooba', 'MZXW6YTB', true],
909+
['foobar', 'MZXW6YTboi======', true],
910+
[null, 'mzxw6ytboi01====', true],
911+
];
912+
}
913+
878914
/**
879915
* @dataProvider splitDelimitedProvider
880916
*

0 commit comments

Comments
 (0)