Validate APP_KEY (#13171)

* Validate APP_KEY
key:rotate command to rotate keys, only rotates validation data for now

* fixes, swapped encrypters, not saving new value to db, check that key exists first

* add confirmation

* Option to generate new key, re-encrypt data and then save it to .env
A lot more text to try to prevent disaster.  Print out both keys 1-2 times.
Fix bug in EnvHelper (when key is commented but not empty)

* fix style

* oops, good phpstan
This commit is contained in:
Tony Murray
2021-08-27 22:48:21 -05:00
committed by GitHub
parent 1ec694595e
commit 9b8b1b814a
6 changed files with 223 additions and 7 deletions

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Console\Commands;
use App\Console\LnmsCommand;
use Artisan;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use LibreNMS\Util\EnvHelper;
use Symfony\Component\Console\Input\InputArgument;
class KeyRotate extends LnmsCommand
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'key:rotate';
/**
* @var Encrypter
*/
private $decrypt;
/**
* @var Encrypter
*/
private $encrypt;
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
$this->addArgument('old_key', InputArgument::OPTIONAL);
$this->addOption('generate-new-key');
}
/**
* Execute the console command.
*
* @return int
*/
public function handle(): int
{
$new = config('app.key');
$cipher = config('app.cipher');
$this->validate([
'generate-new-key' => [
'exclude_unless:old_key,null',
'boolean',
],
'old_key' => [
'exclude_if:generate-new-key,true',
'required',
'starts_with:base64:',
Rule::notIn([$new]),
],
]);
// check for cached config
if (is_file(base_path('bootstrap/cache/config.php'))) {
Artisan::call('config:clear'); // clear config cache
$this->warn(trans('commands.key:rotate.cleared-cache'));
return 0;
}
$old = $this->argument('old_key');
if ($this->option('generate-new-key')) {
$old = $new; // use key in env as existing key
$new = 'base64:' . base64_encode(
Encrypter::generateKey($this->laravel['config']['app.cipher'])
);
}
$this->line(trans('commands.key:rotate.old_key', ['key' => $old]));
$this->line(trans('commands.key:rotate.new_key', ['key' => $new]));
$this->error(trans('commands.key:rotate.backup_keys'));
$this->newLine();
// init encrypters
$this->decrypt = $this->createEncrypter($old, $cipher);
$this->encrypt = $this->createEncrypter($new, $cipher);
$this->line(trans('commands.key:rotate.backups'));
if (! $this->confirm(trans('commands.key:rotate.confirm'))) {
return 1;
}
$success = $this->rekeyConfigData('validation.encryption.test');
if (! $success) {
$this->line(trans('commands.key:rotate.old_key', ['key' => $old]));
$this->line(trans('commands.key:rotate.new_key', ['key' => $new]));
$this->error(trans('commands.key:rotate.failed'));
return 1;
}
$this->info(trans('commands.key:rotate.success'));
if ($this->option('generate-new-key') && $this->confirm(trans('commands.key:rotate.save_key'))) {
EnvHelper::writeEnv([
'OLD_APP_KEY' => $old,
'APP_KEY' => $new,
], ['OLD_APP_KEY', 'APP_KEY']);
}
return 0;
}
private function createEncrypter(string $key, string $cipher): Encrypter
{
return new Encrypter(base64_decode(Str::after($key, 'base64:')), $cipher);
}
private function rekeyConfigData(string $key): bool
{
if (! \LibreNMS\Config::has($key)) {
return true;
}
try {
$data = $this->decrypt->decryptString(\LibreNMS\Config::get($key));
\LibreNMS\Config::persist($key, $this->encrypt->encryptString($data));
return true;
} catch (DecryptException $e) {
try {
$this->encrypt->decryptString(\LibreNMS\Config::get($key));
return true; // already rotated
} catch (DecryptException $e) {
$this->warn(trans('commands.key:rotate.decrypt-failed', ['item' => $key]));
return false;
}
}
}
}

View File

@@ -105,16 +105,19 @@ abstract class LnmsCommand extends Command
/**
* Validate the input of this command. Uses Laravel input validation
* merging the arguments and options together to check.
*
* @param array $rules
* @param array $messages
*/
protected function validate($rules, $messages = [])
protected function validate(array $rules, array $messages = []): array
{
$validator = Validator::make($this->arguments() + $this->options(), $rules, $messages);
$validator = Validator::make(
$this->arguments() + $this->options(),
$rules,
array_merge(trans('commands.' . $this->getName() . '.validation-errors'), $messages)
);
try {
$validator->validate();
return $validator->validated();
} catch (ValidationException $e) {
collect($validator->getMessageBag()->all())->each(function ($message) {
$this->error($message);