Skip to content

feat: add key:rotate command#10174

Open
paulbalandan wants to merge 3 commits intocodeigniter4:4.8from
paulbalandan:key-rotate
Open

feat: add key:rotate command#10174
paulbalandan wants to merge 3 commits intocodeigniter4:4.8from
paulbalandan:key-rotate

Conversation

@paulbalandan
Copy link
Copy Markdown
Member

@paulbalandan paulbalandan commented May 7, 2026

Description
Adds a key:rotate spark command that demotes the current encryption.key to encryption.previousKeys in .env and generates a fresh key, so existing ciphertext stays decryptable via the KeyRotationDecorator fallback.

The actual key write is delegated to key:generate to avoid duplicating that logic. All inputs (--prefix, --length, --keep) are validated up-front, before any .env mutation, so an invalid value cannot leave the file half-rotated. Once validation passes, previousKeys is written first; if key:generate were ever to fail afterwards, the worst case is a stale-but-decryptable .env rather than a key-loss event. The replace/insert path uses anchored regexes that recognize DotEnv's optional export prefix and ignore comments mentioning the setting name.

Options: --force / -f, --prefix, --length, --keep=N.

The IO-swap pattern used to silence the key:generate sub-command was extracted to a reusable AbstractCommand::callSilently() helper in #10177 (per @michalsn's review); this branch will be rebased onto that once it lands.

Checklist:

  • Securely signed commits
  • Component(s) with PHPDoc blocks, only if necessary or adds value (without duplication)
  • Unit testing, with >80% coverage
  • User guide updated
  • Conforms to style guide

@github-actions github-actions Bot added the 4.8 PRs that target the `4.8` branch. label May 7, 2026
Copy link
Copy Markdown
Contributor

@memleakd memleakd left a comment

Choose a reason for hiding this comment

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

Thanks for working on this. I left a few inline questions about .env rewrite edge cases.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added the upfront validation.

$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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added support for export.

Comment on lines +166 to +189
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();
}
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we may need these types of calls again in the future. Maybe we could add a reusable helper, like callSilently(), so this IO-swap/restore logic doesn't need to be repeated?

Like:

protected function callSilently(
    string $command,
    array $arguments = [],
    array $options = [],
    ?bool $noInteractionOverride = true,
): int {
    // swap IO, call(), restore IO
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added in #10177

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.8 PRs that target the `4.8` branch.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants