Skip to content

Commit a68eb63

Browse files
committed
Added ability to rotate hmac encryption
keys and re-encrypt existing hmac secretKeys with updated encryption
1 parent 0acff4b commit a68eb63

5 files changed

Lines changed: 219 additions & 15 deletions

File tree

phpunit.xml.dist

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,9 @@
9494
<env name="COMPOSER_DISABLE_XDEBUG_WARN" value="1"/>
9595

9696
<!-- Default HMAC encryption key -->
97-
<env name="authtoken.hmacEncryptionKey" value="hex2bin:178ed94fd0b6d57dd31dd6b22fc601fab8ad191efac165a5f3f30a8ac09d813d"/>
97+
<env name="authtoken.hmacEncryptionKey" value="{&quot;k1&quot;:&quot;hex2bin:178ed94fd0b6d57dd31dd6b22fc601fab8ad191efac165a5f3f30a8ac09d813d&quot;,&quot;k2&quot;:&quot;hex2bin:b0ab85bd0320824c496db2f40eb47c8712a6dfcfdf99b805988e22bdea6b9203&quot;}"/>
98+
<env name="authtoken.hmacEncryptionDriver" value="{&quot;k1&quot;:&quot;OpenSSL&quot;,&quot;k2&quot;:&quot;OpenSSL&quot;}"/>
99+
<env name="authtoken.hmacEncryptionDigest" value="{&quot;k1&quot;:&quot;SHA512&quot;,&quot;k2&quot;:&quot;SHA512&quot;}"/>
98100

99101
<!-- Database configuration -->
100102
<env name="database.tests.strictOn" value="true"/>

src/Authentication/HMAC/HmacEncrypter.php

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use CodeIgniter\Encryption\EncrypterInterface;
88
use CodeIgniter\Encryption\Exceptions\EncryptionException;
9+
use CodeIgniter\Shield\Auth;
910
use CodeIgniter\Shield\Config\AuthToken;
1011
use CodeIgniter\Shield\Exceptions\RuntimeException;
1112
use Config\Encryption;
@@ -29,19 +30,31 @@ class HmacEncrypter
2930
*/
3031
private AuthToken $authConfig;
3132

