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