Skip to content

Commit 998dc81

Browse files
authored
Merge pull request #1382 from michalsn/fix/harden-auth-timing
fix: reduce timing-based user enumeration
2 parents 4dfa429 + 0519679 commit 998dc81

20 files changed

Lines changed: 241 additions & 32 deletions

‎UPGRADING.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,22 @@
1010
- `CodeIgniter\Shield\Config\BaseAuthToken` is now `abstract`. Code that
1111
instantiated it directly with `new BaseAuthToken()` will now throw an `Error`.
1212

13+
### Login Timing Hardening
14+
15+
Failed password logins now return `Auth.badAttempt` for both unknown users and
16+
incorrect passwords. `Auth.invalidPassword` remains in the language files for
17+
application code that uses it, so no new translations are required. Update any
18+
custom views or tests that expect the old incorrect-password message.
19+
20+
Unknown-user logins now verify against `Auth::$dummyPasswordHash`. The default
21+
hash uses bcrypt cost 12. If your application changes the password algorithm or
22+
cost, run `php spark shield:generate-dummy-hash` with its current configuration.
23+
Copy the printed property declaration into **app/Config/Auth.php**, or set
24+
`auth.dummyPasswordHash = '<generated hash>'` in **.env** using only the hash
25+
value. The command does not modify either file. Accounts with older password
26+
hash settings can still have different verification times until their hashes
27+
are upgraded.
28+
1329
## Version 1.2 to 1.3
1430

1531
### JWT: Minimum Key Length Now Enforced