33+
/**
34+
* Selected Key Index
35+
*/
36+
private string $keyIndex;
37+
3238
/**
3339
* Constructor
3440
* Setup encryption configuration
41+
*
42+
* @param bool $deprecatedKey Use the deprecated key (useful when re-encrypting on key rotation) [default: false]
3543
*/
36-
public function __construct()
44+
public function __construct(bool $deprecatedKey = false)
3745
{
38-
$this->authConfig = config('AuthToken');
39-
$config = new Encryption();
46+
/** @var AuthToken $authConfig */
47+
$authConfig = config('AuthToken');
48+
$config = new Encryption();
49+
50+
// identify which encryption key should be used
51+
$this->keyIndex = $deprecatedKey ? $authConfig->hmacDeprecatedKeyIndex : $authConfig->hmacKeyIndex;
4052

41-
$config->key = $this->authConfig->hmacEncryptionKey;
42-
$config->driver = $this->authConfig->hmacEncryptionDriver;
43-
$config->digest = $this->authConfig->hmacEncryptionDigest;
53+
$config->key = $authConfig->hmacEncryptionKey[$this->keyIndex];
54+
$config->driver = $authConfig->hmacEncryptionDriver[$this->keyIndex];
55+
$config->digest = $authConfig->hmacEncryptionDigest[$this->keyIndex];
4456

57+
$this->authConfig = $authConfig;
4558
// decrypt secret key so signature can be validated
4659
$this->encrypter = Services::encrypter($config);
4760
}
@@ -57,8 +70,8 @@ public function __construct()
5770
*/
5871
public function decrypt(string $encString): string
5972
{
60-
// Removes `$b6$` for base64 format.
61-
$encString = substr($encString, 4);
73+
// Removes `$b6$keyIndex$` for base64 format.
74+
$encString = substr($encString, strlen($this->keyIndex) + 5);
6275

6376
return $this->encrypter->decrypt(base64_decode($encString, true));
6477
}
@@ -75,7 +88,7 @@ public function decrypt(string $encString): string
7588
*/
7689
public function encrypt(string $rawString): string
7790
{
78-
$encryptedString = '$b6$' . base64_encode($this->encrypter->encrypt($rawString));
91+
$encryptedString = '$b6$' . $this->keyIndex . '$' . base64_encode($this->encrypter->encrypt($rawString));
7992

8093
if (strlen($encryptedString) > $this->authConfig->secret2StorageLimit) {
8194
throw new RuntimeException('Encrypted key too long. Unable to store value.');
@@ -89,7 +102,7 @@ public function encrypt(string $rawString): string
89102
*/
90103
public function isEncrypted(string $string): bool
91104
{
92-
return str_starts_with($string, '$b6$');
105+
return str_starts_with($string, '$b6$' . $this->keyIndex . '$');
93106
}
94107

95108
/**

src/Commands/Hmac.php

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ class Hmac extends BaseCommand
2525
*
2626
* @var string
2727
*/
28-
protected $description = 'Encrypt/Decrypt secretKey for HMAC tokens. The encryption should only be run on existing raw secret keys (extremely rare).';
28+
protected $description = 'Encrypt/Decrypt secretKey for HMAC tokens. The reencrypt command should be used when
29+
rotating the encryption keys. The encrypt command should only be run on existing raw secret keys (extremely rare).';
2930

3031
/**
3132
* the Command's usage
@@ -34,6 +35,7 @@ class Hmac extends BaseCommand
3435
*/
3536
protected $usage = <<<'EOL'
3637
shield:hmac <action>
38+
shield:hmac reencrypt
3739
shield:hmac encrypt
3840
shield:hmac decrypt
3941
EOL;
@@ -45,6 +47,7 @@ class Hmac extends BaseCommand
4547
*/
4648
protected $arguments = [
4749
'action' => <<<'EOL'
50+
reencrypt: Re-encrypts all HMAC Secret Keys on encryption key rotation
4851
encrypt: Encrypt all raw HMAC Secret Keys
4952
decrypt: Decrypt all encrypted HMAC Secret Keys
5053
EOL,
@@ -81,6 +84,10 @@ public function run(array $params): int
8184
$this->decrypt();
8285
break;
8386

87+
case 'reencrypt':
88+
$this->reEncrypt();
89+
break;
90+
8491
default:
8592
throw new BadInputException('Unrecognized Command');
8693
}
@@ -156,4 +163,37 @@ static function ($identity) use ($uIdModelSub, $encrypter, $that): void {
156163
}
157164
);
158165
}
166+
167+
/**
168+
* Re-encrypt all encrypted HMAC Secret Keys from existing/deprecated
169+
* encryption key to new encryption key.
170+
*
171+
* @throws ReflectionException
172+
*/
173+
public function reEncrypt(): void
174+
{
175+
$uIdModel = new UserIdentityModel();
176+
$uIdModelSub = new UserIdentityModel(); // For saving.
177+
$decrypter = new HmacEncrypter(true);
178+
$encrypter = $this->encrypter;
179+
180+
$that = $this;
181+
182+
$uIdModel->where('type', 'hmac_sha256')->chunk(
183+
100,
184+
static function ($identity) use ($uIdModelSub, $decrypter, $encrypter, $that): void {
185+
if (! $decrypter->isEncrypted($identity->secret2)) {
186+
$that->write('id: ' . $identity->id . ', not re-encrypted, skipped.');
187+
188+
return;
189+
}
190+
191+
$identity->secret2 = $decrypter->decrypt($identity->secret2);
192+
$identity->secret2 = $encrypter->encrypt($identity->secret2);
193+
$uIdModelSub->save($identity);
194+
195+
$that->write('id: ' . $identity->id . ', Re-encrypted.');
196+
}
197+
);
198+
}
159199
}

