From ab83cfe3d3b68e24e2207087747a3a45e67ff83f Mon Sep 17 00:00:00 2001 From: Altamash Shaikh Date: Tue, 7 Jul 2026 14:22:22 +0530 Subject: [PATCH 1/4] Adds code to encrypt sensitive values in database to improve security, #PG-5235 --- CHANGELOG.md | 3 + Configuration.php | 60 ++++++++++ Encryption.php | 119 +++++++++++++++++++ Exceptions/SecretConfigurationException.php | 14 +++ Settings/EncryptedSlackOauthTokenSetting.php | 84 +++++++++++++ SystemSettings.php | 7 +- Updates/5.1.0.php | 44 +++++++ plugin.json | 4 +- tests/Integration/EncryptionTest.php | 72 +++++++++++ tests/Integration/SystemSettingsTest.php | 47 ++++++++ 10 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 Configuration.php create mode 100644 Encryption.php create mode 100644 Exceptions/SecretConfigurationException.php create mode 100644 Settings/EncryptedSlackOauthTokenSetting.php create mode 100644 Updates/5.1.0.php create mode 100644 tests/Integration/EncryptionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f68f8f6..a268ed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## Changelog +5.1.0 - 2026-07-13 +- Added code to encrypt sensitive values in database to improve security + 5.0.4 - 2026-01-05 - Updated Api timeout to 5 seconds and changed log levels diff --git a/Configuration.php b/Configuration.php new file mode 100644 index 0000000..6b3ff6e --- /dev/null +++ b/Configuration.php @@ -0,0 +1,60 @@ +getOrCreateEncryptionKey(); + } + + public function getEncryptionKey(): string + { + $config = $this->getConfig(); + + return (string) ($config->{self::SECTION_NAME}[self::KEY_ENCRYPTION_KEY] ?? ''); + } + + public function getOrCreateEncryptionKey(): string + { + $key = $this->getEncryptionKey(); + + if ($key !== '') { + return $key; + } + + $key = base64_encode(random_bytes(32)); + $this->setEncryptionKey($key); + + return $key; + } + + public function setEncryptionKey( + #[\SensitiveParameter] + string $key + ): void { + $config = $this->getConfig(); + $pluginConfig = $config->{self::SECTION_NAME} ?: []; + $pluginConfig[self::KEY_ENCRYPTION_KEY] = $key; + $config->{self::SECTION_NAME} = $pluginConfig; + $config->forceSave(); + } + + private function getConfig(): Config + { + return Config::getInstance(); + } +} diff --git a/Encryption.php b/Encryption.php new file mode 100644 index 0000000..d9d28a2 --- /dev/null +++ b/Encryption.php @@ -0,0 +1,119 @@ +configuration = $configuration ?: new Configuration(); + } + + public function isEncrypted($value): bool + { + return is_string($value) && strpos($value, self::ENCRYPTED_PREFIX) === 0; + } + + public function encryptString( + #[\SensitiveParameter] + string $value + ): string { + if (!extension_loaded('openssl')) { + throw new SecretConfigurationException(Piwik::translate('General_ExceptionInvalidState', 'OpenSSL is required to encrypt Slack credentials.')); + } + + $key = $this->getEncryptionKey(true); + $ivLength = (int) openssl_cipher_iv_length(self::CIPHER); + $iv = random_bytes($ivLength); + $ciphertext = openssl_encrypt($value, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv); + + if (!is_string($ciphertext)) { + throw new SecretConfigurationException(Piwik::translate('General_ExceptionInvalidState', 'Failed to encrypt the Slack credential.')); + } + + $payload = [ + 'iv' => base64_encode($iv), + 'value' => base64_encode($ciphertext), + 'mac' => base64_encode(hash_hmac('sha256', self::ENCRYPTED_PREFIX . $iv . $ciphertext, $key, true)), + ]; + + return self::ENCRYPTED_PREFIX . base64_encode(json_encode($payload)); + } + + public function decryptString( + #[\SensitiveParameter] + string $value + ): string { + if (!$this->isEncrypted($value)) { + return $value; + } + + if (!extension_loaded('openssl')) { + throw new SecretConfigurationException(Piwik::translate('General_ExceptionInvalidState', 'OpenSSL is required to decrypt Slack credentials.')); + } + + $key = $this->getEncryptionKey(false); + $encodedPayload = substr($value, strlen(self::ENCRYPTED_PREFIX)); + $payload = json_decode(base64_decode($encodedPayload, true) ?: '', true); + + if (empty($payload['iv']) || empty($payload['value']) || empty($payload['mac'])) { + throw new SecretConfigurationException($this->getInvalidKeyMessage()); + } + + $iv = base64_decode($payload['iv'], true); + $ciphertext = base64_decode($payload['value'], true); + $mac = base64_decode($payload['mac'], true); + + if (!is_string($iv) || !is_string($ciphertext) || !is_string($mac)) { + throw new SecretConfigurationException($this->getInvalidKeyMessage()); + } + + $expectedMac = hash_hmac('sha256', self::ENCRYPTED_PREFIX . $iv . $ciphertext, $key, true); + if (!hash_equals($expectedMac, $mac)) { + throw new SecretConfigurationException($this->getInvalidKeyMessage()); + } + + $plaintext = openssl_decrypt($ciphertext, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv); + if (!is_string($plaintext)) { + throw new SecretConfigurationException($this->getInvalidKeyMessage()); + } + + return $plaintext; + } + + private function getEncryptionKey(bool $createIfMissing): string + { + $key = $createIfMissing + ? $this->configuration->getOrCreateEncryptionKey() + : $this->configuration->getEncryptionKey(); + + if ($key === '') { + throw new SecretConfigurationException($this->getInvalidKeyMessage()); + } + + return hash('sha256', $key, true); + } + + private function getInvalidKeyMessage(): string + { + return 'Slack encryption key is missing or invalid.'; + } +} diff --git a/Exceptions/SecretConfigurationException.php b/Exceptions/SecretConfigurationException.php new file mode 100644 index 0000000..c54e4e4 --- /dev/null +++ b/Exceptions/SecretConfigurationException.php @@ -0,0 +1,14 @@ +encryption = $encryption ?: new Encryption(); + } + + public function getValue() + { + $value = parent::getValue(); + + if (!is_string($value) || $value === '') { + return $value; + } + + try { + return $this->encryption->decryptString($value); + } catch (SecretConfigurationException $e) { + Log::error('[Slack] ' . $e->getMessage()); + return $this->getDefaultValue(); + } + } + + public function setValue($value) + { + if (($value === '' || !is_string($value)) && $this->hasUndecryptableValue()) { + return; + } + + parent::setValue($value); + $normalizedValue = parent::getValue(); + + if (!is_string($normalizedValue) || $normalizedValue === '' || $this->encryption->isEncrypted($normalizedValue)) { + return; + } + + $encryptedValue = $this->encryption->encryptString($normalizedValue); + $backend = $this->storage->getBackend(); + if ($backend instanceof PluginSettingsTable) { + $backend->saveValue($this->name, $encryptedValue); + } + + $this->storage->setValue($this->name, $encryptedValue); + } + + private function hasUndecryptableValue(): bool + { + $stored = parent::getValue(); + + if (!is_string($stored) || !$this->encryption->isEncrypted($stored)) { + return false; + } + + try { + $this->encryption->decryptString($stored); + return false; + } catch (SecretConfigurationException $e) { + return true; + } + } +} diff --git a/SystemSettings.php b/SystemSettings.php index 71cce96..5549a68 100644 --- a/SystemSettings.php +++ b/SystemSettings.php @@ -12,6 +12,7 @@ use Piwik\Piwik; use Piwik\Settings\Setting; use Piwik\Settings\FieldConfig; +use Piwik\Plugins\Slack\Settings\EncryptedSlackOauthTokenSetting; use Piwik\Url; class SystemSettings extends \Piwik\Settings\Plugin\SystemSettings @@ -27,7 +28,8 @@ protected function init() private function createSlackOauthTokenSetting() { - return $this->makeSetting('slackOauthToken', $default = '', FieldConfig::TYPE_STRING, function (FieldConfig $field) { + $setting = new EncryptedSlackOauthTokenSetting('slackOauthToken', $default = '', FieldConfig::TYPE_STRING, $this->pluginName); + $setting->setConfigureCallback(function (FieldConfig $field) { $field->title = Piwik::translate('Slack_OauthTokenSettingTitle'); $field->uiControl = FieldConfig::UI_CONTROL_PASSWORD; $link = Url::addCampaignParametersToMatomoLink('https://matomo.org/faq/reports/how-to-get-the-slack-oauth-token-for-matomo-integration/', null, null, 'App.SystemSettings.Slack') . '#step-1-get-a-slack-oauth-token'; @@ -36,5 +38,8 @@ private function createSlackOauthTokenSetting() return trim($value); }; }); + $this->addSetting($setting); + + return $setting; } } diff --git a/Updates/5.1.0.php b/Updates/5.1.0.php new file mode 100644 index 0000000..1b75bc0 --- /dev/null +++ b/Updates/5.1.0.php @@ -0,0 +1,44 @@ +install(); + + $encryption = new Encryption($configuration); + $table = Common::prefixTable('plugin_setting'); + $rows = Db::fetchAll( + 'SELECT `setting_value` FROM ' . $table . ' WHERE `plugin_name` = ? AND `user_login` = ? AND `setting_name` = ?', + ['Slack', '', 'slackOauthToken'] + ); + + foreach ($rows as $row) { + $value = $row['setting_value'] ?? ''; + + if (!is_string($value) || $value === '' || $encryption->isEncrypted($value)) { + continue; + } + + Db::query( + 'UPDATE ' . $table . ' SET `setting_value` = ? WHERE `plugin_name` = ? AND `user_login` = ? AND `setting_name` = ? AND `setting_value` = ?', + [$encryption->encryptString($value), 'Slack', '', 'slackOauthToken', $value] + ); + } + } +} diff --git a/plugin.json b/plugin.json index d750b45..5027835 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "name": "Slack", "description": "Send Matomo reports and alerts to Slack channels, keeping your team informed and ready to act in real time.", - "version": "5.0.4", + "version": "5.1.0", "theme": false, "require": { "matomo": ">=5.0.0,<6.0.0-b1" @@ -28,4 +28,4 @@ "CustomAlerts" ], "category": "integration" -} \ No newline at end of file +} diff --git a/tests/Integration/EncryptionTest.php b/tests/Integration/EncryptionTest.php new file mode 100644 index 0000000..71c81a5 --- /dev/null +++ b/tests/Integration/EncryptionTest.php @@ -0,0 +1,72 @@ +key; + } + + public function getOrCreateEncryptionKey(): string + { + if ($this->key === '') { + $this->key = base64_encode(random_bytes(32)); + } + + return $this->key; + } + + public function setEncryptionKey( + #[\SensitiveParameter] + string $key + ): void { + $this->key = $key; + } + }; + } + + public function test_shouldEncryptAndDecryptRoundTrip() + { + $encryption = new Encryption($this->makeConfiguration()); + $encrypted = $encryption->encryptString('very-secret-value'); + + $this->assertTrue($encryption->isEncrypted($encrypted)); + $this->assertNotSame('very-secret-value', $encrypted); + $this->assertSame('very-secret-value', $encryption->decryptString($encrypted)); + } + + public function test_shouldFailToDecryptTamperedPayload() + { + $this->expectException(SecretConfigurationException::class); + + $encryption = new Encryption($this->makeConfiguration()); + $encrypted = $encryption->encryptString('very-secret-value'); + $encryption->decryptString(substr($encrypted, 0, -2) . 'ab'); + } +} diff --git a/tests/Integration/SystemSettingsTest.php b/tests/Integration/SystemSettingsTest.php index 8a24598..23a6ba6 100644 --- a/tests/Integration/SystemSettingsTest.php +++ b/tests/Integration/SystemSettingsTest.php @@ -9,9 +9,12 @@ namespace Piwik\Plugins\Slack\tests; +use Piwik\Config; +use Piwik\Plugins\Slack\Configuration; use Piwik\Tests\Framework\TestCase\IntegrationTestCase; use Piwik\Plugins\Slack\SystemSettings; use Piwik\Tests\Framework\Fixture; +use Piwik\Settings\Storage\Factory; /** * @group Slack @@ -22,11 +25,14 @@ class SystemSettingsTest extends IntegrationTestCase { private $settings; + private $backupSlackConfig = []; public function setUp(): void { parent::setUp(); + $this->backupSlackConfig = Config::getInstance()->Slack ?: []; + Fixture::loadAllTranslations(); Fixture::createSuperUser(); @@ -35,6 +41,14 @@ public function setUp(): void $this->settings = new SystemSettings(); } + public function tearDown(): void + { + Config::getInstance()->Slack = $this->backupSlackConfig; + Config::getInstance()->forceSave(); + + parent::tearDown(); + } + public function testSlackOauthTokenDefaultValue() { $this->assertEmpty($this->settings->slackOauthToken->getValue()); @@ -44,11 +58,44 @@ public function testSlackOauthTokenValueChangeSuccess() { $this->settings->slackOauthToken->setValue('token'); $this->assertEquals('token', $this->settings->slackOauthToken->getValue()); + $this->assertStoredValueIsEncrypted('token'); } public function testSlackOauthTokenValueChangeSuccess2() { $this->settings->slackOauthToken->setValue('token '); $this->assertEquals('token', $this->settings->slackOauthToken->getValue()); + $this->assertStoredValueIsEncrypted('token'); + } + + public function testShouldNotOverwriteEncryptedValueWhenKeyIsInvalidAndBlankValueIsSaved() + { + $this->settings->slackOauthToken->setValue('token'); + $storedValue = $this->getStoredTokenValue(); + + Config::getInstance()->Slack[Configuration::KEY_ENCRYPTION_KEY] = 'invalid-key'; + + $this->settings = new SystemSettings(); + + $this->assertSame('', $this->settings->slackOauthToken->getValue()); + + $this->settings->slackOauthToken->setValue(''); + + $this->assertSame($storedValue, $this->getStoredTokenValue()); + } + + private function assertStoredValueIsEncrypted(string $expectedPlaintext): void + { + $storedValue = $this->getStoredTokenValue(); + + $this->assertNotSame($expectedPlaintext, $storedValue); + $this->assertStringStartsWith('enc:v1:', $storedValue); + } + + private function getStoredTokenValue(): string + { + $backend = (new Factory())->getPluginStorage('Slack', '')->getBackend(); + + return (string) $backend->loadValue('slackOauthToken', ''); } } From cecc204c8f569e14add05302d5fb549cb3ca63ef Mon Sep 17 00:00:00 2001 From: Altamash Shaikh Date: Tue, 7 Jul 2026 14:33:06 +0530 Subject: [PATCH 2/4] Fixes for failing test --- Settings/EncryptedSlackOauthTokenSetting.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Settings/EncryptedSlackOauthTokenSetting.php b/Settings/EncryptedSlackOauthTokenSetting.php index fb60395..ef6c962 100644 --- a/Settings/EncryptedSlackOauthTokenSetting.php +++ b/Settings/EncryptedSlackOauthTokenSetting.php @@ -58,12 +58,14 @@ public function setValue($value) } $encryptedValue = $this->encryption->encryptString($normalizedValue); + $this->storage->setValue($this->name, $encryptedValue); $backend = $this->storage->getBackend(); - if ($backend instanceof PluginSettingsTable) { + if ($backend instanceof PluginSettingsTable && method_exists($backend, 'saveValue')) { $backend->saveValue($this->name, $encryptedValue); + return; } - $this->storage->setValue($this->name, $encryptedValue); + $this->storage->save(); } private function hasUndecryptableValue(): bool From ac62110acbae23e7b380a59a74aef9de534cfbb5 Mon Sep 17 00:00:00 2001 From: Altamash Shaikh Date: Tue, 7 Jul 2026 14:36:48 +0530 Subject: [PATCH 3/4] fixes tests --- tests/Integration/SystemSettingsTest.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/Integration/SystemSettingsTest.php b/tests/Integration/SystemSettingsTest.php index 23a6ba6..1e389cb 100644 --- a/tests/Integration/SystemSettingsTest.php +++ b/tests/Integration/SystemSettingsTest.php @@ -96,6 +96,12 @@ private function getStoredTokenValue(): string { $backend = (new Factory())->getPluginStorage('Slack', '')->getBackend(); - return (string) $backend->loadValue('slackOauthToken', ''); + if (method_exists($backend, 'loadValue')) { + return (string) $backend->loadValue('slackOauthToken', ''); + } + + $values = $backend->load(); + + return isset($values['slackOauthToken']) ? (string) $values['slackOauthToken'] : ''; } } From af37cfc12db9583601e825e467de4dbefeec64ec Mon Sep 17 00:00:00 2001 From: Altamash Shaikh Date: Tue, 7 Jul 2026 14:43:28 +0530 Subject: [PATCH 4/4] Adds missing translation --- Encryption.php | 8 ++++---- lang/en.json | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Encryption.php b/Encryption.php index d9d28a2..befe6b8 100644 --- a/Encryption.php +++ b/Encryption.php @@ -37,7 +37,7 @@ public function encryptString( string $value ): string { if (!extension_loaded('openssl')) { - throw new SecretConfigurationException(Piwik::translate('General_ExceptionInvalidState', 'OpenSSL is required to encrypt Slack credentials.')); + throw new SecretConfigurationException(Piwik::translate('Slack_EncryptionOpenSslRequired')); } $key = $this->getEncryptionKey(true); @@ -46,7 +46,7 @@ public function encryptString( $ciphertext = openssl_encrypt($value, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv); if (!is_string($ciphertext)) { - throw new SecretConfigurationException(Piwik::translate('General_ExceptionInvalidState', 'Failed to encrypt the Slack credential.')); + throw new SecretConfigurationException(Piwik::translate('Slack_EncryptionFailed')); } $payload = [ @@ -67,7 +67,7 @@ public function decryptString( } if (!extension_loaded('openssl')) { - throw new SecretConfigurationException(Piwik::translate('General_ExceptionInvalidState', 'OpenSSL is required to decrypt Slack credentials.')); + throw new SecretConfigurationException(Piwik::translate('Slack_EncryptionOpenSslRequired')); } $key = $this->getEncryptionKey(false); @@ -114,6 +114,6 @@ private function getEncryptionKey(bool $createIfMissing): string private function getInvalidKeyMessage(): string { - return 'Slack encryption key is missing or invalid.'; + return Piwik::translate('Slack_EncryptionKeyInvalid'); } } diff --git a/lang/en.json b/lang/en.json index 5d1c6dc..1b358a9 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1,6 +1,9 @@ { "Slack": { "ChannelId": "Slack Channel ID", + "EncryptionFailed": "Failed to encrypt the Slack credential.", + "EncryptionKeyInvalid": "Slack encryption key is missing or invalid.", + "EncryptionOpenSslRequired": "OpenSSL is required to encrypt or decrypt Slack credentials.", "NoOauthTokenAdded": "Please add your Slack OAuth Token %1$shere%2$s.", "OauthTokenRequiredErrorMessage": "To enable Slack, provide your Slack OAuth Token in General settings > Slack.", "OauthTokenSettingTitle": "Slack OAuth Token",