Files
opensourcepos/app/Commands/EnvProvision.php
T
jekkos 8821d29255 fix(security): abort on backup/read/saveAll failure to avoid data loss
Three related data-integrity fixes:

- backupEnvFile() now returns true/false based on whether the backup
  actually exists and is readable. rotateEncryptionKey() aborts before
  destroying the key when the backup could not be written to disk.

- rotateEncryptionKey() and provisionThrottleKey() throw
  RuntimeException(Error.unable_to_read_env_file) when the .env read
  fails, instead of silently replacing the whole file with an empty
  string. This prevents a permission error from wiping all keys.

- checkEncryption() and EnvProvision::run() now both roll back to the
  backup with abortEncryptionConversion() when the post-rotation
  saveAll() throws, matching the migration path (which already did this).
  A failing fake Appconfig is used to exercise this in the new
  testCheckEncryptionRollsBackWhenSaveAllFails test.
2026-09-14 13:55:40 +00:00

127 lines
3.7 KiB
PHP

<?php
namespace App\Commands;
use App\Libraries\CI3SecretConverter;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
use Exception;
use RuntimeException;
/**
* Idempotent startup provisioning of the encryption + throttle keys.
*
* In bare-metal deployments the web runtime can auto-provision keys inline
* (see checkEncryption()/checkThrottleEncryption(), gated by .env
* writability). In Docker/Compose deployments .env is typically mounted
* read-only, so keys are minted here at container start via
* `php spark env:provision`. For legacy CI3 deployments this command
* also re-encrypts stored CI3 secrets to the CI4 cipher (shared
* implementation with the interactive ConvertToCI4 migration).
*/
class EnvProvision extends BaseCommand
{
/**
* The command's group.
*
* @var string
*/
protected $group = 'Environment';
protected $name = 'env:provision';
protected $usage = 'env:provision';
protected $description = 'Ensures the encryption and throttle keys are provisioned and converts any legacy CI3-encrypted secrets.';
public function run(array $params): void
{
helper('security');
$throttleKey = provisionThrottleKey();
CLI::write('throttle.key : ' . ($throttleKey !== '' ? 'present' : 'MISSING'), 'green');
$encryptionConfig = config('Encryption');
$key = (string) ($encryptionConfig->key ?? '');
if ($key !== '' && strlen($key) >= 64) {
CLI::write('encryption.key : CI4 key already present', 'green');
CLI::newLine();
return;
}
$converter = new CI3SecretConverter();
if ($key !== '' && strlen($key) < 64) {
$plain = $converter->decryptAll($key);
$hasData = $this->anyNonEmpty($plain);
rotateEncryptionKey($key);
CLI::write('encryption.key : rotated CI3 -> CI4 key', 'green');
$encrypted = $converter->encryptAll($plain);
if ($hasData && array_diff_assoc($plain, $converter->verifyAll($encrypted)) !== []) {
abortEncryptionConversion();
throw new RuntimeException('Failed to verify converted encryption data.');
}
if ($hasData) {
try {
$converter->saveAll($encrypted);
} catch (RuntimeException $e) {
abortEncryptionConversion();
throw $e;
}
CLI::write('legacy secrets : converted and verified to CI4 cipher', 'green');
}
} else {
rotateEncryptionKey(null);
CLI::write('encryption.key : new CI4 key generated', 'green');
if ($this->legacySecretsPresent()) {
CLI::write('legacy secrets : WARNING - stored CI3 secrets found but no CI3 key to decrypt them; they could not be recovered', 'yellow');
}
}
CLI::newLine();
CLI::write('env:provision complete.', 'green');
CLI::newLine();
}
private function anyNonEmpty(array $plain): bool
{
foreach ($plain as $value) {
if ((string) $value !== '') {
return true;
}
}
return false;
}
private function legacySecretsPresent(): bool
{
try {
$appConfig = model('Appconfig');
} catch (Exception $e) {
return false;
}
foreach (CI3SecretConverter::LEGACY_KEYS as $col) {
try {
if ($appConfig->get_value($col) !== '') {
return true;
}
} catch (Exception $e) {
return false;
}
}
return false;
}
}