From 69b9d101a2394933de0a1b2f8d5fe9ec0d5c0bf7 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Thu, 9 Jul 2026 17:57:59 -0400 Subject: [PATCH 1/4] FOUR-32191 [52344] Clear Text Submission of Login Password in ProcessMaker Description: When the login button is clicked, the payload shows that the username and password are visible in clear text in the request body. - Expected Behavior Login credentials should be Encrypted and transmitted over the network, and not exposed in request body. - Notes to QA This was also tested on an intercepting site called Burp Suite Related tickets: https://processmaker.atlassian.net/browse/FOUR-32191 --- .gitignore | 1 + .../Console/Commands/GenerateLoginKeys.php | 30 ++++ .../Http/Controllers/Auth/LoginController.php | 38 ++++- .../Services/LoginCredentialEncryption.php | 152 ++++++++++++++++++ config/auth.php | 4 + resources/views/auth/login.blade.php | 1 + resources/views/auth/newLogin.blade.php | 1 + .../login-credential-encryption.blade.php | 109 +++++++++++++ tests/Feature/LoginEncryptionTest.php | 83 ++++++++++ .../LoginCredentialEncryptionTest.php | 56 +++++++ 10 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 ProcessMaker/Console/Commands/GenerateLoginKeys.php create mode 100644 ProcessMaker/Services/LoginCredentialEncryption.php create mode 100644 resources/views/auth/partials/login-credential-encryption.blade.php create mode 100644 tests/Feature/LoginEncryptionTest.php create mode 100644 tests/unit/ProcessMaker/Services/LoginCredentialEncryptionTest.php diff --git a/.gitignore b/.gitignore index 7a47d39bab..3b0c7fae27 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ /public/js /public/css /storage/*.key +/storage/app/keys/ /storage/*.index /storage/tenant_* /vendor diff --git a/ProcessMaker/Console/Commands/GenerateLoginKeys.php b/ProcessMaker/Console/Commands/GenerateLoginKeys.php new file mode 100644 index 0000000000..16f6427503 --- /dev/null +++ b/ProcessMaker/Console/Commands/GenerateLoginKeys.php @@ -0,0 +1,30 @@ +hasKeyPair() && !$this->option('force')) { + $this->error('Login encryption keys already exist. Use --force to overwrite them.'); + + return self::FAILURE; + } + + $encryption->generateKeyPair(); + $this->info('Login encryption keys generated successfully.'); + + return self::SUCCESS; + } +} diff --git a/ProcessMaker/Http/Controllers/Auth/LoginController.php b/ProcessMaker/Http/Controllers/Auth/LoginController.php index ccdffe7a09..e26833fb2f 100644 --- a/ProcessMaker/Http/Controllers/Auth/LoginController.php +++ b/ProcessMaker/Http/Controllers/Auth/LoginController.php @@ -17,6 +17,7 @@ use ProcessMaker\Models\Setting; use ProcessMaker\Models\User; use ProcessMaker\Package\Auth\Database\Seeds\AuthDefaultSeeder; +use ProcessMaker\Services\LoginCredentialEncryption; use ProcessMaker\Services\PermissionCacheService; use ProcessMaker\Traits\HasControllerAddons; @@ -115,8 +116,15 @@ public function showLoginForm(Request $request) false, $this->sessionSameSite() ); + $loginEncryption = app(LoginCredentialEncryption::class); + $loginPublicKey = null; + if ($loginEncryption->isEnabled()) { + $loginEncryption->ensureKeyPair(); + $loginPublicKey = $loginEncryption->getPublicKeyPem(); + } + $loginView = empty(config('app.login_view')) ? 'auth.login' : config('app.login_view'); - $response = response(view($loginView, compact('addons', 'block'))); + $response = response(view($loginView, compact('addons', 'block', 'loginPublicKey'))); $response->withCookie($cookie); // Remove 'password_hash_web' from session @@ -194,6 +202,10 @@ protected function getColumnAttribute(object $setting, string $attribute, string public function loginWithIntendedCheck(Request $request) { + if (!$this->decryptLoginCredentialsIfNeeded($request)) { + $this->sendFailedLoginResponse($request); + } + $intended = Cookie::get('processmaker_intended'); if ($intended) { // Check if the route is a fallback, meaning it's invalid (like favicon.ico) @@ -437,4 +449,28 @@ private function sessionSameSite(): string { return config('session.same_site') ?: 'lax'; } + + private function decryptLoginCredentialsIfNeeded(Request $request): bool + { + if (!$request->boolean('encrypted')) { + return true; + } + + $encryption = app(LoginCredentialEncryption::class); + if (!$encryption->isEnabled() || !$encryption->hasKeyPair()) { + return false; + } + + $credentials = $encryption->decryptCredentials((string) $request->input('encrypted_credentials', '')); + if ($credentials === null) { + return false; + } + + $request->merge([ + 'username' => $credentials['username'], + 'password' => $credentials['password'], + ]); + + return true; + } } diff --git a/ProcessMaker/Services/LoginCredentialEncryption.php b/ProcessMaker/Services/LoginCredentialEncryption.php new file mode 100644 index 0000000000..6c16815eb1 --- /dev/null +++ b/ProcessMaker/Services/LoginCredentialEncryption.php @@ -0,0 +1,152 @@ +privateKeyPath()) && is_readable($this->publicKeyPath()); + } + + public function ensureKeyPair(): void + { + if (!$this->hasKeyPair()) { + $this->generateKeyPair(); + } + } + + public function getPublicKeyPem(): ?string + { + if (!$this->hasKeyPair()) { + return null; + } + + return trim((string) file_get_contents($this->publicKeyPath())); + } + + /** + * @return array{username: string, password: string}|null + */ + public function decryptCredentials(string $cipherTextBase64): ?array + { + $privateKey = openssl_pkey_get_private((string) file_get_contents($this->privateKeyPath())); + if ($privateKey === false) { + return null; + } + + $cipher = base64_decode($cipherTextBase64, true); + if ($cipher === false) { + return null; + } + + $plain = ''; + $success = openssl_private_decrypt($cipher, $plain, $privateKey, OPENSSL_PKCS1_OAEP_PADDING); + if (!$success) { + return null; + } + + return $this->parsePayload($plain); + } + + public function encryptCredentials(string $username, string $password, ?int $timestamp = null): string + { + $this->ensureKeyPair(); + + $payload = json_encode([ + 'u' => $username, + 'p' => $password, + 't' => $timestamp ?? time(), + ], JSON_THROW_ON_ERROR); + + $publicKey = openssl_pkey_get_public((string) file_get_contents($this->publicKeyPath())); + if ($publicKey === false) { + throw new \RuntimeException('Unable to load login public key.'); + } + + $encrypted = ''; + $success = openssl_public_encrypt($payload, $encrypted, $publicKey, OPENSSL_PKCS1_OAEP_PADDING); + if (!$success) { + throw new \RuntimeException('Unable to encrypt login credentials.'); + } + + return base64_encode($encrypted); + } + + public function generateKeyPair(): void + { + $directory = $this->keysDirectory(); + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + + $key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + if ($key === false) { + throw new \RuntimeException('Unable to generate login RSA key pair.'); + } + + if (!openssl_pkey_export_to_file($key, $this->privateKeyPath())) { + throw new \RuntimeException('Unable to write login private key.'); + } + + $details = openssl_pkey_get_details($key); + if ($details === false || empty($details['key'])) { + throw new \RuntimeException('Unable to extract login public key.'); + } + + file_put_contents($this->publicKeyPath(), $details['key']); + chmod($this->privateKeyPath(), 0600); + } + + /** + * @return array{username: string, password: string}|null + */ + private function parsePayload(string $json): ?array + { + $data = json_decode($json, true); + if (!is_array($data) || !isset($data['u'], $data['p'], $data['t'])) { + return null; + } + + $ttl = (int) config('auth.login_encrypt_ttl', 300); + $timestamp = (int) $data['t']; + if (abs(time() - $timestamp) > $ttl) { + return null; + } + + return [ + 'username' => (string) $data['u'], + 'password' => (string) $data['p'], + ]; + } + + private function keysDirectory(): string + { + return storage_path('app/keys'); + } + + private function privateKeyPath(): string + { + return $this->keysDirectory() . '/' . self::PRIVATE_KEY_FILE; + } + + private function publicKeyPath(): string + { + return $this->keysDirectory() . '/' . self::PUBLIC_KEY_FILE; + } +} diff --git a/config/auth.php b/config/auth.php index e7811a250d..3cf4d6d16f 100644 --- a/config/auth.php +++ b/config/auth.php @@ -111,6 +111,10 @@ 'log_auth_events' => env('LOG_AUTH_EVENTS', true), + 'login_encrypt_credentials' => env('LOGIN_ENCRYPT_CREDENTIALS', true), + + 'login_encrypt_ttl' => env('LOGIN_ENCRYPT_TTL', 300), + /* |-------------------------------------------------------------------------- | Password Confirmation Timeout diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 41fb646f3e..92eaba9eef 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -82,6 +82,7 @@ @endsection @section('js') + @include('auth.partials.login-credential-encryption') +@endif diff --git a/tests/Feature/LoginEncryptionTest.php b/tests/Feature/LoginEncryptionTest.php new file mode 100644 index 0000000000..da4c750988 --- /dev/null +++ b/tests/Feature/LoginEncryptionTest.php @@ -0,0 +1,83 @@ + true, + 'auth.login_encrypt_ttl' => 300, + ]); + + $this->encryption = app(LoginCredentialEncryption::class); + $this->encryption->generateKeyPair(); + } + + protected function tearDown(): void + { + @unlink(storage_path('app/keys/login_private.pem')); + @unlink(storage_path('app/keys/login_public.pem')); + + parent::tearDown(); + } + + public function test_login_with_encrypted_credentials(): void + { + User::factory()->create([ + 'username' => 'encrypted-user', + 'password' => Hash::make('encrypted-password'), + 'status' => 'ACTIVE', + ]); + + $cipher = $this->encryption->encryptCredentials('encrypted-user', 'encrypted-password'); + + $response = $this->post('login', [ + 'encrypted' => 1, + 'encrypted_credentials' => $cipher, + ]); + + $response->assertRedirect('/'); + $response->assertSessionHasNoErrors(); + $this->assertAuthenticated(); + } + + public function test_login_rejects_invalid_encrypted_credentials(): void + { + User::factory()->create([ + 'username' => 'encrypted-user', + 'password' => Hash::make('encrypted-password'), + 'status' => 'ACTIVE', + ]); + + $response = $this->post('login', [ + 'encrypted' => 1, + 'encrypted_credentials' => 'invalid-cipher-text', + ]); + + $response->assertRedirect('/'); + $response->assertSessionHasErrors('username'); + $this->assertGuest(); + } + + public function test_login_page_exposes_public_key_when_encryption_is_enabled(): void + { + $response = $this->get(route('login')); + + $response->assertOk(); + $response->assertSee('login-public-key', false); + $response->assertSee($this->encryption->getPublicKeyPem(), false); + } +} diff --git a/tests/unit/ProcessMaker/Services/LoginCredentialEncryptionTest.php b/tests/unit/ProcessMaker/Services/LoginCredentialEncryptionTest.php new file mode 100644 index 0000000000..7b86704866 --- /dev/null +++ b/tests/unit/ProcessMaker/Services/LoginCredentialEncryptionTest.php @@ -0,0 +1,56 @@ + true, + 'auth.login_encrypt_ttl' => 300, + ]); + + $this->encryption = new LoginCredentialEncryption(); + $this->encryption->generateKeyPair(); + } + + protected function tearDown(): void + { + @unlink(storage_path('app/keys/login_private.pem')); + @unlink(storage_path('app/keys/login_public.pem')); + + parent::tearDown(); + } + + public function test_it_encrypts_and_decrypts_login_credentials(): void + { + $cipher = $this->encryption->encryptCredentials('test-user', 'secret-password'); + + $credentials = $this->encryption->decryptCredentials($cipher); + + $this->assertSame('test-user', $credentials['username']); + $this->assertSame('secret-password', $credentials['password']); + } + + public function test_it_rejects_expired_credentials(): void + { + $cipher = $this->encryption->encryptCredentials('test-user', 'secret-password', time() - 301); + + $this->assertNull($this->encryption->decryptCredentials($cipher)); + } + + public function test_it_rejects_invalid_cipher_text(): void + { + $this->assertNull($this->encryption->decryptCredentials('not-valid-base64-cipher')); + } +} From 526b8604266d85f39e8ae979f693adfbd903d584 Mon Sep 17 00:00:00 2001 From: Roly Gutierrez Date: Fri, 10 Jul 2026 10:49:24 -0400 Subject: [PATCH 2/4] Improve code quality and performance. --- .../Console/Commands/GenerateLoginKeys.php | 3 +- .../Http/Controllers/Auth/LoginController.php | 33 +---- .../Services/LoginCredentialEncryption.php | 103 ++++++------- resources/views/auth/login.blade.php | 3 +- resources/views/auth/newLogin.blade.php | 13 +- .../login-credential-encryption.blade.php | 135 ++++++------------ tests/Feature/LoginEncryptionTest.php | 25 +--- .../InteractsWithLoginEncryptionKeys.php | 29 ++++ .../LoginCredentialEncryptionTest.php | 27 ++-- 9 files changed, 156 insertions(+), 215 deletions(-) create mode 100644 tests/Support/InteractsWithLoginEncryptionKeys.php diff --git a/ProcessMaker/Console/Commands/GenerateLoginKeys.php b/ProcessMaker/Console/Commands/GenerateLoginKeys.php index 16f6427503..12b1378671 100644 --- a/ProcessMaker/Console/Commands/GenerateLoginKeys.php +++ b/ProcessMaker/Console/Commands/GenerateLoginKeys.php @@ -9,8 +9,7 @@ class GenerateLoginKeys extends Command { - protected $signature = 'login:generate-keys - {--force : Overwrite existing key files}'; + protected $signature = 'login:generate-keys {--force : Overwrite existing key files}'; protected $description = 'Generate RSA key pair for encrypted login credentials'; diff --git a/ProcessMaker/Http/Controllers/Auth/LoginController.php b/ProcessMaker/Http/Controllers/Auth/LoginController.php index e26833fb2f..47498d220e 100644 --- a/ProcessMaker/Http/Controllers/Auth/LoginController.php +++ b/ProcessMaker/Http/Controllers/Auth/LoginController.php @@ -116,12 +116,7 @@ public function showLoginForm(Request $request) false, $this->sessionSameSite() ); - $loginEncryption = app(LoginCredentialEncryption::class); - $loginPublicKey = null; - if ($loginEncryption->isEnabled()) { - $loginEncryption->ensureKeyPair(); - $loginPublicKey = $loginEncryption->getPublicKeyPem(); - } + $loginPublicKey = app(LoginCredentialEncryption::class)->publicKeyForLogin(); $loginView = empty(config('app.login_view')) ? 'auth.login' : config('app.login_view'); $response = response(view($loginView, compact('addons', 'block', 'loginPublicKey'))); @@ -202,7 +197,7 @@ protected function getColumnAttribute(object $setting, string $attribute, string public function loginWithIntendedCheck(Request $request) { - if (!$this->decryptLoginCredentialsIfNeeded($request)) { + if (!app(LoginCredentialEncryption::class)->mergeDecryptedCredentials($request)) { $this->sendFailedLoginResponse($request); } @@ -449,28 +444,4 @@ private function sessionSameSite(): string { return config('session.same_site') ?: 'lax'; } - - private function decryptLoginCredentialsIfNeeded(Request $request): bool - { - if (!$request->boolean('encrypted')) { - return true; - } - - $encryption = app(LoginCredentialEncryption::class); - if (!$encryption->isEnabled() || !$encryption->hasKeyPair()) { - return false; - } - - $credentials = $encryption->decryptCredentials((string) $request->input('encrypted_credentials', '')); - if ($credentials === null) { - return false; - } - - $request->merge([ - 'username' => $credentials['username'], - 'password' => $credentials['password'], - ]); - - return true; - } } diff --git a/ProcessMaker/Services/LoginCredentialEncryption.php b/ProcessMaker/Services/LoginCredentialEncryption.php index 6c16815eb1..6267d94a0c 100644 --- a/ProcessMaker/Services/LoginCredentialEncryption.php +++ b/ProcessMaker/Services/LoginCredentialEncryption.php @@ -4,36 +4,50 @@ namespace ProcessMaker\Services; +use Illuminate\Http\Request; + class LoginCredentialEncryption { - private const PRIVATE_KEY_FILE = 'login_private.pem'; + private const KEYS_DIR = 'app/keys'; + + private const PRIVATE_KEY = 'login_private.pem'; - private const PUBLIC_KEY_FILE = 'login_public.pem'; + private const PUBLIC_KEY = 'login_public.pem'; public function isEnabled(): bool { return (bool) config('auth.login_encrypt_credentials', true); } - public function hasKeyPair(): bool + public function publicKeyForLogin(): ?string { - return is_readable($this->privateKeyPath()) && is_readable($this->publicKeyPath()); + if (!$this->isEnabled()) { + return null; + } + + $this->ensureKeyPair(); + + return trim((string) file_get_contents($this->path(self::PUBLIC_KEY))); } - public function ensureKeyPair(): void + public function mergeDecryptedCredentials(Request $request): bool { - if (!$this->hasKeyPair()) { - $this->generateKeyPair(); + if (!$request->boolean('encrypted')) { + return true; } - } - public function getPublicKeyPem(): ?string - { - if (!$this->hasKeyPair()) { - return null; + if (!$this->isEnabled() || !$this->hasKeyPair()) { + return false; + } + + $credentials = $this->decryptCredentials((string) $request->input('encrypted_credentials', '')); + if ($credentials === null) { + return false; } - return trim((string) file_get_contents($this->publicKeyPath())); + $request->merge($credentials); + + return true; } /** @@ -41,19 +55,15 @@ public function getPublicKeyPem(): ?string */ public function decryptCredentials(string $cipherTextBase64): ?array { - $privateKey = openssl_pkey_get_private((string) file_get_contents($this->privateKeyPath())); - if ($privateKey === false) { - return null; - } - + $privateKey = openssl_pkey_get_private((string) file_get_contents($this->path(self::PRIVATE_KEY))); $cipher = base64_decode($cipherTextBase64, true); - if ($cipher === false) { + $plain = ''; + + if ($privateKey === false || $cipher === false) { return null; } - $plain = ''; - $success = openssl_private_decrypt($cipher, $plain, $privateKey, OPENSSL_PKCS1_OAEP_PADDING); - if (!$success) { + if (!openssl_private_decrypt($cipher, $plain, $privateKey, OPENSSL_PKCS1_OAEP_PADDING)) { return null; } @@ -70,14 +80,10 @@ public function encryptCredentials(string $username, string $password, ?int $tim 't' => $timestamp ?? time(), ], JSON_THROW_ON_ERROR); - $publicKey = openssl_pkey_get_public((string) file_get_contents($this->publicKeyPath())); - if ($publicKey === false) { - throw new \RuntimeException('Unable to load login public key.'); - } - + $publicKey = openssl_pkey_get_public((string) file_get_contents($this->path(self::PUBLIC_KEY))); $encrypted = ''; - $success = openssl_public_encrypt($payload, $encrypted, $publicKey, OPENSSL_PKCS1_OAEP_PADDING); - if (!$success) { + + if ($publicKey === false || !openssl_public_encrypt($payload, $encrypted, $publicKey, OPENSSL_PKCS1_OAEP_PADDING)) { throw new \RuntimeException('Unable to encrypt login credentials.'); } @@ -86,7 +92,7 @@ public function encryptCredentials(string $username, string $password, ?int $tim public function generateKeyPair(): void { - $directory = $this->keysDirectory(); + $directory = storage_path(self::KEYS_DIR); if (!is_dir($directory)) { mkdir($directory, 0755, true); } @@ -100,7 +106,8 @@ public function generateKeyPair(): void throw new \RuntimeException('Unable to generate login RSA key pair.'); } - if (!openssl_pkey_export_to_file($key, $this->privateKeyPath())) { + $privatePath = $this->path(self::PRIVATE_KEY); + if (!openssl_pkey_export_to_file($key, $privatePath)) { throw new \RuntimeException('Unable to write login private key.'); } @@ -109,8 +116,20 @@ public function generateKeyPair(): void throw new \RuntimeException('Unable to extract login public key.'); } - file_put_contents($this->publicKeyPath(), $details['key']); - chmod($this->privateKeyPath(), 0600); + file_put_contents($this->path(self::PUBLIC_KEY), $details['key']); + chmod($privatePath, 0600); + } + + public function hasKeyPair(): bool + { + return is_readable($this->path(self::PRIVATE_KEY)) && is_readable($this->path(self::PUBLIC_KEY)); + } + + private function ensureKeyPair(): void + { + if (!$this->hasKeyPair()) { + $this->generateKeyPair(); + } } /** @@ -123,9 +142,7 @@ private function parsePayload(string $json): ?array return null; } - $ttl = (int) config('auth.login_encrypt_ttl', 300); - $timestamp = (int) $data['t']; - if (abs(time() - $timestamp) > $ttl) { + if (abs(time() - (int) $data['t']) > (int) config('auth.login_encrypt_ttl', 300)) { return null; } @@ -135,18 +152,8 @@ private function parsePayload(string $json): ?array ]; } - private function keysDirectory(): string - { - return storage_path('app/keys'); - } - - private function privateKeyPath(): string - { - return $this->keysDirectory() . '/' . self::PRIVATE_KEY_FILE; - } - - private function publicKeyPath(): string + private function path(string $file): string { - return $this->keysDirectory() . '/' . self::PUBLIC_KEY_FILE; + return storage_path(self::KEYS_DIR . '/' . $file); } } diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 92eaba9eef..aa47a2461e 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -14,7 +14,8 @@
@if (! $block) -
+ + @csrf @if (session()->has('timeout'))
{{ __("Your account has been timed out for security.") }}
@endif diff --git a/resources/views/auth/newLogin.blade.php b/resources/views/auth/newLogin.blade.php index f254b2cd0b..a31b6658de 100644 --- a/resources/views/auth/newLogin.blade.php +++ b/resources/views/auth/newLogin.blade.php @@ -8,7 +8,6 @@ - @include('auth.partials.login-credential-encryption') {{ __('Login') }} - {{ __('ProcessMaker') }} @include('auth.partials.login-critical-styles') @@ -35,7 +34,8 @@ @endcomponent
@if (! $block) - + + @csrf @if (session()->has('timeout'))
{{ __("Your account has been timed out for security.") }}
@endif @@ -140,8 +140,7 @@ class="login-remember-input" @endif
- - -@include('auth.partials.auth-language-scripts') + + @include('auth.partials.auth-language-scripts') + @include('auth.partials.login-credential-encryption') + diff --git a/resources/views/auth/partials/login-credential-encryption.blade.php b/resources/views/auth/partials/login-credential-encryption.blade.php index b6cf42e530..6a8976020d 100644 --- a/resources/views/auth/partials/login-credential-encryption.blade.php +++ b/resources/views/auth/partials/login-credential-encryption.blade.php @@ -1,107 +1,60 @@ @if (!empty($loginPublicKey)) -