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