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