1<?php 2declare(strict_types=1); 3/** 4 * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> 5 * 6 * @author Roeland Jago Douma <roeland@famdouma.nl> 7 * 8 * @license GNU AGPL version 3 or any later version 9 * 10 * This program is free software: you can redistribute it and/or modify 11 * it under the terms of the GNU Affero General Public License as 12 * published by the Free Software Foundation, either version 3 of the 13 * License, or (at your option) any later version. 14 * 15 * This program is distributed in the hope that it will be useful, 16 * but WITHOUT ANY WARRANTY; without even the implied warranty of 17 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 * GNU Affero General Public License for more details. 19 * 20 * You should have received a copy of the GNU Affero General Public License 21 * along with this program. If not, see <http://www.gnu.org/licenses/>. 22 * 23 */ 24 25namespace EasyTOTP; 26 27class TOTP implements TOTPInterface { 28 29 /** @var string */ 30 private $secret; 31 /** @var int */ 32 private $digits; 33 /** @var int */ 34 private $offset; 35 /** @var int */ 36 private $timeStep; 37 /** @var string */ 38 private $hashFunction; 39 /** @var TimeService */ 40 private $timeService; 41 42 public function __construct(string $secret, int $timeStep, int $digits, int $offset, string $hashFunction, TimeService $timeService) { 43 $this->secret = $secret; 44 $this->timeStep = $timeStep; 45 $this->digits = $digits; 46 $this->offset = $offset; 47 $this->hashFunction = $hashFunction; 48 $this->timeService = $timeService; 49 } 50 51 public function verify(string $otp, int $drift = 1, ?int $lastKnownCounter = null): TOTPResultInterface { 52 $currentCounter = $this->getCurrentCounter(); 53 54 $start = $currentCounter - $drift; 55 $end = $currentCounter + $drift; 56 57 for ($i = $start; $i <= $end; $i++) { 58 // Skip counters smaller than the minimum 59 if ($lastKnownCounter !== null && $i <= $lastKnownCounter) { 60 continue; 61 } 62 63 if (hash_equals($this->hotp($i), $otp)) { 64 return new TOTPValidResult( 65 $i, 66 $i - $currentCounter 67 ); 68 } 69 } 70 71 return new TOTPInvalidResult(); 72 } 73 74 public function getDigits(): int { 75 return $this->digits; 76 } 77 78 public function getHashFunction(): string { 79 return $this->hashFunction; 80 } 81 82 public function getOffset(): int { 83 return $this->offset; 84 } 85 86 public function getSecret(): string { 87 return $this->secret; 88 } 89 90 public function getTimeStep(): int { 91 return $this->timeStep; 92 } 93 94 private function binaryCounter(int $counter): string { 95 if (PHP_INT_SIZE === 4) { 96 /* 97 * Manually do 64bit magic 98 * This will do boom in 2038 ;) 99 */ 100 return pack('N*', 0) . pack('N*', $counter); 101 } 102 103 return pack('J', $counter); 104 } 105 106 /** 107 * See https://tools.ietf.org/html/rfc4226#section-5 108 */ 109 private function hotp(int $counter): string { 110 $hash = hash_hmac( 111 $this->hashFunction, 112 $this->binaryCounter($counter), 113 $this->secret, 114 true 115 ); 116 117 return str_pad((string)$this->truncate($hash), $this->digits, '0', STR_PAD_LEFT); 118 } 119 120 private function truncate(string $hash): int { 121 $offset = \ord($hash[strlen($hash)-1]) & 0xf; 122 123 return ( 124 ((\ord($hash[$offset + 0]) & 0x7f) << 24) | 125 ((\ord($hash[$offset + 1]) & 0xff) << 16) | 126 ((\ord($hash[$offset + 2]) & 0xff) << 8) | 127 (\ord($hash[$offset + 3]) & 0xff) 128 ) % (10 ** $this->digits); 129 } 130 131 private function getCurrentCounter(): int { 132 return (int)floor(($this->timeService->getTime() + $this->offset) / $this->timeStep); 133 } 134 135} 136