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