mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(app-key): re-encrypt stored secrets after key rotation
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\OauthSetting;
|
||||
use App\Models\ServerSetting;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use ReflectionClass;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
class ReencryptWithAppKey extends Command
|
||||
{
|
||||
protected $signature = 'app-key:reencrypt
|
||||
{--dry-run : Count the values that use an old key without changing them}
|
||||
{--force : Re-encrypt without asking for confirmation}';
|
||||
|
||||
protected $description = 'Re-encrypt stored secrets with the current APP_KEY after a key rotation (old keys in APP_PREVIOUS_KEYS)';
|
||||
|
||||
/**
|
||||
* Columns that model accessors encrypt instead of an encrypted cast.
|
||||
* A guard test fails when a model encrypts data in a way this command does not know.
|
||||
*
|
||||
* @var array<class-string<Model>, list<string>>
|
||||
*/
|
||||
public const ACCESSOR_ENCRYPTED_COLUMNS = [
|
||||
GitlabApp::class => ['webhook_token'],
|
||||
OauthSetting::class => ['client_secret'],
|
||||
];
|
||||
|
||||
private Encrypter $currentKeyOnly;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$this->currentKeyOnly = new Encrypter(Crypt::getKey(), config('app.cipher'));
|
||||
|
||||
if (Crypt::getPreviousKeys() === []) {
|
||||
$this->warn('APP_PREVIOUS_KEYS is empty. Values encrypted with an old key cannot be decrypted.');
|
||||
}
|
||||
|
||||
if (! $dryRun && ! $this->option('force') && ! $this->confirm('Re-encrypt all stored secrets with the current APP_KEY?')) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
$reencrypted = 0;
|
||||
$undecryptable = 0;
|
||||
foreach (self::encryptedColumns() as $class => $columns) {
|
||||
foreach ($columns as $column) {
|
||||
[$changed, $failed] = $this->reencryptColumn(new $class, $column, $dryRun);
|
||||
$reencrypted += $changed;
|
||||
$undecryptable += $failed;
|
||||
if ($changed + $failed > 0) {
|
||||
$rows[] = [class_basename($class), $column, $changed, $failed];
|
||||
}
|
||||
}
|
||||
}
|
||||
$sentinelTokens = $this->regenerateSentinelTokens($dryRun);
|
||||
|
||||
if ($rows !== []) {
|
||||
$this->table(['Model', 'Column', $dryRun ? 'Uses an old key' : 'Re-encrypted', 'Undecryptable'], $rows);
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info("{$reencrypted} values and {$sentinelTokens} Sentinel tokens use an old key. Nothing was changed.");
|
||||
} else {
|
||||
$this->info("Re-encrypted {$reencrypted} values.");
|
||||
$this->info("Regenerated {$sentinelTokens} Sentinel tokens. Sentinel restarts on the servers where it is enabled.");
|
||||
}
|
||||
|
||||
if ($undecryptable > 0) {
|
||||
$this->error("{$undecryptable} values cannot be decrypted with APP_KEY or APP_PREVIOUS_KEYS. Do not remove APP_PREVIOUS_KEYS until you fix them.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$this->info('You can remove APP_PREVIOUS_KEYS when queued jobs finished. Sessions, invitation links, and old backups still need the previous key.');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every model column that holds a Laravel encrypted payload.
|
||||
*
|
||||
* @return array<class-string<Model>, list<string>>
|
||||
*/
|
||||
public static function encryptedColumns(): array
|
||||
{
|
||||
$columns = [];
|
||||
foreach (Finder::create()->files()->in(app_path('Models'))->name('*.php') as $file) {
|
||||
$class = 'App\\Models\\'.str_replace(['/', '.php'], ['\\', ''], $file->getRelativePathname());
|
||||
if (! is_subclass_of($class, Model::class) || (new ReflectionClass($class))->isAbstract()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$castColumns = collect((new $class)->getCasts())
|
||||
->filter(fn ($cast) => is_string($cast) && Str::contains($cast, 'encrypted', ignoreCase: true))
|
||||
->keys()
|
||||
->all();
|
||||
$encrypted = array_values(array_unique([...$castColumns, ...(self::ACCESSOR_ENCRYPTED_COLUMNS[$class] ?? [])]));
|
||||
if ($encrypted !== []) {
|
||||
$columns[$class] = $encrypted;
|
||||
}
|
||||
}
|
||||
ksort($columns);
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts and encrypts the raw string, so serialized (encrypt()) and plain (encryptString())
|
||||
* payloads keep their format. Plaintext legacy values are left as they are.
|
||||
*
|
||||
* @return array{int, int} The re-encrypted and the undecryptable value counts.
|
||||
*/
|
||||
private function reencryptColumn(Model $model, string $column, bool $dryRun): array
|
||||
{
|
||||
$schema = Schema::connection($model->getConnectionName());
|
||||
if (! $schema->hasTable($model->getTable()) || ! $schema->hasColumn($model->getTable(), $column)) {
|
||||
return [0, 0];
|
||||
}
|
||||
|
||||
$keyName = $model->getKeyName();
|
||||
$changed = 0;
|
||||
$failed = 0;
|
||||
$model->newQueryWithoutScopes()->toBase()
|
||||
->select([$keyName, $column])
|
||||
->whereNotNull($column)
|
||||
->chunkById(500, function ($rows) use ($model, $keyName, $column, $dryRun, &$changed, &$failed) {
|
||||
foreach ($rows as $row) {
|
||||
$payload = $row->{$column};
|
||||
if (! $this->usesOldKey($payload)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$plaintext = Crypt::decryptString($payload);
|
||||
} catch (DecryptException) {
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Compare-and-set, so a value saved during the run is not overwritten.
|
||||
$model->newQueryWithoutScopes()->toBase()
|
||||
->where($keyName, $row->{$keyName})
|
||||
->where($column, $payload)
|
||||
->update([$column => Crypt::encryptString($plaintext)]);
|
||||
}
|
||||
$changed++;
|
||||
}
|
||||
}, $keyName);
|
||||
|
||||
return [$changed, $failed];
|
||||
}
|
||||
|
||||
/**
|
||||
* A Sentinel token is an encrypted payload itself, and Sentinel sends it back on every push.
|
||||
* Saving a new token restarts Sentinel with it (see ServerSetting::booted()).
|
||||
*/
|
||||
private function regenerateSentinelTokens(bool $dryRun): int
|
||||
{
|
||||
$count = 0;
|
||||
ServerSetting::query()->with('server')->chunkById(100, function (Collection $settings) use ($dryRun, &$count) {
|
||||
foreach ($settings as $setting) {
|
||||
try {
|
||||
$token = $setting->sentinel_token;
|
||||
} catch (DecryptException) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($setting->server === null || ! $this->usesOldKey($token)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$setting->generateSentinelToken();
|
||||
}
|
||||
$count++;
|
||||
}
|
||||
});
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function usesOldKey(mixed $value): bool
|
||||
{
|
||||
if (! is_string($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = json_decode(base64_decode($value, true) ?: '', true);
|
||||
if (! is_array($payload) || ! isset($payload['iv'], $payload['value'], $payload['mac'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->currentKeyOnly->decryptString($value);
|
||||
|
||||
return false;
|
||||
} catch (DecryptException) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Server\StartSentinel;
|
||||
use App\Console\Commands\ReencryptWithAppKey;
|
||||
use App\Models\Application;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function useAppKeys(string $key, array $previousKeys = []): void
|
||||
{
|
||||
config(['app.key' => $key, 'app.previous_keys' => $previousKeys]);
|
||||
app()->forgetInstance('encrypter');
|
||||
Crypt::clearResolvedInstance('encrypter');
|
||||
}
|
||||
|
||||
function newAppKey(): string
|
||||
{
|
||||
return 'base64:'.base64_encode(random_bytes(32));
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
$this->oldKey = newAppKey();
|
||||
$this->newKey = newAppKey();
|
||||
useAppKeys($this->oldKey);
|
||||
|
||||
InstanceSettings::forceCreate(['id' => 0, 'smtp_password' => 'instance-smtp-password']);
|
||||
$this->team = Team::factory()->create();
|
||||
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]);
|
||||
$this->server->settings->forceFill(['is_sentinel_enabled' => true])->saveQuietly();
|
||||
$this->environmentVariable = EnvironmentVariable::create([
|
||||
'key' => 'DATABASE_URL',
|
||||
'value' => 'postgres://user:secret@db:5432/app',
|
||||
'resourceable_type' => Application::class,
|
||||
'resourceable_id' => Application::factory()->create()->id,
|
||||
]);
|
||||
$this->gitlabApp = GitlabApp::create([
|
||||
'name' => 'GitLab',
|
||||
'api_url' => 'https://gitlab.example.com/api/v4',
|
||||
'html_url' => 'https://gitlab.example.com',
|
||||
'custom_user' => 'git',
|
||||
'custom_port' => 22,
|
||||
'webhook_token' => 'secret-webhook-token',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$this->oldSentinelToken = $this->server->settings->sentinel_token;
|
||||
$this->privateKeyValue = $this->privateKey->private_key;
|
||||
});
|
||||
|
||||
it('re-encrypts stored secrets so they decrypt without the previous key', function () {
|
||||
useAppKeys($this->newKey, [$this->oldKey]);
|
||||
|
||||
$this->artisan('app-key:reencrypt --force')
|
||||
->expectsOutputToContain('Regenerated 1 Sentinel tokens.')
|
||||
->assertSuccessful();
|
||||
StartSentinel::assertPushed();
|
||||
|
||||
useAppKeys($this->newKey);
|
||||
|
||||
expect(InstanceSettings::query()->find(0)->smtp_password)->toBe('instance-smtp-password')
|
||||
->and($this->privateKey->fresh()->private_key)->toBe($this->privateKeyValue)
|
||||
->and($this->environmentVariable->fresh()->value)->toBe('postgres://user:secret@db:5432/app')
|
||||
->and($this->gitlabApp->fresh()->webhook_token)->toBe('secret-webhook-token');
|
||||
|
||||
$sentinelToken = $this->server->settings->fresh()->sentinel_token;
|
||||
expect($sentinelToken)->not->toBe($this->oldSentinelToken)
|
||||
->and(json_decode(decrypt($sentinelToken), true))->toBe(['server_uuid' => $this->server->uuid]);
|
||||
});
|
||||
|
||||
it('changes nothing when every secret already uses the current key', function () {
|
||||
useAppKeys($this->newKey, [$this->oldKey]);
|
||||
$this->artisan('app-key:reencrypt --force')->assertSuccessful();
|
||||
$rawPrivateKey = DB::table('private_keys')->where('id', $this->privateKey->id)->value('private_key');
|
||||
|
||||
$this->artisan('app-key:reencrypt --force')
|
||||
->expectsOutputToContain('Re-encrypted 0 values.')
|
||||
->expectsOutputToContain('Regenerated 0 Sentinel tokens.')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(DB::table('private_keys')->where('id', $this->privateKey->id)->value('private_key'))->toBe($rawPrivateKey);
|
||||
});
|
||||
|
||||
it('reports old-key values in a dry run without changing them', function () {
|
||||
$rawPrivateKey = DB::table('private_keys')->where('id', $this->privateKey->id)->value('private_key');
|
||||
useAppKeys($this->newKey, [$this->oldKey]);
|
||||
|
||||
$this->artisan('app-key:reencrypt --dry-run')
|
||||
->expectsOutputToContain('values and 1 Sentinel tokens use an old key. Nothing was changed.')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(DB::table('private_keys')->where('id', $this->privateKey->id)->value('private_key'))->toBe($rawPrivateKey)
|
||||
->and($this->server->settings->fresh()->sentinel_token)->toBe($this->oldSentinelToken);
|
||||
StartSentinel::assertNotPushed();
|
||||
});
|
||||
|
||||
it('keeps legacy plaintext values as they are', function () {
|
||||
DB::table('gitlab_apps')->where('id', $this->gitlabApp->id)->update(['webhook_token' => 'legacy-plaintext-token']);
|
||||
useAppKeys($this->newKey, [$this->oldKey]);
|
||||
|
||||
$this->artisan('app-key:reencrypt --force')->assertSuccessful();
|
||||
|
||||
expect(DB::table('gitlab_apps')->where('id', $this->gitlabApp->id)->value('webhook_token'))->toBe('legacy-plaintext-token');
|
||||
});
|
||||
|
||||
it('fails and keeps values that no configured key can decrypt', function () {
|
||||
$rawPrivateKey = DB::table('private_keys')->where('id', $this->privateKey->id)->value('private_key');
|
||||
useAppKeys($this->newKey);
|
||||
|
||||
$this->artisan('app-key:reencrypt --force')
|
||||
->expectsOutputToContain('cannot be decrypted with APP_KEY or APP_PREVIOUS_KEYS')
|
||||
->assertFailed();
|
||||
|
||||
expect(DB::table('private_keys')->where('id', $this->privateKey->id)->value('private_key'))->toBe($rawPrivateKey);
|
||||
});
|
||||
|
||||
it('knows every model that encrypts data without an encrypted cast', function () {
|
||||
// Reviewed: EnvironmentVariable encrypts its "value" column, which also has an encrypted cast.
|
||||
// ServerSetting encrypts the Sentinel token, which the command regenerates.
|
||||
$reviewed = [EnvironmentVariable::class, ServerSetting::class];
|
||||
|
||||
$manualEncryption = collect(Finder::create()->files()->in(app_path('Models'))->name('*.php'))
|
||||
->filter(fn ($file) => preg_match('/\bCrypt::|(?<![\w>:$])(en|de)crypt\(/', $file->getContents()) === 1)
|
||||
->map(fn ($file) => 'App\\Models\\'.str_replace(['/', '.php'], ['\\', ''], $file->getRelativePathname()))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect(array_diff($manualEncryption, [...array_keys(ReencryptWithAppKey::ACCESSOR_ENCRYPTED_COLUMNS), ...$reviewed]))
|
||||
->toBe([], 'Add the encrypted columns of these models to ReencryptWithAppKey::ACCESSOR_ENCRYPTED_COLUMNS.');
|
||||
});
|
||||
Reference in New Issue
Block a user