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