Skip to content

Commit d98572d

Browse files
committed
feat: add key:rotate command
1 parent 2c774ca commit d98572d

8 files changed

Lines changed: 865 additions & 4 deletions

File tree

system/CLI/CLI.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,8 +1166,14 @@ public static function resetLastWrite(): void
11661166
}
11671167

11681168
/**
1169-
* Testing purpose only
1170-
*
1169+
* @internal
1170+
*/
1171+
public static function getInputOutput(): ?InputOutput
1172+
{
1173+
return static::$io;
1174+
}
1175+
1176+
/**
11711177
* @internal
11721178
*/
11731179
public static function setInputOutput(InputOutput $io): void
@@ -1176,8 +1182,6 @@ public static function setInputOutput(InputOutput $io): void
11761182
}
11771183

11781184
/**
1179-
* Testing purpose only
1180-
*
11811185
* @internal
11821186
*/
11831187
public static function resetInputOutput(): void

system/CLI/NullInputOutput.php

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

tests/system/CLI/CLITest.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,34 @@ public function testNew(): void
5656
$this->assertInstanceOf(CLI::class, $actual);
5757
}
5858

59+
public function testGetInputOutputReturnsCurrentlyAssignedIo(): void
60+
{
61+
$io = new InputOutput();
62+
CLI::setInputOutput($io);
63+
64+
try {
65+
$this->assertSame($io, CLI::getInputOutput());
66+
} finally {
67+
CLI::resetInputOutput();
68+
}
69+
70+
// After reset, the property is repopulated with a fresh instance, never null.
71+
$this->assertInstanceOf(InputOutput::class, CLI::getInputOutput());
72+
}
73+
74+
public function testGetInputOutputReturnsNullWhenIoIsUnset(): void
75+
{
76+
$property = new ReflectionProperty(CLI::class, 'io');
77+
$previous = $property->getValue();
78+
$property->setValue(null, null);
79+
80+
try {
81+
$this->assertNotInstanceOf(InputOutput::class, CLI::getInputOutput());
82+
} finally {
83+
$property->setValue(null, $previous);
84+
}
85+
}
86+
5987
public function testBeep(): void
6088
{
6189
$this->expectOutputString("\x07");

0 commit comments

Comments
 (0)