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:
co-authored by
Claude Opus 5.5
parent
0bfb3bb49c
commit
ea5697cb81
+2
-2
@@ -2,6 +2,7 @@ APP_NAME="Thế Giới Số"
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_PREVIOUS_KEYS=
|
||||
HASH_DRIVER=argon2id
|
||||
APP_DEBUG=false
|
||||
APP_URL=https://dev.tgs.com.vn
|
||||
APP_LOCALE=vi
|
||||
@@ -17,10 +18,9 @@ DB_DATABASE=portal_dev
|
||||
DB_USERNAME=portal_dev
|
||||
DB_PASSWORD=
|
||||
|
||||
#Config Custom SignRSA
|
||||
#Legacy SignRSA (3DES) — chỉ dùng để đọc dữ liệu cũ, gỡ sau khi chạy secrets:reencrypt
|
||||
RSA_KEY=
|
||||
RSA_PUBLIC=
|
||||
RSA_PRIVATE=
|
||||
RSA_PREVIOUS_KEY=
|
||||
RSA_PREVIOUS_PUBLIC=
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class ReencryptSecrets extends Command
|
||||
{
|
||||
protected $signature = 'secrets:reencrypt {--dry-run}';
|
||||
|
||||
protected $description = 'Re-encrypt stored secrets with the current APP_KEY and RSA_KEY (old values are read via APP_PREVIOUS_KEYS / RSA_PREVIOUS_*)';
|
||||
protected $description = 'Re-encrypt stored secrets with the current APP_KEY (old values are read via APP_PREVIOUS_KEYS; legacy 3DES SignRSA values via RSA_KEY/RSA_PUBLIC and RSA_PREVIOUS_*)';
|
||||
|
||||
private const RSA_COLUMNS = [
|
||||
['module_vcenter', 'password'],
|
||||
|
||||
@@ -55,6 +55,7 @@ class AuthController extends Controller
|
||||
RateLimiter::clear($throttleKey);
|
||||
|
||||
$user = Auth::guard('admin')->user();
|
||||
Auth::guard('admin')->getProvider()->rehashPasswordIfRequired($user, $request->only('password'));
|
||||
|
||||
$this->activityLogger->logAuth('login', 'admin', $user->id, $user->username ?? $user->email);
|
||||
|
||||
|
||||
@@ -2,64 +2,51 @@
|
||||
|
||||
namespace App\Http\Services;
|
||||
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
class SignRSA
|
||||
{
|
||||
protected string $key;
|
||||
protected ?string $private;
|
||||
protected string $public;
|
||||
protected ?string $previousKey;
|
||||
protected ?string $previousPublic;
|
||||
private const LEGACY_KEYS = [['rsa.key', 'rsa.public'], ['rsa.previous_key', 'rsa.previous_public']];
|
||||
|
||||
public function __construct()
|
||||
public function EncryptFunc($plaintext): string
|
||||
{
|
||||
$this->key = base64_decode(config('rsa.key'));
|
||||
$this->private = str_replace('\n', "\n", config('rsa.private'));
|
||||
$this->public = str_replace('\n', "\n", config('rsa.public'));
|
||||
$this->previousKey = config('rsa.previous_key') ? base64_decode(config('rsa.previous_key')) : null;
|
||||
$this->previousPublic = config('rsa.previous_public') ? str_replace('\n', "\n", config('rsa.previous_public')) : null;
|
||||
return Crypt::encryptString((string) $plaintext);
|
||||
}
|
||||
|
||||
|
||||
public function EncryptFunc($signdata): string
|
||||
{
|
||||
|
||||
$sign = $this->SignData($signdata);
|
||||
|
||||
return $this->EncryptedData($signdata . '|' . $sign, $this->key);
|
||||
}
|
||||
|
||||
private function SignData($signdata = ''): string
|
||||
{
|
||||
|
||||
openssl_sign($signdata, $result, $this->private, 'sha256WithRSAEncryption');
|
||||
|
||||
return base64_encode($result);
|
||||
}
|
||||
|
||||
private function EncryptedData($original_schema = '', $key_hash = ''): string
|
||||
{
|
||||
$encrypted_data = openssl_encrypt($original_schema, 'DES-EDE3', $key_hash, OPENSSL_RAW_DATA);
|
||||
|
||||
return base64_encode($encrypted_data);
|
||||
}
|
||||
|
||||
/*
|
||||
* Function Decrypt Data
|
||||
*/
|
||||
|
||||
public function DecryptFunc($data): string
|
||||
{
|
||||
if (!$data) {
|
||||
return false;
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return Crypt::decryptString($data);
|
||||
} catch (DecryptException) {
|
||||
return $this->decryptLegacy((string) $data);
|
||||
}
|
||||
}
|
||||
|
||||
private function decryptLegacy(string $data): string
|
||||
{
|
||||
$encrypted_data = base64_decode($data);
|
||||
$verified = $this->Verify_Sign((string) $this->DecryptData($encrypted_data, $this->key), $this->public);
|
||||
|
||||
if ($verified === false && $this->previousKey && $this->previousPublic) {
|
||||
$verified = $this->Verify_Sign((string) $this->DecryptData($encrypted_data, $this->previousKey), $this->previousPublic);
|
||||
foreach (self::LEGACY_KEYS as [$keyConfig, $publicConfig]) {
|
||||
if (!config($keyConfig) || !config($publicConfig)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$verified = $this->Verify_Sign(
|
||||
(string) $this->DecryptData($encrypted_data, base64_decode(config($keyConfig))),
|
||||
str_replace('\n', "\n", config($publicConfig)),
|
||||
);
|
||||
|
||||
if ($verified !== false) {
|
||||
return $verified;
|
||||
}
|
||||
}
|
||||
|
||||
return $verified;
|
||||
return '';
|
||||
}
|
||||
|
||||
private function DecryptData($encrypted_data, $random_key): bool|string
|
||||
|
||||
@@ -72,6 +72,8 @@ class ClientAuthService
|
||||
]);
|
||||
}
|
||||
|
||||
$guard->getProvider()->rehashPasswordIfRequired($client, $credentials);
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
@@ -98,7 +100,7 @@ class ClientAuthService
|
||||
'fullname' => $data['fullname'],
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'email' => $data['email'],
|
||||
'password' => bcrypt($data['password']),
|
||||
'password' => Hash::make($data['password']),
|
||||
'status' => 0,
|
||||
'email_verify_code' => $code,
|
||||
'email_verify_code_expired_at' => now()->addMinutes(5),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services\Imports;
|
||||
|
||||
use App\Http\Facade\SignRSA;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -163,7 +164,7 @@ class LegacyPortalImporter
|
||||
'ip' => $row->vmware_ip,
|
||||
'port' => is_numeric($row->vmware_port) ? (int) $row->vmware_port : null,
|
||||
'user' => $row->vmware_user,
|
||||
'password' => $row->vmware_password,
|
||||
'password' => $row->vmware_password ? SignRSA::EncryptFunc($row->vmware_password) : null,
|
||||
'hostname' => $row->vmware_hostname,
|
||||
'vmware_id' => $vcVmId,
|
||||
'virtual_machine_id' => $vmInventoryId,
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => 'bcrypt',
|
||||
'driver' => env('HASH_DRIVER', 'bcrypt'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
return [
|
||||
'key' => env('RSA_KEY'),
|
||||
'public' => env('RSA_PUBLIC'),
|
||||
'private' => env('RSA_PRIVATE'),
|
||||
'previous_key' => env('RSA_PREVIOUS_KEY'),
|
||||
'previous_public' => env('RSA_PREVIOUS_PUBLIC'),
|
||||
];
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Facade\SignRSA;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('module_vmware')
|
||||
->select(['id', 'password'])
|
||||
->whereNotNull('password')
|
||||
->where('password', '!=', '')
|
||||
->chunkById(200, function ($rows) {
|
||||
foreach ($rows as $row) {
|
||||
if (SignRSA::DecryptFunc($row->password) !== '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('module_vmware')->where('id', $row->id)->update(['password' => SignRSA::EncryptFunc($row->password)]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordRehashTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const PASSWORD = 'Secret12345';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
config(['hashing.driver' => 'argon2id']);
|
||||
}
|
||||
|
||||
public function test_customer_bcrypt_hash_is_upgraded_to_argon2id_on_login(): void
|
||||
{
|
||||
$customer = Customer::factory()->create([
|
||||
'status' => 1,
|
||||
'password' => Hash::driver('bcrypt')->make(self::PASSWORD),
|
||||
]);
|
||||
|
||||
$this->withHeaders(['x-access-token' => $this->buildClientAccessToken()])
|
||||
->postJson('/v1/client/login', ['email' => $customer->email, 'password' => self::PASSWORD])
|
||||
->assertOk()
|
||||
->assertJsonStructure(['data' => ['token']]);
|
||||
|
||||
$hash = $customer->fresh()->password;
|
||||
$this->assertStringStartsWith('$argon2id$', $hash);
|
||||
$this->assertTrue(Hash::check(self::PASSWORD, $hash));
|
||||
}
|
||||
|
||||
public function test_admin_bcrypt_hash_is_upgraded_to_argon2id_on_login(): void
|
||||
{
|
||||
$admin = User::factory()->create([
|
||||
'username' => 'rehash-admin',
|
||||
'email' => 'rehash-admin@example.com',
|
||||
'password' => Hash::driver('bcrypt')->make(self::PASSWORD),
|
||||
]);
|
||||
|
||||
$this->postJson('/api/v1/administrator/login', ['username' => 'rehash-admin', 'password' => self::PASSWORD])
|
||||
->assertOk();
|
||||
|
||||
$this->assertStringStartsWith('$argon2id$', $admin->fresh()->password);
|
||||
}
|
||||
|
||||
public function test_wrong_password_does_not_rehash(): void
|
||||
{
|
||||
$bcrypt = Hash::driver('bcrypt')->make(self::PASSWORD);
|
||||
$customer = Customer::factory()->create(['status' => 1, 'password' => $bcrypt]);
|
||||
|
||||
$this->withHeaders(['x-access-token' => $this->buildClientAccessToken()])
|
||||
->postJson('/v1/client/login', ['email' => $customer->email, 'password' => 'wrong-password'])
|
||||
->assertStatus(422);
|
||||
|
||||
$this->assertSame($bcrypt, $customer->fresh()->password);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user