Skip to content

Commit ee18e56

Browse files
committed
address reviews
1 parent 2a228cd commit ee18e56

3 files changed

Lines changed: 98 additions & 39 deletions

File tree

system/Commands/Encryption/RotateKey.php

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -115,15 +115,15 @@ protected function execute(array $arguments, array $options): int
115115

116116
$keep = $options['keep'];
117117

118-
if (! is_numeric($keep) || (int) $keep < 0) {
118+
if (! is_string($keep) || ! ctype_digit($keep)) {
119119
CLI::error('The --keep option must be a non-negative integer.');
120120

121121
return EXIT_ERROR;
122122
}
123123

124124
$length = $options['length'];
125125

126-
if (! is_numeric($length) || (int) $length < 1) {
126+
if (! is_string($length) || ! ctype_digit($length) || (int) $length < 1) {
127127
CLI::error('The --length option must be a positive integer.');
128128

129129
return EXIT_ERROR;
@@ -136,10 +136,24 @@ protected function execute(array $arguments, array $options): int
136136
// key is preserved on disk).
137137
$envFile = ((new Paths())->envDirectory ?? ROOTPATH) . '.env'; // @phpstan-ignore nullCoalesce.property
138138

139+
if (! is_file($envFile)) {
140+
CLI::error(sprintf('Cannot rotate: `.env` file not found at %s.', clean_path($envFile)));
141+
142+
return EXIT_ERROR;
143+
}
144+
145+
if (! is_writable($envFile)) {
146+
CLI::error(sprintf('Cannot rotate: `.env` file at %s is not writable.', clean_path($envFile)));
147+
148+
return EXIT_ERROR;
149+
}
150+
139151
if (! $this->writePreviousKeys($previousKeys, $envFile)) {
152+
// @codeCoverageIgnoreStart
140153
CLI::error(sprintf('Failed to write `encryption.previousKeys` to %s.', clean_path($envFile)));
141154

142155
return EXIT_ERROR;
156+
// @codeCoverageIgnoreEnd
143157
}
144158

145159
// Clear `encryption.previousKeys` from all env sources so the DotEnv
@@ -223,21 +237,13 @@ private function mergePreviousKeys(string $currentKey, array $existing, int $kee
223237

224238
/**
225239
* Replaces or inserts the `encryption.previousKeys` line in the `.env` file.
226-
* `key:generate` is responsible for the file's existence and the
227-
* `encryption.key` line; this method only touches `encryption.previousKeys`.
240+
* The caller is responsible for ensuring `$envFile` exists and is writable;
241+
* `key:generate` handles the `encryption.key` line.
228242
*
229243
* @param list<string> $previousKeys
230244
*/
231245
private function writePreviousKeys(array $previousKeys, string $envFile): bool
232246
{
233-
if (! is_file($envFile)) {
234-
return false; // @codeCoverageIgnore
235-
}
236-
237-
if (! is_writable($envFile)) {
238-
return false;
239-
}
240-
241247
$contents = (string) file_get_contents($envFile);
242248
$value = implode(',', $previousKeys);
243249

@@ -260,8 +266,9 @@ private function writePreviousKeys(array $previousKeys, string $envFile): bool
260266
);
261267

262268
if ($injected === $contents) {
263-
// Fallback: append to the end. Shouldn't trigger because `key:generate`
264-
// writes the `encryption.key` line just before this method runs.
269+
// Fallback: append to the end. Reachable only when `encryption.key`
270+
// is set via a non-`.env` source (e.g., server config / `putenv()`),
271+
// so the regex cannot find it as a line in the file.
265272
$injected = $contents . "\nencryption.previousKeys = {$value}"; // @codeCoverageIgnore
266273
}
267274

tests/system/Commands/Encryption/RotateKeyTest.php

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@
2020
use CodeIgniter\Test\CIUnitTestCase;
2121
use CodeIgniter\Test\Mock\MockInputOutput;
2222
use CodeIgniter\Test\StreamFilterTrait;
23+
use PHPUnit\Framework\Attributes\CoversClass;
2324
use PHPUnit\Framework\Attributes\Group;
25+
use PHPUnit\Framework\Attributes\RequiresOperatingSystem;
2426
use PHPUnit\Framework\Attributes\WithoutErrorHandler;
2527

2628
/**
2729
* @internal
2830
*/
31+
#[CoversClass(RotateKey::class)]
2932
#[Group('Others')]
3033
final class RotateKeyTest extends CIUnitTestCase
3134
{
@@ -113,6 +116,7 @@ public function testRotateMovesCurrentKeyToPreviousKeysAndGeneratesNew(): void
113116

114117
$this->assertSame(
115118
<<<'EOT'
119+
116120
Encryption key rotated. 1 previous key retained for decryption fallback.
117121
Re-encrypt existing data with the new key when ready.
118122

@@ -140,6 +144,7 @@ public function testRotatePrependsToExistingPreviousKeysList(): void
140144

141145
$this->assertSame(
142146
<<<'EOT'
147+
143148
Encryption key rotated. 3 previous keys retained for decryption fallback.
144149
Re-encrypt existing data with the new key when ready.
145150

@@ -396,6 +401,23 @@ public function testRotateRejectsNonNumericKeepValue(): void
396401
$this->assertSame(self::SEED_KEY, env('encryption.key'));
397402
}
398403

404+
public function testRotateRejectsFractionalKeepValue(): void
405+
{
406+
$this->seedEnv(self::SEED_KEY);
407+
408+
command('key:rotate --force --keep=3.5');
409+
410+
$this->assertSame(
411+
<<<'EOT'
412+
413+
The --keep option must be a non-negative integer.
414+
415+
EOT,
416+
$this->getUndecoratedBuffer(),
417+
);
418+
$this->assertSame(self::SEED_KEY, env('encryption.key'));
419+
}
420+
399421
public function testRotateRejectsNegativeLengthValue(): void
400422
{
401423
$this->seedEnv(self::SEED_KEY);
@@ -457,6 +479,57 @@ public function testRotateRejectsNonNumericLengthValue(): void
457479
$this->assertSame($envContentsBefore, (string) file_get_contents($this->envPath));
458480
}
459481

482+
public function testRotateRejectsFractionalLengthValue(): void
483+
{
484+
$this->seedEnv(self::SEED_KEY);
485+
$envContentsBefore = (string) file_get_contents($this->envPath);
486+
487+
command('key:rotate --force --length=3.5');
488+
489+
$this->assertSame(
490+
<<<'EOT'
491+
492+
The --length option must be a positive integer.
493+
494+
EOT,
495+
$this->getUndecoratedBuffer(),
496+
);
497+
$this->assertSame(self::SEED_KEY, env('encryption.key'));
498+
$this->assertSame($envContentsBefore, (string) file_get_contents($this->envPath));
499+
}
500+
501+
public function testRotateErrorsWhenEnvFileIsMissing(): void
502+
{
503+
// No seedEnv() call: `.env` is absent. Populate the env var directly so
504+
// the up-front `encryption.key` existence check passes.
505+
putenv('encryption.key=' . self::SEED_KEY);
506+
$_ENV['encryption.key'] = self::SEED_KEY;
507+
508+
command('key:rotate --force');
509+
510+
$this->assertStringContainsString('Cannot rotate: `.env` file not found at', $this->getUndecoratedBuffer());
511+
$this->assertFileDoesNotExist($this->envPath, 'No `.env` file should have been created.');
512+
}
513+
514+
#[RequiresOperatingSystem('Linux|Darwin')]
515+
public function testRotateErrorsWhenEnvFileIsNotWritable(): void
516+
{
517+
$this->seedEnv(self::SEED_KEY);
518+
$envContentsBefore = (string) file_get_contents($this->envPath);
519+
chmod($this->envPath, 0o444);
520+
521+
try {
522+
command('key:rotate --force');
523+
524+
$output = $this->getUndecoratedBuffer();
525+
$this->assertStringContainsString('Cannot rotate: `.env` file at', $output);
526+
$this->assertStringContainsString('is not writable', $output);
527+
$this->assertSame($envContentsBefore, (string) file_get_contents($this->envPath));
528+
} finally {
529+
chmod($this->envPath, 0o644);
530+
}
531+
}
532+
460533
public function testRotateIgnoresCommentMentioningPreviousKeysWhenInserting(): void
461534
{
462535
$envContents = "# encryption.previousKeys is for decryption fallback\nencryption.key = " . self::SEED_KEY . "\n";
@@ -514,29 +587,4 @@ public function testRotateInsertsAfterExportPrefixedEncryptionKey(): void
514587
);
515588
$this->assertSame(self::SEED_KEY, env('encryption.previousKeys'));
516589
}
517-
518-
public function testRotateErrorsWhenEnvFileIsNotWritable(): void
519-
{
520-
$this->seedEnv(self::SEED_KEY);
521-
chmod($this->envPath, 0o444);
522-
523-
try {
524-
command('key:rotate --force');
525-
526-
$this->assertSame(
527-
sprintf(
528-
<<<'EOT'
529-
530-
Failed to write `encryption.previousKeys` to %s.
531-
532-
EOT,
533-
clean_path($this->envPath),
534-
),
535-
$this->getUndecoratedBuffer(),
536-
);
537-
$this->assertSame(self::SEED_KEY, env('encryption.key'));
538-
} finally {
539-
chmod($this->envPath, 0o644);
540-
}
541-
}
542590
}

user_guide_src/source/libraries/encryption.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,10 @@ Useful options:
252252
All three options are validated up-front, so an invalid value cannot leave the **.env** file
253253
half-rotated.
254254

255+
.. warning:: ``key:rotate`` is not safe under concurrent execution. The command edits the
256+
**.env** file without taking a file lock, so two operators (or two automation runs)
257+
rotating at the same time can lose rotated-out keys.
258+
255259
Padding
256260
=======
257261

0 commit comments

Comments
 (0)