Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions system/CLI/CLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -1176,8 +1182,6 @@ public static function setInputOutput(InputOutput $io): void
}

/**
* Testing purpose only
*
* @internal
*/
public static function resetInputOutput(): void
Expand Down
33 changes: 33 additions & 0 deletions system/CLI/NullInputOutput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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 '';
}
}
286 changes: 286 additions & 0 deletions system/Commands/Encryption/RotateKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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<string>
*/
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']);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could --length be validated before writing previousKeys? For example, a negative or zero value reaches key:generate, fails in key generation, and leaves .env partially modified even though rotation did not complete.


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<string>
*/
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<string> $existing
*
* @return list<string>
*/
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<string> $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')) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this treat comments or unrelated text containing encryption.previousKeys as an existing setting? If .env has a comment mentioning this key, the branch below runs, but the replacement regex may not match anything. The command can then continue and rotate encryption.key without actually writing the old key to 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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this also handle DotEnv's supported export encryption.previousKeys = ... syntax? As written, that line would make the earlier str_contains() check true, but this regex would not replace it, so the rotated-out key may not be persisted.

'/^[#\s]*encryption\.previousKeys[=\s]*[^\r\n]*$/m',
trim($replacement),
$contents,
);

return file_put_contents($envFile, $contents) !== false;
}
}
28 changes: 28 additions & 0 deletions tests/system/CLI/CLITest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading