Commit | Line | Data |
---|---|---|
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 | ||
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 | ||
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 | } |