PluginProbe
CryptX / trunk
CryptX vtrunk
4.2.0 4.1.1 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.9 2.0 2.1 2.2 2.3 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 All 92 releases
cryptx / classes / ImageToken.php

ImageToken.php in CryptX trunk, at classes/ImageToken.php

259 lines 9.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace CryptX;
4
5 /**
6 * The stand-in for an address in an image URL.
7 *
8 * When CryptX renders an address as a picture, the picture has to be fetched
9 * from somewhere, and until 4.2.0 that somewhere was
10 * "https://example.org/<hash>/info@example.com". The address sat in the page
11 * source -- entity-encoded, which stops nothing that decodes entities -- and,
12 * once the browser had resolved those entities, in the request line of every
13 * single image load. From there it reached the access log, and every proxy,
14 * CDN and log aggregator on the way. A visitor's browser handed the address to
15 * more machines than a plainly written address on the page would have.
16 *
17 * A token replaces it: opaque in the page, opaque in the log, and the server
18 * can still work out which address to draw.
19 *
20 * Two properties decide the construction, and both are unusual enough to be
21 * worth stating:
22 *
23 * The key is NOT the encryption password. That one is published -- it travels
24 * to the browser in every link, because the browser is what does the
25 * decrypting. A token keyed with it could be read by exactly the audience this
26 * is hiding from. Config::getImageTokenSecret() is a separate secret that never
27 * leaves the server.
28 *
29 * The token is deterministic: the same address always yields the same token.
30 * A random nonce would give a fresh URL on every page view, so no browser and
31 * no CDN could ever reuse a cached image, and the server would draw a new PNG
32 * for every visitor on every page. The IV is therefore derived from the
33 * address itself with a keyed hash. That is a deliberate weakening, and it has
34 * two halves worth naming rather than discovering:
35 *
36 * Passively, an observer can tell that two pictures show the same address --
37 * which costs nothing, because two identical addresses on a page look identical
38 * anyway. Actively, somebody who can get an address of their choosing rendered
39 * on the site -- a comment, on a site that draws addresses as pictures -- can
40 * compare the token they get back with one on another page and so confirm a
41 * guessed address without ever fetching the picture. That is cheaper than
42 * guessing, and no cheaper than simply fetching the picture and looking at it,
43 * which anyone can do. The token hides the address from whoever reads the log,
44 * not from whoever asks the server.
45 *
46 * @package CryptX
47 * @since 4.2.0
48 */
49 final class ImageToken
50 {
51 /** AES-GCM, same cipher as the links, different key and different purpose. */
52 private const CIPHER = 'aes-256-gcm';
53
54 /** Bytes of IV, as AES-GCM wants them. */
55 private const IV_LENGTH = 12;
56
57 /** Bytes of authentication tag. */
58 private const TAG_LENGTH = 16;
59
60 /**
61 * Separates this key from anything else derived from the same secret.
62 *
63 * Even though the secret has no second use today, deriving through HKDF
64 * with a label means a future one cannot accidentally share a key.
65 */
66 private const KEY_INFO = 'cryptx-image-token';
67
68 /**
69 * A second, separate key for deriving the IV.
70 *
71 * The same key would work and no attack on it is known -- HMAC and AES are
72 * different primitives. Real deterministic-AEAD constructions separate them
73 * anyway, because "no known attack" is a statement about today and a second
74 * HKDF label costs nothing.
75 */
76 private const IV_KEY_INFO = 'cryptx-image-token-iv';
77
78 /**
79 * Addresses are padded up to a multiple of this before encryption.
80 *
81 * Without it the token's length gives the address's length away exactly --
82 * base64 of a fixed overhead plus the plaintext, reversible with one
83 * division. A harvester reading nothing but the page markup could shorten
84 * its guessing list accordingly. Padding turns that into a bucket: every
85 * address up to 32 characters looks alike, then every one up to 64.
86 *
87 * NUL is the padding byte, and unambiguous here because an address can
88 * never contain one -- Exposure::isAddress() would reject it.
89 */
90 private const PAD_TO = 32;
91
92 /**
93 * The token for an address.
94 *
95 * @param string $address The address to hide.
96 *
97 * @return string A URL-safe token, or an empty string if it cannot be made.
98 */
99 public static function mint(string $address): string
100 {
101 if ($address === '' || !self::isAvailable()) {
102 return '';
103 }
104
105 $key = self::key(self::KEY_INFO);
106 $ivKey = self::key(self::IV_KEY_INFO);
107
108 if ($key === '' || $ivKey === '') {
109 return '';
110 }
111
112 // Derived from the address, so the same address gives the same URL and
113 // the image stays cacheable. Keyed, so the derivation cannot be
114 // reproduced without the secret.
115 $iv = substr(hash_hmac('sha256', $address, $ivKey, true), 0, self::IV_LENGTH);
116
117 $tag = '';
118 $encrypted = openssl_encrypt(
119 self::pad($address),
120 self::CIPHER,
121 $key,
122 OPENSSL_RAW_DATA,
123 $iv,
124 $tag
125 );
126
127 if ($encrypted === false) {
128 return '';
129 }
130
131 // Base64url: the token travels in a path segment, so "+" and "/" would
132 // have to be percent-encoded and "=" is noise.
133 return rtrim(strtr(base64_encode($iv . $encrypted . $tag), '+/', '-_'), '=');
134 }
135
136 /**
137 * The address behind a token.
138 *
139 * @param string $token The token from the URL.
140 *
141 * @return string The address, or an empty string if the token is not ours.
142 */
143 public static function read(string $token): string
144 {
145 if ($token === '' || !self::isAvailable()) {
146 return '';
147 }
148
149 // Anything outside the base64url alphabet is not a token of ours, and
150 // rejecting it here keeps malformed input away from the decoder.
151 if (preg_match('/^[A-Za-z0-9_-]+$/', $token) !== 1) {
152 return '';
153 }
154
155 $raw = base64_decode(strtr($token, '-_', '+/'), true);
156
157 if ($raw === false || strlen($raw) <= self::IV_LENGTH + self::TAG_LENGTH) {
158 return '';
159 }
160
161 $iv = substr($raw, 0, self::IV_LENGTH);
162 $tag = substr($raw, -self::TAG_LENGTH);
163 $encrypted = substr($raw, self::IV_LENGTH, -self::TAG_LENGTH);
164
165 // The current secret first, then the one it replaced -- for as long as
166 // that one is inside its grace period. Without the second try, changing
167 // the secret would leave a broken picture in every page still sitting
168 // in a cache, and the site owner would have no way to tell why: the
169 // token is opaque, so nothing in the markup says which secret made it.
170 //
171 // Without minting, either way: if there is no secret at all, no token
172 // was ever made and there is nothing here to decode. Minting on this
173 // path would let a stranger calling the image endpoint decide when the
174 // secret comes into being.
175 $config = CryptX::get_instance()->getConfig();
176
177 foreach ([$config->peekImageTokenSecret(), $config->previousImageTokenSecret()] as $secret) {
178 if ($secret === '') {
179 continue;
180 }
181
182 $address = openssl_decrypt(
183 $encrypted,
184 self::CIPHER,
185 hash_hkdf('sha256', $secret, 32, self::KEY_INFO),
186 OPENSSL_RAW_DATA,
187 $iv,
188 $tag
189 );
190
191 // The tag covers the IV as well, so a token from another site or a
192 // tampered one fails here rather than drawing somebody else's
193 // address.
194 if ($address === false) {
195 continue;
196 }
197
198 $address = rtrim($address, "\0");
199
200 // Belt and braces, and cheap: only addresses were ever minted, so
201 // anything else means the secret leaked or the construction changed.
202 if (Exposure::isAddress($address)) {
203 return $address;
204 }
205 }
206
207 return '';
208 }
209
210 /**
211 * Pads an address up to the next multiple of PAD_TO.
212 *
213 * @param string $address The address.
214 *
215 * @return string The padded plaintext.
216 */
217 private static function pad(string $address): string
218 {
219 $length = strlen($address);
220 $target = (int) (ceil(max(1, $length) / self::PAD_TO) * self::PAD_TO);
221
222 return str_pad($address, $target, "\0");
223 }
224
225 /**
226 * Whether the platform can do this at all.
227 *
228 * @return bool True when the cipher is there.
229 */
230 private static function isAvailable(): bool
231 {
232 return function_exists('openssl_encrypt')
233 && in_array(self::CIPHER, openssl_get_cipher_methods(), true);
234 }
235
236 /**
237 * A key, derived from the site's own image secret.
238 *
239 * @param string $info Which key -- the label keeps the two apart.
240 * @param bool $mint Whether a missing secret may be created here.
241 *
242 * @return string 32 raw bytes, or an empty string if there is no secret.
243 */
244 private static function key(string $info, bool $mint = true): string
245 {
246 $config = CryptX::get_instance()->getConfig();
247
248 $secret = $mint
249 ? $config->getImageTokenSecret()
250 : $config->peekImageTokenSecret();
251
252 if ($secret === '') {
253 return '';
254 }
255
256 return hash_hkdf('sha256', $secret, 32, $info);
257 }
258 }
259