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 | ||
58 | /** | |
59 | * CryptoToken constructor. | |
8d3452c4 | 60 | */ |
7c5110c3 | 61 | public function __construct() { |
8d3452c4 | 62 | $this->delim = chr(2); |
8d3452c4 TO |
63 | } |
64 | ||
65 | /** | |
66 | * Determine if a string looks like plain-text. | |
67 | * | |
68 | * @param string $plainText | |
69 | * @return bool | |
70 | */ | |
71 | public function isPlainText($plainText) { | |
72 | return is_string($plainText) && ($plainText === '' || $plainText{0} !== $this->delim); | |
73 | } | |
74 | ||
75 | /** | |
76 | * Create an encrypted token (given the plaintext). | |
77 | * | |
78 | * @param string $plainText | |
79 | * The secret value to encode (e.g. plain-text password). | |
80 | * @param string|string[] $keyIdOrTag | |
81 | * List of key IDs or key tags to check. First available match wins. | |
82 | * @return string | |
83 | * A token | |
84 | * @throws \Civi\Crypto\Exception\CryptoException | |
85 | */ | |
86 | public function encrypt($plainText, $keyIdOrTag) { | |
7c5110c3 TO |
87 | /** @var CryptoRegistry $registry */ |
88 | $registry = \Civi::service('crypto.registry'); | |
89 | ||
90 | $key = $registry->findKey($keyIdOrTag); | |
8d3452c4 TO |
91 | if ($key['suite'] === 'plain') { |
92 | if (!$this->isPlainText($plainText)) { | |
93 | throw new CryptoException("Cannot use plaintext encoding for data with reserved delimiter."); | |
94 | } | |
95 | return $plainText; | |
96 | } | |
97 | ||
98 | /** @var \Civi\Crypto\CipherSuiteInterface $cipherSuite */ | |
7c5110c3 | 99 | $cipherSuite = $registry->findSuite($key['suite']); |
8d3452c4 | 100 | $cipherText = $cipherSuite->encrypt($plainText, $key); |
5b394c8f TO |
101 | |
102 | return $this->delim . self::FMT_QUERY . \http_build_query([ | |
103 | 'k' => $key['id'], | |
104 | 't' => \CRM_Utils_String::base64UrlEncode($cipherText), | |
105 | ]); | |
8d3452c4 TO |
106 | } |
107 | ||
108 | /** | |
109 | * Get the plaintext (given an encrypted token). | |
110 | * | |
111 | * @param string $token | |
112 | * @param string|string[] $keyIdOrTag | |
113 | * Whitelist of acceptable keys. Wildcard '*' will allow it to use | |
114 | * any/all available means to decode the token. | |
115 | * @return string | |
116 | * @throws \Civi\Crypto\Exception\CryptoException | |
117 | */ | |
118 | public function decrypt($token, $keyIdOrTag = '*') { | |
119 | $keyIdOrTag = (array) $keyIdOrTag; | |
120 | ||
121 | if ($this->isPlainText($token)) { | |
122 | if (in_array('*', $keyIdOrTag) || in_array('plain', $keyIdOrTag)) { | |
123 | return $token; | |
124 | } | |
125 | else { | |
126 | throw new CryptoException("Cannot decrypt token. Unexpected key: plain"); | |
127 | } | |
128 | } | |
129 | ||
7c5110c3 TO |
130 | /** @var CryptoRegistry $registry */ |
131 | $registry = \Civi::service('crypto.registry'); | |
132 | ||
a5eae9cb TO |
133 | $tokenData = $this->parse($token); |
134 | ||
135 | $key = $registry->findKey($tokenData['k']); | |
136 | if (!in_array('*', $keyIdOrTag) && !in_array($tokenData['k'], $keyIdOrTag) && empty(array_intersect($keyIdOrTag, $key['tags']))) { | |
137 | throw new CryptoException("Cannot decrypt token. Unexpected key: {$tokenData['k']}"); | |
138 | } | |
139 | ||
140 | /** @var \Civi\Crypto\CipherSuiteInterface $cipherSuite */ | |
141 | $cipherSuite = $registry->findSuite($key['suite']); | |
142 | $plainText = $cipherSuite->decrypt($tokenData['t'], $key); | |
143 | return $plainText; | |
144 | } | |
145 | ||
fe4d2bdc TO |
146 | /** |
147 | * Re-encrypt an existing token with a newer version of the key. | |
148 | * | |
149 | * @param string $oldToken | |
150 | * @param string $keyTag | |
151 | * Ex: 'CRED' | |
152 | * | |
153 | * @return string|null | |
154 | * A re-encrypted version of $oldToken, or NULL if there should be no change. | |
155 | * @throws \Civi\Crypto\Exception\CryptoException | |
156 | */ | |
157 | public function rekey($oldToken, $keyTag) { | |
158 | /** @var \Civi\Crypto\CryptoRegistry $registry */ | |
159 | $registry = \Civi::service('crypto.registry'); | |
160 | ||
161 | $sourceKeys = $registry->findKeysByTag($keyTag); | |
162 | $targetKey = array_shift($sourceKeys); | |
163 | ||
164 | if ($this->isPlainText($oldToken)) { | |
165 | if ($targetKey['suite'] === 'plain') { | |
166 | return NULL; | |
167 | } | |
168 | } | |
169 | else { | |
170 | $tokenData = $this->parse($oldToken); | |
171 | if ($tokenData['k'] === $targetKey['id'] || !isset($sourceKeys[$tokenData['k']])) { | |
172 | return NULL; | |
173 | } | |
174 | } | |
175 | ||
176 | $decrypted = $this->decrypt($oldToken); | |
177 | return $this->encrypt($decrypted, $targetKey['id']); | |
178 | } | |
179 | ||
a5eae9cb TO |
180 | /** |
181 | * Parse the content of a token (without decrypting it). | |
182 | * | |
183 | * @param string $token | |
184 | * | |
185 | * @return array | |
186 | * @throws \Civi\Crypto\Exception\CryptoException | |
187 | */ | |
188 | public function parse($token): array { | |
5b394c8f TO |
189 | $fmt = substr($token, 1, 4); |
190 | switch ($fmt) { | |
191 | case self::FMT_QUERY: | |
a5eae9cb | 192 | $tokenData = []; |
5b394c8f | 193 | parse_str(substr($token, 5), $tokenData); |
a5eae9cb | 194 | $tokenData['t'] = \CRM_Utils_String::base64UrlDecode($tokenData['t']); |
5b394c8f TO |
195 | break; |
196 | ||
197 | default: | |
198 | throw new CryptoException("Cannot decrypt token. Invalid format."); | |
8d3452c4 | 199 | } |
a5eae9cb | 200 | return $tokenData; |
8d3452c4 TO |
201 | } |
202 | ||
203 | } |