1: <?php
2:
3: declare(strict_types=1);
4:
5: /**
6: * This file is part of the Nexus framework.
7: *
8: * (c) John Paul E. Balandan, CPA <paulbalandan@gmail.com>
9: *
10: * For the full copyright and license information, please view
11: * the LICENSE file that was distributed with this source code.
12: */
13:
14: namespace Nexus\Password\Hash;
15:
16: use Nexus\Password\Algorithm;
17: use Nexus\Password\HashException;
18: use Nexus\Password\SaltedHashInterface;
19:
20: final readonly class Pbkdf2Hash extends AbstractHash implements SaltedHashInterface
21: {
22: private const array ALLOWED_ALGORITHMS = [
23: Algorithm::Pbkdf2HmacSha1,
24: Algorithm::Pbkdf2HmacSha256,
25: Algorithm::Pbkdf2HmacSha512,
26: ];
27: private const int DEFAULT_LENGTH = 40;
28: private const int MINIMUM_LENGTH = 0;
29: private const int MINIMUM_ITERATIONS = 1_000;
30:
31: /**
32: * @var int<self::MINIMUM_ITERATIONS, max>
33: */
34: private int $iterations;
35:
36: /**
37: * @var int<self::MINIMUM_LENGTH, max>
38: */
39: private int $length;
40:
41: /**
42: * @param array{
43: * iterations?: int,
44: * length?: int,
45: * } $options
46: *
47: * @throws HashException
48: */
49: public function __construct(
50: public Algorithm $algorithm,
51: array $options = [],
52: ) {
53: if (! \in_array($algorithm, self::ALLOWED_ALGORITHMS, true)) {
54: throw new HashException(\sprintf(
55: 'Algorithm expected to be any of %s, but Algorithm::%s given.',
56: implode(', ', array_map(
57: static fn(Algorithm $algorithm): string => \sprintf('Algorith::%s', $algorithm->name),
58: self::ALLOWED_ALGORITHMS,
59: )),
60: $algorithm->name,
61: ));
62: }
63:
64: $validatedOptions = self::validatedOptions($options, $this->defaultIterations(), self::DEFAULT_LENGTH);
65:
66: $this->iterations = $validatedOptions['iterations'];
67: $this->length = $validatedOptions['length'];
68: }
69:
70: /**
71: * @param array{
72: * iterations?: int,
73: * length?: int,
74: * } $options
75: */
76: #[\Override]
77: public function hash(#[\SensitiveParameter] string $password, array $options = [], string $salt = ''): string
78: {
79: if (! $this->isValidPassword($password)) {
80: throw new HashException('Invalid password provided.');
81: }
82:
83: $validatedOptions = self::validatedOptions($options, $this->iterations, $this->length);
84:
85: return \sprintf(
86: '$%s$i=%d,l=%d$%s$%s',
87: $this->algorithm->value,
88: $validatedOptions['iterations'],
89: $validatedOptions['length'],
90: base64_encode($salt),
91: base64_encode($this->pbkdf2(
92: $password,
93: $salt,
94: $validatedOptions['iterations'],
95: $validatedOptions['length'],
96: )),
97: );
98: }
99:
100: #[\Override]
101: public function needsRehash(string $hash, array $options = []): bool
102: {
103: return false;
104: }
105:
106: #[\Override]
107: public function verify(string $password, string $hash, string $salt = ''): bool
108: {
109: if (! $this->isValidPassword($password)) {
110: return false;
111: }
112:
113: if (preg_match('/^\$([^\$]+)\$([^\$]+)\$([^\$]+)\$([^\$]+)$/', $hash, $parts) !== 1) {
114: return false;
115: }
116:
117: array_shift($parts);
118:
119: if (Algorithm::tryFrom($parts[0]) === null) {
120: return false;
121: }
122:
123: if (preg_match('/i=(\-?\d+),l=(\-?\d+)/', $parts[1], $matches) !== 1) {
124: return false;
125: }
126:
127: try {
128: $validatedOptions = self::validatedOptions([], (int) $matches[1], (int) $matches[2]);
129: $iterations = $validatedOptions['iterations'];
130: $length = $validatedOptions['length'];
131: } catch (HashException) {
132: return false;
133: }
134:
135: if (base64_decode($parts[2], true) === false) {
136: return false;
137: }
138:
139: $rawHash = base64_decode($parts[3], true);
140:
141: if (false === $rawHash) {
142: return false;
143: }
144:
145: return hash_equals(
146: $rawHash,
147: $this->pbkdf2(
148: $password,
149: $salt,
150: $iterations,
151: $length,
152: ),
153: );
154: }
155:
156: #[\Override]
157: public function valid(): bool
158: {
159: return \function_exists('hash_pbkdf2');
160: }
161:
162: /**
163: * @param int<1, max> $iterations
164: * @param int<0, max> $length
165: */
166: private function pbkdf2(string $password, string $salt, int $iterations, int $length): string
167: {
168: return hash_pbkdf2(
169: $this->algorithm->value,
170: $password,
171: $salt,
172: $iterations,
173: $length,
174: true,
175: );
176: }
177:
178: /**
179: * @see https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2
180: */
181: private function defaultIterations(): int
182: {
183: return match ($this->algorithm) {
184: Algorithm::Pbkdf2HmacSha1 => 1_300_000,
185: Algorithm::Pbkdf2HmacSha256 => 600_000,
186: default => 210_000,
187: };
188: }
189:
190: /**
191: * Returns validated options for `hash_pbkdf2`.
192: *
193: * @param array{iterations?: int, length?: int} $options
194: *
195: * @return array{
196: * iterations: int<self::MINIMUM_ITERATIONS, max>,
197: * length: int<self::MINIMUM_LENGTH, max>
198: * }
199: *
200: * @throws HashException
201: */
202: private static function validatedOptions(array $options, int $iterations, int $length): array
203: {
204: $iterations = $options['iterations'] ?? $iterations;
205: $length = $options['length'] ?? $length;
206:
207: if ($iterations < self::MINIMUM_ITERATIONS) {
208: throw new HashException(\sprintf(
209: 'Internal iterations expected to be %s or greater, %s given.',
210: number_format(self::MINIMUM_ITERATIONS),
211: number_format($iterations),
212: ));
213: }
214:
215: if ($length < self::MINIMUM_LENGTH) {
216: throw new HashException(\sprintf(
217: 'Length of the output string expected to be %d or greater, %d given.',
218: self::MINIMUM_LENGTH,
219: $length,
220: ));
221: }
222:
223: return compact('iterations', 'length');
224: }
225: }
226: