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: | private int $iterations; |
35: | |
36: | |
37: | |
38: | |
39: | private int $length; |
40: | |
41: | |
42: | |
43: | |
44: | |
45: | |
46: | |
47: | |
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: | |
72: | |
73: | |
74: | |
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: | |
164: | |
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: | |
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: | |
192: | |
193: | |
194: | |
195: | |
196: | |
197: | |
198: | |
199: | |
200: | |
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: | |