);
}
+ /**
+ * Initialize the cryptographic service.
+ *
+ * This may be used to register additional keys or cipher-suites.
+ *
+ * @param \Civi\Crypto\CryptoRegistry $crypto
+ *
+ * @return mixed
+ */
+ public static function crypto($crypto) {
+ return self::singleton()->invoke(['crypto'], $crypto, self::$_nullObject,
+ self::$_nullObject, self::$_nullObject, self::$_nullObject,
+ self::$_nullObject,
+ 'civicrm_crypto'
+ );
+ }
+
/**
* This hook collects the trigger definition from all components.
*
$container->setDefinition('pear_mail', new Definition('Mail'))
->setFactory('CRM_Utils_Mail::createMailer')->setPublic(TRUE);
+ $container->setDefinition('crypto.registry', new Definition('Civi\Crypto\CryptoService'))
+ ->setFactory(__CLASS__ . '::createCryptoRegistry')->setPublic(TRUE);
+
if (empty(\Civi::$statics[__CLASS__]['boot'])) {
throw new \RuntimeException('Cannot initialize container. Boot services are undefined.');
}
return new \ArrayObject($settings);
}
+ /**
+ * Initialize the cryptogrpahic registry. It tracks available ciphers and keys.
+ *
+ * @return \Civi\Crypto\CryptoRegistry
+ * @throws \CRM_Core_Exception
+ * @throws \Civi\Crypto\Exception\CryptoException
+ */
+ public static function createCryptoRegistry() {
+ $crypto = new \Civi\Crypto\CryptoRegistry();
+
+ $crypto->addPlainText(['tags' => ['CRED']]);
+ if (defined('CIVICRM_CRED_KEYS')) {
+ foreach (explode(' ', CIVICRM_CRED_KEYS) as $n => $keyExpr) {
+ $crypto->addSymmetricKey($crypto->parseKey($keyExpr) + [
+ 'tags' => ['CRED'],
+ 'weight' => $n,
+ ]);
+ }
+ }
+ if (defined('CIVICRM_SITE_KEY')) {
+ // Recent upgrades may not have CIVICRM_CRED_KEYS. Transitional support - the CIVICRM_SITE_KEY is last-priority option for credentials.
+ $crypto->addSymmetricKey([
+ 'key' => hash_hkdf('sha256', CIVICRM_SITE_KEY),
+ 'suite' => 'aes-cbc',
+ 'tags' => ['CRED'],
+ 'weight' => 30000,
+ ]);
+ }
+ //if (isset($_COOKIE['CIVICRM_FORM_KEY'])) {
+ // $crypto->addSymmetricKey([
+ // 'key' => base64_decode($_COOKIE['CIVICRM_FORM_KEY']),
+ // 'suite' => 'aes-cbc',
+ // 'tag' => ['FORM'],
+ // ]);
+ // // else: somewhere in CRM_Core_Form, we may need to initialize CIVICRM_FORM_KEY
+ //}
+
+ // Allow plugins to add/replace any keys and ciphers.
+ \CRM_Utils_Hook::crypto($crypto);
+ return $crypto;
+ }
+
/**
* Get a list of boot services.
*
--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved. |
+ | |
+ | This work is published under the GNU AGPLv3 license with some |
+ | permitted exceptions and without any warranty. For full license |
+ | and copyright information, see https://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+namespace Civi\Crypto;
+
+/**
+ * @package Civi\Crypt
+ */
+interface CipherSuiteInterface {
+
+ /**
+ * Get a list of supported cipher suites.
+ *
+ * @return array
+ * Ex: ['aes-cbc', 'aes-bbc', 'aes-pbs']
+ */
+ public function getSuites(): array;
+
+ /**
+ * Encrypt a string
+ *
+ * @param string $plainText
+ * @param array $key
+ *
+ * @return string
+ * Encrypted content as a binary string.
+ * Depending on the suite, this may include related values (eg HMAC + IV).
+ */
+ public function encrypt(string $plainText, array $key): string;
+
+ /**
+ * Decrypt a string
+ *
+ * @param string $cipherText
+ * Encrypted content as a binary string.
+ * Depending on the suite, this may include related values (eg HMAC + IV).
+ * @param array $key
+ *
+ * @return string
+ * Decrypted string
+ */
+ public function decrypt(string $cipherText, array $key): string;
+
+}
--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved. |
+ | |
+ | This work is published under the GNU AGPLv3 license with some |
+ | permitted exceptions and without any warranty. For full license |
+ | and copyright information, see https://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Crypto;
+
+use Civi\Crypto\Exception\CryptoException;
+
+/**
+ * The CryptoRegistry tracks a list of available keys and cipher suites:
+ *
+ * - A registered cipher suite is an instance of CipherSuiteInterface that
+ * provides a list of encryption options ("aes-cbc", "aes-ctr", etc) and
+ * an implementation for them.
+ * - A registered key is an array that indicates a set of cryptographic options:
+ * - key: string, binary representation of the key
+ * - suite: string, e.g. "aes-cbc" or "aes-cbc-hs"
+ * - id: string, unique (non-sensitive) ID. Usually a fingerprint.
+ * - tags: string[], list of symbolic names/use-cases that may call upon this key
+ * - weight: int, when choosing a key for encryption, two similar keys will be
+ * be differentiated by weight. (Low values chosen before high values.)
+ *
+ * @package CRM
+ * @copyright CiviCRM LLC https://civicrm.org/licensing
+ */
+class CryptoRegistry {
+
+ const LAST_WEIGHT = 32768;
+
+ const DEFAULT_SUITE = 'aes-cbc';
+
+ const DEFAULT_KDF = 'hkdf-sha256';
+
+ /**
+ * List of available keys.
+ *
+ * @var array[]
+ */
+ protected $keys = [];
+
+ /**
+ * List of key-derivation functions. Used when loading keys.
+ *
+ * @var array
+ */
+ protected $kdfs = [];
+
+ protected $cipherSuites = [];
+
+ public function __construct() {
+ $this->cipherSuites['plain'] = TRUE;
+ $this->keys['plain'] = [
+ 'key' => '',
+ 'suite' => 'plain',
+ 'tags' => [],
+ 'id' => 'plain',
+ 'weight' => self::LAST_WEIGHT,
+ ];
+
+ // Base64 - Useful for precise control. Relatively quick decode. Please bring your own entropy.
+ $this->kdfs['b64'] = 'base64_decode';
+
+ // HKDF - Forgiving about diverse inputs. Relatively quick decode. Please bring your own entropy.
+ $this->kdfs['hkdf-sha256'] = function($v) {
+ // NOTE: 256-bit output by default. Useful for pairing with AES-256.
+ return hash_hkdf('sha256', $v);
+ };
+
+ // Possible future options: Read from PEM file. Run PBKDF2 on a passphrase.
+ }
+
+ /**
+ * @param string|array $options
+ * Additional options:
+ * - key: string, a representation of the key as binary
+ * - suite: string, ex: 'aes-cbc'
+ * - tags: string[]
+ * - weight: int, default 0
+ * - id: string, a unique identifier for this key. (default: fingerprint the key+suite)
+ *
+ * @return array
+ * The full key record. (Same format as $options)
+ * @throws \Civi\Crypto\Exception\CryptoException
+ */
+ public function addSymmetricKey($options) {
+ $defaults = [
+ 'suite' => self::DEFAULT_SUITE,
+ 'weight' => 0,
+ ];
+ $options = array_merge($defaults, $options);
+
+ if (!isset($options['key'])) {
+ throw new CryptoException("Missing crypto key");
+ }
+
+ if (!isset($options['id'])) {
+ $options['id'] = \CRM_Utils_String::base64UrlEncode(sha1($options['suite'] . chr(0) . $options['key'], TRUE));
+ }
+ // Manual key IDs should be validated.
+ elseif (!$this->isValidKeyId($options['id'])) {
+ throw new CryptoException("Malformed key ID");
+ }
+
+ $this->keys[$options['id']] = $options;
+ return $options;
+ }
+
+ /**
+ * Determine if a key ID is well-formed.
+ *
+ * @param string $id
+ * @return bool
+ */
+ public function isValidKeyId($id) {
+ if (strpos($id, "\n") !== FALSE) {
+ return FALSE;
+ }
+ return (bool) preg_match(';^[a-zA-Z0-9_\-\.:,=+/\;\\\\]+$;s', $id);
+ }
+
+ /**
+ * Enable plain-text encoding.
+ *
+ * @param array $options
+ * Array with options:
+ * - tags: string[]
+ * @return array
+ */
+ public function addPlainText($options) {
+ if (!isset($this->keys['plain'])) {
+ }
+ if (isset($options['tags'])) {
+ $this->keys['plain']['tags'] = array_merge(
+ $options['tags']
+ );
+ }
+ return $this->keys['plain'];
+ }
+
+ /**
+ * @param CipherSuiteInterface $cipherSuite
+ * The encryption/decryption callback/handler
+ * @param string[]|NULL $names
+ * Symbolic names. Ex: 'aes-cbc'
+ * If NULL, probe $cipherSuite->getNames()
+ */
+ public function addCipherSuite(CipherSuiteInterface $cipherSuite, $names = NULL) {
+ $names = $names ?: $cipherSuite->getSuites();
+ foreach ($names as $name) {
+ $this->cipherSuites[$name] = $cipherSuite;
+ }
+ }
+
+ public function getKeys() {
+ return $this->keys;
+ }
+
+ /**
+ * Locate a key in the list of available keys.
+ *
+ * @param string|string[] $keyIds
+ * List of IDs or tags. The first match in the list is returned.
+ * If multiple keys match the same tag, then the one with lowest 'weight' is returned.
+ * @return array
+ * @throws \Civi\Crypto\Exception\CryptoException
+ */
+ public function findKey($keyIds) {
+ $keyIds = (array) $keyIds;
+ foreach ($keyIds as $keyIdOrTag) {
+ if (isset($this->keys[$keyIdOrTag])) {
+ return $this->keys[$keyIdOrTag];
+ }
+
+ $matchKeyId = NULL;
+ $matchWeight = self::LAST_WEIGHT;
+ foreach ($this->keys as $key) {
+ if (in_array($keyIdOrTag, $key['tags']) && $key['weight'] <= $matchWeight) {
+ $matchKeyId = $key['id'];
+ $matchWeight = $key['weight'];
+ }
+ }
+ if ($matchKeyId !== NULL) {
+ return $this->keys[$matchKeyId];
+ }
+ }
+
+ throw new CryptoException("Failed to find key by ID or tag (" . implode(' ', $keyIds) . ")");
+ }
+
+ /**
+ * @param string $name
+ * @return \Civi\Crypto\CipherSuiteInterface
+ * @throws \Civi\Crypto\Exception\CryptoException
+ */
+ public function findSuite($name) {
+ if (isset($this->cipherSuites[$name])) {
+ return $this->cipherSuites[$name];
+ }
+ else {
+ throw new CryptoException('Unknown cipher suite ' . $name);
+ }
+ }
+
+ /**
+ * @param string $keyExpr
+ * String in the form "<suite>:<key-encoding>:<key-value>".
+ *
+ * 'aes-cbc:b64:cGxlYXNlIHVzZSAzMiBieXRlcyBmb3IgYWVzLTI1NiE='
+ * 'aes-cbc:hkdf-sha256:ABCD1234ABCD1234ABCD1234ABCD1234'
+ * '::ABCD1234ABCD1234ABCD1234ABCD1234'
+ *
+ * @return array
+ * Properties:
+ * - key: string, binary representation
+ * - suite: string, ex: 'aes-cbc'
+ * @throws CryptoException
+ */
+ public function parseKey($keyExpr) {
+ list($suite, $keyFunc, $keyVal) = explode(':', $keyExpr);
+ if ($suite === '') {
+ $suite = self::DEFAULT_SUITE;
+ }
+ if ($keyFunc === '') {
+ $keyFunc = self::DEFAULT_KDF;
+ }
+ if (isset($this->kdfs[$keyFunc])) {
+ return [
+ 'suite' => $suite,
+ 'key' => call_user_func($this->kdfs[$keyFunc], $keyVal),
+ ];
+ }
+ else {
+ throw new CryptoException("Crypto key has unrecognized type");
+ }
+ }
+
+}
--- /dev/null
+<?php
+namespace Civi\Crypto\Exception;
+
+/**
+ * Class CryptException
+ */
+class CryptoException extends \CRM_Core_Exception {
+
+}
--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved. |
+ | |
+ | This work is published under the GNU AGPLv3 license with some |
+ | permitted exceptions and without any warranty. For full license |
+ | and copyright information, see https://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Crypto;
+
+use Civi\Crypto\Exception\CryptoException;
+
+/**
+ * Test major use-cases of the 'crypto.registry' service.
+ */
+class CryptoRegistryTest extends \CiviUnitTestCase {
+
+ use CryptoTestTrait;
+
+ protected function setUp() {
+ parent::setUp();
+ \CRM_Utils_Hook::singleton()->setHook('civicrm_crypto', [$this, 'registerExampleKeys']);
+ }
+
+ public function testParseKey() {
+ $examples = self::getExampleKeys();
+ $registry = \Civi::service('crypto.registry');
+
+ $key0 = $registry->parseKey($examples[0]);
+ $this->assertEquals("please use 32 bytes for aes-256!", $key0['key']);
+ $this->assertEquals('aes-cbc', $key0['suite']);
+
+ $key1 = $registry->parseKey($examples[1]);
+ $this->assertEquals(32, strlen($key1['key']));
+ $this->assertEquals('aes-cbc', $key1['suite']);
+ $this->assertEquals('0ao5eC7C/rwwk2qii4oLd6eG3KJq8ZDX2K9zWbvaLdo=', base64_encode($key1['key']));
+
+ $key2 = $registry->parseKey($examples[2]);
+ $this->assertEquals(32, strlen($key2['key']));
+ $this->assertEquals('aes-ctr', $key2['suite']);
+ $this->assertEquals('0ao5eC7C/rwwk2qii4oLd6eG3KJq8ZDX2K9zWbvaLdo=', base64_encode($key2['key']));
+
+ $key3 = $registry->parseKey($examples[3]);
+ $this->assertEquals(32, strlen($key3['key']));
+ $this->assertEquals('aes-cbc-hs', $key3['suite']);
+ $this->assertEquals('0ao5eC7C/rwwk2qii4oLd6eG3KJq8ZDX2K9zWbvaLdo=', base64_encode($key3['key']));
+ }
+
+ public function testRegisterAndFindKeys() {
+ /** @var CryptoRegistry $registry */
+ $registry = \Civi::service('crypto.registry');
+
+ $key = $registry->findKey('asdf-key-0');
+ $this->assertEquals(32, strlen($key['key']));
+ $this->assertEquals('aes-cbc', $key['suite']);
+
+ $key = $registry->findKey('asdf-key-1');
+ $this->assertEquals(32, strlen($key['key']));
+ $this->assertEquals('aes-cbc', $key['suite']);
+
+ $key = $registry->findKey('asdf-key-2');
+ $this->assertEquals(32, strlen($key['key']));
+ $this->assertEquals('aes-ctr', $key['suite']);
+
+ $key = $registry->findKey('asdf-key-3');
+ $this->assertEquals(32, strlen($key['key']));
+ $this->assertEquals('aes-cbc-hs', $key['suite']);
+
+ $key = $registry->findKey('UNIT-TEST');
+ $this->assertEquals(32, strlen($key['key']));
+ $this->assertEquals('asdf-key-1', $key['id']);
+ }
+
+ public function testValidKeyId() {
+ $valids = ['abc', 'a.b-c_d+e/', 'f\\g:h;i='];
+ $invalids = [chr(0), chr(1), chr(1) . 'abc', 'a b', "ab\n", "ab\nc", "\r", "\n"];
+
+ /** @var CryptoRegistry $registry */
+ $registry = \Civi::service('crypto.registry');
+
+ foreach ($valids as $valid) {
+ $this->assertEquals(TRUE, $registry->isValidKeyId($valid), "Key ID \"$valid\" should be valid");
+ }
+
+ foreach ($invalids as $invalid) {
+ $this->assertEquals(FALSE, $registry->isValidKeyId($invalid), "Key ID \"$invalid\" should be invalid");
+ }
+ }
+
+ public function testAddBadKeyId() {
+ /** @var CryptoRegistry $registry */
+ $registry = \Civi::service('crypto.registry');
+
+ try {
+ $registry->addSymmetricKey([
+ 'key' => 'abcd',
+ 'id' => "foo\n",
+ ]);
+ $this->fail("Expected crypto exception");
+ }
+ catch (CryptoException $e) {
+ $this->assertRegExp(';Malformed key ID;', $e->getMessage());
+ }
+ }
+
+}
--- /dev/null
+<?php
+/*
+ +--------------------------------------------------------------------+
+ | Copyright CiviCRM LLC. All rights reserved. |
+ | |
+ | This work is published under the GNU AGPLv3 license with some |
+ | permitted exceptions and without any warranty. For full license |
+ | and copyright information, see https://civicrm.org/licensing |
+ +--------------------------------------------------------------------+
+ */
+
+namespace Civi\Crypto;
+
+trait CryptoTestTrait {
+
+ public static function getExampleKeys() {
+ return [
+ ':b64:cGxlYXNlIHVzZSAzMiBieXRlcyBmb3IgYWVzLTI1NiE',
+ 'aes-cbc:hkdf-sha256:abcd1234abcd1234',
+ 'aes-ctr::abcd1234abcd1234',
+ 'aes-cbc-hs::abcd1234abcd1234',
+ ];
+ }
+
+ /**
+ * @param CryptoRegistry $registry
+ * @see \CRM_Utils_Hook::crypto()
+ */
+ public function registerExampleKeys($registry) {
+ $origCount = count($registry->getKeys());
+
+ $examples = self::getExampleKeys();
+ $key = $registry->addSymmetricKey($registry->parseKey($examples[0]) + [
+ 'tags' => ['UNIT-TEST'],
+ 'weight' => 10,
+ 'id' => 'asdf-key-0',
+ ]);
+ $this->assertEquals(10, $key['weight']);
+
+ $key = $registry->addSymmetricKey($registry->parseKey($examples[1]) + [
+ 'tags' => ['UNIT-TEST'],
+ 'weight' => -10,
+ 'id' => 'asdf-key-1',
+ ]);
+ $this->assertEquals(-10, $key['weight']);
+
+ $key = $registry->addSymmetricKey($registry->parseKey($examples[2]) + [
+ 'tags' => ['UNIT-TEST'],
+ 'id' => 'asdf-key-2',
+ ]);
+ $this->assertEquals(0, $key['weight']);
+
+ $key = $registry->addSymmetricKey($registry->parseKey($examples[3]) + [
+ 'tags' => ['UNIT-TEST'],
+ 'id' => 'asdf-key-3',
+ ]);
+ $this->assertEquals(0, $key['weight']);
+
+ $this->assertEquals(4, count($examples));
+ $this->assertEquals(4 + $origCount, count($registry->getKeys()));
+ }
+
+}