Commit | Line | Data |
---|---|---|
7f254ad8 AE |
1 | <?php |
2 | ||
3 | /* | |
4 | * This file is part of the civicrm-cxn-rpc package. | |
5 | * | |
6 | * Copyright (c) CiviCRM LLC <info@civicrm.org> | |
7 | * | |
8 | * For the full copyright and license information, please view the LICENSE | |
9 | * file that was distributed with this package. | |
10 | */ | |
11 | ||
12 | namespace Civi\Cxn\Rpc; | |
13 | ||
14 | use Civi\Cxn\Rpc\Exception\InvalidMessageException; | |
15 | ||
16 | class AesHelper { | |
17 | /** | |
18 | * @return string | |
19 | * A secret, expressed in a series of printable ASCII characters. | |
20 | */ | |
21 | public static function createSecret() { | |
22 | return base64_encode(crypt_random_string(Constants::AES_BYTES)); | |
23 | } | |
24 | ||
25 | /** | |
26 | * @param $secret | |
27 | * A secret, expressed in a series of printable ASCII characters. | |
28 | * @return array | |
29 | * - enc: string, raw encryption key | |
30 | * - auth: string, raw authentication key | |
31 | */ | |
32 | public static function deriveAesKeys($secret) { | |
33 | $rawSecret = base64_decode($secret); | |
34 | if (Constants::AES_BYTES != strlen($rawSecret)) { | |
35 | throw new InvalidMessageException("Failed to derive keys from secret."); | |
36 | } | |
37 | ||
38 | $result = array( | |
39 | 'enc' => BinHex::hex2bin(hash_hmac('sha256', 'dearbrutus', $rawSecret)), | |
40 | 'auth' => BinHex::hex2bin(hash_hmac('sha256', 'thefaultisinourselves', $rawSecret)), | |
41 | ); | |
42 | if (Constants::AES_BYTES != strlen($result['enc']) || Constants::AES_BYTES != strlen($result['auth'])) { | |
43 | throw new InvalidMessageException("Failed to derive keys from secret."); | |
44 | } | |
45 | return $result; | |
46 | } | |
47 | ||
48 | /** | |
49 | * Encrypt $plaintext with $secret, then date and sign the message. | |
50 | * | |
51 | * @param string $secret | |
52 | * @param string $plaintext | |
53 | * @return array | |
54 | * Array(string $body, string $signature). | |
55 | * Note that $body begins with an unencrypted envelope (ttl, iv). | |
56 | * @throws InvalidMessageException | |
57 | */ | |
58 | public static function encryptThenSign($secret, $plaintext) { | |
59 | $iv = crypt_random_string(Constants::AES_BYTES); | |
60 | ||
61 | $keys = AesHelper::deriveAesKeys($secret); | |
62 | ||
63 | $cipher = new \Crypt_AES(CRYPT_AES_MODE_CBC); | |
64 | $cipher->setKeyLength(Constants::AES_BYTES); | |
65 | $cipher->setKey($keys['enc']); | |
66 | $cipher->setIV($iv); | |
67 | ||
68 | // JSON string; this will be signed but not encrypted | |
69 | $jsonEnvelope = json_encode(array( | |
70 | 'ttl' => Time::getTime() + Constants::REQUEST_TTL, | |
71 | 'iv' => BinHex::bin2hex($iv), | |
72 | )); | |
73 | // JSON string; this will be signed and encrypted | |
74 | $jsonEncrypted = $cipher->encrypt($plaintext); | |
75 | $body = $jsonEnvelope . Constants::PROTOCOL_DELIM . $jsonEncrypted; | |
76 | $signature = hash_hmac('sha256', $body, $keys['auth']); | |
77 | return array($body, $signature); | |
78 | } | |
79 | ||
80 | /** | |
81 | * Validate the signature and date of the message, then | |
82 | * decrypt it. | |
83 | * | |
84 | * @param string $secret | |
85 | * @param string $body | |
86 | * @param string $signature | |
87 | * @return string | |
88 | * Plain text. | |
89 | * @throws InvalidMessageException | |
90 | */ | |
91 | public static function authenticateThenDecrypt($secret, $body, $signature) { | |
92 | $keys = self::deriveAesKeys($secret); | |
93 | ||
94 | $localHmac = hash_hmac('sha256', $body, $keys['auth']); | |
95 | if (!self::hash_compare($signature, $localHmac)) { | |
96 | throw new InvalidMessageException("Incorrect hash"); | |
97 | } | |
98 | ||
99 | list ($jsonEnvelope, $jsonEncrypted) = explode(Constants::PROTOCOL_DELIM, $body, 2); | |
100 | if (strlen($jsonEnvelope) > Constants::MAX_ENVELOPE_BYTES) { | |
101 | throw new InvalidMessageException("Oversized envelope"); | |
102 | } | |
103 | ||
104 | $envelope = json_decode($jsonEnvelope, TRUE); | |
105 | if (!$envelope) { | |
106 | throw new InvalidMessageException("Malformed envelope"); | |
107 | } | |
108 | ||
109 | if (!is_numeric($envelope['ttl']) || Time::getTime() > $envelope['ttl']) { | |
110 | throw new InvalidMessageException("Invalid TTL"); | |
111 | } | |
112 | ||
113 | if (!is_string($envelope['iv']) || strlen($envelope['iv']) !== Constants::AES_BYTES * 2 || !preg_match('/^[a-f0-9]+$/', $envelope['iv'])) { | |
114 | // AES_BYTES (32) ==> bin2hex ==> 2 hex digits (4-bit) per byte (8-bit) | |
115 | throw new InvalidMessageException("Malformed initialization vector"); | |
116 | } | |
117 | ||
118 | $jsonPlaintext = UserError::adapt('Civi\Cxn\Rpc\Exception\InvalidMessageException', function () use ($jsonEncrypted, $envelope, $keys) { | |
119 | $cipher = new \Crypt_AES(CRYPT_AES_MODE_CBC); | |
120 | $cipher->setKeyLength(Constants::AES_BYTES); | |
121 | $cipher->setKey($keys['enc']); | |
122 | $cipher->setIV(BinHex::hex2bin($envelope['iv'])); | |
123 | return $cipher->decrypt($jsonEncrypted); | |
124 | }); | |
125 | return $jsonPlaintext; | |
126 | } | |
127 | ||
128 | /** | |
129 | * Comparison function which resists timing attacks. | |
130 | * | |
131 | * @param string $a | |
132 | * @param string $b | |
133 | * @return bool | |
134 | */ | |
135 | private static function hash_compare($a, $b) { | |
136 | if (!is_string($a) || !is_string($b)) { | |
137 | return FALSE; | |
138 | } | |
139 | ||
140 | $len = strlen($a); | |
141 | if ($len !== strlen($b)) { | |
142 | return FALSE; | |
143 | } | |
144 | ||
145 | $status = 0; | |
146 | for ($i = 0; $i < $len; $i++) { | |
147 | $status |= ord($a[$i]) ^ ord($b[$i]); | |
148 | } | |
149 | return $status === 0; | |
150 | } | |
151 | ||
152 | } |