| 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 |
|