feat(security): argon2id password hashing and AES for SignRSA secrets

- Hash driver read from HASH_DRIVER (argon2id in production); bcrypt
  hashes still verify and are rehashed on successful admin/client login
  since the JWT guard and guard->validate() never rehash on their own.
- Client registration uses Hash::make instead of bcrypt(), which always
  forced bcrypt regardless of the configured driver.
- SignRSA now encrypts with Crypt (AES-256-CBC + HMAC, APP_KEY) instead
  of 3DES in ECB mode without IV. Legacy 3DES values are still readable
  via RSA_KEY/RSA_PUBLIC until `secrets:reencrypt` migrates them;
  RSA_PRIVATE is no longer used.
- LegacyPortalImporter stored module_vmware.password in plaintext; it is
  now encrypted, and a migration encrypts existing plaintext rows.

Deploy: php artisan migrate, then secrets:reencrypt --dry-run and
secrets:reencrypt; remove RSA_* from env once it exits 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-15 19:56:38 +07:00
co-authored by Claude Opus 5.5
parent 0bfb3bb49c
commit ea5697cb81
11 changed files with 166 additions and 69 deletions
+28 -18
View File
@@ -20,23 +20,29 @@ class ReencryptSecretsTest extends TestCase
DB::table('infra_management')->delete();
}
public function test_rotating_keys_re_encrypts_rsa_and_app_key_columns(): void
public function test_legacy_3des_values_and_rotated_app_key_are_re_encrypted_with_current_app_key(): void
{
[$oldRsa, $newRsa] = [$this->rsaConfig(), $this->rsaConfig()];
$legacyRsa = $this->rsaConfig();
[$oldAppKey, $newAppKey] = [$this->appKey(), $this->appKey()];
$this->useKeys($oldRsa, $oldAppKey);
$vcenterId = DB::table('module_vcenter')->insertGetId(['password' => SignRSA::EncryptFunc('vc-secret')]);
$this->useKeys($oldAppKey, legacyRsa: $legacyRsa);
$vcenterId = DB::table('module_vcenter')->insertGetId(['password' => $this->legacyEncrypt('vc-secret', $legacyRsa)]);
$infraId = DB::table('module_hosting')->insertGetId(['password' => SignRSA::EncryptFunc('hosting-secret')]);
$dnsServerId = DB::table('dns_manager_servers')->insertGetId([
'name' => 'ns1', 'ip_address' => '10.0.0.1', 'username' => 'admin', 'password' => Crypt::encryptString('plesk-secret'),
]);
DB::table('setting')->insert(['key' => 'smtp', 'value' => json_encode(['host' => 'smtp', 'password' => SignRSA::EncryptFunc('smtp-secret')])]);
DB::table('setting')->insert(['key' => 'smtp', 'value' => json_encode(['host' => 'smtp', 'password' => $this->legacyEncrypt('smtp-secret', $legacyRsa)])]);
$this->useKeys($newRsa, $newAppKey, previousRsa: $oldRsa, previousAppKey: $oldAppKey);
$this->assertSame('vc-secret', SignRSA::DecryptFunc(DB::table('module_vcenter')->where('id', $vcenterId)->value('password')));
$this->useKeys($newAppKey, previousAppKey: $oldAppKey, legacyRsa: $legacyRsa);
$this->artisan('secrets:reencrypt')->assertSuccessful();
$this->useKeys($newRsa, $newAppKey);
$this->assertSame('vc-secret', SignRSA::DecryptFunc(DB::table('module_vcenter')->where('id', $vcenterId)->value('password')));
$this->useKeys($newAppKey);
$vcenterPassword = DB::table('module_vcenter')->where('id', $vcenterId)->value('password');
$this->assertSame('vc-secret', Crypt::decryptString($vcenterPassword));
$this->assertSame('vc-secret', SignRSA::DecryptFunc($vcenterPassword));
$this->assertSame('hosting-secret', SignRSA::DecryptFunc(DB::table('module_hosting')->where('id', $infraId)->value('password')));
$this->assertSame('plesk-secret', Crypt::decryptString(DB::table('dns_manager_servers')->where('id', $dnsServerId)->value('password')));
$smtp = json_decode(DB::table('setting')->where('key', 'smtp')->value('value'), true);
$this->assertSame('smtp-secret', SignRSA::DecryptFunc($smtp['password']));
@@ -45,34 +51,38 @@ class ReencryptSecretsTest extends TestCase
public function test_dry_run_changes_nothing_and_undecryptable_rows_fail_the_command(): void
{
$this->useKeys($this->rsaConfig(), $this->appKey());
$this->useKeys($this->appKey());
$id = DB::table('module_vcenter')->insertGetId(['password' => SignRSA::EncryptFunc('vc-secret')]);
$original = DB::table('module_vcenter')->where('id', $id)->value('password');
$this->artisan('secrets:reencrypt --dry-run')->assertSuccessful();
$this->assertSame($original, DB::table('module_vcenter')->where('id', $id)->value('password'));
$this->useKeys($this->rsaConfig(), $this->appKey());
$this->useKeys($this->appKey(), legacyRsa: $this->rsaConfig());
$this->artisan('secrets:reencrypt')->assertFailed();
$this->assertSame($original, DB::table('module_vcenter')->where('id', $id)->value('password'));
}
private function useKeys(array $rsa, string $appKey, ?array $previousRsa = null, ?string $previousAppKey = null): void
private function useKeys(string $appKey, ?string $previousAppKey = null, ?array $legacyRsa = null): void
{
config([
'rsa.key' => $rsa['key'],
'rsa.public' => $rsa['public'],
'rsa.private' => $rsa['private'],
'rsa.previous_key' => $previousRsa['key'] ?? null,
'rsa.previous_public' => $previousRsa['public'] ?? null,
'rsa.key' => $legacyRsa['key'] ?? null,
'rsa.public' => $legacyRsa['public'] ?? null,
'rsa.previous_key' => null,
'rsa.previous_public' => null,
'app.key' => $appKey,
'app.previous_keys' => $previousAppKey ? [$previousAppKey] : [],
]);
$this->app->forgetInstance('encrypter');
$this->app->forgetInstance(\App\Http\Services\SignRSA::class);
Crypt::clearResolvedInstance('encrypter');
SignRSA::clearResolvedInstance(\App\Http\Services\SignRSA::class);
}
private function legacyEncrypt(string $plaintext, array $rsa): string
{
openssl_sign($plaintext, $signature, $rsa['private'], 'sha256WithRSAEncryption');
return base64_encode(openssl_encrypt($plaintext . '|' . base64_encode($signature), 'DES-EDE3', base64_decode($rsa['key']), OPENSSL_RAW_DATA));
}
private function rsaConfig(): array