| 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') && 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); |
| 74 | } |
| 75 | else { |
| 76 | $registry->addSymmetricKey($registry->parseKey($keyExpr) + $key); |
| 77 | } |
| 78 | } |
| 79 | } |
| 80 | |
| 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 | |
| 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) { |
| 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; |
| 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 | |
| 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 | |
| 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 | } |