(REF) CryptoToken - Extract method 'parse()'
[civicrm-core.git] / Civi / Crypto / CryptoToken.php
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:
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)
43 *
44 * @package Civi\Crypto
45 */
46 class CryptoToken {
47
48 /**
49 * Format identification code
50 */
51 const FMT_QUERY = 'CTK?';
52
53 /**
54 * @var string
55 */
56 protected $delim;
57
58 /**
59 * CryptoToken constructor.
60 */
61 public function __construct() {
62 $this->delim = chr(2);
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) {
87 /** @var CryptoRegistry $registry */
88 $registry = \Civi::service('crypto.registry');
89
90 $key = $registry->findKey($keyIdOrTag);
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 */
99 $cipherSuite = $registry->findSuite($key['suite']);
100 $cipherText = $cipherSuite->encrypt($plainText, $key);
101
102 return $this->delim . self::FMT_QUERY . \http_build_query([
103 'k' => $key['id'],
104 't' => \CRM_Utils_String::base64UrlEncode($cipherText),
105 ]);
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
130 /** @var CryptoRegistry $registry */
131 $registry = \Civi::service('crypto.registry');
132
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
146 /**
147 * Parse the content of a token (without decrypting it).
148 *
149 * @param string $token
150 *
151 * @return array
152 * @throws \Civi\Crypto\Exception\CryptoException
153 */
154 public function parse($token): array {
155 $fmt = substr($token, 1, 4);
156 switch ($fmt) {
157 case self::FMT_QUERY:
158 $tokenData = [];
159 parse_str(substr($token, 5), $tokenData);
160 $tokenData['t'] = \CRM_Utils_String::base64UrlDecode($tokenData['t']);
161 break;
162
163 default:
164 throw new CryptoException("Cannot decrypt token. Invalid format.");
165 }
166 return $tokenData;
167 }
168
169 }