diff --git a/CRM/Utils/Hook.php b/CRM/Utils/Hook.php index 920cdf74e145..e5f576e408d5 100644 --- a/CRM/Utils/Hook.php +++ b/CRM/Utils/Hook.php @@ -2083,6 +2083,47 @@ public static function permission_check($permission, &$granted, $contactId) { ); } + /** + * Rotate the cryptographic key used in the database. + * + * The purpose of this hook is to visit any encrypted values in the database + * and re-encrypt the content. + * + * For values encoded via `CryptoToken`, you can use `CryptoToken::rekey($oldToken, $tag)` + * + * @param string $tag + * The type of crypto-key that is currently being rotated. + * The hook-implementer should use this to decide which (if any) fields to visit. + * Ex: 'CRED' + * @param \Psr\Log\LoggerInterface $log + * List of messages about re-keyed values. + * + * @code + * function example_civicrm_rekey($tag, &$log) { + * if ($tag !== 'CRED') return; + * + * $cryptoToken = Civi::service('crypto.token'); + * $rows = sql('SELECT id, secret_column FROM some_table'); + * foreach ($rows as $row) { + * $new = $cryptoToken->rekey($row['secret_column']); + * if ($new !== NULL) { + * sql('UPDATE some_table SET secret_column = %1 WHERE id = %2', + * $new, $row['id']); + * } + * } + * } + * @endCode + * + * @return null + * The return value is ignored + */ + public static function cryptoRotateKey($tag, $log) { + return self::singleton()->invoke(['tag', 'log'], $tag, $log, self::$_nullObject, + self::$_nullObject, self::$_nullObject, self::$_nullObject, + 'civicrm_cryptoRotateKey' + ); + } + /** * @param CRM_Core_Exception $exception * @param mixed $request diff --git a/Civi/Api4/Action/System/RotateKey.php b/Civi/Api4/Action/System/RotateKey.php new file mode 100644 index 000000000000..c904a2c0e4bc --- /dev/null +++ b/Civi/Api4/Action/System/RotateKey.php @@ -0,0 +1,81 @@ +tag)) { + throw new \API_Exception("Missing required argument: tag"); + } + + // Track log of changes in memory. + $logger = new class() extends \Psr\Log\AbstractLogger { + + /** + * @var array + */ + public $log = []; + + /** + * Logs with an arbitrary level. + * + * @param mixed $level + * @param string $message + * @param array $context + */ + public function log($level, $message, array $context = []) { + $evalVar = function($m) use ($context) { + return $context[$m[1]] ?? ''; + }; + + $this->log[] = [ + 'level' => $level, + 'message' => preg_replace_callback('/\{([a-zA-Z0-9\.]+)\}/', $evalVar, $message), + ]; + } + + }; + + \CRM_Utils_Hook::cryptoRotateKey($this->tag, $logger); + + $result->exchangeArray($logger->log); + } + +} diff --git a/Civi/Api4/System.php b/Civi/Api4/System.php index da4ea2bb7554..0afefe11a1a5 100644 --- a/Civi/Api4/System.php +++ b/Civi/Api4/System.php @@ -43,6 +43,16 @@ public static function check($checkPermissions = TRUE) { ->setCheckPermissions($checkPermissions); } + /** + * @param bool $checkPermissions + * + * @return Action\System\RotateKey + */ + public static function rotateKey($checkPermissions = TRUE) { + return (new Action\System\RotateKey(__CLASS__, __FUNCTION__)) + ->setCheckPermissions($checkPermissions); + } + /** * @param bool $checkPermissions * @return Generic\BasicGetFieldsAction diff --git a/tests/phpunit/api/v4/Entity/SystemRotateKeyTest.php b/tests/phpunit/api/v4/Entity/SystemRotateKeyTest.php new file mode 100644 index 000000000000..5244fbd7fc2d --- /dev/null +++ b/tests/phpunit/api/v4/Entity/SystemRotateKeyTest.php @@ -0,0 +1,61 @@ +setHook('civicrm_crypto', [$this, 'registerExampleKeys']); + \CRM_Utils_Hook::singleton()->setHook('civicrm_cryptoRotateKey', [$this, 'onRotateKey']); + } + + public function testRekey() { + $result = \Civi\Api4\System::rotateKey(0)->setTag('UNIT-TEST')->execute(); + $this->assertEquals(2, count($result)); + $this->assertEquals('Updated field A using UNIT-TEST.', $result[0]['message']); + $this->assertEquals('info', $result[0]['level']); + $this->assertEquals('Updated field B using UNIT-TEST.', $result[1]['message']); + $this->assertEquals('info', $result[1]['level']); + } + + public function onRotateKey(string $tag, LoggerInterface $log) { + $this->assertEquals('UNIT-TEST', $tag); + $log->info('Updated field A using {tag}.', [ + 'tag' => $tag, + ]); + $log->info('Updated field B using {tag}.', [ + 'tag' => $tag, + ]); + } + +}