Commit | Line | Data |
---|---|---|
8d3452c4 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 "Crypto Token" service supports a token format suitable for | |
18 | * storing specific values in the database with encryption. Characteristics: | |
19 | * | |
20 | * - Primarily designed to defend confidentiality in case of data-leaks | |
21 | * (SQL injections, lost backups, etc). | |
22 | * - NOT appropriate for securing data-transmission. Data-transmission | |
23 | * requires more protections (eg mandatory TTLs + signatures). If you need | |
24 | * that, consider adding a JWT/JWS/JWE implementation. | |
25 | * - Data-format allows phase-in/phase-out. If you have a datum that was written | |
26 | * with an old key or with no key, it will still be readable. | |
27 | * | |
28 | * USAGE: The "encrypt()" and "decrypt()" methods are the primary interfaces. | |
29 | * | |
30 | * $encrypted = Civi::service('crypto.token')->encrypt('my-mail-password, 'KEY_ID_OR_TAG'); | |
31 | * $decrypted = Civi::service('crypto.token')->decrypt($encrypted, '*'); | |
32 | * | |
33 | * FORMAT: An encoded token may be in either of these formats: | |
34 | * | |
35 | * - Plain text: Any string which does not begin with chr(2) | |
36 | * - Encrypted text: A string in the format: | |
5b394c8f TO |
37 | * TOKEN := DLM + FMT + QUERY |
38 | * DLM := ASCII char #2 | |
39 | * FMT := String, 4-digit, alphanumeric (as in "CTK?") | |
40 | * QUERY := String, URL-encoded key-value pairs, | |
41 | * "k", the key ID (alphanumeric and symbols "_-.,:;=+/\") | |
42 | * "t", the text (base64-encoded ciphertext) | |
8d3452c4 TO |
43 | * |
44 | * @package Civi\Crypto | |
45 | */ | |
46 | class CryptoToken { | |
47 | ||
5b394c8f TO |
48 | /** |
49 | * Format identification code | |
50 | */ | |
51 | const FMT_QUERY = 'CTK?'; | |
8d3452c4 | 52 | |
5b394c8f TO |
53 | /** |
54 | * @var string | |
55 | */ | |
8d3452c4 TO |
56 | protected $delim; |
57 | ||
6d5d2a55 TO |
58 | /** |
59 | * @var \Civi\Crypto\CryptoRegistry|null | |
60 | */ | |
61 | private $registry; | |
62 | ||
8d3452c4 TO |
63 | /** |
64 | * CryptoToken constructor. | |
6d5d2a55 TO |
65 | * |
66 | * @param CryptoRegistry $registry | |
8d3452c4 | 67 | */ |
6d5d2a55 | 68 | public function __construct($registry = NULL) { |
8d3452c4 | 69 | $this->delim = chr(2); |
6d5d2a55 | 70 | $this->registry = $registry; |
8d3452c4 TO |
71 | } |
72 | ||
73 | /** | |
74 | * Determine if a string looks like plain-text. | |
75 | * | |
76 | * @param string $plainText | |
77 | * @return bool | |
78 | */ | |
79 | public function isPlainText($plainText) { | |
e1499714 | 80 | return is_string($plainText) && ($plainText === '' || $plainText[0] !== $this->delim); |
8d3452c4 TO |
81 | } |
82 | ||
83 | /** | |
84 | * Create an encrypted token (given the plaintext). | |
85 | * | |
86 | * @param string $plainText | |
87 | * The secret value to encode (e.g. plain-text password). | |
88 | * @param string|string[] $keyIdOrTag | |
89 | * List of key IDs or key tags to check. First available match wins. | |
90 | * @return string | |
91 | * A token | |
92 | * @throws \Civi\Crypto\Exception\CryptoException | |
93 | */ | |
94 | public function encrypt($plainText, $keyIdOrTag) { | |
7c5110c3 | 95 | /** @var CryptoRegistry $registry */ |
6d5d2a55 | 96 | $registry = $this->getRegistry(); |
7c5110c3 TO |
97 | |
98 | $key = $registry->findKey($keyIdOrTag); | |
8d3452c4 TO |
99 | if ($key['suite'] === 'plain') { |
100 | if (!$this->isPlainText($plainText)) { | |
101 | throw new CryptoException("Cannot use plaintext encoding for data with reserved delimiter."); | |
102 | } | |
103 | return $plainText; | |
104 | } | |
105 | ||
106 | /** @var \Civi\Crypto\CipherSuiteInterface $cipherSuite */ | |
7c5110c3 | 107 | $cipherSuite = $registry->findSuite($key['suite']); |
8d3452c4 | 108 | $cipherText = $cipherSuite->encrypt($plainText, $key); |
5b394c8f TO |
109 | |
110 | return $this->delim . self::FMT_QUERY . \http_build_query([ | |
111 | 'k' => $key['id'], | |
112 | 't' => \CRM_Utils_String::base64UrlEncode($cipherText), | |
113 | ]); | |
8d3452c4 TO |
114 | } |
115 | ||
116 | /** | |
117 | * Get the plaintext (given an encrypted token). | |
118 | * | |
119 | * @param string $token | |
120 | * @param string|string[] $keyIdOrTag | |
121 | * Whitelist of acceptable keys. Wildcard '*' will allow it to use | |
122 | * any/all available means to decode the token. | |
123 | * @return string | |
124 | * @throws \Civi\Crypto\Exception\CryptoException | |
125 | */ | |
126 | public function decrypt($token, $keyIdOrTag = '*') { | |
127 | $keyIdOrTag = (array) $keyIdOrTag; | |
128 | ||
129 | if ($this->isPlainText($token)) { | |
130 | if (in_array('*', $keyIdOrTag) || in_array('plain', $keyIdOrTag)) { | |
131 | return $token; | |
132 | } | |
133 | else { | |
134 | throw new CryptoException("Cannot decrypt token. Unexpected key: plain"); | |
135 | } | |
136 | } | |
137 | ||
7c5110c3 | 138 | /** @var CryptoRegistry $registry */ |
6d5d2a55 | 139 | $registry = $this->getRegistry(); |
7c5110c3 | 140 | |
a5eae9cb TO |
141 | $tokenData = $this->parse($token); |
142 | ||
143 | $key = $registry->findKey($tokenData['k']); | |
144 | if (!in_array('*', $keyIdOrTag) && !in_array($tokenData['k'], $keyIdOrTag) && empty(array_intersect($keyIdOrTag, $key['tags']))) { | |
145 | throw new CryptoException("Cannot decrypt token. Unexpected key: {$tokenData['k']}"); | |
146 | } | |
147 | ||
148 | /** @var \Civi\Crypto\CipherSuiteInterface $cipherSuite */ | |
149 | $cipherSuite = $registry->findSuite($key['suite']); | |
150 | $plainText = $cipherSuite->decrypt($tokenData['t'], $key); | |
151 | return $plainText; | |
152 | } | |
153 | ||
fe4d2bdc TO |
154 | /** |
155 | * Re-encrypt an existing token with a newer version of the key. | |
156 | * | |
157 | * @param string $oldToken | |
158 | * @param string $keyTag | |
159 | * Ex: 'CRED' | |
160 | * | |
161 | * @return string|null | |
162 | * A re-encrypted version of $oldToken, or NULL if there should be no change. | |
163 | * @throws \Civi\Crypto\Exception\CryptoException | |
164 | */ | |
165 | public function rekey($oldToken, $keyTag) { | |
166 | /** @var \Civi\Crypto\CryptoRegistry $registry */ | |
6d5d2a55 | 167 | $registry = $this->getRegistry(); |
fe4d2bdc TO |
168 | |
169 | $sourceKeys = $registry->findKeysByTag($keyTag); | |
170 | $targetKey = array_shift($sourceKeys); | |
171 | ||
172 | if ($this->isPlainText($oldToken)) { | |
173 | if ($targetKey['suite'] === 'plain') { | |
174 | return NULL; | |
175 | } | |
176 | } | |
177 | else { | |
178 | $tokenData = $this->parse($oldToken); | |
179 | if ($tokenData['k'] === $targetKey['id'] || !isset($sourceKeys[$tokenData['k']])) { | |
180 | return NULL; | |
181 | } | |
182 | } | |
183 | ||
184 | $decrypted = $this->decrypt($oldToken); | |
185 | return $this->encrypt($decrypted, $targetKey['id']); | |
186 | } | |
187 | ||
a5eae9cb TO |
188 | /** |
189 | * Parse the content of a token (without decrypting it). | |
190 | * | |
191 | * @param string $token | |
192 | * | |
193 | * @return array | |
194 | * @throws \Civi\Crypto\Exception\CryptoException | |
195 | */ | |
196 | public function parse($token): array { | |
5b394c8f TO |
197 | $fmt = substr($token, 1, 4); |
198 | switch ($fmt) { | |
199 | case self::FMT_QUERY: | |
a5eae9cb | 200 | $tokenData = []; |
5b394c8f | 201 | parse_str(substr($token, 5), $tokenData); |
a5eae9cb | 202 | $tokenData['t'] = \CRM_Utils_String::base64UrlDecode($tokenData['t']); |
5b394c8f TO |
203 | break; |
204 | ||
205 | default: | |
206 | throw new CryptoException("Cannot decrypt token. Invalid format."); | |
8d3452c4 | 207 | } |
a5eae9cb | 208 | return $tokenData; |
8d3452c4 TO |
209 | } |
210 | ||
6d5d2a55 TO |
211 | /** |
212 | * @return CryptoRegistry | |
213 | */ | |
214 | protected function getRegistry(): CryptoRegistry { | |
215 | if ($this->registry === NULL) { | |
216 | $this->registry = \Civi::service('crypto.registry'); | |
217 | } | |
218 | return $this->registry; | |
219 | } | |
220 | ||
8d3452c4 | 221 | } |