From d98572dbef6962825db0eef42bdfb0bdc7318869 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Fri, 8 May 2026 00:46:29 +0530 Subject: [PATCH] feat: add `key:rotate` command --- system/CLI/CLI.php | 12 +- system/CLI/NullInputOutput.php | 33 ++ system/Commands/Encryption/RotateKey.php | 286 ++++++++++++ tests/system/CLI/CLITest.php | 28 ++ tests/system/CLI/NullInputOutputTest.php | 63 +++ .../Commands/Encryption/RotateKeyTest.php | 421 ++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 3 + .../source/libraries/encryption.rst | 23 + 8 files changed, 865 insertions(+), 4 deletions(-) create mode 100644 system/CLI/NullInputOutput.php create mode 100644 system/Commands/Encryption/RotateKey.php create mode 100644 tests/system/CLI/NullInputOutputTest.php create mode 100644 tests/system/Commands/Encryption/RotateKeyTest.php diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index 75dc24f35f5f..78c21a4115a6 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -1166,8 +1166,14 @@ public static function resetLastWrite(): void } /** - * Testing purpose only - * + * @internal + */ + public static function getInputOutput(): ?InputOutput + { + return static::$io; + } + + /** * @internal */ public static function setInputOutput(InputOutput $io): void @@ -1176,8 +1182,6 @@ public static function setInputOutput(InputOutput $io): void } /** - * Testing purpose only - * * @internal */ public static function resetInputOutput(): void diff --git a/system/CLI/NullInputOutput.php b/system/CLI/NullInputOutput.php new file mode 100644 index 000000000000..e8eeee2535b7 --- /dev/null +++ b/system/CLI/NullInputOutput.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +/** + * An InputOutput sink that discards all output and never reads input. + * + * Useful for silencing a sub-command invoked via `AbstractCommand::call()` + * when the parent command wants to emit its own consolidated message instead + * of letting the sub-command's output leak through. + */ +final class NullInputOutput extends InputOutput +{ + public function fwrite($handle, string $string): void + { + } + + public function input(?string $prefix = null): string + { + return ''; + } +} diff --git a/system/Commands/Encryption/RotateKey.php b/system/Commands/Encryption/RotateKey.php new file mode 100644 index 000000000000..1a86f279a683 --- /dev/null +++ b/system/Commands/Encryption/RotateKey.php @@ -0,0 +1,286 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Encryption; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\CLI; +use CodeIgniter\CLI\Input\Option; +use CodeIgniter\CLI\InputOutput; +use CodeIgniter\CLI\NullInputOutput; +use Config\Paths; + +/** + * Rotates the encryption key, demoting the current key to `previousKeys`. + */ +#[Command( + name: 'key:rotate', + description: 'Rotates the encryption key, demoting the current key to `encryption.previousKeys` in the `.env` file.', + group: 'Encryption', +)] +class RotateKey extends AbstractCommand +{ + /** + * @var list + */ + private const VALID_PREFIXES = ['hex2bin', 'base64']; + + protected function configure(): void + { + $this + ->addOption(new Option( + name: 'force', + shortcut: 'f', + description: 'Skip the key rotation confirmation.', + )) + ->addOption(new Option( + name: 'length', + description: 'The length of the random string for the new key, in bytes.', + requiresValue: true, + default: '32', + )) + ->addOption(new Option( + name: 'prefix', + description: 'Prefix for the new key (either hex2bin or base64).', + requiresValue: true, + default: 'hex2bin', + )) + ->addOption(new Option( + name: 'keep', + description: 'Maximum number of previous keys to retain. Older keys are dropped. 0 means unlimited.', + requiresValue: true, + default: '0', + )); + } + + protected function interact(array &$arguments, array &$options): void + { + $prefix = $this->getUnboundOption('prefix', $options); + + if (is_string($prefix) && ! in_array($prefix, self::VALID_PREFIXES, true)) { + $options['prefix'] = CLI::prompt('Please provide a valid prefix to use.', self::VALID_PREFIXES, 'required'); + } + + if ($this->hasUnboundOption('force', $options)) { + return; + } + + if (env('encryption.key', '') === '') { + return; + } + + if (CLI::prompt('Rotate encryption key? The current key will be moved to `previousKeys`.', ['n', 'y']) === 'y') { + $options['force'] = null; // simulate the presence of the --force option + } + } + + protected function execute(array $arguments, array $options): int + { + $prefix = $options['prefix']; + + if (! in_array($prefix, self::VALID_PREFIXES, true)) { + CLI::error(sprintf('Invalid prefix "%s". Use either "hex2bin" or "base64".', $prefix)); + + return EXIT_ERROR; + } + + $currentKey = env('encryption.key', ''); + + if ($currentKey === '') { + CLI::error('No existing `encryption.key` to rotate. Run `spark key:generate` first.'); + + return EXIT_ERROR; + } + + if ($options['force'] === false) { + if ($this->isInteractive()) { + CLI::error('Key rotation cancelled.'); + } else { + CLI::error('Key rotation aborted.'); + CLI::error('If you want, use the "--force" option to force the rotation.'); + } + + return EXIT_ERROR; + } + + $keep = $options['keep']; + + if (! is_numeric($keep) || (int) $keep < 0) { + CLI::error('The --keep option must be a non-negative integer.'); + + return EXIT_ERROR; + } + + $previousKeys = $this->mergePreviousKeys($currentKey, $this->parsePreviousKeys(), (int) $keep); + + // Write previousKeys first. If the subsequent `key:generate` call fails, + // the worst case is a stale-but-still-decryptable `.env` (the rotated-out + // key is preserved on disk). + if (! $this->writePreviousKeys($previousKeys)) { + CLI::error('Error in writing `encryption.previousKeys` to `.env` file.'); + + return EXIT_ERROR; + } + + // Clear `encryption.previousKeys` from all env sources so the DotEnv + // reload triggered by `key:generate` picks up the new value (DotEnv's + // `setVariable()` skips vars that are already set). + putenv('encryption.previousKeys'); + unset($_ENV['encryption.previousKeys']); + service('superglobals')->unsetServer('encryption.previousKeys'); + + $exitCode = $this->callKeyGenerateSilently($prefix, $options['length']); + + if ($exitCode !== EXIT_SUCCESS) { + return $exitCode; // @codeCoverageIgnore + } + + $count = count($previousKeys); + + CLI::write(sprintf( + 'Encryption key rotated. %d %s retained for decryption fallback.', + $count, + $count === 1 ? 'previous key' : 'previous keys', + ), 'green'); + CLI::write('Re-encrypt existing data with the new key when ready.', 'yellow'); + + return EXIT_SUCCESS; + } + + /** + * Calls `key:generate` with a discarding IO so its "successfully set" + * message doesn't leak through; we emit a single consolidated rotation + * message ourselves. The prior IO is restored even on exception. + */ + private function callKeyGenerateSilently(string $prefix, string $length): int + { + $priorIo = CLI::getInputOutput(); + + CLI::setInputOutput(new NullInputOutput()); + + try { + return $this->call( + 'key:generate', + options: [ + 'force' => null, + 'prefix' => $prefix, + 'length' => $length, + ], + noInteractionOverride: true, + ); + } finally { + if ($priorIo instanceof InputOutput) { + CLI::setInputOutput($priorIo); + } else { + CLI::resetInputOutput(); + } + } + } + + /** + * Reads the existing `encryption.previousKeys` from the environment as a + * comma-separated list, ignoring blank entries. + * + * @return list + */ + private function parsePreviousKeys(): array + { + $raw = env('encryption.previousKeys', ''); + + if (! is_string($raw) || $raw === '') { + return []; + } + + return array_values(array_filter( + array_map(trim(...), explode(',', $raw)), + static fn (string $v): bool => $v !== '', + )); + } + + /** + * Prepends the rotated-out key, deduplicates while preserving newest-first order, + * and optionally caps the list length. + * + * @param list $existing + * + * @return list + */ + private function mergePreviousKeys(string $currentKey, array $existing, int $keep): array + { + $merged = [$currentKey, ...$existing]; + $seen = []; + $result = []; + + foreach ($merged as $key) { + if (isset($seen[$key])) { + continue; + } + + $seen[$key] = true; + $result[] = $key; + } + + if ($keep > 0) { + $result = array_slice($result, 0, $keep); + } + + return $result; + } + + /** + * Replaces or inserts the `encryption.previousKeys` line in the `.env` file. + * `key:generate` is responsible for the file's existence and the + * `encryption.key` line; this method only touches `encryption.previousKeys`. + * + * @param list $previousKeys + */ + private function writePreviousKeys(array $previousKeys): bool + { + $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property + + if (! is_file($envFile)) { + return false; // @codeCoverageIgnore + } + + if (! is_writable($envFile)) { + return false; + } + + $contents = (string) file_get_contents($envFile); + $value = implode(',', $previousKeys); + $replacement = "\nencryption.previousKeys = {$value}"; + + if (! str_contains($contents, 'encryption.previousKeys')) { + // Insert right after the `encryption.key` line so the two stay grouped. + $injected = (string) preg_replace( + '/^([#\s]*encryption\.key[=\s]*[^\r\n]*)$/m', + '$1' . $replacement, + $contents, + 1, + ); + + // The append fallback shouldn't trigger because `key:generate` writes + // the `encryption.key` line just before this method runs. + return file_put_contents($envFile, $injected !== $contents ? $injected : $contents . $replacement) !== false; + } + + $contents = (string) preg_replace( + '/^[#\s]*encryption\.previousKeys[=\s]*[^\r\n]*$/m', + trim($replacement), + $contents, + ); + + return file_put_contents($envFile, $contents) !== false; + } +} diff --git a/tests/system/CLI/CLITest.php b/tests/system/CLI/CLITest.php index 9fcfe2da3692..157086c425ad 100644 --- a/tests/system/CLI/CLITest.php +++ b/tests/system/CLI/CLITest.php @@ -56,6 +56,34 @@ public function testNew(): void $this->assertInstanceOf(CLI::class, $actual); } + public function testGetInputOutputReturnsCurrentlyAssignedIo(): void + { + $io = new InputOutput(); + CLI::setInputOutput($io); + + try { + $this->assertSame($io, CLI::getInputOutput()); + } finally { + CLI::resetInputOutput(); + } + + // After reset, the property is repopulated with a fresh instance, never null. + $this->assertInstanceOf(InputOutput::class, CLI::getInputOutput()); + } + + public function testGetInputOutputReturnsNullWhenIoIsUnset(): void + { + $property = new ReflectionProperty(CLI::class, 'io'); + $previous = $property->getValue(); + $property->setValue(null, null); + + try { + $this->assertNotInstanceOf(InputOutput::class, CLI::getInputOutput()); + } finally { + $property->setValue(null, $previous); + } + } + public function testBeep(): void { $this->expectOutputString("\x07"); diff --git a/tests/system/CLI/NullInputOutputTest.php b/tests/system/CLI/NullInputOutputTest.php new file mode 100644 index 000000000000..4008bc7bf6ff --- /dev/null +++ b/tests/system/CLI/NullInputOutputTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class NullInputOutputTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + public function testFwriteDiscardsOutput(): void + { + $io = new NullInputOutput(); + $io->fwrite(STDOUT, 'should not appear'); + $io->fwrite(STDERR, 'should not appear either'); + + $this->assertSame('', $this->getStreamFilterBuffer()); + } + + public function testInputReturnsEmptyStringWithoutEchoingPrefix(): void + { + $io = new NullInputOutput(); + + $this->assertSame('', $io->input()); + $this->assertSame('', $io->input('any prefix > ')); + $this->assertSame('', $this->getStreamFilterBuffer()); + } + + public function testCanBeSwappedIntoCliToSilenceWrites(): void + { + $prior = CLI::getInputOutput(); + CLI::setInputOutput(new NullInputOutput()); + + try { + CLI::write('this should be discarded'); + CLI::error('this too'); + $this->assertSame('', $this->getStreamFilterBuffer()); + } finally { + if ($prior instanceof InputOutput) { + CLI::setInputOutput($prior); + } else { + CLI::resetInputOutput(); + } + } + } +} diff --git a/tests/system/Commands/Encryption/RotateKeyTest.php b/tests/system/Commands/Encryption/RotateKeyTest.php new file mode 100644 index 000000000000..11e792986340 --- /dev/null +++ b/tests/system/Commands/Encryption/RotateKeyTest.php @@ -0,0 +1,421 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Encryption; + +use CodeIgniter\CLI\CLI; +use CodeIgniter\Config\DotEnv; +use CodeIgniter\Config\Services; +use CodeIgniter\Superglobals; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; +use CodeIgniter\Test\StreamFilterTrait; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\WithoutErrorHandler; + +/** + * @internal + */ +#[Group('Others')] +final class RotateKeyTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + private const SEED_KEY = 'hex2bin:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + private string $envPath; + private string $backupEnvPath; + + #[WithoutErrorHandler] + protected function setUp(): void + { + parent::setUp(); + + CLI::resetLastWrite(); + Services::injectMock('superglobals', new Superglobals()); + + $this->envPath = ROOTPATH . '.env'; + $this->backupEnvPath = ROOTPATH . '.env.backup'; + + if (is_file($this->envPath)) { + rename($this->envPath, $this->backupEnvPath); + } + + $this->resetEnvironment(); + } + + protected function tearDown(): void + { + if (is_file($this->envPath)) { + unlink($this->envPath); + } + + if (is_file($this->backupEnvPath)) { + rename($this->backupEnvPath, $this->envPath); + } + + $this->resetEnvironment(); + $this->resetServices(); + + CLI::reset(); + } + + private function getUndecoratedBuffer(): string + { + return preg_replace('/\e\[[^m]+m/', '', $this->getStreamFilterBuffer()) ?? ''; + } + + private static function getUndecoratedIoOutput(MockInputOutput $io): string + { + return preg_replace('/\e\[[^m]+m/', '', $io->getOutput()) ?? ''; + } + + private function resetEnvironment(): void + { + putenv('encryption.key'); + putenv('encryption.previousKeys'); + unset($_ENV['encryption.key'], $_ENV['encryption.previousKeys']); + + $superglobals = service('superglobals'); + $superglobals->unsetServer('encryption.key'); + $superglobals->unsetServer('encryption.previousKeys'); + } + + private function seedEnv(string $key, string $previousKeys = ''): void + { + $content = "encryption.key = {$key}\n"; + + if ($previousKeys !== '') { + $content .= "encryption.previousKeys = {$previousKeys}\n"; + } + + file_put_contents($this->envPath, $content); + + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + } + + public function testRotateMovesCurrentKeyToPreviousKeysAndGeneratesNew(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --force'); + + $this->assertSame( + <<<'EOT' + Encryption key rotated. 1 previous key retained for decryption fallback. + Re-encrypt existing data with the new key when ready. + + EOT, + $this->getUndecoratedBuffer(), + ); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression( + '/^encryption\.key = hex2bin:[a-f0-9]{64}\nencryption\.previousKeys = ' . preg_quote(self::SEED_KEY, '/') . '$/m', + $contents, + 'previousKeys should be inserted on the line directly after encryption.key.', + ); + $this->assertNotSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotatePrependsToExistingPreviousKeysList(): void + { + $older = 'hex2bin:' . str_repeat('a', 64); + $oldest = 'hex2bin:' . str_repeat('b', 64); + $this->seedEnv(self::SEED_KEY, "{$older},{$oldest}"); + + command('key:rotate --force'); + + $this->assertSame( + <<<'EOT' + Encryption key rotated. 3 previous keys retained for decryption fallback. + Re-encrypt existing data with the new key when ready. + + EOT, + $this->getUndecoratedBuffer(), + ); + + $this->assertSame( + self::SEED_KEY . ",{$older},{$oldest}", + env('encryption.previousKeys'), + ); + } + + public function testRotateDeduplicatesWhenCurrentKeyAlreadyInPreviousKeys(): void + { + $other = 'hex2bin:' . str_repeat('a', 64); + $this->seedEnv(self::SEED_KEY, self::SEED_KEY . ",{$other}"); + + command('key:rotate --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertSame( + self::SEED_KEY . ",{$other}", + env('encryption.previousKeys'), + 'Current key should not appear twice in the rotated list.', + ); + $this->assertSame( + 1, + substr_count($contents, 'encryption.previousKeys = '), + 'Should rewrite the previousKeys line in place rather than appending a duplicate.', + ); + $this->assertStringNotContainsString( + "\n\nencryption.previousKeys", + $contents, + 'In-place replacement should not introduce a blank line before encryption.previousKeys.', + ); + } + + public function testRotateRespectsKeepLimit(): void + { + $a = 'hex2bin:' . str_repeat('a', 64); + $b = 'hex2bin:' . str_repeat('b', 64); + $c = 'hex2bin:' . str_repeat('c', 64); + $this->seedEnv(self::SEED_KEY, "{$a},{$b},{$c}"); + + command('key:rotate --force --keep=2'); + + $this->assertSame( + self::SEED_KEY . ",{$a}", + env('encryption.previousKeys'), + ); + $contents = (string) file_get_contents($this->envPath); + $this->assertStringNotContainsString($b, $contents); + $this->assertStringNotContainsString($c, $contents); + } + + public function testRotateRespectsKeepLimitOfOne(): void + { + $older = 'hex2bin:' . str_repeat('a', 64); + $oldest = 'hex2bin:' . str_repeat('b', 64); + $this->seedEnv(self::SEED_KEY, "{$older},{$oldest}"); + + command('key:rotate --force --keep=1'); + + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + $contents = (string) file_get_contents($this->envPath); + $this->assertStringNotContainsString($older, $contents); + $this->assertStringNotContainsString($oldest, $contents); + } + + public function testRotateErrorsWhenNoCurrentKey(): void + { + file_put_contents($this->envPath, "# encryption.key =\n"); + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + + command('key:rotate --force'); + + $this->assertSame( + <<<'EOT' + + No existing `encryption.key` to rotate. Run `spark key:generate` first. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertStringNotContainsString('encryption.previousKeys', (string) file_get_contents($this->envPath)); + } + + public function testRotateAbortsWhenOverwritePromptIsDeclined(): void + { + $this->seedEnv(self::SEED_KEY); + + $io = new MockInputOutput(); + $io->setInputs(['n']); + CLI::setInputOutput($io); + + command('key:rotate'); + + $this->assertSame( + <<<'EOT' + Rotate encryption key? The current key will be moved to `previousKeys`. [n, y]: n + Key rotation cancelled. + + EOT, + self::getUndecoratedIoOutput($io), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + $this->assertStringContainsString(self::SEED_KEY, (string) file_get_contents($this->envPath)); + } + + public function testRotateOverwritesWhenOverwritePromptIsConfirmed(): void + { + $this->seedEnv(self::SEED_KEY); + + $io = new MockInputOutput(); + $io->setInputs(['y']); + CLI::setInputOutput($io); + + command('key:rotate --prefix base64'); + + $this->assertSame( + <<<'EOT' + Rotate encryption key? The current key will be moved to `previousKeys`. [n, y]: y + Encryption key rotated. 1 previous key retained for decryption fallback. + Re-encrypt existing data with the new key when ready. + + EOT, + self::getUndecoratedIoOutput($io), + ); + $this->assertNotSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotateAbortsNonInteractivelyAndHintsAboutForceFlag(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --no-interaction'); + + $this->assertSame( + <<<'EOT' + + Key rotation aborted. + If you want, use the "--force" option to force the rotation. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + } + + public function testRotateWithBase64Prefix(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --prefix base64 --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression('/^encryption\.key = base64:[A-Za-z0-9+\/]+={0,2}$/m', $contents); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotateErrorsOnInvalidPrefixNonInteractively(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --prefix invalid --no-interaction'); + + $this->assertSame( + <<<'EOT' + + Invalid prefix "invalid". Use either "hex2bin" or "base64". + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + } + + public function testRotateInteractRePromptsForInvalidPrefix(): void + { + $this->seedEnv(self::SEED_KEY); + + $io = new MockInputOutput(); + // First input answers the invalid-prefix recovery prompt; second answers the rotate confirmation. + $io->setInputs(['base64', 'y']); + CLI::setInputOutput($io); + + command('key:rotate --prefix invalid'); + + $output = self::getUndecoratedIoOutput($io); + $this->assertStringContainsString('Please provide a valid prefix to use. [hex2bin, base64]: base64', $output); + $this->assertStringContainsString('Encryption key rotated. 1 previous key retained for decryption fallback.', $output); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression('/^encryption\.key = base64:[A-Za-z0-9+\/]+={0,2}$/m', $contents); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotateInteractSkipsConfirmationWhenNoCurrentKey(): void + { + file_put_contents($this->envPath, "# encryption.key =\n"); + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + + // No MockInputOutput inputs are set; if interact() reached the rotate prompt it would + // throw `LogicException('No input data...')` from `MockInputOutput::input()`. + $io = new MockInputOutput(); + CLI::setInputOutput($io); + + command('key:rotate'); + + $this->assertSame( + <<<'EOT' + + No existing `encryption.key` to rotate. Run `spark key:generate` first. + + EOT, + self::getUndecoratedIoOutput($io), + ); + } + + public function testRotateRejectsNegativeKeepValue(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --force --keep=-1'); + + $this->assertSame( + <<<'EOT' + + The --keep option must be a non-negative integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + } + + public function testRotateRejectsNonNumericKeepValue(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --force --keep=abc'); + + $this->assertSame( + <<<'EOT' + + The --keep option must be a non-negative integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + } + + public function testRotateErrorsWhenEnvFileIsNotWritable(): void + { + $this->seedEnv(self::SEED_KEY); + chmod($this->envPath, 0o444); + + try { + command('key:rotate --force'); + + $this->assertSame( + <<<'EOT' + + Error in writing `encryption.previousKeys` to `.env` file. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + } finally { + chmod($this->envPath, 0o644); + } + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 29e3aba1acfb..32941646a6d2 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -193,6 +193,9 @@ Commands When used with ``CLI::getOption()``, an array option will return its last value (for example, in this case, ``value2``). To retrieve all values for an array option, use ``CLI::getRawOption()``. - Likewise, the ``command()`` function now also supports the above enhancements for command-line option parsing when using the function to run commands from code. - Added ``make:request`` generator command to scaffold :ref:`Form Request ` classes. +- Added ``key:rotate`` command to demote the current ``encryption.key`` to ``encryption.previousKeys`` in **.env** and generate a new key. See :ref:`spark-key-rotate`. +- Added :php:class:`NullInputOutput `, an :php:class:`InputOutput ` sink that discards all writes and returns an empty string from ``input()``. +- Added ``CLI::getInputOutput()`` (``@internal``) as a getter symmetric to ``CLI::setInputOutput()`` and ``CLI::resetInputOutput()``. Testing ======= diff --git a/user_guide_src/source/libraries/encryption.rst b/user_guide_src/source/libraries/encryption.rst index bbdfa1e053c9..8051baa5d6a2 100644 --- a/user_guide_src/source/libraries/encryption.rst +++ b/user_guide_src/source/libraries/encryption.rst @@ -226,6 +226,29 @@ Key Rotation Workflow operations always use the current ``key``. If you pass an explicit key via the ``$params`` argument to ``encrypt()`` or ``decrypt()``, the previousKeys fallback will not be used. +.. _spark-key-rotate: + +Rotating with the ``key:rotate`` Command +---------------------------------------- + +.. versionadded:: 4.8.0 + +Step 2 above (demoting the current ``key`` and generating a new one) can be performed with the +``key:rotate`` spark command, which edits the **.env** file in place:: + + php spark key:rotate + +The command reads ``encryption.key`` from your environment, prepends it to +``encryption.previousKeys`` (newest first, deduplicated), and writes a fresh ``encryption.key``. +Useful options: + +- ``--prefix`` (``hex2bin`` or ``base64``, default ``hex2bin``) and ``--length`` (default ``32``) + control how the new key is generated, mirroring ``key:generate``. +- ``--keep=N`` caps the retained ``previousKeys`` list to the ``N`` most recent entries. ``N`` must + be a non-negative integer; ``0`` (the default) keeps every previous key. +- ``--force`` / ``-f`` skips the interactive confirmation. Required when running with + ``--no-interaction``. + Padding =======