diff --git a/system/Commands/Encryption/RotateKey.php b/system/Commands/Encryption/RotateKey.php new file mode 100644 index 000000000000..ade51d6f12e0 --- /dev/null +++ b/system/Commands/Encryption/RotateKey.php @@ -0,0 +1,277 @@ + + * + * 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 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::write('Key rotation cancelled.', 'yellow'); + + return EXIT_SUCCESS; + } + + CLI::error('Key rotation aborted: pass --force to rotate the encryption key in non-interactive mode.'); + + return EXIT_ERROR; + } + + $keep = $options['keep']; + + if (! is_string($keep) || ! ctype_digit($keep)) { + CLI::error('The --keep option must be a non-negative integer.'); + + return EXIT_ERROR; + } + + $length = $options['length']; + + if (! is_string($length) || ! ctype_digit($length) || (int) $length < 1) { + CLI::error('The --length option must be a positive 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). + $envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property + + if (! is_file($envFile)) { + CLI::error(sprintf('Cannot rotate: `.env` file not found at %s.', clean_path($envFile))); + + return EXIT_ERROR; + } + + if (! is_writable($envFile)) { + CLI::error(sprintf('Cannot rotate: `.env` file at %s is not writable.', clean_path($envFile))); + + return EXIT_ERROR; + } + + if (! $this->writePreviousKeys($previousKeys, $envFile)) { + // @codeCoverageIgnoreStart + CLI::error(sprintf('Failed to write `encryption.previousKeys` to %s.', clean_path($envFile))); + + return EXIT_ERROR; + // @codeCoverageIgnoreEnd + } + + // 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->callSilently('key:generate', options: [ + 'force' => null, + 'prefix' => $prefix, + 'length' => $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; + } + + /** + * 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. + * The caller is responsible for ensuring `$envFile` exists and is writable; + * `key:generate` handles the `encryption.key` line. + * + * @param list $previousKeys + */ + private function writePreviousKeys(array $previousKeys, string $envFile): bool + { + $contents = (string) file_get_contents($envFile); + $value = implode(',', $previousKeys); + + // Match an actual setting line, not a substring buried in a comment. The optional + // `export` prefix mirrors what DotEnv accepts. + $previousKeysPattern = '/^(\h*(?:export\h+)?encryption\.previousKeys\h*=\h*)[^\r\n]*$/m'; + + if (preg_match($previousKeysPattern, $contents) === 1) { + $contents = (string) preg_replace($previousKeysPattern, '$1' . $value, $contents, 1); + + return file_put_contents($envFile, $contents) !== false; + } + + // Insert right after the `encryption.key` line so the two stay grouped. + $injected = (string) preg_replace( + '/^(\h*(?:export\h+)?encryption\.key\h*=\h*[^\r\n]*)$/m', + "$1\nencryption.previousKeys = {$value}", + $contents, + 1, + ); + + if ($injected === $contents) { + // Fallback: append to the end. Reachable only when `encryption.key` + // is set via a non-`.env` source (e.g., server config / `putenv()`), + // so the regex cannot find it as a line in the file. + $injected = $contents . "\nencryption.previousKeys = {$value}"; // @codeCoverageIgnore + } + + return file_put_contents($envFile, $injected) !== false; + } +} diff --git a/tests/system/Commands/Encryption/RotateKeyTest.php b/tests/system/Commands/Encryption/RotateKeyTest.php new file mode 100644 index 000000000000..2dda2fabcda5 --- /dev/null +++ b/tests/system/Commands/Encryption/RotateKeyTest.php @@ -0,0 +1,590 @@ + + * + * 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\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\RequiresOperatingSystem; +use PHPUnit\Framework\Attributes\WithoutErrorHandler; + +/** + * @internal + */ +#[CoversClass(RotateKey::class)] +#[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 testRotateCancelsWhenOverwritePromptIsDeclined(): 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 testRotateAbortsNonInteractively(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --no-interaction'); + + $this->assertSame( + <<<'EOT' + + Key rotation aborted: pass --force to rotate the encryption key in non-interactive mode. + + 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 testRotateRejectsFractionalKeepValue(): void + { + $this->seedEnv(self::SEED_KEY); + + command('key:rotate --force --keep=3.5'); + + $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 testRotateRejectsNegativeLengthValue(): void + { + $this->seedEnv(self::SEED_KEY); + $envContentsBefore = (string) file_get_contents($this->envPath); + + command('key:rotate --force --length=-1'); + + $this->assertSame( + <<<'EOT' + + The --length option must be a positive integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame( + $envContentsBefore, + (string) file_get_contents($this->envPath), + 'Validation must reject the run before any .env mutation.', + ); + } + + public function testRotateRejectsZeroLengthValue(): void + { + $this->seedEnv(self::SEED_KEY); + $envContentsBefore = (string) file_get_contents($this->envPath); + + command('key:rotate --force --length=0'); + + $this->assertSame( + <<<'EOT' + + The --length option must be a positive integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame($envContentsBefore, (string) file_get_contents($this->envPath)); + } + + public function testRotateRejectsNonNumericLengthValue(): void + { + $this->seedEnv(self::SEED_KEY); + $envContentsBefore = (string) file_get_contents($this->envPath); + + command('key:rotate --force --length=abc'); + + $this->assertSame( + <<<'EOT' + + The --length option must be a positive integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame($envContentsBefore, (string) file_get_contents($this->envPath)); + } + + public function testRotateRejectsFractionalLengthValue(): void + { + $this->seedEnv(self::SEED_KEY); + $envContentsBefore = (string) file_get_contents($this->envPath); + + command('key:rotate --force --length=3.5'); + + $this->assertSame( + <<<'EOT' + + The --length option must be a positive integer. + + EOT, + $this->getUndecoratedBuffer(), + ); + $this->assertSame(self::SEED_KEY, env('encryption.key')); + $this->assertSame($envContentsBefore, (string) file_get_contents($this->envPath)); + } + + public function testRotateErrorsWhenEnvFileIsMissing(): void + { + // No seedEnv() call: `.env` is absent. Populate the env var directly so + // the up-front `encryption.key` existence check passes. + putenv('encryption.key=' . self::SEED_KEY); + $_ENV['encryption.key'] = self::SEED_KEY; + + command('key:rotate --force'); + + $this->assertStringContainsString('Cannot rotate: `.env` file not found at', $this->getUndecoratedBuffer()); + $this->assertFileDoesNotExist($this->envPath, 'No `.env` file should have been created.'); + } + + #[RequiresOperatingSystem('Linux|Darwin')] + public function testRotateErrorsWhenEnvFileIsNotWritable(): void + { + $this->seedEnv(self::SEED_KEY); + $envContentsBefore = (string) file_get_contents($this->envPath); + chmod($this->envPath, 0o444); + + try { + command('key:rotate --force'); + + $output = $this->getUndecoratedBuffer(); + $this->assertStringContainsString('Cannot rotate: `.env` file at', $output); + $this->assertStringContainsString('is not writable', $output); + $this->assertSame($envContentsBefore, (string) file_get_contents($this->envPath)); + } finally { + chmod($this->envPath, 0o644); + } + } + + public function testRotateIgnoresCommentMentioningPreviousKeysWhenInserting(): void + { + $envContents = "# encryption.previousKeys is for decryption fallback\nencryption.key = " . self::SEED_KEY . "\n"; + file_put_contents($this->envPath, $envContents); + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + + command('key:rotate --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression( + '/^encryption\.previousKeys = ' . preg_quote(self::SEED_KEY, '/') . '$/m', + $contents, + 'A real `encryption.previousKeys` setting must be written even when a comment mentions the name.', + ); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } + + public function testRotateReplacesPreviousKeysLineWithExportPrefix(): void + { + $existing = 'hex2bin:' . str_repeat('a', 64); + $envContents = 'encryption.key = ' . self::SEED_KEY . "\nexport encryption.previousKeys = {$existing}\n"; + file_put_contents($this->envPath, $envContents); + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + + command('key:rotate --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression( + '/^export encryption\.previousKeys = ' . preg_quote(self::SEED_KEY . ',' . $existing, '/') . '$/m', + $contents, + 'The existing `export` prefix should be preserved and the value rewritten.', + ); + $this->assertSame( + self::SEED_KEY . ',' . $existing, + env('encryption.previousKeys'), + ); + } + + public function testRotateInsertsAfterExportPrefixedEncryptionKey(): void + { + $envContents = 'export encryption.key = ' . self::SEED_KEY . "\n"; + file_put_contents($this->envPath, $envContents); + $this->resetEnvironment(); + (new DotEnv(ROOTPATH))->load(); + + command('key:rotate --force'); + + $contents = (string) file_get_contents($this->envPath); + $this->assertMatchesRegularExpression( + '/^export encryption\.key = hex2bin:[a-f0-9]{64}\nencryption\.previousKeys = ' . preg_quote(self::SEED_KEY, '/') . '$/m', + $contents, + '`encryption.previousKeys` should be inserted on the line directly after an `export`-prefixed `encryption.key`.', + ); + $this->assertSame(self::SEED_KEY, env('encryption.previousKeys')); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 756dc5620860..7a8e3934070e 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -196,6 +196,7 @@ 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 ``AbstractCommand::callSilently()`` to invoke another command with its output discarded, restoring the prior IO afterwards. See :ref:`modern-commands-call-silently`. - Added :php:class:`NullInputOutput `, an :php:class:`InputOutput ` sink that discards all writes and returns an empty string from ``input()``. diff --git a/user_guide_src/source/libraries/encryption.rst b/user_guide_src/source/libraries/encryption.rst index bbdfa1e053c9..3d9da139dce1 100644 --- a/user_guide_src/source/libraries/encryption.rst +++ b/user_guide_src/source/libraries/encryption.rst @@ -226,6 +226,36 @@ 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`` (positive + integer, 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``. + +All three options are validated up-front, so an invalid value cannot leave the **.env** file +half-rotated. + +.. warning:: ``key:rotate`` is not safe under concurrent execution. The command edits the + **.env** file without taking a file lock, so two operators (or two automation runs) + rotating at the same time can lose rotated-out keys. + Padding =======