Merge pull request #23232 from braders/nodefaults-tab-links
[civicrm-core.git] / Civi / Crypto / CryptoRegistry.php
CommitLineData
281eacd8
TO
1<?php
2/*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
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 +--------------------------------------------------------------------+
10 */
11
12namespace Civi\Crypto;
13
14use Civi\Crypto\Exception\CryptoException;
15
16/**
17 * The CryptoRegistry tracks a list of available keys and cipher suites:
18 *
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.)
29 *
30 * @package CRM
31 * @copyright CiviCRM LLC https://civicrm.org/licensing
32 */
33class CryptoRegistry {
34
35 const LAST_WEIGHT = 32768;
36
37 const DEFAULT_SUITE = 'aes-cbc';
38
39 const DEFAULT_KDF = 'hkdf-sha256';
40
41 /**
42 * List of available keys.
43 *
44 * @var array[]
45 */
46 protected $keys = [];
47
48 /**
49 * List of key-derivation functions. Used when loading keys.
50 *
51 * @var array
52 */
53 protected $kdfs = [];
54
55 protected $cipherSuites = [];
56
23fa0118
TO
57 /**
58 * Initialize a default instance of the registry.
59 *
60 * @return \Civi\Crypto\CryptoRegistry
61 * @throws \CRM_Core_Exception
62 * @throws \Civi\Crypto\Exception\CryptoException
63 */
7dfe650d 64 public static function createDefaultRegistry(): CryptoRegistry {
23fa0118
TO
65 $registry = new static();
66 $registry->addCipherSuite(new \Civi\Crypto\PhpseclibCipherSuite());
67
68 $registry->addPlainText(['tags' => ['CRED']]);
71110177 69 if (defined('CIVICRM_CRED_KEYS') && CIVICRM_CRED_KEYS !== '') {
23fa0118 70 foreach (explode(' ', CIVICRM_CRED_KEYS) as $n => $keyExpr) {
d463c543
TO
71 $key = ['tags' => ['CRED'], 'weight' => $n];
72 if ($keyExpr === 'plain') {
73 $registry->addPlainText($key);
74 }
75 else {
76 $registry->addSymmetricKey($registry->parseKey($keyExpr) + $key);
77 }
23fa0118
TO
78 }
79 }
f702c7f5 80
8846c128
TO
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);
85 }
86 }
87
23fa0118
TO
88 //if (isset($_COOKIE['CIVICRM_FORM_KEY'])) {
89 // $crypto->addSymmetricKey([
90 // 'key' => base64_decode($_COOKIE['CIVICRM_FORM_KEY']),
91 // 'suite' => 'aes-cbc',
92 // 'tag' => ['FORM'],
93 // ]);
94 // // else: somewhere in CRM_Core_Form, we may need to initialize CIVICRM_FORM_KEY
95 //}
96
97 // Allow plugins to add/replace any keys and ciphers.
98 \CRM_Utils_Hook::crypto($registry);
99 return $registry;
100 }
101
281eacd8
TO
102 public function __construct() {
103 $this->cipherSuites['plain'] = TRUE;
104 $this->keys['plain'] = [
105 'key' => '',
106 'suite' => 'plain',
107 'tags' => [],
108 'id' => 'plain',
109 'weight' => self::LAST_WEIGHT,
110 ];
111
112 // Base64 - Useful for precise control. Relatively quick decode. Please bring your own entropy.
113 $this->kdfs['b64'] = 'base64_decode';
114
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);
119 };
120
121 // Possible future options: Read from PEM file. Run PBKDF2 on a passphrase.
122 }
123
124 /**
125 * @param string|array $options
126 * Additional options:
127 * - key: string, a representation of the key as binary
128 * - suite: string, ex: 'aes-cbc'
129 * - tags: string[]
130 * - weight: int, default 0
131 * - id: string, a unique identifier for this key. (default: fingerprint the key+suite)
132 *
133 * @return array
134 * The full key record. (Same format as $options)
135 * @throws \Civi\Crypto\Exception\CryptoException
136 */
137 public function addSymmetricKey($options) {
138 $defaults = [
139 'suite' => self::DEFAULT_SUITE,
140 'weight' => 0,
141 ];
142 $options = array_merge($defaults, $options);
143
144 if (!isset($options['key'])) {
145 throw new CryptoException("Missing crypto key");
146 }
147
148 if (!isset($options['id'])) {
149 $options['id'] = \CRM_Utils_String::base64UrlEncode(sha1($options['suite'] . chr(0) . $options['key'], TRUE));
150 }
151 // Manual key IDs should be validated.
152 elseif (!$this->isValidKeyId($options['id'])) {
153 throw new CryptoException("Malformed key ID");
154 }
155
156 $this->keys[$options['id']] = $options;
157 return $options;
158 }
159
160 /**
161 * Determine if a key ID is well-formed.
162 *
163 * @param string $id
164 * @return bool
165 */
166 public function isValidKeyId($id) {
167 if (strpos($id, "\n") !== FALSE) {
168 return FALSE;
169 }
170 return (bool) preg_match(';^[a-zA-Z0-9_\-\.:,=+/\;\\\\]+$;s', $id);
171 }
172
173 /**
174 * Enable plain-text encoding.
175 *
176 * @param array $options
177 * Array with options:
178 * - tags: string[]
179 * @return array
180 */
181 public function addPlainText($options) {
d463c543
TO
182 static $n = 0;
183 $defaults = [
184 'suite' => 'plain',
185 'weight' => self::LAST_WEIGHT,
186 ];
187 $options = array_merge($defaults, $options);
188 $options['id'] = 'plain' . ($n++);
189 $this->keys[$options['id']] = $options;
190 return $options;
281eacd8
TO
191 }
192
193 /**
194 * @param CipherSuiteInterface $cipherSuite
195 * The encryption/decryption callback/handler
4dbdebf0 196 * @param string[]|null $names
281eacd8
TO
197 * Symbolic names. Ex: 'aes-cbc'
198 * If NULL, probe $cipherSuite->getNames()
199 */
200 public function addCipherSuite(CipherSuiteInterface $cipherSuite, $names = NULL) {
201 $names = $names ?: $cipherSuite->getSuites();
202 foreach ($names as $name) {
203 $this->cipherSuites[$name] = $cipherSuite;
204 }
205 }
206
207 public function getKeys() {
208 return $this->keys;
209 }
210
211 /**
212 * Locate a key in the list of available keys.
213 *
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.
217 * @return array
218 * @throws \Civi\Crypto\Exception\CryptoException
219 */
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];
225 }
226
227 $matchKeyId = NULL;
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'];
233 }
234 }
235 if ($matchKeyId !== NULL) {
236 return $this->keys[$matchKeyId];
237 }
238 }
239
240 throw new CryptoException("Failed to find key by ID or tag (" . implode(' ', $keyIds) . ")");
241 }
242
8d54915f
TO
243 /**
244 * Find all the keys that apply to a tag.
245 *
246 * @param string $keyTag
247 *
248 * @return array
249 * List of keys, indexed by id, ordered by weight.
250 */
251 public function findKeysByTag($keyTag) {
252 $keys = array_filter($this->keys, function ($key) use ($keyTag) {
253 return in_array($keyTag, $key['tags'] ?? []);
254 });
255 uasort($keys, function($a, $b) {
256 return ($a['weight'] ?? 0) - ($b['weight'] ?? 0);
257 });
258 return $keys;
259 }
260
281eacd8
TO
261 /**
262 * @param string $name
263 * @return \Civi\Crypto\CipherSuiteInterface
264 * @throws \Civi\Crypto\Exception\CryptoException
265 */
266 public function findSuite($name) {
267 if (isset($this->cipherSuites[$name])) {
268 return $this->cipherSuites[$name];
269 }
270 else {
271 throw new CryptoException('Unknown cipher suite ' . $name);
272 }
273 }
274
275 /**
276 * @param string $keyExpr
277 * String in the form "<suite>:<key-encoding>:<key-value>".
278 *
279 * 'aes-cbc:b64:cGxlYXNlIHVzZSAzMiBieXRlcyBmb3IgYWVzLTI1NiE='
280 * 'aes-cbc:hkdf-sha256:ABCD1234ABCD1234ABCD1234ABCD1234'
281 * '::ABCD1234ABCD1234ABCD1234ABCD1234'
282 *
283 * @return array
284 * Properties:
285 * - key: string, binary representation
286 * - suite: string, ex: 'aes-cbc'
287 * @throws CryptoException
288 */
289 public function parseKey($keyExpr) {
290 list($suite, $keyFunc, $keyVal) = explode(':', $keyExpr);
291 if ($suite === '') {
292 $suite = self::DEFAULT_SUITE;
293 }
294 if ($keyFunc === '') {
295 $keyFunc = self::DEFAULT_KDF;
296 }
297 if (isset($this->kdfs[$keyFunc])) {
298 return [
299 'suite' => $suite,
300 'key' => call_user_func($this->kdfs[$keyFunc], $keyVal),
301 ];
302 }
303 else {
304 throw new CryptoException("Crypto key has unrecognized type");
305 }
306 }
307
308}