Cleartext Storage of Sensitive Information in codeigniter4/shield
Description
CodeIgniter Shield is an authentication and authorization provider for CodeIgniter 4. The secretKey value is an important key for HMAC SHA256 authentication and in affected versions was stored in the database in cleartext form. If a malicious person somehow had access to the data in the database, they could use the key and secretKey for HMAC SHA256 authentication to send requests impersonating that corresponding user. This issue has been addressed in version 1.0.0-beta.8. Users are advised to upgrade. There are no known workarounds for this vulnerability.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
codeigniter4/shieldPackagist | < 1.0.0-beta.8 | 1.0.0-beta.8 |
Affected products
1- Range: < 1.0.0-beta.8
Patches
1f77c6ae20275Merge pull request from GHSA-v427-c49j-8w6x
15 files changed · +818 −40
docs/guides/api_hmac_keys.md+26 −6 modified@@ -15,10 +15,11 @@ API. When making requests using HMAC keys, the token should be included in the ` setting the `$authenticatorHeader['hmac']` value in the **app/Config/AuthToken.php** config file. Tokens are issued with the `generateHmacToken()` method on the user. This returns a -`CodeIgniter\Shield\Entities\AccessToken` instance. These shared keys are saved to the database in plain text. The -`AccessToken` object returned when you generate it will include a `secret` field which will be the `key` and a `secret2` -field that will be the `secretKey`. You should display the `secretKey` to your user once, so they have a chance to copy -it somewhere safe, as this is the only time you should reveal this key. +`CodeIgniter\Shield\Entities\AccessToken` instance. The `AccessToken` object returned will include a `secret` field +which will be the '**key**' and a `rawSecretKey` field that will be the '**secretKey**'. You should display the +'**secretKey**' to your user immediately, so they have a chance to copy it somewhere safe, as this is the only time +you can reveal this key. The '**key**' and '**secretKey**' are saved to the database. The '**secretKey**' is stored +encrypted. The `generateHmacToken()` method requires a name for the token. These are free strings and are often used to identify the user/device the token was generated from/for, like 'Johns MacBook Air'. @@ -27,7 +28,7 @@ the user/device the token was generated from/for, like 'Johns MacBook Air'. $routes->get('hmac/token', static function () { $token = auth()->user()->generateHmacToken(service('request')->getVar('token_name')); - return json_encode(['key' => $token->secret, 'secretKey' => $token->secret2]); + return json_encode(['key' => $token->secret, 'secretKey' => $token->rawSecretKey]); }); ``` @@ -62,7 +63,7 @@ token is granted all access to all scopes. This might be enough for a smaller AP ```php $token = $user->generateHmacToken('token-name', ['users-read']); -return json_encode(['key' => $token->secret, 'secretKey' => $token->secret2]); +return json_encode(['key' => $token->secret, 'secretKey' => $token->rawSecretKey]); ``` !!! note @@ -87,6 +88,25 @@ $user->revokeHmacToken($key); $user->revokeAllHmacTokens(); ``` +## HMAC Secret Key Encryption + +The HMAC Secret Key is stored encrypted. Before you start using HMAC, you will need to set/override the encryption key +in `$hmacEncryptionKeys` in **app/Config/AuthToken.php**. This should be set using **.env** and/or system +environment variables. Instructions on how to do that can be found in the +[Setting Your Encryption Key](https://codeigniter.com/user_guide/libraries/encryption.html#setting-your-encryption-key) +section of the CodeIgniter 4 documentation. + +You will also be able to adjust the default Driver `$hmacEncryptionDefaultDriver` and the default Digest +`$hmacEncryptionDefaultDigest`, these default to `'OpenSSL'` and `'SHA512'` respectively. + +See [HMAC SHA256 Token Authenticator](../references/authentication/hmac.md#hmac-secret-key-encryption) for additional +details on setting these values. + +### Encryption Key Rotation + +See [HMAC SHA256 Token Authenticator](../references/authentication/hmac.md#hmac-secret-key-encryption) for information on +how to set, rotate encryption keys and re-encrypt existing HMAC `'secretKey'` values. + ## Protecting Routes The first way to specify which routes are protected is to use the `hmac` controller filter.
docs/references/authentication/hmac.md+70 −7 modified@@ -7,7 +7,7 @@ access to your API. These keys typically have a very long expiration time, often These are also suitable for use with mobile applications. In this case, the user would register/sign-in with their email/password. The application would create a new access token for them, with a recognizable -name, like John's iPhone 12, and return it to the mobile application, where it is stored and used +name, like "John's iPhone 12", and return it to the mobile application, where it is stored and used in all future requests. !!! note @@ -67,19 +67,19 @@ $token = $user->generateHmacToken('Work Laptop'); ``` This creates the keys/tokens using a cryptographically secure random string. The keys operate as shared keys. -This means they are stored as-is in the database. The method returns an instance of -`CodeIgniters\Shield\Authentication\Entities\AccessToken`. The field `secret` is the 'key' the field `secret2` is -the shared 'secretKey'. Both are required to when using this authentication method. +The '**key**' is stored as plain text in the database, the '**secretKey**' is stored encrypted. The method returns an +instance of `CodeIgniters\Shield\Authentication\Entities\AccessToken`. The field `secret` is the '**key**' the field +`rawSecretKey` is the shared '**secretKey**'. Both are required to when using this authentication method. **The plain text version of these keys should be displayed to the user immediately, so they can copy it for -their use.** It is recommended that after that only the 'key' field is displayed to a user. If a user loses the -'secretKey', they should be required to generate a new set of keys to use. +their use.** It is recommended that after that only the '**key**' field is displayed to a user. If a user loses the +'**secretKey**', they should be required to generate a new set of keys to use. ```php $token = $user->generateHmacToken('Work Laptop'); echo 'Key: ' . $token->secret; -echo 'SecretKey: ' . $token->secret2; +echo 'SecretKey: ' . $token->rawSecretKey; ``` ## Revoking HMAC Keys @@ -156,3 +156,66 @@ if ($user->hmacTokenCant('forums.manage')) { // do something.... } ``` + +## HMAC Secret Key Encryption + +The HMAC Secret Key is stored encrypted. Before you start using HMAC, you will need to set/override the encryption key +in `$hmacEncryptionKeys` in **app/Config/AuthToken.php**. This should be set using **.env** and/or system +environment variables. Instructions on how to do that can be found in the +[Setting Your Encryption Key](https://codeigniter.com/user_guide/libraries/encryption.html#setting-your-encryption-key) +section of the CodeIgniter 4 documentation. + +You will also be able to adjust the default Driver `$hmacEncryptionDefaultDriver` and the default Digest +`$hmacEncryptionDefaultDigest`, these default to `'OpenSSL'` and `'SHA512'` respectively. These can also be +overridden for an individual key by including them in the keys array. + +```php +public $hmacEncryptionKeys = [ + 'k1' => [ + 'key' => 'hex2bin:923dfab5ddca0c7784c2c388a848a704f5e048736c1a852c862959da62ade8c7', + ], +]; + +public string $hmacEncryptionCurrentKey = 'k1'; +public string $hmacEncryptionDefaultDriver = 'OpenSSL'; +public string $hmacEncryptionDefaultDigest = 'SHA512'; +``` + +When it is time to update your encryption keys you will need to add an additional key to the above +`$hmacEncryptionKeys` array. Then adjust the `$hmacEncryptionCurrentKey` to point at the new key. After the new +encryption key is in place, run `php spark shield:hmac reencrypt` to re-encrypt all existing keys with the new +encryption key. You will need to leave the old key in the array as it will be used read the existing 'Secret Keys' +during re-encryption. + +```php +public $hmacEncryptionKeys = [ + 'k1' => [ + 'key' => 'hex2bin:923dfab5ddca0c7784c2c388a848a704f5e048736c1a852c862959da62ade8c7', + ], + 'k2' => [ + 'key' => 'hex2bin:451df599363b19be1434605fff8556a0bbfc50bede1bb33793dcde4d97fce4b0', + 'digest' => 'SHA256', + ], +]; + +public string $hmacEncryptionCurrentKey = 'k2'; +public string $hmacEncryptionDefaultDriver = 'OpenSSL'; +public string $hmacEncryptionDefaultDigest = 'SHA512'; + +``` + +```console +php spark shield:hmac reencrypt +``` + +You can (and should) set these values using environment variable and/or the **.env** file. To do this you will need to set +the values as JSON strings: + +```text +authtoken.hmacEncryptionKeys = '{"k1":{"key":"hex2bin:923dfab5ddca0c7784c2c388a848a704f5e048736c1a852c862959da62ade8c7"},"k2":{"key":"hex2bin:451df599363b19be1434605fff8556a0bbfc50bede1bb33793dcde4d97fce4b0"}}' +authtoken.hmacEncryptionCurrentKey = k2 +``` + +Depending on the set length of the Secret Key and the type of encryption used, it is possible for the encrypted value to +exceed the database column character limit of 255 characters. If this happens, creation of a new HMAC identity will +throw a `RuntimeException`.
phpunit.xml.dist+4 −1 modified@@ -93,7 +93,10 @@ <!-- https://getcomposer.org/xdebug --> <env name="COMPOSER_DISABLE_XDEBUG_WARN" value="1"/> - <!-- Database configuration --> + <!-- Default HMAC encryption key --> + <env name="authtoken.hmacEncryptionKeys" value="{"k1":{"key":"hex2bin:178ed94fd0b6d57dd31dd6b22fc601fab8ad191efac165a5f3f30a8ac09d813d"},"k2":{"key":"hex2bin:b0ab85bd0320824c496db2f40eb47c8712a6dfcfdf99b805988e22bdea6b9203"}}"/> + + <!-- Database configuration --> <env name="database.tests.strictOn" value="true"/> <!-- Uncomment to use alternate testing database configuration <env name="database.tests.hostname" value="localhost"/>
src/Authentication/Authenticators/HmacSha256.php+5 −1 modified@@ -17,6 +17,7 @@ use CodeIgniter\I18n\Time; use CodeIgniter\Shield\Authentication\AuthenticationException; use CodeIgniter\Shield\Authentication\AuthenticatorInterface; +use CodeIgniter\Shield\Authentication\HMAC\HmacEncrypter; use CodeIgniter\Shield\Config\Auth; use CodeIgniter\Shield\Entities\User; use CodeIgniter\Shield\Exceptions\InvalidArgumentException; @@ -159,8 +160,11 @@ public function check(array $credentials): Result ]); } + $encrypter = new HmacEncrypter(); + $secretKey = $encrypter->decrypt($token->secret2); + // Check signature... - $hash = hash_hmac('sha256', $credentials['body'], $token->secret2); + $hash = hash_hmac('sha256', $credentials['body'], $secretKey); if ($hash !== $signature) { return new Result([ 'success' => false,
src/Authentication/HMAC/HmacEncrypter.php+153 −0 added@@ -0,0 +1,153 @@ +<?php + +declare(strict_types=1); + +/** + * This file is part of CodeIgniter Shield. + * + * (c) CodeIgniter Foundation <admin@codeigniter.com> + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Shield\Authentication\HMAC; + +use CodeIgniter\Encryption\EncrypterInterface; +use CodeIgniter\Encryption\Exceptions\EncryptionException; +use CodeIgniter\Shield\Auth; +use CodeIgniter\Shield\Config\AuthToken; +use CodeIgniter\Shield\Exceptions\RuntimeException; +use Config\Encryption; +use Config\Services; +use Exception; + +/** + * HMAC Encrypter class + * + * This class handles the setup and configuration of the HMAC Encryption + */ +class HmacEncrypter +{ + /** + * Codeigniter Encrypter + * + * @var array<string, EncrypterInterface> + */ + private array $encrypter; + + /** + * Auth Token config + */ + private AuthToken $authConfig; + + /** + * Constructor + * Setup encryption configuration + */ + public function __construct() + { + $this->authConfig = config('AuthToken'); + + $this->getEncrypter($this->authConfig->hmacEncryptionCurrentKey); + } + + /** + * Decrypt + * + * @param string $encString Encrypted string + * + * @return string Raw decrypted string + * + * @throws EncryptionException + */ + public function decrypt(string $encString): string + { + $matches = []; + // check for a match + if (preg_match('/^\$b6\$(\w+?)\$(.+)\z/', $encString, $matches) !== 1) { + throw new EncryptionException('Unable to decrypt string'); + } + + $encrypter = $this->getEncrypter($matches[1]); + + return $encrypter->decrypt(base64_decode($matches[2], true)); + } + + /** + * Encrypt + * + * @param string $rawString Raw string to encrypt + * + * @return string Encrypted string + * + * @throws EncryptionException + * @throws RuntimeException + */ + public function encrypt(string $rawString): string + { + $currentKey = $this->authConfig->hmacEncryptionCurrentKey; + + $encryptedString = '$b6$' . $currentKey . '$' . base64_encode($this->encrypter[$currentKey]->encrypt($rawString)); + + if (strlen($encryptedString) > $this->authConfig->secret2StorageLimit) { + throw new RuntimeException('Encrypted key too long. Unable to store value.'); + } + + return $encryptedString; + } + + /** + * Check if the string already encrypted + */ + public function isEncrypted(string $string): bool + { + return preg_match('/^\$b6\$/', $string) === 1; + } + + /** + * Check if the string already encrypted with the Current Set Key + */ + public function isEncryptedWithCurrentKey(string $string): bool + { + $currentKey = $this->authConfig->hmacEncryptionCurrentKey; + + return preg_match('/^\$b6\$' . $currentKey . '\$/', $string) === 1; + } + + /** + * Generate Key + * + * @return string Secret Key in base64 format + * + * @throws Exception + */ + public function generateSecretKey(): string + { + return base64_encode(random_bytes($this->authConfig->hmacSecretKeyByteSize)); + } + + /** + * Retrieve encrypter for selected key + * + * @param string $encrypterKey Index Key for selected Encrypter + */ + private function getEncrypter(string $encrypterKey): EncrypterInterface + { + if (! isset($this->encrypter[$encrypterKey])) { + if (! isset($this->authConfig->hmacEncryptionKeys[$encrypterKey]['key'])) { + throw new RuntimeException('Encryption key does not exist.'); + } + + $config = new Encryption(); + + $config->key = $this->authConfig->hmacEncryptionKeys[$encrypterKey]['key']; + $config->driver = $this->authConfig->hmacEncryptionKeys[$encrypterKey]['driver'] ?? $this->authConfig->hmacEncryptionDefaultDriver; + $config->digest = $this->authConfig->hmacEncryptionKeys[$encrypterKey]['digest'] ?? $this->authConfig->hmacEncryptionDefaultDigest; + + $this->encrypter[$encrypterKey] = Services::encrypter($config); + } + + return $this->encrypter[$encrypterKey]; + } +}
src/Commands/Hmac.php+209 −0 added@@ -0,0 +1,209 @@ +<?php + +declare(strict_types=1); + +/** + * This file is part of CodeIgniter Shield. + * + * (c) CodeIgniter Foundation <admin@codeigniter.com> + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Shield\Commands; + +use CodeIgniter\Shield\Authentication\HMAC\HmacEncrypter; +use CodeIgniter\Shield\Commands\Exceptions\BadInputException; +use CodeIgniter\Shield\Exceptions\RuntimeException; +use CodeIgniter\Shield\Models\UserIdentityModel; +use Exception; +use ReflectionException; + +class Hmac extends BaseCommand +{ + /** + * The Command's name + * + * @var string + */ + protected $name = 'shield:hmac'; + + /** + * the Command's short description + * + * @var string + */ + protected $description = 'Encrypt/Decrypt secretKey for HMAC tokens.'; + + /** + * the Command's usage + * + * @var string + */ + protected $usage = <<<'EOL' + shield:hmac <action> + shield:hmac reencrypt + shield:hmac encrypt + shield:hmac decrypt + + The reencrypt command should be used when rotating the encryption keys. + The encrypt command should only be run on existing raw secret keys (extremely rare). + EOL; + + /** + * the Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'action' => <<<'EOL' + reencrypt: Re-encrypts all HMAC Secret Keys on encryption key rotation + encrypt: Encrypt all raw HMAC Secret Keys + decrypt: Decrypt all encrypted HMAC Secret Keys + EOL, + ]; + + /** + * HMAC Encrypter Object + */ + private HmacEncrypter $encrypter; + + /** + * the Command's Options + * + * @var array + */ + protected $options = []; + + /** + * Run Encryption Methods + */ + public function run(array $params): int + { + $action = $params[0] ?? null; + + $this->encrypter = new HmacEncrypter(); + + try { + switch ($action) { + case 'encrypt': + $this->encrypt(); + break; + + case 'decrypt': + $this->decrypt(); + break; + + case 'reencrypt': + $this->reEncrypt(); + break; + + default: + throw new BadInputException('Unrecognized Command'); + } + } catch (Exception $e) { + $this->write($e->getMessage(), 'red'); + + return EXIT_ERROR; + } + + return EXIT_SUCCESS; + } + + /** + * Encrypt all Raw HMAC Secret Keys + * + * @throws ReflectionException + */ + public function encrypt(): void + { + $uIdModel = new UserIdentityModel(); + $uIdModelSub = new UserIdentityModel(); // For saving. + $encrypter = $this->encrypter; + + $that = $this; + + $uIdModel->where('type', 'hmac_sha256')->orderBy('id')->chunk( + 100, + static function ($identity) use ($uIdModelSub, $encrypter, $that): void { + if ($encrypter->isEncrypted($identity->secret2)) { + $that->write('id: ' . $identity->id . ', already encrypted, skipped.'); + + return; + } + + try { + $identity->secret2 = $encrypter->encrypt($identity->secret2); + $uIdModelSub->save($identity); + + $that->write('id: ' . $identity->id . ', encrypted.'); + } catch (RuntimeException $e) { + $that->error('id: ' . $identity->id . ', ' . $e->getMessage()); + } + } + ); + } + + /** + * Decrypt all encrypted HMAC Secret Keys + * + * @throws ReflectionException + */ + public function decrypt(): void + { + $uIdModel = new UserIdentityModel(); + $uIdModelSub = new UserIdentityModel(); // For saving. + $encrypter = $this->encrypter; + + $that = $this; + + $uIdModel->where('type', 'hmac_sha256')->orderBy('id')->chunk( + 100, + static function ($identity) use ($uIdModelSub, $encrypter, $that): void { + if (! $encrypter->isEncrypted($identity->secret2)) { + $that->write('id: ' . $identity->id . ', not encrypted, skipped.'); + + return; + } + + $identity->secret2 = $encrypter->decrypt($identity->secret2); + $uIdModelSub->save($identity); + + $that->write('id: ' . $identity->id . ', decrypted.'); + } + ); + } + + /** + * Re-encrypt all encrypted HMAC Secret Keys from existing/deprecated + * encryption key to new encryption key. + * + * @throws ReflectionException + */ + public function reEncrypt(): void + { + $uIdModel = new UserIdentityModel(); + $uIdModelSub = new UserIdentityModel(); // For saving. + $encrypter = $this->encrypter; + + $that = $this; + + $uIdModel->where('type', 'hmac_sha256')->orderBy('id')->chunk( + 100, + static function ($identity) use ($uIdModelSub, $encrypter, $that): void { + if ($encrypter->isEncryptedWithCurrentKey($identity->secret2)) { + $that->write('id: ' . $identity->id . ', already encrypted with current key, skipped.'); + + return; + } + + $identity->secret2 = $encrypter->decrypt($identity->secret2); + $identity->secret2 = $encrypter->encrypt($identity->secret2); + $uIdModelSub->save($identity); + + $that->write('id: ' . $identity->id . ', Re-encrypted.'); + } + ); + } +}
src/Commands/Setup.php+2 −3 modified@@ -140,9 +140,8 @@ private function publishConfigAuthToken(): void { $file = 'Config/AuthToken.php'; $replaces = [ - 'namespace CodeIgniter\Shield\Config' => 'namespace Config', - 'use CodeIgniter\\Config\\BaseConfig;' => 'use CodeIgniter\\Shield\\Config\\AuthToken as ShieldAuthToken;', - 'extends BaseConfig' => 'extends ShieldAuthToken', + 'namespace CodeIgniter\Shield\Config;' => "namespace Config;\n\nuse CodeIgniter\\Shield\\Config\\AuthToken as ShieldAuthToken;", + 'extends BaseAuthToken' => 'extends ShieldAuthToken', ]; $this->copyAndReplace($file, $replaces);
src/Config/AuthToken.php+73 −3 modified@@ -13,12 +13,10 @@ namespace CodeIgniter\Shield\Config; -use CodeIgniter\Config\BaseConfig; - /** * Configuration for Token Auth and HMAC Auth */ -class AuthToken extends BaseConfig +class AuthToken extends BaseAuthToken { /** * -------------------------------------------------------------------- @@ -55,6 +53,14 @@ class AuthToken extends BaseConfig */ public int $unusedTokenLifetime = YEAR; + /** + * -------------------------------------------------------------------- + * Secret2 storage character limit + * -------------------------------------------------------------------- + * Database size limit for the identities 'secret2' field. + */ + public int $secret2StorageLimit = 255; + /** * -------------------------------------------------------------------- * HMAC secret key byte size @@ -63,4 +69,68 @@ class AuthToken extends BaseConfig * HMAC SHA256 byte size */ public int $hmacSecretKeyByteSize = 32; + + /** + * -------------------------------------------------------------------- + * HMAC encryption Keys + * -------------------------------------------------------------------- + * This sets the key to be used when encrypting a user's HMAC Secret Key. + * + * 'keys' is an array of keys which will facilitate key rotation. Valid + * keyTitles must include only [a-zA-Z0-9_] and should be kept to a + * max of 8 characters. + * + * Each keyTitle is an associative array containing the required 'key' + * value, and the optional 'driver' and 'digest' values. If the + * 'driver' and 'digest' values are not specified, the default 'driver' + * and 'digest' values will be used. + * + * Old keys will are used to decrypt existing Secret Keys. It is encouraged + * to run 'php spark shield:hmac reencrypt' to update existing Secret + * Key encryptions. + * + * @see https://codeigniter.com/user_guide/libraries/encryption.html + * + * @var array<string, array{key: string, driver?: string, digest?: string}>|string + * + * NOTE: The value becomes temporarily a string when setting value as JSON + * from environment variable. + * + * [key_name => ['key' => key_value]] + * or [key_name => ['key' => key_value, 'driver' => driver, 'digest' => digest]] + */ + public $hmacEncryptionKeys = [ + 'k1' => [ + 'key' => '', + ], + ]; + + /** + * -------------------------------------------------------------------- + * HMAC Current Encryption Key Selector + * -------------------------------------------------------------------- + * This specifies which of the encryption keys should be used. + */ + public string $hmacEncryptionCurrentKey = 'k1'; + + /** + * -------------------------------------------------------------------- + * HMAC Encryption Key Driver + * -------------------------------------------------------------------- + * This specifies which of the encryption drivers should be used. + * + * Available drivers: + * - OpenSSL + * - Sodium + */ + public string $hmacEncryptionDefaultDriver = 'OpenSSL'; + + /** + * -------------------------------------------------------------------- + * HMAC Encryption Key Driver + * -------------------------------------------------------------------- + * THis specifies the type of encryption to be used. + * e.g. 'SHA512' or 'SHA256'. + */ + public string $hmacEncryptionDefaultDigest = 'SHA512'; }
src/Config/BaseAuthToken.php+58 −0 added@@ -0,0 +1,58 @@ +<?php + +declare(strict_types=1); + +/** + * This file is part of CodeIgniter Shield. + * + * (c) CodeIgniter Foundation <admin@codeigniter.com> + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Shield\Config; + +use CodeIgniter\Config\BaseConfig; + +class BaseAuthToken extends BaseConfig +{ + /** + * List of HMAC Encryption Keys + * + * @var array<string, array{key: string, driver?: string, digest?: string}>|string + */ + public $hmacEncryptionKeys; + + /** + * AuthToken Config Constructor + */ + public function __construct() + { + parent::__construct(); + + if (is_string($this->hmacEncryptionKeys)) { + $array = json_decode($this->hmacEncryptionKeys, true); + if (is_array($array)) { + $this->hmacEncryptionKeys = $array; + } + } + } + + /** + * Override parent initEnvValue() to allow for direct setting to array properties values from ENV + * + * In order to set array properties via ENV vars we need to set the property to a string value first. + * + * @param mixed $property + */ + protected function initEnvValue(&$property, string $name, string $prefix, string $shortPrefix): void + { + // if attempting to set property from ENV, first set to empty string + if ($name === 'hmacEncryptionKeys' && $this->getEnvValue($name, $prefix, $shortPrefix) !== null) { + $property = ''; + } + + parent::initEnvValue($property, $name, $prefix, $shortPrefix); + } +}
src/Models/UserIdentityModel.php+12 −2 modified@@ -17,6 +17,7 @@ use CodeIgniter\Shield\Authentication\Authenticators\AccessTokens; use CodeIgniter\Shield\Authentication\Authenticators\HmacSha256; use CodeIgniter\Shield\Authentication\Authenticators\Session; +use CodeIgniter\Shield\Authentication\HMAC\HmacEncrypter; use CodeIgniter\Shield\Authentication\Passwords; use CodeIgniter\Shield\Entities\AccessToken; use CodeIgniter\Shield\Entities\User; @@ -251,20 +252,29 @@ public function generateHmacToken(User $user, string $name, array $scopes = ['*' { $this->checkUserId($user); + $encrypter = new HmacEncrypter(); + $rawSecretKey = $encrypter->generateSecretKey(); + $secretKey = $encrypter->encrypt($rawSecretKey); + $return = $this->insert([ 'type' => HmacSha256::ID_TYPE_HMAC_TOKEN, 'user_id' => $user->id, 'name' => $name, 'secret' => bin2hex(random_bytes(16)), // Key - 'secret2' => bin2hex(random_bytes(config('AuthToken')->hmacSecretKeyByteSize)), // Secret Key + 'secret2' => $secretKey, 'extra' => serialize($scopes), ]); $this->checkQueryReturn($return); - return $this + /** @var AccessToken $token */ + $token = $this ->asObject(AccessToken::class) ->find($this->getInsertID()); + + $token->rawSecretKey = $rawSecretKey; + + return $token; } /**
tests/Authentication/Authenticators/HmacAuthenticatorTest.php+7 −7 modified@@ -109,7 +109,7 @@ public function testLoginByIdWithToken(): void $user = fake(UserModel::class); $token = $user->generateHmacToken('foo'); - $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar'); + $rawToken = $this->generateRawHeaderToken($token->secret, $token->rawSecretKey, 'bar'); $this->setRequestHeader($rawToken); $this->auth->loginById($user->id); @@ -126,7 +126,7 @@ public function testLoginByIdWithMultipleTokens(): void $token1 = $user->generateHmacToken('foo'); $user->generateHmacToken('bar'); - $this->setRequestHeader($this->generateRawHeaderToken($token1->secret, $token1->secret2, 'bar')); + $this->setRequestHeader($this->generateRawHeaderToken($token1->secret, $token1->rawSecretKey, 'bar')); $this->auth->loginById($user->id); @@ -170,7 +170,7 @@ public function testCheckOldToken(): void $identities->save($token); $result = $this->auth->check([ - 'token' => $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar'), + 'token' => $this->generateRawHeaderToken($token->secret, $token->rawSecretKey, 'bar'), 'body' => 'bar', ]); @@ -190,7 +190,7 @@ public function testCheckSuccess(): void 'last_used_at' => null, ]); - $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar'); + $rawToken = $this->generateRawHeaderToken($token->secret, $token->rawSecretKey, 'bar'); $result = $this->auth->check([ 'token' => $rawToken, @@ -220,7 +220,7 @@ public function testCheckBadToken(): void 'last_used_at' => null, ]); - $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, 'foobar'); + $rawToken = $this->generateRawHeaderToken($token->secret, $token->rawSecretKey, 'foobar'); $result = $this->auth->check([ 'token' => $rawToken, @@ -254,7 +254,7 @@ public function testAttemptSuccess(): void /** @var User $user */ $user = fake(UserModel::class); $token = $user->generateHmacToken('foo'); - $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar'); + $rawToken = $this->generateRawHeaderToken($token->secret, $token->rawSecretKey, 'bar'); $this->setRequestHeader($rawToken); $result = $this->auth->attempt([ @@ -294,7 +294,7 @@ public function testAttemptBanned(): void $user->ban('Test ban.'); $token = $user->generateHmacToken('foo'); - $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar'); + $rawToken = $this->generateRawHeaderToken($token->secret, $token->rawSecretKey, 'bar'); $this->setRequestHeader($rawToken); $result = $this->auth->attempt([
tests/Authentication/Filters/HmacFilterTest.php+7 −7 modified@@ -47,7 +47,7 @@ public function testFilterSuccess(): void $user = fake(UserModel::class); $token = $user->generateHmacToken('foo'); - $rawToken = $this->generateRawHeaderToken($token->secret, $token->secret2, ''); + $rawToken = $this->generateRawHeaderToken($token->secret, $token->rawSecretKey, ''); $result = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $rawToken]) ->get('protected-route'); @@ -68,7 +68,7 @@ public function testFilterInvalidSignature(): void $user = fake(UserModel::class); $token = $user->generateHmacToken('foo'); - $result = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->secret2, 'bar')]) + $result = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->rawSecretKey, 'bar')]) ->get('protected-route'); $result->assertStatus(401); @@ -80,7 +80,7 @@ public function testRecordActiveDate(): void $user = fake(UserModel::class); $token = $user->generateHmacToken('foo'); - $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->secret2, '')]) + $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->rawSecretKey, '')]) ->get('protected-route'); // Last Active should be greater than 'updated_at' column @@ -97,15 +97,15 @@ public function testFiltersProtectsWithScopes(): void $token2 = $user2->generateHmacToken('foo', ['users-write']); // User 1 should be able to access the route - $result1 = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token1->secret, $token1->secret2, '')]) + $result1 = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token1->secret, $token1->rawSecretKey, '')]) ->get('protected-user-route'); $result1->assertStatus(200); // Last Active should be greater than 'updated_at' column $this->assertGreaterThan(auth('hmac')->user()->updated_at, auth('hmac')->user()->last_active); // User 2 should NOT be able to access the route - $result2 = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token2->secret, $token2->secret2, '')]) + $result2 = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token2->secret, $token2->rawSecretKey, '')]) ->get('protected-user-route'); $result2->assertStatus(401); @@ -120,7 +120,7 @@ public function testBlocksInactiveUsers(): void // Activation only required with email activation setting('Auth.actions', ['register' => null]); - $result = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->secret2, '')]) + $result = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->rawSecretKey, '')]) ->get('protected-route'); $result->assertStatus(200); @@ -129,7 +129,7 @@ public function testBlocksInactiveUsers(): void // Now require user activation and try again setting('Auth.actions', ['register' => '\CodeIgniter\Shield\Authentication\Actions\EmailActivator']); - $result = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->secret2, '')]) + $result = $this->withHeaders(['Authorization' => 'HMAC-SHA256 ' . $this->generateRawHeaderToken($token->secret, $token->rawSecretKey, '')]) ->get('protected-route'); $result->assertStatus(403);
tests/Authentication/HasHmacTokensTest.php+3 −2 modified@@ -43,6 +43,7 @@ public function testGenerateHmacToken(): void $this->assertIsString($token->secret); $this->assertIsString($token->secret2); + $this->assertIsString($token->rawSecretKey); // All scopes are assigned by default via wildcard $this->assertSame(['*'], $token->scopes); @@ -56,12 +57,12 @@ public function testHmacTokens(): void // Give the user a couple of access tokens $token1 = fake( UserIdentityModel::class, - ['user_id' => $this->user->id, 'type' => 'hmac_sha256', 'secret' => 'key1', 'secret2' => 'secretKey1'] + ['user_id' => $this->user->id, 'type' => 'hmac_sha256', 'secret' => 'key1', 'secret2' => 'd862cd9ddc23e960ca6d45a3e0b64c7509f0c0ef0e5f7b64be8910a6a714c89b83fab95251bbf17f6c84b42c26cf460a28ea969591dc64b1f5c4b323f47615d2e8cbe4c62118001d3274e0f25850b0ac2617bc43119af22c99a1a83072002267177da01f9f37225435e1914be004f4d35a49869b737ed10ab232c1ed1048bb96ef6fb70979dc9c981e17134f4356a938'] ); $token2 = fake( UserIdentityModel::class, - ['user_id' => $this->user->id, 'type' => 'hmac_sha256', 'secret' => 'key2', 'secret2' => 'secretKey2'] + ['user_id' => $this->user->id, 'type' => 'hmac_sha256', 'secret' => 'key2', 'secret2' => 'd862cd9ddc23e960ca6d45a3e0b64c7509f0c0ef0e5f7b64be8910a6a714c89b83fab95251bbf17f6c84b42c26cf460a28ea969591dc64b1f5c4b323f47615d2e8cbe4c62118001d3274e0f25850b0ac2617bc43119af22c99a1a83072002267177da01f9f37225435e1914be004f4d35a49869b737ed10ab232c1ed1048bb96ef6fb70979dc9c981e17134f4356a938'] ); $tokens = $this->user->hmacTokens();
tests/Commands/HmacTest.php+169 −0 added@@ -0,0 +1,169 @@ +<?php + +declare(strict_types=1); + +/** + * This file is part of CodeIgniter Shield. + * + * (c) CodeIgniter Foundation <admin@codeigniter.com> + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Commands; + +use CodeIgniter\Shield\Authentication\HMAC\HmacEncrypter; +use CodeIgniter\Shield\Commands\Hmac; +use CodeIgniter\Shield\Config\AuthToken; +use CodeIgniter\Shield\Entities\User; +use CodeIgniter\Shield\Models\UserIdentityModel; +use CodeIgniter\Shield\Models\UserModel; +use CodeIgniter\Shield\Test\MockInputOutput; +use Tests\Support\DatabaseTestCase; + +/** + * @internal + */ +final class HmacTest extends DatabaseTestCase +{ + private ?MockInputOutput $io = null; + + public function testEncrypt(): void + { + $idModel = new UserIdentityModel(); + + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + + $rawSecretKey = $token->rawSecretKey; + $token->secret2 = $rawSecretKey; + + $idModel->save($token); + $tokenCheck = $idModel->find($token->id); + + $this->assertSame($rawSecretKey, $tokenCheck->secret2); + + $this->setMockIo([]); + $this->assertNotFalse(command('shield:hmac encrypt')); + + $tokenCheck = $idModel->find($token->id); + + $encrypter = new HmacEncrypter(); + $decryptedKey = $encrypter->decrypt($tokenCheck->secret2); + + $this->assertSame($rawSecretKey, $decryptedKey); + + // verify that encryption can only happen once + $this->setMockIo([]); + $this->assertNotFalse(command('shield:hmac encrypt')); + + $resultsString = trim($this->io->getOutputs()); + $this->assertSame('id: 1, already encrypted, skipped.', $resultsString); + } + + public function testDecrypt(): void + { + $idModel = new UserIdentityModel(); + + /** @var User $user */ + $user = fake(UserModel::class); + $token = $user->generateHmacToken('foo'); + + $rawSecretKey = $token->rawSecretKey; + + $this->setMockIo([]); + $this->assertNotFalse(command('shield:hmac decrypt')); + + $token->secret2 = $rawSecretKey; + + $idModel->save($token); + $tokenCheck = $idModel->find($token->id); + + $this->assertSame($rawSecretKey, $tokenCheck->secret2); + + // verify that decryption does not run on fields already decrypted + $this->setMockIo([]); + $this->assertNotFalse(command('shield:hmac decrypt')); + + $resultsString = trim($this->io->getOutputs()); + $this->assertSame('id: 1, not encrypted, skipped.', $resultsString); + } + + public function testReEncrypt(): void + { + $idModel = new UserIdentityModel(); + + // generate first token + /** @var User $user */ + $user = fake(UserModel::class); + $token1 = $user->generateHmacToken('foo'); + + // update config, rotate keys + /** @var AuthToken $config */ + $config = config('AuthToken'); + + $config->hmacEncryptionCurrentKey = 'k2'; + + // new key generated with updated encryption + $token2 = $user->generateHmacToken('bar'); + + $this->setMockIo([]); + $this->assertNotFalse(command('shield:hmac reencrypt')); + + $resultsString = $this->io->getOutputs(); + $results = explode("\n", trim($resultsString)); + + // verify that only 1 key needed to be re-encrypted + $this->assertCount(2, $results); + $this->assertSame('id: 1, Re-encrypted.', trim($results[0])); + $this->assertSame('id: 2, already encrypted with current key, skipped.', trim($results[1])); + + $encrypter = new HmacEncrypter(); + + $tokenCheck1 = $idModel->find($token1->id); + $descryptSecretKey1 = $encrypter->decrypt($tokenCheck1->secret2); + $this->assertSame($token1->rawSecretKey, $descryptSecretKey1); + + $tokenCheck2 = $idModel->find($token2->id); + $descryptSecretKey2 = $encrypter->decrypt($tokenCheck2->secret2); + $this->assertSame($token2->rawSecretKey, $descryptSecretKey2); + } + + public function testBadCommand(): void + { + $this->setMockIo([]); + $this->assertNotFalse(command('shield:hmac badcommand')); + + $resultsString = $this->stripRedColorCode(trim($this->io->getOutputs())); + + $this->assertSame('Unrecognized Command', $resultsString); + } + + /** + * Set MockInputOutput and user inputs. + * + * @param list<string> $inputs User inputs + */ + private function setMockIo(array $inputs): void + { + $this->io = new MockInputOutput(); + $this->io->setInputs($inputs); + Hmac::setInputOutput($this->io); + } + + /** + * Strip color from output code + */ + private function stripRedColorCode(string $output): string + { + $output = str_replace(["\033[0;31m", "\033[0m"], '', $output); + + if (is_windows()) { + $output = str_replace("\r\n", "\n", $output); + } + + return $output; + } +}
UPGRADING.md+20 −1 modified@@ -45,9 +45,28 @@ protected function redirectToDeniedUrl(): RedirectResponse { return redirect()->to(config('Auth')->groupDeniedRedirect()) ->with('error', lang('Auth.notEnoughPrivilege')); -} +} ``` +### Fix to HMAC Secret Key Encryption + +#### Config\AuthToken + +If you are using the HMAC authentication you need to update the encryption settings in **app/Config/AuthToken.php**. +You will need to update and set the encryption key in `$hmacEncryptionKeys`. This should be set using **.env** and/or +system environment variables. Instructions on how to do that can be found in the +[Setting Your Encryption Key](https://codeigniter.com/user_guide/libraries/encryption.html#setting-your-encryption-key) +section of the CodeIgniter 4 documentation and in [HMAC SHA256 Token Authenticator](./docs/references/authentication/hmac.md#hmac-secret-key-encryption). + +You also may wish to adjust the default Driver `$hmacEncryptionDefaultDriver` and the default Digest +`$hmacEncryptionDefaultDigest`, these currently default to `'OpenSSL'` and `'SHA512'` respectively. + +#### Encrypt Existing Keys + +After updating the key in `$hmacEncryptionKeys` value, you will need to run `php spark shield:hmac encrypt` in order +to encrypt any existing HMAC tokens. This only needs to be run if you have existing unencrypted HMAC secretKeys in +stored in the database. + ## Version 1.0.0-beta.6 to 1.0.0-beta.7 ### The minimum CodeIgniter version
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-v427-c49j-8w6xghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-48707ghsaADVISORY
- github.com/codeigniter4/shield/commit/f77c6ae20275ac1245330a2b9a523bf7e6f6202fghsax_refsource_MISCWEB
- github.com/codeigniter4/shield/security/advisories/GHSA-v427-c49j-8w6xghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.