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