src/Config/AuthToken.php

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,20 @@ class AuthToken extends BaseConfig
7878
* --------------------------------------------------------------------
7979
* Key to be used when encrypting HMAC Secret Key for storage.
8080
*
81+
* This is an array of keys which will facilitate key rotation. Valid
82+
* keyTitles must include only [a-zA-z0-9_] and should be kept to a
83+
* max of 8 characters.
84+
*
85+
* The valid and old/deprecated keys are identified using $hmacKeyIndex
86+
* and $hmacDeprecatedKeyIndex.
87+
*
88+
* @param array<string, string>|string $hmacEncryptionKey ['keyTitle' => 'keyValue']
89+
*
8190
* @see https://codeigniter.com/user_guide/libraries/encryption.html
8291
*/
83-
public string $hmacEncryptionKey = '';
92+
public $hmacEncryptionKey = [
93+
'k1' => '',
94+
];
8495

8596
/**
8697
* --------------------------------------------------------------------
@@ -92,9 +103,16 @@ class AuthToken extends BaseConfig
92103
* OpenSSL
93104
* Sodium
94105
*
106+
* This is an array of drivers values. The keys MUST match and correlate
107+
* to the $hmacEncryptionKey array keys.
108+
*
109+
* @param array<string, string>|string $hmacEncryptionDriver ['keyTitle' => 'driverValue']
110+
*
95111
* @see https://codeigniter.com/user_guide/libraries/encryption.html
96112
*/
97-
public string $hmacEncryptionDriver = 'OpenSSL';
113+
public $hmacEncryptionDriver = [
114+
'k1' => 'OpenSSL',
115+
];
98116

99117
/**
100118
* --------------------------------------------------------------------
@@ -104,7 +122,79 @@ class AuthToken extends BaseConfig
104122
*
105123
* e.g. 'SHA512' or 'SHA256'. Default value is 'SHA512'.
106124
*
125+
* This is an array of digest values. The keys MUST match and correlate
126+
* to the $hmacEncryptionKey array keys.
127+
*
128+
* @param array<string, string>|string $hmacEncryptionDigest ['keyTitle' => 'digestValue']
129+
*
107130
* @see https://codeigniter.com/user_guide/libraries/encryption.html
108131
*/
109-
public string $hmacEncryptionDigest = 'SHA512';
132+
public $hmacEncryptionDigest = [
133+
'k1' => 'SHA512',
134+
];
135+
136+
/**
137+
* --------------------------------------------------------------------
138+
* HMAC encryption key selector
139+
* --------------------------------------------------------------------
140+
* This identifies which encryption key {$hmacEncryptionKey} is active
141+
* and valid.
142+
*/
143+
public string $hmacKeyIndex = 'k1';
144+
145+
/**
146+
* --------------------------------------------------------------------
147+
* HMAC encryption key deprecated selector
148+
* --------------------------------------------------------------------
149+
* This identifies which encryption key {$hmacEncryptionKey} is
150+
* recently deprecated. This is required and used when rotating keys.
151+
* Effectively, this is the index selector for the old key.
152+
*/
153+
public string $hmacDeprecatedKeyIndex = '';
154+
155+
public function __construct()
156+
{
157+
parent::__construct();
158+
159+
if (is_string($this->hmacEncryptionKey)) {
160+
$array = json_decode($this->hmacEncryptionKey, true);
161+
if (is_array($array)) {
162+
$this->hmacEncryptionKey = $array;
163+
}
164+
}
165+
if (is_string($this->hmacEncryptionDriver)) {
166+
$array = json_decode($this->hmacEncryptionDriver, true);
167+
if (is_array($array)) {
168+
$this->hmacEncryptionDriver = $array;
169+
}
170+
}
171+
if (is_string($this->hmacEncryptionDigest)) {
172+
$array = json_decode($this->hmacEncryptionDigest, true);
173+
if (is_array($array)) {
174+
$this->hmacEncryptionDigest = $array;
175+
}
176+
}
177+
}
178+
179+
/**
180+
* Override parent initEnvValue() to allow for direct setting to array properties values from ENV
181+
*
182+
* In order to set array properties via ENV vars we need to set the property to a string value first.
183+
*
184+
* @param mixed $property
185+
*/
186+
protected function initEnvValue(&$property, string $name, string $prefix, string $shortPrefix): void
187+
{
188+
switch ($name) {
189+
case 'hmacEncryptionKey':
190+
case 'hmacEncryptionDriver':
191+
case 'hmacEncryptionDigest':
192+
// if attempting to set property from ENV, first set to empty string
193+
if ((bool) $this->getEnvValue($name, $prefix, $shortPrefix)) {
194+
$property = '';
195+
}
196+
}
197+
198+
parent::initEnvValue($property, $name, $prefix, $shortPrefix);
199+
}
110200
}

