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