3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
12 namespace Civi\Crypto
;
14 use Civi\Crypto\Exception\CryptoException
;
17 * The CryptoRegistry tracks a list of available keys and cipher suites:
19 * - A registered cipher suite is an instance of CipherSuiteInterface that
20 * provides a list of encryption options ("aes-cbc", "aes-ctr", etc) and
21 * an implementation for them.
22 * - A registered key is an array that indicates a set of cryptographic options:
23 * - key: string, binary representation of the key
24 * - suite: string, e.g. "aes-cbc" or "aes-cbc-hs"
25 * - id: string, unique (non-sensitive) ID. Usually a fingerprint.
26 * - tags: string[], list of symbolic names/use-cases that may call upon this key
27 * - weight: int, when choosing a key for encryption, two similar keys will be
28 * be differentiated by weight. (Low values chosen before high values.)
31 * @copyright CiviCRM LLC https://civicrm.org/licensing
33 class CryptoRegistry
{
35 const LAST_WEIGHT
= 32768;
37 const DEFAULT_SUITE
= 'aes-cbc';
39 const DEFAULT_KDF
= 'hkdf-sha256';
42 * List of available keys.
49 * List of key-derivation functions. Used when loading keys.
55 protected $cipherSuites = [];
58 * Initialize a default instance of the registry.
60 * @return \Civi\Crypto\CryptoRegistry
61 * @throws \CRM_Core_Exception
62 * @throws \Civi\Crypto\Exception\CryptoException
64 public static function createDefaultRegistry(): CryptoRegistry
{
65 $registry = new static();
66 $registry->addCipherSuite(new \Civi\Crypto\
PhpseclibCipherSuite());
68 $registry->addPlainText(['tags' => ['CRED']]);
69 if (defined('CIVICRM_CRED_KEYS') && CIVICRM_CRED_KEYS
!== '') {
70 foreach (explode(' ', CIVICRM_CRED_KEYS
) as $n => $keyExpr) {
71 $key = ['tags' => ['CRED'], 'weight' => $n];
72 if ($keyExpr === 'plain') {
73 $registry->addPlainText($key);
76 $registry->addSymmetricKey($registry->parseKey($keyExpr) +
$key);
81 if (defined('CIVICRM_SIGN_KEYS') && CIVICRM_SIGN_KEYS
!== '') {
82 foreach (explode(' ', CIVICRM_SIGN_KEYS
) as $n => $keyExpr) {
83 $key = ['tags' => ['SIGN'], 'weight' => $n];
84 $registry->addSymmetricKey($registry->parseKey($keyExpr) +
$key);
88 //if (isset($_COOKIE['CIVICRM_FORM_KEY'])) {
89 // $crypto->addSymmetricKey([
90 // 'key' => base64_decode($_COOKIE['CIVICRM_FORM_KEY']),
91 // 'suite' => 'aes-cbc',
94 // // else: somewhere in CRM_Core_Form, we may need to initialize CIVICRM_FORM_KEY
97 // Allow plugins to add/replace any keys and ciphers.
98 \CRM_Utils_Hook
::crypto($registry);
102 public function __construct() {
103 $this->cipherSuites
['plain'] = TRUE;
104 $this->keys
['plain'] = [
109 'weight' => self
::LAST_WEIGHT
,
112 // Base64 - Useful for precise control. Relatively quick decode. Please bring your own entropy.
113 $this->kdfs
['b64'] = 'base64_decode';
115 // HKDF - Forgiving about diverse inputs. Relatively quick decode. Please bring your own entropy.
116 $this->kdfs
['hkdf-sha256'] = function($v) {
117 // NOTE: 256-bit output by default. Useful for pairing with AES-256.
118 return hash_hkdf('sha256', $v);
121 // Possible future options: Read from PEM file. Run PBKDF2 on a passphrase.
125 * @param string|array $options
126 * Additional options:
127 * - key: string, a representation of the key as binary
128 * - suite: string, ex: 'aes-cbc'
130 * - weight: int, default 0
131 * - id: string, a unique identifier for this key. (default: fingerprint the key+suite)
134 * The full key record. (Same format as $options)
135 * @throws \Civi\Crypto\Exception\CryptoException
137 public function addSymmetricKey($options) {
139 'suite' => self
::DEFAULT_SUITE
,
142 $options = array_merge($defaults, $options);
144 if (!isset($options['key'])) {
145 throw new CryptoException("Missing crypto key");
148 if (!isset($options['id'])) {
149 $options['id'] = \CRM_Utils_String
::base64UrlEncode(sha1($options['suite'] . chr(0) . $options['key'], TRUE));
151 // Manual key IDs should be validated.
152 elseif (!$this->isValidKeyId($options['id'])) {
153 throw new CryptoException("Malformed key ID");
156 $this->keys
[$options['id']] = $options;
161 * Determine if a key ID is well-formed.
166 public function isValidKeyId($id) {
167 if (strpos($id, "\n") !== FALSE) {
170 return (bool) preg_match(';^[a-zA-Z0-9_\-\.:,=+/\;\\\\]+$;s', $id);
174 * Enable plain-text encoding.
176 * @param array $options
177 * Array with options:
181 public function addPlainText($options) {
185 'weight' => self
::LAST_WEIGHT
,
187 $options = array_merge($defaults, $options);
188 $options['id'] = 'plain' . ($n++
);
189 $this->keys
[$options['id']] = $options;
194 * @param CipherSuiteInterface $cipherSuite
195 * The encryption/decryption callback/handler
196 * @param string[]|null $names
197 * Symbolic names. Ex: 'aes-cbc'
198 * If NULL, probe $cipherSuite->getNames()
200 public function addCipherSuite(CipherSuiteInterface
$cipherSuite, $names = NULL) {
201 $names = $names ?
: $cipherSuite->getSuites();
202 foreach ($names as $name) {
203 $this->cipherSuites
[$name] = $cipherSuite;
207 public function getKeys() {
212 * Locate a key in the list of available keys.
214 * @param string|string[] $keyIds
215 * List of IDs or tags. The first match in the list is returned.
216 * If multiple keys match the same tag, then the one with lowest 'weight' is returned.
218 * @throws \Civi\Crypto\Exception\CryptoException
220 public function findKey($keyIds) {
221 $keyIds = (array) $keyIds;
222 foreach ($keyIds as $keyIdOrTag) {
223 if (isset($this->keys
[$keyIdOrTag])) {
224 return $this->keys
[$keyIdOrTag];
228 $matchWeight = self
::LAST_WEIGHT
;
229 foreach ($this->keys
as $key) {
230 if (in_array($keyIdOrTag, $key['tags']) && $key['weight'] <= $matchWeight) {
231 $matchKeyId = $key['id'];
232 $matchWeight = $key['weight'];
235 if ($matchKeyId !== NULL) {
236 return $this->keys
[$matchKeyId];
240 throw new CryptoException("Failed to find key by ID or tag (" . implode(' ', $keyIds) . ")");
244 * Find all the keys that apply to a tag.
246 * @param string $keyTag
249 * List of keys, indexed by id, ordered by weight.
251 public function findKeysByTag($keyTag) {
252 $keys = array_filter($this->keys
, function ($key) use ($keyTag) {
253 return in_array($keyTag, $key['tags'] ??
[]);
255 uasort($keys, function($a, $b) {
256 return ($a['weight'] ??
0) - ($b['weight'] ??
0);
262 * @param string $name
263 * @return \Civi\Crypto\CipherSuiteInterface
264 * @throws \Civi\Crypto\Exception\CryptoException
266 public function findSuite($name) {
267 if (isset($this->cipherSuites
[$name])) {
268 return $this->cipherSuites
[$name];
271 throw new CryptoException('Unknown cipher suite ' . $name);
276 * @param string $keyExpr
277 * String in the form "<suite>:<key-encoding>:<key-value>".
279 * 'aes-cbc:b64:cGxlYXNlIHVzZSAzMiBieXRlcyBmb3IgYWVzLTI1NiE='
280 * 'aes-cbc:hkdf-sha256:ABCD1234ABCD1234ABCD1234ABCD1234'
281 * '::ABCD1234ABCD1234ABCD1234ABCD1234'
285 * - key: string, binary representation
286 * - suite: string, ex: 'aes-cbc'
287 * @throws CryptoException
289 public function parseKey($keyExpr) {
290 list($suite, $keyFunc, $keyVal) = explode(':', $keyExpr);
292 $suite = self
::DEFAULT_SUITE
;
294 if ($keyFunc === '') {
295 $keyFunc = self
::DEFAULT_KDF
;
297 if (isset($this->kdfs
[$keyFunc])) {
300 'key' => call_user_func($this->kdfs
[$keyFunc], $keyVal),
304 throw new CryptoException("Crypto key has unrecognized type");