aboutsummaryrefslogtreecommitdiffstats
path: root/vendor/spomky-labs/otphp/src/HOTP.php
blob: 1588d48aabc82b1d842a1b07723ee3a3baf6b48a (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
<?php

declare(strict_types=1);

namespace OTPHP;

use InvalidArgumentException;
use function is_int;

/**
 * @see \OTPHP\Test\HOTPTest
 */
final class HOTP extends OTP implements HOTPInterface
{
    private const DEFAULT_WINDOW = 0;

    public static function create(
        null|string $secret = null,
        int $counter = self::DEFAULT_COUNTER,
        string $digest = self::DEFAULT_DIGEST,
        int $digits = self::DEFAULT_DIGITS
    ): self {
        $htop = $secret !== null
            ? self::createFromSecret($secret)
            : self::generate()
        ;
        $htop->setCounter($counter);
        $htop->setDigest($digest);
        $htop->setDigits($digits);

        return $htop;
    }

    public static function createFromSecret(string $secret): self
    {
        $htop = new self($secret);
        $htop->setCounter(self::DEFAULT_COUNTER);
        $htop->setDigest(self::DEFAULT_DIGEST);
        $htop->setDigits(self::DEFAULT_DIGITS);

        return $htop;
    }

    public static function generate(): self
    {
        return self::createFromSecret(self::generateSecret());
    }

    public function getCounter(): int
    {
        $value = $this->getParameter('counter');
        (is_int($value) && $value >= 0) || throw new InvalidArgumentException('Invalid "counter" parameter.');

        return $value;
    }

    public function getProvisioningUri(): string
    {
        return $this->generateURI('hotp', [
            'counter' => $this->getCounter(),
        ]);
    }

    /**
     * If the counter is not provided, the OTP is verified at the actual counter.
     */
    public function verify(string $otp, null|int $counter = null, null|int $window = null): bool
    {
        $counter >= 0 || throw new InvalidArgumentException('The counter must be at least 0.');

        if ($counter === null) {
            $counter = $this->getCounter();
        } elseif ($counter < $this->getCounter()) {
            return false;
        }

        return $this->verifyOtpWithWindow($otp, $counter, $window);
    }

    public function setCounter(int $counter): void
    {
        $this->setParameter('counter', $counter);
    }

    /**
     * @return array<non-empty-string, callable>
     */
    protected function getParameterMap(): array
    {
        return [...parent::getParameterMap(), ...[
            'counter' => static function (mixed $value): int {
                $value = (int) $value;
                $value >= 0 || throw new InvalidArgumentException('Counter must be at least 0.');

                return $value;
            },
        ]];
    }

    /**
     * @param positive-int $counter
     */
    private function updateCounter(int $counter): void
    {
        $this->setCounter($counter);
    }

    /**
     * @param null|0|positive-int $window
     */
    private function getWindow(null|int $window): int
    {
        return abs($window ?? self::DEFAULT_WINDOW);
    }

    /**
     * @param non-empty-string $otp
     * @param 0|positive-int $counter
     * @param null|0|positive-int $window
     */
    private function verifyOtpWithWindow(string $otp, int $counter, null|int $window): bool
    {
        $window = $this->getWindow($window);

        for ($i = $counter; $i <= $counter + $window; ++$i) {
            if ($this->compareOTP($this->at($i), $otp)) {
                $this->updateCounter($i + 1);

                return true;
            }
        }

        return false;
    }
}