tests/Commands/HmacTest.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,21 @@
55
namespace Tests\Commands;
66

77
use CodeIgniter\Shield\Authentication\HMAC\HmacEncrypter;
8+
use CodeIgniter\Shield\Commands\Hmac;
9+
use CodeIgniter\Shield\Config\AuthToken;
810
use CodeIgniter\Shield\Entities\User;
911
use CodeIgniter\Shield\Models\UserIdentityModel;
1012
use CodeIgniter\Shield\Models\UserModel;
13+
use CodeIgniter\Shield\Test\MockInputOutput;
1114
use Tests\Support\DatabaseTestCase;
1215

1316
/**
1417
* @internal
1518
*/
1619
final class HmacTest extends DatabaseTestCase
1720
{
21+
private ?MockInputOutput $io = null;
22+
1823
public function testEncrypt(): void
1924
{
2025
$idModel = new UserIdentityModel();
@@ -60,4 +65,58 @@ public function testDecrypt(): void
6065

6166
$this->assertSame($rawSecretKey, $tokenCheck->secret2);
6267
}
68+
69+
public function testReEncrypt(): void
70+
{
71+
$idModel = new UserIdentityModel();
72+
73+
// generate first token
74+
/** @var User $user */
75+
$user = fake(UserModel::class);
76+
$token1 = $user->generateHmacToken('foo');
77+
78+
// update config, rotate keys
79+
/** @var AuthToken $config */
80+
$config = config('AuthToken');
81+
82+
$config->hmacKeyIndex = 'k2';
83+
$config->hmacDeprecatedKeyIndex = 'k1';
84+
85+
// new key generated with updated encryption
86+
$token2 = $user->generateHmacToken('bar');
87+
88+
$this->setMockIo([]);
89+
$this->assertNotFalse(command('shield:hmac reencrypt'));
90+
91+
$resultsString = $this->io->getOutputs();
92+
$results = explode("\n", trim($resultsString));
93+
94+
// verify that only 1 key needed to be re-encrypted
95+
$this->assertCount(2, $results);
96+
$this->assertSame('id: 1, Re-encrypted.', $results[0]);
97+
$this->assertSame('id: 2, not re-encrypted, skipped.', $results[1]);
98+
99+
$encrypter = new HmacEncrypter();
100+
101+
$tokenCheck1 = $idModel->find($token1->id);
102+
$descryptSecretKey1 = $encrypter->decrypt($tokenCheck1->secret2);
103+
$this->assertSame($token1->rawSecretKey, $descryptSecretKey1);
104+
105+
$tokenCheck2 = $idModel->find($token2->id);
106+
$descryptSecretKey2 = $encrypter->decrypt($tokenCheck2->secret2);
107+
$this->assertSame($token2->rawSecretKey, $descryptSecretKey2);
108+
}
109+
110+
/**
111+
* Set MockInputOutput and user inputs.
112+
*
113+
* @param array<int, string> $inputs User inputs
114+
* @phpstan-param list<string> $inputs
115+
*/
116+
private function setMockIo(array $inputs): void
117+
{
118+
$this->io = new MockInputOutput();
119+
$this->io->setInputs($inputs);
120+
Hmac::setInputOutput($this->io);
121+
}
63122
}

0 commit comments

Comments
 (0)