‎docs/guides/strengthen_password.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,31 @@ public int $hashTimeCost = PASSWORD_ARGON2_DEFAULT_TIME_COST;
9999
public int $hashThreads = PASSWORD_ARGON2_DEFAULT_THREADS;
100100
```
101101

102+
### Update the Dummy Password Hash
103+
104+
When you change the password hashing algorithm or its cost settings, update
105+
`Auth::$dummyPasswordHash` as well. Shield uses this hash to
106+
verify a password when the login identifier does not match an account, reducing
107+
timing differences between failed login attempts. The default dummy hash uses
108+
bcrypt with cost 12.
109+
110+
After applying the new hash settings in the target environment, run:
111+
112+
```sh
113+
php spark shield:generate-dummy-hash
114+
```
115+
116+
Copy the printed property declaration into `app/Config/Auth.php`, or put only the
117+
generated hash in your `.env` file:
118+
119+
```ini
120+
auth.dummyPasswordHash = '<generated hash>'
121+
```
122+
123+
The command reads the current hash settings and does not edit either file.
124+
Existing user hashes may still use older settings until they are rehashed after
125+
a successful login.
126+
102127
## Maximum Password Length
103128

104129
By default, Shield has the validation rules for maximum password length.

‎src/Auth.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ public function id()
111111
{
112112
$user = $this->user();
113113

114-
return ($user !== null) ? $user->id : null;
114+
return $user?->id;
115115
}
116116

117117
public function authenticate(array $credentials): Result

‎src/Authentication/Authenticators/HmacSha256.php‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,17 @@ public function check(array $credentials): Result
141141
$credentials['token'] = trim(substr((string) $credentials['token'], 11)); // HMAC-SHA256
142142
}
143143

144+
$authTokens = $this->getHmacAuthTokens($credentials['token']);
145+
146+
if ($authTokens === null || count($authTokens) !== 2) {
147+
return new Result([
148+
'success' => false,
149+
'reason' => lang('Auth.badToken'),
150+
]);
151+
}
152+
144153
// Extract UserToken and HMACSHA256 Signature from Authorization token
145-
[$userToken, $signature] = $this->getHmacAuthTokens($credentials['token']);
154+
[$userToken, $signature] = $authTokens;
146155

147156
$identityModel = model(UserIdentityModel::class);
148157

@@ -160,7 +169,7 @@ public function check(array $credentials): Result
160169

161170
// Check signature...
162171
$hash = hash_hmac('sha256', (string) $credentials['body'], $secretKey);
163-
if ($hash !== $signature) {
172+
if (! hash_equals($hash, $signature)) {
164173
return new Result([
165174
'success' => false,
166175
'reason' => lang('Auth.badToken'),
@@ -307,7 +316,7 @@ public function getHmacAuthTokens(?string $fullToken = null): ?array
307316
*/
308317
public function getHmacKeyFromToken(): ?string
309318
{
310-
[$key, $secretKey] = $this->getHmacAuthTokens();
319+
[$key] = $this->getHmacAuthTokens();
311320

312321
return $key;
313322
}
@@ -319,7 +328,7 @@ public function getHmacKeyFromToken(): ?string
319328
*/
320329
public function getHmacHashFromToken(): ?string
321330
{
322-
[$key, $hash] = $this->getHmacAuthTokens();
331+
[, $hash] = $this->getHmacAuthTokens();
323332

324333
return $hash;
325334
}

‎src/Authentication/Authenticators/Session.php‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ public function checkAction(UserIdentity $identity, string $token): bool
245245
throw new LogicException('Cannot get the User.');
246246
}
247247

248-
if ($token === '' || $token !== $identity->secret) {
248+
if ($token === '' || ! hash_equals((string) $identity->secret, $token)) {
249249
return false;
250250
}
251251

@@ -365,21 +365,25 @@ public function check(array $credentials): Result
365365
// Find the existing user
366366
$user = $this->provider->findByCredentials($credentials);
367367

368+
/** @var Passwords $passwords */
369+
$passwords = service('passwords');
370+
368371
if ($user === null) {
372+
// Verify against a fixed hash so an unknown identifier still does
373+
// the expensive password check. The result is never accepted.
374+
$passwords->verify($givenPassword, config('Auth')->dummyPasswordHash);
375+
369376
return new Result([
370377
'success' => false,
371378
'reason' => lang('Auth.badAttempt'),
372379
]);
373380
}
374381

375-
/** @var Passwords $passwords */
376-
$passwords = service('passwords');
377-
378382
// Now, try matching the passwords.
379383
if (! $passwords->verify($givenPassword, $user->password_hash)) {
380384
return new Result([
381385
'success' => false,
382-
'reason' => lang('Auth.invalidPassword'),
386+
'reason' => lang('Auth.badAttempt'),
383387
]);
384388
}
385389

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter Shield.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.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 CodeIgniter\Shield\Commands;
15+
16+
class GenerateDummyPasswordHash extends BaseCommand
17+
{
18+
protected $name = 'shield:generate-dummy-hash';
19+
protected $description = 'Generate a dummy password hash using the current Auth password settings.';
20+
protected $usage = 'shield:generate-dummy-hash';
21+
22+
public function run(array $params): int
23+
{
24+
$hash = service('passwords')->hash(bin2hex(random_bytes(32)));
25+
26+
if (! is_string($hash)) {
27+
$this->error('Could not generate a dummy password hash.');
28+
29+
return EXIT_ERROR;
30+
}
31+
32+
$this->write('public string $dummyPasswordHash = \'' . $hash . '\';');
33+
34+
return EXIT_SUCCESS;
35+
}
36+
}

‎src/Config/Auth.php‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,15 @@ class Auth extends BaseConfig
379379
*/
380380
public int $hashCost = 12;
381381

382+
/**
383+
* Hash used when a login identifier does not match an account. It must use
384+
* the same password algorithm and cost as real accounts so that failed
385+
* logins take comparable time. Override this when changing hash settings.
386+
* Run `php spark shield:generate-dummy-hash` to generate a matching hash.
387+
* The corresponding plaintext password is never accepted.
388+
*/
389+
public string $dummyPasswordHash = '$2y$12$lGwEWbbJHNlNehaFdwrcH.e7c0S3L2sMFVZr6LX/IHjU0ONwWzogq';
390+
382391
/*
383392
* ////////////////////////////////////////////////////////////////////
384393
* OTHER SETTINGS

‎tests/Authentication/Authenticators/AccessTokenAuthenticatorTest.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ public function testCheckSuccess(): void
168168
$this->assertSame($user->id, $result->extraInfo()->id);
169169

170170
$updatedToken = $result->extraInfo()->currentAccessToken();
171-
$this->assertNotEmpty($updatedToken->last_used_at);
171+
$this->assertInstanceOf(Time::class, $updatedToken->last_used_at);
172172

173173
// Checking token in the same second does not throw "DataException : There is no data to update."
174174
$this->auth->check(['token' => $token->raw_token]);

‎tests/Authentication/Authenticators/HmacAuthenticatorTest.php‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,17 @@ public function testCheckBadSignature(): void
155155
$this->assertSame(lang('Auth.badToken'), $result->reason());
156156
}
157157

158+
public function testCheckMalformedToken(): void
159+
{
160+
$result = $this->auth->check([
161+
'token' => 'abc123',
162+
'body' => 'bar',
163+
]);
164+
165+
$this->assertFalse($result->isOK());
166+
$this->assertSame(lang('Auth.badToken'), $result->reason());
167+
}
168+
158169
public function testCheckOldToken(): void
159170
{
160171
$user = fake(UserModel::class);
@@ -198,7 +209,7 @@ public function testCheckSuccess(): void
198209
$this->assertSame($user->id, $result->extraInfo()->id);
199210

200211
$updatedToken = $result->extraInfo()->currentHmacToken();
201-
$this->assertNotEmpty($updatedToken->last_used_at);
212+
$this->assertInstanceOf(Time::class, $updatedToken->last_used_at);
202213

203214
// Checking token in the same second does not throw "DataException : There is no data to update."
204215
$this->auth->check(['token' => $rawToken, 'body' => 'bar']);

‎tests/Authentication/Authenticators/SessionAuthenticatorTest.php‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use CodeIgniter\Shield\Authentication\Authentication;
1818
use CodeIgniter\Shield\Authentication\AuthenticationException;
1919
use CodeIgniter\Shield\Authentication\Authenticators\Session;
20+
use CodeIgniter\Shield\Authentication\Passwords;
2021
use CodeIgniter\Shield\Config\Auth;
2122
use CodeIgniter\Shield\Entities\User;
2223
use CodeIgniter\Shield\Exceptions\LogicException;
@@ -272,6 +273,29 @@ public function testCheckCannotFindUser(): void
272273
$this->assertSame(lang('Auth.badAttempt'), $result->reason());
273274
}
274275

276+
public function testCheckUnknownUserStillVerifiesPassword(): void
277+
{
278+
$passwords = new class (new Auth()) extends Passwords {
279+
public array $checks = [];
280+
281+
public function verify(string $password, string $hash): bool
282+
{
283+
$this->checks[] = [$password, $hash];
284+
285+
return false;
286+
}
287+
};
288+
Services::injectMock('passwords', $passwords);
289+
290+
$result = $this->auth->check([
291+
'email' => 'unknown@example.com',
292+
'password' => 'secret',
293+
]);
294+
295+
$this->assertFalse($result->isOK());
296+
$this->assertSame([['secret', config('Auth')->dummyPasswordHash]], $passwords->checks);
297+
}
298+
275299
public function testCheckBadPassword(): void
276300
{
277301
$this->user->createEmailIdentity([
@@ -286,7 +310,7 @@ public function testCheckBadPassword(): void
286310

287311
$this->assertInstanceOf(Result::class, $result);
288312
$this->assertFalse($result->isOK());
289-
$this->assertSame(lang('Auth.invalidPassword'), $result->reason());
313+
$this->assertSame(lang('Auth.badAttempt'), $result->reason());
290314
}
291315

292316
public function testCheckSuccess(): void

0 commit comments

Comments
 (0)