From: Tim Otten Date: Wed, 16 Dec 2020 21:26:32 +0000 (-0800) Subject: (dev/core#2258) CryptoRegistry - Keep track of available keys+suites. Hookable. X-Git-Url: https://vcs.fsf.org/?a=commitdiff_plain;h=281eacd865bdf56317eb58eb13076b3e4c33e032;hp=08b2ba0ca7cc8a5cac9d0cb50eb9cb0edce4f245;p=civicrm-core.git (dev/core#2258) CryptoRegistry - Keep track of available keys+suites. Hookable. --- diff --git a/CRM/Utils/Hook.php b/CRM/Utils/Hook.php index 95a5496ffd..8ec1d7235d 100644 --- a/CRM/Utils/Hook.php +++ b/CRM/Utils/Hook.php @@ -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. * diff --git a/Civi/Core/Container.php b/Civi/Core/Container.php index 4f38b4e3a2..404405adad 100644 --- a/Civi/Core/Container.php +++ b/Civi/Core/Container.php @@ -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 index 0000000000..925e743d31 --- /dev/null +++ b/Civi/Crypto/CipherSuiteInterface.php @@ -0,0 +1,51 @@ +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 "::". + * + * '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 index 0000000000..84677ace41 --- /dev/null +++ b/Civi/Crypto/Exception/CryptoException.php @@ -0,0 +1,9 @@ +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 index 0000000000..e20eefdc01 --- /dev/null +++ b/tests/phpunit/Civi/Crypto/CryptoTestTrait.php @@ -0,0 +1,63 @@ +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())); + } + +}