| @@ -2,58 +2,8 @@ | ||
| 2 | 2 | |
| 3 | 3 | namespace CryptX; |
| 4 | 4 | |
| 5 | 5 | final class Config { |
| 6 | - /** | |
| 7 | - * Default configuration options for the application. | |
| 8 | - * | |
| 9 | - * This array provides the default settings and values for various | |
| 10 | - * features and behaviors of the application. These options can be | |
| 11 | - * customized to suit specific implementation requirements. | |
| 12 | - * | |
| 13 | - * Keys and their purposes: | |
| 14 | - * - 'version': The version of the application (default: null). | |
| 15 | - * - 'at': Replacement string for the "@" symbol (default: ' [at] '). | |
| 16 | - * - 'dot': Replacement string for the "." symbol (default: ' [dot] '). | |
| 17 | - * - 'css_id': CSS ID to use for specific elements (default: ''). | |
| 18 | - * - 'css_class': CSS class to use for specific elements (default: ''). | |
| 19 | - * - 'the_content': Flag to enable processing on content (default: 1). | |
| 20 | - * On block themes this filter is swapped for 'render_block' | |
| 21 | - * at runtime, see CryptX::initializePluginFilters(). There is | |
| 22 | - * no separate option for it. | |
| 23 | - * - 'the_meta_key': Flag to enable processing on meta keys (default: 1). | |
| 24 | - * - 'the_excerpt': Flag to enable processing on excerpts (default: 1). | |
| 25 | - * - 'comment_text': Flag to enable processing on comments (default: 1). | |
| 26 | - * - 'widget_text': Flag to enable processing in widgets (default: 1). | |
| 27 | - * - 'java': Flag indicating JavaScript-related configurations (default: 1). | |
| 28 | - * - 'load_java': Flag to enable JavaScript loading (default: 1). | |
| 29 | - * - 'opt_linktext': Option for link text settings (default: 0). | |
| 30 | - * - 'autolink': Flag to enable auto-linking of content (default: 1). | |
| 31 | - * - 'alt_linktext': Alternative text for links (default: ''). | |
| 32 | - * - 'alt_linkimage': Alternative image for links (default: ''). | |
| 33 | - * - 'http_linkimage_title': Link image title with HTTP reference (default: ''). | |
| 34 | - * - 'alt_linkimage_title': Alternative title for the link image (default: ''). | |
| 35 | - * - 'excludedIDs': IDs to exclude from processing (default: ''). | |
| 36 | - * - 'metaBox': Flag to enable or disable meta box features (default: 1). | |
| 37 | - * - 'alt_uploadedimage': Alternative uploaded image setting (default: '0'). | |
| 38 | - * - 'c2i_font': Custom font setting (default: null). | |
| 39 | - * - 'c2i_fontSize': Font size for configuration (default: 10). | |
| 40 | - * - 'c2i_fontRGB': Font color in RGB format (default: '#000000'). | |
| 41 | - * - 'echo': Flag to enable output directly to the browser (default: 1). | |
| 42 | - * - 'exemptAddresses': Comma-separated addresses CryptX leaves alone; an entry | |
| 43 | - * may also be a bare domain such as '@example.com' (default: ''). | |
| 44 | - * - 'whiteList': Comma-separated string of allowed file extensions (default: 'jpeg,jpg,png,gif'). | |
| 45 | - * - 'disable_rss': Flag to disable CryptX in RSS feeds by default (default: 1). | |
| 46 | - * - 'encryption_mode': Encryption mode setting (default: 'secure'). | |
| 47 | - * - 'encryption_password': Password for encryption; a random secret is generated | |
| 48 | - * on first use if null and then kept forever (default: null). | |
| 49 | - * - 'use_secure_encryption': Flag to enable secure encryption by default (default: 1). | |
| 50 | - * - 'iterations': PBKDF2 iteration count for the secure mode (default: 10000). | |
| 51 | - * - 'link_mode': How the encrypted link is delivered -- 'data' puts the payload | |
| 52 | - * into data attributes and lets a delegated click handler take | |
| 53 | - * over (survives a Content-Security-Policy), 'js' is the historical | |
| 54 | - * "javascript:" URI (default: 'data'). | |
| 55 | - */ | |
| 56 | 6 | private const DEFAULT_OPTIONS = [ |
| 57 | 7 | 'version' => null, |
| 58 | 8 | 'at' => ' [at] ', |
| 59 | 9 | 'dot' => ' [dot] ', |
| @@ -78,29 +28,16 @@ | ||
| 78 | 28 | 'c2i_font' => null, |
| 79 | 29 | 'c2i_fontSize' => 10, |
| 80 | 30 | 'c2i_fontRGB' => '#000000', |
| 81 | 31 | 'echo' => 1, |
| 82 | - 'exemptAddresses' => '', | |
| 32 | + 'filter' => ['the_content', 'the_meta_key', 'the_excerpt', 'comment_text', 'widget_text'], | |
| 83 | 33 | 'whiteList' => 'jpeg,jpg,png,gif', |
| 84 | - 'disable_rss' => 1, | |
| 85 | - 'encryption_mode' => 'secure', | |
| 86 | - 'encryption_password' => null, | |
| 87 | - 'image_token_secret' => null, | |
| 88 | - 'image_token_secret_previous' => null, | |
| 89 | - 'image_token_secret_previous_until' => 0, | |
| 90 | - 'secrets_rotated_at' => 0, | |
| 91 | - 'use_secure_encryption' => 1, | |
| 92 | - 'iterations' => 10000, | |
| 93 | - 'link_mode' => 'data', | |
| 34 | + 'disable_rss' => 1, // Disable CryptX in RSS feeds by default | |
| 35 | + 'encryption_mode' => 'secure', // Changed to 'secure' by default | |
| 36 | + 'encryption_password' => null, // Will be auto-generated if null | |
| 37 | + 'use_secure_encryption' => 1, // Enable secure encryption by default | |
| 94 | 38 | ]; |
| 95 | 39 | |
| 96 | - /** | |
| 97 | - * An array of filter names used within the application. | |
| 98 | - * These filters are commonly applied to various types of content, | |
| 99 | - * including posts, comments, and widgets. | |
| 100 | - */ | |
| 101 | - private const FILTERS = ['the_content', 'the_meta_key', 'the_excerpt', 'comment_text', 'widget_text']; | |
| 102 | - | |
| 103 | 40 | // Define the actual widget filters that will be used when widget_text is enabled |
| 104 | 41 | private const WIDGET_FILTERS = [ |
| 105 | 42 | 'widget_text', // Legacy text widget (pre-4.9) |
| 106 | 43 | 'widget_text_content', // Modern text widget (4.9+) |
| @@ -107,15 +44,17 @@ | ||
| 107 | 44 | 'widget_custom_html_content' // Custom HTML widget (4.8.1+) |
| 108 | 45 | ]; |
| 109 | 46 | |
| 110 | 47 | private array $options; |
| 48 | + private array $originalOptions; | |
| 111 | 49 | |
| 112 | 50 | public function __construct(array $options = []) { |
| 113 | 51 | $this->options = array_merge(self::DEFAULT_OPTIONS, $options); |
| 52 | + $this->originalOptions = $this->options; | |
| 114 | 53 | } |
| 115 | 54 | |
| 116 | 55 | public function getActiveFilters(): array { |
| 117 | - return array_filter(self::FILTERS, fn($filter) => | |
| 56 | + return array_filter($this->options['filter'], fn($filter) => | |
| 118 | 57 | isset($this->options[$filter]) && $this->options[$filter] |
| 119 | 58 | ); |
| 120 | 59 | } |
| 121 | 60 | |
| @@ -179,19 +118,22 @@ | ||
| 179 | 118 | public function getWidgetFilters(): array { |
| 180 | 119 | return self::WIDGET_FILTERS; |
| 181 | 120 | } |
| 182 | 121 | |
| 183 | - // updateFromShortcode() and restoreOriginalOptions() used to sit here. They | |
| 184 | - // had no caller anywhere in the plugin -- CryptX::cryptXShortcode() does | |
| 185 | - // that job on the static option list -- and they were the more dangerous of | |
| 186 | - // the two implementations: they merged shortcode attributes straight into | |
| 187 | - // $this->options, which is what getEncryptionPassword() and | |
| 188 | - // getImageTokenSecret() read from and what save() writes to the database. | |
| 189 | - // Whoever revived them would have made key material settable by anyone | |
| 190 | - // allowed to write a post, and the guard added to cryptXShortcode() would | |
| 191 | - // not have covered it. Dead code that only waits for someone to call it is | |
| 192 | - // worse than no code. | |
| 122 | + public function updateFromShortcode(array $attributes, string $tag): void { | |
| 123 | + $this->originalOptions = $this->options; | |
| 124 | + $shortcodeOptions = shortcode_atts( | |
| 125 | + $this->options, | |
| 126 | + array_change_key_case($attributes, CASE_LOWER), | |
| 127 | + $tag | |
| 128 | + ); | |
| 129 | + $this->options = array_merge($this->options, $shortcodeOptions); | |
| 130 | + } | |
| 193 | 131 | |
| 132 | + public function restoreOriginalOptions(): void { | |
| 133 | + $this->options = $this->originalOptions; | |
| 134 | + } | |
| 135 | + | |
| 194 | 136 | public function save(): void { |
| 195 | 137 | update_option('cryptX', $this->options); |
| 196 | 138 | } |
| 197 | 139 | |
| @@ -221,19 +163,10 @@ | ||
| 221 | 163 | return $this->options; |
| 222 | 164 | } |
| 223 | 165 | |
| 224 | 166 | /** |
| 225 | - * Resets all options to their default values. | |
| 167 | + * Resets all options to their default values | |
| 226 | 168 | * |
| 227 | - * Careful before wiring this up again: it has had no caller since 4.1.0, | |
| 228 | - * because the settings screen resets through SettingsSchema instead. This | |
| 229 | - * method sets the whole array to DEFAULT_OPTIONS, in which | |
| 230 | - * encryption_password is null -- and then saves. That discards the secret | |
| 231 | - * every already delivered link was encrypted with, so those links stop | |
| 232 | - * resolving until the pages are regenerated. SettingsSchema::defaults() | |
| 233 | - * covers only the editable options and leaves the secret alone, which is | |
| 234 | - * why the REST route uses it. | |
| 235 | - * | |
| 236 | 169 | * @return void |
| 237 | 170 | */ |
| 238 | 171 | public function reset(): void |
| 239 | 172 | { |
| @@ -277,209 +210,16 @@ | ||
| 277 | 210 | * @return string Returns the encryption password as a string. |
| 278 | 211 | */ |
| 279 | 212 | public function getEncryptionPassword(): string |
| 280 | 213 | { |
| 281 | - // A stored password is returned untouched and is NEVER regenerated. | |
| 282 | - // The password is baked into every link this plugin has ever emitted, | |
| 283 | - // so a new one would turn all already delivered and cached pages into | |
| 284 | - // undecryptable garbage. Only a missing (or empty) value is filled in. | |
| 285 | 214 | if (empty($this->options['encryption_password'])) { |
| 286 | - // Random secret instead of a value derived from the WordPress keys. | |
| 287 | - // Rationale: this password is published. It is handed to the browser | |
| 288 | - // as the second argument of the generated | |
| 289 | - // javascript:secureDecryptAndNavigate(...) link and therefore sits | |
| 290 | - // in the HTML of every page in clear text. Deriving it from AUTH_KEY | |
| 291 | - // and SECURE_AUTH_KEY was not reversible, but there is no reason to | |
| 292 | - // publish anything at all that is a function of the site's secrets. | |
| 293 | - // | |
| 294 | - // bin2hex(random_bytes(32)) is the choice because random_bytes() is | |
| 295 | - // the platform CSPRNG (always available on the required PHP 8.1+, | |
| 296 | - // not filterable by other plugins) and hex output is pure [0-9a-f]: | |
| 297 | - // it survives every escaping stage on the way into the JavaScript | |
| 298 | - // string literal and into the option row unchanged. 32 bytes = 256 | |
| 299 | - // bits, matching the AES-256 key later derived from it via PBKDF2. | |
| 300 | - try { | |
| 301 | - $this->options['encryption_password'] = bin2hex(random_bytes(32)); | |
| 302 | - } catch (\Throwable $e) { | |
| 303 | - // random_bytes() throws when the system has no usable source of | |
| 304 | - // randomness. wp_generate_password() then provides the fallback; | |
| 305 | - // 64 chars without special characters keeps the value safe to | |
| 306 | - // embed unescaped. | |
| 307 | - $this->options['encryption_password'] = wp_generate_password(64, false, false); | |
| 308 | - } | |
| 215 | + // Generate a secure password based on WordPress keys | |
| 216 | + $this->options['encryption_password'] = hash('sha256', | |
| 217 | + (defined('AUTH_KEY') ? AUTH_KEY : '') . | |
| 218 | + (defined('SECURE_AUTH_KEY') ? SECURE_AUTH_KEY : '') . | |
| 219 | + get_site_url() | |
| 220 | + ); | |
| 309 | 221 | $this->save(); |
| 310 | 222 | } |
| 311 | 223 | return $this->options['encryption_password']; |
| 312 | - } | |
| 313 | - | |
| 314 | - /** | |
| 315 | - * The secret behind the image tokens -- and the one that is never published. | |
| 316 | - * | |
| 317 | - * getEncryptionPassword() above is handed to the browser with every link; | |
| 318 | - * it has to be, because the visitor's browser does the decrypting. Anything | |
| 319 | - * keyed with it is therefore readable by whoever reads the page, which is | |
| 320 | - * exactly the audience the image variant is hiding from. So the tokens in | |
| 321 | - * the image URLs get their own secret, and this one stays on the server. | |
| 322 | - * | |
| 323 | - * Stored rather than derived from the WordPress salts: rotating those -- | |
| 324 | - * which an administrator may do at any time, and which only logs everyone | |
| 325 | - * out -- would invalidate every image URL in every cached page at once. | |
| 326 | - * | |
| 327 | - * @return string The secret, minted on first use and then kept. | |
| 328 | - */ | |
| 329 | - public function getImageTokenSecret(): string | |
| 330 | - { | |
| 331 | - if (empty($this->options['image_token_secret'])) { | |
| 332 | - // No fallback to wp_generate_password() here, deliberately -- and | |
| 333 | - // that is the difference to getEncryptionPassword() above, which | |
| 334 | - // has one. If random_bytes() throws, random_int() throws too, and | |
| 335 | - // wp_rand() then falls back to a source that is not cryptographic. | |
| 336 | - // For the link password that costs nothing, because the value is | |
| 337 | - // published in every link anyway. For this one it is the whole | |
| 338 | - // protection: a guessable secret would let anybody rebuild the | |
| 339 | - // tokens and read the addresses back out of an access log, while | |
| 340 | - // the site went on reporting itself as protected. | |
| 341 | - // | |
| 342 | - // Returning nothing instead means ImageToken mints nothing, and | |
| 343 | - // getImageFromText() renders no picture. The link around it still | |
| 344 | - // works and the address is still hidden. Missing a picture is the | |
| 345 | - // better failure. | |
| 346 | - try { | |
| 347 | - $this->options['image_token_secret'] = bin2hex(random_bytes(32)); | |
| 348 | - } catch (\Throwable $e) { | |
| 349 | - return ''; | |
| 350 | - } | |
| 351 | - | |
| 352 | - $this->save(); | |
| 353 | - } | |
| 354 | - | |
| 355 | - return (string) $this->options['image_token_secret']; | |
| 356 | - } | |
| 357 | - | |
| 358 | - /** | |
| 359 | - * How long a replaced image secret keeps working. | |
| 360 | - * | |
| 361 | - * Long enough to outlive any ordinary page cache, short enough that a | |
| 362 | - * secret somebody wanted rid of does not stay usable indefinitely. | |
| 363 | - */ | |
| 364 | - private const IMAGE_SECRET_GRACE = 30 * DAY_IN_SECONDS; | |
| 365 | - | |
| 366 | - /** | |
| 367 | - * Replaces both secrets with fresh ones. | |
| 368 | - * | |
| 369 | - * The two behave completely differently under a change, and that is the | |
| 370 | - * whole reason this method exists rather than a line of code somewhere: | |
| 371 | - * | |
| 372 | - * The link password can be replaced at any moment with no consequence at | |
| 373 | - * all. It travels inside every link -- data-cxk, or the second argument of | |
| 374 | - * the javascript: call -- so a link already sitting in a cache carries the | |
| 375 | - * password it was made with and goes on working for ever. Measured, not | |
| 376 | - * assumed: the note in updateCryptXSettings() claiming that a new password | |
| 377 | - * kills cached links describes a format that no longer exists. | |
| 378 | - * | |
| 379 | - * The image secret is the opposite: it never leaves the server, so a token | |
| 380 | - * in a cached page can only be read while the secret that made it is still | |
| 381 | - * known. Replacing it therefore keeps the old one for a grace period, and | |
| 382 | - * ImageToken::read() falls back to it. | |
| 383 | - * | |
| 384 | - * @return void | |
| 385 | - */ | |
| 386 | - public function rotateSecrets(): void | |
| 387 | - { | |
| 388 | - $previous = $this->peekImageTokenSecret(); | |
| 389 | - | |
| 390 | - unset($this->options['encryption_password'], $this->options['image_token_secret']); | |
| 391 | - | |
| 392 | - if ($previous !== '') { | |
| 393 | - $this->options['image_token_secret_previous'] = $previous; | |
| 394 | - $this->options['image_token_secret_previous_until'] = time() + self::IMAGE_SECRET_GRACE; | |
| 395 | - } | |
| 396 | - | |
| 397 | - // Minted here rather than left to the next page view, so that a failure | |
| 398 | - // is visible while an administrator is looking at the screen. | |
| 399 | - $this->getEncryptionPassword(); | |
| 400 | - $this->getImageTokenSecret(); | |
| 401 | - | |
| 402 | - $this->options['secrets_rotated_at'] = time(); | |
| 403 | - | |
| 404 | - $this->save(); | |
| 405 | - } | |
| 406 | - | |
| 407 | - /** | |
| 408 | - * The replaced image secret, while it is still within its grace period. | |
| 409 | - * | |
| 410 | - * @return string The previous secret, or an empty string. | |
| 411 | - */ | |
| 412 | - public function previousImageTokenSecret(): string | |
| 413 | - { | |
| 414 | - $until = (int) ($this->options['image_token_secret_previous_until'] ?? 0); | |
| 415 | - | |
| 416 | - if ($until < time()) { | |
| 417 | - return ''; | |
| 418 | - } | |
| 419 | - | |
| 420 | - return (string) ($this->options['image_token_secret_previous'] ?? ''); | |
| 421 | - } | |
| 422 | - | |
| 423 | - /** | |
| 424 | - * Drops a replaced image secret once its grace period is over. | |
| 425 | - * | |
| 426 | - * Separate from the getter above, and called only from the settings screen, | |
| 427 | - * because the getter runs on the image endpoint -- on an unauthenticated | |
| 428 | - * request from a stranger. Writing an option there is the same mistake that | |
| 429 | - * was taken out of ImageToken::read() one round earlier: a stranger should | |
| 430 | - * not decide when this site writes to its own database. | |
| 431 | - * | |
| 432 | - * Returning '' is already enough to stop using the value. This is about not | |
| 433 | - * leaving a retired secret sitting in wp_options for ever next to the one | |
| 434 | - * that replaced it -- hygiene, not a hole: whoever can read that table has | |
| 435 | - * the current secret anyway. | |
| 436 | - * | |
| 437 | - * @return void | |
| 438 | - */ | |
| 439 | - public function forgetExpiredImageTokenSecret(): void | |
| 440 | - { | |
| 441 | - $until = (int) ($this->options['image_token_secret_previous_until'] ?? 0); | |
| 442 | - | |
| 443 | - if ($until >= time() || empty($this->options['image_token_secret_previous'])) { | |
| 444 | - return; | |
| 445 | - } | |
| 446 | - | |
| 447 | - $this->options['image_token_secret_previous'] = null; | |
| 448 | - $this->options['image_token_secret_previous_until'] = 0; | |
| 449 | - $this->save(); | |
| 450 | - } | |
| 451 | - | |
| 452 | - /** | |
| 453 | - * When the secrets were last replaced, if ever. | |
| 454 | - * | |
| 455 | - * @return int A Unix timestamp, or 0. | |
| 456 | - */ | |
| 457 | - public function secretsRotatedAt(): int | |
| 458 | - { | |
| 459 | - return (int) ($this->options['secrets_rotated_at'] ?? 0); | |
| 460 | - } | |
| 461 | - | |
| 462 | - /** | |
| 463 | - * The image secret if there is one, without minting. | |
| 464 | - * | |
| 465 | - * Reading a token never needs one to exist: if there is no secret, no token | |
| 466 | - * was ever made and nothing can decode. Minting on the read path would let | |
| 467 | - * a stranger who calls the image endpoint decide the moment the secret | |
| 468 | - * comes into being -- and two such calls arriving together can each mint | |
| 469 | - * one, after which whichever loses has published image URLs that will never | |
| 470 | - * resolve again. Narrow, but the consequence outlives the request: those | |
| 471 | - * URLs sit in caches. | |
| 472 | - * | |
| 473 | - * To be precise about what this does and does not fix: it takes the timing | |
| 474 | - * away from a stranger. Two ordinary first page views arriving together can | |
| 475 | - * still each mint, with the same consequence -- the same race the link | |
| 476 | - * password has always had. That window closes at the first uncached render; | |
| 477 | - * it is not worth an option row of its own. | |
| 478 | - * | |
| 479 | - * @return string The stored secret, or an empty string. | |
| 480 | - */ | |
| 481 | - public function peekImageTokenSecret(): string | |
| 482 | - { | |
| 483 | - return (string) ($this->options['image_token_secret'] ?? ''); | |
| 484 | 224 | } |
| 485 | 225 | } |