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
164 changes: 78 additions & 86 deletions system/Commands/Encryption/GenerateKey.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,88 +13,108 @@

namespace CodeIgniter\Commands\Encryption;

use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\AbstractCommand;
use CodeIgniter\CLI\Attributes\Command;
use CodeIgniter\CLI\CLI;
use CodeIgniter\CLI\Input\Option;
use CodeIgniter\Config\DotEnv;
use CodeIgniter\Encryption\Encryption;
use Config\Paths;

/**
* Generates a new encryption key.
* Generates a new encryption key and writes it in an `.env` file.
*/
class GenerateKey extends BaseCommand
#[Command(name: 'key:generate', description: 'Generates a new encryption key and writes it in an `.env` file.', group: 'Encryption')]
class GenerateKey extends AbstractCommand
{
/**
* The Command's group.
*
* @var string
* @var list<string>
*/
protected $group = 'Encryption';
private const VALID_PREFIXES = ['hex2bin', 'base64'];

/**
* The Command's name.
*
* @var string
*/
protected $name = 'key:generate';
protected function configure(): void
{
$this
->addOption(new Option(
name: 'force',
shortcut: 'f',
description: 'Force overwrite existing key in `.env` file.',
))
->addOption(new Option(
name: 'length',
description: 'The length of the random string that should be returned in bytes.',
requiresValue: true,
default: '32',
))
->addOption(new Option(
name: 'prefix',
description: 'Prefix to prepend to encoded key (either hex2bin or base64).',
requiresValue: true,
default: 'hex2bin',
))
->addOption(new Option(
name: 'show',
description: 'Shows the generated key in the terminal instead of storing in the `.env` file.',
));
}

/**
* The Command's usage.
*
* @var string
*/
protected $usage = 'key:generate [options]';
protected function interact(array &$arguments, array &$options): void
{
$prefix = $this->getUnboundOption('prefix', $options);

/**
* The Command's short description.
*
* @var string
*/
protected $description = 'Generates a new encryption key and writes it in an `.env` file.';
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');
}

/**
* The command's options
*
* @var array<string, string>
*/
protected $options = [
'--force' => 'Force overwrite existing key in `.env` file.',
'--length' => 'The length of the random string that should be returned in bytes. Defaults to 32.',
'--prefix' => 'Prefix to prepend to encoded key (either hex2bin or base64). Defaults to hex2bin.',
'--show' => 'Shows the generated key in the terminal instead of storing in the `.env` file.',
];
if ($this->hasUnboundOption('show', $options)) {
return;
}

/**
* Actually execute the command.
*/
public function run(array $params)
{
$prefix = $params['prefix'] ?? CLI::getOption('prefix');
if ($this->hasUnboundOption('force', $options)) {
return;
}

if (in_array($prefix, [null, true], true)) {
$prefix = 'hex2bin';
} elseif (! in_array($prefix, ['hex2bin', 'base64'], true)) {
$prefix = CLI::prompt('Please provide a valid prefix to use.', ['hex2bin', 'base64'], 'required'); // @codeCoverageIgnore
if (env('encryption.key', '') === '') {
return;
}

$length = $params['length'] ?? CLI::getOption('length');
if (CLI::prompt('Overwrite existing key?', ['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($length, [null, true], true)) {
$length = 32;
if (! in_array($prefix, self::VALID_PREFIXES, true)) {
CLI::error(sprintf('Invalid prefix "%s". Use either "hex2bin" or "base64".', $prefix));

return EXIT_ERROR;
}

$encodedKey = $this->generateRandomKey($prefix, $length);
$encodedKey = $this->generateRandomKey($prefix, (int) $options['length']);

if (array_key_exists('show', $params) || (bool) CLI::getOption('show')) {
if ($options['show'] === true) {
CLI::write($encodedKey, 'yellow');
CLI::newLine();

return EXIT_SUCCESS;
}

if (! $this->setNewEncryptionKey($encodedKey, $params)) {
CLI::write('Error in setting new encryption key to .env file.', 'light_gray', 'red');
CLI::newLine();
$currentKey = env('encryption.key', '');

if ($currentKey !== '' && $options['force'] === false) {
CLI::error('Setting new encryption key aborted.');

if (! $this->isInteractive()) {
CLI::error('If you want, use the "--force" option to force overwrite the existing key.');
}

return EXIT_ERROR;
}

if (! $this->writeNewEncryptionKeyToFile($currentKey, $encodedKey)) {
CLI::write('Error in setting new encryption key to .env file.');

return EXIT_ERROR;
}
Expand All @@ -114,7 +134,7 @@ public function run(array $params)
/**
* Generates a key and encodes it.
*/
protected function generateRandomKey(string $prefix, int $length): string
private function generateRandomKey(string $prefix, int $length): string
{
$key = Encryption::createKey($length);

Expand All @@ -125,37 +145,10 @@ protected function generateRandomKey(string $prefix, int $length): string
return 'base64:' . base64_encode($key);
}

/**
* Sets the new encryption key in your .env file.
*
* @param array<int|string, string|null> $params
*/
protected function setNewEncryptionKey(string $key, array $params): bool
{
$currentKey = env('encryption.key', '');

if ($currentKey !== '' && ! $this->confirmOverwrite($params)) {
// Not yet testable since it requires keyboard input
return false; // @codeCoverageIgnore
}

return $this->writeNewEncryptionKeyToFile($currentKey, $key);
}

/**
* Checks whether to overwrite existing encryption key.
*
* @param array<int|string, string|null> $params
*/
protected function confirmOverwrite(array $params): bool
{
return (array_key_exists('force', $params) || CLI::getOption('force')) || CLI::prompt('Overwrite existing key?', ['n', 'y']) === 'y';
}

/**
* Writes the new encryption key to .env file.
*/
protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): bool
private function writeNewEncryptionKeyToFile(string $oldKey, string $newKey): bool
{
$baseEnv = ROOTPATH . 'env';
$envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property
Expand All @@ -164,7 +157,6 @@ protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey):
if (! is_file($baseEnv)) {
CLI::write('Both default shipped `env` file and custom `.env` are missing.', 'yellow');
CLI::write('Here\'s your new key instead: ' . CLI::color($newKey, 'yellow'));
CLI::newLine();

return false;
}
Expand Down Expand Up @@ -195,7 +187,7 @@ protected function writeNewEncryptionKeyToFile(string $oldKey, string $newKey):
/**
* Get the regex of the current encryption key.
*/
protected function keyPattern(string $oldKey): string
private function keyPattern(string $oldKey): string
{
$escaped = preg_quote($oldKey, '/');

Expand Down
100 changes: 100 additions & 0 deletions tests/system/Commands/Encryption/GenerateKeyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@

namespace CodeIgniter\Commands\Encryption;

use CodeIgniter\CLI\CLI;
use CodeIgniter\Config\Services;
use CodeIgniter\Superglobals;
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\Filters\CITestStreamFilter;
use CodeIgniter\Test\Mock\MockInputOutput;
use CodeIgniter\Test\StreamFilterTrait;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
Expand All @@ -39,6 +41,7 @@ protected function setUp(): void
{
parent::setUp();

CLI::resetLastWrite();
Services::injectMock('superglobals', new Superglobals());

$this->envPath = ROOTPATH . '.env';
Expand All @@ -62,6 +65,9 @@ protected function tearDown(): void
}

$this->resetEnvironment();

CLI::resetLastWrite();
CLI::reset();
}

/**
Expand Down Expand Up @@ -169,4 +175,98 @@ public function testKeyGenerateWhenNewBase64KeyIsSubsequentlyCommentedOut(): voi
$this->assertStringContainsString('was successfully set.', $this->getBuffer());
$this->assertNotSame($key, env('encryption.key', $key), 'Failed replacing the commented out key.');
}

/**
* Simulates a stale env cache: the `.env` file has a valid key, but
* `env('encryption.key')` resolves to '' because nothing has loaded it
* into the superglobals. The primary regex (built from `oldKey`) cannot
* locate the line, so the fallback regex must replace the existing entry.
*/
public function testKeyGenerateReplacesUnloadedKeyInDotEnvFile(): void
{
$existingKey = 'hex2bin:' . str_repeat('a', 64);
file_put_contents($this->envPath, "encryption.key = {$existingKey}\n");

$this->assertSame('', env('encryption.key', ''));

command('key:generate --force');

$this->assertStringContainsString('was successfully set.', $this->getBuffer());

$contents = (string) file_get_contents($this->envPath);
$this->assertStringNotContainsString($existingKey, $contents);
$this->assertStringContainsString('encryption.key = ' . env('encryption.key'), $contents);
}

public function testKeyGenerateAbortsWhenOverwritePromptIsDeclined(): void
{
command('key:generate');
$key = env('encryption.key', '');
$this->assertNotSame('', $key);

$io = new MockInputOutput();
$io->setInputs(['n']);
CLI::setInputOutput($io);

command('key:generate');

$this->assertSame($key, env('encryption.key', ''), 'Existing key should not change.');
$this->assertStringContainsString($key, (string) file_get_contents($this->envPath));
$this->assertStringContainsString('Overwrite existing key?', $io->getOutput());
$this->assertStringContainsString('Setting new encryption key aborted.', $io->getOutput());
}

public function testKeyGenerateOverwritesWhenOverwritePromptIsConfirmed(): void
{
command('key:generate');
$oldKey = env('encryption.key', '');
$this->assertNotSame('', $oldKey);

$io = new MockInputOutput();
$io->setInputs(['y']);
CLI::setInputOutput($io);

command('key:generate --prefix base64');

$this->assertNotSame($oldKey, env('encryption.key', $oldKey));
$this->assertStringContainsString('base64:', (string) file_get_contents($this->envPath));
$this->assertStringContainsString('Overwrite existing key?', $io->getOutput());
$this->assertStringContainsString('successfully set.', $io->getOutput());
}

#[PreserveGlobalState(false)]
#[RunInSeparateProcess]
public function testKeyGenerateAbortsNonInteractivelyWithExistingKey(): void
{
command('key:generate');
$key = env('encryption.key', '');
$this->assertNotSame('', $key);

$this->resetStreamFilterBuffer();

command('key:generate --no-interaction');

$this->assertSame($key, env('encryption.key', ''), 'Existing key should not change.');
$this->assertStringContainsString('Setting new encryption key aborted.', $this->getBuffer());
$this->assertStringContainsString('--force', $this->getBuffer());
}

public function testKeyGenerateErrorsOnInvalidPrefixNonInteractively(): void
{
command('key:generate --prefix invalid --show --no-interaction');

$this->assertStringContainsString('Invalid prefix "invalid"', $this->getBuffer());
}

public function testKeyGeneratePromptsForInvalidPrefix(): void
{
$io = new MockInputOutput();
$io->setInputs(['hex2bin']);
CLI::setInputOutput($io);

command('key:generate --prefix invalid --show');

$this->assertStringContainsString('Please provide a valid prefix to use.', $io->getOutput());
$this->assertStringContainsString('hex2bin:', $io->getOutput());
}
}
Loading