From d404da904fec33a6609772a955d37be5f2a3f0e0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:47:51 +0200 Subject: [PATCH] feat(app-key): re-encrypt stored secrets after key rotation --- app/Console/Commands/ReencryptWithAppKey.php | 216 ++++++++++++++++++ .../ReencryptWithAppKeyCommandTest.php | 142 ++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 app/Console/Commands/ReencryptWithAppKey.php create mode 100644 tests/Feature/ReencryptWithAppKeyCommandTest.php diff --git a/app/Console/Commands/ReencryptWithAppKey.php b/app/Console/Commands/ReencryptWithAppKey.php new file mode 100644 index 0000000000..68ae2acf4d --- /dev/null +++ b/app/Console/Commands/ReencryptWithAppKey.php @@ -0,0 +1,216 @@ +, list> + */ + 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, list> + */ + 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; + } + } +} diff --git a/tests/Feature/ReencryptWithAppKeyCommandTest.php b/tests/Feature/ReencryptWithAppKeyCommandTest.php new file mode 100644 index 0000000000..02c6ab7713 --- /dev/null +++ b/tests/Feature/ReencryptWithAppKeyCommandTest.php @@ -0,0 +1,142 @@ + $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::|(?:$])(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.'); +});