7fc2150ad9da2a4ecd53774aae7f3d7e3beb9f12
[civicrm-core.git] / Civi / Crypto / CryptoRegistry.php
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
12 namespace Civi\Crypto;
13
14 use 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 */
33 class 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
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 */
64 public static function createDefaultRegistry() {
65 $registry = new static();
66 $registry->addCipherSuite(new \Civi\Crypto\PhpseclibCipherSuite());
67
68 $registry->addPlainText(['tags' => ['CRED']]);
69 if (defined('CIVICRM_CRED_KEYS')) {
70 foreach (explode(' ', CIVICRM_CRED_KEYS) as $n => $keyExpr) {
71 $registry->addSymmetricKey($registry->parseKey($keyExpr) + [
72 'tags' => ['CRED'],
73 'weight' => $n,
74 ]);
75 }
76 }
77 if (defined('CIVICRM_SITE_KEY')) {
78 // Recent upgrades may not have CIVICRM_CRED_KEYS. Transitional support - the CIVICRM_SITE_KEY is last-priority option for credentials.
79 $registry->addSymmetricKey([
80 'key' => hash_hkdf('sha256', CIVICRM_SITE_KEY),
81 'suite' => 'aes-cbc',
82 'tags' => ['CRED'],
83 'weight' => 30000,
84 ]);
85 }
86 //if (isset($_COOKIE['CIVICRM_FORM_KEY'])) {
87 // $crypto->addSymmetricKey([
88 // 'key' => base64_decode($_COOKIE['CIVICRM_FORM_KEY']),
89 // 'suite' => 'aes-cbc',
90 // 'tag' => ['FORM'],
91 // ]);
92 // // else: somewhere in CRM_Core_Form, we may need to initialize CIVICRM_FORM_KEY
93 //}
94
95 // Allow plugins to add/replace any keys and ciphers.
96 \CRM_Utils_Hook::crypto($registry);
97 return $registry;
98 }
99
100 public function __construct() {
101 $this->cipherSuites['plain'] = TRUE;
102 $this->keys['plain'] = [
103 'key' => '',
104 'suite' => 'plain',
105 'tags' => [],
106 'id' => 'plain',
107 'weight' => self::LAST_WEIGHT,
108 ];
109
110 // Base64 - Useful for precise control. Relatively quick decode. Please bring your own entropy.
111 $this->kdfs['b64'] = 'base64_decode';
112
113 // HKDF - Forgiving about diverse inputs. Relatively quick decode. Please bring your own entropy.
114 $this->kdfs['hkdf-sha256'] = function($v) {
115 // NOTE: 256-bit output by default. Useful for pairing with AES-256.
116 return hash_hkdf('sha256', $v);
117 };
118
119 // Possible future options: Read from PEM file. Run PBKDF2 on a passphrase.
120 }
121
122 /**
123 * @param string|array $options
124 * Additional options:
125 * - key: string, a representation of the key as binary
126 * - suite: string, ex: 'aes-cbc'
127 * - tags: string[]
128 * - weight: int, default 0
129 * - id: string, a unique identifier for this key. (default: fingerprint the key+suite)
130 *
131 * @return array
132 * The full key record. (Same format as $options)
133 * @throws \Civi\Crypto\Exception\CryptoException
134 */
135 public function addSymmetricKey($options) {
136 $defaults = [
137 'suite' => self::DEFAULT_SUITE,
138 'weight' => 0,
139 ];
140 $options = array_merge($defaults, $options);
141
142 if (!isset($options['key'])) {
143 throw new CryptoException("Missing crypto key");
144 }
145
146 if (!isset($options['id'])) {
147 $options['id'] = \CRM_Utils_String::base64UrlEncode(sha1($options['suite'] . chr(0) . $options['key'], TRUE));
148 }
149 // Manual key IDs should be validated.
150 elseif (!$this->isValidKeyId($options['id'])) {
151 throw new CryptoException("Malformed key ID");
152 }
153
154 $this->keys[$options['id']] = $options;
155 return $options;
156 }
157
158 /**
159 * Determine if a key ID is well-formed.
160 *
161 * @param string $id
162 * @return bool
163 */
164 public function isValidKeyId($id) {
165 if (strpos($id, "\n") !== FALSE) {
166 return FALSE;
167 }
168 return (bool) preg_match(';^[a-zA-Z0-9_\-\.:,=+/\;\\\\]+$;s', $id);
169 }
170
171 /**
172 * Enable plain-text encoding.
173 *
174 * @param array $options
175 * Array with options:
176 * - tags: string[]
177 * @return array
178 */
179 public function addPlainText($options) {
180 if (!isset($this->keys['plain'])) {
181 }
182 if (isset($options['tags'])) {
183 $this->keys['plain']['tags'] = array_merge(
184 $options['tags']
185 );
186 }
187 return $this->keys['plain'];
188 }
189
190 /**
191 * @param CipherSuiteInterface $cipherSuite
192 * The encryption/decryption callback/handler
193 * @param string[]|NULL $names
194 * Symbolic names. Ex: 'aes-cbc'
195 * If NULL, probe $cipherSuite->getNames()
196 */
197 public function addCipherSuite(CipherSuiteInterface $cipherSuite, $names = NULL) {
198 $names = $names ?: $cipherSuite->getSuites();
199 foreach ($names as $name) {
200 $this->cipherSuites[$name] = $cipherSuite;
201 }
202 }
203
204 public function getKeys() {
205 return $this->keys;
206 }
207
208 /**
209 * Locate a key in the list of available keys.
210 *
211 * @param string|string[] $keyIds
212 * List of IDs or tags. The first match in the list is returned.
213 * If multiple keys match the same tag, then the one with lowest 'weight' is returned.
214 * @return array
215 * @throws \Civi\Crypto\Exception\CryptoException
216 */
217 public function findKey($keyIds) {
218 $keyIds = (array) $keyIds;
219 foreach ($keyIds as $keyIdOrTag) {
220 if (isset($this->keys[$keyIdOrTag])) {
221 return $this->keys[$keyIdOrTag];
222 }
223
224 $matchKeyId = NULL;
225 $matchWeight = self::LAST_WEIGHT;
226 foreach ($this->keys as $key) {
227 if (in_array($keyIdOrTag, $key['tags']) && $key['weight'] <= $matchWeight) {
228 $matchKeyId = $key['id'];
229 $matchWeight = $key['weight'];
230 }
231 }
232 if ($matchKeyId !== NULL) {
233 return $this->keys[$matchKeyId];
234 }
235 }
236
237 throw new CryptoException("Failed to find key by ID or tag (" . implode(' ', $keyIds) . ")");
238 }
239
240 /**
241 * @param string $name
242 * @return \Civi\Crypto\CipherSuiteInterface
243 * @throws \Civi\Crypto\Exception\CryptoException
244 */
245 public function findSuite($name) {
246 if (isset($this->cipherSuites[$name])) {
247 return $this->cipherSuites[$name];
248 }
249 else {
250 throw new CryptoException('Unknown cipher suite ' . $name);
251 }
252 }
253
254 /**
255 * @param string $keyExpr
256 * String in the form "<suite>:<key-encoding>:<key-value>".
257 *
258 * 'aes-cbc:b64:cGxlYXNlIHVzZSAzMiBieXRlcyBmb3IgYWVzLTI1NiE='
259 * 'aes-cbc:hkdf-sha256:ABCD1234ABCD1234ABCD1234ABCD1234'
260 * '::ABCD1234ABCD1234ABCD1234ABCD1234'
261 *
262 * @return array
263 * Properties:
264 * - key: string, binary representation
265 * - suite: string, ex: 'aes-cbc'
266 * @throws CryptoException
267 */
268 public function parseKey($keyExpr) {
269 list($suite, $keyFunc, $keyVal) = explode(':', $keyExpr);
270 if ($suite === '') {
271 $suite = self::DEFAULT_SUITE;
272 }
273 if ($keyFunc === '') {
274 $keyFunc = self::DEFAULT_KDF;
275 }
276 if (isset($this->kdfs[$keyFunc])) {
277 return [
278 'suite' => $suite,
279 'key' => call_user_func($this->kdfs[$keyFunc], $keyVal),
280 ];
281 }
282 else {
283 throw new CryptoException("Crypto key has unrecognized type");
284 }
285 }
286
287 }