(REF) CryptoToken - Allow optional injection of $registry
[civicrm-core.git] / Civi / Crypto / CryptoToken.php
CommitLineData
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
12namespace Civi\Crypto;
13
14use 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 */
46class 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) {
80 return is_string($plainText) && ($plainText === '' || $plainText{0} !== $this->delim);
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}