where('tenant_id', $tenantId)->where('is_active', true)->first() ?? $this->createKey($tenantId, null, $actorId); } public function encrypt(int $tenantId, string $plaintext, ?TenantEncryptionKey $key = null): string { $key ??= $this->ensureKey($tenantId); $dek = Crypt::decryptString($key->wrapped_key); $nonce = random_bytes(12); $tag = ''; $ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $dek, OPENSSL_RAW_DATA, $nonce, $tag, (string) $tenantId); if ($ciphertext === false) { throw new RuntimeException('ENCRYPTION_FAILED'); } return self::PREFIX.$key->id.':'.base64_encode($nonce).':'.base64_encode($tag).':'.base64_encode($ciphertext); } public function decrypt(int $tenantId, string $payload): string { if (! str_starts_with($payload, self::PREFIX)) { return Crypt::decryptString($payload); } $parts = explode(':', $payload, 6); if (count($parts) !== 6) { throw new RuntimeException('INVALID_ENCRYPTED_PAYLOAD'); } $key = TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->findOrFail((int) $parts[2]); $dek = Crypt::decryptString($key->wrapped_key); $plaintext = openssl_decrypt(base64_decode($parts[5], true), 'aes-256-gcm', $dek, OPENSSL_RAW_DATA, base64_decode($parts[3], true), base64_decode($parts[4], true), (string) $tenantId); if ($plaintext === false) { throw new RuntimeException('DECRYPTION_FAILED'); } return $plaintext; } public function rotate(int $tenantId, ?string $manualKey, int $actorId): TenantEncryptionKey { return DB::transaction(function () use ($tenantId, $manualKey, $actorId): TenantEncryptionKey { Tenant::whereKey($tenantId)->lockForUpdate()->firstOrFail(); $oldKeys = TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->lockForUpdate()->get(); $newKey = $this->createKey($tenantId, $manualKey, $actorId, ($oldKeys->max('version') ?? 0) + 1); foreach (DB::table('device_credentials')->where('tenant_id', $tenantId)->lockForUpdate()->get(['id', 'password', 'enable_password']) as $credential) { DB::table('device_credentials')->where('id', $credential->id)->update([ 'password' => $this->encrypt($tenantId, $this->decrypt($tenantId, $credential->password), $newKey), 'enable_password' => $credential->enable_password === null ? null : $this->encrypt($tenantId, $this->decrypt($tenantId, $credential->enable_password), $newKey), 'updated_at' => now(), ]); } foreach (DB::table('tenant_device_settings')->where('tenant_id', $tenantId)->lockForUpdate()->get(['id', 'base_password']) as $setting) { DB::table('tenant_device_settings')->where('id', $setting->id)->update(['base_password' => $this->encrypt($tenantId, $this->decrypt($tenantId, $setting->base_password), $newKey), 'updated_at' => now()]); } foreach (DB::table('device_access_users')->where('tenant_id', $tenantId)->lockForUpdate()->get(['id', 'password']) as $accessUser) { DB::table('device_access_users')->where('id', $accessUser->id)->update(['password' => $this->encrypt($tenantId, $this->decrypt($tenantId, $accessUser->password), $newKey), 'updated_at' => now()]); } TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->where('id', '!=', $newKey->id)->where('is_active', true)->update(['is_active' => false, 'retired_at' => now()]); return $newKey; }); } private function createKey(int $tenantId, ?string $manualKey, ?int $actorId, int $version = 1): TenantEncryptionKey { $tenant = Tenant::findOrFail($tenantId); $dek = $manualKey === null ? random_bytes(32) : hash_hkdf('sha256', $manualKey, 32, 'RADIQ-NDM:'.$tenant->uuid); return TenantEncryptionKey::withoutGlobalScope('tenant')->create([ 'tenant_id' => $tenantId, 'version' => $version, 'wrapped_key' => Crypt::encryptString($dek), 'is_active' => true, 'source' => $manualKey === null ? 'generated' : 'manual', 'created_by' => $actorId, ]); } }