Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
## Changelog

6.0.1 - 2026-10-05
- Support rotating the encryption key of the stored Slack OAuth token with the `core:rotate-encryption-keys` console command (available from the Matomo release that introduces it)

6.0.0 - 2026-08-10
- Compatibility with Matomo 6

Expand Down
28 changes: 25 additions & 3 deletions Encryption.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,29 @@ class Encryption
*/
private $configuration;

/**
* @var string|null
*/
private $key;

public function __construct(?Configuration $configuration = null)
{
$this->configuration = $configuration ?: new Configuration();
}

/**
* Returns an instance that uses the given key instead of the one in the config, eg while the key is rotated.
*/
public static function withKey(
#[\SensitiveParameter]
string $key
): self {
$encryption = new self();
$encryption->key = $key;

return $encryption;
}

public function isEncrypted($value): bool
{
return is_string($value) && strpos($value, self::ENCRYPTED_PREFIX) === 0;
Expand Down Expand Up @@ -101,9 +119,13 @@ public function decryptString(

private function getEncryptionKey(bool $createIfMissing): string
{
$key = $createIfMissing
? $this->configuration->getOrCreateEncryptionKey()
: $this->configuration->getEncryptionKey();
if ($this->key !== null) {
$key = $this->key;
} else {
$key = $createIfMissing
? $this->configuration->getOrCreateEncryptionKey()
: $this->configuration->getEncryptionKey();
}

if ($key === '') {
throw new SecretConfigurationException($this->getInvalidKeyMessage());
Expand Down
19 changes: 19 additions & 0 deletions Slack.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public function registerEvents()
'Translate.getClientSideTranslationKeys' => 'getClientSideTranslationKeys',
'CustomAlerts.validateReportParameters' => 'validateCustomAlertReportParameters',
'CustomAlerts.sendNewAlerts' => 'sendNewAlerts',
'CoreAdminHome.getEncryptionKeyRotationTargets' => 'getEncryptionKeyRotationTargets',
];
}

Expand All @@ -67,6 +68,24 @@ public function requiresInternetConnection()
return true;
}

public function getEncryptionKeyRotationTargets(array &$targets): void
{
$targets['Slack'] = [
'configSection' => Configuration::SECTION_NAME,
'configKey' => Configuration::KEY_ENCRYPTION_KEY,
'pluginSettings' => ['slackOauthToken'],
'isEncrypted' => function ($value): bool {
return (new Encryption())->isEncrypted($value);
},
'decrypt' => function (string $value, #[\SensitiveParameter] string $key): string {
return Encryption::withKey($key)->decryptString($value);
},
'encrypt' => function (#[\SensitiveParameter] string $value, #[\SensitiveParameter] string $key): string {
return Encryption::withKey($key)->encryptString($value);
},
];
}

public function getClientSideTranslationKeys(&$translationKeys)
{
$translationKeys[] = 'Slack_ChannelId';
Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -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": "6.0.0",
"version": "6.0.1",
"theme": false,
"require": {
"matomo": ">=6.0.0-b1,<7.0.0-b1"
Expand Down
101 changes: 101 additions & 0 deletions tests/Integration/EncryptionKeyRotationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

/**
* Matomo - free/libre analytics platform
*
* @link https://matomo.org
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
*/

namespace Piwik\Plugins\Slack\tests;

use Piwik\Common;
use Piwik\Config;
use Piwik\Db;
use Piwik\Piwik;
use Piwik\Plugins\CoreAdminHome\EncryptionKeyRotator;
use Piwik\Plugins\CoreAdminHome\tests\Framework\Mock\FileBackedConfig;
use Piwik\Plugins\Slack\Configuration;
use Piwik\Plugins\Slack\Encryption;
use Piwik\Plugins\Slack\Exceptions\SecretConfigurationException;
use Piwik\Plugins\Slack\SystemSettings;
use Piwik\Tests\Framework\Fixture;
use Piwik\Tests\Framework\TestCase\IntegrationTestCase;

/**
* @group Slack
* @group EncryptionKeyRotationTest
* @group Plugins
*/
class EncryptionKeyRotationTest extends IntegrationTestCase
{
private $backupSlackConfig = [];

/**
* @var FileBackedConfig|null
*/
private $config;

public function setUp(): void
{
parent::setUp();

$this->backupSlackConfig = Config::getInstance()->Slack ?: [];

// the rotation command is only available from the Matomo release that introduced it
if (!class_exists(EncryptionKeyRotator::class)) {
$this->markTestSkipped('Encryption key rotation is not available in this Matomo version.');
}

Config::getInstance()->Slack = [Configuration::KEY_ENCRYPTION_KEY => 'old-key'];
$this->config = FileBackedConfig::replaceTestConfig();

Fixture::loadAllTranslations();
Fixture::createSuperUser();
}

public function tearDown(): void
{
if ($this->config !== null) {
$this->config->deleteFile();
}

Config::getInstance()->Slack = $this->backupSlackConfig;

parent::tearDown();
}

public function testRotationReEncryptsTheOauthTokenWithTheNewKey()
{
$settings = new SystemSettings();
$settings->slackOauthToken->setValue('xoxb-token');
$settings->save();
$oldStoredValue = $this->getStoredTokenValue();

$targets = [];
Piwik::postEvent('CoreAdminHome.getEncryptionKeyRotationTargets', [&$targets]);
$count = (new EncryptionKeyRotator())->rotate('Slack', $targets['Slack']);

$this->assertSame(1, $count);

$newKey = Config::getInstance()->Slack[Configuration::KEY_ENCRYPTION_KEY];
$this->assertSame($newKey, $this->config->readFile()['Slack'][Configuration::KEY_ENCRYPTION_KEY]);
$this->assertNotSame('old-key', $newKey);

$newStoredValue = $this->getStoredTokenValue();
$this->assertNotSame($oldStoredValue, $newStoredValue);
$this->assertSame('xoxb-token', Encryption::withKey($newKey)->decryptString($newStoredValue));
$this->assertSame('xoxb-token', (new SystemSettings())->slackOauthToken->getValue());

$this->expectException(SecretConfigurationException::class);
Encryption::withKey('old-key')->decryptString($newStoredValue);
}

private function getStoredTokenValue(): string
{
return Db::fetchOne(
'SELECT `setting_value` FROM `' . Common::prefixTable('plugin_setting') . '` WHERE `plugin_name` = ? AND `user_login` = ? AND `setting_name` = ?',
['Slack', '', 'slackOauthToken']
);
}
}
22 changes: 22 additions & 0 deletions tests/Integration/EncryptionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,26 @@ public function test_shouldFailToDecryptTamperedPayload()
$encrypted = $encryption->encryptString('very-secret-value');
$encryption->decryptString(substr($encrypted, 0, -2) . 'ab');
}

public function test_withKey_shouldEncryptAndDecryptRoundTrip()
{
$encrypted = Encryption::withKey('key-a')->encryptString('very-secret-value');

$this->assertSame('very-secret-value', Encryption::withKey('key-a')->decryptString($encrypted));
}

public function test_withKey_shouldFailToDecryptWithAnotherKey()
{
$encrypted = Encryption::withKey('key-a')->encryptString('very-secret-value');

$this->expectException(SecretConfigurationException::class);
Encryption::withKey('key-b')->decryptString($encrypted);
}

public function test_withKey_shouldRejectAnEmptyKey()
{
$this->expectException(SecretConfigurationException::class);

Encryption::withKey('')->encryptString('very-secret-value');
}
}
Loading