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:
19: final readonly class BcryptHash extends AbstractHash
20: {
21: public const int DEFAULT_COST = 12;
22: public const int MINIMUM_COST = 4;
23: public const int MAXIMUM_COST = 31;
24: private const int MAXIMUM_BCRYPT_PASSWORD_LENGTH = 72;
25:
26: /**
27: * @var int<self::MINIMUM_COST, self::MAXIMUM_COST>
28: */
29: private int $cost;
30:
31: /**
32: * @param array{cost?: int} $options
33: *
34: * @throws HashException
35: */
36: public function __construct(public Algorithm $algorithm, array $options = [])
37: {
38: if (Algorithm::Bcrypt !== $algorithm) {
39: throw new HashException(\sprintf(
40: 'Algorithm expected to be Algorithm::Bcrypt, Algorithm::%s given.',
41: $algorithm->name,
42: ));
43: }
44:
45: $this->cost = self::validatedCost($options, self::DEFAULT_COST)['cost'];
46: }
47:
48: /**
49: * @param array{cost?: int} $options
50: */
51: #[\Override]
52: public function hash(#[\SensitiveParameter] string $password, array $options = []): string
53: {
54: if (! $this->isValidPassword($password)) {
55: throw new HashException('Invalid password provided.');
56: }
57:
58: if (\strlen($password) > self::MAXIMUM_BCRYPT_PASSWORD_LENGTH) {
59: throw new HashException('Invalid password provided.');
60: }
61:
62: return password_hash(
63: $password,
64: $this->algorithm->value,
65: self::validatedCost($options, $this->cost),
66: );
67: }
68:
69: /**
70: * @param array{cost?: int} $options
71: */
72: #[\Override]
73: public function needsRehash(string $hash, array $options = []): bool
74: {
75: return password_needs_rehash(
76: $hash,
77: $this->algorithm->value,
78: self::validatedCost($options, $this->cost),
79: );
80: }
81:
82: #[\Override]
83: public function verify(string $password, string $hash): bool
84: {
85: if (! $this->isValidPassword($password)) {
86: return false;
87: }
88:
89: if (\strlen($password) > self::MAXIMUM_BCRYPT_PASSWORD_LENGTH) {
90: return false;
91: }
92:
93: if (! str_starts_with($hash, '$2y')) {
94: return false;
95: }
96:
97: return password_verify($password, $hash);
98: }
99:
100: #[\Override]
101: public function valid(): bool
102: {
103: return \defined('PASSWORD_BCRYPT');
104: }
105:
106: /**
107: * @param array{cost?: int} $options
108: *
109: * @return array{cost: int<self::MINIMUM_COST, self::MAXIMUM_COST>}
110: *
111: * @throws HashException
112: */
113: private static function validatedCost(array $options, int $cost): array
114: {
115: $cost = $options['cost'] ?? $cost;
116:
117: if (self::MINIMUM_COST > $cost || $cost > self::MAXIMUM_COST) {
118: throw new HashException(\sprintf(
119: 'Algorithmic cost is expected to be between %d and %d, %d given.',
120: self::MINIMUM_COST,
121: self::MAXIMUM_COST,
122: $cost,
123: ));
124: }
125:
126: return compact('cost');
127: }
128: }
129: