(dev/core#2258) CryptoRegistry - Keep track of available keys+suites. Hookable.
authorTim Otten <totten@civicrm.org>
Wed, 16 Dec 2020 21:26:32 +0000 (13:26 -0800)
committerTim Otten <totten@civicrm.org>
Sat, 19 Dec 2020 04:54:30 +0000 (20:54 -0800)
CRM/Utils/Hook.php
Civi/Core/Container.php
Civi/Crypto/CipherSuiteInterface.php [new file with mode: 0644]
Civi/Crypto/CryptoRegistry.php [new file with mode: 0644]
Civi/Crypto/Exception/CryptoException.php [new file with mode: 0644]
tests/phpunit/Civi/Crypto/CryptoRegistryTest.php [new file with mode: 0644]
tests/phpunit/Civi/Crypto/CryptoTestTrait.php [new file with mode: 0644]

index 95a5496ffd60fd784edfe717de6fa71da3bef4fb..8ec1d7235da5138ca13158603baf83d3cad47ea4 100644 (file)
@@ -1759,6 +1759,23 @@ abstract class CRM_Utils_Hook {
     );
   }
 
+  /**
+   * 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.
    *
index 4f38b4e3a2f0fd2d11fcac5567d6159273b3f873..404405adadb94f489dbc2d6df85646bcc3224c17 100644 (file)
@@ -218,6 +218,9 @@ class Container {
     $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.');
     }
@@ -499,6 +502,48 @@ class Container {
     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.
    *
diff --git a/Civi/Crypto/CipherSuiteInterface.php b/Civi/Crypto/CipherSuiteInterface.php
new file mode 100644 (file)
index 0000000..925e743
--- /dev/null
@@ -0,0 +1,51 @@
+<?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;
+
+}
diff --git a/Civi/Crypto/CryptoRegistry.php b/Civi/Crypto/CryptoRegistry.php
new file mode 100644 (file)
index 0000000..5e660b4
--- /dev/null
@@ -0,0 +1,244 @@
+<?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");
+    }
+  }
+
+}
diff --git a/Civi/Crypto/Exception/CryptoException.php b/Civi/Crypto/Exception/CryptoException.php
new file mode 100644 (file)
index 0000000..84677ac
--- /dev/null
@@ -0,0 +1,9 @@
+<?php
+namespace Civi\Crypto\Exception;
+
+/**
+ * Class CryptException
+ */
+class CryptoException extends \CRM_Core_Exception {
+
+}
diff --git a/tests/phpunit/Civi/Crypto/CryptoRegistryTest.php b/tests/phpunit/Civi/Crypto/CryptoRegistryTest.php
new file mode 100644 (file)
index 0000000..259c9e6
--- /dev/null
@@ -0,0 +1,109 @@
+<?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());
+    }
+  }
+
+}
diff --git a/tests/phpunit/Civi/Crypto/CryptoTestTrait.php b/tests/phpunit/Civi/Crypto/CryptoTestTrait.php
new file mode 100644 (file)
index 0000000..e20eefd
--- /dev/null
@@ -0,0 +1,63 @@
+<?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()));
+  }
+
+}