Skip to content

Commit 8ad03eb

Browse files
committed
feat: add key:rotate command
1 parent 9f966a3 commit 8ad03eb

4 files changed

Lines changed: 838 additions & 0 deletions

File tree

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
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\Commands\Encryption;
15+
16+
use CodeIgniter\CLI\AbstractCommand;
17+
use CodeIgniter\CLI\Attributes\Command;
18+
use CodeIgniter\CLI\CLI;
19+
use CodeIgniter\CLI\Input\Option;
20+
use Config\Paths;
21+
22+
/**
23+
* Rotates the encryption key, demoting the current key to `previousKeys`.
24+
*/
25+
#[Command(
26+
name: 'key:rotate',
27+
description: 'Rotates the encryption key, demoting the current key to `encryption.previousKeys` in the `.env` file.',
28+
group: 'Encryption',
29+
)]
30+
class RotateKey extends AbstractCommand
31+
{
32+
/**
33+
* @var list<string>
34+
*/
35+
private const VALID_PREFIXES = ['hex2bin', 'base64'];
36+
37+
protected function configure(): void
38+
{
39+
$this
40+
->addOption(new Option(
41+
name: 'force',
42+
shortcut: 'f',
43+
description: 'Skip the key rotation confirmation.',
44+
))
45+
->addOption(new Option(
46+
name: 'length',
47+
description: 'The length of the random string for the new key, in bytes.',
48+
requiresValue: true,
49+
default: '32',
50+
))
51+
->addOption(new Option(
52+
name: 'prefix',
53+
description: 'Prefix for the new key (either hex2bin or base64).',
54+
requiresValue: true,
55+
default: 'hex2bin',
56+
))
57+
->addOption(new Option(
58+
name: 'keep',
59+
description: 'Maximum number of previous keys to retain. Older keys are dropped. 0 means unlimited.',
60+
requiresValue: true,
61+
default: '0',
62+
));
63+
}
64+
65+
protected function interact(array &$arguments, array &$options): void
66+
{
67+
$prefix = $this->getUnboundOption('prefix', $options);
68+
69+
if (is_string($prefix) && ! in_array($prefix, self::VALID_PREFIXES, true)) {
70+
$options['prefix'] = CLI::prompt('Please provide a valid prefix to use.', self::VALID_PREFIXES, 'required');
71+
}
72+
73+
if ($this->hasUnboundOption('force', $options)) {
74+
return;
75+
}
76+
77+
if (env('encryption.key', '') === '') {
78+
return;
79+
}
80+
81+
if (CLI::prompt('Rotate encryption key? The current key will be moved to `previousKeys`.', ['n', 'y']) === 'y') {
82+
$options['force'] = null; // simulate the presence of the --force option
83+
}
84+
}
85+
86+
protected function execute(array $arguments, array $options): int
87+
{
88+
$prefix = $options['prefix'];
89+
90+
if (! in_array($prefix, self::VALID_PREFIXES, true)) {
91+
CLI::error(sprintf('Invalid prefix "%s". Use either "hex2bin" or "base64".', $prefix));
92+
93+
return EXIT_ERROR;
94+
}
95+
96+
$currentKey = env('encryption.key', '');
97+
98+
if ($currentKey === '') {
99+
CLI::error('No existing `encryption.key` to rotate. Run `spark key:generate` first.');
100+
101+
return EXIT_ERROR;
102+
}
103+
104+
if ($options['force'] === false) {
105+
if ($this->isInteractive()) {
106+
CLI::error('Key rotation cancelled.');
107+
} else {
108+
CLI::error('Key rotation aborted.');
109+
CLI::error('If you want, use the "--force" option to force the rotation.');
110+
}
111+
112+
return EXIT_ERROR;
113+
}
114+
115+
$keep = $options['keep'];
116+
117+
if (! is_numeric($keep) || (int) $keep < 0) {
118+
CLI::error('The --keep option must be a non-negative integer.');
119+
120+
return EXIT_ERROR;
121+
}
122+
123+
$length = $options['length'];
124+
125+
if (! is_numeric($length) || (int) $length < 1) {
126+
CLI::error('The --length option must be a positive integer.');
127+
128+
return EXIT_ERROR;
129+
}
130+
131+
$previousKeys = $this->mergePreviousKeys($currentKey, $this->parsePreviousKeys(), (int) $keep);
132+
133+
// Write previousKeys first. If the subsequent `key:generate` call fails,
134+
// the worst case is a stale-but-still-decryptable `.env` (the rotated-out
135+
// key is preserved on disk).
136+
if (! $this->writePreviousKeys($previousKeys)) {
137+
CLI::error('Error in writing `encryption.previousKeys` to `.env` file.');
138+
139+
return EXIT_ERROR;
140+
}
141+
142+
// Clear `encryption.previousKeys` from all env sources so the DotEnv
143+
// reload triggered by `key:generate` picks up the new value (DotEnv's
144+
// `setVariable()` skips vars that are already set).
145+
putenv('encryption.previousKeys');
146+
unset($_ENV['encryption.previousKeys']);
147+
service('superglobals')->unsetServer('encryption.previousKeys');
148+
149+
$exitCode = $this->callSilently('key:generate', options: [
150+
'force' => null,
151+
'prefix' => $prefix,
152+
'length' => $length,
153+
]);
154+
155+
if ($exitCode !== EXIT_SUCCESS) {
156+
return $exitCode; // @codeCoverageIgnore
157+
}
158+
159+
$count = count($previousKeys);
160+
161+
CLI::write(sprintf(
162+
'Encryption key rotated. %d %s retained for decryption fallback.',
163+
$count,
164+
$count === 1 ? 'previous key' : 'previous keys',
165+
), 'green');
166+
CLI::write('Re-encrypt existing data with the new key when ready.', 'yellow');
167+
168+
return EXIT_SUCCESS;
169+
}
170+
171+
/**
172+
* Reads the existing `encryption.previousKeys` from the environment as a
173+
* comma-separated list, ignoring blank entries.
174+
*
175+
* @return list<string>
176+
*/
177+
private function parsePreviousKeys(): array
178+
{
179+
$raw = env('encryption.previousKeys', '');
180+
181+
if (! is_string($raw) || $raw === '') {
182+
return [];
183+
}
184+
185+
return array_values(array_filter(
186+
array_map(trim(...), explode(',', $raw)),
187+
static fn (string $v): bool => $v !== '',
188+
));
189+
}
190+
191+
/**
192+
* Prepends the rotated-out key, deduplicates while preserving newest-first order,
193+
* and optionally caps the list length.
194+
*
195+
* @param list<string> $existing
196+
*
197+
* @return list<string>
198+
*/
199+
private function mergePreviousKeys(string $currentKey, array $existing, int $keep): array
200+
{
201+
$merged = [$currentKey, ...$existing];
202+
$seen = [];
203+
$result = [];
204+
205+
foreach ($merged as $key) {
206+
if (isset($seen[$key])) {
207+
continue;
208+
}
209+
210+
$seen[$key] = true;
211+
$result[] = $key;
212+
}
213+
214+
if ($keep > 0) {
215+
$result = array_slice($result, 0, $keep);
216+
}
217+
218+
return $result;
219+
}
220+
221+
/**
222+
* Replaces or inserts the `encryption.previousKeys` line in the `.env` file.
223+
* `key:generate` is responsible for the file's existence and the
224+
* `encryption.key` line; this method only touches `encryption.previousKeys`.
225+
*
226+
* @param list<string> $previousKeys
227+
*/
228+
private function writePreviousKeys(array $previousKeys): bool
229+
{
230+
$envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property
231+
232+
if (! is_file($envFile)) {
233+
return false; // @codeCoverageIgnore
234+
}
235+
236+
if (! is_writable($envFile)) {
237+
return false;
238+
}
239+
240+
$contents = (string) file_get_contents($envFile);
241+
$value = implode(',', $previousKeys);
242+
243+
// Match an actual setting line, not a substring buried in a comment. The optional
244+
// `export` prefix mirrors what DotEnv accepts.
245+
$previousKeysPattern = '/^(\h*(?:export\h+)?encryption\.previousKeys\h*=\h*)[^\r\n]*$/m';
246+
247+
if (preg_match($previousKeysPattern, $contents) === 1) {
248+
$contents = (string) preg_replace($previousKeysPattern, '$1' . $value, $contents, 1);
249+
250+
return file_put_contents($envFile, $contents) !== false;
251+
}
252+
253+
// Insert right after the `encryption.key` line so the two stay grouped.
254+
$injected = (string) preg_replace(
255+
'/^(\h*(?:export\h+)?encryption\.key\h*=\h*[^\r\n]*)$/m',
256+
"$1\nencryption.previousKeys = {$value}",
257+
$contents,
258+
1,
259+
);
260+
261+
if ($injected === $contents) {
262+
// @codeCoverageIgnoreStart
263+
// Fallback: append to the end. Shouldn't trigger because `key:generate`
264+
// writes the `encryption.key` line just before this method runs.
265+
$injected = $contents . "\nencryption.previousKeys = {$value}";
266+
// @codeCoverageIgnoreEnd
267+
}
268+
269+
return file_put_contents($envFile, $injected) !== false;
270+
}
271+
}

0 commit comments

Comments
 (0)