1: | <?php |
2: | |
3: | declare(strict_types=1); |
4: | |
5: | |
6: | |
7: | |
8: | |
9: | |
10: | |
11: | |
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: | |
33: | |
34: | public Algorithm $algorithm; |
35: | |
36: | |
37: | |
38: | |
39: | private int $iterations; |
40: | |
41: | |
42: | |
43: | |
44: | private int $length; |
45: | |
46: | |
47: | |
48: | |
49: | |
50: | |
51: | |
52: | |
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: | |
80: | |
81: | |
82: | |
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: | |
174: | |
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: | |
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: | |
202: | |
203: | |
204: | |
205: | |
206: | |
207: | |
208: | |
209: | |
210: | |
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: | |