93fe284fc8941948d409f998ea1b1475c67a59cd
[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 + VERSION + DLM + KEY_ID + DLM + CIPHERTEXT
38 * DLM := ASCII CHAR #2
39 * VERSION := String, 4-digit, alphanumeric (as in "CTK0")
40 * KEY_ID := String, alphanumeric and symbols "_-.,:;=+/\"
41 *
42 * @package Civi\Crypto
43 */
44 class CryptoToken {
45
46 const VERSION_1 = 'CTK0';
47
48 protected $delim;
49
50 /**
51 * CryptoToken constructor.
52 */
53 public function __construct() {
54 $this->delim = chr(2);
55 }
56
57 /**
58 * Determine if a string looks like plain-text.
59 *
60 * @param string $plainText
61 * @return bool
62 */
63 public function isPlainText($plainText) {
64 return is_string($plainText) && ($plainText === '' || $plainText{0} !== $this->delim);
65 }
66
67 /**
68 * Create an encrypted token (given the plaintext).
69 *
70 * @param string $plainText
71 * The secret value to encode (e.g. plain-text password).
72 * @param string|string[] $keyIdOrTag
73 * List of key IDs or key tags to check. First available match wins.
74 * @return string
75 * A token
76 * @throws \Civi\Crypto\Exception\CryptoException
77 */
78 public function encrypt($plainText, $keyIdOrTag) {
79 /** @var CryptoRegistry $registry */
80 $registry = \Civi::service('crypto.registry');
81
82 $key = $registry->findKey($keyIdOrTag);
83 if ($key['suite'] === 'plain') {
84 if (!$this->isPlainText($plainText)) {
85 throw new CryptoException("Cannot use plaintext encoding for data with reserved delimiter.");
86 }
87 return $plainText;
88 }
89
90 /** @var \Civi\Crypto\CipherSuiteInterface $cipherSuite */
91 $cipherSuite = $registry->findSuite($key['suite']);
92 $cipherText = $cipherSuite->encrypt($plainText, $key);
93 return $this->delim . self::VERSION_1 . $this->delim . $key['id'] . $this->delim . base64_encode($cipherText);
94 }
95
96 /**
97 * Get the plaintext (given an encrypted token).
98 *
99 * @param string $token
100 * @param string|string[] $keyIdOrTag
101 * Whitelist of acceptable keys. Wildcard '*' will allow it to use
102 * any/all available means to decode the token.
103 * @return string
104 * @throws \Civi\Crypto\Exception\CryptoException
105 */
106 public function decrypt($token, $keyIdOrTag = '*') {
107 $keyIdOrTag = (array) $keyIdOrTag;
108
109 if ($this->isPlainText($token)) {
110 if (in_array('*', $keyIdOrTag) || in_array('plain', $keyIdOrTag)) {
111 return $token;
112 }
113 else {
114 throw new CryptoException("Cannot decrypt token. Unexpected key: plain");
115 }
116 }
117
118 /** @var CryptoRegistry $registry */
119 $registry = \Civi::service('crypto.registry');
120
121 $parts = explode($this->delim, $token);
122 if ($parts[1] !== self::VERSION_1) {
123 throw new CryptoException("Unrecognized encoding");
124 }
125 $keyId = $parts[2];
126 $cipherText = base64_decode($parts[3]);
127
128 $key = $registry->findKey($keyId);
129 if (!in_array('*', $keyIdOrTag) && !in_array($keyId, $keyIdOrTag) && empty(array_intersect($keyIdOrTag, $key['tags']))) {
130 throw new CryptoException("Cannot decrypt token. Unexpected key: $keyId");
131 }
132
133 /** @var \Civi\Crypto\CipherSuiteInterface $cipherSuite */
134 $cipherSuite = $registry->findSuite($key['suite']);
135 $plainText = $cipherSuite->decrypt($cipherText, $key);
136 return $plainText;
137 }
138
139 }